From e4b4ff4bea4c41b483a0d080f8101b81a81c6810 Mon Sep 17 00:00:00 2001 From: Babayaga Date: Sun, 12 Apr 2026 22:38:43 +0200 Subject: [PATCH] media cpp 1/3 --- packages/kbot/dev-kbot.code-workspace | 3 + packages/media/cpp/.gitignore | 37 + packages/media/cpp/CMakeLists.txt | 177 ++ packages/media/cpp/CMakePresets.json | 50 + packages/media/cpp/LICENSE | 9 + packages/media/cpp/README.md | 266 +++ packages/media/cpp/a.json | 112 ++ packages/media/cpp/build-linux.sh | 6 + packages/media/cpp/config.toml | 12 + .../config/gridsearch-bcn-universities.json | 43 + .../media/cpp/config/gridsearch-lamu.json | 49 + .../media/cpp/config/gridsearch-sample.json | 40 + .../cpp/config/gridsearch-test-bcn-large.json | 45 + .../media/cpp/config/gridsearch-test-bcn.json | 85 + .../media/cpp/config/gridsearch-test.json | 37 + packages/media/cpp/install-lnx.sh | 60 + .../classifier-openrouter-stress.mjs | 8 + .../orchestrator/classifier-openrouter.mjs | 6 + packages/media/cpp/orchestrator/presets.js | 186 ++ packages/media/cpp/orchestrator/reports.js | 397 ++++ packages/media/cpp/orchestrator/spawn.mjs | 159 ++ .../media/cpp/orchestrator/test-commons.js | 237 +++ .../media/cpp/orchestrator/test-files.mjs | 204 +++ .../test-gridsearch-ipc-daemon.mjs | 204 +++ .../test-gridsearch-ipc-uds-meta.mjs | 218 +++ .../orchestrator/test-gridsearch-ipc-uds.mjs | 255 +++ .../cpp/orchestrator/test-gridsearch-ipc.mjs | 204 +++ .../cpp/orchestrator/test-ipc-classifier.mjs | 802 +++++++++ packages/media/cpp/orchestrator/test-ipc.mjs | 283 +++ packages/media/cpp/package-lock.json | 193 ++ packages/media/cpp/package.json | 41 + .../media/cpp/packages/html/CMakeLists.txt | 33 + .../cpp/packages/html/include/html/html.h | 55 + .../cpp/packages/html/include/html/html2md.h | 690 +++++++ .../cpp/packages/html/include/html/table.h | 11 + packages/media/cpp/packages/html/readme.md | 101 ++ packages/media/cpp/packages/html/src/html.cpp | 403 +++++ .../media/cpp/packages/html/src/html2md.cpp | 1195 +++++++++++++ .../media/cpp/packages/html/src/table.cpp | 106 ++ .../media/cpp/packages/http/CMakeLists.txt | 48 + .../cpp/packages/http/include/http/http.h | 40 + packages/media/cpp/packages/http/src/http.cpp | 216 +++ .../media/cpp/packages/ipc/CMakeLists.txt | 45 + .../media/cpp/packages/ipc/include/ipc/ipc.h | 35 + .../cpp/packages/ipc/include/ipc/ipc_export.h | 25 + packages/media/cpp/packages/ipc/src/ipc.cpp | 158 ++ .../media/cpp/packages/json/CMakeLists.txt | 28 + .../cpp/packages/json/include/json/json.h | 23 + packages/media/cpp/packages/json/src/json.cpp | 62 + .../media/cpp/packages/kbot/CMakeLists.txt | 50 + packages/media/cpp/packages/kbot/kbot.cpp | 189 ++ packages/media/cpp/packages/kbot/kbot.h | 79 + .../media/cpp/packages/kbot/llm_client.cpp | 165 ++ packages/media/cpp/packages/kbot/llm_client.h | 37 + .../media/cpp/packages/kbot/polymech_export.h | 26 + .../media/cpp/packages/kbot/source_files.cpp | 221 +++ .../media/cpp/packages/kbot/source_files.h | 32 + .../.github/ISSUE_TEMPLATE/bug_report.yml | 49 + .../ISSUE_TEMPLATE/feature_request.yml | 20 + packages/media/cpp/packages/liboai/.gitignore | 6 + packages/media/cpp/packages/liboai/AGENTS.md | 24 + .../media/cpp/packages/liboai/CMakeLists.txt | 22 + packages/media/cpp/packages/liboai/LICENSE | 21 + packages/media/cpp/packages/liboai/README.md | 100 ++ packages/media/cpp/packages/liboai/ROADMAP.md | 25 + .../liboai/documentation/CMakeLists.txt | 28 + .../packages/liboai/documentation/README.md | 217 +++ .../liboai/documentation/audio/README.md | 96 + .../audio/examples/CMakeLists.txt | 10 + .../audio/examples/create_speech.cpp | 24 + .../audio/examples/create_speech_async.cpp | 31 + .../audio/examples/create_transcription.cpp | 20 + .../examples/create_transcription_async.cpp | 30 + .../audio/examples/create_translation.cpp | 20 + .../examples/create_translation_async.cpp | 30 + .../documentation/authorization/README.md | 177 ++ .../authorization/examples/CMakeLists.txt | 15 + .../authorization/examples/set_azure_key.cpp | 10 + .../examples/set_azure_key_env.cpp | 10 + .../examples/set_azure_key_file.cpp | 10 + .../authorization/examples/set_key.cpp | 10 + .../examples/set_key_env_var.cpp | 10 + .../authorization/examples/set_key_file.cpp | 10 + .../examples/set_organization.cpp | 10 + .../examples/set_organization_env_var.cpp | 10 + .../examples/set_organization_file.cpp | 10 + .../authorization/examples/set_proxies.cpp | 21 + .../authorization/examples/set_proxy_auth.cpp | 31 + .../liboai/documentation/azure/README.md | 204 +++ .../azure/examples/CMakeLists.txt | 16 + .../azure/examples/create_chat_completion.cpp | 28 + .../examples/create_chat_completion_async.cpp | 37 + .../azure/examples/create_completion.cpp | 21 + .../examples/create_completion_async.cpp | 29 + .../azure/examples/create_embedding.cpp | 21 + .../azure/examples/create_embedding_async.cpp | 27 + .../azure/examples/delete_generated_image.cpp | 22 + .../examples/delete_generated_image_async.cpp | 30 + .../azure/examples/get_generated_image.cpp | 22 + .../examples/get_generated_image_async.cpp | 30 + .../examples/request_image_generation.cpp | 24 + .../request_image_generation_async.cpp | 29 + .../liboai/documentation/chat/README.md | 63 + .../documentation/chat/conversation/README.md | 409 +++++ .../chat/conversation/examples/CMakeLists.txt | 13 + .../conversation/examples/adduserdata.cpp | 15 + .../conversation/examples/getjsonobject.cpp | 30 + .../conversation/examples/getlastresponse.cpp | 30 + .../examples/getrawconversation.cpp | 30 + .../conversation/examples/poplastresponse.cpp | 33 + .../conversation/examples/popsystemdata.cpp | 21 + .../conversation/examples/popuserdata.cpp | 21 + .../conversation/examples/setsystemdata.cpp | 15 + .../chat/conversation/examples/update.cpp | 30 + .../chat/examples/CMakeLists.txt | 7 + .../chat/examples/create_chat_completion.cpp | 30 + .../examples/create_chat_completion_async.cpp | 38 + .../chat/examples/ongoing_user_convo.cpp | 39 + .../documentation/completions/README.md | 63 + .../completions/examples/CMakeLists.txt | 6 + .../examples/generate_completion.cpp | 21 + .../examples/generate_completion_async.cpp | 32 + .../liboai/documentation/edits/README.md | 43 + .../edits/examples/CMakeLists.txt | 6 + .../edits/examples/create_edit.cpp | 20 + .../edits/examples/create_edit_async.cpp | 31 + .../liboai/documentation/embeddings/README.md | 37 + .../embeddings/examples/CMakeLists.txt | 6 + .../embeddings/examples/create_embedding.cpp | 19 + .../examples/create_embedding_async.cpp | 30 + .../liboai/documentation/files/README.md | 103 ++ .../files/examples/CMakeLists.txt | 14 + .../files/examples/delete_file.cpp | 18 + .../files/examples/delete_file_async.cpp | 29 + .../files/examples/download_uploaded_file.cpp | 20 + .../examples/download_uploaded_file_async.cpp | 31 + .../files/examples/list_files.cpp | 16 + .../files/examples/list_files_async.cpp | 27 + .../files/examples/retrieve_file.cpp | 18 + .../files/examples/retrieve_file_async.cpp | 29 + .../files/examples/upload_file.cpp | 19 + .../files/examples/upload_file_async.cpp | 30 + .../liboai/documentation/fine-tunes/README.md | 144 ++ .../fine-tunes/examples/CMakeLists.txt | 16 + .../fine-tunes/examples/cancel_fine_tune.cpp | 18 + .../examples/cancel_fine_tune_async.cpp | 29 + .../fine-tunes/examples/create_fine_tune.cpp | 18 + .../examples/create_fine_tune_async.cpp | 29 + .../examples/delete_fine_tune_model.cpp | 18 + .../examples/delete_fine_tune_model_async.cpp | 29 + .../examples/list_fine_tune_events.cpp | 18 + .../examples/list_fine_tune_events_async.cpp | 29 + .../fine-tunes/examples/list_fine_tunes.cpp | 16 + .../examples/list_fine_tunes_async.cpp | 27 + .../examples/retrieve_fine_tune.cpp | 18 + .../examples/retrieve_fine_tune_async.cpp | 29 + .../liboai/documentation/images/README.md | 97 + .../images/examples/CMakeLists.txt | 12 + .../examples/download_generated_image.cpp | 22 + .../images/examples/generate_edit.cpp | 21 + .../images/examples/generate_edit_async.cpp | 31 + .../images/examples/generate_image.cpp | 18 + .../images/examples/generate_image_async.cpp | 29 + .../images/examples/generate_variation.cpp | 19 + .../examples/generate_variation_async.cpp | 29 + .../documentation/installation/README.md | 47 + .../documentation/maintenance/README.md | 40 + .../liboai/documentation/models/README.md | 45 + .../models/examples/CMakeLists.txt | 8 + .../models/examples/list_models.cpp | 16 + .../models/examples/list_models_async.cpp | 27 + .../models/examples/retrieve_model.cpp | 18 + .../models/examples/retrieve_model_async.cpp | 29 + .../documentation/moderations/README.md | 35 + .../moderations/examples/CMakeLists.txt | 6 + .../examples/create_moderation.cpp | 18 + .../examples/create_moderation_async.cpp | 29 + .../liboai/documentation/responses/README.md | 114 ++ .../documentation/responses/TECHNICAL_PLAN.md | 112 ++ .../responses/examples/CMakeLists.txt | 6 + .../responses/examples/create_response.cpp | 47 + .../examples/create_response_typed.cpp | 48 + packages/media/cpp/packages/liboai/flake.lock | 61 + packages/media/cpp/packages/liboai/flake.nix | 17 + .../cpp/packages/liboai/images/_logo.png | Bin 0 -> 64476 bytes .../cpp/packages/liboai/images/snake.png | Bin 0 -> 197109 bytes .../cpp/packages/liboai/liboai/CMakeLists.txt | 194 ++ .../packages/liboai/liboai/Config.cmake.in | 6 + .../liboai/liboai/components/audio.cpp | 100 ++ .../liboai/liboai/components/azure.cpp | 206 +++ .../liboai/liboai/components/chat.cpp | 1166 ++++++++++++ .../liboai/liboai/components/completions.cpp | 40 + .../liboai/liboai/components/edits.cpp | 29 + .../liboai/liboai/components/embeddings.cpp | 26 + .../liboai/liboai/components/files.cpp | 95 + .../liboai/liboai/components/fine_tunes.cpp | 125 ++ .../liboai/liboai/components/images.cpp | 109 ++ .../liboai/liboai/components/models.cpp | 35 + .../liboai/liboai/components/moderations.cpp | 25 + .../liboai/liboai/components/responses.cpp | 221 +++ .../liboai/liboai/core/authorization.cpp | 197 ++ .../packages/liboai/liboai/core/netimpl.cpp | 1592 +++++++++++++++++ .../packages/liboai/liboai/core/response.cpp | 113 ++ .../liboai/liboai/include/components/audio.h | 199 +++ .../liboai/liboai/include/components/azure.h | 304 ++++ .../liboai/liboai/include/components/chat.h | 982 ++++++++++ .../liboai/include/components/completions.h | 171 ++ .../liboai/liboai/include/components/edits.h | 90 + .../liboai/include/components/embeddings.h | 60 + .../liboai/liboai/include/components/files.h | 157 ++ .../liboai/include/components/fine_tunes.h | 232 +++ .../liboai/liboai/include/components/images.h | 164 ++ .../liboai/liboai/include/components/models.h | 68 + .../liboai/include/components/moderations.h | 58 + .../liboai/include/components/responses.h | 193 ++ .../liboai/include/core/authorization.h | 246 +++ .../liboai/liboai/include/core/exception.h | 86 + .../liboai/liboai/include/core/netimpl.h | 759 ++++++++ .../liboai/liboai/include/core/network.h | 264 +++ .../liboai/liboai/include/core/response.h | 122 ++ .../packages/liboai/liboai/include/liboai.h | 146 ++ packages/media/cpp/packages/liboai/shell.nix | 14 + .../media/cpp/packages/logger/CMakeLists.txt | 21 + .../packages/logger/include/logger/logger.h | 22 + .../media/cpp/packages/logger/src/logger.cpp | 57 + .../cpp/packages/polymech/CMakeLists.txt | 9 + .../polymech/include/polymech/polymech.h | 16 + .../cpp/packages/polymech/src/polymech.cpp | 17 + .../cpp/packages/postgres/CMakeLists.txt | 11 + .../postgres/include/postgres/postgres.h | 46 + .../cpp/packages/postgres/src/postgres.cpp | 236 +++ packages/media/cpp/polymech.md | 315 ++++ .../cpp/ref/images/__tests__/e2e.test.ts | 293 +++ packages/media/cpp/ref/images/db-images-pg.ts | 22 + packages/media/cpp/ref/images/index.ts | 1194 +++++++++++++ packages/media/cpp/ref/images/logger.ts | 30 + packages/media/cpp/ref/images/metadata.ts | 120 ++ packages/media/cpp/ref/images/presets.ts | 26 + packages/media/cpp/ref/images/routes.ts | 283 +++ packages/media/cpp/scripts/run-7b.sh | 9 + packages/media/cpp/scripts/setup-7b.sh | 4 + packages/media/cpp/src/cmd_kbot.cpp | 189 ++ packages/media/cpp/src/cmd_kbot.h | 28 + packages/media/cpp/src/cmd_kbot_uds.cpp | 370 ++++ packages/media/cpp/src/main.cpp | 271 +++ packages/media/cpp/src/sys_metrics.cpp | 36 + packages/media/cpp/src/sys_metrics.h | 8 + packages/media/cpp/tests/CMakeLists.txt | 54 + .../media/cpp/tests/e2e/test_polymech_e2e.cpp | 34 + .../media/cpp/tests/e2e/test_supabase.cpp | 50 + .../media/cpp/tests/functional/test_cli.cpp | 74 + .../media/cpp/tests/unit/test_cmd_kbot.cpp | 60 + packages/media/cpp/tests/unit/test_html.cpp | 452 +++++ packages/media/cpp/tests/unit/test_http.cpp | 17 + packages/media/cpp/tests/unit/test_ipc.cpp | 89 + packages/media/cpp/tests/unit/test_json.cpp | 46 + packages/media/cpp/tests/unit/test_logger.cpp | 22 + .../media/cpp/tests/unit/test_polymech.cpp | 10 + .../media/cpp/tests/unit/test_postgres.cpp | 9 + 259 files changed, 26223 insertions(+) create mode 100644 packages/media/cpp/.gitignore create mode 100644 packages/media/cpp/CMakeLists.txt create mode 100644 packages/media/cpp/CMakePresets.json create mode 100644 packages/media/cpp/LICENSE create mode 100644 packages/media/cpp/README.md create mode 100644 packages/media/cpp/a.json create mode 100644 packages/media/cpp/build-linux.sh create mode 100644 packages/media/cpp/config.toml create mode 100644 packages/media/cpp/config/gridsearch-bcn-universities.json create mode 100644 packages/media/cpp/config/gridsearch-lamu.json create mode 100644 packages/media/cpp/config/gridsearch-sample.json create mode 100644 packages/media/cpp/config/gridsearch-test-bcn-large.json create mode 100644 packages/media/cpp/config/gridsearch-test-bcn.json create mode 100644 packages/media/cpp/config/gridsearch-test.json create mode 100644 packages/media/cpp/install-lnx.sh create mode 100644 packages/media/cpp/orchestrator/classifier-openrouter-stress.mjs create mode 100644 packages/media/cpp/orchestrator/classifier-openrouter.mjs create mode 100644 packages/media/cpp/orchestrator/presets.js create mode 100644 packages/media/cpp/orchestrator/reports.js create mode 100644 packages/media/cpp/orchestrator/spawn.mjs create mode 100644 packages/media/cpp/orchestrator/test-commons.js create mode 100644 packages/media/cpp/orchestrator/test-files.mjs create mode 100644 packages/media/cpp/orchestrator/test-gridsearch-ipc-daemon.mjs create mode 100644 packages/media/cpp/orchestrator/test-gridsearch-ipc-uds-meta.mjs create mode 100644 packages/media/cpp/orchestrator/test-gridsearch-ipc-uds.mjs create mode 100644 packages/media/cpp/orchestrator/test-gridsearch-ipc.mjs create mode 100644 packages/media/cpp/orchestrator/test-ipc-classifier.mjs create mode 100644 packages/media/cpp/orchestrator/test-ipc.mjs create mode 100644 packages/media/cpp/package-lock.json create mode 100644 packages/media/cpp/package.json create mode 100644 packages/media/cpp/packages/html/CMakeLists.txt create mode 100644 packages/media/cpp/packages/html/include/html/html.h create mode 100644 packages/media/cpp/packages/html/include/html/html2md.h create mode 100644 packages/media/cpp/packages/html/include/html/table.h create mode 100644 packages/media/cpp/packages/html/readme.md create mode 100644 packages/media/cpp/packages/html/src/html.cpp create mode 100644 packages/media/cpp/packages/html/src/html2md.cpp create mode 100644 packages/media/cpp/packages/html/src/table.cpp create mode 100644 packages/media/cpp/packages/http/CMakeLists.txt create mode 100644 packages/media/cpp/packages/http/include/http/http.h create mode 100644 packages/media/cpp/packages/http/src/http.cpp create mode 100644 packages/media/cpp/packages/ipc/CMakeLists.txt create mode 100644 packages/media/cpp/packages/ipc/include/ipc/ipc.h create mode 100644 packages/media/cpp/packages/ipc/include/ipc/ipc_export.h create mode 100644 packages/media/cpp/packages/ipc/src/ipc.cpp create mode 100644 packages/media/cpp/packages/json/CMakeLists.txt create mode 100644 packages/media/cpp/packages/json/include/json/json.h create mode 100644 packages/media/cpp/packages/json/src/json.cpp create mode 100644 packages/media/cpp/packages/kbot/CMakeLists.txt create mode 100644 packages/media/cpp/packages/kbot/kbot.cpp create mode 100644 packages/media/cpp/packages/kbot/kbot.h create mode 100644 packages/media/cpp/packages/kbot/llm_client.cpp create mode 100644 packages/media/cpp/packages/kbot/llm_client.h create mode 100644 packages/media/cpp/packages/kbot/polymech_export.h create mode 100644 packages/media/cpp/packages/kbot/source_files.cpp create mode 100644 packages/media/cpp/packages/kbot/source_files.h create mode 100644 packages/media/cpp/packages/liboai/.github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 packages/media/cpp/packages/liboai/.github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 packages/media/cpp/packages/liboai/.gitignore create mode 100644 packages/media/cpp/packages/liboai/AGENTS.md create mode 100644 packages/media/cpp/packages/liboai/CMakeLists.txt create mode 100644 packages/media/cpp/packages/liboai/LICENSE create mode 100644 packages/media/cpp/packages/liboai/README.md create mode 100644 packages/media/cpp/packages/liboai/ROADMAP.md create mode 100644 packages/media/cpp/packages/liboai/documentation/CMakeLists.txt create mode 100644 packages/media/cpp/packages/liboai/documentation/README.md create mode 100644 packages/media/cpp/packages/liboai/documentation/audio/README.md create mode 100644 packages/media/cpp/packages/liboai/documentation/audio/examples/CMakeLists.txt create mode 100644 packages/media/cpp/packages/liboai/documentation/audio/examples/create_speech.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/audio/examples/create_speech_async.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/audio/examples/create_transcription.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/audio/examples/create_transcription_async.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/audio/examples/create_translation.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/audio/examples/create_translation_async.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/authorization/README.md create mode 100644 packages/media/cpp/packages/liboai/documentation/authorization/examples/CMakeLists.txt create mode 100644 packages/media/cpp/packages/liboai/documentation/authorization/examples/set_azure_key.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/authorization/examples/set_azure_key_env.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/authorization/examples/set_azure_key_file.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/authorization/examples/set_key.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/authorization/examples/set_key_env_var.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/authorization/examples/set_key_file.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/authorization/examples/set_organization.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/authorization/examples/set_organization_env_var.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/authorization/examples/set_organization_file.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/authorization/examples/set_proxies.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/authorization/examples/set_proxy_auth.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/azure/README.md create mode 100644 packages/media/cpp/packages/liboai/documentation/azure/examples/CMakeLists.txt create mode 100644 packages/media/cpp/packages/liboai/documentation/azure/examples/create_chat_completion.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/azure/examples/create_chat_completion_async.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/azure/examples/create_completion.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/azure/examples/create_completion_async.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/azure/examples/create_embedding.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/azure/examples/create_embedding_async.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/azure/examples/delete_generated_image.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/azure/examples/delete_generated_image_async.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/azure/examples/get_generated_image.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/azure/examples/get_generated_image_async.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/azure/examples/request_image_generation.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/azure/examples/request_image_generation_async.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/chat/README.md create mode 100644 packages/media/cpp/packages/liboai/documentation/chat/conversation/README.md create mode 100644 packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/CMakeLists.txt create mode 100644 packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/adduserdata.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/getjsonobject.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/getlastresponse.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/getrawconversation.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/poplastresponse.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/popsystemdata.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/popuserdata.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/setsystemdata.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/update.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/chat/examples/CMakeLists.txt create mode 100644 packages/media/cpp/packages/liboai/documentation/chat/examples/create_chat_completion.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/chat/examples/create_chat_completion_async.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/chat/examples/ongoing_user_convo.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/completions/README.md create mode 100644 packages/media/cpp/packages/liboai/documentation/completions/examples/CMakeLists.txt create mode 100644 packages/media/cpp/packages/liboai/documentation/completions/examples/generate_completion.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/completions/examples/generate_completion_async.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/edits/README.md create mode 100644 packages/media/cpp/packages/liboai/documentation/edits/examples/CMakeLists.txt create mode 100644 packages/media/cpp/packages/liboai/documentation/edits/examples/create_edit.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/edits/examples/create_edit_async.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/embeddings/README.md create mode 100644 packages/media/cpp/packages/liboai/documentation/embeddings/examples/CMakeLists.txt create mode 100644 packages/media/cpp/packages/liboai/documentation/embeddings/examples/create_embedding.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/embeddings/examples/create_embedding_async.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/files/README.md create mode 100644 packages/media/cpp/packages/liboai/documentation/files/examples/CMakeLists.txt create mode 100644 packages/media/cpp/packages/liboai/documentation/files/examples/delete_file.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/files/examples/delete_file_async.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/files/examples/download_uploaded_file.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/files/examples/download_uploaded_file_async.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/files/examples/list_files.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/files/examples/list_files_async.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/files/examples/retrieve_file.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/files/examples/retrieve_file_async.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/files/examples/upload_file.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/files/examples/upload_file_async.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/fine-tunes/README.md create mode 100644 packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/CMakeLists.txt create mode 100644 packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/cancel_fine_tune.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/cancel_fine_tune_async.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/create_fine_tune.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/create_fine_tune_async.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/delete_fine_tune_model.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/delete_fine_tune_model_async.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/list_fine_tune_events.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/list_fine_tune_events_async.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/list_fine_tunes.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/list_fine_tunes_async.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/retrieve_fine_tune.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/retrieve_fine_tune_async.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/images/README.md create mode 100644 packages/media/cpp/packages/liboai/documentation/images/examples/CMakeLists.txt create mode 100644 packages/media/cpp/packages/liboai/documentation/images/examples/download_generated_image.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/images/examples/generate_edit.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/images/examples/generate_edit_async.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/images/examples/generate_image.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/images/examples/generate_image_async.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/images/examples/generate_variation.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/images/examples/generate_variation_async.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/installation/README.md create mode 100644 packages/media/cpp/packages/liboai/documentation/maintenance/README.md create mode 100644 packages/media/cpp/packages/liboai/documentation/models/README.md create mode 100644 packages/media/cpp/packages/liboai/documentation/models/examples/CMakeLists.txt create mode 100644 packages/media/cpp/packages/liboai/documentation/models/examples/list_models.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/models/examples/list_models_async.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/models/examples/retrieve_model.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/models/examples/retrieve_model_async.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/moderations/README.md create mode 100644 packages/media/cpp/packages/liboai/documentation/moderations/examples/CMakeLists.txt create mode 100644 packages/media/cpp/packages/liboai/documentation/moderations/examples/create_moderation.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/moderations/examples/create_moderation_async.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/responses/README.md create mode 100644 packages/media/cpp/packages/liboai/documentation/responses/TECHNICAL_PLAN.md create mode 100644 packages/media/cpp/packages/liboai/documentation/responses/examples/CMakeLists.txt create mode 100644 packages/media/cpp/packages/liboai/documentation/responses/examples/create_response.cpp create mode 100644 packages/media/cpp/packages/liboai/documentation/responses/examples/create_response_typed.cpp create mode 100644 packages/media/cpp/packages/liboai/flake.lock create mode 100644 packages/media/cpp/packages/liboai/flake.nix create mode 100644 packages/media/cpp/packages/liboai/images/_logo.png create mode 100644 packages/media/cpp/packages/liboai/images/snake.png create mode 100644 packages/media/cpp/packages/liboai/liboai/CMakeLists.txt create mode 100644 packages/media/cpp/packages/liboai/liboai/Config.cmake.in create mode 100644 packages/media/cpp/packages/liboai/liboai/components/audio.cpp create mode 100644 packages/media/cpp/packages/liboai/liboai/components/azure.cpp create mode 100644 packages/media/cpp/packages/liboai/liboai/components/chat.cpp create mode 100644 packages/media/cpp/packages/liboai/liboai/components/completions.cpp create mode 100644 packages/media/cpp/packages/liboai/liboai/components/edits.cpp create mode 100644 packages/media/cpp/packages/liboai/liboai/components/embeddings.cpp create mode 100644 packages/media/cpp/packages/liboai/liboai/components/files.cpp create mode 100644 packages/media/cpp/packages/liboai/liboai/components/fine_tunes.cpp create mode 100644 packages/media/cpp/packages/liboai/liboai/components/images.cpp create mode 100644 packages/media/cpp/packages/liboai/liboai/components/models.cpp create mode 100644 packages/media/cpp/packages/liboai/liboai/components/moderations.cpp create mode 100644 packages/media/cpp/packages/liboai/liboai/components/responses.cpp create mode 100644 packages/media/cpp/packages/liboai/liboai/core/authorization.cpp create mode 100644 packages/media/cpp/packages/liboai/liboai/core/netimpl.cpp create mode 100644 packages/media/cpp/packages/liboai/liboai/core/response.cpp create mode 100644 packages/media/cpp/packages/liboai/liboai/include/components/audio.h create mode 100644 packages/media/cpp/packages/liboai/liboai/include/components/azure.h create mode 100644 packages/media/cpp/packages/liboai/liboai/include/components/chat.h create mode 100644 packages/media/cpp/packages/liboai/liboai/include/components/completions.h create mode 100644 packages/media/cpp/packages/liboai/liboai/include/components/edits.h create mode 100644 packages/media/cpp/packages/liboai/liboai/include/components/embeddings.h create mode 100644 packages/media/cpp/packages/liboai/liboai/include/components/files.h create mode 100644 packages/media/cpp/packages/liboai/liboai/include/components/fine_tunes.h create mode 100644 packages/media/cpp/packages/liboai/liboai/include/components/images.h create mode 100644 packages/media/cpp/packages/liboai/liboai/include/components/models.h create mode 100644 packages/media/cpp/packages/liboai/liboai/include/components/moderations.h create mode 100644 packages/media/cpp/packages/liboai/liboai/include/components/responses.h create mode 100644 packages/media/cpp/packages/liboai/liboai/include/core/authorization.h create mode 100644 packages/media/cpp/packages/liboai/liboai/include/core/exception.h create mode 100644 packages/media/cpp/packages/liboai/liboai/include/core/netimpl.h create mode 100644 packages/media/cpp/packages/liboai/liboai/include/core/network.h create mode 100644 packages/media/cpp/packages/liboai/liboai/include/core/response.h create mode 100644 packages/media/cpp/packages/liboai/liboai/include/liboai.h create mode 100644 packages/media/cpp/packages/liboai/shell.nix create mode 100644 packages/media/cpp/packages/logger/CMakeLists.txt create mode 100644 packages/media/cpp/packages/logger/include/logger/logger.h create mode 100644 packages/media/cpp/packages/logger/src/logger.cpp create mode 100644 packages/media/cpp/packages/polymech/CMakeLists.txt create mode 100644 packages/media/cpp/packages/polymech/include/polymech/polymech.h create mode 100644 packages/media/cpp/packages/polymech/src/polymech.cpp create mode 100644 packages/media/cpp/packages/postgres/CMakeLists.txt create mode 100644 packages/media/cpp/packages/postgres/include/postgres/postgres.h create mode 100644 packages/media/cpp/packages/postgres/src/postgres.cpp create mode 100644 packages/media/cpp/polymech.md create mode 100644 packages/media/cpp/ref/images/__tests__/e2e.test.ts create mode 100644 packages/media/cpp/ref/images/db-images-pg.ts create mode 100644 packages/media/cpp/ref/images/index.ts create mode 100644 packages/media/cpp/ref/images/logger.ts create mode 100644 packages/media/cpp/ref/images/metadata.ts create mode 100644 packages/media/cpp/ref/images/presets.ts create mode 100644 packages/media/cpp/ref/images/routes.ts create mode 100644 packages/media/cpp/scripts/run-7b.sh create mode 100644 packages/media/cpp/scripts/setup-7b.sh create mode 100644 packages/media/cpp/src/cmd_kbot.cpp create mode 100644 packages/media/cpp/src/cmd_kbot.h create mode 100644 packages/media/cpp/src/cmd_kbot_uds.cpp create mode 100644 packages/media/cpp/src/main.cpp create mode 100644 packages/media/cpp/src/sys_metrics.cpp create mode 100644 packages/media/cpp/src/sys_metrics.h create mode 100644 packages/media/cpp/tests/CMakeLists.txt create mode 100644 packages/media/cpp/tests/e2e/test_polymech_e2e.cpp create mode 100644 packages/media/cpp/tests/e2e/test_supabase.cpp create mode 100644 packages/media/cpp/tests/functional/test_cli.cpp create mode 100644 packages/media/cpp/tests/unit/test_cmd_kbot.cpp create mode 100644 packages/media/cpp/tests/unit/test_html.cpp create mode 100644 packages/media/cpp/tests/unit/test_http.cpp create mode 100644 packages/media/cpp/tests/unit/test_ipc.cpp create mode 100644 packages/media/cpp/tests/unit/test_json.cpp create mode 100644 packages/media/cpp/tests/unit/test_logger.cpp create mode 100644 packages/media/cpp/tests/unit/test_polymech.cpp create mode 100644 packages/media/cpp/tests/unit/test_postgres.cpp diff --git a/packages/kbot/dev-kbot.code-workspace b/packages/kbot/dev-kbot.code-workspace index 0d4525a5..06032005 100644 --- a/packages/kbot/dev-kbot.code-workspace +++ b/packages/kbot/dev-kbot.code-workspace @@ -14,6 +14,9 @@ }, { "path": "../xblox" + }, + { + "path": "../media" } ], "settings": {} diff --git a/packages/media/cpp/.gitignore b/packages/media/cpp/.gitignore new file mode 100644 index 00000000..2c5edf7a --- /dev/null +++ b/packages/media/cpp/.gitignore @@ -0,0 +1,37 @@ +# Build output +/build/ + +# Compiled objects +*.o +*.obj +*.exe +*.out +*.app +# CMake generated +CMakeCache.txt +CMakeFiles/ +cmake_install.cmake +Makefile + +# IDE / Editor +.vscode/ +.idea/ +*.swp +*.swo +*~ +.env* + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +cache/ +config/postgres.toml +dist + +# Orchestrator reports (cwd/tests/*) +tests/*.json +tests/*.md +src/cmd_grid*.cpp diff --git a/packages/media/cpp/CMakeLists.txt b/packages/media/cpp/CMakeLists.txt new file mode 100644 index 00000000..21866fed --- /dev/null +++ b/packages/media/cpp/CMakeLists.txt @@ -0,0 +1,177 @@ +cmake_minimum_required(VERSION 3.20) + +project(kbot-cli + VERSION 0.1.0 + DESCRIPTION "KBot C++ CLI" + LANGUAGES CXX C +) + +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/dist") +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_SOURCE_DIR}/dist") +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_SOURCE_DIR}/dist") +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFO "${CMAKE_SOURCE_DIR}/dist") +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_MINSIZEREL "${CMAKE_SOURCE_DIR}/dist") + +# ── C++ standard ───────────────────────────────────────────────────────────── +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +# ── Dependencies ───────────────────────────────────────────────────────────── +include(FetchContent) + +FetchContent_Declare( + cli11 + GIT_REPOSITORY https://github.com/CLIUtils/CLI11.git + GIT_TAG v2.4.2 + GIT_SHALLOW TRUE +) + +FetchContent_Declare( + tomlplusplus + GIT_REPOSITORY https://github.com/marzer/tomlplusplus.git + GIT_TAG v3.4.0 + GIT_SHALLOW TRUE +) + +FetchContent_Declare( + Catch2 + GIT_REPOSITORY https://github.com/catchorg/Catch2.git + GIT_TAG v3.7.1 + GIT_SHALLOW TRUE +) + +FetchContent_Declare( + asio + GIT_REPOSITORY https://github.com/chriskohlhoff/asio.git + GIT_TAG asio-1-28-0 + GIT_SHALLOW TRUE +) + +FetchContent_Declare( + concurrentqueue + GIT_REPOSITORY https://github.com/cameron314/concurrentqueue.git + GIT_TAG v1.0.4 + GIT_SHALLOW TRUE +) + +FetchContent_Declare( + taskflow + GIT_REPOSITORY https://github.com/taskflow/taskflow.git + GIT_TAG v3.6.0 + GIT_SHALLOW TRUE +) + +FetchContent_Declare( + nlohmann_json + GIT_REPOSITORY https://github.com/nlohmann/json.git + GIT_TAG v3.11.3 + GIT_SHALLOW TRUE +) + +FetchContent_Declare( + liboai + GIT_REPOSITORY https://github.com/jasonduncan/liboai.git + GIT_TAG main + GIT_SHALLOW TRUE + SOURCE_SUBDIR liboai +) + +# p-ranav/glob — Unix-style glob / rglob (C++17); avoid upstream CMake (CPM + gtest). +FetchContent_Declare( + pranav_glob + GIT_REPOSITORY https://github.com/p-ranav/glob.git + GIT_TAG master + GIT_SHALLOW TRUE +) +FetchContent_GetProperties(pranav_glob) +if(NOT pranav_glob_POPULATED) + FetchContent_Populate(pranav_glob) +endif() +add_library(pranav_glob STATIC ${pranav_glob_SOURCE_DIR}/source/glob.cpp) +target_include_directories(pranav_glob PUBLIC ${pranav_glob_SOURCE_DIR}/include) +target_compile_features(pranav_glob PUBLIC cxx_std_17) +if(MSVC) + target_compile_options(pranav_glob PRIVATE /permissive-) +endif() + +# laserpants/dotenv-cpp — load .env into the process environment (header-only). +FetchContent_Declare( + laserpants_dotenv + GIT_REPOSITORY https://github.com/laserpants/dotenv-cpp.git + GIT_TAG master + GIT_SHALLOW TRUE +) +FetchContent_GetProperties(laserpants_dotenv) +if(NOT laserpants_dotenv_POPULATED) + FetchContent_Populate(laserpants_dotenv) +endif() +add_library(laserpants_dotenv INTERFACE) +target_include_directories(laserpants_dotenv INTERFACE ${laserpants_dotenv_SOURCE_DIR}/include) +add_library(laserpants::dotenv ALIAS laserpants_dotenv) + +set(TF_BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(TF_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) +set(JSON_BuildTests OFF CACHE BOOL "" FORCE) + +FetchContent_MakeAvailable(cli11 tomlplusplus Catch2 asio concurrentqueue taskflow nlohmann_json) +# ── Packages ───────────────────────────────────────────────────────────────── +add_subdirectory(packages/logger) +add_subdirectory(packages/html) +add_subdirectory(packages/postgres) +add_subdirectory(packages/http) +add_subdirectory(packages/json) +add_subdirectory(packages/polymech) +add_subdirectory(packages/ipc) +add_subdirectory(packages/liboai/liboai) + +add_subdirectory(packages/kbot) + +# ── Sources ────────────────────────────────────────────────────────────────── +add_executable(${PROJECT_NAME} + src/main.cpp + src/cmd_kbot.cpp + src/cmd_kbot_uds.cpp + src/sys_metrics.cpp +) + +# Output file name is kbot.exe / kbot (not kbot-cli) +set_target_properties(${PROJECT_NAME} PROPERTIES OUTPUT_NAME "kbot") + +target_link_libraries(${PROJECT_NAME} PRIVATE CLI11::CLI11 tomlplusplus::tomlplusplus logger html postgres http json polymech ipc kbot laserpants::dotenv) + +target_include_directories(${PROJECT_NAME} PRIVATE + ${asio_SOURCE_DIR}/asio/include + ${taskflow_SOURCE_DIR} + ${concurrentqueue_SOURCE_DIR} +) + +# Define standalone ASIO (since it's not boost) +if(WIN32) + # Enable math constants like M_PI + add_compile_definitions(_USE_MATH_DEFINES) + add_compile_definitions(NOMINMAX) +endif() +target_compile_definitions(${PROJECT_NAME} PRIVATE ASIO_STANDALONE=1 ASIO_NO_DEPRECATED=1) + + +# ── Compiler warnings ─────────────────────────────────────────────────────── +if(MSVC) + target_compile_options(${PROJECT_NAME} PRIVATE /W4 /permissive-) +else() + target_compile_options(${PROJECT_NAME} PRIVATE -Wall -Wextra -Wpedantic) +endif() + +# ── Install ────────────────────────────────────────────────────────────────── +# Library + headers: see packages/kbot/CMakeLists.txt and packages/ipc/CMakeLists.txt +# Optional DLL/so: configure with -DIPC_BUILD_SHARED=ON -DPOLYMECH_KBOT_SHARED=ON +install(TARGETS ${PROJECT_NAME} + RUNTIME DESTINATION bin +) +install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/src/cmd_kbot.h + DESTINATION include/polymech +) + +# ── Tests ──────────────────────────────────────────────────────────────────── +enable_testing() +add_subdirectory(tests) diff --git a/packages/media/cpp/CMakePresets.json b/packages/media/cpp/CMakePresets.json new file mode 100644 index 00000000..b8b380d4 --- /dev/null +++ b/packages/media/cpp/CMakePresets.json @@ -0,0 +1,50 @@ +{ + "version": 6, + "cmakeMinimumRequired": { + "major": 3, + "minor": 20, + "patch": 0 + }, + "configurePresets": [ + { + "name": "dev", + "displayName": "Dev (Debug)", + "binaryDir": "${sourceDir}/build/dev", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug" + } + }, + { + "name": "release", + "displayName": "Release", + "binaryDir": "${sourceDir}/build/release", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release" + } + }, + { + "name": "dev-dll", + "displayName": "Dev (Debug, ipc + kbot as DLL)", + "binaryDir": "${sourceDir}/build/dev-dll", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "IPC_BUILD_SHARED": "ON", + "POLYMECH_KBOT_SHARED": "ON" + } + } + ], + "buildPresets": [ + { + "name": "dev", + "configurePreset": "dev" + }, + { + "name": "release", + "configurePreset": "release" + }, + { + "name": "dev-dll", + "configurePreset": "dev-dll" + } + ] +} \ No newline at end of file diff --git a/packages/media/cpp/LICENSE b/packages/media/cpp/LICENSE new file mode 100644 index 00000000..b0e20f53 --- /dev/null +++ b/packages/media/cpp/LICENSE @@ -0,0 +1,9 @@ +Copyright (c) All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/packages/media/cpp/README.md b/packages/media/cpp/README.md new file mode 100644 index 00000000..ff5e6244 --- /dev/null +++ b/packages/media/cpp/README.md @@ -0,0 +1,266 @@ +# kbot (C++) + +CMake-based C++ toolchain for **kbot**: HTML/HTTP/JSON utilities, **length-prefixed JSON IPC**, optional **UDS/TCP worker** for Node orchestrators, and **LLM chat** via liboai (OpenRouter, OpenAI, Ollama-compatible servers, etc.). The main binary is **`kbot`** (`kbot.exe` on Windows). + +## Prerequisites + +| Requirement | Notes | +|-------------|--------| +| CMake | ≥ 3.20 | +| C++ compiler | C++17 (MSVC, GCC, Clang) | +| Git | For `FetchContent` dependencies | +| Node.js | Optional; for `orchestrator/` IPC integration tests (`npm run test:ipc`) | + +On Windows, use a **Developer Command Prompt** or **PowerShell** with MSVC in `PATH`. **Git Bash** helps if you use shell scripts under `scripts/`. + +## Quick start (build) + +From this directory (`packages/kbot/cpp`): + +```bash +npm install # optional; only needed if you use npm scripts +npm run build +``` + +Artifacts go to **`dist/`** (e.g. `dist/kbot.exe`, test tools). + +Equivalent CMake: + +```bash +cmake --preset dev +cmake --build --preset dev +``` + +### Presets + +| Preset | Role | +|--------|------| +| `dev` | Debug, static `ipc` + `kbot` libraries (default) | +| `release` | Release build | +| `dev-dll` | Debug with **`ipc.dll`** and **`kbot.dll`** (`IPC_BUILD_SHARED=ON`, `POLYMECH_KBOT_SHARED=ON`) | + +```bash +cmake --preset dev-dll +cmake --build --preset dev-dll --config Debug +``` + +Place **`ipc.dll`** and **`kbot.dll`** next to **`kbot.exe`** (or on `PATH`) when using the DLL configuration. + +### npm scripts (reference) + +| Script | Purpose | +|--------|---------| +| `npm run build` | Configure `dev` + build | +| `npm run build:release` | Release preset | +| `npm run test` | `ctest` in `build/dev` | +| `npm run clean` | Remove `build/` and `dist/` | +| `npm run test:ipc` | Node UDS IPC integration test | +| `npm run worker` | Run worker (stdio IPC) | + +## Installation + +Install the CLI and headers into a prefix (e.g. local tree or system root): + +```bash +cmake --install build/dev --prefix "C:/path/to/install" +``` + +This installs: + +- **`bin/kbot`** (runtime) +- **`include/polymech/`** — `kbot.h`, `llm_client.h`, `polymech_export.h`, `cmd_kbot.h` +- **`include/ipc/`** — `ipc.h`, `ipc_export.h` +- **`lib/`** — import libraries / archives (depending on static vs shared) + +Library layout is defined in `packages/kbot/CMakeLists.txt` and `packages/ipc/CMakeLists.txt`. + +### CMake options (libraries) + +| Cache variable | Effect | +|----------------|--------| +| `IPC_BUILD_SHARED` | Build **`ipc`** as a shared library (`OFF` default) | +| `POLYMECH_KBOT_SHARED` | Build **`kbot`** as a shared library (`OFF` default) | + +Static builds define `IPC_STATIC_BUILD` / `POLYMECH_STATIC_BUILD` for consumers via `INTERFACE` compile definitions. Shared builds export **`IPC_API`** / **`POLYMECH_API`** (see `ipc_export.h`, `polymech_export.h`). + +## CLI overview + +Top-level: + +```bash +kbot --help +kbot -v,--version +kbot --log-level debug|info|warn|error +``` + +### Subcommands + +| Command | Description | +|---------|-------------| +| `parse ` | Parse HTML and list elements | +| `select ` | CSS-select elements | +| `config ` | Load and print a TOML file | +| `fetch ` | HTTP GET | +| `json ` | Prettify JSON | +| `db [-c config] [table] [-l limit]` | Supabase / DB helper (uses `config/postgres.toml` by default) | +| `worker [--uds ]` | IPC worker (see below) | +| `kbot ai ...` / `kbot run ...` | AI and run pipelines (`setup_cmd_kbot` — use `kbot kbot ai --help`) | + +### Worker mode (`kbot worker`) + +Used by orchestrators and tests. + +- **Stdio IPC** (length-prefixed JSON frames on stdin/stdout): + + ```bash + kbot worker + ``` + +- **UDS / TCP** (Windows: TCP port string, e.g. `4001`; Unix: socket path): + + ```bash + kbot worker --uds 4001 + ``` + +Framing: `[uint32 LE length][UTF-8 JSON object with id, type, payload]`. Message types include `ping`, `job`, `kbot-ai`, `kbot-run`, `shutdown`, etc. See `src/main.cpp` and `orchestrator/test-ipc.mjs`. + +### `kbot kbot` (nested) + +CLI for AI tasks and run configurations: + +```bash +kbot kbot ai --help +kbot kbot run --help +``` + +Example: + +```bash +kbot kbot ai --prompt "Hello" --config config/postgres.toml +``` + +API keys are typically resolved from **`config/postgres.toml`** (`[services]`). + +## Using in other CMake projects + +There is no single `find_package(kbot)` config yet. Practical options: + +### 1. Same repository / superbuild (recommended) + +Add this repo’s `cpp` tree as a subdirectory from a parent `CMakeLists.txt` so `FetchContent` and internal targets (`logger`, `json`, `ipc`, `oai`, `kbot`, …) resolve once. Then: + +```cmake +target_link_libraries(your_app PRIVATE ipc kbot) +``` + +`kbot` pulls in `logger`, `json`, `liboai` (`oai`) per `packages/kbot/CMakeLists.txt`. + +### 2. Install prefix + explicit `IMPORTED` libraries + +After `cmake --install`, link import libraries under `lib/` and add `include/` for **`ipc`** and **`polymech`**. You must still satisfy **transitive** dependencies (`oai`, `logger`, `json`, …) from the **same** build/install of this project, or duplicate their build—usually easier to use option 1. + +### 3. Minimal example: IPC framing only + +If you only need **`ipc::encode` / `ipc::decode`** (and can build `logger` + `json` the same way this project does), mirror `packages/ipc/CMakeLists.txt`: + +```cmake +cmake_minimum_required(VERSION 3.20) +project(myapp CXX) +set(CMAKE_CXX_STANDARD 17) + +add_subdirectory(path/to/polymech-mono/packages/kbot/cpp/packages/logger) +add_subdirectory(path/to/polymech-mono/packages/kbot/cpp/packages/json) +add_subdirectory(path/to/polymech-mono/packages/kbot/cpp/packages/ipc) + +add_executable(myapp main.cpp) +target_link_libraries(myapp PRIVATE ipc) +``` + +**`main.cpp`** (stdio-style framing helpers): + +```cpp +#include +#include + +int main() { + ipc::Message msg{"1", "ping", "{}"}; + auto frame = ipc::encode(msg); + // frame: 4-byte LE length + JSON object bytes + + ipc::Message roundtrip; + if (frame.size() > 4 && + ipc::decode(frame.data() + 4, frame.size() - 4, roundtrip)) { + std::cout << roundtrip.type << "\n"; // ping + } + return 0; +} +``` + +### 4. Example: LLM pipeline API (`kbot` library) + +Headers: `kbot.h`, `llm_client.h`, `polymech_export.h`. You need a valid API key and options (see `KBotOptions` in `kbot.h`). + +```cpp +#include +#include "kbot.h" +#include "llm_client.h" + +int main() { + polymech::kbot::KBotOptions opts; + opts.prompt = "Say hello in one sentence."; + opts.api_key = "YOUR_KEY"; + opts.router = "openrouter"; + opts.model = "openai/gpt-4o-mini"; + + polymech::kbot::LLMClient client(opts); + polymech::kbot::LLMResponse r = client.execute_chat(opts.prompt); + if (r.success) { + std::cout << r.text << "\n"; + } else { + std::cerr << r.error << "\n"; + return 1; + } + return 0; +} +``` + +Or use the callback-based pipeline: + +```cpp +polymech::kbot::KBotCallbacks cb; +cb.onEvent = [](const std::string& type, const std::string& json) { + std::cout << type << ": " << json << "\n"; +}; +return polymech::kbot::run_kbot_ai_pipeline(opts, cb); +``` + +Link **`kbot`** (and its public dependencies). **`cmd_kbot.h`** entry points (`run_kbot_ai_ipc`, `run_cmd_kbot_uds`, …) are implemented in **`src/cmd_kbot*.cpp`** in this project; to reuse them, compile those sources into your binary or vendor the logic. + +## Node / IPC tests + +Integration tests live under **`orchestrator/`** (see comments in `orchestrator/test-ipc.mjs`). Typical run from `cpp/`: + +```bash +npm run test:ipc +``` + +Classifier batch (semantic distances vs JobViewer labels): + +```bash +npm run test:ipc:classifier +npm run test:ipc:classifier:openrouter +``` + +Stress: repeat the **same** batched `kbot-ai` call **N** times on **one** worker; prints per-run wall time, token usage (when present), then **min / max / avg / p50 / p95** and Σ tokens. Default **N = 5** for the OpenRouter stress script: + +```bash +npm run test:ipc:classifier:openrouter:stress +npm run test:ipc:classifier -- -r openrouter -m openai/gpt-4o-mini --backend remote -n 3 +KBOT_CLASSIFIER_STRESS_RUNS=10 npm run test:ipc:classifier:openrouter:stress +``` + +Requires a built **`dist/kbot.exe`** (or `kbot` on Unix). Set API keys via `config/postgres.toml` for OpenRouter. + +## License + +See [LICENSE](LICENSE) in this directory. diff --git a/packages/media/cpp/a.json b/packages/media/cpp/a.json new file mode 100644 index 00000000..916c8840 --- /dev/null +++ b/packages/media/cpp/a.json @@ -0,0 +1,112 @@ +{ + "items": [ + { + "label": "3D printing service", + "distance": 6.0 + }, + { + "label": "Drafting service", + "distance": 7.0 + }, + { + "label": "Engraver", + "distance": 6.5 + }, + { + "label": "Furniture maker", + "distance": 7.5 + }, + { + "label": "Industrial engineer", + "distance": 7.0 + }, + { + "label": "Industrial equipment supplier", + "distance": 5.5 + }, + { + "label": "Laser cutting service", + "distance": 4.5 + }, + { + "label": "Machine construction", + "distance": 3.0 + }, + { + "label": "Machine repair service", + "distance": 2.5 + }, + { + "label": "Machine shop", + "distance": 0.2 + }, + { + "label": "Machine workshop", + "distance": 0.0 + }, + { + "label": "Machinery parts manufacturer", + "distance": 2.0 + }, + { + "label": "Machining manufacturer", + "distance": 1.5 + }, + { + "label": "Manufacturer", + "distance": 6.0 + }, + { + "label": "Mechanic", + "distance": 5.0 + }, + { + "label": "Mechanical engineer", + "distance": 6.5 + }, + { + "label": "Mechanical plant", + "distance": 3.5 + }, + { + "label": "Metal fabricator", + "distance": 2.0 + }, + { + "label": "Metal heat treating service", + "distance": 3.5 + }, + { + "label": "Metal machinery supplier", + "distance": 5.0 + }, + { + "label": "Metal working shop", + "distance": 1.0 + }, + { + "label": "Metal workshop", + "distance": 1.2 + }, + { + "label": "Novelty store", + "distance": 10.0 + }, + { + "label": "Plywood supplier", + "distance": 9.5 + }, + { + "label": "Sign shop", + "distance": 7.5 + }, + { + "label": "Tool manufacturer", + "distance": 3.0 + }, + { + "label": "Trophy shop", + "distance": 8.0 + } + ] +} \ No newline at end of file diff --git a/packages/media/cpp/build-linux.sh b/packages/media/cpp/build-linux.sh new file mode 100644 index 00000000..4fb0f9c0 --- /dev/null +++ b/packages/media/cpp/build-linux.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +#rm -rf /tmp/polymech-build +mkdir -p /tmp/polymech-build +export PATH="/snap/bin:$PATH" +cmake -S ./ -B /tmp/polymech-build -DCMAKE_BUILD_TYPE=Release +cmake --build /tmp/polymech-build diff --git a/packages/media/cpp/config.toml b/packages/media/cpp/config.toml new file mode 100644 index 00000000..f093f4ef --- /dev/null +++ b/packages/media/cpp/config.toml @@ -0,0 +1,12 @@ +[project] +name = "polymech" +version = "0.1.0" +description = "Polymech C++ CLI" + +[database] +host = "localhost" +port = 5432 +name = "polymech" + +[logging] +level = "debug" diff --git a/packages/media/cpp/config/gridsearch-bcn-universities.json b/packages/media/cpp/config/gridsearch-bcn-universities.json new file mode 100644 index 00000000..632f76dc --- /dev/null +++ b/packages/media/cpp/config/gridsearch-bcn-universities.json @@ -0,0 +1,43 @@ +{ + "guided": { + "areas": [ + { + "gid": "ESP.6.1.10.14_1", + "name": "Sabadell", + "level": 4, + "raw": { + "level": 3, + "gadmName": "Sabadell", + "gid": "ESP.6.1.10.14_1" + } + } + ], + "settings": { + "gridMode": "centers", + "pathOrder": "snake", + "groupByRegion": false, + "cellSize": 5, + "cellOverlap": 0, + "centroidOverlap": 0, + "ghsFilterMode": "OR", + "maxCellsLimit": 50000, + "maxElevation": 1000, + "minDensity": 0, + "minGhsPop": 0, + "minGhsBuilt": 0, + "allowMissingGhs": false, + "bypassFilters": false + } + }, + "search": { + "types": [ + "university" + ], + "filterCountry": "", + "googleDomain": "google.com", + "limitPerArea": 20, + "zoom": 15, + "language": "en" + }, + "filterTypes": [] +} \ No newline at end of file diff --git a/packages/media/cpp/config/gridsearch-lamu.json b/packages/media/cpp/config/gridsearch-lamu.json new file mode 100644 index 00000000..e909de04 --- /dev/null +++ b/packages/media/cpp/config/gridsearch-lamu.json @@ -0,0 +1,49 @@ +{ + "guided": { + "areas": [ + { + "gid": "KEN.21_1", + "name": "Lamu", + "level": 1, + "raw": { + "gid": "KEN.21_1", + "gadmName": "Lamu", + "level": 1 + } + } + ], + "settings": { + "gridMode": "centers", + "pathOrder": "snake", + "groupByRegion": true, + "cellSize": 5, + "cellOverlap": 0, + "centroidOverlap": 50, + "ghsFilterMode": "OR", + "maxCellsLimit": 50000, + "maxElevation": 1000, + "minDensity": 10, + "minGhsPop": 26, + "minGhsBuilt": 154, + "enableElevation": false, + "enableDensity": false, + "enableGhsPop": false, + "enableGhsBuilt": false, + "allowMissingGhs": false, + "bypassFilters": true + } + }, + "search": { + "types": [ + "plastic" + ], + "filterCountry": "", + "googleDomain": "google.com", + "limitPerArea": 20, + "zoom": 15, + "language": "en" + }, + "filterTypes": [ + "Recycling center" + ] +} \ No newline at end of file diff --git a/packages/media/cpp/config/gridsearch-sample.json b/packages/media/cpp/config/gridsearch-sample.json new file mode 100644 index 00000000..713beb6a --- /dev/null +++ b/packages/media/cpp/config/gridsearch-sample.json @@ -0,0 +1,40 @@ +{ + "guided": { + "areas": [ + { + "gid": "ABW", + "name": "Aruba", + "level": 0 + } + ], + "settings": { + "gridMode": "centers", + "pathOrder": "snake", + "groupByRegion": false, + "cellSize": 5, + "cellOverlap": 0, + "centroidOverlap": 0, + "ghsFilterMode": "OR", + "maxCellsLimit": 50000, + "maxElevation": 1000, + "minDensity": 0, + "minGhsPop": 0, + "minGhsBuilt": 0, + "allowMissingGhs": false, + "bypassFilters": false + } + }, + "search": { + "types": [ + "recycling" + ], + "filterCountry": "", + "googleDomain": "google.com", + "limitPerArea": 20, + "zoom": 15, + "language": "en" + }, + "filterTypes": [ + "Recycling center" + ] +} \ No newline at end of file diff --git a/packages/media/cpp/config/gridsearch-test-bcn-large.json b/packages/media/cpp/config/gridsearch-test-bcn-large.json new file mode 100644 index 00000000..180b02af --- /dev/null +++ b/packages/media/cpp/config/gridsearch-test-bcn-large.json @@ -0,0 +1,45 @@ +{ + "guided": { + "areas": [ + { + "gid": "ESP.6.1_1", + "name": "Barcelona", + "level": 3, + "raw": { + "level": 2, + "gadmName": "Barcelona", + "gid": "ESP.6.1_1" + } + } + ], + "settings": { + "gridMode": "centers", + "pathOrder": "snake", + "groupByRegion": true, + "cellSize": 5, + "cellOverlap": 0, + "centroidOverlap": 0, + "ghsFilterMode": "OR", + "maxCellsLimit": 50000, + "maxElevation": 1000, + "minDensity": 10, + "minGhsPop": 26, + "minGhsBuilt": 154, + "enableElevation": false, + "enableDensity": false, + "enableGhsPop": false, + "enableGhsBuilt": false, + "allowMissingGhs": false, + "bypassFilters": true + } + }, + "search": { + "types": [ + "marketing" + ], + "filterCountry": "Spain", + "googleDomain": "google.es", + "limitPerArea": 10, + "useCache": true + } +} \ No newline at end of file diff --git a/packages/media/cpp/config/gridsearch-test-bcn.json b/packages/media/cpp/config/gridsearch-test-bcn.json new file mode 100644 index 00000000..1ff5c646 --- /dev/null +++ b/packages/media/cpp/config/gridsearch-test-bcn.json @@ -0,0 +1,85 @@ +{ + "guided": { + "areas": [ + { + "gid": "ESP.6.1.10.2_1", + "name": "Barberà del Vallès", + "level": 4, + "raw": { + "level": 4, + "gadmName": "Barberà del Vallès", + "gid": "ESP.6.1.10.2_1" + } + }, + { + "gid": "ESP.6.1.10.14_1", + "name": "Sabadell", + "level": 4, + "raw": { + "level": 4, + "gadmName": "Sabadell", + "gid": "ESP.6.1.10.14_1" + } + }, + { + "gid": "ESP.6.1.10.11_1", + "name": "Polinyà", + "level": 4, + "raw": { + "level": 4, + "gadmName": "Polinyà", + "gid": "ESP.6.1.10.11_1" + } + }, + { + "gid": "ESP.6.1.10.4_1", + "name": "Castellar del Vallès", + "level": 4, + "raw": { + "level": 4, + "gadmName": "Castellar del Vallès", + "gid": "ESP.6.1.10.4_1" + } + }, + { + "gid": "ESP.6.1.10.19_1", + "name": "Sentmenat", + "level": 4, + "raw": { + "level": 4, + "gadmName": "Sentmenat", + "gid": "ESP.6.1.10.19_1" + } + } + ], + "settings": { + "gridMode": "centers", + "pathOrder": "snake", + "groupByRegion": true, + "cellSize": 10, + "cellOverlap": 0, + "centroidOverlap": 0, + "ghsFilterMode": "OR", + "maxCellsLimit": 50000, + "maxElevation": 1000, + "minDensity": 10, + "minGhsPop": 26, + "minGhsBuilt": 154, + "enableElevation": false, + "enableDensity": false, + "enableGhsPop": false, + "enableGhsBuilt": false, + "allowMissingGhs": false, + "bypassFilters": true + } + }, + "search": { + "types": [ + "mecanizado cnc" + ], + "filterCountry": "Spain", + "googleDomain": "google.es", + "limitPerArea": 10, + "useCache": true + } +} \ No newline at end of file diff --git a/packages/media/cpp/config/gridsearch-test.json b/packages/media/cpp/config/gridsearch-test.json new file mode 100644 index 00000000..f6031f79 --- /dev/null +++ b/packages/media/cpp/config/gridsearch-test.json @@ -0,0 +1,37 @@ +{ + "guided": { + "areas": [ + { + "gid": "ABW", + "name": "Aruba", + "level": 0 + } + ], + "settings": { + "gridMode": "centers", + "pathOrder": "snake", + "groupByRegion": false, + "cellSize": 5, + "cellOverlap": 0, + "centroidOverlap": 0, + "ghsFilterMode": "OR", + "maxCellsLimit": 50000, + "maxElevation": 1000, + "minDensity": 0, + "minGhsPop": 0, + "minGhsBuilt": 0, + "allowMissingGhs": false, + "bypassFilters": false + } + }, + "search": { + "types": [ + "recycling" + ], + "filterCountry": "", + "googleDomain": "google.com", + "limitPerArea": 1, + "zoom": 15, + "language": "en" + } +} \ No newline at end of file diff --git a/packages/media/cpp/install-lnx.sh b/packages/media/cpp/install-lnx.sh new file mode 100644 index 00000000..3fcc655b --- /dev/null +++ b/packages/media/cpp/install-lnx.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────────────── +# install-lnx.sh – Install build dependencies for polymech-cli on Linux +# +# Tested on: Ubuntu 20.04+ / Debian 11+ +# Usage: sudo bash install-lnx.sh +# ───────────────────────────────────────────────────────────────────────────── +set -euo pipefail + +echo "── polymech-cli Linux dependency installer ──" + +# ── 1. System packages (apt) ───────────────────────────────────────────────── +echo "" +echo "[1/3] Installing system packages via apt …" +apt-get update -qq +apt-get install -y --no-install-recommends \ + build-essential \ + gcc \ + g++ \ + git \ + libssl-dev \ + pkg-config \ + snapd + +# ── 2. CMake ≥ 3.20 via snap ──────────────────────────────────────────────── +# The project requires cmake_minimum_required(VERSION 3.20). +# Ubuntu 20.04 ships cmake 3.16, so we use the snap package instead. +echo "" +echo "[2/3] Installing CMake via snap (≥ 3.20 required) …" +if command -v /snap/bin/cmake &>/dev/null; then + echo " cmake snap already installed: $(/snap/bin/cmake --version | head -1)" +else + snap install cmake --classic + echo " Installed: $(/snap/bin/cmake --version | head -1)" +fi + +# ── 3. Node.js (for npm run build:linux) ────────────────────────────────────── +echo "" +echo "[3/3] Checking for Node.js / npm …" +if command -v node &>/dev/null; then + echo " node $(node --version) already installed" +else + echo " Node.js not found. Install via nvm or nodesource, e.g.:" + echo " curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -" + echo " sudo apt-get install -y nodejs" +fi + +# ── Summary ────────────────────────────────────────────────────────────────── +echo "" +echo "── Done! ──" +echo "" +echo "All C++ dependencies (CLI11, tomlplusplus, Catch2, asio, concurrentqueue," +echo "taskflow, curl, lexbor, rapidjson) are fetched automatically by CMake" +echo "FetchContent at build time — no manual installation needed." +echo "" +echo "To build:" +echo " cd $(dirname "$0")" +echo " npm run build:linux" +echo "" +echo "The binary will be placed in: dist/polymech-cli" diff --git a/packages/media/cpp/orchestrator/classifier-openrouter-stress.mjs b/packages/media/cpp/orchestrator/classifier-openrouter-stress.mjs new file mode 100644 index 00000000..0b6d80ce --- /dev/null +++ b/packages/media/cpp/orchestrator/classifier-openrouter-stress.mjs @@ -0,0 +1,8 @@ +/** + * OpenRouter classifier + stress defaults: remote router, N batch iterations (see KBOT_CLASSIFIER_STRESS_RUNS). + */ +process.env.KBOT_IPC_CLASSIFIER_LLAMA = '0'; +if (process.env.KBOT_CLASSIFIER_STRESS_RUNS === undefined || process.env.KBOT_CLASSIFIER_STRESS_RUNS === '') { + process.env.KBOT_CLASSIFIER_STRESS_RUNS = '5'; +} +await import('./test-ipc-classifier.mjs'); diff --git a/packages/media/cpp/orchestrator/classifier-openrouter.mjs b/packages/media/cpp/orchestrator/classifier-openrouter.mjs new file mode 100644 index 00000000..97a536ab --- /dev/null +++ b/packages/media/cpp/orchestrator/classifier-openrouter.mjs @@ -0,0 +1,6 @@ +/** + * Sets KBOT_IPC_CLASSIFIER_LLAMA=0 then runs the classifier IPC test against + * KBOT_ROUTER / KBOT_IPC_MODEL (default router: openrouter — see presets.js). + */ +process.env.KBOT_IPC_CLASSIFIER_LLAMA = '0'; +await import('./test-ipc-classifier.mjs'); diff --git a/packages/media/cpp/orchestrator/presets.js b/packages/media/cpp/orchestrator/presets.js new file mode 100644 index 00000000..0809227c --- /dev/null +++ b/packages/media/cpp/orchestrator/presets.js @@ -0,0 +1,186 @@ +/** + * orchestrator/presets.js — defaults for IPC integration tests (extend here as suites grow). + * + * Llama local runner (llama-basics.test.ts): OpenAI-compatible API at http://localhost:8888/v1, + * router `ollama` + `base_url` override, model `default` (server picks loaded GGUF). + */ + +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; + +import { probeTcpPort } from './test-commons.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export const platform = { + isWin: process.platform === 'win32', +}; + +/** kbot/cpp root (parent of orchestrator/). */ +export const paths = { + orchestratorDir: __dirname, + cppRoot: resolve(__dirname, '..'), + /** Same as packages/kbot/cpp/scripts/run-7b.sh — llama-server on :8888 */ + run7bScript: resolve(__dirname, '../scripts/run-7b.sh'), +}; + +/** Dist binary name for the current OS. */ +export function exeName() { + return platform.isWin ? 'kbot.exe' : 'kbot'; +} + +/** Absolute path to kbot binary given orchestrator/ directory (where test-ipc.mjs lives). */ +export function distExePath(orchestratorDir) { + return resolve(orchestratorDir, '..', 'dist', exeName()); +} + +/** UDS / TCP listen argument passed to `kbot worker --uds `. */ +export const uds = { + tcpPort: 4001, + unixPath: '/tmp/kbot-test-ipc.sock', + /** Value for `--uds` on this OS (Windows: port string; Unix: socket path). */ + workerArg() { + return platform.isWin ? String(this.tcpPort) : this.unixPath; + }, + /** Options for `net.connect` to reach the worker. */ + connectOpts(cppUdsArg) { + return platform.isWin + ? { port: this.tcpPort, host: '127.0.0.1' } + : cppUdsArg; + }, +}; + +/** Millisecond timeouts — tune per step in new tests. */ +export const timeouts = { + ipcDefault: 5000, + kbotAi: 180_000, + /** Llama local arithmetic (same order of magnitude as kbot-ai). */ + llamaKbotAi: 180_000, + /** Max wait for :8888 after spawning run-7b.sh (model load can be slow). */ + llamaServerStart: Number(process.env.KBOT_LLAMA_START_TIMEOUT_MS || 600_000), + connectAttempts: 15, + connectRetryMs: 400, + postShutdownMs: 200, +}; + +export const router = { + default: 'openrouter', + fromEnv() { + return process.env.KBOT_ROUTER || this.default; + }, +}; + +/** + * Local llama.cpp HTTP server — mirrors tests/unit/llama-basics.test.ts (LLAMA_OPTS). + * Uses router `ollama` so api_key resolves to dummy `ollama`; `base_url` points at :8888/v1. + */ +export const llama = { + get port() { + return Number(process.env.KBOT_LLAMA_PORT || 8888); + }, + get host() { + return process.env.KBOT_LLAMA_HOST || '127.0.0.1'; + }, + get baseURL() { + return process.env.KBOT_LLAMA_BASE_URL || `http://localhost:${this.port}/v1`; + }, + router: 'ollama', + get model() { + return process.env.KBOT_LLAMA_MODEL || 'default'; + }, + prompts: { + /** Same idea as llama-basics completion tests. */ + add5_3: 'What is 5 + 3? Reply with just the number, nothing else.', + }, +}; + +/** + * IPC payload for kbot-ai → local llama-server (OpenAI-compatible). + * Pass `base_url` so LLMClient uses port 8888 instead of default ollama :11434. + */ +export function kbotAiPayloadLlamaLocal(overrides = {}) { + const merged = { + prompt: llama.prompts.add5_3, + router: llama.router, + model: llama.model, + base_url: llama.baseURL, + ...overrides, + }; + merged.base_url = merged.base_url ?? merged.baseURL ?? llama.baseURL; + delete merged.baseURL; + return merged; +} + +/** Stock prompts and assertions helpers for LLM smoke tests. */ +export const prompts = { + germanyCapital: 'What is the capital of Germany? Reply in one short sentence.', +}; + +/** Build `kbot-ai` IPC payload from env + presets (OpenRouter-friendly defaults). */ +export function kbotAiPayloadFromEnv() { + const payload = { + prompt: process.env.KBOT_IPC_PROMPT || prompts.germanyCapital, + router: router.fromEnv(), + }; + if (process.env.KBOT_IPC_MODEL) { + payload.model = process.env.KBOT_IPC_MODEL; + } + return payload; +} + +/** True when using the default Germany prompt (for optional Berlin assertion). */ +export function usingDefaultGermanyPrompt() { + return !process.env.KBOT_IPC_PROMPT; +} + +/** + * If nothing listens on llama.port, optionally spawn `scripts/run-7b.sh` (requires `sh` on PATH, e.g. Git Bash on Windows). + * + * @param {{ autostart?: boolean, startTimeoutMs?: number }} [opts] + * @returns {Promise<{ ok: boolean, alreadyRunning: boolean, started?: boolean, pid?: number }>} + */ +export async function ensureLlamaLocalServer(opts = {}) { + const autostart = opts.autostart ?? true; + const startTimeoutMs = opts.startTimeoutMs ?? timeouts.llamaServerStart; + const host = llama.host; + const port = llama.port; + const scriptPath = paths.run7bScript; + + if (await probeTcpPort(host, port, 1500)) { + return { ok: true, alreadyRunning: true }; + } + + if (!autostart) { + throw new Error( + `[llama] Nothing listening on ${host}:${port}. Start the server (e.g. sh scripts/run-7b.sh), or remove KBOT_IPC_LLAMA_AUTOSTART=0 to allow autostart` + ); + } + + if (!existsSync(scriptPath)) { + throw new Error(`[llama] Script missing: ${scriptPath}`); + } + + console.log(`[llama] Port ${port} closed — starting ${scriptPath} (timeout ${startTimeoutMs}ms) …`); + + const child = spawn('sh', [scriptPath], { + detached: true, + stdio: 'ignore', + cwd: dirname(scriptPath), + env: { ...process.env }, + }); + child.unref(); + + const deadline = Date.now() + startTimeoutMs; + while (Date.now() < deadline) { + if (await probeTcpPort(host, port, 1500)) { + return { ok: true, alreadyRunning: false, started: true, pid: child.pid }; + } + await new Promise((r) => setTimeout(r, 1500)); + } + + throw new Error( + `[llama] Server did not open ${host}:${port} within ${startTimeoutMs}ms — check llama-server / GPU / model path` + ); +} diff --git a/packages/media/cpp/orchestrator/reports.js b/packages/media/cpp/orchestrator/reports.js new file mode 100644 index 00000000..43a8686b --- /dev/null +++ b/packages/media/cpp/orchestrator/reports.js @@ -0,0 +1,397 @@ +/** + * orchestrator/reports.js — JSON + Markdown reports under cwd/tests/ + * + * File pattern (logical): test-name::hh:mm + * On-disk: test-name__HH-mm.json / .md (Windows: no `:` in filenames) + */ + +import { mkdir, writeFile } from 'node:fs/promises'; +import { join, dirname } from 'node:path'; +import os from 'node:os'; +import { performance } from 'node:perf_hooks'; +import { resourceUsage } from 'node:process'; + +const WIN_BAD = /[<>:"/\\|?*\x00-\x1f]/g; + +/** Strip characters invalid in Windows / POSIX filenames. */ +export function sanitizeTestName(name) { + const s = String(name).trim().replace(WIN_BAD, '_').replace(/\s+/g, '_'); + return s || 'test'; +} + +/** + * @param {Date} [now] + * @returns {{ hh: string, mm: string, label: string }} + */ +export function timeParts(now = new Date()) { + const hh = String(now.getHours()).padStart(2, '0'); + const mm = String(now.getMinutes()).padStart(2, '0'); + return { hh, mm, label: `${hh}:${mm}` }; +} + +/** + * @param {string} testName + * @param {string} ext — including dot, e.g. '.json' + * @param {{ cwd?: string, now?: Date }} [options] + */ +export function reportFilePathWithExt(testName, ext, options = {}) { + const cwd = options.cwd ?? process.cwd(); + const now = options.now ?? new Date(); + const base = sanitizeTestName(testName); + const { hh, mm } = timeParts(now); + const file = `${base}__${hh}-${mm}${ext}`; + return join(cwd, 'tests', file); +} + +export function reportFilePath(testName, options = {}) { + return reportFilePathWithExt(testName, '.json', options); +} + +export function reportMarkdownPath(testName, options = {}) { + return reportFilePathWithExt(testName, '.md', options); +} + +function formatBytes(n) { + if (typeof n !== 'number' || Number.isNaN(n)) return String(n); + const u = ['B', 'KB', 'MB', 'GB']; + let i = 0; + let x = n; + while (x >= 1024 && i < u.length - 1) { + x /= 1024; + i++; + } + return `${x < 10 && i > 0 ? x.toFixed(1) : Math.round(x)} ${u[i]}`; +} + +/** Snapshot of host / OS (cheap; call anytime). */ +export function hostSnapshot() { + const cpus = os.cpus(); + const total = os.totalmem(); + const free = os.freemem(); + return { + hostname: os.hostname(), + platform: os.platform(), + arch: os.arch(), + release: os.release(), + cpuCount: cpus.length, + cpuModel: cpus[0]?.model?.trim() ?? '', + totalMemBytes: total, + freeMemBytes: free, + usedMemBytes: total - free, + loadAvg: os.loadavg(), + osUptimeSec: os.uptime(), + }; +} + +/** + * Call at test start; then call `.finalize()` at end for wall + CPU delta + memory. + */ +export function createMetricsCollector() { + const cpu0 = process.cpuUsage(); + const perf0 = performance.now(); + const wall0 = Date.now(); + + return { + hostSnapshot, + + finalize() { + const cpu = process.cpuUsage(cpu0); + const perf1 = performance.now(); + let ru = null; + try { + ru = resourceUsage(); + } catch { + /* older runtimes */ + } + return { + durationWallMs: Math.round((perf1 - perf0) * 1000) / 1000, + durationClockMs: Date.now() - wall0, + cpuUserUs: cpu.user, + cpuSystemUs: cpu.system, + cpuUserMs: cpu.user / 1000, + cpuSystemMs: cpu.system / 1000, + memory: process.memoryUsage(), + resourceUsage: ru, + pid: process.pid, + node: process.version, + processUptimeSec: process.uptime(), + }; + }, + }; +} + +/** + * @param {Record} payload + * @returns {string} + */ +export function renderMarkdownReport(payload) { + const meta = payload.meta ?? {}; + const m = payload.metrics ?? {}; + const host = m.host ?? {}; + const timing = m.timing ?? {}; + const proc = m.process ?? {}; + const tStart = timing.startedAt ?? payload.startedAt; + const tEnd = timing.finishedAt ?? payload.finishedAt; + + const lines = []; + + lines.push(`# Test report: ${meta.displayName ?? meta.testName ?? 'run'}`); + lines.push(''); + lines.push('## Summary'); + lines.push(''); + lines.push(`| Key | Value |`); + lines.push(`| --- | --- |`); + lines.push(`| Result | ${payload.ok === true ? 'PASS' : payload.ok === false ? 'FAIL' : '—'} |`); + if (payload.passed != null) lines.push(`| Assertions passed | ${payload.passed} |`); + if (payload.failed != null) lines.push(`| Assertions failed | ${payload.failed} |`); + if (payload.ipcLlm != null) lines.push(`| IPC LLM step | ${payload.ipcLlm ? 'enabled' : 'skipped'} |`); + if (payload.ipcLlama != null) { + lines.push(`| IPC llama :8888 step | ${payload.ipcLlama ? 'enabled' : 'skipped'} |`); + } + if (payload.ipcClassifierLlama != null) { + lines.push( + `| IPC classifier | ${payload.ipcClassifierLlama ? 'local llama :8888' : 'remote (KBOT_ROUTER / KBOT_IPC_MODEL)'} |` + ); + } + lines.push(`| CWD | \`${String(meta.cwd ?? '').replace(/`/g, "'")}\` |`); + lines.push(''); + + lines.push('## Timing'); + lines.push(''); + lines.push(`| Metric | Value |`); + lines.push(`| --- | --- |`); + if (tStart) lines.push(`| Started (ISO) | ${tStart} |`); + if (tEnd) lines.push(`| Finished (ISO) | ${tEnd} |`); + if (proc.durationWallMs != null) lines.push(`| Wall time (perf) | ${proc.durationWallMs} ms |`); + if (proc.durationClockMs != null) lines.push(`| Wall time (clock) | ${proc.durationClockMs} ms |`); + lines.push(''); + + lines.push('## Process (Node)'); + lines.push(''); + lines.push(`| Metric | Value |`); + lines.push(`| --- | --- |`); + if (proc.pid != null) lines.push(`| PID | ${proc.pid} |`); + if (proc.node) lines.push(`| Node | ${proc.node} |`); + if (proc.processUptimeSec != null) lines.push(`| process.uptime() | ${proc.processUptimeSec.toFixed(3)} s |`); + if (proc.cpuUserMs != null && proc.cpuSystemMs != null) { + lines.push(`| CPU user (process.cpuUsage Δ) | ${proc.cpuUserMs.toFixed(3)} ms (${proc.cpuUserUs ?? '—'} µs) |`); + lines.push(`| CPU system (process.cpuUsage Δ) | ${proc.cpuSystemMs.toFixed(3)} ms (${proc.cpuSystemUs ?? '—'} µs) |`); + } + const ru = proc.resourceUsage; + if (ru && typeof ru === 'object') { + if (ru.userCPUTime != null) { + lines.push(`| CPU user (resourceUsage) | ${(ru.userCPUTime / 1000).toFixed(3)} ms |`); + } + if (ru.systemCPUTime != null) { + lines.push(`| CPU system (resourceUsage) | ${(ru.systemCPUTime / 1000).toFixed(3)} ms |`); + } + if (ru.maxRSS != null) { + lines.push(`| Max RSS (resourceUsage) | ${formatBytes(ru.maxRSS * 1024)} |`); + } + } + const mem = proc.memory; + if (mem && typeof mem === 'object') { + lines.push(`| RSS | ${formatBytes(mem.rss)} (${mem.rss} B) |`); + lines.push(`| Heap used | ${formatBytes(mem.heapUsed)} |`); + lines.push(`| Heap total | ${formatBytes(mem.heapTotal)} |`); + lines.push(`| External | ${formatBytes(mem.external)} |`); + if (mem.arrayBuffers != null) lines.push(`| Array buffers | ${formatBytes(mem.arrayBuffers)} |`); + } + lines.push(''); + + lines.push('## Host'); + lines.push(''); + lines.push(`| Metric | Value |`); + lines.push(`| --- | --- |`); + if (host.hostname) lines.push(`| Hostname | ${host.hostname} |`); + if (host.platform) lines.push(`| OS | ${host.platform} ${host.release ?? ''} |`); + if (host.arch) lines.push(`| Arch | ${host.arch} |`); + if (host.cpuCount != null) lines.push(`| CPUs | ${host.cpuCount} |`); + if (host.cpuModel) lines.push(`| CPU model | ${host.cpuModel} |`); + if (host.totalMemBytes != null) { + lines.push(`| RAM total | ${formatBytes(host.totalMemBytes)} |`); + lines.push(`| RAM free | ${formatBytes(host.freeMemBytes)} |`); + lines.push(`| RAM used | ${formatBytes(host.usedMemBytes)} |`); + } + if (host.loadAvg && host.loadAvg.length) { + lines.push(`| Load avg (1/5/15) | ${host.loadAvg.map((x) => x.toFixed(2)).join(' / ')} |`); + } + if (host.osUptimeSec != null) lines.push(`| OS uptime | ${(host.osUptimeSec / 3600).toFixed(2)} h |`); + lines.push(''); + + const kbotAi = payload.kbotAi; + const hasKbotAiMeta = + kbotAi && + typeof kbotAi === 'object' && + (kbotAi.routerStep != null || kbotAi.llamaStep != null); + const hasClassifierLlm = payload.llm != null && typeof payload.llm === 'object'; + if (hasKbotAiMeta || hasClassifierLlm) { + lines.push('## LLM API (provider JSON)'); + lines.push(''); + lines.push( + 'Fields from the chat completion response except assistant message bodies (`usage`, `model`, `id`, provider-specific).' + ); + lines.push(''); + if (hasKbotAiMeta) { + if (kbotAi.routerStep != null) { + lines.push('### IPC step 6 — router / main kbot-ai'); + lines.push(''); + lines.push('```json'); + lines.push(JSON.stringify(kbotAi.routerStep, null, 2)); + lines.push('```'); + lines.push(''); + } + if (kbotAi.llamaStep != null) { + lines.push('### IPC step 7 — local llama :8888'); + lines.push(''); + lines.push('```json'); + lines.push(JSON.stringify(kbotAi.llamaStep, null, 2)); + lines.push('```'); + lines.push(''); + } + } + if (hasClassifierLlm) { + lines.push('### Classifier — batched kbot-ai'); + lines.push(''); + lines.push('```json'); + lines.push(JSON.stringify(payload.llm, null, 2)); + lines.push('```'); + lines.push(''); + } + } + + if (payload.anchor != null || (Array.isArray(payload.distances) && payload.distances.length > 0)) { + lines.push('## Classifier batch'); + lines.push(''); + lines.push(`| Key | Value |`); + lines.push(`| --- | --- |`); + if (payload.anchor != null) lines.push(`| Anchor | ${payload.anchor} |`); + if (payload.labelCount != null) lines.push(`| Label count | ${payload.labelCount} |`); + if (payload.backend != null) lines.push(`| Backend | ${payload.backend} |`); + const pe = payload.parseError; + if (pe != null && String(pe).length) { + lines.push(`| Parse | Failed: ${String(pe).replace(/\|/g, '\\|').slice(0, 500)}${String(pe).length > 500 ? '…' : ''} |`); + } else { + lines.push(`| Parse | OK |`); + } + lines.push(''); + const sorted = Array.isArray(payload.byDistance) ? payload.byDistance : []; + const preview = sorted.filter((r) => r && r.distance != null).slice(0, 12); + if (preview.length > 0) { + lines.push('### Nearest labels (by distance)'); + lines.push(''); + lines.push(`| Label | Distance |`); + lines.push(`| --- | ---: |`); + for (const row of preview) { + const lab = String(row.label ?? '').replace(/\|/g, '\\|'); + lines.push(`| ${lab} | ${row.distance} |`); + } + lines.push(''); + } + } + + if (payload.stress?.summary && typeof payload.stress.summary === 'object') { + const s = payload.stress.summary; + const w = s.wallMs; + lines.push('## Classifier stress (batch repeats)'); + lines.push(''); + lines.push(`| Metric | Value |`); + lines.push(`| --- | --- |`); + lines.push(`| Requested runs | ${s.requestedRuns ?? '—'} |`); + if (w && typeof w === 'object') { + lines.push( + `| Wall time (ms) | min ${w.min} · max ${w.max} · avg ${w.avg} · p50 ${w.p50} · p95 ${w.p95} |` + ); + } + lines.push(`| Batch OK / fail | ${s.successCount ?? '—'} / ${s.failCount ?? '—'} |`); + if (s.totalTokens > 0 || s.totalPromptTokens > 0 || s.totalCompletionTokens > 0) { + lines.push( + `| Σ tokens (prompt / completion / total) | ${s.totalPromptTokens} / ${s.totalCompletionTokens} / ${s.totalTokens} |` + ); + } + lines.push(''); + } + + if (payload.env && typeof payload.env === 'object') { + lines.push('## Environment (selected)'); + lines.push(''); + lines.push(`| Variable | Value |`); + lines.push(`| --- | --- |`); + for (const [k, v] of Object.entries(payload.env)) { + lines.push(`| \`${k}\` | ${v === null || v === undefined ? '—' : String(v)} |`); + } + lines.push(''); + } + + if (payload.error) { + lines.push('## Error'); + lines.push(''); + lines.push('```'); + lines.push(String(payload.error)); + lines.push('```'); + lines.push(''); + } + + lines.push('---'); + lines.push(`*Written ${meta.writtenAt ?? new Date().toISOString()}*`); + lines.push(''); + + return lines.join('\n'); +} + +/** + * Build metrics block for JSON + MD (host snapshot + process finalize). + */ +export function buildMetricsBundle(collector, startedAtIso, finishedAtIso) { + const host = collector.hostSnapshot(); + const processMetrics = collector.finalize(); + return { + timing: { + startedAt: startedAtIso, + finishedAt: finishedAtIso, + }, + host, + process: processMetrics, + }; +} + +/** + * @param {string} testName + * @param {Record} data — merged into payload (meta + metrics added) + * @param {{ cwd?: string, now?: Date, metrics?: object }} [options] + * @returns {Promise<{ jsonPath: string, mdPath: string }>} + */ +export async function writeTestReports(testName, data, options = {}) { + const cwd = options.cwd ?? process.cwd(); + const now = options.now ?? new Date(); + const jsonPath = reportFilePath(testName, { cwd, now }); + const mdPath = reportMarkdownPath(testName, { cwd, now }); + const { hh, mm, label } = timeParts(now); + + const base = sanitizeTestName(testName); + const payload = { + meta: { + testName: base, + displayName: `${base}::${label}`, + cwd, + writtenAt: now.toISOString(), + jsonFile: jsonPath, + mdFile: mdPath, + }, + ...data, + }; + + await mkdir(dirname(jsonPath), { recursive: true }); + await writeFile(jsonPath, JSON.stringify(payload, null, 2), 'utf8'); + + const md = renderMarkdownReport(payload); + await writeFile(mdPath, md, 'utf8'); + + return { jsonPath, mdPath }; +} + +/** @deprecated Prefer writeTestReports */ +export async function writeJsonReport(testName, data, options = {}) { + const { jsonPath } = await writeTestReports(testName, data, options); + return jsonPath; +} diff --git a/packages/media/cpp/orchestrator/spawn.mjs b/packages/media/cpp/orchestrator/spawn.mjs new file mode 100644 index 00000000..1e8980c5 --- /dev/null +++ b/packages/media/cpp/orchestrator/spawn.mjs @@ -0,0 +1,159 @@ +/** + * orchestrator/spawn.mjs + * + * Spawn a C++ worker as a child process, send/receive length-prefixed + * JSON messages over stdin/stdout. + * + * Usage: + * import { spawnWorker } from './spawn.mjs'; + * const w = await spawnWorker('./dist/polymech-cli.exe'); + * console.log(res); // { id: '...', type: 'pong', payload: {} } + * await w.shutdown(); + */ + +import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; + +// ── frame helpers ──────────────────────────────────────────────────────────── + +/** Write a 4-byte LE length + JSON body to a writable stream. */ +function writeFrame(stream, msg) { + const body = JSON.stringify(msg); + const bodyBuf = Buffer.from(body, 'utf8'); + const lenBuf = Buffer.alloc(4); + lenBuf.writeUInt32LE(bodyBuf.length, 0); + stream.write(Buffer.concat([lenBuf, bodyBuf])); +} + +/** + * Creates a streaming frame parser. + * Calls `onMessage(parsed)` for each complete frame. + */ +function createFrameReader(onMessage) { + let buffer = Buffer.alloc(0); + + return (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + + while (buffer.length >= 4) { + const bodyLen = buffer.readUInt32LE(0); + const totalLen = 4 + bodyLen; + + if (buffer.length < totalLen) break; // need more data + + const bodyBuf = buffer.subarray(4, totalLen); + buffer = buffer.subarray(totalLen); + + try { + const msg = JSON.parse(bodyBuf.toString('utf8')); + onMessage(msg); + } catch (e) { + console.error('[orchestrator] failed to parse frame:', e.message); + } + } + }; +} + +// ── spawnWorker ────────────────────────────────────────────────────────────── + +/** + * Spawn the C++ binary in `worker` mode. + * Returns: { send, request, shutdown, kill, process, ready } + * + * `ready` is a Promise that resolves when the worker sends `{ type: 'ready' }`. + */ +export function spawnWorker(exePath, args = ['worker']) { + const proc = spawn(exePath, args, { + stdio: ['pipe', 'pipe', 'pipe'], + }); + + // Pending request map: id → { resolve, reject, timer } + const pending = new Map(); + + // Event handler for unmatched messages (progress events, etc.) + let eventHandler = null; + + let readyResolve; + const ready = new Promise((resolve) => { readyResolve = resolve; }); + + // stderr → console (worker logs via spdlog go to stderr) + proc.stderr.on('data', (chunk) => { + const text = chunk.toString().trim(); + if (text) console.error(`[worker:stderr] ${text}`); + }); + + // stdout → frame parser → route by id / type + const feedData = createFrameReader((msg) => { + // Handle the initial "ready" signal + if (msg.type === 'ready') { + readyResolve(msg); + return; + } + + // Route response to pending request + if (msg.id && pending.has(msg.id)) { + const { resolve, timer } = pending.get(msg.id); + clearTimeout(timer); + pending.delete(msg.id); + resolve(msg); + return; + } + + // Unmatched message (progress event, broadcast, etc.) + if (eventHandler) { + eventHandler(msg); + } else { + console.log('[orchestrator] unmatched message:', msg); + } + }); + + proc.stdout.on('data', feedData); + + // ── public API ────────────────────────────────────────────────────────── + + /** Fire-and-forget send. */ + function send(msg) { + if (!msg.id) msg.id = randomUUID(); + writeFrame(proc.stdin, msg); + } + + /** Send a message and wait for the response with matching `id`. */ + function request(msg, timeoutMs = 5000) { + return new Promise((resolve, reject) => { + const id = msg.id || randomUUID(); + msg.id = id; + + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error(`IPC request timed out after ${timeoutMs}ms (id=${id}, type=${msg.type})`)); + }, timeoutMs); + + pending.set(id, { resolve, reject, timer }); + writeFrame(proc.stdin, msg); + }); + } + + /** Graceful shutdown: send shutdown message & wait for process exit. */ + async function shutdown(timeoutMs = 3000) { + const res = await request({ type: 'shutdown' }, timeoutMs); + // Wait for process to exit + await new Promise((resolve) => { + const timer = setTimeout(() => { + proc.kill(); + resolve(); + }, timeoutMs); + proc.on('exit', () => { clearTimeout(timer); resolve(); }); + }); + return res; + } + + return { + send, + request, + shutdown, + kill: () => proc.kill(), + process: proc, + ready, + onEvent: (handler) => { eventHandler = handler; }, + }; +} diff --git a/packages/media/cpp/orchestrator/test-commons.js b/packages/media/cpp/orchestrator/test-commons.js new file mode 100644 index 00000000..e405051f --- /dev/null +++ b/packages/media/cpp/orchestrator/test-commons.js @@ -0,0 +1,237 @@ +/** + * orchestrator/test-commons.js — shared helpers for IPC orchestrator tests. + */ + +import { randomUUID } from 'node:crypto'; +import net from 'node:net'; + +/** kbot-ai live call runs unless KBOT_IPC_LLM is explicitly disabled. */ +export function ipcLlmEnabled() { + const v = process.env.KBOT_IPC_LLM; + if (v === undefined || v === '') return true; + const s = String(v).trim().toLowerCase(); + if (s === '0' || s === 'false' || s === 'no' || s === 'off') return false; + return true; +} + +/** Llama local (:8888) IPC block — on by default; set KBOT_IPC_LLAMA=0 to skip (CI / no server). */ +export function ipcLlamaEnabled() { + const v = process.env.KBOT_IPC_LLAMA; + if (v === undefined || v === '') return true; + const s = String(v).trim().toLowerCase(); + if (s === '0' || s === 'false' || s === 'no' || s === 'off') return false; + return true; +} + +/** + * Classifier batch test (`test-ipc-classifier.mjs`): local llama :8888 by default. + * Set KBOT_IPC_CLASSIFIER_LLAMA=0 to use KBOT_ROUTER / KBOT_IPC_MODEL (e.g. OpenRouter) instead. + */ +export function ipcClassifierLlamaEnabled() { + const v = process.env.KBOT_IPC_CLASSIFIER_LLAMA; + if (v === undefined || v === '') return true; + const s = String(v).trim().toLowerCase(); + if (s === '0' || s === 'false' || s === 'no' || s === 'off') return false; + return true; +} + +/** Auto-start scripts/run-7b.sh when :8888 is closed (default on). */ +export function llamaAutostartEnabled() { + const v = process.env.KBOT_IPC_LLAMA_AUTOSTART; + if (v === undefined || v === '') return true; + const s = String(v).trim().toLowerCase(); + if (s === '0' || s === 'false' || s === 'no' || s === 'off') return false; + return true; +} + +/** TCP connect probe — true if something accepts connections. */ +export function probeTcpPort(host, port, timeoutMs = 2000) { + return new Promise((resolve) => { + const socket = net.connect({ port, host }); + const done = (ok) => { + socket.removeAllListeners(); + try { + socket.destroy(); + } catch { + /* ignore */ + } + resolve(ok); + }; + const timer = setTimeout(() => done(false), timeoutMs); + socket.once('connect', () => { + clearTimeout(timer); + done(true); + }); + socket.once('error', () => { + clearTimeout(timer); + done(false); + }); + }); +} + +/** Counters for a test run (create one per process / suite). */ +export function createAssert() { + let passed = 0; + let failed = 0; + + function assert(condition, label) { + if (condition) { + console.log(` ✅ ${label}`); + passed++; + } else { + console.error(` ❌ ${label}`); + failed++; + } + } + + return { + assert, + get passed() { + return passed; + }, + get failed() { + return failed; + }, + }; +} + +/** Normalize IPC payload (object or JSON string). */ +export function payloadObj(msg) { + const p = msg?.payload; + if (p == null) return null; + if (typeof p === 'string') { + try { + return JSON.parse(p); + } catch { + return { raw: p }; + } + } + return p; +} + +/** Print LLM job_result so it is easy to spot (stdout, not mixed with worker stderr). */ +export function logKbotAiResponse(stepLabel, msg) { + const p = payloadObj(msg); + const text = p?.text != null ? String(p.text) : ''; + const err = p?.error != null ? String(p.error) : ''; + const maxRaw = process.env.KBOT_IPC_LLM_LOG_MAX; + const max = + maxRaw === undefined || maxRaw === '' + ? Infinity + : Number.parseInt(maxRaw, 10); + + console.log(''); + console.log(` ┌── ${stepLabel} ──────────────────────────────────────────`); + console.log(` │ type: ${msg?.type ?? '?'}`); + if (p && typeof p === 'object') { + console.log(` │ status: ${p.status ?? '?'}`); + if (p.mode != null) console.log(` │ mode: ${p.mode}`); + if (p.router != null) console.log(` │ router: ${p.router}`); + if (p.model != null) console.log(` │ model: ${p.model}`); + } + if (err) { + const showErr = + Number.isFinite(max) && err.length > max + ? `${err.slice(0, max)}… [truncated, ${err.length} chars]` + : err; + console.log(` │ error: ${showErr.replace(/\n/g, '\n │ ')}`); + } + if (p?.llm != null && typeof p.llm === 'object') { + const raw = JSON.stringify(p.llm); + const cap = 4000; + const shown = raw.length > cap ? `${raw.slice(0, cap)}… [+${raw.length - cap} chars]` : raw; + console.log(` │ llm (usage / provider JSON): ${shown}`); + } + if (text) { + let body = text; + let note = ''; + if (Number.isFinite(max) && text.length > max) { + body = text.slice(0, max); + note = `\n │ … [truncated: ${text.length} chars total; set KBOT_IPC_LLM_LOG_MAX= to adjust]`; + } + console.log(' │ text:'); + for (const line of body.split('\n')) { + console.log(` │ ${line}`); + } + if (note) console.log(note); + } else if (!err) { + console.log(' │ (no text in payload)'); + } + console.log(' └────────────────────────────────────────────────────────────'); + console.log(''); +} + +/** + * Length-prefixed JSON framing used by the C++ UDS worker. + * Call `attach()` once to wire `socket.on('data', ...)`. + */ +export function createIpcClient(socket) { + const pending = new Map(); + let readyResolve; + const readyPromise = new Promise((res) => { + readyResolve = res; + }); + + let buffer = Buffer.alloc(0); + + function onData(chunk) { + buffer = Buffer.concat([buffer, chunk]); + while (buffer.length >= 4) { + const len = buffer.readUInt32LE(0); + if (buffer.length >= 4 + len) { + const payload = buffer.toString('utf8', 4, 4 + len); + buffer = buffer.subarray(4 + len); + try { + const msg = JSON.parse(payload); + if (msg.type === 'ready') { + readyResolve(msg); + } else if (msg.id && pending.has(msg.id)) { + const p = pending.get(msg.id); + clearTimeout(p.timer); + pending.delete(msg.id); + p.resolve(msg); + } + } catch (e) { + console.error('[orchestrator] frame parse error', e); + } + } else { + break; + } + } + } + + function request(msg, timeoutMs = 5000) { + return new Promise((resolve, reject) => { + const id = msg.id || randomUUID(); + msg.id = id; + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error(`IPC request timed out`)); + }, timeoutMs); + pending.set(id, { resolve, reject, timer }); + + const str = JSON.stringify(msg); + const lenBuf = Buffer.alloc(4); + lenBuf.writeUInt32LE(Buffer.byteLength(str)); + socket.write(lenBuf); + socket.write(str); + }); + } + + return { + pending, + readyPromise, + request, + attach() { + socket.on('data', onData); + }, + }; +} + +/** Forward worker stderr lines to console (prefixed). */ +export function pipeWorkerStderr(workerProc, label = '[worker:stderr]') { + workerProc.stderr.on('data', (d) => { + const txt = d.toString().trim(); + if (txt) console.error(`${label} ${txt}`); + }); +} diff --git a/packages/media/cpp/orchestrator/test-files.mjs b/packages/media/cpp/orchestrator/test-files.mjs new file mode 100644 index 00000000..0b250ddd --- /dev/null +++ b/packages/media/cpp/orchestrator/test-files.mjs @@ -0,0 +1,204 @@ +/** + * orchestrator/test-files.mjs + * + * IPC + CLI parity for text file sources (port of kbot/src/source.ts — text slice only; images later). + * Fixtures: packages/kbot/tests/test-data/files (path below is resolved from orchestrator/). + * + * Run: npm run test:files + * + * Env (optional live LLM step): + * KBOT_IPC_LLM — set 0/false/off to skip live kbot-ai (default: run when key available) + * KBOT_ROUTER, KBOT_IPC_MODEL — same as test-ipc + * + * CLI (npm run test:files -- --help): + * --fixtures Override fixture root (default: ../../tests/test-data/files) + */ + +import { spawn } from 'node:child_process'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import net from 'node:net'; +import { existsSync, unlinkSync } from 'node:fs'; +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; + +import { + distExePath, + platform, + uds, + timeouts, + kbotAiPayloadFromEnv, +} from './presets.js'; +import { + createAssert, + payloadObj, + ipcLlmEnabled, + createIpcClient, + pipeWorkerStderr, +} from './test-commons.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const EXE = distExePath(__dirname); +const stats = createAssert(); +const { assert } = stats; + +const defaultFixtures = resolve(__dirname, '../../tests/test-data/files'); + +function parseArgv() { + const y = yargs(hideBin(process.argv)) + .scriptName('test-files') + .usage('$0 [options]\n\nText file source IPC tests (fixtures under packages/kbot/tests/test-data/files).') + .option('fixtures', { + type: 'string', + default: defaultFixtures, + describe: 'Directory used as kbot-ai `path` (project root for includes)', + }) + .strict() + .help() + .alias('h', 'help'); + return y.parseSync(); +} + +/** + * @param {import('node:net').Socket} socket + * @param {string} fixturesDir + */ +async function runFileSuite(socket, fixturesDir) { + const ipc = createIpcClient(socket); + ipc.attach(); + + const readyMsg = await ipc.readyPromise; + assert(readyMsg.type === 'ready', 'worker ready'); + + console.log('\n── Dry-run source attachment (no LLM) ──\n'); + + /** @param {Record} payload */ + async function dry(payload) { + const msg = await ipc.request({ type: 'kbot-ai', payload }, timeouts.ipcDefault); + assert(msg.type === 'job_result', `job_result (got ${msg.type})`); + const p = payloadObj(msg); + assert(p?.dry_run === true, 'dry_run flag'); + assert(p?.status === 'success', 'status success'); + assert(Array.isArray(p?.sources), 'sources array'); + return p; + } + + let p = await dry({ + dry_run: true, + path: fixturesDir, + include: ['bubblesort.js'], + prompt: 'What function is defined? Reply one word.', + }); + assert( + p.sources.some((s) => String(s).includes('bubblesort')), + 'sources lists bubblesort.js', + ); + assert( + /bubbleSort/i.test(String(p.prompt_preview || '')), + 'prompt_preview contains bubbleSort', + ); + + p = await dry({ + dry_run: true, + path: fixturesDir, + include: ['*.js'], + prompt: 'List algorithms.', + }); + assert(p.sources.length >= 2, 'glob *.js yields at least 2 files'); + const names = p.sources.map((s) => String(s).toLowerCase()); + assert(names.some((n) => n.includes('bubblesort')), 'glob includes bubblesort.js'); + assert(names.some((n) => n.includes('factorial')), 'glob includes factorial.js'); + + p = await dry({ + dry_run: true, + path: fixturesDir, + include: ['glob/data.json'], + prompt: 'What is the title?', + }); + assert( + String(p.prompt_preview || '').includes('Injection Barrel'), + 'JSON fixture content in preview', + ); + + if (ipcLlmEnabled()) { + console.log('\n── Live LLM — single file prompt ──\n'); + const base = kbotAiPayloadFromEnv(); + const payload = { + ...base, + path: fixturesDir, + include: ['bubblesort.js'], + prompt: + process.env.KBOT_FILES_LIVE_PROMPT || + 'What is the name of the sorting algorithm in the code? Reply with two words: bubble sort', + }; + const msg = await ipc.request({ type: 'kbot-ai', payload }, timeouts.kbotAi); + assert(msg.type === 'job_result', 'live job_result'); + const lp = payloadObj(msg); + assert(lp?.status === 'success', 'live status success'); + const text = String(lp?.text || ''); + assert(/bubble/i.test(text), 'assistant mentions bubble (file context worked)'); + } else { + console.log('\n── Live LLM — skipped (KBOT_IPC_LLM off) ──\n'); + } + + const shutdownRes = await ipc.request({ type: 'shutdown' }, timeouts.ipcDefault); + assert(shutdownRes.type === 'shutdown_ack', 'shutdown ack'); +} + +async function run() { + const argv = parseArgv(); + const fixturesDir = resolve(argv.fixtures); + + if (!existsSync(EXE)) { + console.error(`Binary not found: ${EXE}`); + process.exit(1); + } + if (!existsSync(fixturesDir)) { + console.error(`Fixtures directory not found: ${fixturesDir}`); + process.exit(1); + } + + console.log(`\n📁 test:files — fixtures: ${fixturesDir}\n`); + + const CPP_UDS_ARG = uds.workerArg(); + if (!platform.isWin && existsSync(CPP_UDS_ARG)) { + unlinkSync(CPP_UDS_ARG); + } + + const workerProc = spawn(EXE, ['worker', '--uds', CPP_UDS_ARG], { stdio: 'pipe' }); + pipeWorkerStderr(workerProc); + + let socket; + for (let i = 0; i < timeouts.connectAttempts; i++) { + try { + await new Promise((res, rej) => { + socket = net.connect(uds.connectOpts(CPP_UDS_ARG)); + socket.once('connect', res); + socket.once('error', rej); + }); + break; + } catch { + if (i === timeouts.connectAttempts - 1) throw new Error('connect failed'); + await new Promise((r) => setTimeout(r, timeouts.connectRetryMs)); + } + } + + try { + await runFileSuite(socket, fixturesDir); + } finally { + try { + socket?.destroy(); + } catch { + /* ignore */ + } + workerProc.kill(); + } + + console.log(`\nDone. Passed: ${stats.passed} Failed: ${stats.failed}\n`); + process.exit(stats.failed > 0 ? 1 : 0); +} + +run().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/packages/media/cpp/orchestrator/test-gridsearch-ipc-daemon.mjs b/packages/media/cpp/orchestrator/test-gridsearch-ipc-daemon.mjs new file mode 100644 index 00000000..feb501ed --- /dev/null +++ b/packages/media/cpp/orchestrator/test-gridsearch-ipc-daemon.mjs @@ -0,0 +1,204 @@ +/** + * orchestrator/test-gridsearch-ipc.mjs + * + * E2E test: spawn the C++ worker, send a gridsearch request + * matching `npm run gridsearch:enrich` defaults, collect IPC events, + * and verify the full event sequence. + * + * Run: node orchestrator/test-gridsearch-ipc.mjs + * Needs: npm run build-debug (or npm run build) + */ + +import { spawnWorker } from './spawn.mjs'; +import { resolve, dirname } from 'node:path'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import fs from 'node:fs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const IS_WIN = process.platform === 'win32'; +const EXE_NAME = IS_WIN ? 'polymech-cli.exe' : 'polymech-cli'; + +const EXE = resolve(__dirname, '..', 'dist', EXE_NAME); +if (!fs.existsSync(EXE)) { + console.error(`❌ No ${EXE_NAME} found in dist. Run npm run build first.`); + process.exit(1); +} +console.log(`Binary: ${EXE}\n`); + +// Load the sample settings (same as gridsearch:enrich) +const sampleConfig = JSON.parse( + readFileSync(resolve(__dirname, '..', 'config', 'gridsearch-sample.json'), 'utf8') +); + +let passed = 0; +let failed = 0; + +function assert(condition, label) { + if (condition) { + console.log(` ✅ ${label}`); + passed++; + } else { + console.error(` ❌ ${label}`); + failed++; + } +} + +// ── Event collector ───────────────────────────────────────────────────────── + +const EXPECTED_EVENTS = [ + 'grid-ready', + 'waypoint-start', + 'area', + 'location', + 'enrich-start', + 'node', + 'nodePage', + // 'node-error' — may or may not occur, depends on network +]; + +function createCollector() { + const events = {}; + for (const t of ['grid-ready', 'waypoint-start', 'area', 'location', + 'enrich-start', 'node', 'node-error', 'nodePage']) { + events[t] = []; + } + return { + events, + handler(msg) { + const t = msg.type; + if (events[t]) { + events[t].push(msg); + } else { + events[t] = [msg]; + } + // Live progress indicator + const d = msg.payload ?? {}; + if (t === 'waypoint-start') { + process.stdout.write(`\r 🔍 Searching waypoint ${(d.index ?? 0) + 1}/${d.total ?? '?'}...`); + } else if (t === 'node') { + process.stdout.write(`\r 📧 Enriched: ${d.title?.substring(0, 40) ?? ''} `); + } else if (t === 'node-error') { + process.stdout.write(`\r ⚠️ Error: ${d.node?.title?.substring(0, 40) ?? ''} `); + } + }, + }; +} + +// ── Main test ─────────────────────────────────────────────────────────────── + +async function run() { + console.log('🧪 Gridsearch IPC E2E Test\n'); + + // ── 1. Spawn worker ─────────────────────────────────────────────────── + console.log('1. Spawn worker in daemon mode'); + const worker = spawnWorker(EXE, ['worker', '--daemon', '--user-uid', '3bb4cfbf-318b-44d3-a9d3-35680e738421']); + const readyMsg = await worker.ready; + assert(readyMsg.type === 'ready', 'Worker sends ready signal'); + + // ── 2. Register event collector ─────────────────────────────────────── + const collector = createCollector(); + worker.onEvent(collector.handler); + + // ── 3. Send gridsearch request (matching gridsearch:enrich) ──────────── + console.log('2. Send gridsearch request (Aruba / recycling / --enrich)'); + const t0 = Date.now(); + + // Very long timeout — enrichment can take minutes + const result = await worker.request( + { + type: 'gridsearch', + payload: { + ...sampleConfig, + enrich: true, + }, + }, + 5 * 60 * 1000 // 5 min timeout + ); + + const elapsed = ((Date.now() - t0) / 1000).toFixed(1); + console.log(`\n\n ⏱️ Completed in ${elapsed}s\n`); + + // ── 4. Verify final result ──────────────────────────────────────────── + console.log('3. Verify job_result'); + assert(result.type === 'job_result', `Response type is "job_result" (got "${result.type}")`); + + const summary = result.payload ?? null; + assert(summary !== null, 'job_result payload is present'); + + if (summary) { + assert(typeof summary.totalMs === 'number', `totalMs is number (${summary.totalMs})`); + assert(typeof summary.searchMs === 'number', `searchMs is number (${summary.searchMs})`); + assert(typeof summary.enrichMs === 'number', `enrichMs is number (${summary.enrichMs})`); + assert(typeof summary.freshApiCalls === 'number', `freshApiCalls is number (${summary.freshApiCalls})`); + assert(typeof summary.waypointCount === 'number', `waypointCount is number (${summary.waypointCount})`); + assert(summary.gridStats && typeof summary.gridStats.validCells === 'number', 'gridStats.validCells present'); + assert(summary.searchStats && typeof summary.searchStats.totalResults === 'number', 'searchStats.totalResults present'); + assert(typeof summary.enrichedOk === 'number', `enrichedOk is number (${summary.enrichedOk})`); + assert(typeof summary.enrichedTotal === 'number', `enrichedTotal is number (${summary.enrichedTotal})`); + } + + // ── 5. Verify event sequence ────────────────────────────────────────── + console.log('4. Verify event stream'); + const e = collector.events; + + assert(e['grid-ready'].length === 1, `Exactly 1 grid-ready event (got ${e['grid-ready'].length})`); + assert(e['waypoint-start'].length > 0, `At least 1 waypoint-start event (got ${e['waypoint-start'].length})`); + assert(e['area'].length > 0, `At least 1 area event (got ${e['area'].length})`); + assert(e['waypoint-start'].length === e['area'].length, `waypoint-start count (${e['waypoint-start'].length}) === area count (${e['area'].length})`); + assert(e['enrich-start'].length === 1, `Exactly 1 enrich-start event (got ${e['enrich-start'].length})`); + + const totalNodes = e['node'].length + e['node-error'].length; + assert(totalNodes > 0, `At least 1 node event (got ${totalNodes}: ${e['node'].length} ok, ${e['node-error'].length} errors)`); + + // Validate grid-ready payload + if (e['grid-ready'].length > 0) { + const gr = e['grid-ready'][0].payload ?? {}; + assert(Array.isArray(gr.areas), 'grid-ready.areas is array'); + assert(typeof gr.total === 'number' && gr.total > 0, `grid-ready.total > 0 (${gr.total})`); + } + + // Validate location events have required fields + if (e['location'].length > 0) { + const loc = e['location'][0].payload ?? {}; + assert(loc.location && typeof loc.location.title === 'string', 'location event has location.title'); + assert(loc.location && typeof loc.location.place_id === 'string', 'location event has location.place_id'); + assert(typeof loc.areaName === 'string', 'location event has areaName'); + } + assert(e['location'].length > 0, `At least 1 location event (got ${e['location'].length})`); + + // Validate node payloads + if (e['node'].length > 0) { + const nd = e['node'][0].payload ?? {}; + assert(typeof nd.placeId === 'string', 'node event has placeId'); + assert(typeof nd.title === 'string', 'node event has title'); + assert(Array.isArray(nd.emails), 'node event has emails array'); + assert(typeof nd.status === 'string', 'node event has status'); + } + + // ── 6. Print event summary ──────────────────────────────────────────── + console.log('\n5. Event summary'); + for (const [type, arr] of Object.entries(e)) { + if (arr.length > 0) console.log(` ${type}: ${arr.length}`); + } + + // ── 7. Shutdown ─────────────────────────────────────────────────────── + console.log('\n6. Graceful shutdown'); + const shutdownRes = await worker.shutdown(); + assert(shutdownRes.type === 'shutdown_ack', 'Shutdown acknowledged'); + + await new Promise(r => setTimeout(r, 500)); + assert(worker.process.exitCode === 0, `Worker exited with code 0 (got ${worker.process.exitCode})`); + + // ── Summary ─────────────────────────────────────────────────────────── + console.log(`\n────────────────────────────────`); + console.log(` Passed: ${passed} Failed: ${failed}`); + console.log(`────────────────────────────────\n`); + + process.exit(failed > 0 ? 1 : 0); +} + +run().catch((err) => { + console.error('Test runner error:', err); + process.exit(1); +}); diff --git a/packages/media/cpp/orchestrator/test-gridsearch-ipc-uds-meta.mjs b/packages/media/cpp/orchestrator/test-gridsearch-ipc-uds-meta.mjs new file mode 100644 index 00000000..a6e7707b --- /dev/null +++ b/packages/media/cpp/orchestrator/test-gridsearch-ipc-uds-meta.mjs @@ -0,0 +1,218 @@ +/** + * orchestrator/test-gridsearch-ipc-uds-meta.mjs + * + * E2E test for Unix Domain Sockets / Windows Named Pipes (Meta Enrichment)! + * Spawns the worker in `--uds` mode and tests direct high-throughput + * lock-free JSON binary framing over a net.Socket. + */ + +import { spawn } from 'node:child_process'; +import { resolve, dirname, join } from 'node:path'; +import { readFileSync, existsSync, unlinkSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import net from 'node:net'; +import { tmpdir } from 'node:os'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const IS_WIN = process.platform === 'win32'; +const EXE_NAME = IS_WIN ? 'polymech-cli.exe' : 'polymech-cli'; +const EXE = resolve(__dirname, '..', 'dist', EXE_NAME); +const TEST_CANCEL = false; + +if (!existsSync(EXE)) { + console.error(`❌ Binary not found at ${EXE}`); + process.exit(1); +} + +const PIPE_NAME = 'polymech-test-uds-meta'; +const CPP_UDS_ARG = IS_WIN ? '4001' : join(tmpdir(), `${PIPE_NAME}.sock`); + +if (!IS_WIN && existsSync(CPP_UDS_ARG)) { + unlinkSync(CPP_UDS_ARG); +} + +console.log(`Binary: ${EXE}`); +console.log(`C++ Arg: ${CPP_UDS_ARG}\n`); + +// ── Event collector ───────────────────────────────────────────────────────── +function createCollector() { + const events = {}; + for (const t of ['grid-ready', 'waypoint-start', 'area', 'location', + 'enrich-start', 'node', 'node-error', 'nodePage', 'job_result']) { + events[t] = []; + } + return { + events, + onComplete: null, + handler(msg) { + const t = msg.type; + if (events[t]) events[t].push(msg); + else events[t] = [msg]; + + const d = msg.data ?? {}; + if (t === 'waypoint-start') { + process.stdout.write(`\r 🔍 Searching waypoint ${(d.index ?? 0) + 1}/${d.total ?? '?'}...`); + } else if (t === 'node') { + process.stdout.write(`\r 📧 Enriched: ${d.title?.substring(0, 40) ?? ''} `); + } else if (t === 'node-error') { + process.stdout.write(`\r ⚠️ Error: ${d.node?.title?.substring(0, 40) ?? ''} `); + } else if (t === 'job_result') { + console.log(`\n 🏁 Pipeline complete!`); + if (this.onComplete) this.onComplete(msg); + } + }, + }; +} + +let passed = 0; +let failed = 0; +function assert(condition, label) { + if (condition) { console.log(` ✅ ${label}`); passed++; } + else { console.error(` ❌ ${label}`); failed++; } +} + +async function run() { + console.log('🧪 Gridsearch UDS Meta E2E Test\n'); + + // 1. Spawn worker in UDS mode + console.log('1. Spawning remote C++ Taskflow Daemon'); + const worker = spawn(EXE, ['worker', '--uds', CPP_UDS_ARG, '--daemon'], { stdio: 'inherit' }); + + // Give the daemon a moment to boot + console.log('2. Connecting net.Socket with retries...'); + + let socket; + for (let i = 0; i < 15; i++) { + try { + await new Promise((resolve, reject) => { + if (IS_WIN) { + socket = net.connect({ port: 4001, host: '127.0.0.1' }); + } else { + socket = net.connect(CPP_UDS_ARG); + } + socket.once('connect', resolve); + socket.once('error', reject); + }); + console.log(' ✅ Socket Connected to UDS!'); + break; + } catch (e) { + if (i === 14) throw e; + await new Promise(r => setTimeout(r, 500)); + } + } + + const collector = createCollector(); + let buffer = Buffer.alloc(0); + + // Buffer framing logic (length-prefixed streaming) + socket.on('data', (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + while (buffer.length >= 4) { + const len = buffer.readUInt32LE(0); + if (buffer.length >= 4 + len) { + const payload = buffer.toString('utf8', 4, 4 + len); + buffer = buffer.subarray(4 + len); + try { + const msg = JSON.parse(payload); + collector.handler(msg); + } catch (e) { + console.error("JSON PARSE ERROR:", e, payload); + } + } else { + break; // Wait for more chunks + } + } + }); + + // 3. Send Gridsearch payload + // USE gridsearch-sample.json instead of gridsearch-bcn-universities.json + const sampleConfig = JSON.parse( + readFileSync(resolve(__dirname, '..', 'config', 'gridsearch-sample.json'), 'utf8') + ); + + sampleConfig.configPath = resolve(__dirname, '..', 'config', 'postgres.toml'); + sampleConfig.jobId = 'uds-meta-test-abc'; + sampleConfig.noCache = true; // force re-enrichment even if cached + + console.log('3. Writing serialized IPC Payload over pipe...'); + const jsonStr = JSON.stringify(sampleConfig); + const lenBuf = Buffer.alloc(4); + lenBuf.writeUInt32LE(Buffer.byteLength(jsonStr)); + socket.write(lenBuf); + socket.write(jsonStr); + + // 4. Wait for pipeline completion (job_result event) or timeout + console.log('\n4. Awaiting multi-threaded Execution Pipeline (can take minutes)...\n'); + + await new Promise((resolve) => { + collector.onComplete = () => { + // Send stop command to gracefully shut down the daemon + console.log(' 📤 Sending stop command to daemon...'); + const stopPayload = JSON.stringify({ action: 'stop' }); + const stopLen = Buffer.alloc(4); + stopLen.writeUInt32LE(Buffer.byteLength(stopPayload)); + socket.write(stopLen); + socket.write(stopPayload); + setTimeout(resolve, 1000); // Give daemon a moment to ack + }; + + // Safety timeout + setTimeout(() => { + console.log('\n ⏰ Timeout reached (300s) — forcing shutdown.'); + resolve(); + }, 300000); // Wait up to 5 minutes + }); + + console.log('\n\n5. Event summary'); + for (const [k, v] of Object.entries(collector.events)) { + console.log(` ${k}: ${v.length}`); + } + + // Assertions + const ev = collector.events; + assert(ev['grid-ready'].length === 1, 'grid-ready emitted once'); + assert(ev['waypoint-start'].length > 0, 'waypoint-start events received'); + assert(ev['location'].length > 0, 'location events received'); + assert(ev['enrich-start'].length === 1, 'enrich-start emitted once'); + assert(ev['job_result'].length === 1, 'job_result emitted once'); + + // Verify social profiles and md body + const nodes = ev['node']; + let foundSocial = false; + let foundSiteMd = false; + + for (const n of nodes) { + const d = n.data; + if (!d) continue; + + if (d.socials && d.socials.length > 0) { + foundSocial = true; + } + + if (d.sites && Array.isArray(d.sites) && d.sites.length > 0) { + foundSiteMd = true; + } + } + + if (foundSocial) { + assert(foundSocial, 'At least one enriched node has social media profiles discovered'); + } else { + console.log(' ⚠️ No social media profiles discovered in this run (data-dependent), but pipeline completed.'); + } + + assert(foundSiteMd, 'At least one enriched node has markdown sites mapped'); + + console.log('6. Cleanup'); + socket.destroy(); + worker.kill('SIGTERM'); + + console.log(`\n────────────────────────────────`); + console.log(` Passed: ${passed} Failed: ${failed}`); + console.log(`────────────────────────────────`); + process.exit(failed > 0 ? 1 : 0); +} + +run().catch(e => { + console.error(e); + process.exit(1); +}); diff --git a/packages/media/cpp/orchestrator/test-gridsearch-ipc-uds.mjs b/packages/media/cpp/orchestrator/test-gridsearch-ipc-uds.mjs new file mode 100644 index 00000000..549d3e0a --- /dev/null +++ b/packages/media/cpp/orchestrator/test-gridsearch-ipc-uds.mjs @@ -0,0 +1,255 @@ +/** + * orchestrator/test-gridsearch-ipc-uds.mjs + * + * E2E test for Unix Domain Sockets / Windows Named Pipes! + * Spawns the worker in `--uds` mode and tests direct high-throughput + * lock-free JSON binary framing over a net.Socket. + */ + +import { spawn } from 'node:child_process'; +import { resolve, dirname, join } from 'node:path'; +import { readFileSync, existsSync, unlinkSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import net from 'node:net'; +import { tmpdir } from 'node:os'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const IS_WIN = process.platform === 'win32'; +const EXE_NAME = IS_WIN ? 'polymech-cli.exe' : 'polymech-cli'; +const EXE = resolve(__dirname, '..', 'dist', EXE_NAME); +const TEST_CANCEL = false; + +if (!existsSync(EXE)) { + console.error(`❌ Binary not found at ${EXE}`); + process.exit(1); +} + +const PIPE_NAME = 'polymech-test-uds'; +const CPP_UDS_ARG = IS_WIN ? '4000' : join(tmpdir(), `${PIPE_NAME}.sock`); + +if (!IS_WIN && existsSync(CPP_UDS_ARG)) { + unlinkSync(CPP_UDS_ARG); +} + +console.log(`Binary: ${EXE}`); +console.log(`C++ Arg: ${CPP_UDS_ARG}\n`); + +// ── Event collector ───────────────────────────────────────────────────────── +function createCollector() { + const events = {}; + for (const t of ['grid-ready', 'waypoint-start', 'area', 'location', + 'enrich-start', 'node', 'node-error', 'nodePage', 'job_result']) { + events[t] = []; + } + return { + events, + onComplete: null, + handler(msg) { + const t = msg.type; + if (events[t]) events[t].push(msg); + else events[t] = [msg]; + + const d = msg.data ?? {}; + if (t === 'waypoint-start') { + process.stdout.write(`\r 🔍 Searching waypoint ${(d.index ?? 0) + 1}/${d.total ?? '?'}...`); + } else if (t === 'node') { + process.stdout.write(`\r 📧 Enriched: ${d.title?.substring(0, 40) ?? ''} `); + } else if (t === 'node-error') { + process.stdout.write(`\r ⚠️ Error: ${d.node?.title?.substring(0, 40) ?? ''} `); + } else if (t === 'job_result') { + console.log(`\n 🏁 Pipeline complete!`); + if (this.onComplete) this.onComplete(msg); + } + }, + }; +} + +let passed = 0; +let failed = 0; +function assert(condition, label) { + if (condition) { console.log(` ✅ ${label}`); passed++; } + else { console.error(` ❌ ${label}`); failed++; } +} + +async function run() { + console.log('🧪 Gridsearch UDS / Named Pipe E2E Test\n'); + + // 1. Spawn worker in UDS mode + console.log('1. Spawning remote C++ Taskflow Daemon'); + const worker = spawn(EXE, ['worker', '--uds', CPP_UDS_ARG, '--daemon'], { stdio: 'inherit' }); + + // Give the daemon a moment to boot + console.log('2. Connecting net.Socket with retries...'); + + let socket; + for (let i = 0; i < 15; i++) { + try { + await new Promise((resolve, reject) => { + if (IS_WIN) { + socket = net.connect({ port: 4000, host: '127.0.0.1' }); + } else { + socket = net.connect(CPP_UDS_ARG); + } + socket.once('connect', resolve); + socket.once('error', reject); + }); + console.log(' ✅ Socket Connected to UDS!'); + break; + } catch (e) { + if (i === 14) throw e; + await new Promise(r => setTimeout(r, 500)); + } + } + + const collector = createCollector(); + let buffer = Buffer.alloc(0); + + // Buffer framing logic (length-prefixed streaming) + socket.on('data', (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + while (buffer.length >= 4) { + const len = buffer.readUInt32LE(0); + if (buffer.length >= 4 + len) { + const payload = buffer.toString('utf8', 4, 4 + len); + buffer = buffer.subarray(4 + len); + try { + const msg = JSON.parse(payload); + collector.handler(msg); + } catch (e) { + console.error("JSON PARSE ERROR:", e, payload); + } + } else { + break; // Wait for more chunks + } + } + }); + + // 3. Send Gridsearch payload + const sampleConfig = JSON.parse( + readFileSync(resolve(__dirname, '..', 'config', 'gridsearch-bcn-universities.json'), 'utf8') + ); + + sampleConfig.configPath = resolve(__dirname, '..', 'config', 'postgres.toml'); + sampleConfig.jobId = 'uds-test-cancel-abc'; + + console.log('3. Writing serialized IPC Payload over pipe...'); + const jsonStr = JSON.stringify(sampleConfig); + const lenBuf = Buffer.alloc(4); + lenBuf.writeUInt32LE(Buffer.byteLength(jsonStr)); + socket.write(lenBuf); + socket.write(jsonStr); + + // Send cancellation after 5 seconds + if (TEST_CANCEL) { + setTimeout(() => { + console.log('\n\n--> Testing Dynamic Cancellation (Sending cancel event for uds-test-cancel-abc)...'); + const cancelPayload = JSON.stringify({ action: "cancel", jobId: "uds-test-cancel-abc" }); + const cancelLenBuf = Buffer.alloc(4); + cancelLenBuf.writeUInt32LE(Buffer.byteLength(cancelPayload)); + socket.write(cancelLenBuf); + socket.write(cancelPayload); + }, 5000); + } + + // 4. Wait for pipeline completion (job_result event) or timeout + console.log('\n4. Awaiting multi-threaded Execution Pipeline (can take minutes)...\n'); + + await new Promise((resolve) => { + collector.onComplete = () => { + // Send stop command to gracefully shut down the daemon + console.log(' 📤 Sending stop command to daemon...'); + const stopPayload = JSON.stringify({ action: 'stop' }); + const stopLen = Buffer.alloc(4); + stopLen.writeUInt32LE(Buffer.byteLength(stopPayload)); + socket.write(stopLen); + socket.write(stopPayload); + setTimeout(resolve, 1000); // Give daemon a moment to ack + }; + + // Safety timeout + setTimeout(() => { + console.log('\n ⏰ Timeout reached (120s) — forcing shutdown.'); + resolve(); + }, 120000); + }); + + console.log('\n\n5. Event summary'); + for (const [k, v] of Object.entries(collector.events)) { + console.log(` ${k}: ${v.length}`); + } + + // Assertions + const ev = collector.events; + assert(ev['grid-ready'].length === 1, 'grid-ready emitted once'); + assert(ev['waypoint-start'].length > 0, 'waypoint-start events received'); + assert(ev['location'].length > 0, 'location events received'); + assert(ev['enrich-start'].length === 1, 'enrich-start emitted once'); + assert(ev['job_result'].length === 1, 'job_result emitted once'); + + // Check enrichment skip log (if present in log events) + const logEvents = ev['log'] ?? []; + const skipLog = logEvents.find(l => + typeof l.data === 'string' && l.data.includes('already enriched') + ); + const nodeCount = ev['node'].length + ev['node-error'].length; + if (skipLog) { + console.log(` ℹ️ Pre-enrich skip detected: ${skipLog.data}`); + assert(nodeCount === 0, 'no enrichment needed (all skipped)'); + } else { + console.log(' ℹ️ No pre-enrich skips (all locations are new or unenriched)'); + assert(nodeCount > 0, 'enrichment node events received'); + } + + // Check filterTypes assertions: all locations must have website + matching type + const FILTER_TYPE = 'Recycling center'; + const locations = ev['location']; + const badWebsite = locations.filter(l => { + const loc = l.data?.location; + return !loc?.website; + }); + + assert(badWebsite.length === 0, `all locations have website (${badWebsite.length} missing)`); + + const badType = locations.filter(l => { + const loc = l.data?.location; + const types = loc?.types ?? []; + const type = loc?.type ?? ''; + return !types.includes(FILTER_TYPE) && type !== FILTER_TYPE; + }); + if (badType.length > 0) { + console.log(` ❌ Mismatched locations:`); + badType.slice(0, 3).forEach(l => console.log(JSON.stringify(l.data?.location, null, 2))); + } + assert(badType.length === 0, `all locations match type "${FILTER_TYPE}" (${badType.length} mismatched)`); + + const filterLog = logEvents.find(l => + typeof l.data === 'string' && l.data.includes('locations removed') + ); + if (filterLog) { + console.log(` ℹ️ Filter applied: ${filterLog.data}`); + } + + const filterTypesLog = logEvents.filter(l => + typeof l.data === 'string' && (l.data.includes('filterTypes:') || l.data.includes(' - ')) + ); + if (filterTypesLog.length > 0) { + console.log(` ℹ️ Parsed filterTypes in C++:`); + filterTypesLog.forEach(l => console.log(` ${l.data}`)); + } + + console.log(` ℹ️ Locations after filter: ${locations.length}`); + + console.log('6. Cleanup'); + socket.destroy(); + worker.kill('SIGTERM'); + + console.log(`\n────────────────────────────────`); + console.log(` Passed: ${passed} Failed: ${failed}`); + console.log(`────────────────────────────────`); + process.exit(failed > 0 ? 1 : 0); +} + +run().catch(e => { + console.error(e); + process.exit(1); +}); diff --git a/packages/media/cpp/orchestrator/test-gridsearch-ipc.mjs b/packages/media/cpp/orchestrator/test-gridsearch-ipc.mjs new file mode 100644 index 00000000..13c93060 --- /dev/null +++ b/packages/media/cpp/orchestrator/test-gridsearch-ipc.mjs @@ -0,0 +1,204 @@ +/** + * orchestrator/test-gridsearch-ipc.mjs + * + * E2E test: spawn the C++ worker, send a gridsearch request + * matching `npm run gridsearch:enrich` defaults, collect IPC events, + * and verify the full event sequence. + * + * Run: node orchestrator/test-gridsearch-ipc.mjs + * Needs: npm run build-debug (or npm run build) + */ + +import { spawnWorker } from './spawn.mjs'; +import { resolve, dirname } from 'node:path'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import fs from 'node:fs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const IS_WIN = process.platform === 'win32'; +const EXE_NAME = IS_WIN ? 'polymech-cli.exe' : 'polymech-cli'; + +const EXE = resolve(__dirname, '..', 'dist', EXE_NAME); +if (!fs.existsSync(EXE)) { + console.error(`❌ No ${EXE_NAME} found in dist. Run npm run build first.`); + process.exit(1); +} +console.log(`Binary: ${EXE}\n`); + +// Load the sample settings (same as gridsearch:enrich) +const sampleConfig = JSON.parse( + readFileSync(resolve(__dirname, '..', 'config', 'gridsearch-sample.json'), 'utf8') +); + +let passed = 0; +let failed = 0; + +function assert(condition, label) { + if (condition) { + console.log(` ✅ ${label}`); + passed++; + } else { + console.error(` ❌ ${label}`); + failed++; + } +} + +// ── Event collector ───────────────────────────────────────────────────────── + +const EXPECTED_EVENTS = [ + 'grid-ready', + 'waypoint-start', + 'area', + 'location', + 'enrich-start', + 'node', + 'nodePage', + // 'node-error' — may or may not occur, depends on network +]; + +function createCollector() { + const events = {}; + for (const t of ['grid-ready', 'waypoint-start', 'area', 'location', + 'enrich-start', 'node', 'node-error', 'nodePage']) { + events[t] = []; + } + return { + events, + handler(msg) { + const t = msg.type; + if (events[t]) { + events[t].push(msg); + } else { + events[t] = [msg]; + } + // Live progress indicator + const d = msg.payload ?? {}; + if (t === 'waypoint-start') { + process.stdout.write(`\r 🔍 Searching waypoint ${(d.index ?? 0) + 1}/${d.total ?? '?'}...`); + } else if (t === 'node') { + process.stdout.write(`\r 📧 Enriched: ${d.title?.substring(0, 40) ?? ''} `); + } else if (t === 'node-error') { + process.stdout.write(`\r ⚠️ Error: ${d.node?.title?.substring(0, 40) ?? ''} `); + } + }, + }; +} + +// ── Main test ─────────────────────────────────────────────────────────────── + +async function run() { + console.log('🧪 Gridsearch IPC E2E Test\n'); + + // ── 1. Spawn worker ─────────────────────────────────────────────────── + console.log('1. Spawn worker'); + const worker = spawnWorker(EXE); + const readyMsg = await worker.ready; + assert(readyMsg.type === 'ready', 'Worker sends ready signal'); + + // ── 2. Register event collector ─────────────────────────────────────── + const collector = createCollector(); + worker.onEvent(collector.handler); + + // ── 3. Send gridsearch request (matching gridsearch:enrich) ──────────── + console.log('2. Send gridsearch request (Aruba / recycling / --enrich)'); + const t0 = Date.now(); + + // Very long timeout — enrichment can take minutes + const result = await worker.request( + { + type: 'gridsearch', + payload: { + ...sampleConfig, + enrich: true, + }, + }, + 5 * 60 * 1000 // 5 min timeout + ); + + const elapsed = ((Date.now() - t0) / 1000).toFixed(1); + console.log(`\n\n ⏱️ Completed in ${elapsed}s\n`); + + // ── 4. Verify final result ──────────────────────────────────────────── + console.log('3. Verify job_result'); + assert(result.type === 'job_result', `Response type is "job_result" (got "${result.type}")`); + + const summary = result.payload ?? null; + assert(summary !== null, 'job_result payload is present'); + + if (summary) { + assert(typeof summary.totalMs === 'number', `totalMs is number (${summary.totalMs})`); + assert(typeof summary.searchMs === 'number', `searchMs is number (${summary.searchMs})`); + assert(typeof summary.enrichMs === 'number', `enrichMs is number (${summary.enrichMs})`); + assert(typeof summary.freshApiCalls === 'number', `freshApiCalls is number (${summary.freshApiCalls})`); + assert(typeof summary.waypointCount === 'number', `waypointCount is number (${summary.waypointCount})`); + assert(summary.gridStats && typeof summary.gridStats.validCells === 'number', 'gridStats.validCells present'); + assert(summary.searchStats && typeof summary.searchStats.totalResults === 'number', 'searchStats.totalResults present'); + assert(typeof summary.enrichedOk === 'number', `enrichedOk is number (${summary.enrichedOk})`); + assert(typeof summary.enrichedTotal === 'number', `enrichedTotal is number (${summary.enrichedTotal})`); + } + + // ── 5. Verify event sequence ────────────────────────────────────────── + console.log('4. Verify event stream'); + const e = collector.events; + + assert(e['grid-ready'].length === 1, `Exactly 1 grid-ready event (got ${e['grid-ready'].length})`); + assert(e['waypoint-start'].length > 0, `At least 1 waypoint-start event (got ${e['waypoint-start'].length})`); + assert(e['area'].length > 0, `At least 1 area event (got ${e['area'].length})`); + assert(e['waypoint-start'].length === e['area'].length, `waypoint-start count (${e['waypoint-start'].length}) === area count (${e['area'].length})`); + assert(e['enrich-start'].length === 1, `Exactly 1 enrich-start event (got ${e['enrich-start'].length})`); + + const totalNodes = e['node'].length + e['node-error'].length; + assert(totalNodes > 0, `At least 1 node event (got ${totalNodes}: ${e['node'].length} ok, ${e['node-error'].length} errors)`); + + // Validate grid-ready payload + if (e['grid-ready'].length > 0) { + const gr = e['grid-ready'][0].payload ?? {}; + assert(Array.isArray(gr.areas), 'grid-ready.areas is array'); + assert(typeof gr.total === 'number' && gr.total > 0, `grid-ready.total > 0 (${gr.total})`); + } + + // Validate location events have required fields + if (e['location'].length > 0) { + const loc = e['location'][0].payload ?? {}; + assert(loc.location && typeof loc.location.title === 'string', 'location event has location.title'); + assert(loc.location && typeof loc.location.place_id === 'string', 'location event has location.place_id'); + assert(typeof loc.areaName === 'string', 'location event has areaName'); + } + assert(e['location'].length > 0, `At least 1 location event (got ${e['location'].length})`); + + // Validate node payloads + if (e['node'].length > 0) { + const nd = e['node'][0].payload ?? {}; + assert(typeof nd.placeId === 'string', 'node event has placeId'); + assert(typeof nd.title === 'string', 'node event has title'); + assert(Array.isArray(nd.emails), 'node event has emails array'); + assert(typeof nd.status === 'string', 'node event has status'); + } + + // ── 6. Print event summary ──────────────────────────────────────────── + console.log('\n5. Event summary'); + for (const [type, arr] of Object.entries(e)) { + if (arr.length > 0) console.log(` ${type}: ${arr.length}`); + } + + // ── 7. Shutdown ─────────────────────────────────────────────────────── + console.log('\n6. Graceful shutdown'); + const shutdownRes = await worker.shutdown(); + assert(shutdownRes.type === 'shutdown_ack', 'Shutdown acknowledged'); + + await new Promise(r => setTimeout(r, 500)); + assert(worker.process.exitCode === 0, `Worker exited with code 0 (got ${worker.process.exitCode})`); + + // ── Summary ─────────────────────────────────────────────────────────── + console.log(`\n────────────────────────────────`); + console.log(` Passed: ${passed} Failed: ${failed}`); + console.log(`────────────────────────────────\n`); + + process.exit(failed > 0 ? 1 : 0); +} + +run().catch((err) => { + console.error('Test runner error:', err); + process.exit(1); +}); diff --git a/packages/media/cpp/orchestrator/test-ipc-classifier.mjs b/packages/media/cpp/orchestrator/test-ipc-classifier.mjs new file mode 100644 index 00000000..d8c473ce --- /dev/null +++ b/packages/media/cpp/orchestrator/test-ipc-classifier.mjs @@ -0,0 +1,802 @@ +/** + * orchestrator/test-ipc-classifier.mjs + * + * IPC + local llama: one kbot-ai call — semantic distance from anchor "machine workshop" + * to every business label (JobViewer.tsx ~205). Output is a single JSON array (+ meta). + * + * Run: npm run test:ipc:classifier + * CLI (overrides env): yargs — see parseClassifierArgv() + * npm run test:ipc:classifier -- --help + * npm run test:ipc:classifier -- --provider openrouter --model openai/gpt-4o-mini --backend remote -n 3 + * npm run test:ipc:classifier -- -r openrouter -m openai/gpt-4o-mini --backend remote -n 3 -F structured + * npm run test:ipc:classifier -- -r openrouter -m x -F stress,no-heartbeat + * npm run test:ipc:classifier -- -r openrouter -m x --backend remote -n 3 -F stress,structured + * npm run test:ipc:classifier -- -r openrouter -m x --backend remote -F structured --dst ./out.json + * + * Env: + * KBOT_IPC_CLASSIFIER_LLAMA — set 0 to use OpenRouter (KBOT_ROUTER, KBOT_IPC_MODEL) instead of local llama :8888 + * KBOT_IPC_LLAMA_AUTOSTART — 0 to skip spawning run-7b.sh (llama mode only) + * KBOT_ROUTER / KBOT_IPC_MODEL — when classifier llama is off (same as test-ipc step 6) + * KBOT_CLASSIFIER_LIMIT — max labels in the batch (default: all) + * KBOT_CLASSIFIER_TIMEOUT_MS — single batched kbot-ai call (default: 300000) + * + * OpenRouter: npm run test:ipc:classifier:openrouter (sets KBOT_IPC_CLASSIFIER_LLAMA=0) + * Stress (batch repeats, one worker): KBOT_CLASSIFIER_STRESS_RUNS=N (default 1) + * npm run test:ipc:classifier:openrouter:stress → OpenRouter + 5 runs (override N via env) + * + * Reports (reports.js): cwd/tests/test-ipc-classifier__HH-mm.{json,md}; distances in + * test-ipc-classifier-distances__HH-mm.json (same timestamp as the main JSON). + * With -F structured, the prompt asks for {"items":[...]} to match json_object APIs. + */ + +import { spawn } from 'node:child_process'; +import { mkdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import net from 'node:net'; +import { existsSync, unlinkSync } from 'node:fs'; +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; + +import { + distExePath, + platform, + uds, + timeouts, + kbotAiPayloadLlamaLocal, + kbotAiPayloadFromEnv, + ensureLlamaLocalServer, + llama, + router, +} from './presets.js'; +import { + createAssert, + payloadObj, + llamaAutostartEnabled, + ipcClassifierLlamaEnabled, + createIpcClient, + pipeWorkerStderr, +} from './test-commons.js'; +import { + reportFilePathWithExt, + timeParts, + createMetricsCollector, + buildMetricsBundle, + writeTestReports, +} from './reports.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +/** Set at run start; used by catch for error reports */ +let classifierMetricsCollector = null; +let classifierRunStartedAt = null; +/** Feature flags from `-F` / `--feature` (stress, structured, no-heartbeat, no-report, quiet) */ +let classifierFeatures = /** @type {Set} */ (new Set()); +/** Parsed argv (after yargs); set in parseClassifierArgv */ +let classifierArgv = /** @type {Record | null} */ (null); + +/** + * @param {unknown} featureOpt + * @returns {Set} + */ +function parseFeatureList(featureOpt) { + const out = []; + const arr = Array.isArray(featureOpt) ? featureOpt : []; + for (const f of arr) { + if (typeof f === 'string') out.push(...f.split(',').map((s) => s.trim()).filter(Boolean)); + } + return new Set(out); +} + +/** + * Parse CLI and apply to `process.env` (CLI wins over prior env). + * @returns {Record & { featuresSet: Set }} + */ +export function parseClassifierArgv() { + const y = yargs(hideBin(process.argv)) + .scriptName('test-ipc-classifier') + .usage('$0 [options]\n\nIPC classifier batch test. Flags override env vars for this process.') + .option('provider', { + alias: 'r', + type: 'string', + describe: 'Router / provider → KBOT_ROUTER (e.g. openrouter, ollama, openai)', + }) + .option('model', { + alias: 'm', + type: 'string', + describe: 'Model id → KBOT_IPC_MODEL', + }) + .option('runs', { + alias: 'n', + type: 'number', + describe: 'Batch repeats (stress) → KBOT_CLASSIFIER_STRESS_RUNS', + }) + .option('limit', { + alias: 'l', + type: 'number', + describe: 'Max labels → KBOT_CLASSIFIER_LIMIT', + }) + .option('timeout', { + alias: 't', + type: 'number', + describe: 'LLM HTTP timeout ms → KBOT_CLASSIFIER_TIMEOUT_MS', + }) + .option('backend', { + type: 'string', + choices: ['local', 'remote'], + describe: 'local = llama :8888; remote = router (sets KBOT_IPC_CLASSIFIER_LLAMA=0)', + }) + .option('no-autostart', { + type: 'boolean', + default: false, + describe: 'Do not spawn run-7b.sh → KBOT_IPC_LLAMA_AUTOSTART=0', + }) + .option('feature', { + alias: 'F', + type: 'array', + default: [], + describe: + 'Feature flags (repeat or comma-separated): stress, structured, no-heartbeat, no-report, quiet', + }) + .option('dst', { + type: 'string', + describe: + 'Forwarded to kbot-ai IPC `dst` (worker writes completion text here; path resolved from cwd). Same as C++ --dst.', + }) + .option('output', { + type: 'string', + describe: + 'Forwarded to IPC if --dst omitted (C++ `output` field). Prefer --dst when both are set.', + }) + .strict() + .help() + .alias('h', 'help'); + + const argv = y.parseSync(); + const featuresSet = parseFeatureList(argv.feature); + + if (argv.provider != null && String(argv.provider).trim() !== '') { + process.env.KBOT_ROUTER = String(argv.provider).trim(); + } + if (argv.model != null && String(argv.model).trim() !== '') { + process.env.KBOT_IPC_MODEL = String(argv.model).trim(); + } + if (argv.runs != null && Number.isFinite(argv.runs) && argv.runs >= 1) { + process.env.KBOT_CLASSIFIER_STRESS_RUNS = String(Math.min(500, Math.floor(Number(argv.runs)))); + } + if (argv.limit != null && Number.isFinite(argv.limit) && argv.limit >= 1) { + process.env.KBOT_CLASSIFIER_LIMIT = String(Math.floor(Number(argv.limit))); + } + if (argv.timeout != null && Number.isFinite(argv.timeout) && argv.timeout > 0) { + process.env.KBOT_CLASSIFIER_TIMEOUT_MS = String(Math.floor(Number(argv.timeout))); + } + if (argv['no-autostart'] === true) { + process.env.KBOT_IPC_LLAMA_AUTOSTART = '0'; + } + if (argv.backend === 'remote') { + process.env.KBOT_IPC_CLASSIFIER_LLAMA = '0'; + } else if (argv.backend === 'local') { + delete process.env.KBOT_IPC_CLASSIFIER_LLAMA; + } + + if (featuresSet.has('stress') && (argv.runs == null || !Number.isFinite(argv.runs))) { + if (!process.env.KBOT_CLASSIFIER_STRESS_RUNS) { + process.env.KBOT_CLASSIFIER_STRESS_RUNS = '5'; + } + } + + classifierFeatures = featuresSet; + const out = { ...argv, featuresSet }; + classifierArgv = out; + return out; +} +const EXE = distExePath(__dirname); +const stats = createAssert(); +const { assert } = stats; + +/** @see packages/kbot/.../JobViewer.tsx — business type options */ +export const JOB_VIEWER_MACHINE_LABELS = [ + '3D printing service', + 'Drafting service', + 'Engraver', + 'Furniture maker', + 'Industrial engineer', + 'Industrial equipment supplier', + 'Laser cutting service', + 'Machine construction', + 'Machine repair service', + 'Machine shop', + 'Machine workshop', + 'Machinery parts manufacturer', + 'Machining manufacturer', + 'Manufacturer', + 'Mechanic', + 'Mechanical engineer', + 'Mechanical plant', + 'Metal fabricator', + 'Metal heat treating service', + 'Metal machinery supplier', + 'Metal working shop', + 'Metal workshop', + 'Novelty store', + 'Plywood supplier', + 'Sign shop', + 'Tool manufacturer', + 'Trophy shop', +]; + +const ANCHOR = 'machine workshop'; + +/** Keys we accept for the batch array when API forces a JSON object (e.g. response_format json_object). */ +const BATCH_ARRAY_OBJECT_KEYS = ['items', 'distances', 'results', 'data', 'labels', 'rows']; + +/** Build one prompt: plain mode = JSON array root; structured (-F structured) = JSON object with "items" (json_object API). */ +function classifierBatchPrompt(labels) { + const numbered = labels.map((l, i) => `${i + 1}. ${JSON.stringify(l)}`).join('\n'); + const structured = classifierFeatures.has('structured'); + + const rules = `Rules for each element: +- Use shape: {"label": , "distance": } +- "distance" is semantic distance from 0 (same as anchor or direct synonym) to 10 (unrelated). One decimal allowed. +- Include EXACTLY one object per line item below, in the SAME ORDER, with "label" copied character-for-character from the list. + +Anchor business type: ${ANCHOR} + +Candidate labels (in order): +${numbered}`; + + if (structured) { + return `You classify business types against one anchor. Output ONLY valid JSON: one object, no markdown fences, no commentary. +The API requires a JSON object (not a top-level array). Use exactly one top-level key "items" whose value is the array. + +${rules} + +Example: {"items":[{"label":"Example","distance":2.5},...]}`; + } + + return `You classify business types against one anchor. Output ONLY a JSON array, no markdown fences, no commentary. + +${rules} + +Output: one JSON array, e.g. [{"label":"...","distance":2.5},...]`; +} + +/** + * Parse model text into the batch array: root [...] or {"items":[...]} (json_object). + * @returns {unknown[] | null} + */ +function extractJsonArray(text) { + if (!text || typeof text !== 'string') return null; + let s = text.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/u, '').trim(); + + /** @param {unknown} v */ + const arrayFromParsed = (v) => { + if (Array.isArray(v)) return v; + if (!v || typeof v !== 'object') return null; + const o = /** @type {Record} */ (v); + for (const key of BATCH_ARRAY_OBJECT_KEYS) { + const a = o[key]; + if (Array.isArray(a)) return a; + } + for (const val of Object.values(o)) { + if ( + Array.isArray(val) && + val.length > 0 && + val[0] && + typeof val[0] === 'object' && + val[0] !== null && + 'label' in val[0] + ) { + return val; + } + } + return null; + }; + + try { + const v = JSON.parse(s); + return arrayFromParsed(v); + } catch { + /* fall through */ + } + + const i = s.indexOf('['); + const j = s.lastIndexOf(']'); + if (i >= 0 && j > i) { + try { + const v = JSON.parse(s.slice(i, j + 1)); + if (Array.isArray(v)) return v; + } catch { + /* ignore */ + } + } + + const oi = s.indexOf('{'); + const oj = s.lastIndexOf('}'); + if (oi >= 0 && oj > oi) { + try { + const v = JSON.parse(s.slice(oi, oj + 1)); + return arrayFromParsed(v); + } catch { + /* ignore */ + } + } + + return null; +} + +/** + * @param {unknown[]} arr + * @param {string[]} expectedLabels — ordered + */ +function normalizeBatchArray(arr, expectedLabels) { + const expectedSet = new Set(expectedLabels); + const byLabel = new Map(); + + for (const item of arr) { + if (!item || typeof item !== 'object') continue; + const label = item.label; + let d = item.distance; + if (typeof d === 'string') d = parseFloat(d); + if (typeof label !== 'string' || typeof d !== 'number' || !Number.isFinite(d)) continue; + if (!expectedSet.has(label)) continue; + byLabel.set(label, d); + } + + const distances = expectedLabels.map((label) => ({ + label, + distance: byLabel.has(label) ? byLabel.get(label) : null, + })); + + const missing = distances.filter((r) => r.distance == null).map((r) => r.label); + return { distances, missing }; +} + +function batchTimeoutMs() { + const raw = process.env.KBOT_CLASSIFIER_TIMEOUT_MS; + if (raw === undefined || raw === '') return 30_000; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : 30_000; +} + +/** Sequential batch iterations on one worker (stress). Default 1 = single run. */ +function stressRunCount() { + const raw = process.env.KBOT_CLASSIFIER_STRESS_RUNS; + if (raw === undefined || raw === '') return 1; + const n = Number.parseInt(String(raw).trim(), 10); + if (!Number.isFinite(n) || n < 1) return 1; + return Math.min(n, 500); +} + +/** @param {unknown} llm — job_result.llm from kbot-ai */ +function usageTokens(llm) { + if (!llm || typeof llm !== 'object') return null; + const u = /** @type {Record} */ (llm).usage; + if (!u || typeof u !== 'object') return null; + const o = /** @type {Record} */ (u); + return { + prompt: o.prompt_tokens ?? o.promptTokens ?? null, + completion: o.completion_tokens ?? o.completionTokens ?? null, + total: o.total_tokens ?? o.totalTokens ?? null, + }; +} + +/** @param {number[]} values */ +function summarizeMs(values) { + if (values.length === 0) return null; + const sorted = [...values].sort((a, b) => a - b); + const sum = values.reduce((a, b) => a + b, 0); + const mid = (a, b) => (a + b) / 2; + const p = (q) => sorted[Math.min(sorted.length - 1, Math.max(0, Math.floor(q * (sorted.length - 1))))]; + return { + min: sorted[0], + max: sorted[sorted.length - 1], + avg: Math.round((sum / values.length) * 100) / 100, + p50: sorted.length % 2 ? sorted[Math.floor(sorted.length / 2)] : mid(sorted[sorted.length / 2 - 1], sorted[sorted.length / 2]), + p95: p(0.95), + }; +} + +/** Log progress while awaiting a long LLM call (no silent hang). */ +function withHeartbeat(promise, ipcTimeoutMs, backendLabel) { + const every = 10_000; + let n = 0; + const id = setInterval(() => { + n += 1; + const sec = (n * every) / 1000; + console.log( + ` … still waiting on ${backendLabel} (batch is large; ${sec}s elapsed, IPC deadline ${Math.round(ipcTimeoutMs / 1000)}s)…` + ); + }, every); + return promise.finally(() => clearInterval(id)); +} + +function buildKbotAiPayload(labels, tmo) { + const prompt = classifierBatchPrompt(labels); + const useLlama = ipcClassifierLlamaEnabled(); + const structured = classifierFeatures.has('structured'); + + if (useLlama) { + return { ...kbotAiPayloadLlamaLocal({ prompt }), llm_timeout_ms: tmo }; + } + + const payload = { + ...kbotAiPayloadFromEnv(), + prompt, + llm_timeout_ms: tmo, + }; + /** OpenAI-style structured outputs; forwarded by kbot LLMClient → liboai ChatCompletion. */ + if (structured) { + payload.response_format = { type: 'json_object' }; + } + + const rawDst = classifierArgv?.dst || classifierArgv?.output; + if (rawDst != null && String(rawDst).trim() !== '') { + payload.dst = path.resolve(process.cwd(), String(rawDst).trim()); + } + + return payload; +} + +/** + * Parse kbot-ai job_result; updates assertion stats. + * @returns {{ distances: {label:string,distance:number|null}[], missing: string[], parseError: string|null, rawText: string|null, batchOk: boolean }} + */ +function processBatchResponse(p, labels) { + let rawText = null; + let distances = []; + let parseError = null; + let missing = []; + let batchOk = false; + + if (p?.status === 'success' && typeof p?.text === 'string') { + rawText = p.text; + const arr = extractJsonArray(p.text); + if (arr) { + const norm = normalizeBatchArray(arr, labels); + distances = norm.distances; + missing = norm.missing; + if (missing.length === 0) { + assert(true, 'batch JSON array: all labels have distance'); + batchOk = true; + } else { + assert(false, `batch array complete (${missing.length} missing labels)`); + parseError = `missing: ${missing.join('; ')}`; + } + } else { + assert(false, 'batch response parses as JSON array or {"items":[...]}'); + parseError = 'could not parse batch array from model text'; + } + } else { + assert(false, 'kbot-ai success'); + parseError = p?.error ?? 'not success'; + } + + return { distances, missing, parseError, rawText, batchOk }; +} + +async function runSingleBatch(ipc, labels, tmo, ipcDeadlineMs, waitLabel) { + const payload = buildKbotAiPayload(labels, tmo); + const t0 = performance.now(); + const pending = ipc.request({ type: 'kbot-ai', payload }, ipcDeadlineMs); + const msg = classifierFeatures.has('no-heartbeat') + ? await pending + : await withHeartbeat(pending, ipcDeadlineMs, waitLabel); + const elapsedMs = Math.round(performance.now() - t0); + const p = payloadObj(msg); + const parsed = processBatchResponse(p, labels); + return { elapsedMs, p, ...parsed }; +} + +async function run() { + const quiet = classifierFeatures.has('quiet'); + classifierMetricsCollector = createMetricsCollector(); + classifierRunStartedAt = new Date().toISOString(); + const startedAt = classifierRunStartedAt; + const useLlama = ipcClassifierLlamaEnabled(); + const backendLabel = useLlama ? `llama @ :${llama.port}` : `router=${router.fromEnv()}`; + if (!quiet) { + console.log(`\n📐 IPC classifier (${backendLabel}) — one batch, distance vs "machine workshop"\n`); + if (classifierFeatures.has('structured')) { + if (useLlama) { + console.log( + ` ⚠️ -F structured: ignored for local llama (use --backend remote for response_format json_object)\n` + ); + } else { + console.log( + ` Structured: response_format json_object + prompt asks for {"items":[...]} (not a top-level array)\n` + ); + } + } + } + + if (!existsSync(EXE)) { + console.error(`❌ Binary not found at ${EXE}`); + process.exit(1); + } + + if (useLlama) { + await ensureLlamaLocalServer({ + autostart: llamaAutostartEnabled(), + startTimeoutMs: timeouts.llamaServerStart, + }); + } + + const limitRaw = process.env.KBOT_CLASSIFIER_LIMIT; + let labels = [...JOB_VIEWER_MACHINE_LABELS]; + if (limitRaw !== undefined && limitRaw !== '') { + const lim = Number.parseInt(limitRaw, 10); + if (Number.isFinite(lim) && lim > 0) labels = labels.slice(0, lim); + } + + const CPP_UDS_ARG = uds.workerArg(); + if (!platform.isWin && existsSync(CPP_UDS_ARG)) { + unlinkSync(CPP_UDS_ARG); + } + + const workerProc = spawn(EXE, ['worker', '--uds', CPP_UDS_ARG], { stdio: 'pipe' }); + pipeWorkerStderr(workerProc); + + let socket; + for (let i = 0; i < timeouts.connectAttempts; i++) { + try { + await new Promise((res, rej) => { + socket = net.connect(uds.connectOpts(CPP_UDS_ARG)); + socket.once('connect', res); + socket.once('error', rej); + }); + break; + } catch (e) { + if (i === timeouts.connectAttempts - 1) throw e; + await new Promise((r) => setTimeout(r, timeouts.connectRetryMs)); + } + } + + const ipc = createIpcClient(socket); + ipc.attach(); + await ipc.readyPromise; + + const tmo = batchTimeoutMs(); + const ipcDeadlineMs = tmo + 60_000; + const waitLabel = useLlama ? 'llama' : router.fromEnv(); + const nRuns = stressRunCount(); + + if (!quiet) { + console.log(` kbot-ai batch: ${labels.length} labels × ${nRuns} run(s)`); + console.log(` liboai HTTP timeout: ${tmo} ms (llm_timeout_ms) — rebuild kbot if this was stuck at ~30s before`); + console.log(` IPC wait deadline: ${ipcDeadlineMs} ms (HTTP + margin)`); + const hb = classifierFeatures.has('no-heartbeat') ? 'off' : '15s'; + console.log(` (Large batches can take many minutes; heartbeat ${hb}…)\n`); + } + + /** @type {Array<{ index: number, wallMs: number, batchOk: boolean, parseError: string|null, usage: ReturnType}>} */ + const stressIterations = []; + + let lastP = /** @type {Record|null} */ (null); + let lastDistances = []; + let lastRawText = null; + let lastParseError = null; + let lastByDistance = []; + + for (let r = 0; r < nRuns; r++) { + if (nRuns > 1 && !quiet) { + console.log(` ── Stress run ${r + 1}/${nRuns} ──`); + } + const batch = await runSingleBatch(ipc, labels, tmo, ipcDeadlineMs, waitLabel); + lastP = batch.p; + lastDistances = batch.distances; + lastRawText = batch.rawText; + lastParseError = batch.parseError; + lastByDistance = [...batch.distances].sort((a, b) => { + if (a.distance == null && b.distance == null) return 0; + if (a.distance == null) return 1; + if (b.distance == null) return -1; + return a.distance - b.distance; + }); + + const u = usageTokens(batch.p?.llm); + stressIterations.push({ + index: r + 1, + wallMs: batch.elapsedMs, + batchOk: batch.batchOk, + parseError: batch.parseError, + usage: u, + }); + + if (nRuns > 1 && !quiet) { + const tok = u + ? `tokens p/c/t ${u.prompt ?? '—'}/${u.completion ?? '—'}/${u.total ?? '—'}` + : 'tokens —'; + console.log(` wall: ${batch.elapsedMs} ms ${batch.batchOk ? 'OK' : 'FAIL'} ${tok}`); + } + } + + const wallMsList = stressIterations.map((x) => x.wallMs); + /** @type {null | { requestedRuns: number, wallMs: NonNullable>, successCount: number, failCount: number, totalPromptTokens: number, totalCompletionTokens: number, totalTokens: number }} */ + let stressSummary = null; + if (nRuns > 1) { + const w = summarizeMs(wallMsList); + stressSummary = { + requestedRuns: nRuns, + wallMs: /** @type {NonNullable} */ (w), + successCount: stressIterations.filter((x) => x.batchOk).length, + failCount: stressIterations.filter((x) => !x.batchOk).length, + totalPromptTokens: stressIterations.reduce((s, x) => s + (Number(x.usage?.prompt) || 0), 0), + totalCompletionTokens: stressIterations.reduce((s, x) => s + (Number(x.usage?.completion) || 0), 0), + totalTokens: stressIterations.reduce((s, x) => s + (Number(x.usage?.total) || 0), 0), + }; + if (quiet) { + console.log( + `stress ${nRuns} runs: min=${stressSummary.wallMs.min}ms max=${stressSummary.wallMs.max}ms avg=${stressSummary.wallMs.avg}ms ok=${stressSummary.successCount}/${nRuns} tokensΣ=${stressSummary.totalTokens}` + ); + } else { + console.log(`\n ═══════════════ Stress summary (${nRuns} batch runs) ═══════════════`); + console.log( + ` Wall time (ms): min ${stressSummary.wallMs.min} max ${stressSummary.wallMs.max} avg ${stressSummary.wallMs.avg} p50 ${stressSummary.wallMs.p50} p95 ${stressSummary.wallMs.p95}` + ); + console.log( + ` Batches OK: ${stressSummary.successCount} fail: ${stressSummary.failCount} (assertions: passed ${stats.passed} failed ${stats.failed})` + ); + if ( + stressSummary.totalPromptTokens > 0 || + stressSummary.totalCompletionTokens > 0 || + stressSummary.totalTokens > 0 + ) { + console.log( + ` Token totals (sum over runs): prompt ${stressSummary.totalPromptTokens} completion ${stressSummary.totalCompletionTokens} total ${stressSummary.totalTokens}` + ); + } + console.log(` ═══════════════════════════════════════════════════════════════════\n`); + } + } + + const p = lastP; + const distances = lastDistances; + const rawText = lastRawText; + const parseError = lastParseError; + const byDistance = lastByDistance; + + const shutdownRes = await ipc.request({ type: 'shutdown' }, timeouts.ipcDefault); + assert(shutdownRes.type === 'shutdown_ack', 'shutdown ack'); + await new Promise((r) => setTimeout(r, timeouts.postShutdownMs)); + socket.destroy(); + assert(workerProc.exitCode === 0, 'worker exit 0'); + + const finishedAt = new Date().toISOString(); + + const reportNow = new Date(); + const cwd = process.cwd(); + + const reportData = { + startedAt, + finishedAt, + passed: stats.passed, + failed: stats.failed, + ok: stats.failed === 0, + ipcClassifierLlama: useLlama, + cli: { + features: [...classifierFeatures], + provider: process.env.KBOT_ROUTER ?? null, + model: process.env.KBOT_IPC_MODEL ?? null, + backend: useLlama ? 'local' : 'remote', + stressRuns: nRuns, + structuredOutput: !useLlama && classifierFeatures.has('structured'), + dst: + classifierArgv?.dst || classifierArgv?.output + ? path.resolve( + process.cwd(), + String(classifierArgv.dst || classifierArgv.output).trim() + ) + : null, + }, + env: { + KBOT_IPC_CLASSIFIER_LLAMA: process.env.KBOT_IPC_CLASSIFIER_LLAMA ?? null, + KBOT_IPC_LLAMA_AUTOSTART: process.env.KBOT_IPC_LLAMA_AUTOSTART ?? null, + KBOT_ROUTER: process.env.KBOT_ROUTER ?? null, + KBOT_IPC_MODEL: process.env.KBOT_IPC_MODEL ?? null, + KBOT_CLASSIFIER_LIMIT: process.env.KBOT_CLASSIFIER_LIMIT ?? null, + KBOT_CLASSIFIER_TIMEOUT_MS: process.env.KBOT_CLASSIFIER_TIMEOUT_MS ?? null, + KBOT_CLASSIFIER_STRESS_RUNS: process.env.KBOT_CLASSIFIER_STRESS_RUNS ?? null, + KBOT_LLAMA_PORT: process.env.KBOT_LLAMA_PORT ?? null, + KBOT_LLAMA_BASE_URL: process.env.KBOT_LLAMA_BASE_URL ?? null, + }, + metrics: buildMetricsBundle(classifierMetricsCollector, startedAt, finishedAt), + anchor: ANCHOR, + source: 'JobViewer.tsx:205', + batch: true, + backend: useLlama ? 'llama_local' : 'remote_router', + ...(useLlama + ? { + llama: { + baseURL: llama.baseURL, + port: llama.port, + router: llama.router, + model: llama.model, + }, + } + : { + router: router.fromEnv(), + model: process.env.KBOT_IPC_MODEL ?? null, + }), + labelCount: labels.length, + /** Provider metadata from API (usage, model, id, OpenRouter fields) — see LLMClient + kbot `llm` key */ + llm: p?.llm ?? null, + distances, + byDistance, + rawText, + parseError: parseError ?? null, + ...(nRuns > 1 && stressSummary + ? { + stress: { + requestedRuns: nRuns, + iterations: stressIterations, + summary: stressSummary, + }, + } + : {}), + }; + + let jsonPath = ''; + let mdPath = ''; + let arrayPath = ''; + if (!classifierFeatures.has('no-report')) { + try { + const written = await writeTestReports('test-ipc-classifier', reportData, { cwd, now: reportNow }); + jsonPath = written.jsonPath; + mdPath = written.mdPath; + } catch (e) { + console.error(' ⚠️ Failed to write report:', e?.message ?? e); + } + + /** Array-only artifact (same timestamp as main report). */ + arrayPath = reportFilePathWithExt('test-ipc-classifier-distances', '.json', { cwd, now: reportNow }); + await mkdir(path.dirname(arrayPath), { recursive: true }); + await writeFile(arrayPath, `${JSON.stringify(distances, null, 2)}\n`, 'utf8'); + } + + const { label: timeLabel } = timeParts(reportNow); + if (!classifierFeatures.has('quiet')) { + console.log(`\n────────────────────────────────`); + console.log(` Passed: ${stats.passed} Failed: ${stats.failed}`); + if (jsonPath) console.log(` Report JSON: ${jsonPath}`); + if (mdPath) console.log(` Report MD: ${mdPath}`); + if (arrayPath) console.log(` Distances JSON: ${arrayPath}`); + console.log(` Run id: test-ipc-classifier::${timeLabel}`); + console.log(` distances.length: ${distances.length}`); + console.log(`────────────────────────────────\n`); + } else { + console.log( + `done: passed=${stats.passed} failed=${stats.failed} ok=${stats.failed === 0}${jsonPath ? ` json=${jsonPath}` : ''}` + ); + } + + process.exit(stats.failed > 0 ? 1 : 0); +} + +parseClassifierArgv(); +run().catch(async (err) => { + console.error('Classifier error:', err); + if (!classifierFeatures.has('no-report')) { + try { + const finishedAt = new Date().toISOString(); + const c = classifierMetricsCollector ?? createMetricsCollector(); + const started = classifierRunStartedAt ?? finishedAt; + await writeTestReports( + 'test-ipc-classifier', + { + startedAt: started, + finishedAt, + error: String(err?.stack ?? err), + passed: stats.passed, + failed: stats.failed, + ok: false, + ipcClassifierLlama: ipcClassifierLlamaEnabled(), + metrics: buildMetricsBundle(c, started, finishedAt), + }, + { cwd: process.cwd() } + ); + } catch (_) { + /* ignore */ + } + } + process.exit(1); +}); diff --git a/packages/media/cpp/orchestrator/test-ipc.mjs b/packages/media/cpp/orchestrator/test-ipc.mjs new file mode 100644 index 00000000..545085ac --- /dev/null +++ b/packages/media/cpp/orchestrator/test-ipc.mjs @@ -0,0 +1,283 @@ +/** + * orchestrator/test-ipc.mjs + * + * Integration test: spawn the C++ worker in UDS mode, exchange messages, verify responses. + * + * Run: npm run test:ipc + * + * Env: + * KBOT_IPC_LLM — real LLM step is on by default; set to 0 / false / no / off to skip (CI / offline). + * KBOT_ROUTER — router (default: openrouter; same defaults as C++ LLMClient / CLI) + * KBOT_IPC_MODEL — optional model id (e.g. openrouter slug); else C++ default for that router + * KBOT_IPC_PROMPT — custom prompt (default: capital of Germany; asserts "berlin" in reply) + * KBOT_IPC_LLM_LOG_MAX — max chars to print for LLM text (default: unlimited) + * KBOT_IPC_LLAMA — llama :8888 step on by default; set 0/false/no/off to skip + * KBOT_IPC_LLAMA_AUTOSTART — if 0, do not spawn scripts/run-7b.sh when :8888 is closed + * KBOT_LLAMA_* — KBOT_LLAMA_PORT, KBOT_LLAMA_BASE_URL, KBOT_LLAMA_MODEL, KBOT_LLAMA_START_TIMEOUT_MS + * + * Shared: presets.js, test-commons.js, reports.js + * Report: cwd/tests/test-ipc__HH-mm.{json,md} (see reports.js) + */ + +import { spawn } from 'node:child_process'; +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import net from 'node:net'; +import { existsSync, unlinkSync } from 'node:fs'; + +import { + distExePath, + platform, + uds, + timeouts, + kbotAiPayloadFromEnv, + kbotAiPayloadLlamaLocal, + usingDefaultGermanyPrompt, + ensureLlamaLocalServer, +} from './presets.js'; +import { + createAssert, + payloadObj, + logKbotAiResponse, + ipcLlmEnabled, + ipcLlamaEnabled, + llamaAutostartEnabled, + createIpcClient, + pipeWorkerStderr, +} from './test-commons.js'; +import { + createMetricsCollector, + buildMetricsBundle, + writeTestReports, +} from './reports.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const EXE = distExePath(__dirname); +const stats = createAssert(); +const { assert } = stats; + +/** Set at run start for error reports */ +let ipcRunStartedAt = null; +let ipcMetricsCollector = null; +/** `llm` object from kbot-ai job_result (usage, model, OpenRouter extras) — filled in steps 6–7 */ +let ipcKbotAiLlmRouter = null; +let ipcKbotAiLlmLlama = null; + +async function run() { + ipcMetricsCollector = createMetricsCollector(); + ipcRunStartedAt = new Date().toISOString(); + ipcKbotAiLlmRouter = null; + ipcKbotAiLlmLlama = null; + console.log('\n🔧 IPC [UDS] Integration Tests\n'); + + if (!existsSync(EXE)) { + console.error(`❌ Binary not found at ${EXE}`); + process.exit(1); + } + + const CPP_UDS_ARG = uds.workerArg(); + if (!platform.isWin && existsSync(CPP_UDS_ARG)) { + unlinkSync(CPP_UDS_ARG); + } + + // ── 1. Spawn & ready ──────────────────────────────────────────────────── + console.log('1. Spawn worker (UDS mode) and wait for ready signal'); + const workerProc = spawn(EXE, ['worker', '--uds', CPP_UDS_ARG], { stdio: 'pipe' }); + pipeWorkerStderr(workerProc); + + let socket; + for (let i = 0; i < timeouts.connectAttempts; i++) { + try { + await new Promise((res, rej) => { + socket = net.connect(uds.connectOpts(CPP_UDS_ARG)); + socket.once('connect', res); + socket.once('error', rej); + }); + break; + } catch (e) { + if (i === timeouts.connectAttempts - 1) throw e; + await new Promise((r) => setTimeout(r, timeouts.connectRetryMs)); + } + } + assert(true, 'Socket connected successfully'); + + const ipc = createIpcClient(socket); + ipc.attach(); + + const readyMsg = await ipc.readyPromise; + assert(readyMsg.type === 'ready', 'Worker sends ready message on startup'); + + // ── 2. Ping / Pong ───────────────────────────────────────────────────── + console.log('2. Ping → Pong'); + const pong = await ipc.request({ type: 'ping' }, timeouts.ipcDefault); + assert(pong.type === 'pong', `Response type is "pong" (got "${pong.type}")`); + + // ── 3. Job echo ───────────────────────────────────────────────────────── + console.log('3. Job → Job Result (echo payload)'); + const payload = { action: 'resize', width: 1024, format: 'webp' }; + const jobResult = await ipc.request({ type: 'job', payload }, timeouts.ipcDefault); + assert(jobResult.type === 'job_result', `Response type is "job_result" (got "${jobResult.type}")`); + assert( + jobResult.payload?.action === 'resize' && jobResult.payload?.width === 1024, + 'Payload echoed back correctly' + ); + + // ── 4. Unknown type → error ───────────────────────────────────────────── + console.log('4. Unknown type → error response'); + const errResp = await ipc.request({ type: 'nonsense' }, timeouts.ipcDefault); + assert(errResp.type === 'error', `Response type is "error" (got "${errResp.type}")`); + + // ── 5. Multiple rapid requests ────────────────────────────────────────── + console.log('5. Multiple concurrent requests'); + const promises = []; + for (let i = 0; i < 10; i++) { + promises.push(ipc.request({ type: 'ping', payload: { seq: i } }, timeouts.ipcDefault)); + } + const results = await Promise.all(promises); + assert(results.length === 10, `All 10 responses received`); + assert(results.every((r) => r.type === 'pong'), 'All responses are pong'); + + // ── 6. kbot-ai — real LLM (optional via ipcLlmEnabled) ───────────────── + if (ipcLlmEnabled()) { + const aiPayload = kbotAiPayloadFromEnv(); + const r = aiPayload.router; + console.log(`6. kbot-ai → real LLM (router=${r}, timeout 3m)`); + const live = await ipc.request( + { + type: 'kbot-ai', + payload: aiPayload, + }, + timeouts.kbotAi + ); + assert(live.type === 'job_result', `LLM response type job_result (got "${live.type}")`); + const lp = payloadObj(live); + assert(lp?.status === 'success', `payload status success (got "${lp?.status}")`); + assert( + typeof lp?.text === 'string' && lp.text.trim().length >= 3, + `assistant text present (length ${(lp?.text || '').length})` + ); + if (usingDefaultGermanyPrompt()) { + assert( + /berlin/i.test(String(lp?.text || '')), + 'assistant text mentions Berlin (capital of Germany)' + ); + } + ipcKbotAiLlmRouter = lp?.llm ?? null; + logKbotAiResponse('kbot-ai response', live); + } else { + console.log('6. kbot-ai — skipped (KBOT_IPC_LLM=0/false/no/off; default is to run live LLM)'); + } + + // ── 7. kbot-ai — llama local :8888 (optional; llama-basics parity) ─────── + if (ipcLlamaEnabled()) { + console.log('7. kbot-ai → llama local runner (OpenAI :8888, presets.llama)'); + let llamaReady = false; + try { + await ensureLlamaLocalServer({ + autostart: llamaAutostartEnabled(), + startTimeoutMs: timeouts.llamaServerStart, + }); + llamaReady = true; + } catch (e) { + console.error(` ❌ ${e?.message ?? e}`); + } + assert(llamaReady, 'llama-server listening on :8888 (or autostart run-7b.sh succeeded)'); + + if (llamaReady) { + const llamaPayload = kbotAiPayloadLlamaLocal(); + const llamaRes = await ipc.request( + { type: 'kbot-ai', payload: llamaPayload }, + timeouts.llamaKbotAi + ); + assert(llamaRes.type === 'job_result', `llama IPC response type job_result (got "${llamaRes.type}")`); + const llp = payloadObj(llamaRes); + assert(llp?.status === 'success', `llama payload status success (got "${llp?.status}")`); + assert( + typeof llp?.text === 'string' && llp.text.trim().length >= 1, + `llama assistant text present (length ${(llp?.text || '').length})` + ); + assert(/\b8\b/.test(String(llp?.text || '')), 'llama arithmetic: reply mentions 8 (5+3)'); + ipcKbotAiLlmLlama = llp?.llm ?? null; + logKbotAiResponse('kbot-ai llama local', llamaRes); + } + } else { + console.log('7. kbot-ai llama local — skipped (KBOT_IPC_LLAMA=0; default is to run)'); + } + + // ── 8. Graceful shutdown ──────────────────────────────────────────────── + console.log('8. Graceful shutdown'); + const shutdownRes = await ipc.request({ type: 'shutdown' }, timeouts.ipcDefault); + assert(shutdownRes.type === 'shutdown_ack', `Shutdown acknowledged (got "${shutdownRes.type}")`); + + await new Promise((r) => setTimeout(r, timeouts.postShutdownMs)); + socket.destroy(); + assert(workerProc.exitCode === 0, `Worker exited with code 0 (got ${workerProc.exitCode})`); + + // ── Summary ───────────────────────────────────────────────────────────── + console.log(`\n────────────────────────────────`); + console.log(` Passed: ${stats.passed} Failed: ${stats.failed}`); + console.log(`────────────────────────────────\n`); + + try { + const finishedAt = new Date().toISOString(); + const { jsonPath, mdPath } = await writeTestReports( + 'test-ipc', + { + startedAt: ipcRunStartedAt, + finishedAt, + passed: stats.passed, + failed: stats.failed, + ok: stats.failed === 0, + ipcLlm: ipcLlmEnabled(), + ipcLlama: ipcLlamaEnabled(), + env: { + KBOT_IPC_LLM: process.env.KBOT_IPC_LLM ?? null, + KBOT_IPC_LLAMA: process.env.KBOT_IPC_LLAMA ?? null, + KBOT_IPC_LLAMA_AUTOSTART: process.env.KBOT_IPC_LLAMA_AUTOSTART ?? null, + KBOT_ROUTER: process.env.KBOT_ROUTER ?? null, + KBOT_IPC_MODEL: process.env.KBOT_IPC_MODEL ?? null, + KBOT_IPC_PROMPT: process.env.KBOT_IPC_PROMPT ?? null, + KBOT_LLAMA_PORT: process.env.KBOT_LLAMA_PORT ?? null, + KBOT_LLAMA_BASE_URL: process.env.KBOT_LLAMA_BASE_URL ?? null, + }, + metrics: buildMetricsBundle(ipcMetricsCollector, ipcRunStartedAt, finishedAt), + kbotAi: { + routerStep: ipcKbotAiLlmRouter, + llamaStep: ipcKbotAiLlmLlama, + }, + }, + { cwd: process.cwd() } + ); + console.log(` 📄 Report JSON: ${jsonPath}`); + console.log(` 📄 Report MD: ${mdPath}\n`); + } catch (e) { + console.error(' ⚠️ Failed to write report:', e?.message ?? e); + } + + process.exit(stats.failed > 0 ? 1 : 0); +} + +run().catch(async (err) => { + console.error('Test runner error:', err); + try { + const finishedAt = new Date().toISOString(); + const c = ipcMetricsCollector ?? createMetricsCollector(); + const started = ipcRunStartedAt ?? finishedAt; + await writeTestReports( + 'test-ipc', + { + startedAt: started, + finishedAt, + error: String(err?.stack ?? err), + passed: stats.passed, + failed: stats.failed, + ok: false, + metrics: buildMetricsBundle(c, started, finishedAt), + }, + { cwd: process.cwd() } + ); + } catch (_) { + /* ignore */ + } + process.exit(1); +}); diff --git a/packages/media/cpp/package-lock.json b/packages/media/cpp/package-lock.json new file mode 100644 index 00000000..dd85a8d3 --- /dev/null +++ b/packages/media/cpp/package-lock.json @@ -0,0 +1,193 @@ +{ + "name": "kbot-cpp", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "kbot-cpp", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "yargs": "^17.7.2" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/packages/media/cpp/package.json b/packages/media/cpp/package.json new file mode 100644 index 00000000..6649a0ee --- /dev/null +++ b/packages/media/cpp/package.json @@ -0,0 +1,41 @@ +{ + "name": "kbot-cpp", + "version": "1.0.0", + "type": "module", + "description": "KBot C++ CLI built with CMake.", + "directories": { + "test": "tests" + }, + "dependencies": { + "yargs": "^17.7.2" + }, + "scripts": { + "config": "cmake --preset dev", + "config:release": "cmake --preset release", + "build": "cmake --preset dev && cmake --build --preset dev", + "build:release": "cmake --preset release && cmake --build --preset release", + "build:linux": "bash build-linux.sh", + "test": "ctest --test-dir build/dev -C Debug --output-on-failure", + "test:release": "ctest --test-dir build/release -C Release --output-on-failure", + "clean": "cmake -E rm -rf build dist", + "rebuild": "npm run clean && npm run build", + "run": ".\\dist\\kbot.exe --help", + "worker": ".\\dist\\kbot.exe worker", + "worker:uds": ".\\dist\\kbot.exe worker --uds \\\\.\\pipe\\kbot-worker", + "kbot:ai": ".\\dist\\kbot.exe kbot ai --prompt \"hi\"", + "kbot:run": ".\\dist\\kbot.exe kbot run --list", + "test:ipc": "node orchestrator/test-ipc.mjs", + "test:ipc:classifier": "node orchestrator/test-ipc-classifier.mjs", + "test:files": "node orchestrator/test-files.mjs", + "test:ipc:classifier:openrouter": "node orchestrator/classifier-openrouter.mjs", + "test:ipc:classifier:openrouter:stress": "node orchestrator/classifier-openrouter-stress.mjs", + "test:html": "cmake --preset release && cmake --build --preset release --target test_html && .\\dist\\test_html.exe" + }, + "repository": { + "type": "git", + "url": "https://git.polymech.info/polymech/mono-cpp.git" + }, + "keywords": [], + "author": "", + "license": "ISC" +} \ No newline at end of file diff --git a/packages/media/cpp/packages/html/CMakeLists.txt b/packages/media/cpp/packages/html/CMakeLists.txt new file mode 100644 index 00000000..f10d8cef --- /dev/null +++ b/packages/media/cpp/packages/html/CMakeLists.txt @@ -0,0 +1,33 @@ +include(FetchContent) + +FetchContent_Declare( + lexbor + GIT_REPOSITORY https://github.com/lexbor/lexbor.git + GIT_TAG v2.4.0 + GIT_SHALLOW TRUE +) + +# Build lexbor as static +set(LEXBOR_BUILD_SHARED OFF CACHE BOOL "" FORCE) +set(LEXBOR_BUILD_STATIC ON CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(lexbor) + +add_library(html STATIC + src/html.cpp + src/html2md.cpp + src/table.cpp +) + +# MSVC: treat source and execution charset as UTF-8 +# (fixes \u200b zero-width-space mismatch in html2md tests) +if(MSVC) + target_compile_options(html PRIVATE /utf-8) +endif() + +target_include_directories(html + PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include +) + +target_link_libraries(html + PUBLIC lexbor_static +) diff --git a/packages/media/cpp/packages/html/include/html/html.h b/packages/media/cpp/packages/html/include/html/html.h new file mode 100644 index 00000000..5159893b --- /dev/null +++ b/packages/media/cpp/packages/html/include/html/html.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include + +namespace html { + +/// Parsed element — tag name + text content. +struct Element { + std::string tag; + std::string text; +}; + +/// Link with href and optional attributes. +struct Link { + std::string href; + std::string rel; // e.g. "canonical", "stylesheet" + std::string text; // anchor text (for tags) +}; + +/// Parse an HTML string and return all elements with their text content. +std::vector parse(const std::string &html_str); + +/// Extract the text content of all elements matching a CSS selector. +std::vector select(const std::string &html_str, + const std::string &selector); + +// ── Enricher extraction helpers ───────────────────────────────────────────── + +/// Extract the text. +std::string get_title(const std::string &html_str); + +/// Extract a <meta name="X"> or <meta property="X"> content attribute. +std::string get_meta(const std::string &html_str, const std::string &name); + +/// Extract <link rel="canonical"> href. +std::string get_canonical(const std::string &html_str); + +/// Extract all <a href="..."> values (resolved links as-is from the HTML). +std::vector<Link> get_links(const std::string &html_str); + +/// Extract visible body text, stripping script/style/noscript/svg/iframe. +std::string get_body_text(const std::string &html_str); + +/// Extract raw JSON strings from <script type="application/ld+json">. +std::vector<std::string> get_json_ld(const std::string &html_str); + +/// Extract an attribute value from the first element matching a CSS selector. +std::string get_attr(const std::string &html_str, const std::string &selector, + const std::string &attr_name); + +/// Convert HTML content to Markdown. +std::string to_markdown(const std::string &html_str); + +} // namespace html diff --git a/packages/media/cpp/packages/html/include/html/html2md.h b/packages/media/cpp/packages/html/include/html/html2md.h new file mode 100644 index 00000000..0c73ca4a --- /dev/null +++ b/packages/media/cpp/packages/html/include/html/html2md.h @@ -0,0 +1,690 @@ +// Copyright (c) Tim Gromeyer +// Licensed under the MIT License - https://opensource.org/licenses/MIT + +#ifndef HTML2MD_H +#define HTML2MD_H + +#include <memory> +#include <string> +#include <unordered_map> +#include <cstdint> + +/*! + * \brief html2md namespace + * + * The html2md namespace provides: + * 1. The Converter class + * 2. Static wrapper around Converter class + * + * \note Do NOT try to convert HTML that contains a list in an ordered list or a + * `blockquote` in a list!\n This will be a **total** mess! + */ +namespace html2md { + +/*! + * \brief Options for the conversion from HTML to Markdown + * \warning Make sure to pass valid options; otherwise, the output will be + * invalid! + * + * Example from `tests/main.cpp`: + * + * ```cpp + * auto *options = new html2md::Options(); + * options->splitLines = false; + * + * html2md::Converter c(html, options); + * auto md = c.convert(); + * ``` + */ +struct Options { + /*! + * \brief Add new line when a certain number of characters is reached + * + * \see softBreak + * \see hardBreak + */ + bool splitLines = true; + + /*! + * \brief softBreak Wrap after ... characters when the next space is reached + * and as long as it's not in a list, table, image or anchor (link). + */ + int softBreak = 80; + + /*! + * \brief hardBreak Force a break after ... characters in a line + */ + int hardBreak = 100; + + /*! + * \brief The char used for unordered lists + * + * Valid: + * - `-` + * - `+` + * - `*` + * + * Example: + * + * ```markdown + * - List + * + Also a list + * * And this to + * ``` + */ + char unorderedList = '-'; + + /*! + * \brief The char used after the number of the item + * + * Valid: + * - `.` + * - `)` + * + * Example: + * + * ```markdown + * 1. Hello + * 2) World! + * ``` + */ + char orderedList = '.'; + + /*! + * \brief Whether title is added as h1 heading at the very beginning of the + * markdown + * + * Whether title is added as h1 heading at the very beginning of the markdown. + * Default is true. + */ + bool includeTitle = true; + + /*! + * \brief Whetever to format Markdown Tables + * + * Whetever to format Markdown Tables. + * Default is true. + */ + bool formatTable = true; + + /*! + * \brief Whether to force left trim of lines in the final Markdown output + * + * Whether to force left trim of lines in the final Markdown output. + * Default is false. + */ + bool forceLeftTrim = false; + + /*! + * \brief Whether to compress whitespace (tabs, multiple spaces) into a single + * space + * + * Whether to compress whitespace (tabs, multiple spaces) into a single space. + * Default is false. + */ + bool compressWhitespace = false; + + /*! + * \brief Whether to escape numbered lists (e.g. "4." -> "4\.") to prevent them + * from being interpreted as lists in Markdown. + * + * Whether to escape numbered lists. + * Default is true. + */ + bool escapeNumberedList = true; + + /*! + * \brief Whether to keep HTML entities (e.g. ` `) in the output + * + * If true, the converter will not replace HTML entities configured in the + * internal conversion map. Default is false (current behaviour). + */ + bool keepHtmlEntities = false; + + inline bool operator==(html2md::Options o) const { + return splitLines == o.splitLines && unorderedList == o.unorderedList && + orderedList == o.orderedList && includeTitle == o.includeTitle && + softBreak == o.softBreak && hardBreak == o.hardBreak && + formatTable == o.formatTable && forceLeftTrim == o.forceLeftTrim && + compressWhitespace == o.compressWhitespace && + escapeNumberedList == o.escapeNumberedList && + keepHtmlEntities == o.keepHtmlEntities; + }; +}; + +/*! + * \brief Class for converting HTML to Markdown + * + * This class converts HTML to Markdown. + * There is also a static wrapper for this class (see html2md::Convert). + * + * ## Usage example + * + * Option 1: Use the class: + * + * ```cpp + * std::string html = "<h1>example</h1>"; + * html2md::Converter c(html); + * auto md = c.convert(); + * + * if (!c.ok()) std::cout << "There was something wrong in the HTML\n"; + * std::cout << md; // # example + * ``` + * + * Option 2: Use the static wrapper: + * + * ```cpp + * std::string html = "<h1>example</h1>"; + * + * auto md = html2md::Convert(html); + * std::cout << md; + * ``` + * + * Advanced: use Options: + * + * ```cpp + * std::string html = "<h1>example</h1>"; + * + * auto *options = new html2md::Options(); + * options->splitLines = false; + * options->unorderedList = '*'; + * + * html2md::Converter c(html, options); + * auto md = c.convert(); + * if (!c.ok()) std::cout << "There was something wrong in the HTML\n"; + * std::cout << md; // # example + * ``` + */ +class Converter { +public: + /*! + * \brief Standard initializer, takes HTML as parameter. Also prepares + * everything. \param html The HTML as std::string. \param options Options for + * the Conversation. See html2md::Options() for more. + * + * \note Don't pass anything else than HTML, otherwise the output will be a + * **mess**! + * + * This is the default initializer.<br> + * You can use appendToMd() to append something to the beginning of the + * generated output. + */ + explicit inline Converter(const std::string &html, + struct Options *options = nullptr) { + *this = Converter(&html, options); + } + + /*! + * \brief Convert HTML into Markdown. + * \return Returns the converted Markdown. + * + * This function actually converts the HTML into Markdown. + * It also cleans up the Markdown so you don't have to do anything. + */ + [[nodiscard]] std::string convert(); + + /*! + * \brief Append a char to the Markdown. + * \param ch The char to append. + * \return Returns a copy of the instance with the char appended. + */ + Converter *appendToMd(char ch); + + /*! + * \brief Append a char* to the Markdown. + * \param str The char* to append. + * \return Returns a copy of the instance with the char* appended. + */ + Converter *appendToMd(const char *str); + + /*! + * \brief Append a string to the Markdown. + * \param s The string to append. + * \return Returns a copy of the instance with the string appended. + */ + inline Converter *appendToMd(const std::string &s) { + return appendToMd(s.c_str()); + } + + /*! + * \brief Appends a ' ' in certain cases. + * \return Copy of the instance with(maybe) the appended space. + * + * This function appends ' ' if: + * - md does not end with `*` + * - md does not end with `\n` aka newline + */ + Converter *appendBlank(); + + /*! + * \brief Add an HTML symbol conversion + * \param htmlSymbol The HTML symbol to convert + * \param replacement The replacement string + * \note This is useful for converting HTML entities to their Markdown + * equivalents. For example, you can add a conversion for " " to + * " " (space) or "<" to "<" (less than). + * \note This is not a standard feature of the Converter class, but it can + * be added to the class to allow for more flexibility in the conversion + * process. You can use this feature to add custom conversions for any HTML + * symbol that you want to convert to a specific Markdown representation. + */ + void addHtmlSymbolConversion(const std::string &htmlSymbol, + const std::string &replacement) { + htmlSymbolConversions_[htmlSymbol] = replacement; + } + + /*! + * \brief Remove an HTML symbol conversion + * \param htmlSymbol The HTML symbol to remove + * \note This is useful for removing custom conversions that you have added + * previously. + */ + void removeHtmlSymbolConversion(const std::string &htmlSymbol) { + htmlSymbolConversions_.erase(htmlSymbol); + } + + /*! + * \brief Clear all HTML symbol conversions + * \note This is useful for clearing the conversion map (it's empty afterwards). + */ + void clearHtmlSymbolConversions() { htmlSymbolConversions_.clear(); } + + /*! + * \brief Checks if everything was closed properly(in the HTML). + * \return Returns false if there is a unclosed tag. + * \note As long as you have not called convert(), it always returns true. + */ + [[nodiscard]] bool ok() const; + + /*! + * \brief Reset the generated Markdown + */ + void reset(); + + /*! + * \brief Checks if the HTML matches and the options are the same. + * \param The Converter object to compare with + * \return true if the HTML and options matches otherwise false + */ + inline bool operator==(const Converter *c) const { return *this == *c; } + + inline bool operator==(const Converter &c) const { + return html_ == c.html_ && option == c.option; + } + + /*! + * \brief Returns ok(). + */ + inline explicit operator bool() const { return ok(); }; + +private: + // Attributes + static constexpr const char *kAttributeHref = "href"; + static constexpr const char *kAttributeAlt = "alt"; + static constexpr const char *kAttributeTitle = "title"; + static constexpr const char *kAttributeClass = "class"; + static constexpr const char *kAttributeSrc = "src"; + static constexpr const char *kAttrinuteAlign = "align"; + + static constexpr const char *kTagAnchor = "a"; + static constexpr const char *kTagBreak = "br"; + static constexpr const char *kTagCode = "code"; + static constexpr const char *kTagDiv = "div"; + static constexpr const char *kTagHead = "head"; + static constexpr const char *kTagLink = "link"; + static constexpr const char *kTagListItem = "li"; + static constexpr const char *kTagMeta = "meta"; + static constexpr const char *kTagNav = "nav"; + static constexpr const char *kTagNoScript = "noscript"; + static constexpr const char *kTagOption = "option"; + static constexpr const char *kTagOrderedList = "ol"; + static constexpr const char *kTagParagraph = "p"; + static constexpr const char *kTagPre = "pre"; + static constexpr const char *kTagScript = "script"; + static constexpr const char *kTagSpan = "span"; + static constexpr const char *kTagStyle = "style"; + static constexpr const char *kTagTemplate = "template"; + static constexpr const char *kTagTitle = "title"; + static constexpr const char *kTagUnorderedList = "ul"; + static constexpr const char *kTagImg = "img"; + static constexpr const char *kTagSeperator = "hr"; + + // Text format + static constexpr const char *kTagBold = "b"; + static constexpr const char *kTagStrong = "strong"; + static constexpr const char *kTagItalic = "em"; + static constexpr const char *kTagItalic2 = "i"; + static constexpr const char *kTagCitation = "cite"; + static constexpr const char *kTagDefinition = "dfn"; + static constexpr const char *kTagUnderline = "u"; + static constexpr const char *kTagStrighthrought = "del"; + static constexpr const char *kTagStrighthrought2 = "s"; + + static constexpr const char *kTagBlockquote = "blockquote"; + + // Header + static constexpr const char *kTagHeader1 = "h1"; + static constexpr const char *kTagHeader2 = "h2"; + static constexpr const char *kTagHeader3 = "h3"; + static constexpr const char *kTagHeader4 = "h4"; + static constexpr const char *kTagHeader5 = "h5"; + static constexpr const char *kTagHeader6 = "h6"; + + // Table + static constexpr const char *kTagTable = "table"; + static constexpr const char *kTagTableRow = "tr"; + static constexpr const char *kTagTableHeader = "th"; + static constexpr const char *kTagTableData = "td"; + + size_t index_ch_in_html_ = 0; + + bool is_closing_tag_ = false; + bool is_in_attribute_value_ = false; + bool is_in_code_ = false; + bool is_in_list_ = false; + bool is_in_p_ = false; + bool is_in_pre_ = false; + bool is_in_table_ = false; + bool is_in_table_row_ = false; + bool is_in_tag_ = false; + bool is_self_closing_tag_ = false; + bool skipping_leading_whitespace_ = true; + + // relevant for <li> only, false = is in unordered list + bool is_in_ordered_list_ = false; + uint8_t index_ol = 0; + + // store the table start + size_t table_start = 0; + + // number of lists + uint8_t index_li = 0; + + uint8_t index_blockquote = 0; + + char prev_ch_in_md_ = 0, prev_prev_ch_in_md_ = 0; + char prev_ch_in_html_ = 'x'; + + std::string html_; + + uint16_t offset_lt_ = 0; + std::string current_tag_; + std::string prev_tag_; + + // Line which separates header from data + std::string tableLine; + + size_t chars_in_curr_line_ = 0; + + std::string md_; + + Options option; + + std::unordered_map<std::string, std::string> htmlSymbolConversions_ = { + {""", "\""}, {"<", "<"}, {">", ">"}, + {"&", "&"}, {" ", " "}, {"→", "→"}}; + + // Tag: base class for tag types + struct Tag { + virtual void OnHasLeftOpeningTag(Converter *c) = 0; + virtual void OnHasLeftClosingTag(Converter *c) = 0; + }; + + // Tag types + + // tags that are not printed (nav, script, noscript, ...) + struct TagIgnored : Tag { + void OnHasLeftOpeningTag(Converter *c) override {}; + void OnHasLeftClosingTag(Converter *c) override {}; + }; + + struct TagAnchor : Tag { + void OnHasLeftOpeningTag(Converter *c) override; + void OnHasLeftClosingTag(Converter *c) override; + + std::string current_href_; + std::string current_title_; + }; + + struct TagBold : Tag { + void OnHasLeftOpeningTag(Converter *c) override; + void OnHasLeftClosingTag(Converter *c) override; + }; + + struct TagItalic : Tag { + void OnHasLeftOpeningTag(Converter *c) override; + void OnHasLeftClosingTag(Converter *c) override; + }; + + struct TagUnderline : Tag { + void OnHasLeftOpeningTag(Converter *c) override; + void OnHasLeftClosingTag(Converter *c) override; + }; + + struct TagStrikethrought : Tag { + void OnHasLeftOpeningTag(Converter *c) override; + void OnHasLeftClosingTag(Converter *c) override; + }; + + struct TagBreak : Tag { + void OnHasLeftOpeningTag(Converter *c) override; + void OnHasLeftClosingTag(Converter *c) override; + }; + + struct TagDiv : Tag { + void OnHasLeftOpeningTag(Converter *c) override; + void OnHasLeftClosingTag(Converter *c) override; + }; + + struct TagHeader1 : Tag { + void OnHasLeftOpeningTag(Converter *c) override; + void OnHasLeftClosingTag(Converter *c) override; + }; + + struct TagHeader2 : Tag { + void OnHasLeftOpeningTag(Converter *c) override; + void OnHasLeftClosingTag(Converter *c) override; + }; + + struct TagHeader3 : Tag { + void OnHasLeftOpeningTag(Converter *c) override; + void OnHasLeftClosingTag(Converter *c) override; + }; + + struct TagHeader4 : Tag { + void OnHasLeftOpeningTag(Converter *c) override; + void OnHasLeftClosingTag(Converter *c) override; + }; + + struct TagHeader5 : Tag { + void OnHasLeftOpeningTag(Converter *c) override; + void OnHasLeftClosingTag(Converter *c) override; + }; + + struct TagHeader6 : Tag { + void OnHasLeftOpeningTag(Converter *c) override; + void OnHasLeftClosingTag(Converter *c) override; + }; + + struct TagListItem : Tag { + void OnHasLeftOpeningTag(Converter *c) override; + void OnHasLeftClosingTag(Converter *c) override; + }; + + struct TagOption : Tag { + void OnHasLeftOpeningTag(Converter *c) override; + void OnHasLeftClosingTag(Converter *c) override; + }; + + struct TagOrderedList : Tag { + void OnHasLeftOpeningTag(Converter *c) override; + void OnHasLeftClosingTag(Converter *c) override; + }; + + struct TagParagraph : Tag { + void OnHasLeftOpeningTag(Converter *c) override; + void OnHasLeftClosingTag(Converter *c) override; + }; + + struct TagPre : Tag { + void OnHasLeftOpeningTag(Converter *c) override; + void OnHasLeftClosingTag(Converter *c) override; + }; + + struct TagCode : Tag { + void OnHasLeftOpeningTag(Converter *c) override; + void OnHasLeftClosingTag(Converter *c) override; + }; + + struct TagSpan : Tag { + void OnHasLeftOpeningTag(Converter *c) override; + void OnHasLeftClosingTag(Converter *c) override; + }; + + struct TagTitle : Tag { + void OnHasLeftOpeningTag(Converter *c) override; + void OnHasLeftClosingTag(Converter *c) override; + }; + + struct TagUnorderedList : Tag { + void OnHasLeftOpeningTag(Converter *c) override; + void OnHasLeftClosingTag(Converter *c) override; + }; + + struct TagImage : Tag { + void OnHasLeftOpeningTag(Converter *c) override; + void OnHasLeftClosingTag(Converter *c) override; + }; + + struct TagSeperator : Tag { + void OnHasLeftOpeningTag(Converter *c) override; + void OnHasLeftClosingTag(Converter *c) override; + }; + + struct TagTable : Tag { + void OnHasLeftOpeningTag(Converter *c) override; + void OnHasLeftClosingTag(Converter *c) override; + }; + + struct TagTableRow : Tag { + void OnHasLeftOpeningTag(Converter *c) override; + void OnHasLeftClosingTag(Converter *c) override; + }; + + struct TagTableHeader : Tag { + void OnHasLeftOpeningTag(Converter *c) override; + void OnHasLeftClosingTag(Converter *c) override; + }; + + struct TagTableData : Tag { + void OnHasLeftOpeningTag(Converter *c) override; + void OnHasLeftClosingTag(Converter *c) override; + }; + + struct TagBlockquote : Tag { + void OnHasLeftOpeningTag(Converter *c) override; + void OnHasLeftClosingTag(Converter *c) override; + }; + + std::unordered_map<std::string, std::shared_ptr<Tag>> tags_; + + explicit Converter(const std::string *html, struct Options *options); + + void CleanUpMarkdown(); + + // Trim from start (in place) + static void LTrim(std::string *s); + + // Trim from end (in place) + Converter *RTrim(std::string *s, bool trim_only_blank = false); + + // Trim from both ends (in place) + Converter *Trim(std::string *s); + + // 1. trim all lines + // 2. reduce consecutive newlines to maximum 3 + void TidyAllLines(std::string *str); + + std::string ExtractAttributeFromTagLeftOf(const std::string &attr); + + void TurnLineIntoHeader1(); + + void TurnLineIntoHeader2(); + + // Current char: '<' + void OnHasEnteredTag(); + + Converter *UpdatePrevChFromMd(); + + /** + * Handle next char within <...> tag + * + * @param ch current character + * @return continue surrounding iteration? + */ + bool ParseCharInTag(char ch); + + // Current char: '>' + bool OnHasLeftTag(); + + inline static bool TagContainsAttributesToHide(std::string *tag) { + using std::string; + + return (*tag).find(" aria=\"hidden\"") != string::npos || + (*tag).find("display:none") != string::npos || + (*tag).find("visibility:hidden") != string::npos || + (*tag).find("opacity:0") != string::npos || + (*tag).find("Details-content--hidden-not-important") != string::npos; + } + + Converter *ShortenMarkdown(size_t chars = 1); + inline bool shortIfPrevCh(char prev) { + if (prev_ch_in_md_ == prev) { + ShortenMarkdown(); + return true; + } + return false; + }; + + /** + * @param ch + * @return continue iteration surrounding this method's invocation? + */ + bool ParseCharInTagContent(char ch); + + // Replace previous space (if any) in current markdown line by newline + bool ReplacePreviousSpaceInLineByNewline(); + + static inline bool IsIgnoredTag(const std::string &tag) { + return (tag[0] == '-' || kTagTemplate == tag || kTagStyle == tag || + kTagScript == tag || kTagNoScript == tag || kTagNav == tag); + + // meta: not ignored to tolerate if closing is omitted + } + + [[nodiscard]] bool IsInIgnoredTag() const; +}; // Converter + +/*! + * \brief Static wrapper around the Converter class + * \param html The HTML passed to Converter + * \param ok Optional: Pass a reference to a local bool to store the output of + * Converter::ok() \return Returns the by Converter generated Markdown + */ +inline std::string Convert(const std::string &html, bool *ok = nullptr) { + Converter c(html); + auto md = c.convert(); + if (ok != nullptr) + *ok = c.ok(); + return md; +} + +#ifndef PYTHON_BINDINGS +inline std::string Convert(const std::string &&html, bool *ok = nullptr) { + return Convert(html, ok); +} +#endif + +} // namespace html2md + +#endif // HTML2MD_H diff --git a/packages/media/cpp/packages/html/include/html/table.h b/packages/media/cpp/packages/html/include/html/table.h new file mode 100644 index 00000000..9cf3c4b7 --- /dev/null +++ b/packages/media/cpp/packages/html/include/html/table.h @@ -0,0 +1,11 @@ +// Copyright (c) Tim Gromeyer +// Licensed under the MIT License - https://opensource.org/licenses/MIT + +#ifndef TABLE_H +#define TABLE_H + +#include <string> + +[[nodiscard]] std::string formatMarkdownTable(const std::string &inputTable); + +#endif // TABLE_H diff --git a/packages/media/cpp/packages/html/readme.md b/packages/media/cpp/packages/html/readme.md new file mode 100644 index 00000000..c1b6518a --- /dev/null +++ b/packages/media/cpp/packages/html/readme.md @@ -0,0 +1,101 @@ +# Scraper Request + +## OpenAPI Specification + +```yaml +openapi: 3.0.1 +info: + title: '' + description: '' + version: 1.0.0 +paths: + /api/v1/scraper/request: + post: + summary: Scraper Request + deprecated: false + description: '' + tags: + - Scraping API + parameters: [] + requestBody: + content: + application/json: + schema: + type: object + properties: + actor: + type: string + input: + type: object + properties: + url: + type: string + required: + - url + x-apidog-orders: + - url + proxy: + type: object + properties: + country: + type: string + required: + - country + x-apidog-orders: + - country + async: + type: boolean + description: |- + If true, the task will be executed asynchronously. + If false, the task will be executed synchronously. + required: + - actor + - input + - proxy + x-apidog-orders: + - actor + - input + - proxy + - async + example: + actor: scraper.xxx + input: + url: >- + https://www.***.com/shop/us/products/stmicroelectronics/tda7265a-3074457345625542393/ + proxy: + country: US + async: false + responses: + '200': + description: '' + content: + application/json: + schema: + type: object + properties: {} + x-apidog-orders: [] + headers: {} + x-apidog-name: Success + security: + - apikey-header-x-api-token: [] + x-apidog-folder: Scraping API + x-apidog-status: released + x-run-in-apidog: https://app.apidog.com/web/project/745098/apis/api-11949852-run +components: + schemas: {} + securitySchemes: + bearer: + type: bearer + scheme: bearer + description: Bearer token authentication using your Scrapeless API key + apikey-header-x-api-token: + type: apiKey + in: header + name: x-api-token +servers: + - url: https://api.scrapeless.com + description: Prod Env +security: + - apikey-header-x-api-token: [] + +``` \ No newline at end of file diff --git a/packages/media/cpp/packages/html/src/html.cpp b/packages/media/cpp/packages/html/src/html.cpp new file mode 100644 index 00000000..a13ff89c --- /dev/null +++ b/packages/media/cpp/packages/html/src/html.cpp @@ -0,0 +1,403 @@ +#include "html/html.h" + +#include <lexbor/css/css.h> +#include <lexbor/html/html.h> +#include <lexbor/selectors/selectors.h> +#include <html/html2md.h> + +#include <algorithm> +#include <cstring> + +namespace html { + +// ── helpers ───────────────────────────────────────────────────────────────── + +static std::string node_text(lxb_dom_node_t *node) { + size_t len = 0; + lxb_char_t *text = lxb_dom_node_text_content(node, &len); + if (!text) + return {}; + std::string result(reinterpret_cast<const char *>(text), len); + lxb_dom_document_destroy_text(node->owner_document, text); + return result; +} + +static std::string tag_name(lxb_dom_element_t *el) { + size_t len = 0; + const lxb_char_t *name = lxb_dom_element_qualified_name(el, &len); + if (!name) + return {}; + return std::string(reinterpret_cast<const char *>(name), len); +} + +static std::string get_element_attr(lxb_dom_element_t *el, const char *attr) { + size_t len = 0; + const lxb_char_t *val = lxb_dom_element_get_attribute( + el, reinterpret_cast<const lxb_char_t *>(attr), strlen(attr), &len); + if (!val) + return {}; + return std::string(reinterpret_cast<const char *>(val), len); +} + +static lxb_html_document_t *parse_doc(const std::string &html_str) { + auto *doc = lxb_html_document_create(); + if (!doc) return nullptr; + auto status = lxb_html_document_parse( + doc, reinterpret_cast<const lxb_char_t *>(html_str.c_str()), + html_str.size()); + if (status != LXB_STATUS_OK) { + lxb_html_document_destroy(doc); + return nullptr; + } + return doc; +} + +// ── Helper: check if a tag name matches a noise element ───────────────────── + +static bool is_noise_tag(const std::string &name) { + return name == "script" || name == "style" || name == "noscript" || + name == "svg" || name == "iframe"; +} + +// ── walk tree recursively ─────────────────────────────────────────────────── + +static void walk(lxb_dom_node_t *node, std::vector<Element> &out) { + if (!node) + return; + if (node->type == LXB_DOM_NODE_TYPE_ELEMENT) { + auto *el = lxb_dom_interface_element(node); + auto txt = node_text(node); + if (!txt.empty()) { + out.push_back({tag_name(el), txt}); + } + } + auto *child = node->first_child; + while (child) { + walk(child, out); + child = child->next; + } +} + +// ── Walk for visible text only (skip noise tags) ──────────────────────────── + +static void walk_text(lxb_dom_node_t *node, std::string &out) { + if (!node) return; + + if (node->type == LXB_DOM_NODE_TYPE_ELEMENT) { + auto *el = lxb_dom_interface_element(node); + auto name = tag_name(el); + if (is_noise_tag(name)) return; // Skip noise subtrees entirely + } + + if (node->type == LXB_DOM_NODE_TYPE_TEXT) { + size_t len = 0; + const lxb_char_t *data = lxb_dom_node_text_content(node, &len); + if (data && len > 0) { + std::string chunk(reinterpret_cast<const char *>(data), len); + // Collapse whitespace + bool needSpace = !out.empty() && out.back() != ' ' && out.back() != '\n'; + // Trim leading/trailing whitespace from chunk + size_t start = chunk.find_first_not_of(" \t\n\r"); + size_t end = chunk.find_last_not_of(" \t\n\r"); + if (start != std::string::npos) { + if (needSpace) out += ' '; + out += chunk.substr(start, end - start + 1); + } + } + } + + auto *child = node->first_child; + while (child) { + walk_text(child, out); + child = child->next; + } +} + +// ── Walk <head> for meta/title/link ───────────────────────────────────────── + +struct HeadData { + std::string title; + std::string canonical; + std::vector<std::pair<std::string, std::string>> metas; // name/property → content + std::vector<std::string> json_ld; +}; + +static void walk_head(lxb_dom_node_t *node, HeadData &data) { + if (!node) return; + + if (node->type == LXB_DOM_NODE_TYPE_ELEMENT) { + auto *el = lxb_dom_interface_element(node); + auto name = tag_name(el); + + if (name == "title") { + data.title = node_text(node); + } else if (name == "meta") { + auto nameAttr = get_element_attr(el, "name"); + auto propAttr = get_element_attr(el, "property"); + auto content = get_element_attr(el, "content"); + if (!content.empty()) { + if (!nameAttr.empty()) data.metas.emplace_back(nameAttr, content); + if (!propAttr.empty()) data.metas.emplace_back(propAttr, content); + } + } else if (name == "link") { + auto rel = get_element_attr(el, "rel"); + if (rel == "canonical") { + data.canonical = get_element_attr(el, "href"); + } + } else if (name == "script") { + auto type = get_element_attr(el, "type"); + if (type == "application/ld+json") { + auto text = node_text(node); + if (!text.empty()) data.json_ld.push_back(text); + } + } + } + + auto *child = node->first_child; + while (child) { + walk_head(child, data); + child = child->next; + } +} + +// ── Walk <body> for <a> links ─────────────────────────────────────────────── + +static void walk_links(lxb_dom_node_t *node, std::vector<Link> &out) { + if (!node) return; + + if (node->type == LXB_DOM_NODE_TYPE_ELEMENT) { + auto *el = lxb_dom_interface_element(node); + auto name = tag_name(el); + + if (name == "a") { + auto href = get_element_attr(el, "href"); + if (!href.empty()) { + Link lk; + lk.href = href; + lk.rel = get_element_attr(el, "rel"); + lk.text = node_text(node); + out.push_back(std::move(lk)); + } + } + } + + auto *child = node->first_child; + while (child) { + walk_links(child, out); + child = child->next; + } +} + +// ── public API ────────────────────────────────────────────────────────────── + +std::vector<Element> parse(const std::string &html_str) { + auto *doc = parse_doc(html_str); + if (!doc) return {}; + + std::vector<Element> result; + auto *body = lxb_dom_interface_node(lxb_html_document_body_element(doc)); + walk(body, result); + + lxb_html_document_destroy(doc); + return result; +} + +// ── CSS selector callback ─────────────────────────────────────────────────── + +struct SelectCtx { + std::vector<std::string> *out; +}; + +static lxb_status_t select_cb(lxb_dom_node_t *node, + lxb_css_selector_specificity_t spec, void *ctx) { + (void)spec; + auto *sctx = static_cast<SelectCtx *>(ctx); + auto txt = node_text(node); + if (!txt.empty()) { + sctx->out->push_back(txt); + } + return LXB_STATUS_OK; +} + +std::vector<std::string> select(const std::string &html_str, + const std::string &selector) { + std::vector<std::string> result; + + auto *doc = parse_doc(html_str); + if (!doc) return result; + + auto *css_parser = lxb_css_parser_create(); + lxb_css_parser_init(css_parser, nullptr); + + auto *selectors = lxb_selectors_create(); + lxb_selectors_init(selectors); + + auto *list = lxb_css_selectors_parse( + css_parser, reinterpret_cast<const lxb_char_t *>(selector.c_str()), + selector.size()); + + if (list) { + SelectCtx ctx{&result}; + lxb_selectors_find( + selectors, lxb_dom_interface_node(lxb_html_document_body_element(doc)), + list, select_cb, &ctx); + lxb_css_selector_list_destroy_memory(list); + } + + lxb_selectors_destroy(selectors, true); + lxb_css_parser_destroy(css_parser, true); + lxb_html_document_destroy(doc); + + return result; +} + +// ── Enricher extraction helpers ───────────────────────────────────────────── + +std::string get_title(const std::string &html_str) { + auto *doc = parse_doc(html_str); + if (!doc) return {}; + + HeadData data; + auto *head = lxb_dom_interface_node(lxb_html_document_head_element(doc)); + walk_head(head, data); + + lxb_html_document_destroy(doc); + return data.title; +} + +std::string get_meta(const std::string &html_str, const std::string &name) { + auto *doc = parse_doc(html_str); + if (!doc) return {}; + + HeadData data; + auto *head = lxb_dom_interface_node(lxb_html_document_head_element(doc)); + walk_head(head, data); + + lxb_html_document_destroy(doc); + + for (auto &[key, val] : data.metas) { + if (key == name) return val; + } + return {}; +} + +std::string get_canonical(const std::string &html_str) { + auto *doc = parse_doc(html_str); + if (!doc) return {}; + + HeadData data; + auto *head = lxb_dom_interface_node(lxb_html_document_head_element(doc)); + walk_head(head, data); + + lxb_html_document_destroy(doc); + return data.canonical; +} + +std::vector<Link> get_links(const std::string &html_str) { + auto *doc = parse_doc(html_str); + if (!doc) return {}; + + std::vector<Link> links; + auto *body = lxb_dom_interface_node(lxb_html_document_body_element(doc)); + walk_links(body, links); + + lxb_html_document_destroy(doc); + return links; +} + +std::string get_body_text(const std::string &html_str) { + auto *doc = parse_doc(html_str); + if (!doc) return {}; + + std::string text; + auto *body = lxb_dom_interface_node(lxb_html_document_body_element(doc)); + walk_text(body, text); + + lxb_html_document_destroy(doc); + return text; +} + +std::vector<std::string> get_json_ld(const std::string &html_str) { + auto *doc = parse_doc(html_str); + if (!doc) return {}; + + HeadData data; + // JSON-LD can be in head or body — walk entire document + auto *root = lxb_dom_interface_node( + lxb_dom_document_element(&doc->dom_document)); + walk_head(root, data); + + lxb_html_document_destroy(doc); + return data.json_ld; +} + +// ── get_attr via CSS selector ─────────────────────────────────────────────── + +struct AttrCtx { + std::string attr_name; + std::string result; + bool found; +}; + +static lxb_status_t attr_cb(lxb_dom_node_t *node, + lxb_css_selector_specificity_t spec, void *ctx) { + (void)spec; + auto *actx = static_cast<AttrCtx *>(ctx); + if (actx->found) return LXB_STATUS_OK; + + if (node->type == LXB_DOM_NODE_TYPE_ELEMENT) { + auto *el = lxb_dom_interface_element(node); + auto val = get_element_attr(el, actx->attr_name.c_str()); + if (!val.empty()) { + actx->result = val; + actx->found = true; + } + } + return LXB_STATUS_OK; +} + +std::string get_attr(const std::string &html_str, const std::string &selector, + const std::string &attr_name) { + auto *doc = parse_doc(html_str); + if (!doc) return {}; + + auto *css_parser = lxb_css_parser_create(); + lxb_css_parser_init(css_parser, nullptr); + + auto *selectors = lxb_selectors_create(); + lxb_selectors_init(selectors); + + auto *list = lxb_css_selectors_parse( + css_parser, reinterpret_cast<const lxb_char_t *>(selector.c_str()), + selector.size()); + + std::string result; + if (list) { + AttrCtx ctx{attr_name, {}, false}; + auto *root = lxb_dom_interface_node( + lxb_dom_document_element(&doc->dom_document)); + lxb_selectors_find(selectors, root, list, attr_cb, &ctx); + result = ctx.result; + lxb_css_selector_list_destroy_memory(list); + } + + lxb_selectors_destroy(selectors, true); + lxb_css_parser_destroy(css_parser, true); + lxb_html_document_destroy(doc); + + return result; +} + +std::string to_markdown(const std::string &html_str) { + // Defense-in-depth: hard cap at 2 MB even if the caller forgets. + // The enricher pipeline already caps at 512 KB, but future callers + // may not — prevent OOM / multi-second hangs from html2md. + static constexpr size_t MAX_HTML2MD_INPUT = 2 * 1024 * 1024; + if (html_str.size() > MAX_HTML2MD_INPUT) { + return "*[Content truncated: HTML too large for markdown conversion (" + + std::to_string(html_str.size() / 1024) + " KB)]*\n"; + } + return html2md::Convert(html_str); +} + +} // namespace html diff --git a/packages/media/cpp/packages/html/src/html2md.cpp b/packages/media/cpp/packages/html/src/html2md.cpp new file mode 100644 index 00000000..33893139 --- /dev/null +++ b/packages/media/cpp/packages/html/src/html2md.cpp @@ -0,0 +1,1195 @@ +// Copyright (c) Tim Gromeyer +// Licensed under the MIT License - https://opensource.org/licenses/MIT + +#include "html/html2md.h" +#include "html/table.h" + +#include <algorithm> +#include <cctype> +#include <cstring> +#include <memory> +#include <sstream> +#include <vector> + +using std::make_shared; +using std::string; +using std::vector; + +namespace { +bool startsWith(const string &str, const string &prefix) { + return str.size() >= prefix.size() && + 0 == str.compare(0, prefix.size(), prefix); +} + +bool endsWith(const string &str, const string &suffix) { + return str.size() >= suffix.size() && + 0 == str.compare(str.size() - suffix.size(), suffix.size(), suffix); +} + +size_t ReplaceAll(string *haystack, const string &needle, + const string &replacement) { + // Get first occurrence + size_t pos = (*haystack).find(needle); + + size_t amount_replaced = 0; + + // Repeat until end is reached + while (pos != string::npos) { + // Replace this occurrence of sub string + (*haystack).replace(pos, needle.size(), replacement); + + // Get the next occurrence from the current position + pos = (*haystack).find(needle, pos + replacement.size()); + + ++amount_replaced; + } + + return amount_replaced; +} + +size_t ReplaceAll(string *haystack, const string &needle, const char c) { + return ReplaceAll(haystack, needle, string({c})); +} + +// Split given string by given character delimiter into vector of strings +vector<string> Split(string const &str, char delimiter) { + vector<string> result; + std::stringstream iss(str); + + for (string token; getline(iss, token, delimiter);) + result.push_back(token); + + return result; +} + +string Repeat(const string &str, size_t amount) { + if (amount == 0) + return ""; + else if (amount == 1) + return str; + + // Optimize for single-character strings (common case for blockquotes, etc.) + if (str.size() == 1) + return string(amount, str[0]); + + // For multi-character strings, reserve space upfront + string out; + out.reserve(str.size() * amount); + for (size_t i = 0; i < amount; ++i) + out.append(str); + + return out; +} + +string toLower(const string &str) { + string lower; + lower.reserve(str.size()); + for (char ch : str) { + lower += tolower((unsigned char)ch); + } + return lower; +} + +} // namespace + +namespace html2md { + +Converter::Converter(const string *html, Options *options) : html_(*html) { + if (options) + option = *options; + + md_.reserve(html->size() * 1.2); + tags_.reserve(41); + + // non-printing tags + auto tagIgnored = make_shared<Converter::TagIgnored>(); + tags_[kTagHead] = tagIgnored; + tags_[kTagMeta] = tagIgnored; + tags_[kTagNav] = tagIgnored; + tags_[kTagNoScript] = tagIgnored; + tags_[kTagScript] = tagIgnored; + tags_[kTagStyle] = tagIgnored; + tags_[kTagTemplate] = tagIgnored; + + // printing tags + tags_[kTagAnchor] = make_shared<Converter::TagAnchor>(); + tags_[kTagBreak] = make_shared<Converter::TagBreak>(); + tags_[kTagDiv] = make_shared<Converter::TagDiv>(); + tags_[kTagHeader1] = make_shared<Converter::TagHeader1>(); + tags_[kTagHeader2] = make_shared<Converter::TagHeader2>(); + tags_[kTagHeader3] = make_shared<Converter::TagHeader3>(); + tags_[kTagHeader4] = make_shared<Converter::TagHeader4>(); + tags_[kTagHeader5] = make_shared<Converter::TagHeader5>(); + tags_[kTagHeader6] = make_shared<Converter::TagHeader6>(); + tags_[kTagListItem] = make_shared<Converter::TagListItem>(); + tags_[kTagOption] = make_shared<Converter::TagOption>(); + tags_[kTagOrderedList] = make_shared<Converter::TagOrderedList>(); + tags_[kTagPre] = make_shared<Converter::TagPre>(); + tags_[kTagCode] = make_shared<Converter::TagCode>(); + tags_[kTagParagraph] = make_shared<Converter::TagParagraph>(); + tags_[kTagSpan] = make_shared<Converter::TagSpan>(); + tags_[kTagUnorderedList] = make_shared<Converter::TagUnorderedList>(); + tags_[kTagTitle] = make_shared<Converter::TagTitle>(); + tags_[kTagImg] = make_shared<Converter::TagImage>(); + tags_[kTagSeperator] = make_shared<Converter::TagSeperator>(); + + // Text formatting + auto tagBold = make_shared<Converter::TagBold>(); + tags_[kTagBold] = tagBold; + tags_[kTagStrong] = tagBold; + + auto tagItalic = make_shared<Converter::TagItalic>(); + tags_[kTagItalic] = tagItalic; + tags_[kTagItalic2] = tagItalic; + tags_[kTagDefinition] = tagItalic; + tags_[kTagCitation] = tagItalic; + + tags_[kTagUnderline] = make_shared<Converter::TagUnderline>(); + + auto tagStrighthrought = make_shared<Converter::TagStrikethrought>(); + tags_[kTagStrighthrought] = tagStrighthrought; + tags_[kTagStrighthrought2] = tagStrighthrought; + + tags_[kTagBlockquote] = make_shared<Converter::TagBlockquote>(); + + // Tables + tags_[kTagTable] = make_shared<Converter::TagTable>(); + tags_[kTagTableRow] = make_shared<Converter::TagTableRow>(); + tags_[kTagTableHeader] = make_shared<Converter::TagTableHeader>(); + tags_[kTagTableData] = make_shared<Converter::TagTableData>(); +} + +void Converter::CleanUpMarkdown() { + TidyAllLines(&md_); + std::string buffer; + buffer.reserve(md_.size()); + + // Replace HTML symbols during the initial pass unless the user requested + // to keep HTML entities intact (e.g. keep ` `) + if (!option.keepHtmlEntities) { + for (size_t i = 0; i < md_.size();) { + bool replaced = false; + + // C++11 compatible iteration over htmlSymbolConversions_ + for (const auto &symbol_replacement : htmlSymbolConversions_) { + const std::string &symbol = symbol_replacement.first; + const std::string &replacement = symbol_replacement.second; + + if (md_.compare(i, symbol.size(), symbol) == 0) { + buffer.append(replacement); + i += symbol.size(); + replaced = true; + break; + } + } + + if (!replaced) { + buffer.push_back(md_[i++]); + } + } + } else { + // Keep entities as-is: copy through without transforming + buffer.append(md_); + } + + // Use swap instead of move assignment for better pre-C++11 compatibility + md_.swap(buffer); + + // Optimized replacement sequence + // Note: Multiple simple passes are faster than one complex pass due to: + // - Better branch prediction + // - Better cache locality + // - Simpler instruction patterns + const char *replacements[][2] = { + {" , ", ", "}, {"\n.\n", ".\n"}, {"\n↵\n", " ↵\n"}, {"\n*\n", "\n"}, + {"\n. ", ".\n"}, {"\t\t ", "\t\t"}, + }; + + for (const auto &replacement : replacements) { + ReplaceAll(&md_, replacement[0], replacement[1]); + } +} + +Converter *Converter::appendToMd(char ch) { + if (IsInIgnoredTag()) + return this; + + if (index_blockquote != 0 && ch == '\n') { + if (is_in_pre_) { + md_ += ch; + chars_in_curr_line_ = 0; + appendToMd(Repeat("> ", index_blockquote)); + } + + return this; + } + + md_ += ch; + + if (ch == '\n') + chars_in_curr_line_ = 0; + else + ++chars_in_curr_line_; + + return this; +} + +Converter *Converter::appendToMd(const char *str) { + if (IsInIgnoredTag()) + return this; + + md_ += str; + + auto str_len = strlen(str); + + // Efficiently update chars_in_curr_line_ by scanning for last newline + for (size_t i = 0; i < str_len; ++i) { + if (str[i] == '\n') + chars_in_curr_line_ = 0; + else + ++chars_in_curr_line_; + } + + return this; +} + +Converter *Converter::appendBlank() { + UpdatePrevChFromMd(); + + if (prev_ch_in_md_ == '\n' || + (prev_ch_in_md_ == '*' && prev_prev_ch_in_md_ == '*')) + return this; + + return appendToMd(' '); +} + +bool Converter::ok() const { + return !is_in_pre_ && !is_in_list_ && !is_in_p_ && !is_in_table_ && + !is_in_tag_ && index_blockquote == 0 && index_li == 0; +} + +void Converter::LTrim(string *s) { + (*s).erase((*s).begin(), + find_if((*s).begin(), (*s).end(), + [](unsigned char ch) { return !std::isspace((unsigned char)ch); })); +} + +Converter *Converter::RTrim(string *s, bool trim_only_blank) { + (*s).erase(find_if((*s).rbegin(), (*s).rend(), + [trim_only_blank](unsigned char ch) { + if (trim_only_blank) + return !isblank((unsigned char)ch); + + return !isspace((unsigned char)ch); + }) + .base(), + (*s).end()); + + return this; +} + +// NOTE: Pay attention when changing one of the trim functions. It can break the +// output! +Converter *Converter::Trim(string *s) { + if (!startsWith(*s, "\t") || option.forceLeftTrim) + LTrim(s); + + if (!(startsWith(*s, " "), endsWith(*s, " "))) + RTrim(s); + + return this; +} + +void Converter::TidyAllLines(string *str) { + if (str->empty()) + return; + + // Ensure input ends with newline to simplify logic + if (str->back() != '\n') { + str->push_back('\n'); + } + + size_t read = 0; + size_t write = 0; + size_t len = str->size(); + + uint8_t amount_newlines = 0; + bool in_code_block = false; + + while (read < len) { + size_t line_start = read; + size_t line_end = read; + + // Find end of line + while (line_end < len && (*str)[line_end] != '\n') { + line_end++; + } + + size_t line_len = line_end - line_start; + + // Check for code block markers + if (line_len >= 3) { + char c1 = (*str)[line_start]; + char c2 = (*str)[line_start + 1]; + char c3 = (*str)[line_start + 2]; + if ((c1 == '`' && c2 == '`' && c3 == '`') || + (c1 == '~' && c2 == '~' && c3 == '~')) { + in_code_block = !in_code_block; + } + } + + if (in_code_block) { + // Copy line as-is + if (write != line_start) { + for (size_t i = 0; i < line_len; ++i) { + (*str)[write + i] = (*str)[line_start + i]; + } + } + write += line_len; + (*str)[write++] = '\n'; + } else { + // Trim logic + size_t trim_start = line_start; + size_t trim_end = line_end; + + // Trim leading whitespace + if (option.forceLeftTrim || + (trim_start < trim_end && (*str)[trim_start] != '\t')) { + while (trim_start < trim_end && + std::isspace((unsigned char)(*str)[trim_start])) { + ++trim_start; + } + } + + // Trim trailing whitespace, preserve " " + bool has_line_break = false; + if (trim_end >= trim_start + 2 && (*str)[trim_end - 1] == ' ' && + (*str)[trim_end - 2] == ' ') { + has_line_break = true; + trim_end -= 2; + } + + while (trim_end > trim_start && + std::isspace((unsigned char)(*str)[trim_end - 1])) { + --trim_end; + } + + if (has_line_break) { + trim_end += 2; + } + + size_t trimmed_len = trim_end - trim_start; + + if (trimmed_len == 0) { + // Empty line + if (amount_newlines < 2 && write > 0) { + (*str)[write++] = '\n'; + amount_newlines++; + } + } else { + amount_newlines = 0; + if (write != trim_start) { + for (size_t i = 0; i < trimmed_len; ++i) { + (*str)[write + i] = (*str)[trim_start + i]; + } + } + write += trimmed_len; + (*str)[write++] = '\n'; + } + } + + read = line_end + 1; + } + + str->resize(write); +} + +string Converter::ExtractAttributeFromTagLeftOf(const string &attr) { + // Extract the whole tag from current offset, e.g. from '>', backwards + auto tag = html_.substr(offset_lt_, index_ch_in_html_ - offset_lt_); + string lowerTag = toLower(tag); // Convert tag to lowercase for comparison + + // locate given attribute (case-insensitive) + auto offset_attr = lowerTag.find(attr); + + if (offset_attr == string::npos) + return ""; + + // locate attribute-value pair's '=' + auto offset_equals = tag.find('=', offset_attr); + + if (offset_equals == string::npos) + return ""; + + // locate value's surrounding quotes + auto offset_double_quote = tag.find('"', offset_equals); + auto offset_single_quote = tag.find('\'', offset_equals); + + bool has_double_quote = offset_double_quote != string::npos; + bool has_single_quote = offset_single_quote != string::npos; + + if (!has_double_quote && !has_single_quote) + return ""; + + char wrapping_quote = 0; + + size_t offset_opening_quote = 0; + size_t offset_closing_quote = 0; + + if (has_double_quote) { + if (!has_single_quote) { + wrapping_quote = '"'; + offset_opening_quote = offset_double_quote; + } else { + if (offset_double_quote < offset_single_quote) { + wrapping_quote = '"'; + offset_opening_quote = offset_double_quote; + } else { + wrapping_quote = '\''; + offset_opening_quote = offset_single_quote; + } + } + } else { + // has only single quote + wrapping_quote = '\''; + offset_opening_quote = offset_single_quote; + } + + if (offset_opening_quote == string::npos) + return ""; + + offset_closing_quote = tag.find(wrapping_quote, offset_opening_quote + 1); + + if (offset_closing_quote == string::npos) + return ""; + + return tag.substr(offset_opening_quote + 1, + offset_closing_quote - 1 - offset_opening_quote); +} + +void Converter::TurnLineIntoHeader1() { + appendToMd('\n' + Repeat("=", chars_in_curr_line_) + "\n\n"); + + chars_in_curr_line_ = 0; +} + +void Converter::TurnLineIntoHeader2() { + appendToMd('\n' + Repeat("-", chars_in_curr_line_) + "\n\n"); + + chars_in_curr_line_ = 0; +} + +string Converter::convert() { + // We already converted + if (index_ch_in_html_ == html_.size()) + return md_; + + reset(); + + for (char ch : html_) { + ++index_ch_in_html_; + + if (!is_in_tag_ && ch == '<') { + OnHasEnteredTag(); + + continue; + } + + if (is_in_tag_) + ParseCharInTag(ch); + else + ParseCharInTagContent(ch); + } + + CleanUpMarkdown(); + + // Remove trailing double newline if present (keep only single newline) + if (md_.size() >= 2 && md_[md_.size() - 1] == '\n' && md_[md_.size() - 2] == '\n') { + md_.pop_back(); + } + + return md_; +} + +void Converter::OnHasEnteredTag() { + offset_lt_ = index_ch_in_html_; + is_in_tag_ = true; + is_closing_tag_ = false; + prev_tag_ = current_tag_; + current_tag_ = ""; + + if (!md_.empty()) { + UpdatePrevChFromMd(); + } +} + +Converter *Converter::UpdatePrevChFromMd() { + if (!md_.empty()) { + prev_ch_in_md_ = md_[md_.length() - 1]; + + if (md_.length() > 1) + prev_prev_ch_in_md_ = md_[md_.length() - 2]; + } + + return this; +} + +bool Converter::ParseCharInTag(char ch) { + + if (ch == '/' && !is_in_attribute_value_) { + is_closing_tag_ = current_tag_.empty(); + is_self_closing_tag_ = !is_closing_tag_; + skipping_leading_whitespace_ = true; // Reset for next tag + return true; + } + + if (ch == '>') { + // Trim trailing whitespace by removing characters from current_tag_ + while (!current_tag_.empty() && std::isspace(static_cast<unsigned char>(current_tag_.back()))) { + current_tag_.pop_back(); + } + skipping_leading_whitespace_ = true; // Reset for next tag + if (!is_self_closing_tag_) + return OnHasLeftTag(); + else { + OnHasLeftTag(); + is_self_closing_tag_ = false; + is_closing_tag_ = true; + return OnHasLeftTag(); + } + } + + if (ch == '"') { + if (is_in_attribute_value_) { + is_in_attribute_value_ = false; + } else { + size_t pos = current_tag_.length(); + while (pos > 0 && isspace((unsigned char)current_tag_[pos - 1])) { + pos--; + } + if (pos > 0 && current_tag_[pos - 1] == '=') { + is_in_attribute_value_ = true; + } + } + skipping_leading_whitespace_ = false; // Stop skipping after attribute + return true; + } + + // Handle whitespace: skip leading whitespace, keep others + if (isspace((unsigned char)ch) && skipping_leading_whitespace_) { + return true; // Ignore leading whitespace + } + + // Once we encounter a non-whitespace character, stop skipping + skipping_leading_whitespace_ = false; + current_tag_ += tolower((unsigned char)ch); + return false; +} + +bool Converter::OnHasLeftTag() { + is_in_tag_ = false; + + UpdatePrevChFromMd(); + + if (!is_closing_tag_) + if (TagContainsAttributesToHide(¤t_tag_)) + return true; + + // Extract tag name without Split() - just find first space + size_t space_pos = current_tag_.find(' '); + if (space_pos != string::npos) { + current_tag_ = current_tag_.substr(0, space_pos); + } + + if (current_tag_.empty()) + return true; + + auto tag = tags_[current_tag_]; + + if (!tag) + return true; + + if (!is_closing_tag_) { + tag->OnHasLeftOpeningTag(this); + } + else { + is_closing_tag_ = false; + + tag->OnHasLeftClosingTag(this); + } + + return true; +} + +Converter *Converter::ShortenMarkdown(size_t chars) { + md_ = md_.substr(0, md_.length() - chars); + + if (chars > chars_in_curr_line_) + chars_in_curr_line_ = 0; + else + chars_in_curr_line_ = chars_in_curr_line_ - chars; + + return this->UpdatePrevChFromMd(); +} + +bool Converter::ParseCharInTagContent(char ch) { + if (is_in_code_) { + md_ += ch; + + if (index_blockquote != 0 && ch == '\n') + appendToMd(Repeat("> ", index_blockquote)); + + return true; + } + + if (option.compressWhitespace && !is_in_pre_) { + if (ch == '\t') + ch = ' '; + + if (ch == ' ') { + UpdatePrevChFromMd(); + if (prev_ch_in_md_ == ' ' || prev_ch_in_md_ == '\n') + return true; + } + } + + if (IsInIgnoredTag() || current_tag_ == kTagLink) { + prev_ch_in_html_ = ch; + + return true; + } + + if (ch == '\n') { + if (index_blockquote != 0) { + md_ += '\n'; + chars_in_curr_line_ = 0; + appendToMd(Repeat("> ", index_blockquote)); + } + + return true; + } + + switch (ch) { + case '*': + appendToMd("\\*"); + break; + case '`': + appendToMd("\\`"); + break; + case '\\': + appendToMd("\\\\"); + break; + case '.': { + bool is_ordered_list_start = false; + if (chars_in_curr_line_ > 0) { + size_t start_idx = md_.length() - chars_in_curr_line_; + size_t idx = start_idx; + // Skip spaces + while (idx < md_.length() && isspace((unsigned char)md_[idx])) { + idx++; + } + // Check digits + bool has_digits = false; + while (idx < md_.length() && isdigit((unsigned char)md_[idx])) { + has_digits = true; + idx++; + } + // If we reached the end and had digits, it's a match + if (has_digits && idx == md_.length()) { + is_ordered_list_start = true; + } + } + + if (is_ordered_list_start && option.escapeNumberedList) { + appendToMd("\\."); + } else { + md_ += ch; + ++chars_in_curr_line_; + } + break; + } + default: + md_ += ch; + ++chars_in_curr_line_; + break; + } + + if (chars_in_curr_line_ > option.softBreak && !is_in_table_ && !is_in_list_ && + current_tag_ != kTagImg && current_tag_ != kTagAnchor && + option.splitLines) { + if (ch == ' ') { // If the next char is - it will become a list + md_ += '\n'; + chars_in_curr_line_ = 0; + } else if (chars_in_curr_line_ > option.hardBreak) { + ReplacePreviousSpaceInLineByNewline(); + } + } + + return false; +} + +bool Converter::ReplacePreviousSpaceInLineByNewline() { + if (current_tag_ == kTagParagraph || + is_in_table_ && (prev_tag_ != kTagCode && prev_tag_ != kTagPre)) + return false; + + auto offset = md_.length() - 1; + + if (md_.length() == 0) + return true; + + do { + if (md_[offset] == '\n') + return false; + + if (md_[offset] == ' ') { + md_[offset] = '\n'; + chars_in_curr_line_ = md_.length() - offset; + + return true; + } + + --offset; + } while (offset > 0); + + return false; +} + +void Converter::TagAnchor::OnHasLeftOpeningTag(Converter *c) { + if (c->prev_tag_ == kTagImg) + c->appendToMd('\n'); + + current_title_ = c->ExtractAttributeFromTagLeftOf(kAttributeTitle); + + c->appendToMd('['); + current_href_ = c->ExtractAttributeFromTagLeftOf(kAttributeHref); +} + +void Converter::TagAnchor::OnHasLeftClosingTag(Converter *c) { + if (!c->shortIfPrevCh('[')) { + c->appendToMd("](")->appendToMd(current_href_); + + // If title is set append it + if (!current_title_.empty()) { + c->appendToMd(" \"")->appendToMd(current_title_)->appendToMd('"'); + current_title_.clear(); + } + + c->appendToMd(')'); + + if (c->prev_tag_ == kTagImg) + c->appendToMd('\n'); + } +} + +void Converter::TagBold::OnHasLeftOpeningTag(Converter *c) { + c->appendToMd("**"); +} + +void Converter::TagBold::OnHasLeftClosingTag(Converter *c) { + c->appendToMd("**"); +} + +void Converter::TagItalic::OnHasLeftOpeningTag(Converter *c) { + c->appendToMd('*'); +} + +void Converter::TagItalic::OnHasLeftClosingTag(Converter *c) { + c->appendToMd('*'); +} + +void Converter::TagUnderline::OnHasLeftOpeningTag(Converter *c) { + c->appendToMd("<u>"); +} + +void Converter::TagUnderline::OnHasLeftClosingTag(Converter *c) { + c->appendToMd("</u>"); +} + +void Converter::TagStrikethrought::OnHasLeftOpeningTag(Converter *c) { + c->appendToMd('~'); +} + +void Converter::TagStrikethrought::OnHasLeftClosingTag(Converter *c) { + c->appendToMd('~'); +} + +void Converter::TagBreak::OnHasLeftOpeningTag(Converter *c) { + if (c->is_in_list_) { // When it's in a list, it's not in a paragraph + c->appendToMd(" \n"); + c->appendToMd(Repeat(" ", c->index_li)); + } else if (c->is_in_table_) { + c->appendToMd("<br>"); + } else if (!c->md_.empty()) + c->appendToMd(" \n"); +} + +void Converter::TagBreak::OnHasLeftClosingTag(Converter *c) {} + +void Converter::TagDiv::OnHasLeftOpeningTag(Converter *c) { + if (c->prev_ch_in_md_ != '\n') + c->appendToMd('\n'); + + if (c->prev_prev_ch_in_md_ != '\n') + c->appendToMd('\n'); +} + +void Converter::TagDiv::OnHasLeftClosingTag(Converter *c) {} + +void Converter::TagHeader1::OnHasLeftOpeningTag(Converter *c) { + c->appendToMd("\n# "); +} + +void Converter::TagHeader1::OnHasLeftClosingTag(Converter *c) { + if (c->prev_prev_ch_in_md_ != ' ') + c->appendToMd('\n'); +} + +void Converter::TagHeader2::OnHasLeftOpeningTag(Converter *c) { + c->appendToMd("\n## "); +} + +void Converter::TagHeader2::OnHasLeftClosingTag(Converter *c) { + if (c->prev_prev_ch_in_md_ != ' ') + c->appendToMd('\n'); +} + +void Converter::TagHeader3::OnHasLeftOpeningTag(Converter *c) { + c->appendToMd("\n### "); +} + +void Converter::TagHeader3::OnHasLeftClosingTag(Converter *c) { + if (c->prev_prev_ch_in_md_ != ' ') + c->appendToMd('\n'); +} + +void Converter::TagHeader4::OnHasLeftOpeningTag(Converter *c) { + c->appendToMd("\n#### "); +} + +void Converter::TagHeader4::OnHasLeftClosingTag(Converter *c) { + if (c->prev_prev_ch_in_md_ != ' ') + c->appendToMd('\n'); +} + +void Converter::TagHeader5::OnHasLeftOpeningTag(Converter *c) { + c->appendToMd("\n##### "); +} + +void Converter::TagHeader5::OnHasLeftClosingTag(Converter *c) { + if (c->prev_prev_ch_in_md_ != ' ') + c->appendToMd('\n'); +} + +void Converter::TagHeader6::OnHasLeftOpeningTag(Converter *c) { + c->appendToMd("\n###### "); +} + +void Converter::TagHeader6::OnHasLeftClosingTag(Converter *c) { + if (c->prev_prev_ch_in_md_ != ' ') + c->appendToMd('\n'); +} + +void Converter::TagListItem::OnHasLeftOpeningTag(Converter *c) { + if (c->is_in_table_) + return; + + if (!c->is_in_ordered_list_) { + c->appendToMd(string({c->option.unorderedList, ' '})); + return; + } + + ++c->index_ol; + + string num = std::to_string(c->index_ol); + num.append({c->option.orderedList, ' '}); + c->appendToMd(num); +} + +void Converter::TagListItem::OnHasLeftClosingTag(Converter *c) { + if (c->is_in_table_) + return; + + if (c->prev_ch_in_md_ != '\n') + c->appendToMd('\n'); +} + +void Converter::TagOption::OnHasLeftOpeningTag(Converter *c) {} + +void Converter::TagOption::OnHasLeftClosingTag(Converter *c) { + if (c->md_.length() > 0) + c->appendToMd(" \n"); +} + +void Converter::TagOrderedList::OnHasLeftOpeningTag(Converter *c) { + if (c->is_in_table_) + return; + + c->is_in_list_ = true; + c->is_in_ordered_list_ = true; + c->index_ol = 0; + + ++c->index_li; + + c->ReplacePreviousSpaceInLineByNewline(); + + c->appendToMd('\n'); +} + +void Converter::TagOrderedList::OnHasLeftClosingTag(Converter *c) { + if (c->is_in_table_) + return; + + c->is_in_ordered_list_ = false; + + if (c->index_li != 0) + --c->index_li; + + c->is_in_list_ = c->index_li != 0; + + c->appendToMd('\n'); +} + +void Converter::TagParagraph::OnHasLeftOpeningTag(Converter *c) { + c->is_in_p_ = true; + + if (c->is_in_list_ && c->prev_tag_ == kTagParagraph) + c->appendToMd("\n\t"); + else if (!c->is_in_list_) + c->appendToMd('\n'); +} + +void Converter::TagParagraph::OnHasLeftClosingTag(Converter *c) { + c->is_in_p_ = false; + + if (!c->md_.empty()) + c->appendToMd("\n"); // Workaround \n restriction for blockquotes + + if (c->index_blockquote != 0) + c->appendToMd(Repeat("> ", c->index_blockquote)); +} + +void Converter::TagPre::OnHasLeftOpeningTag(Converter *c) { + c->is_in_pre_ = true; + + if (c->prev_ch_in_md_ != '\n') + c->appendToMd('\n'); + + if (c->prev_prev_ch_in_md_ != '\n') + c->appendToMd('\n'); + + if (c->is_in_list_ && c->prev_tag_ != kTagParagraph) + c->ShortenMarkdown(2); + + if (c->is_in_list_) + c->appendToMd("\t\t"); + else + c->appendToMd("```"); +} + +void Converter::TagPre::OnHasLeftClosingTag(Converter *c) { + c->is_in_pre_ = false; + + if (c->is_in_list_) + return; + + c->appendToMd("```"); + c->appendToMd('\n'); // Don't combine because of blockquote +} + +void Converter::TagCode::OnHasLeftOpeningTag(Converter *c) { + c->is_in_code_ = true; + + if (c->is_in_pre_) { + if (c->is_in_list_) + return; + + auto code = c->ExtractAttributeFromTagLeftOf(kAttributeClass); + if (!code.empty()) { + if (startsWith(code, "language-")) + code.erase(0, 9); // remove language- + c->appendToMd(code); + } + c->appendToMd('\n'); + } else + c->appendToMd('`'); +} + +void Converter::TagCode::OnHasLeftClosingTag(Converter *c) { + c->is_in_code_ = false; + + if (c->is_in_pre_) + return; + + c->appendToMd('`'); +} + +void Converter::TagSpan::OnHasLeftOpeningTag(Converter *c) {} + +void Converter::TagSpan::OnHasLeftClosingTag(Converter *c) {} + +void Converter::TagTitle::OnHasLeftOpeningTag(Converter *c) {} + +void Converter::TagTitle::OnHasLeftClosingTag(Converter *c) { + c->TurnLineIntoHeader1(); +} + +void Converter::TagUnorderedList::OnHasLeftOpeningTag(Converter *c) { + if (c->is_in_list_ || c->is_in_table_) + return; + + c->is_in_list_ = true; + + ++c->index_li; + + c->appendToMd('\n'); +} + +void Converter::TagUnorderedList::OnHasLeftClosingTag(Converter *c) { + if (c->is_in_table_) + return; + + if (c->index_li != 0) + --c->index_li; + + c->is_in_list_ = c->index_li != 0; + + if (c->prev_prev_ch_in_md_ == '\n' && c->prev_ch_in_md_ == '\n') + c->ShortenMarkdown(); + else if (c->prev_ch_in_md_ != '\n') + c->appendToMd('\n'); +} + +void Converter::TagImage::OnHasLeftOpeningTag(Converter *c) { + if (c->prev_tag_ != kTagAnchor && c->prev_ch_in_md_ != '\n') + c->appendToMd('\n'); + + c->appendToMd("![") + ->appendToMd(c->ExtractAttributeFromTagLeftOf(kAttributeAlt)) + ->appendToMd("](") + ->appendToMd(c->ExtractAttributeFromTagLeftOf(kAttributeSrc)); + + auto title = c->ExtractAttributeFromTagLeftOf(kAttributeTitle); + if (!title.empty()) { + c->appendToMd(" \"")->appendToMd(title)->appendToMd('"'); + } + + c->appendToMd(")"); +} + +void Converter::TagImage::OnHasLeftClosingTag(Converter *c) { + if (c->prev_tag_ == kTagAnchor) + c->appendToMd('\n'); +} + +void Converter::TagSeperator::OnHasLeftOpeningTag(Converter *c) { + c->appendToMd("\n---\n"); // NOTE: We can make this an option +} + +void Converter::TagSeperator::OnHasLeftClosingTag(Converter *c) {} + +void Converter::TagTable::OnHasLeftOpeningTag(Converter *c) { + c->is_in_table_ = true; + c->appendToMd('\n'); + c->table_start = c->md_.length(); // Set start AFTER the newline +} + +void Converter::TagTable::OnHasLeftClosingTag(Converter *c) { + c->is_in_table_ = false; + c->appendToMd('\n'); + + if (!c->option.formatTable) + return; + + string table = c->md_.substr(c->table_start); + table = formatMarkdownTable(table); + c->ShortenMarkdown(c->md_.size() - c->table_start); + c->appendToMd(table); +} + +void Converter::TagTableRow::OnHasLeftOpeningTag(Converter *c) { + // Don't add newline here - it creates empty rows + // The newline is added by the closing tag of the previous row +} + +void Converter::TagTableRow::OnHasLeftClosingTag(Converter *c) { + c->UpdatePrevChFromMd(); + + // Always close the row with a pipe and space, then newline + if (c->prev_ch_in_md_ != '|') { + c->appendToMd(" |"); + } + c->appendToMd('\n'); + + if (!c->tableLine.empty()) { + c->tableLine.append("|\n"); + c->appendToMd(c->tableLine); + c->tableLine.clear(); + } +} + + +void Converter::TagTableHeader::OnHasLeftOpeningTag(Converter *c) { + auto align = c->ExtractAttributeFromTagLeftOf(kAttrinuteAlign); + + string line = "| "; + + if (align == "left" || align == "center") + line += ':'; + + line += '-'; + + if (align == "right" || align == "center") + line += ": "; + else + line += ' '; + + c->tableLine.append(line); + + c->appendToMd("| "); +} + +void Converter::TagTableHeader::OnHasLeftClosingTag(Converter *c) { + c->appendToMd(" "); +} + + +void Converter::TagTableData::OnHasLeftOpeningTag(Converter *c) { + c->appendToMd("| "); +} + + +void Converter::TagTableData::OnHasLeftClosingTag(Converter *c) { + c->appendToMd(" "); +} + + +void Converter::TagBlockquote::OnHasLeftOpeningTag(Converter *c) { + ++c->index_blockquote; + c->appendToMd("\n"); + c->appendToMd(Repeat("> ", c->index_blockquote)); +} + +void Converter::TagBlockquote::OnHasLeftClosingTag(Converter *c) { + --c->index_blockquote; + // Only shorten if a "> " was added (i.e., a newline was processed in the blockquote) + if (!c->md_.empty() && c->md_.length() >= 2 && + c->md_.substr(c->md_.length() - 2) == "> ") { + c->ShortenMarkdown(2); // Remove the '> ' only if it exists + } +} + +void Converter::reset() { + md_.clear(); + prev_ch_in_md_ = 0; + prev_prev_ch_in_md_ = 0; + index_ch_in_html_ = 0; +} + +bool Converter::IsInIgnoredTag() const { + if (current_tag_ == kTagTitle && !option.includeTitle) + return true; + + return IsIgnoredTag(current_tag_); +} +} // namespace html2md diff --git a/packages/media/cpp/packages/html/src/table.cpp b/packages/media/cpp/packages/html/src/table.cpp new file mode 100644 index 00000000..9a1ccdc3 --- /dev/null +++ b/packages/media/cpp/packages/html/src/table.cpp @@ -0,0 +1,106 @@ +// Copyright (c) Tim Gromeyer +// Licensed under the MIT License - https://opensource.org/licenses/MIT + +#include "html/table.h" + +#include <iomanip> +#include <iostream> +#include <sstream> +#include <vector> + +using std::string; +using std::vector; + +const size_t MIN_LINE_LENGTH = 3; // Minimum length of line + +void removeLeadingTrailingSpaces(string &str) { + size_t firstNonSpace = str.find_first_not_of(' '); + if (firstNonSpace == string::npos) { + str.clear(); // Entire string is spaces + return; + } + + size_t lastNonSpace = str.find_last_not_of(' '); + str = str.substr(firstNonSpace, lastNonSpace - firstNonSpace + 1); +} + +string enlargeTableHeaderLine(const string &str, size_t length) { + if (str.empty() || length < MIN_LINE_LENGTH) + return ""; + + size_t first = str.find_first_of(':'); + size_t last = str.find_last_of(':'); + + if (first == 0 && first == last) + last = string::npos; + + string line = string(length, '-'); + + if (first == 0) + line[0] = ':'; + if (last == str.length() - 1) + line[length - 1] = ':'; + + return line; +} + +string formatMarkdownTable(const string &inputTable) { + std::istringstream iss(inputTable); + string line; + vector<vector<string>> tableData; + + // Parse the input table into a 2D vector + while (std::getline(iss, line)) { + std::istringstream lineStream(line); + string cell; + vector<string> rowData; + + while (std::getline(lineStream, cell, '|')) { + removeLeadingTrailingSpaces(cell); // Trim first + if (!cell.empty()) { // Then check if empty + rowData.push_back(cell); + } + } + + if (!rowData.empty()) { + tableData.push_back(std::move(rowData)); // Move rowData to avoid copying + } + } + + if (tableData.empty()) { + return ""; + } + + // Determine maximum width of each column + vector<size_t> columnWidths(tableData[0].size(), 0); + for (const auto &row : tableData) { + if (columnWidths.size() < row.size()) { + columnWidths.resize(row.size(), 0); + } + + for (size_t i = 0; i < row.size(); ++i) { + columnWidths[i] = std::max(columnWidths[i], row[i].size()); + } + } + + // Build the formatted table + std::ostringstream formattedTable; + for (size_t rowNumber = 0; rowNumber < tableData.size(); ++rowNumber) { + const auto &row = tableData[rowNumber]; + + formattedTable << "|"; + + for (size_t i = 0; i < row.size(); ++i) { + if (rowNumber == 1) { + formattedTable << enlargeTableHeaderLine(row[i], columnWidths[i] + 2) + << "|"; + continue; + } + formattedTable << " " << std::setw(columnWidths[i]) << std::left << row[i] + << " |"; + } + formattedTable << "\n"; + } + + return formattedTable.str(); +} diff --git a/packages/media/cpp/packages/http/CMakeLists.txt b/packages/media/cpp/packages/http/CMakeLists.txt new file mode 100644 index 00000000..9b170f55 --- /dev/null +++ b/packages/media/cpp/packages/http/CMakeLists.txt @@ -0,0 +1,48 @@ +include(FetchContent) + +# Work around curl's old cmake_minimum_required for CMake 4.x +set(CMAKE_POLICY_VERSION_MINIMUM 3.5 CACHE STRING "" FORCE) + +FetchContent_Declare( + CURL + URL https://github.com/curl/curl/releases/download/curl-8_12_1/curl-8.12.1.tar.xz + DOWNLOAD_EXTRACT_TIMESTAMP TRUE +) + +# Minimal curl build — static, SChannel TLS, no optional deps +set(BUILD_CURL_EXE OFF CACHE BOOL "" FORCE) +set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) +set(BUILD_TESTING OFF CACHE BOOL "" FORCE) + +# TLS backend: platform-appropriate +if(WIN32) + set(CURL_USE_OPENSSL OFF CACHE BOOL "" FORCE) + set(CURL_USE_SCHANNEL ON CACHE BOOL "" FORCE) +else() + set(CURL_USE_SCHANNEL OFF CACHE BOOL "" FORCE) + set(CURL_USE_OPENSSL ON CACHE BOOL "" FORCE) +endif() + +# Disable optional compression/protocol deps +set(CURL_ZLIB OFF CACHE BOOL "" FORCE) +set(CURL_BROTLI OFF CACHE BOOL "" FORCE) +set(CURL_ZSTD OFF CACHE BOOL "" FORCE) +set(USE_NGHTTP2 OFF CACHE BOOL "" FORCE) +set(CURL_USE_LIBSSH2 OFF CACHE BOOL "" FORCE) +set(CURL_USE_LIBPSL OFF CACHE BOOL "" FORCE) +set(CURL_DISABLE_LDAP ON CACHE BOOL "" FORCE) +set(CURL_DISABLE_LDAPS ON CACHE BOOL "" FORCE) + +FetchContent_MakeAvailable(CURL) + +add_library(http STATIC + src/http.cpp +) + +target_include_directories(http + PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include +) + +target_link_libraries(http + PUBLIC CURL::libcurl +) diff --git a/packages/media/cpp/packages/http/include/http/http.h b/packages/media/cpp/packages/http/include/http/http.h new file mode 100644 index 00000000..1f848d68 --- /dev/null +++ b/packages/media/cpp/packages/http/include/http/http.h @@ -0,0 +1,40 @@ +#pragma once + +#include <string> + +namespace http { + +struct Response { + long status_code; + std::string body; +}; + +/// Options for customisable HTTP GET requests. +struct GetOptions { + std::string user_agent = "Mozilla/5.0 (compatible; PolymechBot/1.0)"; + int timeout_ms = 10000; + bool follow_redirects = true; +}; + +/// Perform an HTTP GET request. Returns the response body and status code. +Response get(const std::string &url); + +/// Perform an HTTP GET request with custom options. +Response get(const std::string &url, const GetOptions &opts); + +/// Perform an HTTP POST request with a body. Returns the response and status. +Response post(const std::string &url, const std::string &body, + const std::string &content_type = "application/json"); + +/// Options for customisable HTTP POST requests. +struct PostOptions { + std::string content_type = "application/json"; + std::string bearer_token; // Authorization: Bearer <token> + int timeout_ms = 30000; +}; + +/// Perform an HTTP POST request with custom options. +Response post(const std::string &url, const std::string &body, + const PostOptions &opts); + +} // namespace http diff --git a/packages/media/cpp/packages/http/src/http.cpp b/packages/media/cpp/packages/http/src/http.cpp new file mode 100644 index 00000000..f0c3fe76 --- /dev/null +++ b/packages/media/cpp/packages/http/src/http.cpp @@ -0,0 +1,216 @@ +#include "http/http.h" + +#include <curl/curl.h> +#include <mutex> +#include <chrono> + +namespace http { + +static std::once_flag curl_init_flag; +static void ensure_curl_init() { + std::call_once(curl_init_flag, []() { + curl_global_init(CURL_GLOBAL_ALL); + }); +} + +struct ThreadLocalCurl { + CURL *handle; + ThreadLocalCurl() { + ensure_curl_init(); + handle = curl_easy_init(); + } + ~ThreadLocalCurl() { + if (handle) curl_easy_cleanup(handle); + } + CURL *get() { + if (handle) curl_easy_reset(handle); + return handle; + } +}; + +thread_local ThreadLocalCurl tl_curl; + +struct ProgressData { + std::chrono::steady_clock::time_point start_time; + int timeout_ms; +}; + +static int progress_cb(void *clientp, curl_off_t dltotal, curl_off_t dlnow, + curl_off_t ultotal, curl_off_t ulnow) { + auto *pd = static_cast<ProgressData *>(clientp); + if (pd->timeout_ms <= 0) return 0; + + auto now = std::chrono::steady_clock::now(); + auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(now - pd->start_time).count(); + if (elapsed > pd->timeout_ms) { + return 1; // Return non-zero to abort the transfer + } + return 0; // Continue +} + +static size_t write_cb(void *contents, size_t size, size_t nmemb, void *userp) { + auto *out = static_cast<std::string *>(userp); + out->append(static_cast<char *>(contents), size * nmemb); + return size * nmemb; +} + +Response get(const std::string &url) { + return get(url, GetOptions{}); +} + +Response get(const std::string &url, const GetOptions &opts) { + Response resp{}; + + CURL *curl = tl_curl.get(); + if (!curl) { + resp.status_code = -1; + resp.body = "curl_easy_init (thread_local) failed"; + return resp; + } + + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &resp.body); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, opts.follow_redirects ? 1L : 0L); + + ProgressData prog_data; + if (opts.timeout_ms > 0) { + curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, static_cast<long>(opts.timeout_ms)); + prog_data.start_time = std::chrono::steady_clock::now(); + prog_data.timeout_ms = opts.timeout_ms + 1000; + curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, progress_cb); + curl_easy_setopt(curl, CURLOPT_XFERINFODATA, &prog_data); + curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L); + } + + // Fail fast on dead sites (TCP SYN timeout) + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, 5000L); + + // Prevent stalling: abort if transfer speed is less than 1 byte/sec for 10 seconds + curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1L); + curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 10L); + + // Prevent signal handlers from breaking in multithreaded environments + curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); + + if (!opts.user_agent.empty()) { + curl_easy_setopt(curl, CURLOPT_USERAGENT, opts.user_agent.c_str()); + } + + // Accept-Encoding for compressed responses + curl_easy_setopt(curl, CURLOPT_ACCEPT_ENCODING, ""); + + CURLcode res = curl_easy_perform(curl); + if (res != CURLE_OK) { + resp.status_code = -1; + resp.body = curl_easy_strerror(res); + } else { + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &resp.status_code); + } + + return resp; +} + +Response post(const std::string &url, const std::string &body, + const std::string &content_type) { + Response resp{}; + + CURL *curl = tl_curl.get(); + if (!curl) { + resp.status_code = -1; + resp.body = "curl_easy_init failed"; + return resp; + } + + struct curl_slist *headers = nullptr; + headers = + curl_slist_append(headers, ("Content-Type: " + content_type).c_str()); + + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.c_str()); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &resp.body); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L); + + ProgressData prog_data; + prog_data.start_time = std::chrono::steady_clock::now(); + prog_data.timeout_ms = 11000; + curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, progress_cb); + curl_easy_setopt(curl, CURLOPT_XFERINFODATA, &prog_data); + curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L); + + // Prevent stalling: abort if transfer speed is less than 1 byte/sec for 10 seconds + curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1L); + curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 10L); + curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); + + CURLcode res = curl_easy_perform(curl); + if (res != CURLE_OK) { + resp.status_code = -1; + resp.body = curl_easy_strerror(res); + } else { + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &resp.status_code); + } + + curl_slist_free_all(headers); + return resp; +} + +Response post(const std::string &url, const std::string &body, + const PostOptions &opts) { + Response resp{}; + + CURL *curl = tl_curl.get(); + if (!curl) { + resp.status_code = -1; + resp.body = "curl_easy_init failed"; + return resp; + } + + struct curl_slist *headers = nullptr; + headers = + curl_slist_append(headers, ("Content-Type: " + opts.content_type).c_str()); + if (!opts.bearer_token.empty()) { + headers = curl_slist_append( + headers, ("Authorization: Bearer " + opts.bearer_token).c_str()); + headers = curl_slist_append( + headers, ("x-api-token: " + opts.bearer_token).c_str()); + } + + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.c_str()); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &resp.body); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + + ProgressData prog_data; + if (opts.timeout_ms > 0) { + curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, static_cast<long>(opts.timeout_ms)); + prog_data.start_time = std::chrono::steady_clock::now(); + prog_data.timeout_ms = opts.timeout_ms + 1000; + curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, progress_cb); + curl_easy_setopt(curl, CURLOPT_XFERINFODATA, &prog_data); + curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L); + } + + // Prevent stalling: abort if transfer speed is less than 1 byte/sec for 10 seconds + curl_easy_setopt(curl, CURLOPT_LOW_SPEED_LIMIT, 1L); + curl_easy_setopt(curl, CURLOPT_LOW_SPEED_TIME, 10L); + curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); + + CURLcode res = curl_easy_perform(curl); + if (res != CURLE_OK) { + resp.status_code = -1; + resp.body = curl_easy_strerror(res); + } else { + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &resp.status_code); + } + + curl_slist_free_all(headers); + return resp; +} + +} // namespace http diff --git a/packages/media/cpp/packages/ipc/CMakeLists.txt b/packages/media/cpp/packages/ipc/CMakeLists.txt new file mode 100644 index 00000000..e6e133a1 --- /dev/null +++ b/packages/media/cpp/packages/ipc/CMakeLists.txt @@ -0,0 +1,45 @@ +cmake_minimum_required(VERSION 3.20) + +project(ipc CXX) + +option(IPC_BUILD_SHARED "Build ipc as a shared library (DLL/so)" OFF) + +set(_ipc_sources src/ipc.cpp) + +if(IPC_BUILD_SHARED) + add_library(ipc SHARED ${_ipc_sources}) + target_compile_definitions(ipc PRIVATE IPC_BUILDING_LIBRARY) +else() + add_library(ipc STATIC ${_ipc_sources}) + target_compile_definitions(ipc PRIVATE IPC_STATIC_BUILD=1) + target_compile_definitions(ipc INTERFACE IPC_STATIC_BUILD=1) +endif() + +target_include_directories(ipc + PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include +) + +target_link_libraries(ipc + PUBLIC json logger +) + +if(IPC_BUILD_SHARED) + set_target_properties(ipc PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/dist" + RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_SOURCE_DIR}/dist" + RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_SOURCE_DIR}/dist" + LIBRARY_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/dist" + ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/dist" + ) +endif() + +install(TARGETS ipc + ARCHIVE DESTINATION lib + LIBRARY DESTINATION lib + RUNTIME DESTINATION bin +) +install(FILES + ${CMAKE_CURRENT_SOURCE_DIR}/include/ipc/ipc.h + ${CMAKE_CURRENT_SOURCE_DIR}/include/ipc/ipc_export.h + DESTINATION include/ipc +) diff --git a/packages/media/cpp/packages/ipc/include/ipc/ipc.h b/packages/media/cpp/packages/ipc/include/ipc/ipc.h new file mode 100644 index 00000000..6577ddca --- /dev/null +++ b/packages/media/cpp/packages/ipc/include/ipc/ipc.h @@ -0,0 +1,35 @@ +#pragma once + +#include "ipc/ipc_export.h" +#include <cstdint> +#include <cstdio> +#include <string> +#include <vector> + +namespace ipc { + +/// A single IPC message: { id, type, payload (raw JSON string) }. +struct Message { + std::string id; + std::string type; + std::string payload; // opaque JSON string (can be "{}" or any object) +}; + +/// Encode a Message into a length-prefixed binary frame. +/// Layout: [4-byte LE uint32 length][JSON bytes] +IPC_API std::vector<uint8_t> encode(const Message &msg); + +/// Decode a binary frame (without the 4-byte length prefix) into a Message. +/// Returns false if the JSON is invalid or missing required fields. +IPC_API bool decode(const uint8_t *data, size_t len, Message &out); +IPC_API bool decode(const std::vector<uint8_t> &frame, Message &out); + +/// Blocking: read exactly one length-prefixed message from a FILE*. +/// Returns false on EOF or read error. +IPC_API bool read_message(Message &out, FILE *in = stdin); + +/// Write one length-prefixed message to a FILE*. Flushes after write. +/// Returns false on write error. +IPC_API bool write_message(const Message &msg, FILE *out = stdout); + +} // namespace ipc diff --git a/packages/media/cpp/packages/ipc/include/ipc/ipc_export.h b/packages/media/cpp/packages/ipc/include/ipc/ipc_export.h new file mode 100644 index 00000000..c4ad514a --- /dev/null +++ b/packages/media/cpp/packages/ipc/include/ipc/ipc_export.h @@ -0,0 +1,25 @@ +#pragma once + +/** + * DLL / shared-object exports for the length-prefixed JSON IPC framing library. + * + * CMake: + * - Building libipc: IPC_BUILDING_LIBRARY (PRIVATE) + * - Linking static ipc: IPC_STATIC_BUILD=1 (INTERFACE) + */ + +#if defined(IPC_STATIC_BUILD) +# define IPC_API +#elif defined(_WIN32) +# if defined(IPC_BUILDING_LIBRARY) +# define IPC_API __declspec(dllexport) +# else +# define IPC_API __declspec(dllimport) +# endif +#else +# if defined(IPC_BUILDING_LIBRARY) +# define IPC_API __attribute__((visibility("default"))) +# else +# define IPC_API +# endif +#endif diff --git a/packages/media/cpp/packages/ipc/src/ipc.cpp b/packages/media/cpp/packages/ipc/src/ipc.cpp new file mode 100644 index 00000000..6a8da112 --- /dev/null +++ b/packages/media/cpp/packages/ipc/src/ipc.cpp @@ -0,0 +1,158 @@ +#include "ipc/ipc.h" + +#include <cstring> + +#include "json/json.h" +#include "logger/logger.h" + +// We use RapidJSON directly for structured serialization +#include <rapidjson/document.h> +#include <rapidjson/stringbuffer.h> +#include <rapidjson/writer.h> + +#ifdef _WIN32 +#include <fcntl.h> +#include <io.h> +#endif + +namespace ipc { + +// ── helpers ────────────────────────────────────────────────────────────────── + +static void write_u32_le(uint8_t *dst, uint32_t val) { + dst[0] = static_cast<uint8_t>(val & 0xFF); + dst[1] = static_cast<uint8_t>((val >> 8) & 0xFF); + dst[2] = static_cast<uint8_t>((val >> 16) & 0xFF); + dst[3] = static_cast<uint8_t>((val >> 24) & 0xFF); +} + +static uint32_t read_u32_le(const uint8_t *src) { + return static_cast<uint32_t>(src[0]) | + (static_cast<uint32_t>(src[1]) << 8) | + (static_cast<uint32_t>(src[2]) << 16) | + (static_cast<uint32_t>(src[3]) << 24); +} + +static bool read_exact(FILE *f, uint8_t *buf, size_t n) { + size_t total = 0; + while (total < n) { + size_t got = std::fread(buf + total, 1, n - total, f); + if (got == 0) return false; // EOF or error + total += got; + } + return true; +} + +// ── encode ─────────────────────────────────────────────────────────────────── + +std::vector<uint8_t> encode(const Message &msg) { + // Build JSON: { "id": "...", "type": "...", "payload": ... } + // payload is stored as a raw JSON string, so we parse it first + rapidjson::StringBuffer sb; + rapidjson::Writer<rapidjson::StringBuffer> w(sb); + + w.StartObject(); + w.Key("id"); + w.String(msg.id.c_str(), static_cast<rapidjson::SizeType>(msg.id.size())); + w.Key("type"); + w.String(msg.type.c_str(), + static_cast<rapidjson::SizeType>(msg.type.size())); + w.Key("payload"); + + // If payload is valid JSON, embed it as-is; otherwise embed as string + rapidjson::Document pd; + if (!msg.payload.empty() && + !pd.Parse(msg.payload.c_str()).HasParseError()) { + pd.Accept(w); + } else { + w.String(msg.payload.c_str(), + static_cast<rapidjson::SizeType>(msg.payload.size())); + } + + w.EndObject(); + + const char *json_str = sb.GetString(); + uint32_t json_len = static_cast<uint32_t>(sb.GetSize()); + + std::vector<uint8_t> frame(4 + json_len); + write_u32_le(frame.data(), json_len); + std::memcpy(frame.data() + 4, json_str, json_len); + + return frame; +} + +// ── decode ─────────────────────────────────────────────────────────────────── + +bool decode(const uint8_t *data, size_t len, Message &out) { + rapidjson::Document doc; + doc.Parse(reinterpret_cast<const char *>(data), len); + + if (doc.HasParseError() || !doc.IsObject()) return false; + + if (!doc.HasMember("id") || !doc["id"].IsString()) return false; + if (!doc.HasMember("type") || !doc["type"].IsString()) return false; + + out.id = doc["id"].GetString(); + out.type = doc["type"].GetString(); + + if (doc.HasMember("payload")) { + if (doc["payload"].IsString()) { + out.payload = doc["payload"].GetString(); + } else { + // Re-serialize non-string payload back to JSON string + rapidjson::StringBuffer sb; + rapidjson::Writer<rapidjson::StringBuffer> w(sb); + doc["payload"].Accept(w); + out.payload = sb.GetString(); + } + } else { + out.payload = "{}"; + } + + return true; +} + +bool decode(const std::vector<uint8_t> &frame, Message &out) { + return decode(frame.data(), frame.size(), out); +} + +// ── read_message ───────────────────────────────────────────────────────────── + +bool read_message(Message &out, FILE *in) { +#ifdef _WIN32 + // Ensure binary mode on Windows to prevent \r\n translation + _setmode(_fileno(in), _O_BINARY); +#endif + + uint8_t len_buf[4]; + if (!read_exact(in, len_buf, 4)) return false; + + uint32_t msg_len = read_u32_le(len_buf); + if (msg_len == 0 || msg_len > 10 * 1024 * 1024) { // sanity: max 10 MB + logger::error("ipc::read_message: invalid length " + + std::to_string(msg_len)); + return false; + } + + std::vector<uint8_t> buf(msg_len); + if (!read_exact(in, buf.data(), msg_len)) return false; + + return decode(buf, out); +} + +// ── write_message ──────────────────────────────────────────────────────────── + +bool write_message(const Message &msg, FILE *out) { +#ifdef _WIN32 + _setmode(_fileno(out), _O_BINARY); +#endif + + auto frame = encode(msg); + size_t written = std::fwrite(frame.data(), 1, frame.size(), out); + if (written != frame.size()) return false; + + std::fflush(out); + return true; +} + +} // namespace ipc diff --git a/packages/media/cpp/packages/json/CMakeLists.txt b/packages/media/cpp/packages/json/CMakeLists.txt new file mode 100644 index 00000000..e896bd66 --- /dev/null +++ b/packages/media/cpp/packages/json/CMakeLists.txt @@ -0,0 +1,28 @@ +include(FetchContent) + +# RapidJSON — use master for CMake 4.x compatibility (v1.1.0 is from 2016) +FetchContent_Declare( + rapidjson + GIT_REPOSITORY https://github.com/Tencent/rapidjson.git + GIT_TAG master + GIT_SHALLOW TRUE +) + +set(RAPIDJSON_BUILD_DOC OFF CACHE BOOL "" FORCE) +set(RAPIDJSON_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) +set(RAPIDJSON_BUILD_TESTS OFF CACHE BOOL "" FORCE) + +FetchContent_GetProperties(rapidjson) +if(NOT rapidjson_POPULATED) + FetchContent_Populate(rapidjson) + # Don't add_subdirectory — just use the headers +endif() + +add_library(json STATIC + src/json.cpp +) + +target_include_directories(json + PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include + PUBLIC ${rapidjson_SOURCE_DIR}/include +) diff --git a/packages/media/cpp/packages/json/include/json/json.h b/packages/media/cpp/packages/json/include/json/json.h new file mode 100644 index 00000000..30ce3e4c --- /dev/null +++ b/packages/media/cpp/packages/json/include/json/json.h @@ -0,0 +1,23 @@ +#pragma once + +#include <string> +#include <vector> + +namespace json { + +/// Parse a JSON string and return a pretty-printed version. +std::string prettify(const std::string &json_str); + +/// Extract a string value by key from a JSON object (top-level only). +std::string get_string(const std::string &json_str, const std::string &key); + +/// Extract an int value by key from a JSON object (top-level only). +int get_int(const std::string &json_str, const std::string &key); + +/// Check if a JSON string is valid. +bool is_valid(const std::string &json_str); + +/// Get all top-level keys from a JSON object. +std::vector<std::string> keys(const std::string &json_str); + +} // namespace json diff --git a/packages/media/cpp/packages/json/src/json.cpp b/packages/media/cpp/packages/json/src/json.cpp new file mode 100644 index 00000000..422c1a73 --- /dev/null +++ b/packages/media/cpp/packages/json/src/json.cpp @@ -0,0 +1,62 @@ +#include "json/json.h" + +#include <rapidjson/document.h> +#include <rapidjson/prettywriter.h> +#include <rapidjson/stringbuffer.h> + +namespace json { + +std::string prettify(const std::string &json_str) { + rapidjson::Document doc; + doc.Parse(json_str.c_str()); + if (doc.HasParseError()) { + return {}; + } + + rapidjson::StringBuffer buffer; + rapidjson::PrettyWriter<rapidjson::StringBuffer> writer(buffer); + doc.Accept(writer); + return std::string(buffer.GetString(), buffer.GetSize()); +} + +std::string get_string(const std::string &json_str, const std::string &key) { + rapidjson::Document doc; + doc.Parse(json_str.c_str()); + if (doc.HasParseError() || !doc.IsObject()) + return {}; + auto it = doc.FindMember(key.c_str()); + if (it == doc.MemberEnd() || !it->value.IsString()) + return {}; + return std::string(it->value.GetString(), it->value.GetStringLength()); +} + +int get_int(const std::string &json_str, const std::string &key) { + rapidjson::Document doc; + doc.Parse(json_str.c_str()); + if (doc.HasParseError() || !doc.IsObject()) + return 0; + auto it = doc.FindMember(key.c_str()); + if (it == doc.MemberEnd() || !it->value.IsInt()) + return 0; + return it->value.GetInt(); +} + +bool is_valid(const std::string &json_str) { + rapidjson::Document doc; + doc.Parse(json_str.c_str()); + return !doc.HasParseError(); +} + +std::vector<std::string> keys(const std::string &json_str) { + std::vector<std::string> result; + rapidjson::Document doc; + doc.Parse(json_str.c_str()); + if (doc.HasParseError() || !doc.IsObject()) + return result; + for (auto it = doc.MemberBegin(); it != doc.MemberEnd(); ++it) { + result.emplace_back(it->name.GetString(), it->name.GetStringLength()); + } + return result; +} + +} // namespace json diff --git a/packages/media/cpp/packages/kbot/CMakeLists.txt b/packages/media/cpp/packages/kbot/CMakeLists.txt new file mode 100644 index 00000000..b512b797 --- /dev/null +++ b/packages/media/cpp/packages/kbot/CMakeLists.txt @@ -0,0 +1,50 @@ +cmake_minimum_required(VERSION 3.20) + +project(kbot CXX) + +option(POLYMECH_KBOT_SHARED "Build kbot as a shared library (DLL/so)" OFF) + +set(_kbot_sources kbot.cpp llm_client.cpp source_files.cpp) + +if(POLYMECH_KBOT_SHARED) + add_library(kbot SHARED ${_kbot_sources}) + target_compile_definitions(kbot PRIVATE POLYMECH_BUILDING_LIBRARY) +else() + add_library(kbot STATIC ${_kbot_sources}) + target_compile_definitions(kbot PRIVATE POLYMECH_STATIC_BUILD=1) + target_compile_definitions(kbot INTERFACE POLYMECH_STATIC_BUILD=1) +endif() + +target_include_directories(kbot PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} + ${taskflow_SOURCE_DIR} +) + +target_link_libraries(kbot PUBLIC + logger + json + oai + pranav_glob +) + +if(POLYMECH_KBOT_SHARED) + set_target_properties(kbot PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/dist" + RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_SOURCE_DIR}/dist" + RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_SOURCE_DIR}/dist" + LIBRARY_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/dist" + ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/dist" + ) +endif() + +install(TARGETS kbot + ARCHIVE DESTINATION lib + LIBRARY DESTINATION lib + RUNTIME DESTINATION bin +) +install(FILES + ${CMAKE_CURRENT_SOURCE_DIR}/kbot.h + ${CMAKE_CURRENT_SOURCE_DIR}/llm_client.h + ${CMAKE_CURRENT_SOURCE_DIR}/polymech_export.h + DESTINATION include/polymech +) diff --git a/packages/media/cpp/packages/kbot/kbot.cpp b/packages/media/cpp/packages/kbot/kbot.cpp new file mode 100644 index 00000000..43ef5eaf --- /dev/null +++ b/packages/media/cpp/packages/kbot/kbot.cpp @@ -0,0 +1,189 @@ +#include "kbot.h" +#include "source_files.h" +#include <fstream> +#include <filesystem> +#include <iostream> +#include "logger/logger.h" +#include "llm_client.h" +#include <nlohmann/json.hpp> +#include <rapidjson/stringbuffer.h> +#include <rapidjson/writer.h> + +namespace polymech { +namespace kbot { + +namespace { + +namespace fs = std::filesystem; + +static void replace_all(std::string &s, const std::string &from, const std::string &to) { + std::size_t pos = 0; + while ((pos = s.find(from, pos)) != std::string::npos) { + s.replace(pos, from.length(), to); + pos += to.length(); + } +} + +static std::string model_basename(const std::string &model) { + if (model.empty()) + return "unknown_model"; + const auto slash = model.find_last_of("/\\"); + if (slash == std::string::npos) + return model; + return model.substr(slash + 1); +} + +static std::string expand_dst_path(const KBotOptions &opts, std::string raw) { + const std::string m = model_basename(opts.model); + const std::string r = opts.router.empty() ? std::string("unknown_router") : opts.router; + replace_all(raw, "${MODEL}", m); + replace_all(raw, "${MODEL_NAME}", m); + replace_all(raw, "${ROUTER}", r); + return raw; +} + +/** Same idea as TS `onCompletion`: write to --dst / --output; `dst` wins over legacy `output` if both set. */ +static std::string effective_completion_dst(const KBotOptions &opts) { + if (!opts.dst.empty()) + return opts.dst; + return opts.output; +} + +/** @returns true if wrote to file (caller should skip printing body to stdout). */ +static bool try_write_completion_to_dst(const KBotOptions &opts, const std::string &text) { + const std::string raw = effective_completion_dst(opts); + if (raw.empty()) + return false; + + std::string expanded = expand_dst_path(opts, raw); + fs::path p; + try { + p = fs::absolute(expanded); + } catch (const std::exception &e) { + logger::error(std::string("Invalid output path: ") + e.what()); + return false; + } + + std::error_code ec; + fs::create_directories(p.parent_path(), ec); + if (ec) { + logger::error("Failed to create output directories: " + ec.message()); + return false; + } + + const bool append_existing = (opts.append != "replace") && fs::exists(p); + std::ofstream out(p, std::ios::binary | (append_existing ? std::ios::app : std::ios::trunc)); + if (!out) { + logger::error("Failed to open output file: " + p.string()); + return false; + } + out << text; + if (!text.empty() && text.back() != '\n') + out.put('\n'); + logger::info(std::string(append_existing ? "Appended completion to " : "Wrote completion to ") + p.string()); + return true; +} + +std::string json_job_result_ai(bool success, const std::string &text_or_error, bool is_text, + const std::string &provider_meta_json = {}) { + nlohmann::json o; + o["status"] = success ? "success" : "error"; + o["mode"] = "ai"; + if (success && is_text) o["text"] = text_or_error; + else if (!success) o["error"] = text_or_error; + if (!provider_meta_json.empty()) { + try { + o["llm"] = nlohmann::json::parse(provider_meta_json); + } catch (...) { + o["llm"] = nlohmann::json{{"_parse_error", true}, {"raw", provider_meta_json}}; + } + } + return o.dump(); +} + +} // namespace + +int run_kbot_ai_pipeline(const KBotOptions &opts, const KBotCallbacks &cb) { + logger::debug("Starting kbot ai pipeline"); + + std::vector<std::string> source_rel_paths; + const std::string full_prompt = build_prompt_with_sources( + opts, opts.include_globs.empty() ? nullptr : &source_rel_paths); + if (!opts.include_globs.empty()) { + logger::info("kbot ai: attached " + std::to_string(source_rel_paths.size()) + " text source file(s)"); + } + + if (opts.dry_run) { + logger::info("Dry run triggered for kbot ai"); + if (cb.onEvent) { + if (!opts.include_globs.empty()) { + cb.onEvent("job_result", make_dry_run_ai_result(opts, full_prompt, source_rel_paths).dump()); + } else { + cb.onEvent("job_result", json_job_result_ai(true, "[dry-run] no LLM call", true)); + } + } + return 0; + } + + LLMClient client(opts); + std::string target_prompt = full_prompt; + if (target_prompt.empty()) { + target_prompt = "Respond with 'Hello from KBot C++ AI Pipeline!'"; + } + + logger::debug("Executing kbot ai completion via LLMClient..."); + LLMResponse res = client.execute_chat(target_prompt); + + if (res.success) { + if (!try_write_completion_to_dst(opts, res.text)) + std::cout << res.text << "\n"; + if (cb.onEvent) { + cb.onEvent("ai_progress", + "{\"message\":\"Task completion received\",\"has_text\":true}"); + } + } else { + logger::error("AI Task Failed: " + res.error); + if (cb.onEvent) { + rapidjson::StringBuffer ebuf; + rapidjson::Writer<rapidjson::StringBuffer> ew(ebuf); + ew.StartObject(); + ew.Key("error"); + ew.String(res.error.c_str(), + static_cast<rapidjson::SizeType>(res.error.size())); + ew.EndObject(); + cb.onEvent("ai_error", + std::string(ebuf.GetString(), ebuf.GetSize())); + } + } + + if (cb.onEvent) { + if (res.success) + cb.onEvent("job_result", json_job_result_ai(true, res.text, true, res.provider_meta_json)); + else + cb.onEvent("job_result", json_job_result_ai(false, res.error, false)); + } + + return res.success ? 0 : 1; +} + +int run_kbot_run_pipeline(const KBotRunOptions &opts, const KBotCallbacks &cb) { + logger::info("Starting kbot run pipeline (stub) for config: " + opts.config); + if (opts.dry) { + logger::info("Dry run triggered for kbot run"); + } + if (opts.list) { + logger::info("List configs mode enabled"); + } + + if (!opts.dry && !opts.list) { + logger::info("Simulating launching: .vscode/launch.json targeting " + opts.config); + } + + if (cb.onEvent) { + cb.onEvent("job_result", "{\"status\":\"success\",\"mode\":\"run\"}"); + } + return 0; +} + +} // namespace kbot +} // namespace polymech diff --git a/packages/media/cpp/packages/kbot/kbot.h b/packages/media/cpp/packages/kbot/kbot.h new file mode 100644 index 00000000..389e1846 --- /dev/null +++ b/packages/media/cpp/packages/kbot/kbot.h @@ -0,0 +1,79 @@ +#pragma once + +#include "polymech_export.h" +#include <string> +#include <vector> +#include <memory> +#include <atomic> +#include <functional> + +namespace polymech { +namespace kbot { + +struct KBotOptions { + std::string path = "."; + std::string prompt; + std::string output; + std::string dst; + std::string append = "concat"; + std::string wrap = "none"; + std::string each; + std::vector<std::string> disable; + std::vector<std::string> disable_tools; + std::vector<std::string> tools; + std::vector<std::string> include_globs; + std::vector<std::string> exclude_globs; + std::string glob_extension; + std::string api_key; + std::string model; + std::string router = "openrouter"; + std::string mode = "tools"; + int log_level = 4; + std::string profile; + std::string base_url; + std::string config_path; + std::string dump; + std::string preferences; + std::string logs; + bool stream = false; + bool alt = false; + std::string env = "default"; + std::string filters; + std::string query; + bool dry_run = false; + std::string format; + /** liboai HTTP timeout (ms). 0 = library default (~30s). IPC may set for long prompts. */ + int llm_timeout_ms = 0; + /** + * Optional chat completion `response_format` JSON (OpenAI structured outputs). + * Example: {"type":"json_object"} or {"type":"json_schema","json_schema":{...}}. + * Empty = omit (default text completion). + */ + std::string response_format_json; + + // Internal + std::string job_id; + std::shared_ptr<std::atomic<bool>> cancel_token; +}; + +struct KBotRunOptions { + std::string config = "default"; + bool dry = false; + bool list = false; + std::string project_path; + std::string log_file_path; + + // Internal + std::string job_id; + std::shared_ptr<std::atomic<bool>> cancel_token; +}; + +struct KBotCallbacks { + std::function<void(const std::string& type, const std::string& json)> onEvent; +}; + +POLYMECH_API int run_kbot_ai_pipeline(const KBotOptions& opts, const KBotCallbacks& cb); +POLYMECH_API int run_kbot_run_pipeline(const KBotRunOptions& opts, const KBotCallbacks& cb); + +} // namespace kbot +} // namespace polymech diff --git a/packages/media/cpp/packages/kbot/llm_client.cpp b/packages/media/cpp/packages/kbot/llm_client.cpp new file mode 100644 index 00000000..ccfb4497 --- /dev/null +++ b/packages/media/cpp/packages/kbot/llm_client.cpp @@ -0,0 +1,165 @@ +#include "llm_client.h" +#include "logger/logger.h" +#include <liboai.h> +#include <nlohmann/json.hpp> +#include <iostream> +#include <optional> + +namespace polymech { +namespace kbot { + +LLMClient::LLMClient(const KBotOptions& opts) + : api_key_(opts.api_key), + model_(opts.model), + router_(opts.router), + llm_timeout_ms_(opts.llm_timeout_ms), + response_format_json_(opts.response_format_json) { + + // Set default base_url_ according to client.ts mappings + if (opts.base_url.empty()) { + if (router_ == "openrouter") base_url_ = "https://openrouter.ai/api/v1"; + else if (router_ == "openai") base_url_ = ""; // liboai uses the default URL automatically + else if (router_ == "deepseek") base_url_ = "https://api.deepseek.com/v1"; + else if (router_ == "huggingface")base_url_ = "https://api-inference.huggingface.co/v1"; + else if (router_ == "ollama") base_url_ = "http://localhost:11434/v1"; + else if (router_ == "fireworks") base_url_ = "https://api.fireworks.ai/v1"; + else if (router_ == "gemini") base_url_ = "https://generativelanguage.googleapis.com/v1beta"; // or gemini openai compat endpt + else if (router_ == "xai") base_url_ = "https://api.x.ai/v1"; + else base_url_ = "https://api.openai.com/v1"; // Fallback to openai API + } else { + base_url_ = opts.base_url; + } + + // Default models based on router (from client.ts) + if (model_.empty()) { + if (router_ == "openrouter") model_ = "anthropic/claude-sonnet-4"; + else if (router_ == "openai") model_ = "gpt-4o"; + else if (router_ == "deepseek") model_ = "deepseek-chat"; + else if (router_ == "huggingface") model_ = "meta-llama/2"; + else if (router_ == "ollama") model_ = "llama3.2"; + else if (router_ == "fireworks") model_ = "llama-v2-70b-chat"; + else if (router_ == "gemini") model_ = "gemini-1.5-pro"; + else if (router_ == "xai") model_ = "grok-1"; + else model_ = "gpt-4o"; + } +} + +LLMClient::~LLMClient() = default; + +LLMResponse LLMClient::execute_chat(const std::string& prompt) { + LLMResponse res; + + logger::debug("LLMClient::execute_chat: Starting. api_key length: " + std::to_string(api_key_.length())); + if (api_key_.empty()) { + res.success = false; + res.error = "API Key is empty."; + return res; + } + + logger::debug("LLMClient::execute_chat: base_url_: " + base_url_); + liboai::OpenAI oai_impl(base_url_.empty() ? "https://api.openai.com/v1" : base_url_); + + logger::debug("LLMClient::execute_chat: Setting API Key"); + bool success = oai_impl.auth.SetKey(api_key_); + if (!success) { + res.success = false; + res.error = "Failed to set API Key in liboai."; + return res; + } + + if (llm_timeout_ms_ > 0) { + oai_impl.auth.SetMaxTimeout(llm_timeout_ms_); + logger::info("LLMClient: HTTP timeout set to " + std::to_string(llm_timeout_ms_) + " ms"); + } + + std::string target_model = model_.empty() ? "gpt-4o" : model_; + logger::debug("LLMClient::execute_chat: Target model: " + target_model); + + logger::info("LLMClient: calling ChatCompletion (prompt chars=" + std::to_string(prompt.size()) + ")"); + logger::debug("LLMClient::execute_chat: Init Conversation"); + liboai::Conversation convo; + convo.AddUserData(prompt); + + std::optional<nlohmann::json> response_format; + if (!response_format_json_.empty()) { + try { + response_format = nlohmann::json::parse(response_format_json_); + } catch (const std::exception& e) { + logger::warn("LLMClient: invalid --response-format / response_format_json, ignoring: " + + std::string(e.what())); + } + } + + logger::debug("LLMClient::execute_chat: Calling create()"); + try { + liboai::Response response = oai_impl.ChatCompletion->create( + target_model, + convo, + std::nullopt, + std::nullopt, + std::nullopt, + std::nullopt, + std::nullopt, + std::nullopt, + std::nullopt, + std::nullopt, + std::nullopt, + std::nullopt, + std::nullopt, + response_format); + logger::info("LLMClient: ChatCompletion returned (HTTP " + std::to_string(response.status_code) + ")"); + logger::debug("LLMClient::execute_chat: Got response with status: " + std::to_string(response.status_code)); + + // liboai may not populate raw_json for custom base URLs — parse content directly. + nlohmann::json j; + bool json_ok = false; + if (!response.raw_json.empty() && response.raw_json.contains("choices")) { + j = response.raw_json; + json_ok = true; + } else if (!response.content.empty()) { + try { + j = nlohmann::json::parse(response.content); + json_ok = j.contains("choices"); + } catch (...) {} + } + + if (!json_ok || j["choices"].empty()) { + res.success = false; + if (json_ok && j.contains("error")) { + res.error = "API Error: " + j["error"].dump(); + } else { + res.error = "Invalid response format: no choices found. Raw: " + response.content; + } + return res; + } + + res.success = true; + res.text = j["choices"][0]["message"]["content"].get<std::string>(); + + /* Usage, model, cost (OpenRouter), etc. — everything except message bodies in choices. */ + try { + nlohmann::json meta = nlohmann::json::object(); + for (auto it = j.begin(); it != j.end(); ++it) { + if (it.key() == "choices") continue; + meta[it.key()] = it.value(); + } + if (!meta.empty()) res.provider_meta_json = meta.dump(); + } catch (...) { + /* keep text; omit provider_meta_json */ + } + + } catch (std::exception& e) { + logger::error("LLMClient::execute_chat: Exception caught: " + std::string(e.what())); + res.success = false; + res.error = e.what(); + } catch (...) { + logger::error("LLMClient::execute_chat: Unknown exception caught"); + res.success = false; + res.error = "Unknown error occurred inside LLMClient execute_chat."; + } + + return res; +} + +} // namespace kbot +} // namespace polymech diff --git a/packages/media/cpp/packages/kbot/llm_client.h b/packages/media/cpp/packages/kbot/llm_client.h new file mode 100644 index 00000000..ad87d33a --- /dev/null +++ b/packages/media/cpp/packages/kbot/llm_client.h @@ -0,0 +1,37 @@ +#pragma once + +#include <string> +#include "kbot.h" + +namespace polymech { +namespace kbot { + +struct LLMResponse { + std::string text; + bool success = false; + std::string error; + /** Top-level chat completion JSON minus `choices` (usage, model, id, OpenRouter extras). Empty if not captured. */ + std::string provider_meta_json; +}; + +class POLYMECH_API LLMClient { +public: + // Initialize the client with the options (api_key, model, router). + explicit LLMClient(const KBotOptions& opts); + ~LLMClient(); + + // Execute a basic chat completion using the provided prompt. + LLMResponse execute_chat(const std::string& prompt); + +private: + std::string api_key_; + std::string model_; + std::string router_; + std::string base_url_; + int llm_timeout_ms_ = 0; + /** Parsed in execute_chat; raw JSON from KBotOptions::response_format_json */ + std::string response_format_json_; +}; + +} // namespace kbot +} // namespace polymech diff --git a/packages/media/cpp/packages/kbot/polymech_export.h b/packages/media/cpp/packages/kbot/polymech_export.h new file mode 100644 index 00000000..fefd1377 --- /dev/null +++ b/packages/media/cpp/packages/kbot/polymech_export.h @@ -0,0 +1,26 @@ +#pragma once + +/** + * DLL / shared-object exports for the Polymech kbot library (pipelines, LLM client). + * + * CMake: + * - Building libkbot: POLYMECH_BUILDING_LIBRARY (PRIVATE) + * - Linking static kbot: POLYMECH_STATIC_BUILD=1 (INTERFACE) + * - Linking shared kbot: default import on Windows + */ + +#if defined(POLYMECH_STATIC_BUILD) +# define POLYMECH_API +#elif defined(_WIN32) +# if defined(POLYMECH_BUILDING_LIBRARY) +# define POLYMECH_API __declspec(dllexport) +# else +# define POLYMECH_API __declspec(dllimport) +# endif +#else +# if defined(POLYMECH_BUILDING_LIBRARY) +# define POLYMECH_API __attribute__((visibility("default"))) +# else +# define POLYMECH_API +# endif +#endif diff --git a/packages/media/cpp/packages/kbot/source_files.cpp b/packages/media/cpp/packages/kbot/source_files.cpp new file mode 100644 index 00000000..26df14e5 --- /dev/null +++ b/packages/media/cpp/packages/kbot/source_files.cpp @@ -0,0 +1,221 @@ +#include "source_files.h" +#include "logger/logger.h" +#include <glob/glob.h> +#include <algorithm> +#include <cctype> +#include <filesystem> +#include <fstream> +#include <sstream> +#include <unordered_set> + +namespace fs = std::filesystem; + +namespace polymech { +namespace kbot { + +namespace { + +constexpr std::size_t kMaxBytesPerFile = 4 * 1024 * 1024; + +std::string to_lower(std::string s) { + std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return static_cast<char>(std::tolower(c)); }); + return s; +} + +std::string ext_of(const fs::path& p) { + std::string e = p.extension().string(); + return to_lower(e); +} + +/** Extensions handled as binary / non-text in this slice (expand later for vision). */ +bool is_image_ext(const std::string& ext) { + static const char* kImg[] = {".jpg", ".jpeg", ".png", ".gif", ".webp", + ".bmp", ".tiff", ".tif", ".ico", ".heic", ".avif"}; + for (auto* x : kImg) { + if (ext == x) return true; + } + return false; +} + +bool is_pdf_ext(const std::string& ext) { return ext == ".pdf"; } + +/** Filename / relative path glob with * and ? only (no **). */ +bool glob_match_segment(const std::string& text, const std::string& pat) { + const size_t n = text.size(), m = pat.size(); + std::vector<std::vector<bool>> dp(n + 1, std::vector<bool>(m + 1, false)); + dp[0][0] = true; + for (size_t j = 1; j <= m; ++j) { + if (pat[j - 1] == '*') dp[0][j] = dp[0][j - 1]; + } + for (size_t i = 1; i <= n; ++i) { + for (size_t j = 1; j <= m; ++j) { + if (pat[j - 1] == '*') { + dp[i][j] = dp[i][j - 1] || dp[i - 1][j]; + } else if (pat[j - 1] == '?' || text[i - 1] == pat[j - 1]) { + dp[i][j] = dp[i - 1][j - 1]; + } + } + } + return dp[n][m]; +} + +fs::path absolute_root(const std::string& path_opt) { + if (path_opt.empty()) return fs::absolute(fs::path(".")); + return fs::absolute(fs::path(path_opt)); +} + +bool excluded(const std::string& rel_fwd, const std::vector<std::string>& exclude_globs) { + for (const auto& pat : exclude_globs) { + if (pat.empty()) continue; + if (glob_match_segment(rel_fwd, pat)) return true; + fs::path p(rel_fwd); + if (glob_match_segment(p.filename().string(), pat)) return true; + } + return false; +} + +void push_unique(std::vector<fs::path>& out, std::unordered_set<std::string>& seen, const fs::path& file) { + std::string key = file.generic_string(); + if (seen.insert(key).second) out.push_back(file); +} + +void expand_one_pattern(const fs::path& root, const std::string& pattern_str, + std::vector<fs::path>& out, std::unordered_set<std::string>& seen) { + fs::path pat_path = pattern_str.empty() ? fs::path() : fs::path(pattern_str); + fs::path resolved = pat_path.is_absolute() ? pat_path : (root / pat_path); + resolved = resolved.lexically_normal(); + + const std::string pat = resolved.string(); + + std::vector<fs::path> matched; + try { + if (pattern_str.find("**") != std::string::npos) { + matched = glob::rglob(pat); + } else { + matched = glob::glob(pat); + } + } catch (const std::exception& e) { + logger::warn(std::string("source_files: glob failed: ") + e.what()); + return; + } + + for (auto& p : matched) { + std::error_code ec; + if (!fs::is_regular_file(p, ec) || ec) continue; + fs::path canon = fs::weakly_canonical(p, ec); + if (!ec) push_unique(out, seen, canon); + } +} + +std::string read_file_limited(const fs::path& p, std::size_t max_bytes) { + std::ifstream in(p, std::ios::binary); + if (!in) return {}; + std::string buf; + buf.assign(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>()); + if (buf.size() > max_bytes) { + logger::warn("source_files: truncating large file " + p.generic_string()); + buf.resize(max_bytes); + } + return buf; +} + +} // namespace + +bool is_text_source_file(const std::string& path_generic) { + fs::path p(path_generic); + std::string ext = ext_of(p); + if (is_image_ext(ext)) return false; + if (is_pdf_ext(ext)) return false; + if (ext.empty()) return true; + /* Code / text-like extensions (aligned with TS text/* + common sources) */ + static const char* kText[] = { + ".txt", ".md", ".json", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".css", + ".html", ".htm", ".xml", ".csv", ".yaml", ".yml", ".toml", ".sh", ".py", + ".rs", ".go", ".java", ".cpp", ".cc", ".cxx", ".h", ".hpp", ".c", + ".cs", ".rb", ".php", ".swift", ".kt", ".vue", ".svelte", ".scss", ".less", + ".ini", ".cfg", ".properties", ".gradle", ".cmake", ".mdx", ".log", ".sql", + }; + for (auto* x : kText) { + if (ext == x) return true; + } + return false; +} + +std::vector<std::string> collect_source_rel_paths(const KBotOptions& opts) { + std::vector<std::string> rel; + build_prompt_with_sources(opts, &rel); + return rel; +} + +std::string build_prompt_with_sources(const KBotOptions& opts, std::vector<std::string>* out_rel_paths) { + if (opts.include_globs.empty()) { + return opts.prompt; + } + + const fs::path root = absolute_root(opts.path); + std::vector<fs::path> files; + std::unordered_set<std::string> seen; + + for (const auto& inc : opts.include_globs) { + if (inc.empty()) continue; + expand_one_pattern(root, inc, files, seen); + } + + std::ostringstream body; + for (const auto& abs : files) { + std::error_code ec; + if (!fs::is_regular_file(abs, ec) || ec) continue; + + std::string abs_gen = abs.generic_string(); + if (!is_text_source_file(abs_gen)) { + logger::info("source_files: skip non-text (e.g. image): " + abs_gen); + continue; + } + + fs::path rel = fs::relative(abs, root, ec); + if (ec) rel = abs.filename(); + std::string rel_fwd = rel.generic_string(); + + if (excluded(rel_fwd, opts.exclude_globs)) { + logger::debug("source_files: excluded: " + rel_fwd); + continue; + } + + std::string content = read_file_limited(abs, kMaxBytesPerFile); + if (out_rel_paths) out_rel_paths->push_back(rel_fwd); + + body << "--- file: " << rel_fwd << " ---\n"; + body << content; + if (!content.empty() && content.back() != '\n') body << '\n'; + body << '\n'; + } + + if (!opts.prompt.empty()) { + body << opts.prompt; + } + + return body.str(); +} + +nlohmann::json make_dry_run_ai_result(const KBotOptions& opts, const std::string& augmented_prompt, + const std::vector<std::string>& rel_paths) { + nlohmann::json o; + o["status"] = "success"; + o["mode"] = "ai"; + o["text"] = "[dry-run] no LLM call"; + o["dry_run"] = true; + o["path"] = opts.path.empty() ? std::string(".") : opts.path; + o["sources"] = rel_paths; + o["prompt_char_count"] = augmented_prompt.size(); + const std::size_t cap = 2000; + if (augmented_prompt.size() <= cap) { + o["prompt_preview"] = augmented_prompt; + } else { + o["prompt_preview"] = augmented_prompt.substr(0, cap); + o["prompt_preview_truncated"] = true; + } + return o; +} + +} // namespace kbot +} // namespace polymech diff --git a/packages/media/cpp/packages/kbot/source_files.h b/packages/media/cpp/packages/kbot/source_files.h new file mode 100644 index 00000000..ad791e34 --- /dev/null +++ b/packages/media/cpp/packages/kbot/source_files.h @@ -0,0 +1,32 @@ +#pragma once + +#include "kbot.h" +#include <nlohmann/json.hpp> +#include <string> +#include <vector> + +namespace polymech { +namespace kbot { + +/** True if we treat this path as a text source (UTF-8). Images/PDF reserved for future. */ +bool is_text_source_file(const std::string& path_generic); + +/** + * Resolve --include / IPC `include` patterns against `opts.path` (project root). + * Skips non-text files (e.g. images) with a debug log. Applies `exclude_globs` to relative paths. + */ +std::vector<std::string> collect_source_rel_paths(const KBotOptions& opts); + +/** + * Build user prompt: optional file blocks (`--- file: rel ---` + contents) then `opts.prompt`. + * If `out_rel_paths` is set, filled with forward-slash relative paths in read order (deduped). + */ +std::string build_prompt_with_sources(const KBotOptions& opts, + std::vector<std::string>* out_rel_paths = nullptr); + +/** JSON body for dry-run job_result when includes are used (sources + preview). */ +nlohmann::json make_dry_run_ai_result(const KBotOptions& opts, const std::string& augmented_prompt, + const std::vector<std::string>& rel_paths); + +} // namespace kbot +} // namespace polymech diff --git a/packages/media/cpp/packages/liboai/.github/ISSUE_TEMPLATE/bug_report.yml b/packages/media/cpp/packages/liboai/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..73e5ca7b --- /dev/null +++ b/packages/media/cpp/packages/liboai/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,49 @@ +name: Bug report +description: Create a report to help us improve +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! + - type: textarea + id: what-happened + attributes: + label: Describe the bug + description: A clear and concise description of what the bug is, and any additional context. + placeholder: Tell us what you see! + validations: + required: true + - type: textarea + id: repro-steps + attributes: + label: To Reproduce + description: Steps to reproduce the behavior. + placeholder: | + 1. Fetch a '...' + 2. Update the '....' + 3. See error + validations: + required: true + - type: textarea + id: code-snippets + attributes: + label: Code snippets + description: If applicable, add code snippets to help explain your problem. + render: C++ + validations: + required: false + - type: input + id: os + attributes: + label: OS + placeholder: macOS + validations: + required: true + - type: input + id: lib-version + attributes: + label: Library version + placeholder: liboai v1.0.0 + validations: + required: true diff --git a/packages/media/cpp/packages/liboai/.github/ISSUE_TEMPLATE/feature_request.yml b/packages/media/cpp/packages/liboai/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..de963b32 --- /dev/null +++ b/packages/media/cpp/packages/liboai/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,20 @@ +name: Feature request +description: Suggest an idea for this library +labels: ["feature-request"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this feature request! + - type: textarea + id: feature + attributes: + label: Describe the feature or improvement you're requesting + description: A clear and concise description of what you want to happen. + validations: + required: true + - type: textarea + id: context + attributes: + label: Additional context + description: Add any other context about the feature request here. diff --git a/packages/media/cpp/packages/liboai/.gitignore b/packages/media/cpp/packages/liboai/.gitignore new file mode 100644 index 00000000..f2ad07a4 --- /dev/null +++ b/packages/media/cpp/packages/liboai/.gitignore @@ -0,0 +1,6 @@ +.vs +[Bb]uild* +out +TestApp +.cache +/.idea diff --git a/packages/media/cpp/packages/liboai/AGENTS.md b/packages/media/cpp/packages/liboai/AGENTS.md new file mode 100644 index 00000000..e1aec7ca --- /dev/null +++ b/packages/media/cpp/packages/liboai/AGENTS.md @@ -0,0 +1,24 @@ +# AGENTS.md + +This repo is a maintained fork of liboai. Our goal is to make it more reliable and feature-complete without breaking existing api. + +## Core Principles +- Preserve backward compatibility; add features without breaking existing APIs. +- Favor small, composable changes over rewrites. +- Keep the codebase clean and maintainable; document anything user-facing. +- Prioritize stability, correctness, and clear error handling. + +## Current Priorities +- Add OpenAI Responses API support for GPT-5.2 and gpt-5.2-pro. +- Keep Chat Completions and other existing components intact. +- Add documentation and examples for new features. + +## Workflow +- Update docs whenever you add or change public APIs. +- Use existing patterns and naming conventions in liboai. +- Avoid introducing new dependencies unless justified. + +## Notes +- The initial Responses API implementation should accept raw JSON payloads. +- A ResponseInput helper is planned, but not part of the initial implementation. +- Azure Responses support is out of scope for now. diff --git a/packages/media/cpp/packages/liboai/CMakeLists.txt b/packages/media/cpp/packages/liboai/CMakeLists.txt new file mode 100644 index 00000000..328d0402 --- /dev/null +++ b/packages/media/cpp/packages/liboai/CMakeLists.txt @@ -0,0 +1,22 @@ +cmake_minimum_required(VERSION 3.21) + +project(liboai) + +IF(WIN32) + set(VCPKG_CMAKE_PATH $ENV{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake CACHE FILEPATH "Location of vcpkg.cmake") + include(${VCPKG_CMAKE_PATH}) + find_package(ZLIB REQUIRED) + find_package(nlohmann_json CONFIG REQUIRED) + find_package(CURL REQUIRED) +ENDIF() + +option(BUILD_EXAMPLES "Build example applications" OFF) +set_property(GLOBAL PROPERTY USE_FOLDERS ON) + +add_subdirectory(liboai) + +if(BUILD_EXAMPLES) + add_subdirectory(documentation) +endif() + +set_property(DIRECTORY PROPERTY VS_STARTUP_PROJECT oai) diff --git a/packages/media/cpp/packages/liboai/LICENSE b/packages/media/cpp/packages/liboai/LICENSE new file mode 100644 index 00000000..33e8ba36 --- /dev/null +++ b/packages/media/cpp/packages/liboai/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Dread + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/media/cpp/packages/liboai/README.md b/packages/media/cpp/packages/liboai/README.md new file mode 100644 index 00000000..5cab6868 --- /dev/null +++ b/packages/media/cpp/packages/liboai/README.md @@ -0,0 +1,100 @@ +<p align="center"> + <img src="/images/_logo.png"> +</p> + +<hr> +<h1>Introduction</h1> +<p><code>liboai</code> is a simple, <b>unofficial</b> C++17 library for the OpenAI API. It allows developers to access OpenAI endpoints through a simple collection of methods and classes. The library can most effectively be thought of as a <b>spiritual port</b> of OpenAI's Python library, simply called <code>openai</code>, due to its similar structure - with few exceptions. + +<h3>Features</h3> + +- [x] [ChatGPT](https://github.com/D7EAD/liboai/tree/main/documentation/chat) +- [x] [Responses API](https://platform.openai.com/docs/api-reference/responses/create) +- [X] [Audio](https://github.com/D7EAD/liboai/tree/main/documentation/audio) +- [X] [Azure](https://github.com/D7EAD/liboai/tree/main/documentation/azure) +- [X] [Functions](https://platform.openai.com/docs/api-reference/chat/create) +- [x] [Image DALL·E](https://github.com/D7EAD/liboai/tree/main/documentation/images) +- [x] [Models](https://github.com/D7EAD/liboai/tree/main/documentation/models) +- [x] [Completions](https://github.com/D7EAD/liboai/tree/main/documentation/completions) +- [x] [Edit](https://github.com/D7EAD/liboai/tree/main/documentation/edits) +- [x] [Embeddings](https://github.com/D7EAD/liboai/tree/main/documentation/embeddings) +- [x] [Files](https://github.com/D7EAD/liboai/tree/main/documentation/files) +- [x] [Fine-tunes](https://github.com/D7EAD/liboai/tree/main/documentation/fine-tunes) +- [x] [Moderation](https://github.com/D7EAD/liboai/tree/main/documentation/moderations) +- [X] Asynchronous Support + +<h1>Usage</h1> +See below for just how similar in style <code>liboai</code> and its Python alternative are when generating an image using DALL-E.</p> +<details open> +<summary>DALL-E Generation in Python.</summary> +<br> + +```py +import openai +import os + +openai.api_key = os.getenv("OPENAI_API_KEY") +response = openai.Image.create( + prompt="A snake in the grass!", + n=1, + size="256x256" +) +print(response["data"][0]["url"]) +``` +</details> + +<details open> +<summary>DALL-E Generation in C++.</summary> +<br> + +```cpp +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + oai.auth.SetKeyEnv("OPENAI_API_KEY"); + + Response res = oai.Image->create( + "A snake in the grass!", + 1, + "256x256" + ); + + std::cout << res["data"][0]["url"] << std::endl; +} +``` + +</details> + +<p>Running the above will print out the URL to the resulting generated image, which may or may not look similar to the one found below.</p> +<table> +<tr> +<th>Example Image</th> +</tr> +<td> + +<img src="/images/snake.png"> + +</td> +</tr> +</table> + +<p><i>Keep in mind the above C++ example is a minimal example and is not an exception-safe snippet. Please see <a href="/documentation">the documentation</a> for more detailed and exception-safe code snippets.</i></p> + +<h1>Dependencies</h1> +<p>For the library to work the way it does, it relies on two major dependencies. These dependencies can be found listed below.<p> + +- <a href="https://github.com/nlohmann/json">nlohmann-json</a> +- <a href="https://curl.se/">cURL</a> + +*If building the library using the provided solution, it is recommended to install these dependencies using <b>vcpkg</b>.* + +<h1>Documentation</h1> +<p>For detailed documentation and additional code examples, see the library's documentation <a href="/documentation">here</a>. + +<h1>Contributing</h1> +<p>Artificial intelligence is an exciting and quickly-changing field. + +If you'd like to partake in further placing the power of AI in the hands of everyday people, please consider contributing by submitting new code and features via a **Pull Request**. If you have any issues using the library, or just want to suggest new features, feel free to contact me directly using the info on my <a href="https://github.com/D7EAD">profile</a> or open an **Issue**. diff --git a/packages/media/cpp/packages/liboai/ROADMAP.md b/packages/media/cpp/packages/liboai/ROADMAP.md new file mode 100644 index 00000000..b694a6ed --- /dev/null +++ b/packages/media/cpp/packages/liboai/ROADMAP.md @@ -0,0 +1,25 @@ +# liboai Roadmap + +This is a living backlog of improvements and ideas as we deepen our use of the library. It is intentionally lightweight and updated as we discover new needs. + +## Now +- Responses API support (GPT-5.2, gpt-5.2-pro) +- Keep all existing APIs stable and intact + +## Next +- Responses streaming helpers and SSE parsing +- ResponseInput helper to build Responses `input` items +- `output_text` convenience helper for Responses outputs +- Structured outputs helpers for `text.format` +- Tool definition builders for Responses (`tools`, `tool_choice`) + +## Later +- More robust testing coverage (unit + integration samples) +- Improved error messaging with request context (safe, no secrets) +- Expanded docs and cookbook-style examples +- Performance pass on JSON construction and streaming + +## Observations +- The Conversation class is useful for Chat Completions; Responses lacks an equivalent. +- The library is stable but needs modernization for new OpenAI primitives. +- Maintaining compatibility is critical for existing users. diff --git a/packages/media/cpp/packages/liboai/documentation/CMakeLists.txt b/packages/media/cpp/packages/liboai/documentation/CMakeLists.txt new file mode 100644 index 00000000..0840c592 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/CMakeLists.txt @@ -0,0 +1,28 @@ +cmake_minimum_required(VERSION 3.13) + +project(documentation) + +macro(add_example target_name source_name) + add_executable(${target_name} "${source_name}") + target_link_libraries(${target_name} oai) + set_target_properties(${target_name} PROPERTIES FOLDER "examples/${PROJECT_NAME}") +endmacro() + +macro(add_basic_example source_base_name) + add_example(${source_base_name} "${source_base_name}.cpp") +endmacro() + +add_subdirectory(audio/examples) +add_subdirectory(authorization/examples) +add_subdirectory(azure/examples) +add_subdirectory(chat/examples) +add_subdirectory(chat/conversation/examples) +add_subdirectory(completions/examples) +add_subdirectory(edits/examples) +add_subdirectory(embeddings/examples) +add_subdirectory(files/examples) +add_subdirectory(fine-tunes/examples) +add_subdirectory(images/examples) +add_subdirectory(models/examples) +add_subdirectory(moderations/examples) +add_subdirectory(responses/examples) diff --git a/packages/media/cpp/packages/liboai/documentation/README.md b/packages/media/cpp/packages/liboai/documentation/README.md new file mode 100644 index 00000000..7499979f --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/README.md @@ -0,0 +1,217 @@ +<h1>Documentation</h1> +<p>Both above and below, you can find resources and documentation for each component of the library.</p> + +<h3>Basic Usage</h3> +<p>In order to understand how to use each component of the library, it would be ideal to first understand the basic structure of the library as a whole. When using <code>liboai</code> in a project, you <b>should</b> only include one header file, <code>liboai.h</code>. This header provides an interface to all other components of the library such as <code>Images</code>, <code>Completions</code>, etc. + +See below for both a correct and incorrect example.</p> +<table> +<tr> +<th>Correct</th> +<th>Incorrect</th> +</tr> +<tr> +<td> + +```cpp +#include "liboai.h" + +int main() { + ... +} +``` + +</td> +<td> + +```cpp +#include "fine_tunes.h" +#include "models.h" +// etc... + +int main() { + ... +} +``` + +</td> +</tr> +</table> + +<br> +<p>Once we have properly included the necessary header file to use the library--and assuming symbols are linked properly--we can make use of the class in <code>liboai.h</code> to get started. At some point in our source code, we will have to choose when to define a <code>liboai::OpenAI</code> object to access component interfaces. Each component interface stored in this object offers methods associated with it, so, for instance, interface <code>Image</code> will have a method <code>create(...)</code> to generate an image from text. Each non-async method returns a <code>liboai::Response</code> containing response information whereas async methods return a <code>liboai::FutureResponse</code>. However, before we start using these methods, we must first set our authorization information--otherwise it will not work! + +<code>liboai::OpenAI</code> also houses another important member, the authorization member, which is used to set authorization information (such as the API key and organization IDs) before we call the API methods. For more information on additional members found in <code>liboai::Authorization</code>, refer to the <a href="./authorization">authorization</a> folder above. + +See below for both a correct and incorrect control flow when generating an image.</p> +<table> +<tr> +<th>Correct</th> +<th>Incorrect</th> +</tr> +<tr> +<td> + +```cpp +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + + // Set our API key using an environment variable. + // This is recommended as hard-coding API keys is + // insecure. + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + Response response = oai.Image->create( + "a siamese cat!" + ); + } + + ... +} +``` + +</td> +<td> + +```cpp +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + + // Failure to set authorization info! + // Will fail, exception will be thrown! + Response response = oai.Image->create( + "a siamese cat!" + ); + + ... +} +``` + +</td> +</tr> +</table> + +<br> +<p>As you can see above, authentication-set related functions return booleans to indicate success and failure, whereas component methods will throw an exception, <code>OpenAIException</code> or <code>OpenAIRateLimited</code>, to indicate their success or failure; these should be checked for accordingly. Below you can find an exception-safe version of the above correct snippet.</p> +<table> +<tr> +<th>Correct, exception-safe</th> +</tr> +<tr> +<td> + +```cpp +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response response = oai.Image->create( + "a siamese cat!" + ); + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + + ... + } +} +``` + +</td> +</tr> +</table> + +<br> +<p>Now, once we have made a call using a component interface, we most certainly want to get the information out of it. To do this, using our knowledge of the format of the API responses, we can extract the information, such as the resulting image's URL, using JSON indexing on the <code>liboai::Response</code> object. See below for an example where we print the generated image's URL.</p> +<table> +<tr> +<th>Accessing JSON Response Data</th> +</tr> +<tr> +<td> + +```cpp +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response response = oai.Image->create( + "a siamese cat!" + ); + std::cout << response["data"][0]["url"].get<std::string>() << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} +``` + +</td> +</tr> +</table> + +<br> +<p>What if we want to do more than just print the URL of the image? Why not download it right when it's done? Thankfully, <code>liboai</code> has a convenient function for that, <code>Network::Download(...)</code> (and <code>Network::DownloadAsync(...)</code>). See below for an example of downloading a freshly generated image. +<table> +<tr> +<th>Downloading a Generated Image</th> +</tr> +<td> + +```cpp +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response response = oai.Image->create( + "a siamese cat!" + ); + Network::Download( + "C:/some/folder/file.png", // to + response["data"][0]["url"].get<std::string>(), // from + oai.auth.GetAuthorizationHeaders() + ); + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} +``` + +</td> +</tr> +</table> + +<br> +<p>After a successful run of the above snippet, the file found at the URL returned from the component call will be download to the path <code>C:/some/folder/file.png</code>. +<br> + +<h1>Synopsis</h1> +<p>Each component interface found within <code>liboai::OpenAI</code> follows the same pattern found above. Whether you want to generate images, completions, or fine-tune models, the control flow should follow--or remain similar to--the above examples. + +For detailed examples regarding individual component interfaces, refer to the appropriate folder listed above.</p> + +<h3>Project Maintenance</h3> +<p>Maintainers can find PR workflow notes in <a href="./maintenance">documentation/maintenance</a>.</p> diff --git a/packages/media/cpp/packages/liboai/documentation/audio/README.md b/packages/media/cpp/packages/liboai/documentation/audio/README.md new file mode 100644 index 00000000..96ed24f0 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/audio/README.md @@ -0,0 +1,96 @@ +<h1>Audio</h1> +<p>The <code>Audio</code> class is defined in <code>audio.h</code> at <code>liboai::Audio</code>, and its interface can ideally be accessed through a <code>liboai::OpenAI</code> object. + +This class and its associated <code>liboai::OpenAI</code> interface allow access to the <a href="https://beta.openai.com/docs/api-reference/audio">Audio</a> endpoint of the OpenAI API; this endpoint's functionality can be found below.</p> +- Turn audio to text. +- Turn text to audio. + +<br> +<h2>Methods</h2> +<p>This document covers the method(s) located in <code>audio.h</code>. You can find their function signature(s) below.</p> + +<h3>Create a Transcription</h3> +<p>Transcribes audio into the input language. Returns a <code>liboai::Response</code> containing response data.</p> + +```cpp +liboai::Response transcribe( + const std::filesystem::path& file, + const std::string& model, + std::optional<std::string> prompt = std::nullopt, + std::optional<std::string> response_format = std::nullopt, + std::optional<float> temperature = std::nullopt, + std::optional<std::string> language = std::nullopt +) const & noexcept(false); +``` + +<h3>Create a Transcription (async)</h3> +<p>Asynchronously transcribes audio into the input language. Returns a <code>liboai::FutureResponse</code> containing future response data.</p> + +```cpp +liboai::FutureResponse transcribe_async( + const std::filesystem::path& file, + const std::string& model, + std::optional<std::string> prompt = std::nullopt, + std::optional<std::string> response_format = std::nullopt, + std::optional<float> temperature = std::nullopt, + std::optional<std::string> language = std::nullopt +) const& noexcept(false); +``` + +<h3>Create a Translation</h3> +<p>Translates audio into English. Returns a <code>liboai::Response</code> containing response data.</p> + +```cpp +liboai::Response translate( + const std::filesystem::path& file, + const std::string& model, + std::optional<std::string> prompt = std::nullopt, + std::optional<std::string> response_format = std::nullopt, + std::optional<float> temperature = std::nullopt +) const & noexcept(false); +``` + +<h3>Create a Translation (async)</h3> +<p>Asynchronously translates audio into English. Returns a <code>liboai::FutureResponse</code> containing future response data.</p> + +```cpp +liboai::FutureResponse translate_async( + const std::filesystem::path& file, + const std::string& model, + std::optional<std::string> prompt = std::nullopt, + std::optional<std::string> response_format = std::nullopt, + std::optional<float> temperature = std::nullopt +) const& noexcept(false); +``` + +<h3>Text to Speech</h3> +<p>Turn text into lifelike spoken audio. Returns a <code>liboai::Response</code> containing response data. The audio data is in the <code>content</code> field of the <code>liboai::Response</code></p> + +```cpp +liboai::Response speech( + const std::string& model, + const std::string& voice, + const std::string& input, + std::optional<std::string> response_format = std::nullopt, + std::optional<float> speed = std::nullopt +) const& noexcept(false); +``` + +<h3>Text to Speech (async)</h3> +<p>Asynchronously turn text into lifelike spoken audio. Returns a <code>liboai::FutureResponse</code> containing response data. The audio data is in the <code>content</code> field of the <code>liboai::Response</code></p> + +```cpp +liboai::FutureResponse speech_async( + const std::string& model, + const std::string& voice, + const std::string& input, + std::optional<std::string> response_format = std::nullopt, + std::optional<float> speed = std::nullopt +) const& noexcept(false); +``` + +<p>All function parameters marked <code>optional</code> are not required and are resolved on OpenAI's end if not supplied.</p> + +<br> +<h2>Example Usage</h2> +<p>For example usage of the above function(s), please refer to the <a href="./examples">examples</a> folder. diff --git a/packages/media/cpp/packages/liboai/documentation/audio/examples/CMakeLists.txt b/packages/media/cpp/packages/liboai/documentation/audio/examples/CMakeLists.txt new file mode 100644 index 00000000..c476f4b5 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/audio/examples/CMakeLists.txt @@ -0,0 +1,10 @@ +cmake_minimum_required(VERSION 3.13) + +project(audio) + +add_basic_example(create_speech) +add_basic_example(create_speech_async) +add_basic_example(create_transcription) +add_basic_example(create_transcription_async) +add_basic_example(create_translation) +add_basic_example(create_translation_async) diff --git a/packages/media/cpp/packages/liboai/documentation/audio/examples/create_speech.cpp b/packages/media/cpp/packages/liboai/documentation/audio/examples/create_speech.cpp new file mode 100644 index 00000000..306bf9fb --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/audio/examples/create_speech.cpp @@ -0,0 +1,24 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response res = oai.Audio->speech( + "tts-1", + "alloy", + "Today is a wonderful day to build something people love!" + ); + std::ofstream ocout("demo.mp3", std::ios::binary); + ocout << res.content; + ocout.close(); + std::cout << res.content.size() << std::endl; + } + catch (const std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/audio/examples/create_speech_async.cpp b/packages/media/cpp/packages/liboai/documentation/audio/examples/create_speech_async.cpp new file mode 100644 index 00000000..cd404cbf --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/audio/examples/create_speech_async.cpp @@ -0,0 +1,31 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + auto fut = oai.Audio->speech_async( + "tts-1", + "alloy", + "Today is a wonderful day to build something people love!" + ); + // do other work... + + // check if the future is ready + fut.wait(); + + // get the contained response + auto res = fut.get(); + std::ofstream ocout("demo.mp3", std::ios::binary); + ocout << res.content; + ocout.close(); + std::cout << res.content.size() << std::endl; + } + catch (const std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/audio/examples/create_transcription.cpp b/packages/media/cpp/packages/liboai/documentation/audio/examples/create_transcription.cpp new file mode 100644 index 00000000..a766bb07 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/audio/examples/create_transcription.cpp @@ -0,0 +1,20 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response res = oai.Audio->transcribe( + "C:/some/folder/audio.mp3", + "whisper-1" + ); + std::cout << res["text"].get<std::string>() << std::endl; + } + catch (const std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/audio/examples/create_transcription_async.cpp b/packages/media/cpp/packages/liboai/documentation/audio/examples/create_transcription_async.cpp new file mode 100644 index 00000000..b5eb00d5 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/audio/examples/create_transcription_async.cpp @@ -0,0 +1,30 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + // call async method; returns a future + auto fut = oai.Audio->transcribe_async( + "C:/some/folder/file.mp3", + "whisper-1" + ); + + // do other work... + + // check if the future is ready + fut.wait(); + + // get the contained response + auto response = fut.get(); + + // print some response data + std::cout << response["text"].get<std::string>() << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/audio/examples/create_translation.cpp b/packages/media/cpp/packages/liboai/documentation/audio/examples/create_translation.cpp new file mode 100644 index 00000000..6aa0c000 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/audio/examples/create_translation.cpp @@ -0,0 +1,20 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response res = oai.Audio->translate( + "C:/some/folder/file.mp3", + "whisper-1" + ); + std::cout << res["text"] << std::endl; + } + catch (const std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/audio/examples/create_translation_async.cpp b/packages/media/cpp/packages/liboai/documentation/audio/examples/create_translation_async.cpp new file mode 100644 index 00000000..db9e303f --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/audio/examples/create_translation_async.cpp @@ -0,0 +1,30 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + // call async method; returns a future + auto fut = oai.Audio->translate_async( + "C:/some/folder/file.mp3", + "whisper-1" + ); + + // do other work... + + // check if the future is ready + fut.wait(); + + // get the contained response + auto response = fut.get(); + + // print some response data + std::cout << response["text"].get<std::string>() << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/authorization/README.md b/packages/media/cpp/packages/liboai/documentation/authorization/README.md new file mode 100644 index 00000000..d466afcc --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/authorization/README.md @@ -0,0 +1,177 @@ +<h1>Authorization</h1> +<p>The <code>Authorization</code> class is defined in <code>authorization.h</code> at <code>liboai::Authorization</code>. This class is responsible for sharing all set authorization information with all component classes in <code>liboai</code>. + +All authorization information should be set prior to the calling of any component methods such as <code>Images</code>, <code>Embeddings</code>, and so on. Failure to do so will result in a <code>liboai::OpenAIException</code> due to authorization failure on OpenAI's end.</p> + +<br> +<h2>Methods</h2> +<p>This document covers the method(s) located in <code>authorization.h</code>. You can find their function signature(s) below.</p> + +<h3>Get Authorizer</h3> +<p>Returns a reference to the <code>liboai::Authorization</code> singleton shared among all components.</p> + +```cpp +static Authorization& Authorizer() noexcept; +``` + +<h3>Set API Key</h3> +<p>Sets the API key to use in subsequent component calls.</p> + +```cpp +bool SetKey(std::string_view key) noexcept; +``` + +<h3>Set Azure API Key</h3> +<p>Sets the Azure API key to use in subsequent component calls.</p> + +```cpp +bool SetAzureKey(std::string_view key) noexcept; +``` + +<h3>Set Active Directory Azure API Key</h3> +<p>Sets the Active Directory Azure API key to use in subsequent component calls.</p> + +```cpp +bool SetAzureKeyAD(std::string_view key) noexcept; +``` + +<h3>Set API Key (File)</h3> +<p>Sets the API key to use in subsequent component calls from data found in file at path.</p> + +```cpp +bool SetKeyFile(const std::filesystem::path& path) noexcept; +``` + +<h3>Set Azure API Key (File)</h3> +<p>Sets the Azure API key to use in subsequent component calls from data found in file at path.</p> + +```cpp +bool SetAzureKeyFile(const std::filesystem::path& path) noexcept; +``` + +<h3>Set Active Directory Azure API Key (File)</h3> +<p>Sets the Active Directory Azure API key to use in subsequent component calls from data found in file at path.</p> + +```cpp +bool SetAzureKeyFileAD(const std::filesystem::path& path) noexcept; +``` + +<h3>Set API Key (Environment Variable)</h3> +<p>Sets the API key to use in subsequent component calls from an environment variable.</p> + +```cpp +bool SetKeyEnv(std::string_view var) noexcept; +``` + +<h3>Set Azure API Key (Environment Variable)</h3> +<p>Sets the Azure API key to use in subsequent component calls from an environment variable.</p> + +```cpp +bool SetAzureKeyEnv(std::string_view var) noexcept; +``` + +<h3>Set Active Directory Azure API Key (Environment Variable)</h3> +<p>Sets the Active Directory Azure API key to use in subsequent component calls from an environment variable.</p> + +```cpp +bool SetAzureKeyEnvAD(std::string_view var) noexcept; +``` + +<h3>Set Organization ID</h3> +<p>Sets the organization ID to send in subsequent component calls.</p> + +```cpp +bool SetOrganization(std::string_view org) noexcept; +``` + +<h3>Set Organization ID (File)</h3> +<p>Sets the organization ID to send in subsequent component calls from data found in file at path.</p> + +```cpp +bool SetOrganizationFile(const std::filesystem::path& path) noexcept; +``` + +<h3>Set Organization ID (Environment Variable)</h3> +<p>Sets the organization ID to send in subsequent component calls from an environment variable.</p> + +```cpp +bool SetOrganizationEnv(std::string_view var) noexcept; +``` + +<h3>Set Proxies</h3> +<p>Sets the proxy, or proxies, to use in subsequent component calls.</p> + +```cpp +void SetProxies(const std::initializer_list<std::pair<const std::string, std::string>>& hosts) noexcept; +void SetProxies(std::initializer_list<std::pair<const std::string, std::string>>&& hosts) noexcept; +void SetProxies(const std::map<std::string, std::string>& hosts) noexcept; +void SetProxies(std::map<std::string, std::string>&& hosts) noexcept; +``` + +<h3>Set Proxy Authentication</h3> +<p>Sets the username and password to use when using a certain proxy protocol.</p> + +```cpp +void SetProxyAuth(const std::map<std::string, netimpl::components::EncodedAuthentication>& proto_up) noexcept; +``` + +<h3>Set Timeout</h3> +<p>Sets the timeout in milliseconds for the library to use in component calls.</p> + +```cpp +void SetMaxTimeout(int32_t ms) noexcept +``` + +<h3>Get Key</h3> +<p>Returns the currently set API key.</p> + +```cpp +constexpr const std::string& GetKey() const noexcept; +``` + +<h3>Get Organization ID</h3> +<p>Returns the currently set organization ID.</p> + +```cpp +constexpr const std::string& GetOrganization() const noexcept; +``` + + +<h3>Get Proxies</h3> +<p>Returns the currently set proxies.</p> + +```cpp +netimpl::components::Proxies GetProxies() const noexcept; +``` + +<h3>Get Proxy Authentication</h3> +<p>Returns the currently set proxy authentication information.</p> + +```cpp +netimpl::components::ProxyAuthentication GetProxyAuth() const noexcept; +``` + +<h3>Get Timeout</h3> +<p>Returns the currently set timeout.</p> + +```cpp +netimpl::components::Timeout GetMaxTimeout() const noexcept; +``` + +<h3>Get Authorization Headers</h3> +<p>Returns the currently set authorization headers based on set information.</p> + +```cpp +constexpr const netimpl::components::Header& GetAuthorizationHeaders() const noexcept; +``` + +<h3>Get Azure Authorization Headers</h3> +<p>Returns the currently set Azure authorization headers based on set information.</p> + +```cpp +constexpr const netimpl::components::Header& GetAzureAuthorizationHeaders() const noexcept; +``` + +<br> +<h2>Example Usage</h2> +<p>For example usage of the above function(s), please refer to the <a href="./examples">examples</a> folder. diff --git a/packages/media/cpp/packages/liboai/documentation/authorization/examples/CMakeLists.txt b/packages/media/cpp/packages/liboai/documentation/authorization/examples/CMakeLists.txt new file mode 100644 index 00000000..1fa3fd49 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/authorization/examples/CMakeLists.txt @@ -0,0 +1,15 @@ +cmake_minimum_required(VERSION 3.13) + +project(authorization) + +add_basic_example(set_azure_key) +add_basic_example(set_azure_key_env) +add_basic_example(set_azure_key_file) +add_basic_example(set_key) +add_basic_example(set_key_env_var) +add_basic_example(set_key_file) +add_basic_example(set_organization) +add_basic_example(set_organization_env_var) +add_basic_example(set_organization_file) +add_basic_example(set_proxies) +add_basic_example(set_proxy_auth) diff --git a/packages/media/cpp/packages/liboai/documentation/authorization/examples/set_azure_key.cpp b/packages/media/cpp/packages/liboai/documentation/authorization/examples/set_azure_key.cpp new file mode 100644 index 00000000..a4f065ce --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/authorization/examples/set_azure_key.cpp @@ -0,0 +1,10 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetAzureKey("hard-coded-key")) { // NOT recommended + // ... + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/authorization/examples/set_azure_key_env.cpp b/packages/media/cpp/packages/liboai/documentation/authorization/examples/set_azure_key_env.cpp new file mode 100644 index 00000000..a5797bf3 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/authorization/examples/set_azure_key_env.cpp @@ -0,0 +1,10 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetAzureKeyEnv("AZURE_API_KEY")) { + // ... + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/authorization/examples/set_azure_key_file.cpp b/packages/media/cpp/packages/liboai/documentation/authorization/examples/set_azure_key_file.cpp new file mode 100644 index 00000000..dee52cab --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/authorization/examples/set_azure_key_file.cpp @@ -0,0 +1,10 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetAzureKeyFile("C:/some/folder/key.dat")) { + // ... + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/authorization/examples/set_key.cpp b/packages/media/cpp/packages/liboai/documentation/authorization/examples/set_key.cpp new file mode 100644 index 00000000..599e3b4c --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/authorization/examples/set_key.cpp @@ -0,0 +1,10 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKey("hard-coded-key")) { // NOT recommended + // ... + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/authorization/examples/set_key_env_var.cpp b/packages/media/cpp/packages/liboai/documentation/authorization/examples/set_key_env_var.cpp new file mode 100644 index 00000000..58c3d61b --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/authorization/examples/set_key_env_var.cpp @@ -0,0 +1,10 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + // ... + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/authorization/examples/set_key_file.cpp b/packages/media/cpp/packages/liboai/documentation/authorization/examples/set_key_file.cpp new file mode 100644 index 00000000..e76415b6 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/authorization/examples/set_key_file.cpp @@ -0,0 +1,10 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyFile("C:/some/folder/key.dat")) { + // ... + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/authorization/examples/set_organization.cpp b/packages/media/cpp/packages/liboai/documentation/authorization/examples/set_organization.cpp new file mode 100644 index 00000000..9686880f --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/authorization/examples/set_organization.cpp @@ -0,0 +1,10 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY") && oai.auth.SetOrganization("org-123")) { + // ... + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/authorization/examples/set_organization_env_var.cpp b/packages/media/cpp/packages/liboai/documentation/authorization/examples/set_organization_env_var.cpp new file mode 100644 index 00000000..0e3926a9 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/authorization/examples/set_organization_env_var.cpp @@ -0,0 +1,10 @@ +#include "liboai.h" + +using namespace liboai; + + int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY") && oai.auth.SetOrganizationEnv("OPENAI_ORG_ID")) { + // ... + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/authorization/examples/set_organization_file.cpp b/packages/media/cpp/packages/liboai/documentation/authorization/examples/set_organization_file.cpp new file mode 100644 index 00000000..55b1ce0c --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/authorization/examples/set_organization_file.cpp @@ -0,0 +1,10 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY") && oai.auth.SetOrganizationFile("C:/some/folder/org.dat")) { + // ... + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/authorization/examples/set_proxies.cpp b/packages/media/cpp/packages/liboai/documentation/authorization/examples/set_proxies.cpp new file mode 100644 index 00000000..d11aad30 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/authorization/examples/set_proxies.cpp @@ -0,0 +1,21 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + + /* + Set some proxies: + when we go to an http site, use fakeproxy1 + when we go to an https site, use fakeproxy2 + */ + oai.auth.SetProxies({ + { "http", "http://www.fakeproxy1.com" }, + { "https", "https://www.fakeproxy2.com" } + }); + + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + // ... + } +} \ No newline at end of file diff --git a/packages/media/cpp/packages/liboai/documentation/authorization/examples/set_proxy_auth.cpp b/packages/media/cpp/packages/liboai/documentation/authorization/examples/set_proxy_auth.cpp new file mode 100644 index 00000000..4ef28bae --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/authorization/examples/set_proxy_auth.cpp @@ -0,0 +1,31 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + + /* + Set some proxies: + when we go to an http site, use fakeproxy1 + when we go to an https site, use fakeproxy2 + */ + oai.auth.SetProxies({ + { "http", "http://www.fakeproxy1.com" }, + { "https", "https://www.fakeproxy2.com" } + }); + + /* + Set the per-protocol proxy auth info: + when we go to an http site, use fakeuser1 and fakepass1 + when we go to an https site, use fakeuser2 and fakepass2 + */ + oai.auth.SetProxyAuth({ + {"http", {"fakeuser1", "fakepass1"}}, + {"https", {"fakeuser2", "fakepass2"}}, + }); + + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + // ... + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/azure/README.md b/packages/media/cpp/packages/liboai/documentation/azure/README.md new file mode 100644 index 00000000..d979761a --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/azure/README.md @@ -0,0 +1,204 @@ +<h1>Azure</h1> +<p>The <code>Azure</code> class is defined in <code>azure.h</code> at <code>liboai::Azure</code>, and its interface can ideally be accessed through a <code>liboai::OpenAI</code> object. + +This class and its associated <code>liboai::OpenAI</code> interface allow access to the <a href="https://learn.microsoft.com/en-us/azure/cognitive-services/openai/reference">Azure</a> OpenAI API components. + +<br> +<h2>Methods</h2> +<p>This document covers the method(s) located in <code>azure.h</code>. You can find their function signature(s) below.</p> + +<h3>Create a Completion</h3> +<p>Given a prompt, the model will return one or more predicted completions. Returns a <code>liboai::Response</code> containing response data.</p> + +```cpp +liboai::Response create_completion( + const std::string& resource_name, + const std::string& deployment_id, + const std::string& api_version, + std::optional<std::string> prompt = std::nullopt, + std::optional<std::string> suffix = std::nullopt, + std::optional<uint16_t> max_tokens = std::nullopt, + std::optional<float> temperature = std::nullopt, + std::optional<float> top_p = std::nullopt, + std::optional<uint16_t> n = std::nullopt, + std::optional<std::function<bool(std::string, intptr_t)>> stream = std::nullopt, + std::optional<uint8_t> logprobs = std::nullopt, + std::optional<bool> echo = std::nullopt, + std::optional<std::vector<std::string>> stop = std::nullopt, + std::optional<float> presence_penalty = std::nullopt, + std::optional<float> frequency_penalty = std::nullopt, + std::optional<uint16_t> best_of = std::nullopt, + std::optional<std::unordered_map<std::string, int8_t>> logit_bias = std::nullopt, + std::optional<std::string> user = std::nullopt +) const & noexcept(false); +``` + +<h3>Create a Completion (async)</h3> +<p>Given a prompt, the model will asynchronously return one or more predicted completions. Returns a <code>liboai::FutureResponse</code> containing future response data.</p> + +```cpp +liboai::FutureResponse create_completion_async( + const std::string& resource_name, + const std::string& deployment_id, + const std::string& api_version, + std::optional<std::string> prompt = std::nullopt, + std::optional<std::string> suffix = std::nullopt, + std::optional<uint16_t> max_tokens = std::nullopt, + std::optional<float> temperature = std::nullopt, + std::optional<float> top_p = std::nullopt, + std::optional<uint16_t> n = std::nullopt, + std::optional<std::function<bool(std::string, intptr_t)>> stream = std::nullopt, + std::optional<uint8_t> logprobs = std::nullopt, + std::optional<bool> echo = std::nullopt, + std::optional<std::vector<std::string>> stop = std::nullopt, + std::optional<float> presence_penalty = std::nullopt, + std::optional<float> frequency_penalty = std::nullopt, + std::optional<uint16_t> best_of = std::nullopt, + std::optional<std::unordered_map<std::string, int8_t>> logit_bias = std::nullopt, + std::optional<std::string> user = std::nullopt +) const & noexcept(false); +``` + +<h3>Create an Embedding</h3> +<p>Creates an embedding vector representing the input text. Returns a <code>liboai::Response</code> containing response data.</p> + +```cpp +liboai::Response create_embedding( + const std::string& resource_name, + const std::string& deployment_id, + const std::string& api_version, + const std::string& input, + std::optional<std::string> user = std::nullopt +) const & noexcept(false); +``` + +<h3>Create an Embedding (async)</h3> +<p>Asynchronously creates an embedding vector representing the input text. Returns a <code>liboai::FutureResponse</code> containing future response data.</p> + +```cpp +liboai::FutureResponse create_embedding_async( + const std::string& resource_name, + const std::string& deployment_id, + const std::string& api_version, + const std::string& input, + std::optional<std::string> user = std::nullopt +) const & noexcept(false); +``` + +<h3>Create a Chat Completion</h3> +<p>Creates a completion for the chat message. Returns a <code>liboai::Response</code> containing response data.</p> + +```cpp +liboai::Response create_chat_completion( + const std::string& resource_name, + const std::string& deployment_id, + const std::string& api_version, + const Conversation& conversation, + std::optional<float> temperature = std::nullopt, + std::optional<uint16_t> n = std::nullopt, + std::optional<std::function<bool(std::string, intptr_t)>> stream = std::nullopt, + std::optional<std::vector<std::string>> stop = std::nullopt, + std::optional<uint16_t> max_tokens = std::nullopt, + std::optional<float> presence_penalty = std::nullopt, + std::optional<float> frequency_penalty = std::nullopt, + std::optional<std::unordered_map<std::string, int8_t>> logit_bias = std::nullopt, + std::optional<std::string> user = std::nullopt +) const & noexcept(false); +``` + +<h3>Create a Chat Completion (async)</h3> +<p>Asynchronously creates a completion for the chat message. Returns a <code>liboai::FutureResponse</code> containing future response data.</p> + +```cpp +liboai::FutureResponse create_chat_completion_async( + const std::string& resource_name, + const std::string& deployment_id, + const std::string& api_version, + const Conversation& conversation, + std::optional<float> temperature = std::nullopt, + std::optional<uint16_t> n = std::nullopt, + std::optional<std::function<bool(std::string, intptr_t)>> stream = std::nullopt, + std::optional<std::vector<std::string>> stop = std::nullopt, + std::optional<uint16_t> max_tokens = std::nullopt, + std::optional<float> presence_penalty = std::nullopt, + std::optional<float> frequency_penalty = std::nullopt, + std::optional<std::unordered_map<std::string, int8_t>> logit_bias = std::nullopt, + std::optional<std::string> user = std::nullopt +) const & noexcept(false); +``` + +<h3>Request an Image Generation</h3> +<p>Generate a batch of images from a text caption. Returns a <code>liboai::Response</code> containing response data.</p> + +```cpp +liboai::Response request_image_generation( + const std::string& resource_name, + const std::string& api_version, + const std::string& prompt, + std::optional<uint8_t> n = std::nullopt, + std::optional<std::string> size = std::nullopt +) const & noexcept(false); +``` + +<h3>Request an Image Generation (async)</h3> +<p>Asynchronously generate a batch of images from a text caption. Returns a <code>liboai::FutureResponse</code> containing future response data.</p> + +```cpp +liboai::FutureResponse request_image_generation_async( + const std::string& resource_name, + const std::string& api_version, + const std::string& prompt, + std::optional<uint8_t> n = std::nullopt, + std::optional<std::string> size = std::nullopt +) const & noexcept(false); +``` + +<h3>Get a Previously Generated Image</h3> +<p>Retrieve the results (URL) of a previously called image generation operation. Returns a <code>liboai::Response</code> containing response data.</p> + +```cpp +liboai::Response get_generated_image( + const std::string& resource_name, + const std::string& api_version, + const std::string& operation_id +) const & noexcept(false); +``` + +<h3>Get a Previously Generated Image (async)</h3> +<p>Asynchronously retrieve the results (URL) of a previously called image generation operation. Returns a <code>liboai::FutureResponse</code> containing future response data.</p> + +```cpp +liboai::FutureResponse get_generated_image_async( + const std::string& resource_name, + const std::string& api_version, + const std::string& operation_id +) const & noexcept(false); +``` + +<h3>Delete a Previously Generated Image</h3> +<p>Deletes the corresponding image from the Azure server. Returns a <code>liboai::Response</code> containing response data.</p> + +```cpp +liboai::Response delete_generated_image( + const std::string& resource_name, + const std::string& api_version, + const std::string& operation_id +) const & noexcept(false); +``` + +<h3>Delete a Previously Generated Image (async)</h3> +<p>Asynchronously deletes the corresponding image from the Azure server. Returns a <code>liboai::FutureResponse</code> containing future response data.</p> + +```cpp +liboai::FutureResponse delete_generated_image_async( + const std::string& resource_name, + const std::string& api_version, + const std::string& operation_id +) const & noexcept(false); +``` + +<p>All function parameters marked <code>optional</code> are not required and are resolved on OpenAI's end if not supplied.</p> + +<br> +<h2>Example Usage</h2> +<p>For example usage of the above function(s), please refer to the <a href="./examples">examples</a> folder. diff --git a/packages/media/cpp/packages/liboai/documentation/azure/examples/CMakeLists.txt b/packages/media/cpp/packages/liboai/documentation/azure/examples/CMakeLists.txt new file mode 100644 index 00000000..22760b45 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/azure/examples/CMakeLists.txt @@ -0,0 +1,16 @@ +cmake_minimum_required(VERSION 3.13) + +project(azure) + +add_example(create_chat_completion_azure "create_chat_completion.cpp") +add_example(create_chat_completion_async_azure "create_chat_completion_async.cpp") +add_basic_example(create_completion) +add_basic_example(create_completion_async) +add_example(create_embedding_azure "create_embedding.cpp") +add_example(create_embedding_async_azure "create_embedding_async.cpp") +add_basic_example(delete_generated_image) +add_basic_example(delete_generated_image_async) +add_basic_example(get_generated_image) +add_basic_example(get_generated_image_async) +add_basic_example(request_image_generation) +add_basic_example(request_image_generation_async) diff --git a/packages/media/cpp/packages/liboai/documentation/azure/examples/create_chat_completion.cpp b/packages/media/cpp/packages/liboai/documentation/azure/examples/create_chat_completion.cpp new file mode 100644 index 00000000..d9b1c87e --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/azure/examples/create_chat_completion.cpp @@ -0,0 +1,28 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + + Conversation convo; + convo.AddUserData("Hi, how are you?"); + + if (oai.auth.SetAzureKeyEnv("AZURE_API_KEY")) { + try { + Response res = oai.Azure->create_chat_completion( + "resource", "deploymentID", "api_version", + convo + ); + + // update the conversation with the response + convo.Update(res); + + // print the response from the API + std::cout << convo.GetLastResponse() << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/azure/examples/create_chat_completion_async.cpp b/packages/media/cpp/packages/liboai/documentation/azure/examples/create_chat_completion_async.cpp new file mode 100644 index 00000000..06d64c09 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/azure/examples/create_chat_completion_async.cpp @@ -0,0 +1,37 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + + Conversation convo; + convo.AddUserData("Hi, how are you?"); + + if (oai.auth.SetAzureKeyEnv("AZURE_API_KEY")) { + try { + // call async method; returns a future + auto fut = oai.Azure->create_chat_completion_async( + "resource", "deploymentID", "api_version", + convo + ); + + // do other work... + + // check if the future is ready + fut.wait(); + + // get the contained response + auto res = fut.get(); + + // update the conversation with the response + convo.Update(res); + + // print the response from the API + std::cout << convo.GetLastResponse() << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/azure/examples/create_completion.cpp b/packages/media/cpp/packages/liboai/documentation/azure/examples/create_completion.cpp new file mode 100644 index 00000000..0f14f282 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/azure/examples/create_completion.cpp @@ -0,0 +1,21 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + + if (oai.auth.SetAzureKeyEnv("AZURE_API_KEY")) { + try { + Response res = oai.Azure->create_completion( + "resource", "deploymentID", "api_version", + "Write a short poem about a snowman." + ); + + std::cout << res["choices"][0]["text"].get<std::string>() << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/azure/examples/create_completion_async.cpp b/packages/media/cpp/packages/liboai/documentation/azure/examples/create_completion_async.cpp new file mode 100644 index 00000000..a94bc00e --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/azure/examples/create_completion_async.cpp @@ -0,0 +1,29 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + + if (oai.auth.SetAzureKeyEnv("AZURE_API_KEY")) { + try { + auto fut = oai.Azure->create_completion_async( + "resource", "deploymentID", "api_version", + "Write a short poem about a snowman." + ); + + // do other stuff + + // wait for the future to be ready + fut.wait(); + + // get the result + auto res = fut.get(); + + std::cout << res["choices"][0]["text"].get<std::string>() << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/azure/examples/create_embedding.cpp b/packages/media/cpp/packages/liboai/documentation/azure/examples/create_embedding.cpp new file mode 100644 index 00000000..61070f8f --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/azure/examples/create_embedding.cpp @@ -0,0 +1,21 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + + if (oai.auth.SetAzureKeyEnv("AZURE_API_KEY")) { + try { + Response res = oai.Azure->create_embedding( + "resource", "deploymentID", "api_version", + "String to get embedding for" + ); + + std::cout << res << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/azure/examples/create_embedding_async.cpp b/packages/media/cpp/packages/liboai/documentation/azure/examples/create_embedding_async.cpp new file mode 100644 index 00000000..8734cad3 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/azure/examples/create_embedding_async.cpp @@ -0,0 +1,27 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + + if (oai.auth.SetAzureKeyEnv("AZURE_API_KEY")) { + try { + auto fut = oai.Azure->create_embedding_async( + "resource", "deploymentID", "api_version", + "String to get embedding for" + ); + + // do other work + + // wait for the future to complete + auto res = fut.get(); + + // output the response + std::cout << res << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/azure/examples/delete_generated_image.cpp b/packages/media/cpp/packages/liboai/documentation/azure/examples/delete_generated_image.cpp new file mode 100644 index 00000000..e261e65d --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/azure/examples/delete_generated_image.cpp @@ -0,0 +1,22 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + + if (oai.auth.SetAzureKeyEnv("AZURE_API_KEY")) { + try { + Response res = oai.Azure->delete_generated_image( + "resource", "api_version", + "f508bcf2-e651-4b4b-85a7-58ad77981ffa" + ); + + // output the response + std::cout << res << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/azure/examples/delete_generated_image_async.cpp b/packages/media/cpp/packages/liboai/documentation/azure/examples/delete_generated_image_async.cpp new file mode 100644 index 00000000..714442f7 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/azure/examples/delete_generated_image_async.cpp @@ -0,0 +1,30 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + + if (oai.auth.SetAzureKeyEnv("AZURE_API_KEY")) { + try { + auto fut = oai.Azure->delete_generated_image_async( + "resource", "api_version", + "f508bcf2-e651-4b4b-85a7-58ad77981ffa" + ); + + // do other work + + // wait for the future to complete + fut.wait(); + + // get the result + auto res = fut.get(); + + // output the response + std::cout << res << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/azure/examples/get_generated_image.cpp b/packages/media/cpp/packages/liboai/documentation/azure/examples/get_generated_image.cpp new file mode 100644 index 00000000..8b189df7 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/azure/examples/get_generated_image.cpp @@ -0,0 +1,22 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + + if (oai.auth.SetAzureKeyEnv("AZURE_API_KEY")) { + try { + Response res = oai.Azure->get_generated_image( + "resource", "api_version", + "f508bcf2-e651-4b4b-85a7-58ad77981ffa" + ); + + // output the response + std::cout << res << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/azure/examples/get_generated_image_async.cpp b/packages/media/cpp/packages/liboai/documentation/azure/examples/get_generated_image_async.cpp new file mode 100644 index 00000000..08ce9b7a --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/azure/examples/get_generated_image_async.cpp @@ -0,0 +1,30 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + + if (oai.auth.SetAzureKeyEnv("AZURE_API_KEY")) { + try { + auto fut = oai.Azure->get_generated_image_async( + "resource", "api_version", + "f508bcf2-e651-4b4b-85a7-58ad77981ffa" + ); + + // do other work + + // wait for the future to complete + fut.wait(); + + // get the result + auto res = fut.get(); + + // output the response + std::cout << res << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/azure/examples/request_image_generation.cpp b/packages/media/cpp/packages/liboai/documentation/azure/examples/request_image_generation.cpp new file mode 100644 index 00000000..c8694721 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/azure/examples/request_image_generation.cpp @@ -0,0 +1,24 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + + if (oai.auth.SetAzureKeyEnv("AZURE_API_KEY")) { + try { + Response res = oai.Azure->request_image_generation( + "resource", "api_version", + "A snake in the grass!", + 1, + "512x512" + ); + + // output the response + std::cout << res["data"][0]["url"].get<std::string>() << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/azure/examples/request_image_generation_async.cpp b/packages/media/cpp/packages/liboai/documentation/azure/examples/request_image_generation_async.cpp new file mode 100644 index 00000000..06c6a090 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/azure/examples/request_image_generation_async.cpp @@ -0,0 +1,29 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + + if (oai.auth.SetAzureKeyEnv("AZURE_API_KEY")) { + try { + auto fut = oai.Azure->request_image_generation_async( + "resource", "api_version", + "A snake in the grass!", + 1, + "512x512" + ); + + // do other work + + // wait for the future to complete + auto res = fut.get(); + + // output the response + std::cout << res["data"][0]["url"].get<std::string>() << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/chat/README.md b/packages/media/cpp/packages/liboai/documentation/chat/README.md new file mode 100644 index 00000000..bf32b68f --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/chat/README.md @@ -0,0 +1,63 @@ +<h1>Chat</h1> +<p>The <code>ChatCompletion</code> class is defined in <code>chat.h</code> at <code>liboai::ChatCompletion</code>, and its interface can ideally be accessed through a <code>liboai::OpenAI</code> object. + +This class and its associated <code>liboai::OpenAI</code> interface allow access to the <a href="https://beta.openai.com/docs/api-reference/chat">Chat</a> endpoint of the OpenAI API; this endpoint's functionality can be found below.</p> +- Given a chat conversation, the model will return a chat completion response. + +> **Note** +> +> Before attempting to use the below methods, it is **highly** recommended +> to read through the documentation, and thoroughly understand the use, +> of the <a href="./conversation">Conversation</a> class as it is used +> in tandem with the `ChatCompletion` methods to keep track of chat +> history and succinctly form a conversation with the OpenAI chat +> endpoint. + +<h2>Methods</h2> +<p>This document covers the method(s) located in <code>chat.h</code>. You can find their function signature(s) below.</p> + +<h3>Create a Chat Completion</h3> +<p>Creates a completion for the ongoing conversation. Returns a <code>liboai::Response</code> containing response data.</p> + +```cpp +liboai::Response create( + const std::string& model, + const Conversation& conversation, + std::optional<float> temperature = std::nullopt, + std::optional<float> top_p = std::nullopt, + std::optional<uint16_t> n = std::nullopt, + std::optional<std::function<bool(std::string, intptr_t)>> stream = std::nullopt, + std::optional<std::vector<std::string>> stop = std::nullopt, + std::optional<uint16_t> max_tokens = std::nullopt, + std::optional<float> presence_penalty = std::nullopt, + std::optional<float> frequency_penalty = std::nullopt, + std::optional<std::unordered_map<std::string, int8_t>> logit_bias = std::nullopt, + std::optional<std::string> user = std::nullopt +) const & noexcept(false); +``` + +<h3>Create a Chat Completion (async)</h3> +<p>Asynchronously creates a completion for the ongoing conversation. Returns a <code>liboai::FutureResponse</code> containing future response data.</p> + +```cpp +liboai::FutureResponse create_async( + const std::string& model, + const Conversation& conversation, + std::optional<float> temperature = std::nullopt, + std::optional<float> top_p = std::nullopt, + std::optional<uint16_t> n = std::nullopt, + std::optional<std::function<bool(std::string, intptr_t)>> stream = std::nullopt, + std::optional<std::vector<std::string>> stop = std::nullopt, + std::optional<uint16_t> max_tokens = std::nullopt, + std::optional<float> presence_penalty = std::nullopt, + std::optional<float> frequency_penalty = std::nullopt, + std::optional<std::unordered_map<std::string, int8_t>> logit_bias = std::nullopt, + std::optional<std::string> user = std::nullopt +) const& noexcept(false); +``` + +<p>All function parameters marked <code>optional</code> are not required and are resolved on OpenAI's end if not supplied.</p> + +<br> +<h2>Example Usage</h2> +<p>For example usage of the above function(s), please refer to the <a href="./examples">examples</a> folder. diff --git a/packages/media/cpp/packages/liboai/documentation/chat/conversation/README.md b/packages/media/cpp/packages/liboai/documentation/chat/conversation/README.md new file mode 100644 index 00000000..8c706e71 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/chat/conversation/README.md @@ -0,0 +1,409 @@ +<h1>Conversation</h1> + +<h3>Contents</h3> +<p>You can jump to any content found on this page using the links below. +<ul> + <li><a href="https://github.com/D7EAD/liboai/tree/v2.3.0/documentation/chat/conversation#basic-use">Basic Use</a></li> + <li><a href="https://github.com/D7EAD/liboai/tree/v2.3.0/documentation/chat/conversation#the-use-of-system">The Use of System</a></li> + <li><a href="https://github.com/D7EAD/liboai/tree/v2.3.0/documentation/chat/conversation#usage-pattern">Usage Pattern</a></li> + <li><a href="https://github.com/D7EAD/liboai/tree/v2.3.0/documentation/chat/conversation#synopsis">Synopsis</a</li> + <li><a href="https://github.com/D7EAD/liboai/tree/v2.3.0/documentation/chat/conversation#synopsis">Methods</a></li> +</ul> + +The <code>Conversation</code> class is defined at <code>liboai::Conversation</code>. + +This class can most effectively be thought of as a container for any conversation(s) that one may wish to carry out with a given model using the <code>ChatCompletion</code> methods. It keeps track of the history of the conversation for subsequent calls to the methods, allows a developer to set <a href="https://platform.openai.com/docs/guides/chat/instructing-chat-models">system</a> directions, retrieve the last response, add user input, and so on. + +<h3>Basic Use</h3> + +Each method found in <code>ChatCompletion</code> requires an existing object of class <code>Conversation</code> be provided. Before providing such an object to a method such as <code>liboai::ChatCompletion::create</code>, we must first populate it--perhaps with a question to ask the model we choose, like so: + +<table> +<tr> +<th>Creating a Conversation</th> +</tr> +<tr> +<td> + +```cpp +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + + // create a conversation + Conversation convo; + + // add a message to the conversation + convo.AddUserData("Hello, how are you?"); + + ... +} +``` + +</td> +</tr> +</table> + +Once we add a message to our <code>Conversation</code>, we can then supply it to a method such as <code>liboai::ChatCompletion::create</code> to begin our conversation starting with our user data, like so: +<table> +<tr> +<th>Starting the Conversation</th> +</tr> +<tr> +<td> + +```cpp +int main() { + OpenAI oai; + + // create a conversation + Conversation convo; + + // add a message to the conversation + convo.AddUserData("Hello, how are you?"); + + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response response = oai.ChatCompletion->create( + "gpt-3.5-turbo", convo + ); + + ... + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} +``` + +</td> +</tr> +</table> + +Assuming that our request succeeded without throwing an exception, the response to our user data in our <code>Conversation</code> can be found in our <code>Response</code> object. We must now update our <code>Conversation</code> object with the response like so: +<table> +<tr> +<th>Updating our Conversation</th> +</tr> +<tr> +<td> + +```cpp +int main() { + OpenAI oai; + + // create a conversation + Conversation convo; + + // add a message to the conversation + convo.AddUserData("Hello, how are you?"); + + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response response = oai.ChatCompletion->create( + "gpt-3.5-turbo", convo + ); + + // update our conversation with the response + convo.Update(response); + + ... + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} +``` + +</td> +</tr> +</table> + +After we update our <code>Conversation</code>, it now contains the original question we asked the model, as well as the response from the model. Now we can extract the response like so: +<table> +<tr> +<th>Printing the Response</th> +</tr> +<tr> +<td> + +```cpp +int main() { + OpenAI oai; + + // create a conversation + Conversation convo; + + // add a message to the conversation + convo.AddUserData("Hello, how are you?"); + + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response response = oai.ChatCompletion->create( + "gpt-3.5-turbo", convo + ); + + // update our conversation with the response + convo.Update(response); + + // print the response + std::cout << convo.GetLastResponse() << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} +``` + +</td> +</tr> +</table> + +This may print something along the lines of the following: +* "<i>As an AI language model, I do not have emotions, but I am always responsive and ready to assist. How can I help you today?</i>" + +<h3>Usage Pattern</h3> +As you have hopefully noticed, there is a pattern that can be followed with <code>Conversation</code>. Generally, when we want to make use of the methods found within <code>liboai::ChatCompletion</code>, we should adhere to the following series of steps: +<ol> + <li>Create a <code>Conversation</code> object.</li> + <li>Set the user data (or optional, single-time system data as well), which is the user's input such as a question or a command.</li> + <li>Provide the <codeChatCompletion::Conversation</code> object to <code>ChatCompletion::create</code> or a similar method.</li> + <li>Update the <code>Conversation</code> object with the response from the API.</li> + <li>Retrieve the chat model's response from the <code>Conversation</code> object.</li> + <li>Repeat steps 2, 3, 4, and 5 until the conversation is complete.</li> +</ol> + +<h3>The Use of System</h3> +Other than setting user data in our <code>Conversation</code> objects, we can also set an optional system parameter that instructs the model on how to respond. If we wish to make use of this system parameter, we can do so like so: +<table> +<tr> +<th>Setting System Data to Guide Models</th> +</tr> +<tr> +<td> + +```cpp +int main() { + OpenAI oai; + + // create a conversation + Conversation convo; + + // set the system message first - helps guide the model + convo.SetSystemData("You are a helpful bot that only answers questions about OpenAI."); + + // add a message to the conversation + convo.AddUserData("Hello, how are you?"); + + ... +} +``` + +</td> +</tr> +</table> + +Keep in mind that it is **highly** important to set the system data before user data. Furthermore, it is important to note that, according to OpenAI, some models (such as gpt-3.5-turbo-0301) do not always pay attention to this system data. As a result, it may be more efficient to set guiding data as user data like so: +<table> +<tr> +<th>Alternate Ways to Guide</th> +</tr> +<tr> +<td> + +```cpp +int main() { + OpenAI oai; + + // create a conversation + Conversation convo; + + // add guiding data and a message to the conversation + convo.AddUserData("You are a helpful bot that only answers questions about OpenAI: Hello, how are you?"); + + ... +} +``` + +</td> +</tr> +</table> + +<h3>Synopsis</h3> +With the use of <code>Conversation</code> objects, as we carry on a given conversation, our object will keep track of not only the history of the conversation we are having, but its contained context as well. That means that if we were to, at first, ask our model "When was last year's Super Bowl," and then subsequently ask it, "Who played in it," it would be aware of the context of the conversation for the second inquiry and answer accordingly. +<br> +<br> +In general, objects of class <code>liboai::Conversation</code> allow us to more easily engage in conversation with existing and future conversational chat models via the use of <code>liboai::ChatCompletion</code> methods. + +<h2>Methods</h2> +Below you can find the function signature(s) of the class methods found within <code>liboai::Conversation</code>. + +<h3>Constructors</h3> +<p>Constructors available to construct a <code>Conversation</code> object.</p> + +```cpp +Conversation(); +Conversation(const Conversation& other); +Conversation(Conversation&& old) noexcept; +Conversation(std::string_view system_data); +Conversation(std::string_view system_data, std::string_view user_data); +Conversation(std::string_view system_data, std::initializer_list<std::string_view> user_data); +Conversation(std::initializer_list<std::string_view> user_data); +explicit Conversation(const std::vector<std::string>& user_data); +``` + +<h3>Assignment Operators</h3> +<p>Operator overloads for assignment.</p> + +```cpp +Conversation& operator=(const Conversation& other); +Conversation& operator=(Conversation&& old) noexcept; +``` + +<h3>Set System Data</h3> +<p>Sets the system parameter in the conversation that can be used to influence how the model may respond to input. This should always be called before setting user data, if used. Returns a <code>bool</code> indicating success.</p> + +```cpp +bool SetSystemData(std::string_view data) & noexcept(false); +``` + +<h3>Pop System Data</h3> +<p>Removes (pops) the set system data. Returns a <code>bool</code> indicating success.</p> + +```cpp +bool PopSystemData() & noexcept(false); +``` + + +<h3>Add User Data</h3> +<p>Adds user input to the conversation, such as a command or question to pose to a model. Returns a <code>bool</code> indicating success.</p> + +```cpp +bool AddUserData(std::string_view data) & noexcept(false); +bool AddUserData(std::string_view data, std::string_view name) & noexcept(false); +``` + +<h3>Pop User Data</h3> +<p>Removes (pops) the most recently added user input to the conversation as long as it is the tail of the conversation. Returns a <code>bool</code> indicating success.</p> + +```cpp +bool PopUserData() & noexcept(false); +``` + +<h3>Get Last Response</h3> +<p>Retrieves the last response from the conversation if one exists. This can be called when the last item in the conversation is an answer from a chat model, such as after the conversation is updated with a successful response from <code>liboai::ChatCompletion::create</code>. Returns a non-empty <code>std::string</code> containing the response from the chat model if one exists, empty otherwise.</p> + +```cpp +std::string GetLastResponse() const & noexcept; +``` + +<h3>Pop Last Response</h3> +<p>Removes (pops) the last response from a chat model within the conversation if the tail of the conversation is a response. This can be called to remove a chat model response from the conversation after updating the conversation with said response. Returns a <code>bool</code> indicating success.</p> + +```cpp +bool PopLastResponse() & noexcept(false); +``` + +<h3>Check if Last Response is Function Call</h3> +<p>Returns whether the most recent response, following a call to <code>Update</code> or a complete <code>AppendStreamData</code>, contains a function_call or not. Returns a boolean indicating if the last response is a function call.</p> + +```cpp +bool LastResponseIsFunctionCall() const & noexcept; +``` + +<h3>Get the Name of the Last Response's Function Call</h3> +<p>Returns the name of the function_call in the most recent response. This should only be called if <code>LastResponseIsFunctionCall()</code> returns true. Returns a <code>std::string</code> containing the name of the last response's function call, empty if non-existent.</p> + +```cpp +std::string GetLastFunctionCallName() const & noexcept(false); +``` + +<h3>Get the Arguments of the Last Response's Function Call</h3> +<p>Returns the arguments of the function_call in the most recent response in their raw JSON form. This should only be called if <code>LastResponseIsFunctionCall()</code> returns true. Returns a <code>std::string</code> containing the name of the last response's arguments in JSON form, empty if non-existent.</p> + +```cpp +std::string GetLastFunctionCallArguments() const & noexcept(false); +``` + +<h3>Update Conversation</h3> +<p>Updates the conversation given a Response object. This method updates the conversation given a Response object. This method should only be used if <code>AppendStreamData</code> was NOT used immediately before it. + +For instance, if we made a call to <code>create*()</code>, and provided a callback function to stream and, within this callback, we used <code>AppendStreamData</code> to update the conversation per message, we would NOT want to use this method. In this scenario, the <code>AppendStreamData</code> method would have already updated the conversation, so this method would be a bad idea to call afterwards. Returns a <code>bool</code> indicating success.</p> + +```cpp +bool Update(std::string_view history) & noexcept(false); +bool Update(const Response& response) & noexcept(false); +``` + +<h3>Export Conversation</h3> +<p>Exports the entire conversation to a JSON string. This method exports the conversation to a JSON string. The JSON string can be used to save the conversation to a file. The exported string contains both the conversation and included functions, if any. Returns the JSON string representing the conversation.</p> + +```cpp +std::string Export() const & noexcept(false); +``` + +<h3>Import Conversation</h3> +<p>Imports a conversation from a JSON string. This method imports a conversation from a JSON string. The JSON string should be the JSON string returned from a call to <code>Export()</code>. Returns a boolean indicating success.</p> + +```cpp +bool Import() const & noexcept(false); +``` + +<h3>Append Stream Data</h3> +<p>Appends stream data (SSEs) from streamed methods. This method updates the conversation given a token from a streamed method. This method should be used when using streamed methods such as <code>ChatCompletion::create</code> or <code>create_async</code> with a callback supplied. This function should be called from within the stream's callback function receiving the SSEs. Returns a boolean indicating data appending success.</p> + +```cpp +bool AppendStreamData(std::string data) & noexcept(false); +``` + +<h3>Set Function(s)</h3> +<p>Sets the functions to be used for the conversation. This method sets the functions to be used for the conversation. Returns a boolean indicating success.</p> + +```cpp +bool SetFunctions(Functions functions) & noexcept(false); +``` + +<h3>Pop Function(s)</h3> +<p>Pops any previously set functions.</p> + +```cpp +void PopFunctions() & noexcept(false); +``` + +<h3>Get Raw JSON Conversation</h3> +<p>Retrieves the raw JSON of the conversation; the same functionality can be achieved using the <code>operator<<(...)</code> overload. Returns a <code>std::string</code> containing the JSON of the conversation.</p> + +```cpp +std::string GetRawConversation() const & noexcept; +``` + +<h3>Get Raw JSON Functions</h3> +<p>Returns the raw JSON dump of the internal functions object in string format - if one exists.</p> + +```cpp +std::string GetRawFunctions() const & noexcept; +``` + +<h3>Get Functions JSON Object</h3> +<p>Returns the JSON object of the set functions.</p> + +```cpp +const nlohmann::json& GetFunctionsJSON() const & noexcept; +``` + +<h3>Get Internal JSON </h3> +<p>Retrieves a <code>const</code>-ref of the internal JSON object containing the conversation. Returns a <code>const nlohmann::json&</code> object.</p> + +```cpp +const nlohmann::json& GetJSON() const & noexcept; +``` + +<br> +<h2>Example Usage</h2> +<p>For example usage of the above function(s), please refer to the <a href="./examples">examples</a> folder here and in the previous directory.</p> diff --git a/packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/CMakeLists.txt b/packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/CMakeLists.txt new file mode 100644 index 00000000..8e314741 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/CMakeLists.txt @@ -0,0 +1,13 @@ +cmake_minimum_required(VERSION 3.13) + +project(conversation) + +add_basic_example(adduserdata) +add_basic_example(getjsonobject) +add_basic_example(getlastresponse) +add_basic_example(getrawconversation) +add_basic_example(poplastresponse) +add_basic_example(popsystemdata) +add_basic_example(popuserdata) +add_basic_example(setsystemdata) +add_basic_example(update) diff --git a/packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/adduserdata.cpp b/packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/adduserdata.cpp new file mode 100644 index 00000000..7d68cbba --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/adduserdata.cpp @@ -0,0 +1,15 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + + // create a conversation + Conversation convo; + + // add user data - such as a question + convo.AddUserData("What is the meaning of life?"); + + // ... +} diff --git a/packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/getjsonobject.cpp b/packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/getjsonobject.cpp new file mode 100644 index 00000000..aa880a79 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/getjsonobject.cpp @@ -0,0 +1,30 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + + // create a conversation + Conversation convo; + + // add a message to the conversation + convo.AddUserData("Hello, how are you? What time is it for you?"); + + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response response = oai.ChatCompletion->create( + "gpt-3.5-turbo", convo + ); + + // update the conversation with the response + convo.Update(response); + + // get the internal conversation JSON object + nlohmann::json json = convo.GetJSON(); + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/getlastresponse.cpp b/packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/getlastresponse.cpp new file mode 100644 index 00000000..dc636304 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/getlastresponse.cpp @@ -0,0 +1,30 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + + // create a conversation + Conversation convo; + + // add a message to the conversation + convo.AddUserData("Hello, how are you? What time is it for you?"); + + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response response = oai.ChatCompletion->create( + "gpt-3.5-turbo", convo + ); + + // update the conversation with the response + convo.Update(response); + + // print the conversation + std::cout << convo.GetLastResponse() << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/getrawconversation.cpp b/packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/getrawconversation.cpp new file mode 100644 index 00000000..551afa93 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/getrawconversation.cpp @@ -0,0 +1,30 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + + // create a conversation + Conversation convo; + + // add a message to the conversation + convo.AddUserData("Hello, how are you? What time is it for you?"); + + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response response = oai.ChatCompletion->create( + "gpt-3.5-turbo", convo + ); + + // update the conversation with the response + convo.Update(response); + + // print the raw JSON conversation string + std::cout << convo.GetRawConversation() << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/poplastresponse.cpp b/packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/poplastresponse.cpp new file mode 100644 index 00000000..f2776b76 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/poplastresponse.cpp @@ -0,0 +1,33 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + + // create a conversation + Conversation convo; + + // add a message to the conversation + convo.AddUserData("Hello, how are you? What time is it for you?"); + + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response response = oai.ChatCompletion->create( + "gpt-3.5-turbo", convo + ); + + // update the conversation with the response + convo.Update(response); + + // print the conversation + std::cout << convo.GetLastResponse() << std::endl; + + // pop (remove) the last response from the conversation + convo.PopLastResponse(); + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/popsystemdata.cpp b/packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/popsystemdata.cpp new file mode 100644 index 00000000..09175d66 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/popsystemdata.cpp @@ -0,0 +1,21 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + + // create a conversation + Conversation convo; + + // set system message to guide the chat model + convo.SetSystemData("You are helpful bot."); + + // remove the set system message + convo.PopSystemData(); + + // add a different system message + convo.SetSystemData("You are a helpful bot that enjoys business."); + + // ... +} diff --git a/packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/popuserdata.cpp b/packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/popuserdata.cpp new file mode 100644 index 00000000..956ea776 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/popuserdata.cpp @@ -0,0 +1,21 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + + // create a conversation + Conversation convo; + + // add user data - such as a question + convo.AddUserData("What is the meaning of life?"); + + // pop (remove) the above added user data + convo.PopUserData(); + + // add different user data + convo.AddUserData("What is the size of the universe?"); + + // ... +} diff --git a/packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/setsystemdata.cpp b/packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/setsystemdata.cpp new file mode 100644 index 00000000..ee373ef5 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/setsystemdata.cpp @@ -0,0 +1,15 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + + // create a conversation + Conversation convo; + + // set system message to guide the chat model + convo.SetSystemData("You are helpful bot."); + + // ... +} diff --git a/packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/update.cpp b/packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/update.cpp new file mode 100644 index 00000000..dc636304 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/chat/conversation/examples/update.cpp @@ -0,0 +1,30 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + + // create a conversation + Conversation convo; + + // add a message to the conversation + convo.AddUserData("Hello, how are you? What time is it for you?"); + + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response response = oai.ChatCompletion->create( + "gpt-3.5-turbo", convo + ); + + // update the conversation with the response + convo.Update(response); + + // print the conversation + std::cout << convo.GetLastResponse() << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/chat/examples/CMakeLists.txt b/packages/media/cpp/packages/liboai/documentation/chat/examples/CMakeLists.txt new file mode 100644 index 00000000..20c5572e --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/chat/examples/CMakeLists.txt @@ -0,0 +1,7 @@ +cmake_minimum_required(VERSION 3.13) + +project(chat) + +add_basic_example(create_chat_completion) +add_basic_example(create_chat_completion_async) +add_basic_example(ongoing_user_convo) diff --git a/packages/media/cpp/packages/liboai/documentation/chat/examples/create_chat_completion.cpp b/packages/media/cpp/packages/liboai/documentation/chat/examples/create_chat_completion.cpp new file mode 100644 index 00000000..31c4c859 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/chat/examples/create_chat_completion.cpp @@ -0,0 +1,30 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + + // create a conversation + Conversation convo; + + // add a message to the conversation + convo.AddUserData("What is the point of taxes?"); + + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response response = oai.ChatCompletion->create( + "gpt-3.5-turbo", convo + ); + + // update our conversation with the response + convo.Update(response); + + // print the response + std::cout << convo.GetLastResponse() << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/chat/examples/create_chat_completion_async.cpp b/packages/media/cpp/packages/liboai/documentation/chat/examples/create_chat_completion_async.cpp new file mode 100644 index 00000000..43ca1fa7 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/chat/examples/create_chat_completion_async.cpp @@ -0,0 +1,38 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + + // create a conversation + Conversation convo; + + // add a message to the conversation + convo.AddUserData("What is the point of taxes?"); + + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + auto fut = oai.ChatCompletion->create_async( + "gpt-3.5-turbo", convo + ); + + // do other work... + + // check if the future is ready + fut.wait(); + + // get the contained response + auto response = fut.get(); + + // update our conversation with the response + convo.Update(response); + + // print the response + std::cout << convo.GetLastResponse() << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/chat/examples/ongoing_user_convo.cpp b/packages/media/cpp/packages/liboai/documentation/chat/examples/ongoing_user_convo.cpp new file mode 100644 index 00000000..7ee2d35c --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/chat/examples/ongoing_user_convo.cpp @@ -0,0 +1,39 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + + // create a conversation + Conversation convo; + + // holds next user input + std::string input; + + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + while (true) { + // get next user input + std::cout << "You: "; std::getline(std::cin, input); + + // add user input to conversation + convo.AddUserData(input); + + // get response from OpenAI + Response response = oai.ChatCompletion->create( + "gpt-3.5-turbo", convo + ); + + // update our conversation with the response + convo.Update(response); + + // print the response + std::cout << "Bot: " << convo.GetLastResponse() << std::endl; + } + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/completions/README.md b/packages/media/cpp/packages/liboai/documentation/completions/README.md new file mode 100644 index 00000000..45c18c65 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/completions/README.md @@ -0,0 +1,63 @@ +<h1>Completions</h1> +<p>The <code>Completions</code> class is defined in <code>completions.h</code> at <code>liboai::Completions</code>, and its interface can ideally be accessed through a <code>liboai::OpenAI</code> object. + +This class and its associated <code>liboai::OpenAI</code> interface allow access to the <a href="https://beta.openai.com/docs/api-reference/completions">Completions</a> endpoint of the OpenAI API; this endpoint's functionality can be found below.</p> +- Given a prompt, the model will return one or more predicted completions, and can also return the probabilities of alternative tokens at each position. + +<br> +<h2>Methods</h2> +<p>This document covers the method(s) located in <code>completions.h</code>. You can find their function signature(s) below.</p> + +<h3>Create a Completion</h3> +<p>Creates a completion for the provided prompt and parameters. Returns a <code>liboai::Response</code> containing response data.</p> + +```cpp +liboai::Response create( + const std::string& model_id, + std::optional<std::string> prompt = std::nullopt, + std::optional<std::string> suffix = std::nullopt, + std::optional<uint16_t> max_tokens = std::nullopt, + std::optional<float> temperature = std::nullopt, + std::optional<float> top_p = std::nullopt, + std::optional<uint16_t> n = std::nullopt, + std::optional<std::function<bool(std::string, intptr_t)>> stream = std::nullopt, + std::optional<uint8_t> logprobs = std::nullopt, + std::optional<bool> echo = std::nullopt, + std::optional<std::vector<std::string>> stop = std::nullopt, + std::optional<float> presence_penalty = std::nullopt, + std::optional<float> frequency_penalty = std::nullopt, + std::optional<uint16_t> best_of = std::nullopt, + std::optional<std::unordered_map<std::string, int8_t>> logit_bias = std::nullopt, + std::optional<std::string> user = std::nullopt +) const & noexcept(false); +``` + +<h3>Create a Completion (async)</h3> +<p>Asynchronously creates a completion for the provided prompt and parameters. Returns a <code>liboai::FutureResponse</code> containing future response data.</p> + +```cpp +liboai::FutureResponse create_async( + const std::string& model_id, + std::optional<std::string> prompt = std::nullopt, + std::optional<std::string> suffix = std::nullopt, + std::optional<uint16_t> max_tokens = std::nullopt, + std::optional<float> temperature = std::nullopt, + std::optional<float> top_p = std::nullopt, + std::optional<uint16_t> n = std::nullopt, + std::optional<std::function<bool(std::string, intptr_t)>> stream = std::nullopt, + std::optional<uint8_t> logprobs = std::nullopt, + std::optional<bool> echo = std::nullopt, + std::optional<std::vector<std::string>> stop = std::nullopt, + std::optional<float> presence_penalty = std::nullopt, + std::optional<float> frequency_penalty = std::nullopt, + std::optional<uint16_t> best_of = std::nullopt, + std::optional<std::unordered_map<std::string, int8_t>> logit_bias = std::nullopt, + std::optional<std::string> user = std::nullopt +) const & noexcept(false); +``` + +<p>All function parameters marked <code>optional</code> are not required and are resolved on OpenAI's end if not supplied.</p> + +<br> +<h2>Example Usage</h2> +<p>For example usage of the above function(s), please refer to the <a href="./examples">examples</a> folder. diff --git a/packages/media/cpp/packages/liboai/documentation/completions/examples/CMakeLists.txt b/packages/media/cpp/packages/liboai/documentation/completions/examples/CMakeLists.txt new file mode 100644 index 00000000..fdb88b29 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/completions/examples/CMakeLists.txt @@ -0,0 +1,6 @@ +cmake_minimum_required(VERSION 3.13) + +project(completions) + +add_basic_example(generate_completion) +add_basic_example(generate_completion_async) diff --git a/packages/media/cpp/packages/liboai/documentation/completions/examples/generate_completion.cpp b/packages/media/cpp/packages/liboai/documentation/completions/examples/generate_completion.cpp new file mode 100644 index 00000000..c287dc72 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/completions/examples/generate_completion.cpp @@ -0,0 +1,21 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response response = oai.Completion->create( + "text-davinci-003", + "Say this is a test", + std::nullopt, + 7 + ); + std::cout << response["choices"][0]["text"].get<std::string>() << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/completions/examples/generate_completion_async.cpp b/packages/media/cpp/packages/liboai/documentation/completions/examples/generate_completion_async.cpp new file mode 100644 index 00000000..0e2b11ec --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/completions/examples/generate_completion_async.cpp @@ -0,0 +1,32 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + // call async method; returns a future + auto fut = oai.Completion->create_async( + "text-davinci-003", + "Say this is a test", + std::nullopt, + 7 + ); + + // do other work... + + // check if the future is ready + fut.wait(); + + // get the contained response + auto response = fut.get(); + + // print some response data + std::cout << response["choices"][0]["text"].get<std::string>() << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/edits/README.md b/packages/media/cpp/packages/liboai/documentation/edits/README.md new file mode 100644 index 00000000..ae26b457 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/edits/README.md @@ -0,0 +1,43 @@ +<h1>Edits</h1> +<p>The <code>Edits</code> class is defined in <code>edits.h</code> at <code>liboai::Edits</code>, and its interface can ideally be accessed through a <code>liboai::OpenAI</code> object. + +This class and its associated <code>liboai::OpenAI</code> interface allow access to the <a href="https://beta.openai.com/docs/api-reference/edits">Edits</a> endpoint of the OpenAI API; this endpoint's functionality can be found below.</p> +- Given a prompt and an instruction, the model will return an edited version of the prompt. + +<br> +<h2>Methods</h2> +<p>This document covers the method(s) located in <code>edits.h</code>. You can find their function signature(s) below.</p> + +<h3>Create an Edit</h3> +<p>Creates a new edit for the provided input, instruction, and parameters. Returns a <code>liboai::Response</code> containing response data.</p> + +```cpp +liboai::Response create( + const std::string& model_id, + std::optional<std::string> input = std::nullopt, + std::optional<std::string> instruction = std::nullopt, + std::optional<uint16_t> n = std::nullopt, + std::optional<float> temperature = std::nullopt, + std::optional<float> top_p = std::nullopt +) const & noexcept(false); +``` + +<h3>Create an Edit (async)</h3> +<p>Asynchronously creates a new edit for the provided input, instruction, and parameters. Returns a <code>liboai::FutureResponse</code> containing future response data.</p> + +```cpp +liboai::FutureResponse create_async( + const std::string& model_id, + std::optional<std::string> input = std::nullopt, + std::optional<std::string> instruction = std::nullopt, + std::optional<uint16_t> n = std::nullopt, + std::optional<float> temperature = std::nullopt, + std::optional<float> top_p = std::nullopt +) const & noexcept(false); +``` + +<p>All function parameters marked <code>optional</code> are not required and are resolved on OpenAI's end if not supplied.</p> + +<br> +<h2>Example Usage</h2> +<p>For example usage of the above function(s), please refer to the <a href="./examples">examples</a> folder. diff --git a/packages/media/cpp/packages/liboai/documentation/edits/examples/CMakeLists.txt b/packages/media/cpp/packages/liboai/documentation/edits/examples/CMakeLists.txt new file mode 100644 index 00000000..8c9a6c9f --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/edits/examples/CMakeLists.txt @@ -0,0 +1,6 @@ +cmake_minimum_required(VERSION 3.13) + +project(edits) + +add_basic_example(create_edit) +add_basic_example(create_edit_async) diff --git a/packages/media/cpp/packages/liboai/documentation/edits/examples/create_edit.cpp b/packages/media/cpp/packages/liboai/documentation/edits/examples/create_edit.cpp new file mode 100644 index 00000000..4fb47baf --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/edits/examples/create_edit.cpp @@ -0,0 +1,20 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response response = oai.Edit->create( + "text-davinci-edit-001", + "What day of the wek is it?", + "Fix the spelling mistakes" + ); + std::cout << response["choices"][0]["text"].get<std::string>() << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/edits/examples/create_edit_async.cpp b/packages/media/cpp/packages/liboai/documentation/edits/examples/create_edit_async.cpp new file mode 100644 index 00000000..631b4797 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/edits/examples/create_edit_async.cpp @@ -0,0 +1,31 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + // call async method; returns a future + auto fut = oai.Edit->create_async( + "text-davinci-edit-001", + "What day of the wek is it?", + "Fix the spelling mistakes" + ); + + // do other work... + + // check if the future is ready + fut.wait(); + + // get the contained response + auto response = fut.get(); + + // print some response data + std::cout << response["choices"][0]["text"].get<std::string>() << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/embeddings/README.md b/packages/media/cpp/packages/liboai/documentation/embeddings/README.md new file mode 100644 index 00000000..afa51694 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/embeddings/README.md @@ -0,0 +1,37 @@ +<h1>Embeddings</h1> +<p>The <code>Embeddings</code> class is defined in <code>embeddings.h</code> at <code>liboai::Embeddings</code>, and its interface can ideally be accessed through a <code>liboai::OpenAI</code> object. + +This class and its associated <code>liboai::OpenAI</code> interface allow access to the <a href="https://beta.openai.com/docs/api-reference/embeddings">Embeddings</a> endpoint of the OpenAI API; this endpoint's functionality can be found below.</p> +- Get a vector representation of a given input that can be easily consumed by machine learning models and algorithms. + +<br> +<h2>Methods</h2> +<p>This document covers the method(s) located in <code>embeddings.h</code>. You can find their function signature(s) below.</p> + +<h3>Create an Embedding</h3> +<p>Creates an embedding vector representing the input text. Returns a <code>liboai::Response</code> containing response data.</p> + +```cpp +liboai::Response create( + const std::string& model_id, + std::optional<std::string> input = std::nullopt, + std::optional<std::string> user = std::nullopt +) const & noexcept(false); +``` + +<h3>Create an Embedding (async)</h3> +<p>Asynchronously creates an embedding vector representing the input text. Returns a <code>liboai::FutureResponse</code> containing future response data.</p> + +```cpp +liboai::FutureResponse create_async( + const std::string& model_id, + std::optional<std::string> input = std::nullopt, + std::optional<std::string> user = std::nullopt +) const & noexcept(false); +``` + +<p>All function parameters marked <code>optional</code> are not required and are resolved on OpenAI's end if not supplied.</p> + +<br> +<h2>Example Usage</h2> +<p>For example usage of the above function(s), please refer to the <a href="./examples">examples</a> folder. diff --git a/packages/media/cpp/packages/liboai/documentation/embeddings/examples/CMakeLists.txt b/packages/media/cpp/packages/liboai/documentation/embeddings/examples/CMakeLists.txt new file mode 100644 index 00000000..1cac2481 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/embeddings/examples/CMakeLists.txt @@ -0,0 +1,6 @@ +cmake_minimum_required(VERSION 3.13) + +project(embeddings) + +add_basic_example(create_embedding) +add_basic_example(create_embedding_async) diff --git a/packages/media/cpp/packages/liboai/documentation/embeddings/examples/create_embedding.cpp b/packages/media/cpp/packages/liboai/documentation/embeddings/examples/create_embedding.cpp new file mode 100644 index 00000000..f1c65c50 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/embeddings/examples/create_embedding.cpp @@ -0,0 +1,19 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response response = oai.Embedding->create( + "text-embedding-ada-002", + "The food was delicious and the waiter..." + ); + std::cout << response["data"][0]["embedding"] << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/embeddings/examples/create_embedding_async.cpp b/packages/media/cpp/packages/liboai/documentation/embeddings/examples/create_embedding_async.cpp new file mode 100644 index 00000000..10c59e30 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/embeddings/examples/create_embedding_async.cpp @@ -0,0 +1,30 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + // call async method; returns a future + auto fut = oai.Embedding->create_async( + "text-embedding-ada-002", + "The food was delicious and the waiter..." + ); + + // do other work... + + // check if the future is ready + fut.wait(); + + // get the contained response + auto response = fut.get(); + + // print some response data + std::cout << response["data"][0]["embedding"] << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/files/README.md b/packages/media/cpp/packages/liboai/documentation/files/README.md new file mode 100644 index 00000000..a4810240 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/files/README.md @@ -0,0 +1,103 @@ +<h1>Files</h1> +<p>The <code>Files</code> class is defined in <code>files.h</code> at <code>liboai::Files</code>, and its interface can ideally be accessed through a <code>liboai::OpenAI</code> object. + +This class and its associated <code>liboai::OpenAI</code> interface allow access to the <a href="https://beta.openai.com/docs/api-reference/files">Files</a> endpoint of the OpenAI API; this endpoint's functionality can be found below.</p> +- Files are used to upload documents that can be used with features like Fine-tuning. + +<br> +<h2>Methods</h2> +<p>This document covers the method(s) located in <code>files.h</code>. You can find their function signature(s) below.</p> + +<h3>List Files</h3> +<p>Gets a list of files that belong to the user's organization. Returns a <code>liboai::Response</code> containing response data.</p> + +```cpp +liboai::Response list() const & noexcept(false); +``` + +<h3>List Files (async)</h3> +<p>Asynchronously gets a list of files that belong to the user's organization. Returns a <code>liboai::FutureResponse</code> containing future response data.</p> + +```cpp +liboai::FutureResponse list_async() const & noexcept(false); +``` + +<h3>Upload File</h3> +<p>Upload a file that contains document(s) to be used across various endpoints/features. Currently, the size of all the files uploaded by one organization can be up to 1 GB. Returns a <code>liboai::Response</code> containing response data.</p> + +```cpp +liboai::Response create( + const std::filesystem::path& file, + const std::string& purpose +) const & noexcept(false); +``` + +<h3>Upload File (async)</h3> +<p>Asynchronously upload a file that contains document(s) to be used across various endpoints/features. Currently, the size of all the files uploaded by one organization can be up to 1 GB. Returns a <code>liboai::FutureResponse</code> containing future response data.</p> + +```cpp +liboai::FutureResponse create_async( + const std::filesystem::path& file, + const std::string& purpose +) const & noexcept(false); +``` + +<h3>Delete a File</h3> +<p>Deletes a file. Returns a <code>liboai::Response</code> containing response data.</p> + +```cpp +liboai::Response remove( + const std::string& file_id +) const & noexcept(false); +``` + +<h3>Delete a File (async)</h3> +<p>Asynchronously deletes a file. Returns a <code>liboai::FutureResponse</code> containing future response data.</p> + +```cpp +liboai::FutureResponse remove_async( + const std::string& file_id +) const & noexcept(false); +``` + +<h3>Retrieve File</h3> +<p>Returns information about a specific file. Returns a <code>liboai::Response</code> containing response data.</p> + +```cpp +liboai::Response retrieve( + const std::string& file_id +) const & noexcept(false); +``` + +<h3>Retrieve File (async)</h3> +<p>Asynchronously returns information about a specific file. Returns a <code>liboai::FutureResponse</code> containing future response data.</p> + +```cpp +liboai::FutureResponse retrieve_async( + const std::string& file_id +) const & noexcept(false); +``` + +<h3>Retrieve File Content (Download)</h3> +<p>Returns the contents of the specified file and downloads it to the provided path. Returns a <code>bool</code> indicating failure or success.</p> + +```cpp +bool download( + const std::string& file_id, + const std::string& save_to +) const & noexcept(false); +``` + +<h3>Retrieve File Content (Download) (async)</h3> +<p>Asynchronously returns the contents of the specified file and downloads it to the provided path. Returns a future <code>bool</code> indicating failure or success.</p> + +```cpp +std::future<bool> download_async( + const std::string& file_id, + const std::string& save_to +) const & noexcept(false); +``` + +<br> +<h2>Example Usage</h2> +<p>For example usage of the above function(s), please refer to the <a href="./examples">examples</a> folder. diff --git a/packages/media/cpp/packages/liboai/documentation/files/examples/CMakeLists.txt b/packages/media/cpp/packages/liboai/documentation/files/examples/CMakeLists.txt new file mode 100644 index 00000000..527dc0f9 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/files/examples/CMakeLists.txt @@ -0,0 +1,14 @@ +cmake_minimum_required(VERSION 3.13) + +project(files) + +add_basic_example(delete_file) +add_basic_example(delete_file_async) +add_basic_example(download_uploaded_file) +add_basic_example(download_uploaded_file_async) +add_basic_example(list_files) +add_basic_example(list_files_async) +add_basic_example(retrieve_file) +add_basic_example(retrieve_file_async) +add_basic_example(upload_file) +add_basic_example(upload_file_async) diff --git a/packages/media/cpp/packages/liboai/documentation/files/examples/delete_file.cpp b/packages/media/cpp/packages/liboai/documentation/files/examples/delete_file.cpp new file mode 100644 index 00000000..f555b37f --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/files/examples/delete_file.cpp @@ -0,0 +1,18 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response response = oai.File->remove( + "file-XjGxS3KTG0uNmNOK362iJua3" + ); + std::cout << response["deleted"].get<bool>() << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/files/examples/delete_file_async.cpp b/packages/media/cpp/packages/liboai/documentation/files/examples/delete_file_async.cpp new file mode 100644 index 00000000..e488cec6 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/files/examples/delete_file_async.cpp @@ -0,0 +1,29 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + // call async method; returns a future + auto fut = oai.File->remove_async( + "file-XjGxS3KTG0uNmNOK362iJua3" + ); + + // do other work... + + // check if the future is ready + fut.wait(); + + // get the contained response + auto response = fut.get(); + + // print some response data + std::cout << response["deleted"].get<bool>() << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/files/examples/download_uploaded_file.cpp b/packages/media/cpp/packages/liboai/documentation/files/examples/download_uploaded_file.cpp new file mode 100644 index 00000000..ca2783ca --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/files/examples/download_uploaded_file.cpp @@ -0,0 +1,20 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + if (oai.File->download("file-XjGxS3KTG0uNmNOK362iJua3", "C:/some/folder/file.jsonl")) { + std::cout << "File downloaded successfully!" << std::endl; + } + else { + std::cout << "File download failed!" << std::endl; + } + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/files/examples/download_uploaded_file_async.cpp b/packages/media/cpp/packages/liboai/documentation/files/examples/download_uploaded_file_async.cpp new file mode 100644 index 00000000..e2492350 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/files/examples/download_uploaded_file_async.cpp @@ -0,0 +1,31 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + // call async method; returns a future + auto fut = oai.File->download_async( + "file-XjGxS3KTG0uNmNOK362iJua3", "C:/some/folder/file.jsonl" + ); + + // do other work... + + // check if the future is ready + fut.wait(); + + // check if downloaded successfully + if (fut.get()) { + std::cout << "File downloaded successfully!" << std::endl; + } + else { + std::cout << "File download failed!" << std::endl; + } + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/files/examples/list_files.cpp b/packages/media/cpp/packages/liboai/documentation/files/examples/list_files.cpp new file mode 100644 index 00000000..ceeae5ee --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/files/examples/list_files.cpp @@ -0,0 +1,16 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response response = oai.File->list(); + std::cout << response["data"] << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/files/examples/list_files_async.cpp b/packages/media/cpp/packages/liboai/documentation/files/examples/list_files_async.cpp new file mode 100644 index 00000000..411f7373 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/files/examples/list_files_async.cpp @@ -0,0 +1,27 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + // call async method; returns a future + auto fut = oai.File->list_async(); + + // do other work... + + // check if the future is ready + fut.wait(); + + // get the contained response + auto response = fut.get(); + + // print some response data + std::cout << response["data"] << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/files/examples/retrieve_file.cpp b/packages/media/cpp/packages/liboai/documentation/files/examples/retrieve_file.cpp new file mode 100644 index 00000000..5c385308 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/files/examples/retrieve_file.cpp @@ -0,0 +1,18 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response response = oai.File->retrieve( + "file-XjGxS3KTG0uNmNOK362iJua3" + ); + std::cout << response << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/files/examples/retrieve_file_async.cpp b/packages/media/cpp/packages/liboai/documentation/files/examples/retrieve_file_async.cpp new file mode 100644 index 00000000..5c6fb03e --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/files/examples/retrieve_file_async.cpp @@ -0,0 +1,29 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + // call async method; returns a future + auto fut = oai.File->retrieve_async( + "file-XjGxS3KTG0uNmNOK362iJua3" + ); + + // do other work... + + // check if the future is ready + fut.wait(); + + // get the contained response + auto response = fut.get(); + + // print some response data + std::cout << response << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/files/examples/upload_file.cpp b/packages/media/cpp/packages/liboai/documentation/files/examples/upload_file.cpp new file mode 100644 index 00000000..a09dfd8e --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/files/examples/upload_file.cpp @@ -0,0 +1,19 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response response = oai.File->create( + "C:/some/folder/file.jsonl", + "fine-tune" + ); + std::cout << response << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/files/examples/upload_file_async.cpp b/packages/media/cpp/packages/liboai/documentation/files/examples/upload_file_async.cpp new file mode 100644 index 00000000..09fdbe23 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/files/examples/upload_file_async.cpp @@ -0,0 +1,30 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + // call async method; returns a future + auto fut = oai.File->create_async( + "C:/some/folder/file.jsonl", + "fine-tune" + ); + + // do other work... + + // check if the future is ready + fut.wait(); + + // get the contained response + auto response = fut.get(); + + // print some response data + std::cout << response << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/fine-tunes/README.md b/packages/media/cpp/packages/liboai/documentation/fine-tunes/README.md new file mode 100644 index 00000000..73facf14 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/fine-tunes/README.md @@ -0,0 +1,144 @@ +<h1>Fine-Tunes</h1> +<p>The <code>FineTunes</code> class is defined in <code>fine_tunes.h</code> at <code>liboai::FineTunes</code>, and its interface can ideally be accessed through a <code>liboai::OpenAI</code> object. + +This class and its associated <code>liboai::OpenAI</code> interface allow access to the <a href="https://beta.openai.com/docs/api-reference/fine-tunes">Fine-tunes</a> endpoint of the OpenAI API; this endpoint's functionality can be found below.</p> +- Manage fine-tuning jobs to tailor a model to your specific training data. + +<br> +<h2>Methods</h2> +<p>This document covers the method(s) located in <code>fine_tunes.h</code>. You can find their function signature(s) below.</p> + +<h3>Create a Fine-Tune</h3> +<p>Creates a job that fine-tunes a specified model from a given dataset. Returns a <code>liboai::Response</code> containing response data.</p> + +```cpp +liboai::Response create( + const std::string& training_file, + std::optional<std::string> validation_file = std::nullopt, + std::optional<std::string> model_id = std::nullopt, + std::optional<uint8_t> n_epochs = std::nullopt, + std::optional<uint16_t> batch_size = std::nullopt, + std::optional<float> learning_rate_multiplier = std::nullopt, + std::optional<float> prompt_loss_weight = std::nullopt, + std::optional<bool> compute_classification_metrics = std::nullopt, + std::optional<uint16_t> classification_n_classes = std::nullopt, + std::optional<std::string> classification_positive_class = std::nullopt, + std::optional<std::vector<float>> classification_betas = std::nullopt, + std::optional<std::string> suffix = std::nullopt +) const & noexcept(false); +``` + +<h3>Create a Fine-Tune (async)</h3> +<p>Asynchronously creates a job that fine-tunes a specified model from a given dataset. Returns a <code>liboai::FutureResponse</code> containing future response data.</p> + +```cpp +liboai::FutureResponse create_async( + const std::string& training_file, + std::optional<std::string> validation_file = std::nullopt, + std::optional<std::string> model_id = std::nullopt, + std::optional<uint8_t> n_epochs = std::nullopt, + std::optional<uint16_t> batch_size = std::nullopt, + std::optional<float> learning_rate_multiplier = std::nullopt, + std::optional<float> prompt_loss_weight = std::nullopt, + std::optional<bool> compute_classification_metrics = std::nullopt, + std::optional<uint16_t> classification_n_classes = std::nullopt, + std::optional<std::string> classification_positive_class = std::nullopt, + std::optional<std::vector<float>> classification_betas = std::nullopt, + std::optional<std::string> suffix = std::nullopt +) const & noexcept(false); +``` + +<h3>List Fine-Tunes</h3> +<p>List your organization's fine-tuning jobs. Returns a <code>liboai::Response</code> containing response data.</p> + +```cpp +liboai::Response list() const & noexcept(false); +``` + + +<h3>List Fine-Tunes (async)</h3> +<p>Asynchronously list your organization's fine-tuning jobs. Returns a <code>liboai::FutureResponse</code> containing future response data.</p> + +```cpp +liboai::FutureResponse list_async() const & noexcept(false); +``` + +<h3>Retrieve Fine-Tune</h3> +<p>Gets info about the fine-tune job. Returns a <code>liboai::Response</code> containing response data.</p> + +```cpp +liboai::Response retrieve( + const std::string& fine_tune_id +) const & noexcept(false); +``` + +<h3>Retrieve Fine-Tune (async)</h3> +<p>Asynchronously gets info about the fine-tune job. Returns a <code>liboai::FutureResponse</code> containing future response data.</p> + +```cpp +liboai::FutureResponse retrieve_async( + const std::string& fine_tune_id +) const & noexcept(false); +``` + +<h3>Cancel Fine-Tune</h3> +<p>Immediately cancel a fine-tune job. Returns a <code>liboai::Response</code> containing response data.</p> + +```cpp +liboai::Response cancel( + const std::string& fine_tune_id +) const & noexcept(false); +``` + +<h3>Cancel Fine-Tune (async)</h3> +<p>Asynchronously and immediately cancel a fine-tune job. Returns a <code>liboai::FutureResponse</code> containing future response data.</p> + +```cpp +liboai::FutureResponse cancel_async( + const std::string& fine_tune_id +) const & noexcept(false); +``` + +<h3>List Fine-Tune Events</h3> +<p>Get fine-grained status updates for a fine-tune job. Returns a <code>liboai::Response</code> containing response data.</p> + +```cpp +liboai::Response list_events( + const std::string& fine_tune_id, + std::optional<std::function<bool(std::string, intptr_t)>> stream = std::nullopt +) const & noexcept(false); +``` + +<h3>List Fine-Tune Events (async)</h3> +<p>Asynchronously get fine-grained status updates for a fine-tune job. Returns a <code>liboai::FutureResponse</code> containing future response data.</p> + +```cpp +liboai::FutureResponse list_events_async( + const std::string& fine_tune_id, + std::optional<std::function<bool(std::string, intptr_t)>> stream = std::nullopt +) const & noexcept(false); +``` + +<h3>Delete Fine-Tune Model</h3> +<p>Delete a fine-tuned model. You must have the Owner role in your organization. Returns a <code>liboai::Response</code> containing response data.</p> + +```cpp +liboai::Response remove( + const std::string& model +) const & noexcept(false); +``` + +<h3>Delete Fine-Tune Model (async)</h3> +<p>Asynchronously delete a fine-tuned model. You must have the Owner role in your organization. Returns a <code>liboai::FutureResponse</code> containing future response data.</p> + +```cpp +liboai::FutureResponse remove_async( + const std::string& model +) const & noexcept(false); +``` + +<p>All function parameters marked <code>optional</code> are not required and are resolved on OpenAI's end if not supplied.</p> + +<br> +<h2>Example Usage</h2> +<p>For example usage of the above function(s), please refer to the <a href="./examples">examples</a> folder. diff --git a/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/CMakeLists.txt b/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/CMakeLists.txt new file mode 100644 index 00000000..96044028 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/CMakeLists.txt @@ -0,0 +1,16 @@ +cmake_minimum_required(VERSION 3.13) + +project(fine-tunes) + +add_basic_example(cancel_fine_tune) +add_basic_example(cancel_fine_tune_async) +add_basic_example(create_fine_tune) +add_basic_example(create_fine_tune_async) +add_basic_example(delete_fine_tune_model) +add_basic_example(delete_fine_tune_model_async) +add_basic_example(list_fine_tune_events) +add_basic_example(list_fine_tune_events_async) +add_basic_example(list_fine_tunes) +add_basic_example(list_fine_tunes_async) +add_basic_example(retrieve_fine_tune) +add_basic_example(retrieve_fine_tune_async) diff --git a/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/cancel_fine_tune.cpp b/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/cancel_fine_tune.cpp new file mode 100644 index 00000000..aa49aa24 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/cancel_fine_tune.cpp @@ -0,0 +1,18 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response response = oai.FineTune->cancel( + "ft-AF1WoRqd3aJAHsqc9NY7iL8F" + ); + std::cout << response["status"].get<std::string>() << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/cancel_fine_tune_async.cpp b/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/cancel_fine_tune_async.cpp new file mode 100644 index 00000000..53c0d0f7 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/cancel_fine_tune_async.cpp @@ -0,0 +1,29 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + // call async method; returns a future + auto fut = oai.FineTune->cancel_async( + "ft-AF1WoRqd3aJAHsqc9NY7iL8F" + ); + + // do other work... + + // check if the future is ready + fut.wait(); + + // get the contained response + auto response = fut.get(); + + // print some response data + std::cout << response["status"].get<std::string>() << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/create_fine_tune.cpp b/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/create_fine_tune.cpp new file mode 100644 index 00000000..9b5b4022 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/create_fine_tune.cpp @@ -0,0 +1,18 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response response = oai.FineTune->create( + "file-XGinujblHPwGLSztz8cPS8XY" + ); + std::cout << response["events"][0]["message"] << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/create_fine_tune_async.cpp b/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/create_fine_tune_async.cpp new file mode 100644 index 00000000..70c23c5d --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/create_fine_tune_async.cpp @@ -0,0 +1,29 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + // call async method; returns a future + auto fut = oai.FineTune->create_async( + "file-XGinujblHPwGLSztz8cPS8XY" + ); + + // do other work... + + // check if the future is ready + fut.wait(); + + // get the contained response + auto response = fut.get(); + + // print some response data + std::cout << response["events"][0]["message"] << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/delete_fine_tune_model.cpp b/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/delete_fine_tune_model.cpp new file mode 100644 index 00000000..a7ba4e79 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/delete_fine_tune_model.cpp @@ -0,0 +1,18 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response response = oai.FineTune->remove( + "curie:ft-acmeco-2021-03-03-21-44-20" + ); + std::cout << response["deleted"].get<bool>() << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/delete_fine_tune_model_async.cpp b/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/delete_fine_tune_model_async.cpp new file mode 100644 index 00000000..40fad9fd --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/delete_fine_tune_model_async.cpp @@ -0,0 +1,29 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + // call async method; returns a future + auto fut = oai.FineTune->remove_async( + "curie:ft-acmeco-2021-03-03-21-44-20" + ); + + // do other work... + + // check if the future is ready + fut.wait(); + + // get the contained response + auto response = fut.get(); + + // print some response data + std::cout << response["deleted"].get<bool>() << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/list_fine_tune_events.cpp b/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/list_fine_tune_events.cpp new file mode 100644 index 00000000..53b42659 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/list_fine_tune_events.cpp @@ -0,0 +1,18 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response response = oai.FineTune->list_events( + "ft-AF1WoRqd3aJAHsqc9NY7iL8F" + ); + std::cout << response["data"] << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/list_fine_tune_events_async.cpp b/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/list_fine_tune_events_async.cpp new file mode 100644 index 00000000..3df5c92c --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/list_fine_tune_events_async.cpp @@ -0,0 +1,29 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + // call async method; returns a future + auto fut = oai.FineTune->list_events_async( + "ft-AF1WoRqd3aJAHsqc9NY7iL8F" + ); + + // do other work... + + // check if the future is ready + fut.wait(); + + // get the contained response + auto response = fut.get(); + + // print some response data + std::cout << response["data"] << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/list_fine_tunes.cpp b/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/list_fine_tunes.cpp new file mode 100644 index 00000000..3c1e9d8d --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/list_fine_tunes.cpp @@ -0,0 +1,16 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response response = oai.FineTune->list(); + std::cout << response["data"] << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/list_fine_tunes_async.cpp b/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/list_fine_tunes_async.cpp new file mode 100644 index 00000000..ba21e1fd --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/list_fine_tunes_async.cpp @@ -0,0 +1,27 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + // call async method; returns a future + auto fut = oai.FineTune->list_async(); + + // do other work... + + // check if the future is ready + fut.wait(); + + // get the contained response + auto response = fut.get(); + + // print some response data + std::cout << response["data"] << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/retrieve_fine_tune.cpp b/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/retrieve_fine_tune.cpp new file mode 100644 index 00000000..6ada2a40 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/retrieve_fine_tune.cpp @@ -0,0 +1,18 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response response = oai.FineTune->retrieve( + "ft-AF1WoRqd3aJAHsqc9NY7iL8F" + ); + std::cout << response << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/retrieve_fine_tune_async.cpp b/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/retrieve_fine_tune_async.cpp new file mode 100644 index 00000000..05914600 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/fine-tunes/examples/retrieve_fine_tune_async.cpp @@ -0,0 +1,29 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + // call async method; returns a future + auto fut = oai.FineTune->retrieve_async( + "ft-AF1WoRqd3aJAHsqc9NY7iL8F" + ); + + // do other work... + + // check if the future is ready + fut.wait(); + + // get the contained response + auto response = fut.get(); + + // print some response data + std::cout << response << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/images/README.md b/packages/media/cpp/packages/liboai/documentation/images/README.md new file mode 100644 index 00000000..7e98066e --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/images/README.md @@ -0,0 +1,97 @@ +<h1>Images</h1> +<p>The <code>Images</code> class is defined in <code>images.h</code> at <code>liboai::Images</code>, and its interface can ideally be accessed through a <code>liboai::OpenAI</code> object. + +This class and its associated <code>liboai::OpenAI</code> interface allow access to the <a href="https://beta.openai.com/docs/api-reference/images">Images</a> endpoint of the OpenAI API; this endpoint's functionality can be found below.</p> +- Given a prompt and/or an input image, the model will generate a new image. + +<br> +<h2>Methods</h2> +<p>This document covers the method(s) located in <code>images.h</code>. You can find their function signature(s) below.</p> + +<h3>Create an Image</h3> +<p>Creates an image given a prompt. Returns a <code>liboai::Response</code> containing response data.</p> + +```cpp +liboai::Response create( + const std::string& prompt, + std::optional<uint8_t> n = std::nullopt, + std::optional<std::string> size = std::nullopt, + std::optional<std::string> response_format = std::nullopt, + std::optional<std::string> user = std::nullopt +) const & noexcept(false); +``` + +<h3>Create an Image (async)</h3> +<p>Asynchronously creates an image given a prompt. Returns a <code>liboai::FutureResponse</code> containing future response data.</p> + +```cpp +liboai::FutureResponse create_async( + const std::string& prompt, + std::optional<uint8_t> n = std::nullopt, + std::optional<std::string> size = std::nullopt, + std::optional<std::string> response_format = std::nullopt, + std::optional<std::string> user = std::nullopt +) const & noexcept(false); +``` + +<h3>Create Image Edit</h3> +<p>Creates an edited or extended image given an original image and a prompt. Returns a <code>liboai::Response</code> containing response data.</p> + +```cpp +liboai::Response create_edit( + const std::filesystem::path& image, + const std::string& prompt, + std::optional<std::filesystem::path> mask = std::nullopt, + std::optional<uint8_t> n = std::nullopt, + std::optional<std::string> size = std::nullopt, + std::optional<std::string> response_format = std::nullopt, + std::optional<std::string> user = std::nullopt +) const & noexcept(false); +``` + +<h3>Create Image Edit (async)</h3> +<p>Asynchronously creates an edited or extended image given an original image and a prompt. Returns a <code>liboai::FutureResponse</code> containing future response data.</p> + +```cpp +liboai::FutureResponse create_edit_async( + const std::filesystem::path& image, + const std::string& prompt, + std::optional<std::filesystem::path> mask = std::nullopt, + std::optional<uint8_t> n = std::nullopt, + std::optional<std::string> size = std::nullopt, + std::optional<std::string> response_format = std::nullopt, + std::optional<std::string> user = std::nullopt +) const & noexcept(false); +``` + +<h3>Create Image Variation</h3> +<p>Creates a variation of a given image. Returns a <code>liboai::Response</code> containing response data.</p> + +```cpp +liboai::Response create_variation( + const std::filesystem::path& image, + std::optional<uint8_t> n = std::nullopt, + std::optional<std::string> size = std::nullopt, + std::optional<std::string> response_format = std::nullopt, + std::optional<std::string> user = std::nullopt +) const & noexcept(false); +``` + +<h3>Create Image Variation (async)</h3> +<p>Asynchronously creates a variation of a given image. Returns a <code>liboai::FutureResponse</code> containing future response data.</p> + +```cpp +liboai::FutureResponse create_variation_async( + const std::filesystem::path& image, + std::optional<uint8_t> n = std::nullopt, + std::optional<std::string> size = std::nullopt, + std::optional<std::string> response_format = std::nullopt, + std::optional<std::string> user = std::nullopt +) const & noexcept(false); +``` + +<p>All function parameters marked <code>optional</code> are not required and are resolved on OpenAI's end if not supplied.</p> + +<br> +<h2>Example Usage</h2> +<p>For example usage of the above function(s), please refer to the <a href="./examples">examples</a> folder. diff --git a/packages/media/cpp/packages/liboai/documentation/images/examples/CMakeLists.txt b/packages/media/cpp/packages/liboai/documentation/images/examples/CMakeLists.txt new file mode 100644 index 00000000..776e0dad --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/images/examples/CMakeLists.txt @@ -0,0 +1,12 @@ +cmake_minimum_required(VERSION 3.13) + +project(images) + +# compilation error +add_basic_example(download_generated_image) +add_basic_example(generate_edit) +add_basic_example(generate_edit_async) +add_basic_example(generate_image) +add_basic_example(generate_image_async) +add_basic_example(generate_variation) +add_basic_example(generate_variation_async) diff --git a/packages/media/cpp/packages/liboai/documentation/images/examples/download_generated_image.cpp b/packages/media/cpp/packages/liboai/documentation/images/examples/download_generated_image.cpp new file mode 100644 index 00000000..680f29a3 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/images/examples/download_generated_image.cpp @@ -0,0 +1,22 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response response = oai.Image->create( + "a siamese cat!" + ); + Network::Download( + "C:/some/folder/file.png", // to + response["data"][0]["url"].get<std::string>(), // from + netimpl::components::Header() + ); + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/images/examples/generate_edit.cpp b/packages/media/cpp/packages/liboai/documentation/images/examples/generate_edit.cpp new file mode 100644 index 00000000..b13bfa61 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/images/examples/generate_edit.cpp @@ -0,0 +1,21 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response response = oai.Image->create_edit( + "C:/some/folder/otter.png", + "A cute baby sea otter wearing a beret", + "C:/some/folder/mask.png" + ); + + std::cout << response["data"][0]["url"].get<std::string>() << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/images/examples/generate_edit_async.cpp b/packages/media/cpp/packages/liboai/documentation/images/examples/generate_edit_async.cpp new file mode 100644 index 00000000..69d512cc --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/images/examples/generate_edit_async.cpp @@ -0,0 +1,31 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + // call async method; returns a future + auto fut = oai.Image->create_edit_async( + "C:/some/folder/otter.png", + "A cute baby sea otter wearing a beret", + "C:/some/folder/mask.png" + ); + + // do other work... + + // check if the future is ready + fut.wait(); + + // get the contained response + auto response = fut.get(); + + // print some response data + std::cout << response["data"][0]["url"].get<std::string>() << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/images/examples/generate_image.cpp b/packages/media/cpp/packages/liboai/documentation/images/examples/generate_image.cpp new file mode 100644 index 00000000..afb3cef9 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/images/examples/generate_image.cpp @@ -0,0 +1,18 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response response = oai.Image->create( + "a siamese cat!" + ); + std::cout << response["data"][0]["url"].get<std::string>() << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/images/examples/generate_image_async.cpp b/packages/media/cpp/packages/liboai/documentation/images/examples/generate_image_async.cpp new file mode 100644 index 00000000..499a2bbb --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/images/examples/generate_image_async.cpp @@ -0,0 +1,29 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + // call async method; returns a future + auto fut = oai.Image->create_async( + "a siamese cat!" + ); + + // do other work... + + // check if the future is ready + fut.wait(); + + // get the contained response + auto response = fut.get(); + + // print some response data + std::cout << response["data"][0]["url"].get<std::string>() << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/images/examples/generate_variation.cpp b/packages/media/cpp/packages/liboai/documentation/images/examples/generate_variation.cpp new file mode 100644 index 00000000..b045df30 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/images/examples/generate_variation.cpp @@ -0,0 +1,19 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response response = oai.Image->create_variation( + "C:/some/folder/otter.png" + ); + + std::cout << response["data"][0]["url"].get<std::string>() << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/images/examples/generate_variation_async.cpp b/packages/media/cpp/packages/liboai/documentation/images/examples/generate_variation_async.cpp new file mode 100644 index 00000000..4f875fec --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/images/examples/generate_variation_async.cpp @@ -0,0 +1,29 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + // call async method; returns a future + auto fut = oai.Image->create_variation_async( + "C:/some/folder/otter.png" + ); + + // do other work... + + // check if the future is ready + fut.wait(); + + // get the contained response + auto response = fut.get(); + + // print some response data + std::cout << response["data"][0]["url"].get<std::string>() << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/installation/README.md b/packages/media/cpp/packages/liboai/documentation/installation/README.md new file mode 100644 index 00000000..5501fb57 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/installation/README.md @@ -0,0 +1,47 @@ +<h1>Making Use of <code>liboai</code></h1> +<p>In order to integrate the power of artificial intelligence and <code>liboai</code> into your codebase, you have a couple of options.</p> + +<h3>Integrate via source code</h3> +<p>As <code>liboai</code> implements a cURL wrapper internally and uses a pure C++ JSON solution, <code>liboai</code>'s header and implementation files can be added to an existing C++17 project and compiled alongside it. However, in order to do so, the project must have the following elements:</p> + +* cURL available and <b>linked</b> to the project. +* nlohmann-json available. +* Compiling to C++17. + +<p>Assuming your existing codebase has the above in mind, you can safely add <code>liboai</code>'s header and implementation files to your existing project and compile.</p> + +<p>It's as easy as that!</p> + +<h3>Integrate via a static/dynamic library</h3> +<p>Another means of integrating <code>liboai</code> into an existing C++17 project is as a static or dynamic library. This is slightly more complicated than simply including the source code of the library into your existing project, but can certainly be done in few steps.</p> + +<p>Static and dynamic libraries take many forms:</p> + +* <b>Windows</b> + * Dynamic-Link Library (.dll) + * Static Library (.lib) +* <b>Linux</b> + * Shared Object (.so) + * Static Library (.a) +* <b>MacOS</b> + * Dynamic Library (.dylib) + * Static Library (.a) + +<p>However, their underlying concepts remain the same.</p> + +<h3>Turning <code>liboai</code> into a library</h3> +<p>The process of compiling <code>liboai</code> into a static or dynamic library is not as hard as it may seem. Simply, using your IDE of choice, perform the following: + + 1. Ensure cURL and nlohmann-json are installed. + 2. Create a new C++ project. + 3. Import the <code>liboai</code> source code (.cpp and .h files). + 4. *Link your project to the cURL library. + 5. *Make sure you are targeting C++17. + 6. *Compile as a static or dynamic library. + +<p>Now, in the project you'd like to integrate <code>liboai</code> into: + + 1. Include the <code>liboai</code> header files (.h files). + 2. *Link to the output static or dynamic library you compiled in the above steps. + +*NOTE: how you do these steps depends on your choice of development environment. They can either be done in an IDE or a compiler on the command line. diff --git a/packages/media/cpp/packages/liboai/documentation/maintenance/README.md b/packages/media/cpp/packages/liboai/documentation/maintenance/README.md new file mode 100644 index 00000000..c97edff1 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/maintenance/README.md @@ -0,0 +1,40 @@ +<h1>Maintainer Notes</h1> +<p>This doc summarizes where PRs live now and how to review them efficiently.</p> + +<h3>Repositories and PR numbering</h3> +<ul> + <li>Canonical repo (accept PRs here): <a href="https://github.com/jasonduncan/liboai">jasonduncan/liboai</a>.</li> + <li>Upstream repo (archived): <a href="https://github.com/D7EAD/liboai">D7EAD/liboai</a>.</li> + <li>PR numbers are per-repository, so PR <code>#1</code> in our repo is unrelated to PR <code>#1</code> upstream.</li> +</ul> + +<h3>Common gh commands</h3> +<pre><code>gh pr list --repo jasonduncan/liboai + +gh pr view 1 --repo jasonduncan/liboai + +gh pr diff 1 --repo jasonduncan/liboai + +gh pr checkout 1 --repo jasonduncan/liboai +</code></pre> + +<p>Upstream PRs are read-only history but can be useful for reference:</p> +<pre><code>gh pr list --repo D7EAD/liboai +</code></pre> + +<h3>Remotes</h3> +<pre><code>git remote -v +</code></pre> +<p>Expected remotes:</p> +<ul> + <li><code>origin</code>: jasonduncan/liboai</li> + <li><code>upstream</code>: D7EAD/liboai</li> +</ul> + +<h3>Review checklist (build / CMake changes)</h3> +<ul> + <li>Verify minimum CMake version compatibility.</li> + <li>Confirm dependency targets exist for both <code>find_package</code> and vendored targets.</li> + <li>Test top-level build + install, and <code>add_subdirectory</code> usage in a parent project.</li> + <li>Ensure new options are documented and defaults preserve existing behavior.</li> +</ul> diff --git a/packages/media/cpp/packages/liboai/documentation/models/README.md b/packages/media/cpp/packages/liboai/documentation/models/README.md new file mode 100644 index 00000000..fd5d0f1e --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/models/README.md @@ -0,0 +1,45 @@ +<h1>Models</h1> +<p>The <code>Models</code> class is defined in <code>models.h</code> at <code>liboai::Models</code>, and its interface can ideally be accessed through a <code>liboai::OpenAI</code> object. + +This class and its associated <code>liboai::OpenAI</code> interface allow access to the <a href="https://beta.openai.com/docs/api-reference/models">Models</a> endpoint of the OpenAI API; this endpoint's functionality can be found below.</p> +- List and describe the various models available in the API. + +<br> +<h2>Methods</h2> +<p>This document covers the method(s) located in <code>models.h</code>. You can find their function signature(s) below.</p> + +<h3>List Models</h3> +<p>Lists the currently available models, and provides basic information about each one such as the owner and availability. Returns a <code>liboai::Response</code> containing response data.</p> + +```cpp +liboai::Response list() const & noexcept(false); +``` + +<h3>List Models (async)</h3> +<p>Asynchronously lists the currently available models, and provides basic information about each one such as the owner and availability. Returns a <code>liboai::FutureResponse</code> containing future response data.</p> + +```cpp +liboai::FutureResponse list_async() const & noexcept(false); +``` + +<h3>Retrieve Model</h3> +<p>Retrieves a model instance, providing basic information about the model such as the owner and permissioning. Returns a <code>liboai::Response</code> containing response data.</p> + +```cpp +liboai::Response retrieve( + const std::string& model +) const & noexcept(false); +``` + +<h3>Retrieve Model (async)</h3> +<p>Asynchronously retrieves a model instance, providing basic information about the model such as the owner and permissioning. Returns a <code>liboai::FutureResponse</code> containing future response data.</p> + +```cpp +liboai::FutureResponse retrieve_async( + const std::string& model +) const & noexcept(false); +``` + +<br> +<h2>Example Usage</h2> +<p>For example usage of the above function(s), please refer to the <a href="./examples">examples</a> folder. diff --git a/packages/media/cpp/packages/liboai/documentation/models/examples/CMakeLists.txt b/packages/media/cpp/packages/liboai/documentation/models/examples/CMakeLists.txt new file mode 100644 index 00000000..7cbb7ef4 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/models/examples/CMakeLists.txt @@ -0,0 +1,8 @@ +cmake_minimum_required(VERSION 3.13) + +project(models) + +add_basic_example(list_models) +add_basic_example(list_models_async) +add_basic_example(retrieve_model) +add_basic_example(retrieve_model_async) diff --git a/packages/media/cpp/packages/liboai/documentation/models/examples/list_models.cpp b/packages/media/cpp/packages/liboai/documentation/models/examples/list_models.cpp new file mode 100644 index 00000000..f50f9a27 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/models/examples/list_models.cpp @@ -0,0 +1,16 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response response = oai.Model->list(); + std::cout << response["data"] << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/models/examples/list_models_async.cpp b/packages/media/cpp/packages/liboai/documentation/models/examples/list_models_async.cpp new file mode 100644 index 00000000..d17561e7 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/models/examples/list_models_async.cpp @@ -0,0 +1,27 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + // call async method; returns a future + auto fut = oai.Model->list_async(); + + // do other work... + + // check if the future is ready + fut.wait(); + + // get the contained response + auto response = fut.get(); + + // print some response data + std::cout << response["data"] << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/models/examples/retrieve_model.cpp b/packages/media/cpp/packages/liboai/documentation/models/examples/retrieve_model.cpp new file mode 100644 index 00000000..fd7b2de3 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/models/examples/retrieve_model.cpp @@ -0,0 +1,18 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response response = oai.Model->retrieve( + "text-davinci-003" + ); + std::cout << response << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/models/examples/retrieve_model_async.cpp b/packages/media/cpp/packages/liboai/documentation/models/examples/retrieve_model_async.cpp new file mode 100644 index 00000000..8ea13971 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/models/examples/retrieve_model_async.cpp @@ -0,0 +1,29 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + // call async method; returns a future + auto fut = oai.Model->retrieve_async( + "text-davinci-003" + ); + + // do other work... + + // check if the future is ready + fut.wait(); + + // get the contained response + auto response = fut.get(); + + // print some response data + std::cout << response << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/moderations/README.md b/packages/media/cpp/packages/liboai/documentation/moderations/README.md new file mode 100644 index 00000000..f1380a7e --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/moderations/README.md @@ -0,0 +1,35 @@ +<h1>Moderations</h1> +<p>The <code>Moderations</code> class is defined in <code>moderations.h</code> at <code>liboai::Moderations</code>, and its interface can ideally be accessed through a <code>liboai::OpenAI</code> object. + +This class and its associated <code>liboai::OpenAI</code> interface allow access to the <a href="https://beta.openai.com/docs/api-reference/moderations">Moderations</a> endpoint of the OpenAI API; this endpoint's functionality can be found below.</p> +- Given a input text, outputs if the model classifies it as violating OpenAI's content policy. + +<br> +<h2>Methods</h2> +<p>This document covers the method(s) located in <code>moderations.h</code>. You can find their function signature(s) below.</p> + +<h3>Create a Moderation</h3> +<p>Classifies if text violates OpenAI's Content Policy. Returns a <code>liboai::Response</code> containing response data.</p> + +```cpp +liboai::Response create( + const std::string& input, + std::optional<std::string> model = std::nullopt +) const & noexcept(false); +``` + +<h3>Create a Moderation (async)</h3> +<p>Asynchronously classifies if text violates OpenAI's Content Policy. Returns a <code>liboai::FutureResponse</code> containing future response data.</p> + +```cpp +liboai::FutureResponse create_async( + const std::string& input, + std::optional<std::string> model = std::nullopt +) const & noexcept(false); +``` + +<p>All function parameters marked <code>optional</code> are not required and are resolved on OpenAI's end if not supplied.</p> + +<br> +<h2>Example Usage</h2> +<p>For example usage of the above function(s), please refer to the <a href="./examples">examples</a> folder. diff --git a/packages/media/cpp/packages/liboai/documentation/moderations/examples/CMakeLists.txt b/packages/media/cpp/packages/liboai/documentation/moderations/examples/CMakeLists.txt new file mode 100644 index 00000000..e89e2561 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/moderations/examples/CMakeLists.txt @@ -0,0 +1,6 @@ +cmake_minimum_required(VERSION 3.13) + +project(moderations) + +add_basic_example(create_moderation) +add_basic_example(create_moderation_async) diff --git a/packages/media/cpp/packages/liboai/documentation/moderations/examples/create_moderation.cpp b/packages/media/cpp/packages/liboai/documentation/moderations/examples/create_moderation.cpp new file mode 100644 index 00000000..e1b4c689 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/moderations/examples/create_moderation.cpp @@ -0,0 +1,18 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response response = oai.Moderation->create( + "I want to kill them." + ); + std::cout << response["results"][0]["flagged"].get<bool>() << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/moderations/examples/create_moderation_async.cpp b/packages/media/cpp/packages/liboai/documentation/moderations/examples/create_moderation_async.cpp new file mode 100644 index 00000000..46e9017d --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/moderations/examples/create_moderation_async.cpp @@ -0,0 +1,29 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + // call async method; returns a future + auto fut = oai.Moderation->create_async( + "I want to kill them." + ); + + // do other work... + + // check if the future is ready + fut.wait(); + + // get the contained response + auto response = fut.get(); + + // print some response data + std::cout << response["results"][0]["flagged"].get<bool>() << std::endl; + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/responses/README.md b/packages/media/cpp/packages/liboai/documentation/responses/README.md new file mode 100644 index 00000000..786693eb --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/responses/README.md @@ -0,0 +1,114 @@ +<h1>Responses</h1> +<p>The <code>Responses</code> class is defined in <code>responses.h</code> at <code>liboai::Responses</code>, and its interface can be accessed through a <code>liboai::OpenAI</code> object.</p> + +<p>This class provides access to the <a href="https://platform.openai.com/docs/api-reference/responses/create">Responses API</a>. It offers a typed <code>create</code> overload for common fields and a raw JSON overload for full flexibility.</p> + +<h2>Methods</h2> +<p>This document covers the method(s) located in <code>responses.h</code>. You can find their function signature(s) below.</p> + +<h3>Build a Request Payload</h3> +<p>Builds a Responses API request payload from typed parameters.</p> + +```cpp +static nlohmann::json build_request( + const std::string& model, + const nlohmann::json& input, + std::optional<std::string> instructions = std::nullopt, + std::optional<nlohmann::json> reasoning = std::nullopt, + std::optional<nlohmann::json> text = std::nullopt, + std::optional<uint32_t> max_output_tokens = std::nullopt, + std::optional<float> temperature = std::nullopt, + std::optional<float> top_p = std::nullopt, + std::optional<uint32_t> seed = std::nullopt, + std::optional<nlohmann::json> tools = std::nullopt, + std::optional<nlohmann::json> tool_choice = std::nullopt, + std::optional<bool> parallel_tool_calls = std::nullopt, + std::optional<bool> store = std::nullopt, + std::optional<std::string> previous_response_id = std::nullopt, + std::optional<nlohmann::json> include = std::nullopt, + std::optional<nlohmann::json> metadata = std::nullopt, + std::optional<std::string> user = std::nullopt, + std::optional<std::string> truncation = std::nullopt, + std::optional<bool> stream = std::nullopt +); +``` + +<h3>Create a Response</h3> +<p>Creates a response from typed parameters. Returns a <code>liboai::Response</code> containing response data.</p> + +```cpp +liboai::Response create( + const std::string& model, + const nlohmann::json& input, + std::optional<std::string> instructions = std::nullopt, + std::optional<nlohmann::json> reasoning = std::nullopt, + std::optional<nlohmann::json> text = std::nullopt, + std::optional<uint32_t> max_output_tokens = std::nullopt, + std::optional<float> temperature = std::nullopt, + std::optional<float> top_p = std::nullopt, + std::optional<uint32_t> seed = std::nullopt, + std::optional<nlohmann::json> tools = std::nullopt, + std::optional<nlohmann::json> tool_choice = std::nullopt, + std::optional<bool> parallel_tool_calls = std::nullopt, + std::optional<bool> store = std::nullopt, + std::optional<std::string> previous_response_id = std::nullopt, + std::optional<nlohmann::json> include = std::nullopt, + std::optional<nlohmann::json> metadata = std::nullopt, + std::optional<std::string> user = std::nullopt, + std::optional<std::string> truncation = std::nullopt, + std::optional<std::function<bool(std::string, intptr_t)>> stream = std::nullopt +) const & noexcept(false); +``` + +<h3>Create a Response (raw JSON)</h3> +<p>Creates a response from a raw JSON payload. Returns a <code>liboai::Response</code> containing response data.</p> + +```cpp +liboai::Response create( + const nlohmann::json& request, + std::optional<std::function<bool(std::string, intptr_t)>> stream = std::nullopt +) const & noexcept(false); +``` + +<h3>Create a Response (async)</h3> +<p>Asynchronously creates a response from typed parameters. Returns a <code>liboai::FutureResponse</code> containing future response data.</p> + +```cpp +liboai::FutureResponse create_async( + const std::string& model, + const nlohmann::json& input, + std::optional<std::string> instructions = std::nullopt, + std::optional<nlohmann::json> reasoning = std::nullopt, + std::optional<nlohmann::json> text = std::nullopt, + std::optional<uint32_t> max_output_tokens = std::nullopt, + std::optional<float> temperature = std::nullopt, + std::optional<float> top_p = std::nullopt, + std::optional<uint32_t> seed = std::nullopt, + std::optional<nlohmann::json> tools = std::nullopt, + std::optional<nlohmann::json> tool_choice = std::nullopt, + std::optional<bool> parallel_tool_calls = std::nullopt, + std::optional<bool> store = std::nullopt, + std::optional<std::string> previous_response_id = std::nullopt, + std::optional<nlohmann::json> include = std::nullopt, + std::optional<nlohmann::json> metadata = std::nullopt, + std::optional<std::string> user = std::nullopt, + std::optional<std::string> truncation = std::nullopt, + std::optional<std::function<bool(std::string, intptr_t)>> stream = std::nullopt +) const & noexcept(false); +``` + +<h3>Create a Response (async, raw JSON)</h3> +<p>Asynchronously creates a response from a raw JSON payload. Returns a <code>liboai::FutureResponse</code> containing future response data.</p> + +```cpp +liboai::FutureResponse create_async( + const nlohmann::json& request, + std::optional<std::function<bool(std::string, intptr_t)>> stream = std::nullopt +) const & noexcept(false); +``` + +<p>When using streaming, include <code>"stream": true</code> in the request (or pass a stream callback to the typed overload) and provide a stream callback to receive SSE data.</p> + +<h2>Example Usage</h2> +<p>For example usage of the above function(s), please refer to the <a href="./examples">examples</a> folder.</p> +<p>Examples include <code>create_response.cpp</code> (raw JSON) and <code>create_response_typed.cpp</code> (typed parameters).</p> diff --git a/packages/media/cpp/packages/liboai/documentation/responses/TECHNICAL_PLAN.md b/packages/media/cpp/packages/liboai/documentation/responses/TECHNICAL_PLAN.md new file mode 100644 index 00000000..28e28427 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/responses/TECHNICAL_PLAN.md @@ -0,0 +1,112 @@ +# Responses API Migration Plan (GPT-5.2) + +## Goal +Add first-class support for the OpenAI Responses API so liboai can use GPT-5.2 and gpt-5.2-pro, while keeping existing Chat Completions support for backward compatibility. + +## Current State (Repo Reality) +- `liboai::ChatCompletion` calls `/v1/chat/completions` and uses `Conversation` to manage messages and function calls. +- No Responses API component exists. +- Streaming parsers and conversation updates are tightly coupled to Chat Completions response shape. +- JSON request building uses `JsonConstructor` with explicit parameter lists. + +## Requirements from GPT-5.2 / Responses API +- New endpoint: `POST /v1/responses`. +- New request shape: `input` can be a string or an array of items; `instructions` can be separate. +- New parameters: `reasoning.effort`, `text.verbosity`, `text.format` (structured outputs), `max_output_tokens`. +- Model-specific constraint: `temperature`, `top_p`, and `logprobs` are only allowed when `reasoning.effort == "none"`. +- Tool definitions use `tools` with internally-tagged objects, and `tool_choice` supports `allowed_tools`. +- Multi-turn can chain with `previous_response_id`. + +## Proposed API Design + +### 1) New Component: `liboai::Responses` +- Files: + - `liboai/components/responses.cpp` + - `liboai/include/components/responses.h` +- Wiring: + - Add to `liboai/include/liboai.h` and `liboai/CMakeLists.txt`. +- Methods: + - `Response create(const nlohmann::json& request, ...) const`. + - `FutureResponse create_async(const nlohmann::json& request, ...) const`. + - Raw JSON payloads only for the initial implementation. + +### 2) Request Builder Types (Deferred) +We will start with raw JSON requests to avoid introducing new abstractions. +If needed later, add a `ResponseRequest` or similar builder to simplify common cases. + +### 3) Input Helpers (Deferred) +- `ResponseInput` helper to build `input` items: + - `AddSystem(string)` + - `AddUser(string)` + - `AddAssistant(string)` + - `AddToolCall(...)` + - `AddToolOutput(...)` +- Provide a static adapter: `ResponseInput::FromConversation(const Conversation&)`. + +### 4) Tool Definitions +- New `Tools` helper to build `tools` arrays: + - Function tools: `{ "type": "function", "name", "description", "parameters" }` + - Custom tools: `{ "type": "custom", "name", "description" }` + - Built-ins: `{ "type": "web_search" }` and others (pass-through JSON) +- New `ToolChoice` helper to build `tool_choice` objects, including `allowed_tools`. + +### 5) Response Parsing Helpers +- Add optional helpers to extract `output_text` from the response JSON. +- Keep `liboai::Response` unchanged for compatibility; add a small utility function in `Responses` or a separate helper header. + +### 6) Streaming +- New streaming parser for Responses SSE events. +- Provide `ResponsesStreamCallback` that surfaces: + - `delta_text` (if any) + - `event_type` (for tool calls, output items, completion) + - a partial `Response` or updated `ResponseInput` (optional) +- Confirm event schema from official Responses streaming docs before implementation. + +### 7) Backward Compatibility +- Keep `ChatCompletion` intact. +- Consider adding optional `verbosity` and `reasoning_effort` parameters to `ChatCompletion` for GPT-5.2 users who stay on Chat Completions. +- Do not remove `Conversation` or existing behavior. + +## Implementation Plan (Milestones) + +### Milestone 1: Scaffolding +- [x] Add Responses component files and wire into build system. +- [x] Add minimal `create` that accepts a raw JSON payload. +- [x] Add a basic usage example (string input). + +### Milestone 2: Core Params and Validation +- [ ] Implement `instructions`, `reasoning`, `text`, `max_output_tokens`. +- [ ] Add validation for `temperature` / `top_p` / `logprobs` when `reasoning.effort != "none"`. +- [ ] Add `store`, `previous_response_id`, `include`, `metadata`. + +### Milestone 3: Tools and Structured Outputs +- [ ] Add `Tools` and `ToolChoice` helpers. +- [ ] Support `text.format` for JSON schema structured outputs. +- [ ] Provide minimal tool-call example usage. + +### Milestone 4: Streaming and Output Helpers +- [ ] Implement SSE parsing for Responses. +- [ ] Add `output_text` helper extraction. +- [ ] Add streaming example and docs. + +### Milestone 5: Docs and Migration Guidance +- [x] Add `documentation/responses/README.md` with API usage. +- [x] Update `documentation/README.md` to link Responses docs. +- [x] Update root `README.md` feature list to include Responses API. + +## Testing Plan +- Compile examples in `documentation/responses/examples`. +- Add at least one integration test snippet (manual run) for: + - simple string input + - tool definition + tool call response parsing + - structured output +- If a unit test framework is added later, add a JSON shape test for `ResponseInput` and `ToolChoice` builders. + +## Risks and Mitigations +- Streaming event schema is different from Chat Completions: verify with official docs before implementing. +- Tool calling item shapes in Responses are not identical to Chat Completions: parse by `type` and `call_id`. +- Parameter incompatibilities for GPT-5.2: add validation and helpful error messages. + +## Open Questions for Jason +- Should `ResponseInput` be added after the basic Responses component ships? +- Should we add `output_text()` convenience on `liboai::Response`, or keep helpers in `Responses` only? diff --git a/packages/media/cpp/packages/liboai/documentation/responses/examples/CMakeLists.txt b/packages/media/cpp/packages/liboai/documentation/responses/examples/CMakeLists.txt new file mode 100644 index 00000000..cbf781aa --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/responses/examples/CMakeLists.txt @@ -0,0 +1,6 @@ +cmake_minimum_required(VERSION 3.13) + +project(responses) + +add_basic_example(create_response) +add_basic_example(create_response_typed) diff --git a/packages/media/cpp/packages/liboai/documentation/responses/examples/create_response.cpp b/packages/media/cpp/packages/liboai/documentation/responses/examples/create_response.cpp new file mode 100644 index 00000000..635d0957 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/responses/examples/create_response.cpp @@ -0,0 +1,47 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + nlohmann::json request; + request["model"] = "gpt-5.2-pro"; + request["input"] = "Hello from the Responses API."; + + Response response = oai.Responses->create(request); + + std::string output_text; + if (response.raw_json.contains("output")) { + for (const auto& item : response["output"]) { + if (item.contains("type") && item["type"] == "message") { + if (item.contains("content") && item["content"].is_array()) { + for (const auto& content : item["content"]) { + if (content.contains("type") && content["type"] == "output_text" && content.contains("text")) { + output_text = content["text"].get<std::string>(); + break; + } + } + } + } + + if (!output_text.empty()) { + break; + } + } + } + + if (!output_text.empty()) { + std::cout << output_text << std::endl; + } + else { + std::cout << response << std::endl; + } + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/documentation/responses/examples/create_response_typed.cpp b/packages/media/cpp/packages/liboai/documentation/responses/examples/create_response_typed.cpp new file mode 100644 index 00000000..99eb0034 --- /dev/null +++ b/packages/media/cpp/packages/liboai/documentation/responses/examples/create_response_typed.cpp @@ -0,0 +1,48 @@ +#include "liboai.h" + +using namespace liboai; + +int main() { + OpenAI oai; + + if (oai.auth.SetKeyEnv("OPENAI_API_KEY")) { + try { + Response response = oai.Responses->create( + "gpt-5.2-pro", + "Hello from the typed Responses API." + ); + + // std::cout << response << std::endl; + // std::cout << response["choices"][0]["text"].get<std::string>() << std::endl; + std::string output_text; + if (response.raw_json.contains("output")) { + for (const auto& item : response["output"]) { + if (item.contains("type") && item["type"] == "message") { + if (item.contains("content") && item["content"].is_array()) { + for (const auto& content : item["content"]) { + if (content.contains("type") && content["type"] == "output_text" && content.contains("text")) { + output_text = content["text"].get<std::string>(); + break; + } + } + } + } + + if (!output_text.empty()) { + break; + } + } + } + + if (!output_text.empty()) { + std::cout << output_text << std::endl; + } + else { + std::cout << response << std::endl; + } + } + catch (std::exception& e) { + std::cout << e.what() << std::endl; + } + } +} diff --git a/packages/media/cpp/packages/liboai/flake.lock b/packages/media/cpp/packages/liboai/flake.lock new file mode 100644 index 00000000..e95f5a77 --- /dev/null +++ b/packages/media/cpp/packages/liboai/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1736012469, + "narHash": "sha256-/qlNWm/IEVVH7GfgAIyP6EsVZI6zjAx1cV5zNyrs+rI=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "8f3e1f807051e32d8c95cd12b9b421623850a34d", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/packages/media/cpp/packages/liboai/flake.nix b/packages/media/cpp/packages/liboai/flake.nix new file mode 100644 index 00000000..925b0e63 --- /dev/null +++ b/packages/media/cpp/packages/liboai/flake.nix @@ -0,0 +1,17 @@ +{ + description = "C++ Development Environment"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in { + devShells.default = import ./shell.nix { pkgs = pkgs; }; + } + ); +} diff --git a/packages/media/cpp/packages/liboai/images/_logo.png b/packages/media/cpp/packages/liboai/images/_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..189522f6bba982b240803535355ad7a3baa31b90 GIT binary patch literal 64476 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®WNk^-?_hjpEdTdrAkf8 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 Saq8eQ{4+DPFex)~rvDF9hW$$b literal 0 HcmV?d00001 diff --git a/packages/media/cpp/packages/liboai/images/snake.png b/packages/media/cpp/packages/liboai/images/snake.png new file mode 100644 index 0000000000000000000000000000000000000000..a3ce566a3903f4e08fae9364d2f545f15d9acfc9 GIT binary patch literal 197109 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 literal 0 HcmV?d00001 diff --git a/packages/media/cpp/packages/liboai/liboai/CMakeLists.txt b/packages/media/cpp/packages/liboai/liboai/CMakeLists.txt new file mode 100644 index 00000000..a74f62b4 --- /dev/null +++ b/packages/media/cpp/packages/liboai/liboai/CMakeLists.txt @@ -0,0 +1,194 @@ +cmake_minimum_required(VERSION 3.21) + +include(CMakePackageConfigHelpers) + +project(oai VERSION 4.0.1) + +if(MSVC) + set(CMAKE_DEBUG_POSTFIX "d") +endif() + +# When liboai is used as a vendored dependency (via add_subdirectory), +# its install/export rules can break the parent build if its link +# dependencies (eg. CURL::libcurl) are not part of liboai's export set. +# Only enable install/export when building liboai as the top-level project. +set(_LIBOAI_DEFAULT_INSTALL OFF) +if(PROJECT_IS_TOP_LEVEL) + set(_LIBOAI_DEFAULT_INSTALL ON) +endif() +option(LIBOAI_INSTALL "Enable liboai install/export rules" ${_LIBOAI_DEFAULT_INSTALL}) + +# Try to find nlohmann_json, but if not found, use a target if it exists +# (common when nlohmann_json is provided by a parent project via add_subdirectory/add_library). +if(TARGET nlohmann_json::nlohmann_json) + set(nlohmann_json_FOUND TRUE) +elseif(TARGET nlohmann_json) + set(nlohmann_json_FOUND TRUE) + add_library(nlohmann_json::nlohmann_json ALIAS nlohmann_json) +else() + find_package(nlohmann_json CONFIG QUIET) + if(NOT nlohmann_json_FOUND) + message(FATAL_ERROR "nlohmann_json not found and no nlohmann_json target exists") + endif() +endif() + +# Try to find CURL, but if not found, use the vendored target if it exists +if(TARGET CURL::libcurl) + set(CURL_FOUND TRUE) +elseif(TARGET libcurl) + add_library(CURL::libcurl ALIAS libcurl) + set(CURL_FOUND TRUE) +elseif(TARGET curl) + add_library(CURL::libcurl ALIAS curl) + set(CURL_FOUND TRUE) +else() + find_package(CURL QUIET) + if(CURL_FOUND AND NOT TARGET CURL::libcurl) + if(TARGET libcurl) + add_library(CURL::libcurl ALIAS libcurl) + elseif(TARGET curl) + add_library(CURL::libcurl ALIAS curl) + else() + add_library(CURL::libcurl INTERFACE IMPORTED) + if(CURL_LIBRARIES) + set_property(TARGET CURL::libcurl PROPERTY + INTERFACE_LINK_LIBRARIES "${CURL_LIBRARIES}") + endif() + if(CURL_INCLUDE_DIRS) + set_property(TARGET CURL::libcurl PROPERTY + INTERFACE_INCLUDE_DIRECTORIES "${CURL_INCLUDE_DIRS}") + endif() + endif() + endif() + if(NOT TARGET CURL::libcurl) + message(FATAL_ERROR "CURL not found and CURL::libcurl target does not exist") + endif() +endif() + +add_library(${PROJECT_NAME}) + +function(make_absolute_paths result_var) + set(absolute_paths) + foreach(file IN LISTS ARGN) + list(APPEND absolute_paths "${CMAKE_CURRENT_SOURCE_DIR}/${file}") + endforeach() + set(${result_var} "${absolute_paths}" PARENT_SCOPE) +endfunction() + +set(HEADERS_RELATIVE + "include/liboai.h" +) + +make_absolute_paths(HEADERS ${HEADERS_RELATIVE}) +source_group("include" FILES ${HEADERS}) + +set(COMPONENT_HEADERS_RELATIVE + "include/components/audio.h" + "include/components/azure.h" + "include/components/chat.h" + "include/components/completions.h" + "include/components/edits.h" + "include/components/embeddings.h" + "include/components/files.h" + "include/components/fine_tunes.h" + "include/components/images.h" + "include/components/models.h" + "include/components/moderations.h" + "include/components/responses.h" +) + +make_absolute_paths(COMPONENT_HEADERS ${COMPONENT_HEADERS_RELATIVE}) +source_group("include/components" FILES ${COMPONENT_HEADERS}) + +set(COMPONENT_SOURCES_RELATIVE + "components/audio.cpp" + "components/azure.cpp" + "components/chat.cpp" + "components/completions.cpp" + "components/edits.cpp" + "components/embeddings.cpp" + "components/files.cpp" + "components/fine_tunes.cpp" + "components/images.cpp" + "components/models.cpp" + "components/moderations.cpp" + "components/responses.cpp" +) + +make_absolute_paths(COMPONENT_SOURCES ${COMPONENT_SOURCES_RELATIVE}) +source_group("source/components" FILES ${COMPONENT_SOURCES}) + +set(CORE_HEADERS_RELATIVE + "include/core/authorization.h" + "include/core/exception.h" + "include/core/netimpl.h" + "include/core/network.h" + "include/core/response.h" +) + +make_absolute_paths(CORE_HEADERS ${CORE_HEADERS_RELATIVE}) +source_group("include/core" FILES ${CORE_HEADERS}) + +set(CORE_SOURCES_RELATIVE + "core/authorization.cpp" + "core/netimpl.cpp" + "core/response.cpp" +) + +make_absolute_paths(CORE_SOURCES ${CORE_SOURCES_RELATIVE}) +source_group("source/core" FILES ${CORE_SOURCES}) + +target_sources(${PROJECT_NAME} + PRIVATE + ${COMPONENT_SOURCES} + ${CORE_SOURCES} + PUBLIC + "$<BUILD_INTERFACE:${HEADERS}>" + "$<BUILD_INTERFACE:${COMPONENT_HEADERS}>" + "$<BUILD_INTERFACE:${CORE_HEADERS}>" + "$<INSTALL_INTERFACE:${HEADERS_RELATIVE}>" + "$<INSTALL_INTERFACE:${COMPONENT_HEADERS_RELATIVE}>" + "$<INSTALL_INTERFACE:${CORE_HEADERS_RELATIVE}>" +) + +target_compile_features(${PROJECT_NAME} PUBLIC cxx_std_17) + +target_link_libraries(${PROJECT_NAME} + PUBLIC + nlohmann_json::nlohmann_json + CURL::libcurl +) + +target_include_directories(${PROJECT_NAME} + PUBLIC + "$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>" + "$<INSTALL_INTERFACE:include>" +) + +if(LIBOAI_INSTALL) + install(TARGETS ${PROJECT_NAME} DESTINATION lib EXPORT ${PROJECT_NAME}Targets) + install(FILES ${HEADERS} DESTINATION "include") + install(FILES ${COMPONENT_HEADERS} DESTINATION "include/components") + install(FILES ${CORE_HEADERS} DESTINATION "include/core") + + configure_package_config_file("${CMAKE_CURRENT_SOURCE_DIR}/Config.cmake.in" + "${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}Config.cmake" + INSTALL_DESTINATION "lib/cmake/${PROJECT_NAME}" + ) + + write_basic_package_version_file( + "${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}ConfigVersion.cmake" + COMPATIBILITY AnyNewerVersion + ) + + install(EXPORT ${PROJECT_NAME}Targets + FILE ${PROJECT_NAME}Targets.cmake + NAMESPACE oai:: + DESTINATION "lib/cmake/${PROJECT_NAME}" + ) + install(FILES + "${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}Config.cmake" + "${CMAKE_CURRENT_BINARY_DIR}/${PROJECT_NAME}ConfigVersion.cmake" + DESTINATION "lib/cmake/${PROJECT_NAME}" + ) +endif() diff --git a/packages/media/cpp/packages/liboai/liboai/Config.cmake.in b/packages/media/cpp/packages/liboai/liboai/Config.cmake.in new file mode 100644 index 00000000..202da493 --- /dev/null +++ b/packages/media/cpp/packages/liboai/liboai/Config.cmake.in @@ -0,0 +1,6 @@ +@PACKAGE_INIT@ + +include("${CMAKE_CURRENT_LIST_DIR}/oaiTargets.cmake") + +find_package(nlohmann_json CONFIG REQUIRED) +find_package(CURL REQUIRED) diff --git a/packages/media/cpp/packages/liboai/liboai/components/audio.cpp b/packages/media/cpp/packages/liboai/liboai/components/audio.cpp new file mode 100644 index 00000000..3b498d48 --- /dev/null +++ b/packages/media/cpp/packages/liboai/liboai/components/audio.cpp @@ -0,0 +1,100 @@ +#include "../include/components/audio.h" + +liboai::Response liboai::Audio::transcribe(const std::filesystem::path& file, const std::string& model, std::optional<std::string> prompt, std::optional<std::string> response_format, std::optional<float> temperature, std::optional<std::string> language) const& noexcept(false) { + if (!this->Validate(file)) { + throw liboai::exception::OpenAIException( + "File path provided is non-existent, is not a file, or is empty.", + liboai::exception::EType::E_FILEERROR, + "liboai::Audio::transcribe(...)" + ); + } + + netimpl::components::Multipart form = { + { "file", netimpl::components::File{file.generic_string()} }, + { "model", model } + }; + + if (prompt) { form.parts.push_back({ "prompt", prompt.value() }); } + if (response_format) { form.parts.push_back({ "response_format", response_format.value() }); } + if (temperature) { form.parts.push_back({ "temperature", std::to_string(temperature.value()) }); } + if (language) { form.parts.push_back({ "language", language.value() }); } + + Response res; + res = this->Request( + Method::HTTP_POST, this->openai_root_, "/audio/transcriptions", "multipart/form-data", + this->auth_.GetAuthorizationHeaders(), + std::move(form), + this->auth_.GetProxies(), + this->auth_.GetProxyAuth(), + this->auth_.GetMaxTimeout() + ); + + return res; +} + +liboai::FutureResponse liboai::Audio::transcribe_async(const std::filesystem::path& file, const std::string& model, std::optional<std::string> prompt, std::optional<std::string> response_format, std::optional<float> temperature, std::optional<std::string> language) const& noexcept(false) { + return std::async(std::launch::async, &liboai::Audio::transcribe, this, file, model, prompt, response_format, temperature, language); +} + +liboai::Response liboai::Audio::translate(const std::filesystem::path& file, const std::string& model, std::optional<std::string> prompt, std::optional<std::string> response_format, std::optional<float> temperature) const& noexcept(false) { + if (!this->Validate(file)) { + throw liboai::exception::OpenAIException( + "File path provided is non-existent, is not a file, or is empty.", + liboai::exception::EType::E_FILEERROR, + "liboai::Audio::translate(...)" + ); + } + + netimpl::components::Multipart form = { + { "file", netimpl::components::File{file.generic_string()} }, + { "model", model } + }; + + if (prompt) { form.parts.push_back({ "prompt", std::move(prompt.value()) }); } + if (response_format) { form.parts.push_back({ "response_format", std::move(response_format.value()) }); } + if (temperature) { form.parts.push_back({ "temperature", std::to_string(temperature.value()) }); } + + Response res; + res = this->Request( + Method::HTTP_POST, this->openai_root_, "/audio/translations", "multipart/form-data", + this->auth_.GetAuthorizationHeaders(), + std::move(form), + this->auth_.GetProxies(), + this->auth_.GetProxyAuth(), + this->auth_.GetMaxTimeout() + ); + + return res; +} + +liboai::FutureResponse liboai::Audio::translate_async(const std::filesystem::path& file, const std::string& model, std::optional<std::string> prompt, std::optional<std::string> response_format, std::optional<float> temperature) const& noexcept(false) { + return std::async(std::launch::async, &liboai::Audio::translate, this, file, model, prompt, response_format, temperature); +} + +liboai::Response liboai::Audio::speech(const std::string& model, const std::string& voice, const std::string& input, std::optional<std::string> response_format, std::optional<float> speed) const& noexcept(false) { + liboai::JsonConstructor jcon; + jcon.push_back("model", model); + jcon.push_back("voice", voice); + jcon.push_back("input", input); + + if (response_format) { jcon.push_back("response_format", std::move(response_format.value())); } + if (speed) { jcon.push_back("speed", speed.value()); } + + Response res; + res = this->Request( + Method::HTTP_POST, this->openai_root_, "/audio/speech", "application/json", + this->auth_.GetAuthorizationHeaders(), + netimpl::components::Body { + jcon.dump() + }, + this->auth_.GetProxies(), + this->auth_.GetProxyAuth(), + this->auth_.GetMaxTimeout() + ); + + return res; +} + +liboai::FutureResponse liboai::Audio::speech_async(const std::string& model, const std::string& voice, const std::string& input, std::optional<std::string> response_format, std::optional<float> speed) const& noexcept(false) { + return std::async(std::launch::async, &liboai::Audio::translate, this, model, voice, input, response_format, speed); +} diff --git a/packages/media/cpp/packages/liboai/liboai/components/azure.cpp b/packages/media/cpp/packages/liboai/liboai/components/azure.cpp new file mode 100644 index 00000000..91c5621e --- /dev/null +++ b/packages/media/cpp/packages/liboai/liboai/components/azure.cpp @@ -0,0 +1,206 @@ +#include "../include/components/azure.h" + +liboai::Response liboai::Azure::create_completion(const std::string& resource_name, const std::string& deployment_id, const std::string& api_version, std::optional<std::string> prompt, std::optional<std::string> suffix, std::optional<uint16_t> max_tokens, std::optional<float> temperature, std::optional<float> top_p, std::optional<uint16_t> n, std::optional<std::function<bool(std::string, intptr_t)>> stream, std::optional<uint8_t> logprobs, std::optional<bool> echo, std::optional<std::vector<std::string>> stop, std::optional<float> presence_penalty, std::optional<float> frequency_penalty, std::optional<uint16_t> best_of, std::optional<std::unordered_map<std::string, int8_t>> logit_bias, std::optional<std::string> user) const & noexcept(false) { + liboai::JsonConstructor jcon; + jcon.push_back("prompt", std::move(prompt)); + jcon.push_back("suffix", std::move(suffix)); + jcon.push_back("max_tokens", std::move(max_tokens)); + jcon.push_back("temperature", std::move(temperature)); + jcon.push_back("top_p", std::move(top_p)); + jcon.push_back("n", std::move(n)); + jcon.push_back("stream", stream); + jcon.push_back("logprobs", std::move(logprobs)); + jcon.push_back("echo", std::move(echo)); + jcon.push_back("stop", std::move(stop)); + jcon.push_back("presence_penalty", std::move(presence_penalty)); + jcon.push_back("frequency_penalty", std::move(frequency_penalty)); + jcon.push_back("best_of", std::move(best_of)); + jcon.push_back("logit_bias", std::move(logit_bias)); + jcon.push_back("user", std::move(user)); + + netimpl::components::Parameters params; + params.Add({ "api-version", api_version }); + + Response res; + res = this->Request( + Method::HTTP_POST, ("https://" + resource_name + this->azure_root_ + "/deployments/" + deployment_id), "/completions", "application/json", + this->auth_.GetAzureAuthorizationHeaders(), + netimpl::components::Body { + jcon.dump() + }, + std::move(params), + stream ? netimpl::components::WriteCallback{std::move(stream.value())} : netimpl::components::WriteCallback{}, + this->auth_.GetProxies(), + this->auth_.GetProxyAuth(), + this->auth_.GetMaxTimeout() + ); + + return res; +} + +liboai::FutureResponse liboai::Azure::create_completion_async(const std::string& resource_name, const std::string& deployment_id, const std::string& api_version, std::optional<std::string> prompt, std::optional<std::string> suffix, std::optional<uint16_t> max_tokens, std::optional<float> temperature, std::optional<float> top_p, std::optional<uint16_t> n, std::optional<std::function<bool(std::string, intptr_t)>> stream, std::optional<uint8_t> logprobs, std::optional<bool> echo, std::optional<std::vector<std::string>> stop, std::optional<float> presence_penalty, std::optional<float> frequency_penalty, std::optional<uint16_t> best_of, std::optional<std::unordered_map<std::string, int8_t>> logit_bias, std::optional<std::string> user) const & noexcept(false) { + return std::async(std::launch::async, &liboai::Azure::create_completion, this, resource_name, deployment_id, api_version, prompt, suffix, max_tokens, temperature, top_p, n, stream, logprobs, echo, stop, presence_penalty, frequency_penalty, best_of, logit_bias, user); +} + +liboai::Response liboai::Azure::create_embedding(const std::string& resource_name, const std::string& deployment_id, const std::string& api_version, const std::string& input, std::optional<std::string> user) const & noexcept(false) { + liboai::JsonConstructor jcon; + jcon.push_back("input", input); + jcon.push_back("user", std::move(user)); + + netimpl::components::Parameters params; + params.Add({ "api-version", api_version }); + + Response res; + res = this->Request( + Method::HTTP_POST, ("https://" + resource_name + this->azure_root_ + "/deployments/" + deployment_id), "/embeddings", "application/json", + this->auth_.GetAzureAuthorizationHeaders(), + netimpl::components::Body { + jcon.dump() + }, + std::move(params), + this->auth_.GetProxies(), + this->auth_.GetProxyAuth(), + this->auth_.GetMaxTimeout() + ); + + return res; +} + +liboai::FutureResponse liboai::Azure::create_embedding_async(const std::string& resource_name, const std::string& deployment_id, const std::string& api_version, const std::string& input, std::optional<std::string> user) const & noexcept(false) { + return std::async(std::launch::async, &liboai::Azure::create_embedding, this, resource_name, deployment_id, api_version, input, user); +} + +liboai::Response liboai::Azure::create_chat_completion(const std::string& resource_name, const std::string& deployment_id, const std::string& api_version, Conversation& conversation, std::optional<std::string> function_call, std::optional<float> temperature, std::optional<uint16_t> n, std::optional<ChatStreamCallback> stream, std::optional<std::vector<std::string>> stop, std::optional<uint16_t> max_tokens, std::optional<float> presence_penalty, std::optional<float> frequency_penalty, std::optional<std::unordered_map<std::string, int8_t>> logit_bias, std::optional<std::string> user) const & noexcept(false) { + liboai::JsonConstructor jcon; + jcon.push_back("temperature", std::move(temperature)); + jcon.push_back("n", std::move(n)); + jcon.push_back("stop", std::move(stop)); + jcon.push_back("max_tokens", std::move(max_tokens)); + jcon.push_back("presence_penalty", std::move(presence_penalty)); + jcon.push_back("frequency_penalty", std::move(frequency_penalty)); + jcon.push_back("logit_bias", std::move(logit_bias)); + jcon.push_back("user", std::move(user)); + + if (function_call) { + if (function_call.value() == "none" || function_call.value() == "auto") { + nlohmann::json j; j["function_call"] = function_call.value(); + jcon.push_back("function_call", j["function_call"]); + } + else { + nlohmann::json j; j["function_call"] = { {"name", function_call.value()} }; + jcon.push_back("function_call", j["function_call"]); + } + } + + StrippedStreamCallback _sscb = nullptr; + if (stream) { + _sscb = [stream, &conversation](std::string data, intptr_t userdata) -> bool { + ChatStreamCallback _stream = stream.value(); + return _stream(data, userdata, conversation); + }; + + jcon.push_back("stream", _sscb); + } + + if (conversation.GetJSON().contains("messages")) { + jcon.push_back("messages", conversation.GetJSON()["messages"]); + } + + if (conversation.HasFunctions()) { + jcon.push_back("functions", conversation.GetFunctionsJSON()["functions"]); + } + + netimpl::components::Parameters params; + params.Add({ "api-version", api_version }); + + Response res; + res = this->Request( + Method::HTTP_POST, ("https://" + resource_name + this->azure_root_ + "/deployments/" + deployment_id), "/chat/completions", "application/json", + this->auth_.GetAzureAuthorizationHeaders(), + netimpl::components::Body { + jcon.dump() + }, + std::move(params), + _sscb ? netimpl::components::WriteCallback{std::move(_sscb)} : netimpl::components::WriteCallback{}, + this->auth_.GetProxies(), + this->auth_.GetProxyAuth(), + this->auth_.GetMaxTimeout() + ); + + return res; +} + +liboai::FutureResponse liboai::Azure::create_chat_completion_async(const std::string& resource_name, const std::string& deployment_id, const std::string& api_version, Conversation& conversation, std::optional<std::string> function_call, std::optional<float> temperature, std::optional<uint16_t> n, std::optional<ChatStreamCallback> stream, std::optional<std::vector<std::string>> stop, std::optional<uint16_t> max_tokens, std::optional<float> presence_penalty, std::optional<float> frequency_penalty, std::optional<std::unordered_map<std::string, int8_t>> logit_bias, std::optional<std::string> user) const & noexcept(false) { + return std::async(std::launch::async, &liboai::Azure::create_chat_completion, this, resource_name, deployment_id, api_version, std::ref(conversation), function_call, temperature, n, stream, stop, max_tokens, presence_penalty, frequency_penalty, logit_bias, user); +} + +liboai::Response liboai::Azure::request_image_generation(const std::string& resource_name, const std::string& api_version, const std::string& prompt, std::optional<uint8_t> n, std::optional<std::string> size) const & noexcept(false) { + liboai::JsonConstructor jcon; + jcon.push_back("prompt", prompt); + jcon.push_back("n", std::move(n)); + jcon.push_back("size", std::move(size)); + + netimpl::components::Parameters params; + params.Add({ "api-version", api_version }); + + Response res; + res = this->Request( + Method::HTTP_POST, ("https://" + resource_name + this->azure_root_), "/images/generations:submit", "application/json", + this->auth_.GetAzureAuthorizationHeaders(), + netimpl::components::Body { + jcon.dump() + }, + std::move(params), + this->auth_.GetProxies(), + this->auth_.GetProxyAuth(), + this->auth_.GetMaxTimeout() + ); + + return res; +} + +liboai::FutureResponse liboai::Azure::request_image_generation_async(const std::string& resource_name, const std::string& api_version, const std::string& prompt, std::optional<uint8_t> n, std::optional<std::string> size) const & noexcept(false) { + return std::async(std::launch::async, &liboai::Azure::request_image_generation, this, resource_name, api_version, prompt, n, size); +} + +liboai::Response liboai::Azure::get_generated_image(const std::string& resource_name, const std::string& api_version, const std::string& operation_id) const & noexcept(false) { + netimpl::components::Parameters params; + params.Add({ "api-version", api_version }); + + Response res; + res = this->Request( + Method::HTTP_GET, ("https://" + resource_name + this->azure_root_), "/operations/images/" + operation_id, "application/json", + this->auth_.GetAzureAuthorizationHeaders(), + std::move(params), + this->auth_.GetProxies(), + this->auth_.GetProxyAuth(), + this->auth_.GetMaxTimeout() + ); + + return res; +} + +liboai::FutureResponse liboai::Azure::get_generated_image_async(const std::string& resource_name, const std::string& api_version, const std::string& operation_id) const & noexcept(false) { + return std::async(std::launch::async, &liboai::Azure::get_generated_image, this, resource_name, api_version, operation_id); +} + +liboai::Response liboai::Azure::delete_generated_image(const std::string& resource_name, const std::string& api_version, const std::string& operation_id) const & noexcept(false) { + netimpl::components::Parameters params; + params.Add({ "api-version", api_version }); + + Response res; + res = this->Request( + Method::HTTP_DELETE, ("https://" + resource_name + this->azure_root_), "/operations/images/" + operation_id, "application/json", + this->auth_.GetAzureAuthorizationHeaders(), + std::move(params), + this->auth_.GetProxies(), + this->auth_.GetProxyAuth(), + this->auth_.GetMaxTimeout() + ); + + return res; +} + +liboai::FutureResponse liboai::Azure::delete_generated_image_async(const std::string& resource_name, const std::string& api_version, const std::string& operation_id) const & noexcept(false) { + return std::async(std::launch::async, &liboai::Azure::delete_generated_image, this, resource_name, api_version, operation_id); +} diff --git a/packages/media/cpp/packages/liboai/liboai/components/chat.cpp b/packages/media/cpp/packages/liboai/liboai/components/chat.cpp new file mode 100644 index 00000000..7a5394e2 --- /dev/null +++ b/packages/media/cpp/packages/liboai/liboai/components/chat.cpp @@ -0,0 +1,1166 @@ +#include "../include/components/chat.h" + +liboai::Conversation::Conversation() { + this->_conversation["messages"] = nlohmann::json::array(); +} + +liboai::Conversation::Conversation(const Conversation& other) { + this->_conversation = other._conversation; + this->_functions = other._functions; + this->_last_resp_is_fc = other._last_resp_is_fc; +} + +liboai::Conversation::Conversation(Conversation&& old) noexcept { + this->_conversation = std::move(old._conversation); + this->_functions = std::move(old._functions); + this->_last_resp_is_fc = old._last_resp_is_fc; + + old._conversation = nlohmann::json::object(); + old._functions = nlohmann::json::object(); +} + +liboai::Conversation::Conversation(std::string_view system_data) { + this->_conversation["messages"] = nlohmann::json::array(); + auto result = this->SetSystemData(system_data); +} + +liboai::Conversation::Conversation(std::string_view system_data, std::string_view user_data) { + this->_conversation["messages"] = nlohmann::json::array(); + auto result = this->SetSystemData(system_data); + result = this->AddUserData(user_data); +} + +liboai::Conversation::Conversation(std::string_view system_data, std::initializer_list<std::string_view> user_data) { + this->_conversation["messages"] = nlohmann::json::array(); + auto result = this->SetSystemData(system_data); + + for (auto& data : user_data) { + auto result = this->AddUserData(data); + } +} + +liboai::Conversation::Conversation(std::initializer_list<std::string_view> user_data) { + this->_conversation["messages"] = nlohmann::json::array(); + + for (auto& data : user_data) { + auto result = this->AddUserData(data); + } +} + +liboai::Conversation::Conversation(const std::vector<std::string>& user_data) { + this->_conversation["messages"] = nlohmann::json::array(); + + for (auto& data : user_data) { + auto result = this->AddUserData(data); + } +} + +liboai::Conversation& liboai::Conversation::operator=(const Conversation& other) { + this->_conversation = other._conversation; + this->_functions = other._functions; + this->_last_resp_is_fc = other._last_resp_is_fc; + return *this; +} + +liboai::Conversation& liboai::Conversation::operator=(Conversation&& old) noexcept { + this->_conversation = std::move(old._conversation); + this->_functions = std::move(old._functions); + this->_last_resp_is_fc = old._last_resp_is_fc; + + old._conversation = nlohmann::json::object(); + old._functions = nlohmann::json::object(); + + return *this; +} + +bool liboai::Conversation::ChangeFirstSystemMessage(std::string_view new_data) & noexcept(false) { + if (!new_data.empty() && !this->_conversation["messages"].empty()) { + if (this->_conversation["messages"][0]["role"].get<std::string>() == "system") { + this->_conversation["messages"][0]["content"] = new_data; + return true; // System message changed successfuly + } + return false; // First message is not a system message + } + return false; // New data is empty or conversation is empty +} + +bool liboai::Conversation::SetSystemData(std::string_view data) & noexcept(false) { + // if data provided is non-empty + if (!data.empty()) { + // if system is not set already - only one system message shall exist in any + // conversation + for (auto& message : this->_conversation["messages"].items()) { + if (message.value()["role"].get<std::string>() == "system") { + return false; // system already set + } + } + this->_conversation["messages"].push_back({ { "role", "system" }, {"content", data} }); + return true; // system set successfully + } + return false; // data is empty +} + +bool liboai::Conversation::PopSystemData() & noexcept(false) { + // if conversation is non-empty + if (!this->_conversation["messages"].empty()) { + // if first message is system + if (this->_conversation["messages"][0]["role"].get<std::string>() == "system") { + this->_conversation["messages"].erase(0); + return true; // system message popped successfully + } + return false; // first message is not system + } + return false; // conversation is empty +} + +void liboai::Conversation::EraseExtra() { + if (_conversation["messages"].size() > _max_history_size) { + // Ensure the system message is preserved + auto first_msg = _conversation["messages"].begin(); + if (first_msg != _conversation["messages"].end() && (*first_msg)["role"].get<std::string>() == "system") { + _conversation["messages"].erase(first_msg + 1); + } else { + _conversation["messages"].erase(first_msg); + } + } +} + +bool liboai::Conversation::AddUserData(std::string_view data) & noexcept(false) { + // if data provided is non-empty + if (!data.empty()) { + EraseExtra(); + this->_conversation["messages"].push_back({ { "role", "user" }, {"content", data} }); + return true; // user data added successfully + } + return false; // data is empty +} + +bool liboai::Conversation::AddUserData(std::string_view data, std::string_view name) & noexcept(false) { + // if data provided is non-empty + if (!data.empty()) { + EraseExtra(); + this->_conversation["messages"].push_back( + { + {"role", "user"}, + {"content", data}, + {"name", name} + } + ); + return true; // user data added successfully + } + return false; // data is empty +} + +bool liboai::Conversation::PopUserData() & noexcept(false) { + // if conversation is not empty + if (!this->_conversation["messages"].empty()) { + // if last message is user message + if (this->_conversation["messages"].back()["role"].get<std::string>() == "user") { + this->_conversation["messages"].erase(this->_conversation["messages"].end() - 1); + return true; // user data popped successfully + } + return false; // last message is not user message + } + return false; // conversation is empty +} + +std::string liboai::Conversation::GetLastResponse() const & noexcept { + // if conversation is not empty + if (!this->_conversation["messages"].empty()) { + // if last message is from assistant + if (this->_conversation["messages"].back()["role"].get<std::string>() == "assistant") { + return this->_conversation["messages"].back()["content"].get<std::string>(); + } + } + return ""; // no response found +} + +bool liboai::Conversation::LastResponseIsFunctionCall() const & noexcept { + return this->_last_resp_is_fc; +} + +std::string liboai::Conversation::GetLastFunctionCallName() const & noexcept(false) { + if (this->_conversation.contains("function_call")) { + if (this->_conversation["function_call"].contains("name")) { + return this->_conversation["function_call"]["name"].get<std::string>(); + } + } + + return ""; +} + +std::string liboai::Conversation::GetLastFunctionCallArguments() const & noexcept(false) { + if (this->_conversation.contains("function_call")) { + if (this->_conversation["function_call"].contains("arguments")) { + return this->_conversation["function_call"]["arguments"].get<std::string>(); + } + } + + return ""; +} + +bool liboai::Conversation::PopLastResponse() & noexcept(false) { + // if conversation is not empty + if (!this->_conversation["messages"].empty()) { + // if last message is assistant message + if (this->_conversation["messages"].back()["role"].get<std::string>() == "assistant") { + this->_conversation["messages"].erase(this->_conversation["messages"].end() - 1); + return true; // assistant data popped successfully + } + return false; // last message is not assistant message + } + return false; // conversation is empty +} + +bool liboai::Conversation::Update(std::string_view response) & noexcept(false) { + // reset "last response is function call" flag + if (this->_last_resp_is_fc) { + if (this->_conversation.contains("function_call")) { + this->_conversation.erase("function_call"); + } + this->_last_resp_is_fc = false; + } + + // if response is non-empty + if (!response.empty()) { + nlohmann::json j = nlohmann::json::parse(response); + if (j.contains("choices")) { // top level, several messages + for (auto& choice : j["choices"].items()) { + if (choice.value().contains("message")) { + if (choice.value()["message"].contains("role") && choice.value()["message"].contains("content")) { + if (!choice.value()["message"]["content"].is_null()) { + EraseExtra(); + this->_conversation["messages"].push_back( + { + { "role", choice.value()["message"]["role"] }, + { "content", choice.value()["message"]["content"] } + } + ); + } + else { + EraseExtra(); + this->_conversation["messages"].push_back( + { + { "role", choice.value()["message"]["role"] }, + { "content", "" } + } + ); + } + + if (choice.value()["message"].contains("function_call")) { + // if a function_call is present in the response, the + // conversation is not updated as there is no assistant + // response to be added. However, we do add the function + // information + + this->_conversation["function_call"] = nlohmann::json::object(); + if (choice.value()["message"]["function_call"].contains("name")) { + this->_conversation["function_call"]["name"] = choice.value()["message"]["function_call"]["name"]; + } + if (choice.value()["message"]["function_call"].contains("arguments")) { + this->_conversation["function_call"]["arguments"] = choice.value()["message"]["function_call"]["arguments"]; + } + + this->_last_resp_is_fc = true; + } + + return true; // conversation updated successfully + } + else { + return false; // response is not valid + } + } + else { + return false; // no response found + } + } + } + else if (j.contains("message")) { // mid level, single message + if (j["message"].contains("role") && j["message"].contains("content")) { + if (j["message"]["content"].is_null()) { + EraseExtra(); + this->_conversation["messages"].push_back( + { + { "role", j["message"]["role"] }, + { "content", j["message"]["content"] } + } + ); + } + else { + EraseExtra(); + this->_conversation["messages"].push_back( + { + { "role", j["message"]["role"] }, + { "content", "" } + } + ); + } + + if (j["message"].contains("function_call")) { + // if a function_call is present in the response, the + // conversation is not updated as there is no assistant + // response to be added. However, we do add the function + // information + + this->_conversation["function_call"] = nlohmann::json::object(); + if (j["message"]["function_call"].contains("name")) { + this->_conversation["function_call"]["name"] = j["message"]["function_call"]["name"]; + } + if (j["message"]["function_call"].contains("arguments")) { + this->_conversation["function_call"]["arguments"] = j["message"]["function_call"]["arguments"]; + } + + this->_last_resp_is_fc = true; + } + + return true; // conversation updated successfully + } + else { + return false; // response is not valid + } + } + else if (j.contains("role") && j.contains("content")) { // low level, single message + if (j["message"]["content"].is_null()) { + EraseExtra(); + this->_conversation["messages"].push_back( + { + { "role", j["message"]["role"] }, + { "content", j["message"]["content"] } + } + ); + } + else { + EraseExtra(); + this->_conversation["messages"].push_back( + { + { "role", j["message"]["role"] }, + { "content", "" } + } + ); + } + + if (j["message"].contains("function_call")) { + // if a function_call is present in the response, the + // conversation is not updated as there is no assistant + // response to be added. However, we do add the function + // information + this->_conversation["function_call"] = nlohmann::json::object(); + if (j["message"]["function_call"].contains("name")) { + this->_conversation["function_call"]["name"] = j["message"]["function_call"]["name"]; + } + if (j["message"]["function_call"].contains("arguments")) { + this->_conversation["function_call"]["arguments"] = j["message"]["function_call"]["arguments"]; + } + + this->_last_resp_is_fc = true; + } + + return true; // conversation updated successfully + } + else { + return false; // invalid response + } + } + return false; // response is empty +} + +bool liboai::Conversation::Update(const Response& response) & noexcept(false) { + return this->Update(response.content); +} + +std::string liboai::Conversation::Export() const & noexcept(false) { + nlohmann::json j; + + if (!this->_conversation.empty()) { + j["messages"] = this->_conversation["messages"]; + + if (this->_functions) { + j["functions"] = this->_functions.value()["functions"]; + } + + return j.dump(4); // conversation exported successfully + } + + return ""; // conversation is empty +} + +bool liboai::Conversation::Import(std::string_view json) & noexcept(false) { + if (!json.empty()) { + nlohmann::json j = nlohmann::json::parse(json); + + if (j.contains("messages")) { + this->_conversation["messages"] = j["messages"]; + + if (j.contains("functions")) { + this->_functions = nlohmann::json(); + this->_functions.value()["functions"] = j["functions"]; + } + + return true; // conversation imported successfully + } + + return false; // no messages found + } + + return false; // json is empty +} + +bool liboai::Conversation::AppendStreamData(std::string data) & noexcept(false) { + if (!data.empty()) { + std::string delta; + bool completed = false; + return this->ParseStreamData(data, delta, completed); + } + + return false; // data is empty +} + +bool liboai::Conversation::AppendStreamData(std::string data, std::string& delta, bool& completed) & noexcept(false){ + if (!data.empty()) { + return this->ParseStreamData(data, delta, completed); + } + + return false; +} + + +bool liboai::Conversation::SetFunctions(Functions functions) & noexcept(false) { + nlohmann::json j = functions.GetJSON(); + + if (!j.empty() && j.contains("functions") && j["functions"].size() > 0) { + this->_functions = std::move(j); + return true; // functions set successfully + } + + return false; // functions are empty +} + +void liboai::Conversation::PopFunctions() & noexcept(false) { + this->_functions = std::nullopt; +} + +std::string liboai::Conversation::GetRawConversation() const & noexcept { + return this->_conversation.dump(4); +} + +const nlohmann::json& liboai::Conversation::GetJSON() const & noexcept { + return this->_conversation; +} + +std::string liboai::Conversation::GetRawFunctions() const & noexcept { + return this->HasFunctions() ? this->_functions.value().dump(4) : ""; +} + +const nlohmann::json& liboai::Conversation::GetFunctionsJSON() const & noexcept { + return this->_functions.value(); +} + +std::vector<std::string> liboai::Conversation::SplitStreamedData(std::string data) const noexcept(false) { + // remove all instances of the string "data: " from the string + this->RemoveStrings(data, "data: "); + + /* + Splits the streamed data into a vector of strings + via delimiter of two newlines. + + For instance, a string of "Hello\n\nWorld" would + be split into a vector of {"Hello", "World"}, and + a string of "Hello World" would be split into + a vector of {"Hello World"}. + */ + if (!data.empty()) { + std::vector<std::string> split_data; + std::string temp; + std::istringstream iss(data); + while (std::getline(iss, temp)) { + if (temp.empty()) { + split_data.push_back(temp); + } + else { + split_data.push_back(temp); + } + } + + // remove empty strings from the vector + split_data.erase(std::remove_if(split_data.begin(), split_data.end(), [](const std::string& s) { return s.empty(); }), split_data.end()); + + return split_data; + } + + return {}; +} + +void liboai::Conversation::RemoveStrings(std::string& s, std::string_view p) const noexcept(false) { + std::string::size_type i = s.find(p); + while (i != std::string::npos) { + s.erase(i, p.length()); + i = s.find(p, i); + } +} + +std::vector<std::string> liboai::Conversation::SplitFullStreamedData(std::string data) const noexcept(false) { + if (data.empty()) { + return {}; + } + + std::vector<std::string> split_data; + std::string temp; + std::istringstream iss(data); + while (std::getline(iss, temp)) { + if (temp.empty()) { + split_data.push_back(temp); + } + else { + split_data.push_back(temp); + } + } + + // remove empty strings from the vector + split_data.erase(std::remove_if(split_data.begin(), split_data.end(), [](const std::string& s) { return s.empty(); }), split_data.end()); + + return split_data; +} + +bool liboai::Conversation::ParseStreamData(std::string data, std::string& delta_content, bool& completed){ + if (!_last_incomplete_buffer.empty()) { + data = _last_incomplete_buffer + data; + _last_incomplete_buffer.clear(); + } + + std::vector<std::string> data_lines = SplitFullStreamedData(data); + + if (data_lines.empty()){ + return false; + } + + // create an empty message at the end of the conversation, + // marked as "pending" to indicate that the response is + // still being processed. This flag will be removed once + // the response is processed. If the marking already + // exists, keep appending to the same message. + if (this->_conversation["messages"].empty() || !this->_conversation["messages"].back().contains("pending")) { + this->_conversation["messages"].push_back( + { + { "role", "" }, + { "content", "" }, + { "pending", true } + } + ); + } + + for (auto& line : data_lines){ + if (line.find("data: [DONE]") == std::string::npos) { + /* + j should have content in the form of: + {"id":"chatcmpl-7SKOck29emvbBbDS6cHg5xwnRrsLO","object":"chat.completion.chunk","created":1686985942,"model":"gpt-3.5-turbo-0613","choices":[{"index":0,"delta":{"content":"."},"finish_reason":null}]} + where "delta" may be empty + */ + this->RemoveStrings(line, "data: "); + + nlohmann::json j; + try { + j = nlohmann::json::parse(line); + } catch (const std::exception& e) { + _last_incomplete_buffer = line; + continue; + } + + if (j.contains("choices")) { + if (j["choices"][0].contains("delta")) { + if (!j["choices"][0]["delta"].empty() && !j["choices"][0]["delta"].is_null()) { + if (j["choices"][0]["delta"].contains("role")) { + this->_conversation["messages"].back()["role"] = j["choices"][0]["delta"]["role"]; + } + + if (j["choices"][0]["delta"].contains("content")) { + if (!j["choices"][0]["delta"]["content"].empty() && !j["choices"][0]["delta"]["content"].is_null()) { + std::string stream_content = j["choices"][0]["delta"]["content"].get<std::string>(); + this->_conversation["messages"].back()["content"] = this->_conversation["messages"].back()["content"].get<std::string>() + stream_content; + delta_content += stream_content; + } + + // function calls do not have a content field, + // set _last_resp_is_fc to false and remove any + // previously set function_call field in the + // conversation + if (this->_last_resp_is_fc) { + if (this->_conversation.contains("function_call")) { + this->_conversation.erase("function_call"); + } + this->_last_resp_is_fc = false; + } + } + + if (j["choices"][0]["delta"].contains("function_call")) { + if (!j["choices"][0]["delta"]["function_call"].empty() && !j["choices"][0]["delta"]["function_call"].is_null()) { + if (j["choices"][0]["delta"]["function_call"].contains("name")) { + if (!j["choices"][0]["delta"]["function_call"]["name"].empty() && !j["choices"][0]["delta"]["function_call"]["name"].is_null()) { + if (!this->_conversation["messages"].back().contains("function_call")) { + this->_conversation["function_call"] = { { "name", j["choices"][0]["delta"]["function_call"]["name"] } }; + this->_last_resp_is_fc = true; + } + } + } + else if (j["choices"][0]["delta"]["function_call"].contains("arguments")) { + if (!j["choices"][0]["delta"]["function_call"]["arguments"].empty() && !j["choices"][0]["delta"]["function_call"]["arguments"].is_null()) { + if (!this->_conversation["function_call"].contains("arguments")) { + this->_conversation["function_call"].push_back({ "arguments", j["choices"][0]["delta"]["function_call"]["arguments"] }); + } + else { + this->_conversation["function_call"]["arguments"] = this->_conversation["function_call"]["arguments"].get<std::string>() + j["choices"][0]["delta"]["function_call"]["arguments"].get<std::string>(); + } + } + } + } + } + } + } + } else { + return false; // no "choices" found - invalid + } + } else { + // the response is complete, erase the "pending" flag + this->_conversation["messages"].back().erase("pending"); + completed = true; + } + } + + return true; // last message received +} + + + +liboai::Response liboai::ChatCompletion::create(const std::string& model, Conversation& conversation, std::optional<std::string> function_call, std::optional<float> temperature, std::optional<float> top_p, std::optional<uint16_t> n, std::optional<ChatStreamCallback> stream, std::optional<std::vector<std::string>> stop, std::optional<uint16_t> max_tokens, std::optional<float> presence_penalty, std::optional<float> frequency_penalty, std::optional<std::unordered_map<std::string, int8_t>> logit_bias, std::optional<std::string> user, std::optional<nlohmann::json> response_format) const& noexcept(false) { + liboai::JsonConstructor jcon; + jcon.push_back("model", model); + jcon.push_back("temperature", std::move(temperature)); + jcon.push_back("top_p", std::move(top_p)); + jcon.push_back("n", std::move(n)); + jcon.push_back("stop", std::move(stop)); + jcon.push_back("max_tokens", std::move(max_tokens)); + jcon.push_back("presence_penalty", std::move(presence_penalty)); + jcon.push_back("frequency_penalty", std::move(frequency_penalty)); + jcon.push_back("logit_bias", std::move(logit_bias)); + jcon.push_back("user", std::move(user)); + if (response_format) { + jcon.push_back("response_format", std::move(response_format.value())); + } + + if (function_call) { + if (function_call.value() == "none" || function_call.value() == "auto") { + nlohmann::json j; j["function_call"] = function_call.value(); + jcon.push_back("function_call", j["function_call"]); + } + else { + nlohmann::json j; j["function_call"] = { {"name", function_call.value()} }; + jcon.push_back("function_call", j["function_call"]); + } + } + + StrippedStreamCallback _sscb = nullptr; + if (stream) { + _sscb = [stream, &conversation](std::string data, intptr_t userdata) -> bool { + ChatStreamCallback _stream = stream.value(); + return _stream(data, userdata, conversation); + }; + + jcon.push_back("stream", _sscb); + } + + if (conversation.GetJSON().contains("messages")) { + jcon.push_back("messages", conversation.GetJSON()["messages"]); + } + + if (conversation.HasFunctions()) { + jcon.push_back("functions", conversation.GetFunctionsJSON()["functions"]); + } + + Response res; + res = this->Request( + Method::HTTP_POST, this->openai_root_, "/chat/completions", "application/json", + this->auth_.GetAuthorizationHeaders(), + netimpl::components::Body { + jcon.dump() + }, + _sscb ? netimpl::components::WriteCallback{std::move(_sscb)} : netimpl::components::WriteCallback{}, + this->auth_.GetProxies(), + this->auth_.GetProxyAuth(), + this->auth_.GetMaxTimeout() + ); + + return res; +} + +liboai::FutureResponse liboai::ChatCompletion::create_async(const std::string& model, Conversation& conversation, std::optional<std::string> function_call, std::optional<float> temperature, std::optional<float> top_p, std::optional<uint16_t> n, std::optional<ChatStreamCallback> stream, std::optional<std::vector<std::string>> stop, std::optional<uint16_t> max_tokens, std::optional<float> presence_penalty, std::optional<float> frequency_penalty, std::optional<std::unordered_map<std::string, int8_t>> logit_bias, std::optional<std::string> user, std::optional<nlohmann::json> response_format) const& noexcept(false) { + return std::async(std::launch::async, &liboai::ChatCompletion::create, this, model, std::ref(conversation), function_call, temperature, top_p, n, stream, stop, max_tokens, presence_penalty, frequency_penalty, logit_bias, user, response_format); +} + +namespace liboai { + +std::ostream& operator<<(std::ostream& os, const Conversation& conv) { + os << conv.GetRawConversation() << std::endl << (conv.HasFunctions() ? conv.GetRawFunctions() : ""); + + return os; +} + +} + +liboai::Functions::Functions() { + this->_functions["functions"] = nlohmann::json::array(); +} + +liboai::Functions::Functions(const Functions& other) { + this->_functions = other._functions; +} + +liboai::Functions::Functions(Functions&& old) noexcept { + this->_functions = std::move(old._functions); + old._functions = nlohmann::json::object(); +} + +liboai::Functions& liboai::Functions::operator=(const Functions& other) { + this->_functions = other._functions; + return *this; +} + +liboai::Functions& liboai::Functions::operator=(Functions&& old) noexcept { + this->_functions = std::move(old._functions); + old._functions = nlohmann::json::object(); + return *this; +} + +bool liboai::Functions::AddFunction(std::string_view function_name) & noexcept(false) { + if (this->GetFunctionIndex(function_name) == -1) { + this->_functions["functions"].push_back({ {"name", function_name} }); + return true; // function added + } + return false; // function already exists +} + +bool liboai::Functions::AddFunctions(std::initializer_list<std::string_view> function_names) & noexcept(false) { + if (function_names.size() > 0) { + for (auto& function_name : function_names) { + if (this->GetFunctionIndex(function_name) == -1) { + this->_functions["functions"].push_back({ {"name", function_name} }); + } + } + return true; // functions added + } + return false; // functions not added (size 0) +} + +bool liboai::Functions::AddFunctions(std::vector<std::string> function_names) & noexcept(false) { + if (function_names.size() > 0) { + for (auto& function_name : function_names) { + if (this->GetFunctionIndex(function_name) == -1) { + this->_functions["functions"].push_back({ {"name", std::move(function_name)} }); + } + } + return true; // functions added + } + return false; // functions not added (size 0) +} + +bool liboai::Functions::PopFunction(std::string_view function_name) & noexcept(false) { + auto index = this->GetFunctionIndex(function_name); + + if (index != -1) { + this->_functions["functions"].erase(this->_functions["functions"].begin() + index); + return true; // function removed + } + + return false; // function not removed +} + +bool liboai::Functions::PopFunctions(std::initializer_list<std::string_view> function_names) & noexcept(false) { + if (function_names.size() > 0) { + for (auto& function_name : function_names) { + auto index = this->GetFunctionIndex(function_name); + + if (index != -1) { + this->_functions["functions"].erase(this->_functions["functions"].begin() + index); + } + } + + return true; // functions removed + } + + return false; // functions not removed (size 0) +} + +bool liboai::Functions::PopFunctions(std::vector<std::string> function_names) & noexcept(false) { + if (function_names.size() > 0) { + for (auto& function_name : function_names) { + auto index = this->GetFunctionIndex(function_name); + + if (index != -1) { + this->_functions["functions"].erase(this->_functions["functions"].begin() + index); + } + } + return true; // functions removed + } + + return false; // functions not removed (size 0) +} + +bool liboai::Functions::SetDescription(std::string_view target, std::string_view description) & noexcept(false) { + index i = this->GetFunctionIndex(target); + + if (i != -1) { + if (!this->_functions["functions"][i].contains("description")) { + this->_functions["functions"][i]["description"] = description; + return true; // description set successfully + } + return false; // already has a description + } + + return false; // function does not exist +} + +bool liboai::Functions::PopDescription(std::string_view target) & noexcept(false) { + index i = this->GetFunctionIndex(target); + + if (i != -1) { + if (this->_functions["functions"][i].contains("description")) { + this->_functions["functions"][i].erase("description"); + return true; // description removed successfully + } + return false; // does not have a description + } + + return false; // function does not exist +} + +bool liboai::Functions::SetRequired(std::string_view target, std::initializer_list<std::string_view> params) & noexcept(false) { + index i = this->GetFunctionIndex(target); + + if (i != -1 && params.size() > 0) { + if (this->_functions["functions"][i].contains("parameters")) { + for (auto& parameter : params) { + this->_functions["functions"][i]["parameters"]["required"] = std::move(params); + return true; // required parameters set successfully + } + } + } + + return false; // required parameters not set +} + +bool liboai::Functions::SetRequired(std::string_view target, std::vector<std::string> params) & noexcept(false) { + index i = this->GetFunctionIndex(target); + + if (i != -1 && params.size() > 0) { + if (this->_functions["functions"][i].contains("parameters")) { + for (auto& parameter : params) { + this->_functions["functions"][i]["parameters"]["required"] = std::move(params); + return true; // required parameters set successfully + } + } + } + + return false; // required parameters not set +} + +bool liboai::Functions::PopRequired(std::string_view target) & noexcept(false) { + index i = this->GetFunctionIndex(target); + + if (i != -1) { + if (this->_functions["functions"][i].contains("parameters")) { + if (this->_functions["functions"][i]["parameters"].contains("required")) { + this->_functions["functions"][i]["parameters"].erase("required"); + return true; // required parameters removed successfully + } + } + } + + return false; // required parameters not removed +} + +bool liboai::Functions::AppendRequired(std::string_view target, std::string_view param) & noexcept(false) { + index i = this->GetFunctionIndex(target); + + if (i != -1) { + if (this->_functions["functions"][i].contains("parameters")) { + if (this->_functions["functions"][i]["parameters"].contains("required")) { + this->_functions["functions"][i]["parameters"]["required"].push_back(param); + return true; // required parameter appended successfully + } + } + } + + return false; // required parameter not appended +} + +bool liboai::Functions::AppendRequired(std::string_view target, std::initializer_list<std::string_view> params) & noexcept(false) { + index i = this->GetFunctionIndex(target); + + if (i != -1 && params.size() > 0) { + if (this->_functions["functions"][i].contains("parameters")) { + if (this->_functions["functions"][i]["parameters"].contains("required")) { + for (auto& param : params) { + this->_functions["functions"][i]["parameters"]["required"].push_back(param); + } + + return true; // required parameters appended successfully + } + } + } + + return false; // required parameters not appended +} + +bool liboai::Functions::AppendRequired(std::string_view target, std::vector<std::string> params) & noexcept(false) { + index i = this->GetFunctionIndex(target); + + if (i != -1 && params.size() > 0) { + if (this->_functions["functions"][i].contains("parameters")) { + if (this->_functions["functions"][i]["parameters"].contains("required")) { + for (auto& param : params) { + this->_functions["functions"][i]["parameters"]["required"].push_back(std::move(param)); + } + + return true; // required parameters appended successfully + } + } + } + + return false; // required parameters not appended +} + +bool liboai::Functions::SetParameter(std::string_view target, FunctionParameter parameter) & noexcept(false) { + index i = this->GetFunctionIndex(target); + + if (i != -1) { + if (!this->_functions["functions"][i].contains("parameters")) { + this->_functions["functions"][i]["parameters"] = nlohmann::json::object(); + this->_functions["functions"][i]["parameters"]["properties"] = nlohmann::json::object(); + this->_functions["functions"][i]["parameters"]["type"] = "object"; + + this->_functions["functions"][i]["parameters"]["properties"].push_back( + { parameter.name, { + { "type", std::move(parameter.type) }, + { "description", std::move(parameter.description) } + }} + ); + + if (parameter.enumeration) { + this->_functions["functions"][i]["parameters"]["properties"][parameter.name]["enum"] = std::move(parameter.enumeration.value()); + } + + return true; // parameter set successfully + } + } + + return false; // function non-existent, or parameters already set (use AppendParameter(s)) +} + +bool liboai::Functions::SetParameters(std::string_view target, std::initializer_list<FunctionParameter> parameters) & noexcept(false) { + index i = this->GetFunctionIndex(target); + + if (i != -1) { + if (!this->_functions["functions"][i].contains("parameters") && parameters.size() > 0) { + this->_functions["functions"][i]["parameters"] = nlohmann::json::object(); + this->_functions["functions"][i]["parameters"]["properties"] = nlohmann::json::object(); + this->_functions["functions"][i]["parameters"]["type"] = "object"; + + for (auto& parameter : parameters) { + if (!this->_functions["functions"][i]["parameters"]["properties"].contains(parameter.name)) { + this->_functions["functions"][i]["parameters"]["properties"].push_back( + { parameter.name, { + { "type", parameter.type }, + { "description", parameter.description } + } } + ); + + if (parameter.enumeration) { + this->_functions["functions"][i]["parameters"]["properties"][parameter.name]["enum"] = parameter.enumeration.value(); + } + } + } + + return true; // parameter set successfully + } + } + + return false; // function non-existent, or parameters already set (use AppendParameter(s)) +} + +bool liboai::Functions::SetParameters(std::string_view target, std::vector<FunctionParameter> parameters) & noexcept(false) { + index i = this->GetFunctionIndex(target); + + if (i != -1) { + if (!this->_functions["functions"][i].contains("parameters") && parameters.size() > 0) { + this->_functions["functions"][i]["parameters"] = nlohmann::json::object(); + this->_functions["functions"][i]["parameters"]["properties"] = nlohmann::json::object(); + this->_functions["functions"][i]["parameters"]["type"] = "object"; + + for (auto& parameter : parameters) { + if (!this->_functions["functions"][i]["parameters"]["properties"].contains(parameter.name)) { + this->_functions["functions"][i]["parameters"]["properties"].push_back( + { parameter.name, { + { "type", std::move(parameter.type) }, + { "description", std::move(parameter.description) } + } } + ); + + if (parameter.enumeration) { + this->_functions["functions"][i]["parameters"]["properties"][parameter.name]["enum"] = std::move(parameter.enumeration.value()); + } + } + } + + return true; // parameter set successfully + } + } + + return false; // function non-existent, or parameters already set (use AppendParameter(s)) +} + +bool liboai::Functions::PopParameters(std::string_view target) & noexcept(false) { + index i = this->GetFunctionIndex(target); + + if (i != -1) { + if (this->_functions["functions"][i].contains("parameters")) { + this->_functions["functions"][i].erase("parameters"); + return true; // parameters removed successfully + } + } + + return false; // parameters not removed +} + +bool liboai::Functions::PopParameters(std::string_view target, std::initializer_list<std::string_view> param_names) & noexcept(false) { + index i = this->GetFunctionIndex(target); + + if (i != -1) { + if (this->_functions["functions"][i].contains("parameters")) { + for (auto& param_name : param_names) { + if (this->_functions["functions"][i]["parameters"]["properties"].contains(param_name)) { + this->_functions["functions"][i]["parameters"]["properties"].erase(param_name); + } + } + + return true; // parameters removed successfully + } + } + + return false; // parameters not removed +} + +bool liboai::Functions::PopParameters(std::string_view target, std::vector<std::string> param_names) & noexcept(false) { + index i = this->GetFunctionIndex(target); + + if (i != -1) { + if (this->_functions["functions"][i].contains("parameters")) { + for (auto& param_name : param_names) { + if (this->_functions["functions"][i]["parameters"]["properties"].contains(param_name)) { + this->_functions["functions"][i]["parameters"]["properties"].erase(param_name); + } + } + + return true; // parameters removed successfully + } + } + + return false; // parameters not removed +} + +bool liboai::Functions::AppendParameter(std::string_view target, FunctionParameter parameter) & noexcept(false) { + index i = this->GetFunctionIndex(target); + + if (i != -1) { + if (this->_functions["functions"][i].contains("parameters")) { + if (!this->_functions["functions"][i]["parameters"]["properties"].contains(parameter.name)) { + this->_functions["functions"][i]["parameters"]["properties"].push_back( + { parameter.name, { + { "type", std::move(parameter.type) }, + { "description", std::move(parameter.description) } + }} + ); + + if (parameter.enumeration) { + this->_functions["functions"][i]["parameters"]["properties"][parameter.name]["enum"] = std::move(parameter.enumeration.value()); + } + + return true; // parameter appended successfully + } + } + } + + return false; // parameter not appended +} + +bool liboai::Functions::AppendParameters(std::string_view target, std::initializer_list<FunctionParameter> parameters) & noexcept(false) { + index i = this->GetFunctionIndex(target); + + if (i != -1) { + if (this->_functions["functions"][i].contains("parameters")) { + for (auto& parameter : parameters) { + if (!this->_functions["functions"][i]["parameters"]["properties"].contains(parameter.name)) { + this->_functions["functions"][i]["parameters"]["properties"].push_back( + { parameter.name, { + { "type", parameter.type }, + { "description", parameter.description } + } } + ); + + if (parameter.enumeration) { + this->_functions["functions"][i]["parameters"]["properties"][parameter.name]["enum"] = parameter.enumeration.value(); + } + } + } + + return true; // parameters appended successfully + } + } + + return false; // parameters not appended +} + +bool liboai::Functions::AppendParameters(std::string_view target, std::vector<FunctionParameter> parameters) & noexcept(false) { + index i = this->GetFunctionIndex(target); + + if (i != -1) { + if (this->_functions["functions"][i].contains("parameters")) { + for (auto& parameter : parameters) { + if (!this->_functions["functions"][i]["parameters"]["properties"].contains(parameter.name)) { + this->_functions["functions"][i]["parameters"]["properties"].push_back( + { parameter.name, { + { "type", std::move(parameter.type) }, + { "description", std::move(parameter.description) } + } } + ); + + if (parameter.enumeration) { + this->_functions["functions"][i]["parameters"]["properties"][parameter.name]["enum"] = std::move(parameter.enumeration.value()); + } + } + } + + return true; // parameters appended successfully + } + } + + return false; // parameters not appended +} + +const nlohmann::json& liboai::Functions::GetJSON() const & noexcept { + return this->_functions; +} + +liboai::Functions::index liboai::Functions::GetFunctionIndex(std::string_view function_name) const & noexcept(false) { + index i = 0; + + if (!this->_functions.empty()) { + for (auto& [key, value] : this->_functions["functions"].items()) { + if (value.contains("name")) { + if (value["name"].get<std::string>() == function_name) { + return i; + } + } + i++; + } + } + + return -1; +} diff --git a/packages/media/cpp/packages/liboai/liboai/components/completions.cpp b/packages/media/cpp/packages/liboai/liboai/components/completions.cpp new file mode 100644 index 00000000..16951ab4 --- /dev/null +++ b/packages/media/cpp/packages/liboai/liboai/components/completions.cpp @@ -0,0 +1,40 @@ +#include "../include/components/completions.h" + +liboai::Response liboai::Completions::create(const std::string& model_id, std::optional<std::string> prompt, std::optional<std::string> suffix, std::optional<uint16_t> max_tokens, std::optional<float> temperature, std::optional<float> top_p, std::optional<uint16_t> n, std::optional<std::function<bool(std::string, intptr_t)>> stream, std::optional<uint8_t> logprobs, std::optional<bool> echo, std::optional<std::vector<std::string>> stop, std::optional<float> presence_penalty, std::optional<float> frequency_penalty, std::optional<uint16_t> best_of, std::optional<std::unordered_map<std::string, int8_t>> logit_bias, std::optional<std::string> user) const & noexcept(false) { + liboai::JsonConstructor jcon; + jcon.push_back("model", model_id); + jcon.push_back("prompt", std::move(prompt)); + jcon.push_back("suffix", std::move(suffix)); + jcon.push_back("max_tokens", std::move(max_tokens)); + jcon.push_back("temperature", std::move(temperature)); + jcon.push_back("top_p", std::move(top_p)); + jcon.push_back("n", std::move(n)); + jcon.push_back("stream", stream); + jcon.push_back("logprobs", std::move(logprobs)); + jcon.push_back("echo", std::move(echo)); + jcon.push_back("stop", std::move(stop)); + jcon.push_back("presence_penalty", std::move(presence_penalty)); + jcon.push_back("frequency_penalty", std::move(frequency_penalty)); + jcon.push_back("best_of", std::move(best_of)); + jcon.push_back("logit_bias", std::move(logit_bias)); + jcon.push_back("user", std::move(user)); + + Response res; + res = this->Request( + Method::HTTP_POST, this->openai_root_, "/completions", "application/json", + this->auth_.GetAuthorizationHeaders(), + netimpl::components::Body { + jcon.dump() + }, + stream ? netimpl::components::WriteCallback{std::move(stream.value())} : netimpl::components::WriteCallback{}, + this->auth_.GetProxies(), + this->auth_.GetProxyAuth(), + this->auth_.GetMaxTimeout() + ); + + return res; +} + +liboai::FutureResponse liboai::Completions::create_async(const std::string& model_id, std::optional<std::string> prompt, std::optional<std::string> suffix, std::optional<uint16_t> max_tokens, std::optional<float> temperature, std::optional<float> top_p, std::optional<uint16_t> n, std::optional<std::function<bool(std::string, intptr_t)>> stream, std::optional<uint8_t> logprobs, std::optional<bool> echo, std::optional<std::vector<std::string>> stop, std::optional<float> presence_penalty, std::optional<float> frequency_penalty, std::optional<uint16_t> best_of, std::optional<std::unordered_map<std::string, int8_t>> logit_bias, std::optional<std::string> user) const & noexcept(false) { + return std::async(std::launch::async, &liboai::Completions::create, this, model_id, prompt, suffix, max_tokens, temperature, top_p, n, stream, logprobs, echo, stop, presence_penalty, frequency_penalty, best_of, logit_bias, user); +} diff --git a/packages/media/cpp/packages/liboai/liboai/components/edits.cpp b/packages/media/cpp/packages/liboai/liboai/components/edits.cpp new file mode 100644 index 00000000..7e851d5f --- /dev/null +++ b/packages/media/cpp/packages/liboai/liboai/components/edits.cpp @@ -0,0 +1,29 @@ +#include "../include/components/edits.h" + +liboai::Response liboai::Edits::create(const std::string& model_id, std::optional<std::string> input, std::optional<std::string> instruction, std::optional<uint16_t> n, std::optional<float> temperature, std::optional<float> top_p) const & noexcept(false) { + liboai::JsonConstructor jcon; + jcon.push_back("model", model_id); + jcon.push_back("input", std::move(input)); + jcon.push_back("instruction", std::move(instruction)); + jcon.push_back("n", std::move(n)); + jcon.push_back("temperature", std::move(temperature)); + jcon.push_back("top_p", std::move(top_p)); + + Response res; + res = this->Request( + Method::HTTP_POST, this->openai_root_, "/edits", "application/json", + this->auth_.GetAuthorizationHeaders(), + netimpl::components::Body { + jcon.dump() + }, + this->auth_.GetProxies(), + this->auth_.GetProxyAuth(), + this->auth_.GetMaxTimeout() + ); + + return res; +} + +liboai::FutureResponse liboai::Edits::create_async(const std::string& model_id, std::optional<std::string> input, std::optional<std::string> instruction, std::optional<uint16_t> n, std::optional<float> temperature, std::optional<float> top_p) const & noexcept(false) { + return std::async(std::launch::async, &liboai::Edits::create, this, model_id, input, instruction, n, temperature, top_p); +} diff --git a/packages/media/cpp/packages/liboai/liboai/components/embeddings.cpp b/packages/media/cpp/packages/liboai/liboai/components/embeddings.cpp new file mode 100644 index 00000000..c453d679 --- /dev/null +++ b/packages/media/cpp/packages/liboai/liboai/components/embeddings.cpp @@ -0,0 +1,26 @@ +#include "../include/components/embeddings.h" + +liboai::Response liboai::Embeddings::create(const std::string& model_id, std::optional<std::string> input, std::optional<std::string> user) const & noexcept(false) { + liboai::JsonConstructor jcon; + jcon.push_back("model", model_id); + jcon.push_back("input", std::move(input)); + jcon.push_back("user", std::move(user)); + + Response res; + res = this->Request( + Method::HTTP_POST, this->openai_root_, "/embeddings", "application/json", + this->auth_.GetAuthorizationHeaders(), + netimpl::components::Body { + jcon.dump() + }, + this->auth_.GetProxies(), + this->auth_.GetProxyAuth(), + this->auth_.GetMaxTimeout() + ); + + return res; +} + +liboai::FutureResponse liboai::Embeddings::create_async(const std::string& model_id, std::optional<std::string> input, std::optional<std::string> user) const & noexcept(false) { + return std::async(std::launch::async, &liboai::Embeddings::create, this, model_id, input, user); +} diff --git a/packages/media/cpp/packages/liboai/liboai/components/files.cpp b/packages/media/cpp/packages/liboai/liboai/components/files.cpp new file mode 100644 index 00000000..c9196ef9 --- /dev/null +++ b/packages/media/cpp/packages/liboai/liboai/components/files.cpp @@ -0,0 +1,95 @@ +#include "../include/components/files.h" + +liboai::Response liboai::Files::list() const & noexcept(false) { + Response res; + res = this->Request( + Method::HTTP_GET, this->openai_root_, "/files", "application/json", + this->auth_.GetAuthorizationHeaders(), + this->auth_.GetProxies(), + this->auth_.GetProxyAuth(), + this->auth_.GetMaxTimeout() + ); + + return res; +} + +liboai::FutureResponse liboai::Files::list_async() const & noexcept(false) { + return std::async(std::launch::async, &liboai::Files::list, this); +} + +liboai::Response liboai::Files::create(const std::filesystem::path& file, const std::string& purpose) const & noexcept(false) { + if (!this->Validate(file)) { + throw liboai::exception::OpenAIException( + "File path provided is non-existent, is not a file, or is empty.", + liboai::exception::EType::E_FILEERROR, + "liboai::Files::create(...)" + ); + } + + netimpl::components::Multipart form = { + { "purpose", purpose }, + { "file", netimpl::components::File{file.generic_string()} } + }; + + Response res; + res = this->Request( + Method::HTTP_POST, this->openai_root_, "/files", "multipart/form-data", + this->auth_.GetAuthorizationHeaders(), + std::move(form), + this->auth_.GetProxies(), + this->auth_.GetProxyAuth(), + this->auth_.GetMaxTimeout() + ); + + return res; +} + +liboai::FutureResponse liboai::Files::create_async(const std::filesystem::path& file, const std::string& purpose) const & noexcept(false) { + return std::async(std::launch::async, &liboai::Files::create, this, file, purpose); +} + +liboai::Response liboai::Files::remove(const std::string& file_id) const & noexcept(false) { + Response res; + res = this->Request( + Method::HTTP_DELETE, this->openai_root_, "/files/" + file_id, "application/json", + this->auth_.GetAuthorizationHeaders(), + this->auth_.GetProxies(), + this->auth_.GetProxyAuth(), + this->auth_.GetMaxTimeout() + ); + + return res; +} + +liboai::FutureResponse liboai::Files::remove_async(const std::string& file_id) const & noexcept(false) { + return std::async(std::launch::async, &liboai::Files::remove, this, file_id); +} + +liboai::Response liboai::Files::retrieve(const std::string& file_id) const & { + Response res; + res = this->Request( + Method::HTTP_GET, this->openai_root_, "/files/" + file_id, "application/json", + this->auth_.GetAuthorizationHeaders(), + this->auth_.GetProxies(), + this->auth_.GetProxyAuth(), + this->auth_.GetMaxTimeout() + ); + + return res; +} + +liboai::FutureResponse liboai::Files::retrieve_async(const std::string& file_id) const & noexcept(false) { + return std::async(std::launch::async, &liboai::Files::retrieve, this, file_id); +} + +bool liboai::Files::download(const std::string& file_id, const std::string& save_to) const & noexcept(false) { + return Network::Download( + save_to, + ("https://api.openai.com/v1/files/" + file_id + "/content"), + this->auth_.GetAuthorizationHeaders() + ); +} + +std::future<bool> liboai::Files::download_async(const std::string& file_id, const std::string& save_to) const & noexcept(false) { + return std::async(std::launch::async, &liboai::Files::download, this, file_id, save_to); +} diff --git a/packages/media/cpp/packages/liboai/liboai/components/fine_tunes.cpp b/packages/media/cpp/packages/liboai/liboai/components/fine_tunes.cpp new file mode 100644 index 00000000..475ae9d5 --- /dev/null +++ b/packages/media/cpp/packages/liboai/liboai/components/fine_tunes.cpp @@ -0,0 +1,125 @@ +#include "../include/components/fine_tunes.h" + +liboai::Response liboai::FineTunes::create(const std::string& training_file, std::optional<std::string> validation_file, std::optional<std::string> model_id, std::optional<uint8_t> n_epochs, std::optional<uint16_t> batch_size, std::optional<float> learning_rate_multiplier, std::optional<float> prompt_loss_weight, std::optional<bool> compute_classification_metrics, std::optional<uint16_t> classification_n_classes, std::optional<std::string> classification_positive_class, std::optional<std::vector<float>> classification_betas, std::optional<std::string> suffix) const & noexcept(false) { + liboai::JsonConstructor jcon; + jcon.push_back("training_file", training_file); + jcon.push_back("validation_file", std::move(validation_file)); + jcon.push_back("model_id", std::move(model_id)); + jcon.push_back("n_epochs", std::move(n_epochs)); + jcon.push_back("batch_size", std::move(batch_size)); + jcon.push_back("learning_rate_multiplier", std::move(learning_rate_multiplier)); + jcon.push_back("prompt_loss_weight", std::move(prompt_loss_weight)); + jcon.push_back("compute_classification_metrics", std::move(compute_classification_metrics)); + jcon.push_back("classification_n_classes", std::move(classification_n_classes)); + jcon.push_back("classification_positive_class", std::move(classification_positive_class)); + jcon.push_back("classification_betas", std::move(classification_betas)); + jcon.push_back("suffix", std::move(suffix)); + + Response res; + res = this->Request( + Method::HTTP_POST, this->openai_root_, "/fine-tunes", "application/json", + this->auth_.GetAuthorizationHeaders(), + netimpl::components::Body { + jcon.dump() + }, + this->auth_.GetProxies(), + this->auth_.GetProxyAuth(), + this->auth_.GetMaxTimeout() + ); + + return res; +} + +liboai::FutureResponse liboai::FineTunes::create_async(const std::string& training_file, std::optional<std::string> validation_file, std::optional<std::string> model_id, std::optional<uint8_t> n_epochs, std::optional<uint16_t> batch_size, std::optional<float> learning_rate_multiplier, std::optional<float> prompt_loss_weight, std::optional<bool> compute_classification_metrics, std::optional<uint16_t> classification_n_classes, std::optional<std::string> classification_positive_class, std::optional<std::vector<float>> classification_betas, std::optional<std::string> suffix) const & noexcept(false) { + return std::async(std::launch::async, &liboai::FineTunes::create, this, training_file, validation_file, model_id, n_epochs, batch_size, learning_rate_multiplier, prompt_loss_weight, compute_classification_metrics, classification_n_classes, classification_positive_class, classification_betas, suffix); +} + +liboai::Response liboai::FineTunes::list() const& { + Response res; + res = this->Request( + Method::HTTP_GET, this->openai_root_, "/fine-tunes", "application/json", + this->auth_.GetAuthorizationHeaders(), + this->auth_.GetProxies(), + this->auth_.GetProxyAuth(), + this->auth_.GetMaxTimeout() + ); + + return res; +} + +liboai::FutureResponse liboai::FineTunes::list_async() const & noexcept(false) { + return std::async(std::launch::async, &liboai::FineTunes::list, this); +} + +liboai::Response liboai::FineTunes::retrieve(const std::string& fine_tune_id) const& { + Response res; + res = this->Request( + Method::HTTP_GET, this->openai_root_, "/fine-tunes/" + fine_tune_id, "application/json", + this->auth_.GetAuthorizationHeaders(), + this->auth_.GetProxies(), + this->auth_.GetProxyAuth(), + this->auth_.GetMaxTimeout() + ); + + return res; +} + +liboai::FutureResponse liboai::FineTunes::retrieve_async(const std::string& fine_tune_id) const & noexcept(false) { + return std::async(std::launch::async, &liboai::FineTunes::retrieve, this, fine_tune_id); +} + +liboai::Response liboai::FineTunes::cancel(const std::string& fine_tune_id) const& { + Response res; + res = this->Request( + Method::HTTP_POST, this->openai_root_, "/fine-tunes/" + fine_tune_id + "/cancel", "application/json", + this->auth_.GetAuthorizationHeaders(), + this->auth_.GetProxies(), + this->auth_.GetProxyAuth(), + this->auth_.GetMaxTimeout() + ); + + return res; +} + +liboai::FutureResponse liboai::FineTunes::cancel_async(const std::string& fine_tune_id) const & noexcept(false) { + return std::async(std::launch::async, &liboai::FineTunes::cancel, this, fine_tune_id); +} + +liboai::Response liboai::FineTunes::list_events(const std::string& fine_tune_id, std::optional<std::function<bool(std::string, intptr_t)>> stream) const & noexcept(false) { + netimpl::components::Parameters params; + stream ? params.Add({"stream", "true"}) : void(); + + Response res; + res = this->Request( + Method::HTTP_GET, this->openai_root_, "/fine-tunes/" + fine_tune_id + "/events", "application/json", + this->auth_.GetAuthorizationHeaders(), + std::move(params), + stream ? netimpl::components::WriteCallback{std::move(stream.value())} : netimpl::components::WriteCallback{}, + this->auth_.GetProxies(), + this->auth_.GetProxyAuth(), + this->auth_.GetMaxTimeout() + ); + + return res; +} + +liboai::FutureResponse liboai::FineTunes::list_events_async(const std::string& fine_tune_id, std::optional<std::function<bool(std::string, intptr_t)>> stream) const & noexcept(false) { + return std::async(std::launch::async, &liboai::FineTunes::list_events, this, fine_tune_id, stream); +} + +liboai::Response liboai::FineTunes::remove(const std::string& model) const& noexcept(false) { + Response res; + res = this->Request( + Method::HTTP_DELETE, this->openai_root_, "/models/" + model, "application/json", + this->auth_.GetAuthorizationHeaders(), + this->auth_.GetProxies(), + this->auth_.GetProxyAuth(), + this->auth_.GetMaxTimeout() + ); + + return res; +} + +liboai::FutureResponse liboai::FineTunes::remove_async(const std::string& model) const & noexcept(false) { + return std::async(std::launch::async, &liboai::FineTunes::remove, this, model); +} diff --git a/packages/media/cpp/packages/liboai/liboai/components/images.cpp b/packages/media/cpp/packages/liboai/liboai/components/images.cpp new file mode 100644 index 00000000..1754aee5 --- /dev/null +++ b/packages/media/cpp/packages/liboai/liboai/components/images.cpp @@ -0,0 +1,109 @@ +#include "../include/components/images.h" + +liboai::Response liboai::Images::create(const std::string& prompt, std::optional<uint8_t> n, std::optional<std::string> size, std::optional<std::string> response_format, std::optional<std::string> user) const & noexcept(false) { + liboai::JsonConstructor jcon; + jcon.push_back("prompt", prompt); + jcon.push_back("n", std::move(n)); + jcon.push_back("size", std::move(size)); + jcon.push_back("response_format", std::move(response_format)); + jcon.push_back("user", std::move(user)); + + Response res; + res = this->Request( + Method::HTTP_POST, this->openai_root_, "/images/generations", "application/json", + this->auth_.GetAuthorizationHeaders(), + netimpl::components::Body { + jcon.dump() + }, + this->auth_.GetProxies(), + this->auth_.GetProxyAuth(), + this->auth_.GetMaxTimeout() + ); + + return res; +} + +liboai::FutureResponse liboai::Images::create_async(const std::string& prompt, std::optional<uint8_t> n, std::optional<std::string> size, std::optional<std::string> response_format, std::optional<std::string> user) const & noexcept(false) { + return std::async(std::launch::async, &liboai::Images::create, this, prompt, n, size, response_format, user); +} + +liboai::Response liboai::Images::create_edit(const std::filesystem::path& image, const std::string& prompt, std::optional<std::filesystem::path> mask, std::optional<uint8_t> n, std::optional<std::string> size, std::optional<std::string> response_format, std::optional<std::string> user) const & noexcept(false) { + if (!this->Validate(image)) { + throw liboai::exception::OpenAIException( + "File path provided is non-existent, is not a file, or is empty.", + liboai::exception::EType::E_FILEERROR, + "liboai::Images::create_edit(...)" + ); + } + + netimpl::components::Multipart form = { + { "prompt", prompt }, + { "image", netimpl::components::File{image.generic_string()} } + }; + + if (mask) { + if (!this->Validate(mask.value())) { + throw liboai::exception::OpenAIException( + "File path provided is non-existent, is not a file, or is empty.", + liboai::exception::EType::E_FILEERROR, + "liboai::Images::create_edit(...)" + ); + } + form.parts.push_back({ "mask", netimpl::components::File{mask.value().generic_string()} }); + } + if (n) { form.parts.push_back({ "n", n.value() }); } + if (size) { form.parts.push_back({ "size", size.value() }); } + if (response_format) { form.parts.push_back({ "response_format", response_format.value() }); } + if (user) { form.parts.push_back({ "user", user.value() }); } + + Response res; + res = this->Request( + Method::HTTP_POST, this->openai_root_, "/images/edits", "multipart/form-data", + this->auth_.GetAuthorizationHeaders(), + std::move(form), + this->auth_.GetProxies(), + this->auth_.GetProxyAuth(), + this->auth_.GetMaxTimeout() + ); + + return res; +} + +liboai::FutureResponse liboai::Images::create_edit_async(const std::filesystem::path& image, const std::string& prompt, std::optional<std::filesystem::path> mask, std::optional<uint8_t> n, std::optional<std::string> size, std::optional<std::string> response_format, std::optional<std::string> user) const & noexcept(false) { + return std::async(std::launch::async, &liboai::Images::create_edit, this, image, prompt, mask, n, size, response_format, user); +} + +liboai::Response liboai::Images::create_variation(const std::filesystem::path& image, std::optional<uint8_t> n, std::optional<std::string> size, std::optional<std::string> response_format, std::optional<std::string> user) const & noexcept(false) { + if (!this->Validate(image)) { + throw liboai::exception::OpenAIException( + "File path provided is non-existent, is not a file, or is empty.", + liboai::exception::EType::E_FILEERROR, + "liboai::Images::create_variation(...)" + ); + } + + netimpl::components::Multipart form = { + { "image", netimpl::components::File{image.generic_string()} } + }; + + if (n) { form.parts.push_back({ "n", n.value() }); } + if (size) { form.parts.push_back({ "size", size.value() }); } + if (response_format) { form.parts.push_back({ "response_format", response_format.value() }); } + if (user) { form.parts.push_back({ "user", user.value() }); } + + Response res; + res = this->Request( + Method::HTTP_POST, this->openai_root_, "/images/variations", "multipart/form-data", + this->auth_.GetAuthorizationHeaders(), + std::move(form), + this->auth_.GetProxies(), + this->auth_.GetProxyAuth(), + this->auth_.GetMaxTimeout() + ); + + return res; +} + +liboai::FutureResponse liboai::Images::create_variation_async(const std::filesystem::path& image, std::optional<uint8_t> n, std::optional<std::string> size, std::optional<std::string> response_format, std::optional<std::string> user) const & noexcept(false) { + return std::async(std::launch::async, &liboai::Images::create_variation, this, image, n, size, response_format, user); +} diff --git a/packages/media/cpp/packages/liboai/liboai/components/models.cpp b/packages/media/cpp/packages/liboai/liboai/components/models.cpp new file mode 100644 index 00000000..41c3e08f --- /dev/null +++ b/packages/media/cpp/packages/liboai/liboai/components/models.cpp @@ -0,0 +1,35 @@ +#include "../include/components/models.h" + +liboai::Response liboai::Models::list() const & noexcept(false) { + Response res; + res = this->Request( + Method::HTTP_GET, this->openai_root_, "/models", "application/json", + this->auth_.GetAuthorizationHeaders(), + this->auth_.GetProxies(), + this->auth_.GetProxyAuth(), + this->auth_.GetMaxTimeout() + ); + + return res; +} + +liboai::FutureResponse liboai::Models::list_async() const & noexcept(false) { + return std::async(std::launch::async, &liboai::Models::list, this); +} + +liboai::Response liboai::Models::retrieve(const std::string& model) const & noexcept(false) { + Response res; + res = this->Request( + Method::HTTP_GET, this->openai_root_, "/models/" + model, "application/json", + this->auth_.GetAuthorizationHeaders(), + this->auth_.GetProxies(), + this->auth_.GetProxyAuth(), + this->auth_.GetMaxTimeout() + ); + + return res; +} + +liboai::FutureResponse liboai::Models::retrieve_async(const std::string& model) const & noexcept(false) { + return std::async(std::launch::async, &liboai::Models::retrieve, this, model); +} diff --git a/packages/media/cpp/packages/liboai/liboai/components/moderations.cpp b/packages/media/cpp/packages/liboai/liboai/components/moderations.cpp new file mode 100644 index 00000000..9f1b98e3 --- /dev/null +++ b/packages/media/cpp/packages/liboai/liboai/components/moderations.cpp @@ -0,0 +1,25 @@ +#include "../include/components/moderations.h" + +liboai::Response liboai::Moderations::create(const std::string& input, std::optional<std::string> model) const & noexcept(false) { + liboai::JsonConstructor jcon; + jcon.push_back("input", input); + jcon.push_back("model", std::move(model)); + + Response res; + res = this->Request( + Method::HTTP_POST, this->openai_root_, "/moderations", "application/json", + this->auth_.GetAuthorizationHeaders(), + netimpl::components::Body { + jcon.dump() + }, + this->auth_.GetProxies(), + this->auth_.GetProxyAuth(), + this->auth_.GetMaxTimeout() + ); + + return res; +} + +liboai::FutureResponse liboai::Moderations::create_async(const std::string& input, std::optional<std::string> model) const & noexcept(false) { + return std::async(std::launch::async, &liboai::Moderations::create, this, input, model); +} diff --git a/packages/media/cpp/packages/liboai/liboai/components/responses.cpp b/packages/media/cpp/packages/liboai/liboai/components/responses.cpp new file mode 100644 index 00000000..9c9e7d3d --- /dev/null +++ b/packages/media/cpp/packages/liboai/liboai/components/responses.cpp @@ -0,0 +1,221 @@ +#include "../include/components/responses.h" + +nlohmann::json liboai::Responses::build_request( + const std::string& model, + const nlohmann::json& input, + std::optional<std::string> instructions, + std::optional<nlohmann::json> reasoning, + std::optional<nlohmann::json> text, + std::optional<uint32_t> max_output_tokens, + std::optional<float> temperature, + std::optional<float> top_p, + std::optional<uint32_t> seed, + std::optional<nlohmann::json> tools, + std::optional<nlohmann::json> tool_choice, + std::optional<bool> parallel_tool_calls, + std::optional<bool> store, + std::optional<std::string> previous_response_id, + std::optional<nlohmann::json> include, + std::optional<nlohmann::json> metadata, + std::optional<std::string> user, + std::optional<std::string> truncation, + std::optional<bool> stream +) { + nlohmann::json request; + request["model"] = model; + request["input"] = input; + + if (instructions) { + request["instructions"] = std::move(*instructions); + } + if (reasoning) { + request["reasoning"] = std::move(*reasoning); + } + if (text) { + request["text"] = std::move(*text); + } + if (max_output_tokens) { + request["max_output_tokens"] = *max_output_tokens; + } + if (temperature) { + request["temperature"] = *temperature; + } + if (top_p) { + request["top_p"] = *top_p; + } + if (seed) { + request["seed"] = *seed; + } + if (tools) { + request["tools"] = std::move(*tools); + } + if (tool_choice) { + request["tool_choice"] = std::move(*tool_choice); + } + if (parallel_tool_calls) { + request["parallel_tool_calls"] = *parallel_tool_calls; + } + if (store) { + request["store"] = *store; + } + if (previous_response_id) { + request["previous_response_id"] = std::move(*previous_response_id); + } + if (include) { + request["include"] = std::move(*include); + } + if (metadata) { + request["metadata"] = std::move(*metadata); + } + if (user) { + request["user"] = std::move(*user); + } + if (truncation) { + request["truncation"] = std::move(*truncation); + } + if (stream) { + request["stream"] = *stream; + } + + return request; +} + +liboai::Response liboai::Responses::create( + const std::string& model, + const nlohmann::json& input, + std::optional<std::string> instructions, + std::optional<nlohmann::json> reasoning, + std::optional<nlohmann::json> text, + std::optional<uint32_t> max_output_tokens, + std::optional<float> temperature, + std::optional<float> top_p, + std::optional<uint32_t> seed, + std::optional<nlohmann::json> tools, + std::optional<nlohmann::json> tool_choice, + std::optional<bool> parallel_tool_calls, + std::optional<bool> store, + std::optional<std::string> previous_response_id, + std::optional<nlohmann::json> include, + std::optional<nlohmann::json> metadata, + std::optional<std::string> user, + std::optional<std::string> truncation, + std::optional<StreamCallback> stream +) const & noexcept(false) { + const auto request = liboai::Responses::build_request( + model, + input, + std::move(instructions), + std::move(reasoning), + std::move(text), + std::move(max_output_tokens), + std::move(temperature), + std::move(top_p), + std::move(seed), + std::move(tools), + std::move(tool_choice), + std::move(parallel_tool_calls), + std::move(store), + std::move(previous_response_id), + std::move(include), + std::move(metadata), + std::move(user), + std::move(truncation), + stream ? std::optional<bool>(true) : std::nullopt + ); + + return this->create(request, std::move(stream)); +} + +liboai::Response liboai::Responses::create(const nlohmann::json& request, std::optional<StreamCallback> stream) const & noexcept(false) { + Response res; + res = this->Request( + Method::HTTP_POST, this->openai_root_, "/responses", "application/json", + this->auth_.GetAuthorizationHeaders(), + netimpl::components::Body { + request.dump(4) + }, + stream ? netimpl::components::WriteCallback{std::move(stream.value())} : netimpl::components::WriteCallback{}, + this->auth_.GetProxies(), + this->auth_.GetProxyAuth(), + this->auth_.GetMaxTimeout() + ); + + return res; +} + +liboai::FutureResponse liboai::Responses::create_async( + const std::string& model, + const nlohmann::json& input, + std::optional<std::string> instructions, + std::optional<nlohmann::json> reasoning, + std::optional<nlohmann::json> text, + std::optional<uint32_t> max_output_tokens, + std::optional<float> temperature, + std::optional<float> top_p, + std::optional<uint32_t> seed, + std::optional<nlohmann::json> tools, + std::optional<nlohmann::json> tool_choice, + std::optional<bool> parallel_tool_calls, + std::optional<bool> store, + std::optional<std::string> previous_response_id, + std::optional<nlohmann::json> include, + std::optional<nlohmann::json> metadata, + std::optional<std::string> user, + std::optional<std::string> truncation, + std::optional<StreamCallback> stream +) const & noexcept(false) { + return std::async( + std::launch::async, + [this, + model, + input, + instructions, + reasoning, + text, + max_output_tokens, + temperature, + top_p, + seed, + tools, + tool_choice, + parallel_tool_calls, + store, + previous_response_id, + include, + metadata, + user, + truncation, + stream]() mutable { + return this->create( + model, + input, + std::move(instructions), + std::move(reasoning), + std::move(text), + std::move(max_output_tokens), + std::move(temperature), + std::move(top_p), + std::move(seed), + std::move(tools), + std::move(tool_choice), + std::move(parallel_tool_calls), + std::move(store), + std::move(previous_response_id), + std::move(include), + std::move(metadata), + std::move(user), + std::move(truncation), + std::move(stream) + ); + } + ); +} + +liboai::FutureResponse liboai::Responses::create_async(const nlohmann::json& request, std::optional<StreamCallback> stream) const & noexcept(false) { + return std::async( + std::launch::async, + [this, request, stream]() mutable { + return this->create(request, std::move(stream)); + } + ); +} diff --git a/packages/media/cpp/packages/liboai/liboai/core/authorization.cpp b/packages/media/cpp/packages/liboai/liboai/core/authorization.cpp new file mode 100644 index 00000000..e1df959b --- /dev/null +++ b/packages/media/cpp/packages/liboai/liboai/core/authorization.cpp @@ -0,0 +1,197 @@ +#include "../include/core/authorization.h" + +liboai::Authorization::~Authorization() { + netimpl::components::EncodedAuthentication().SecureStringClear(this->key_); +} + +bool liboai::Authorization::SetKey(std::string_view key) noexcept { + if (!key.empty()) { + this->key_ = key; + if (this->openai_auth_headers_.count("Authorization") > 0) { + this->openai_auth_headers_.erase("Authorization"); + } + this->openai_auth_headers_["Authorization"] = ("Bearer " + this->key_); + return true; + } + return false; +} + +bool liboai::Authorization::SetAzureKey(std::string_view key) noexcept { + if (!key.empty()) { + this->key_ = key; + if (this->azure_auth_headers_.size() > 0) { + this->azure_auth_headers_.clear(); + } + this->azure_auth_headers_["api-key"] = this->key_; + return true; + } + return false; +} + +bool liboai::Authorization::SetAzureKeyAD(std::string_view key) noexcept { + if (!key.empty()) { + this->key_ = key; + if (this->azure_auth_headers_.size() > 0) { + this->azure_auth_headers_.clear(); + } + this->azure_auth_headers_["Authorization"] = ("Bearer " + this->key_); + return true; + } + return false; +} + +bool liboai::Authorization::SetKeyFile(const std::filesystem::path& path) noexcept { + if (std::filesystem::exists(path) && std::filesystem::is_regular_file(path) && std::filesystem::file_size(path) > 0) { + std::ifstream file(path); + if (file.is_open()) { + std::getline(file, this->key_); + if (this->openai_auth_headers_.count("Authorization") > 0) { + this->openai_auth_headers_.erase("Authorization"); + } + this->openai_auth_headers_["Authorization"] = ("Bearer " + this->key_); + return true; + } + } + return false; +} + +bool liboai::Authorization::SetAzureKeyFile(const std::filesystem::path& path) noexcept { + if (std::filesystem::exists(path) && std::filesystem::is_regular_file(path) && std::filesystem::file_size(path) > 0) { + std::ifstream file(path); + if (file.is_open()) { + std::getline(file, this->key_); + if (this->azure_auth_headers_.size() > 0) { + this->azure_auth_headers_.clear(); + } + this->azure_auth_headers_["api-key"] = this->key_; + return true; + } + } + return false; +} + +bool liboai::Authorization::SetAzureKeyFileAD(const std::filesystem::path& path) noexcept { + if (std::filesystem::exists(path) && std::filesystem::is_regular_file(path) && std::filesystem::file_size(path) > 0) { + std::ifstream file(path); + if (file.is_open()) { + std::getline(file, this->key_); + if (this->azure_auth_headers_.size() > 0) { + this->azure_auth_headers_.clear(); + } + this->azure_auth_headers_["Authorization"] = ("Bearer " + this->key_); + return true; + } + } + return false; +} + +bool liboai::Authorization::SetKeyEnv(std::string_view var) noexcept { + if (!var.empty()) { + const char* key = std::getenv(var.data()); + if (key != nullptr) { + this->key_ = key; + if (this->openai_auth_headers_.count("Authorization") > 0) { + this->openai_auth_headers_.erase("Authorization"); + } + this->openai_auth_headers_["Authorization"] = ("Bearer " + this->key_); + return true; + } + return false; + } + return false; +} + +bool liboai::Authorization::SetAzureKeyEnv(std::string_view var) noexcept { + if (!var.empty()) { + const char* key = std::getenv(var.data()); + if (key != nullptr) { + this->key_ = key; + if (this->azure_auth_headers_.size() > 0) { + this->azure_auth_headers_.clear(); + } + this->azure_auth_headers_["api-key"] = this->key_; + return true; + } + return false; + } + return false; +} + +bool liboai::Authorization::SetAzureKeyEnvAD(std::string_view var) noexcept { + if (!var.empty()) { + const char* key = std::getenv(var.data()); + if (key != nullptr) { + this->key_ = key; + if (this->azure_auth_headers_.size() > 0) { + this->azure_auth_headers_.clear(); + } + this->azure_auth_headers_["Authorization"] = ("Bearer " + this->key_); + return true; + } + return false; + } + return false; +} + +bool liboai::Authorization::SetOrganization(std::string_view org) noexcept { + if (!org.empty()) { + this->org_ = std::move(org); + if (this->openai_auth_headers_.count("OpenAI-Organization") > 0) { + this->openai_auth_headers_.erase("OpenAI-Organization"); + } + this->openai_auth_headers_["OpenAI-Organization"] = this->org_; + return true; + } + return false; +} + +bool liboai::Authorization::SetOrganizationFile(const std::filesystem::path& path) noexcept { + if (std::filesystem::exists(path) && std::filesystem::is_regular_file(path) && std::filesystem::file_size(path) > 0) { + std::ifstream file(path); + if (file.is_open()) { + std::getline(file, this->key_); + if (this->openai_auth_headers_.count("OpenAI-Organization") > 0) { + this->openai_auth_headers_.erase("OpenAI-Organization"); + } + this->openai_auth_headers_["OpenAI-Organization"] = this->org_; + return true; + } + } + return false; +} + +bool liboai::Authorization::SetOrganizationEnv(std::string_view var) noexcept { + if (!var.empty()) { + const char* org = std::getenv(var.data()); + if (org != nullptr) { + this->org_ = org; + if (this->openai_auth_headers_.count("OpenAI-Organization") > 0) { + this->openai_auth_headers_.erase("OpenAI-Organization"); + } + this->openai_auth_headers_["OpenAI-Organization"] = this->org_; + return true; + } + return false; + } + return false; +} + +void liboai::Authorization::SetProxies(const std::initializer_list<std::pair<const std::string, std::string>>& hosts) noexcept { + this->proxies_ = netimpl::components::Proxies(hosts); +} + +void liboai::Authorization::SetProxies(std::initializer_list<std::pair<const std::string, std::string>>&& hosts) noexcept { + this->proxies_ = netimpl::components::Proxies(std::move(hosts)); +} + +void liboai::Authorization::SetProxies(const std::map<std::string, std::string>& hosts) noexcept { + this->proxies_ = netimpl::components::Proxies(hosts); +} + +void liboai::Authorization::SetProxies(std::map<std::string, std::string>&& hosts) noexcept { + this->proxies_ = netimpl::components::Proxies(std::move(hosts)); +} + +void liboai::Authorization::SetProxyAuth(const std::map<std::string, netimpl::components::EncodedAuthentication>& proto_up) noexcept { + this->proxyAuth_ = netimpl::components::ProxyAuthentication(proto_up); +} \ No newline at end of file diff --git a/packages/media/cpp/packages/liboai/liboai/core/netimpl.cpp b/packages/media/cpp/packages/liboai/liboai/core/netimpl.cpp new file mode 100644 index 00000000..a45d4a3c --- /dev/null +++ b/packages/media/cpp/packages/liboai/liboai/core/netimpl.cpp @@ -0,0 +1,1592 @@ +#include "../include/core/netimpl.h" + +liboai::netimpl::CurlHolder::CurlHolder() { + std::lock_guard<std::mutex> lock{ this->curl_easy_get_mutex_() }; + + if (!_flag) { + curl_version_info_data* data = curl_version_info(CURLVERSION_NOW); + + // if curl doesn't have ssl enabled, throw an exception + if (!(data->features & CURL_VERSION_SSL)) { + throw liboai::exception::OpenAIException( + "Curl does not have SSL enabled.", + liboai::exception::EType::E_CURLERROR, + "liboai::netimpl::CurlHolder::CurlHolder()" + ); + } + else { + // flag set to true to avoid future checks if SSL present + _flag = true; + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] SSL is enabled; check flag set.\n", + __func__ + ); + #endif + } + } + + this->curl_ = curl_easy_init(); + if (!this->curl_) { + throw liboai::exception::OpenAIException( + curl_easy_strerror(CURLE_FAILED_INIT), + liboai::exception::EType::E_CURLERROR, + "liboai::netimpl::CurlHolder::CurlHolder()" + ); + } + + #if defined(LIBOAI_DEBUG) + curl_easy_setopt(this->curl_, CURLOPT_VERBOSE, 1L); + #endif + + #if defined(LIBOAI_DISABLE_PEERVERIFY) + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] LIBOAI_DISABLE_PEERVERIFY set; peer verification disabled.\n", + __func__ + ); + #endif + curl_easy_setopt(this->curl_, CURLOPT_SSL_VERIFYPEER, 0L); + #else + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] LIBOAI_DISABLE_PEERVERIFY not set; peer verification enabled.\n", + __func__ + ); + #endif + curl_easy_setopt(this->curl_, CURLOPT_SSL_VERIFYPEER, 1L); + #endif +} + +liboai::netimpl::CurlHolder::~CurlHolder() { + if (this->curl_) { + curl_easy_cleanup(this->curl_); + this->curl_ = nullptr; + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] curl_easy_cleanup() called.\n", + __func__ + ); + #endif + } +} + +liboai::netimpl::Session::~Session() { + if (this->headers) { + curl_slist_free_all(this->headers); + this->headers = nullptr; + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] curl_slist_free_all() called.\n", + __func__ + ); + #endif + } + + #if LIBCURL_VERSION_MAJOR < 7 || (LIBCURL_VERSION_MAJOR == 7 && LIBCURL_VERSION_MINOR < 56) + if (this->form) { + curl_formfree(this->form); + this->form = nullptr; + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] curl_formfree() called.\n", + __func__ + ); + #endif + } + #endif + + #if LIBCURL_VERSION_MAJOR > 7 || (LIBCURL_VERSION_MAJOR == 7 && LIBCURL_VERSION_MINOR >= 56) + if (this->mime) { + curl_mime_free(this->mime); + this->mime = nullptr; + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] curl_mime_free() called.\n", + __func__ + ); + #endif + } + #endif +} + +void liboai::netimpl::Session::Prepare() { + // holds error codes - all init to OK to prevent errors + // when checking unset values + CURLcode e[11]; memset(e, CURLcode::CURLE_OK, sizeof(e)); + + // add parameters to base url + if (!this->parameter_string_.empty()) { + this->url_ += "?"; + this->url_ += this->parameter_string_; + } + this->url_str = this->url_; + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Set URL for Session (0x%p) to %s.\n", + __func__, this, this->url_str.c_str() + ); + #endif + + e[0] = curl_easy_setopt(this->curl_, CURLOPT_URL, this->url_.c_str()); + + const std::string protocol_socket5_hostname = "socket5_hostname"; + if (proxies_.has(protocol_socket5_hostname)) { + e[1] = curl_easy_setopt(this->curl_, CURLOPT_PROXY, proxies_[protocol_socket5_hostname].c_str()); + e[2] = curl_easy_setopt(this->curl_, CURLOPT_PROXYTYPE, CURLPROXY_SOCKS5_HOSTNAME); + + if (proxyAuth_.has(protocol_socket5_hostname)) { + e[3] = curl_easy_setopt(this->curl_, CURLOPT_PROXYUSERNAME, proxyAuth_.GetUsername(protocol_socket5_hostname)); + e[4] = curl_easy_setopt(this->curl_, CURLOPT_PROXYPASSWORD, proxyAuth_.GetPassword(protocol_socket5_hostname)); + } + } else { + // set proxy if available + const std::string protocol = url_.substr(0, url_.find(':')); + if (proxies_.has(protocol)) { + e[1] = curl_easy_setopt(this->curl_, CURLOPT_PROXY, proxies_[protocol].c_str()); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Set CURLOPT_PROXY for Session (0x%p) to %s.\n", + __func__, this, proxies_[protocol].c_str() + ); + #endif + + if (proxyAuth_.has(protocol)) { + e[2] = curl_easy_setopt(this->curl_, CURLOPT_PROXYUSERNAME, proxyAuth_.GetUsername(protocol)); + e[3] = curl_easy_setopt(this->curl_, CURLOPT_PROXYPASSWORD, proxyAuth_.GetPassword(protocol)); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Set CURLOPT_PROXYUSERNAME and CURLOPT_PROXYPASSWORD for Session (0x%p) to %s and %s.\n", + __func__, this, proxyAuth_.GetUsername(protocol), proxyAuth_.GetPassword(protocol) + ); + #endif + } + } + } + + // accept all encoding types + e[5] = curl_easy_setopt(this->curl_, CURLOPT_ACCEPT_ENCODING, ""); + + #if LIBCURL_VERSION_MAJOR > 7 || (LIBCURL_VERSION_MAJOR == 7 && LIBCURL_VERSION_MINOR >= 71) + e[6] = curl_easy_setopt(this->curl_, CURLOPT_SSL_OPTIONS, CURLSSLOPT_NATIVE_CA); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Set CURLOPT_SSL_OPTIONS for Session (0x%p) to CURLSSLOPT_NATIVE_CA.\n", + __func__, this + ); + #endif + #endif + + // set string the response will be sent to + if (!this->write_.callback) { + e[7] = curl_easy_setopt(this->curl_, CURLOPT_WRITEFUNCTION, liboai::netimpl::components::writeFunction); + e[8] = curl_easy_setopt(this->curl_, CURLOPT_WRITEDATA, &this->response_string_); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] No user supplied WriteCallback. Set CURLOPT_WRITEFUNCTION and CURLOPT_WRITEDATA for Session (0x%p) to 0x%p and 0x%p.\n", + __func__, this, liboai::netimpl::components::writeFunction, &this->response_string_ + ); + #endif + } + + // set string the raw headers will be sent to + e[9] = curl_easy_setopt(this->curl_, CURLOPT_HEADERFUNCTION, liboai::netimpl::components::writeFunction); + e[10] = curl_easy_setopt(this->curl_, CURLOPT_HEADERDATA, &this->header_string_); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Set CURLOPT_HEADERFUNCTION and CURLOPT_HEADERDATA for Session (0x%p) to 0x%p and 0x%p.\n", + __func__, this, liboai::netimpl::components::writeFunction, &this->header_string_ + ); + #endif + + ErrorCheck(e, 11, "liboai::netimpl::Session::Prepare()"); +} + +void liboai::netimpl::Session::PrepareDownloadInternal() { + // holds error codes - all init to OK to prevent errors + // when checking unset values + CURLcode e[7]; memset(e, CURLcode::CURLE_OK, sizeof(e)); + + if (!this->parameter_string_.empty()) { + this->url_ += "?"; + this->url_ += this->parameter_string_; + } + this->url_str = this->url_; + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Set URL for Session (0x%p) to %s.\n", + __func__, this, this->url_str.c_str() + ); + #endif + + e[0] = curl_easy_setopt(this->curl_, CURLOPT_URL, this->url_.c_str()); + + const std::string protocol_socket5_hostname = "socket5_hostname"; + if (proxies_.has(protocol_socket5_hostname)) { + e[1] = curl_easy_setopt(this->curl_, CURLOPT_PROXY, proxies_[protocol_socket5_hostname].c_str()); + e[2] = curl_easy_setopt(this->curl_, CURLOPT_PROXYTYPE, CURLPROXY_SOCKS5_HOSTNAME); + if (proxyAuth_.has(protocol_socket5_hostname)) { + e[3] = curl_easy_setopt(this->curl_, CURLOPT_PROXYUSERNAME, proxyAuth_.GetUsername(protocol_socket5_hostname)); + e[4] = curl_easy_setopt(this->curl_, CURLOPT_PROXYPASSWORD, proxyAuth_.GetPassword(protocol_socket5_hostname)); + } + } else { + const std::string protocol = url_.substr(0, url_.find(':')); + if (proxies_.has(protocol)) { + e[1] = curl_easy_setopt(this->curl_, CURLOPT_PROXY, proxies_[protocol].c_str()); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Set CURLOPT_PROXY for Session (0x%p) to %s.\n", + __func__, this, proxies_[protocol].c_str() + ); + #endif + + if (proxyAuth_.has(protocol)) { + e[2] = curl_easy_setopt(this->curl_, CURLOPT_PROXYUSERNAME, proxyAuth_.GetUsername(protocol)); + e[3] = curl_easy_setopt(this->curl_, CURLOPT_PROXYPASSWORD, proxyAuth_.GetPassword(protocol)); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Set CURLOPT_PROXYUSERNAME and CURLOPT_PROXYPASSWORD for Session (0x%p) to %s and %s.\n", + __func__, this, proxyAuth_.GetUsername(protocol), proxyAuth_.GetPassword(protocol) + ); + #endif + } + } + } + + e[5] = curl_easy_setopt(this->curl_, CURLOPT_HEADERFUNCTION, liboai::netimpl::components::writeFunction); + e[6] = curl_easy_setopt(this->curl_, CURLOPT_HEADERDATA, &this->header_string_); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Set CURLOPT_HEADERFUNCTION and CURLOPT_HEADERDATA for Session (0x%p) to 0x%p and 0x%p.\n", + __func__, this, liboai::netimpl::components::writeFunction, &this->header_string_ + ); + #endif + + ErrorCheck(e, 7, "liboai::netimpl::Session::PrepareDownloadInternal()"); +} + +CURLcode liboai::netimpl::Session::Perform() { + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Called curl_easy_perform() for Session (0x%p).\n", + __func__, this + ); + #endif + + CURLcode e = curl_easy_perform(this->curl_); + ErrorCheck(e, "liboai::netimpl::Session::Perform()"); + return e; +} + +liboai::Response liboai::netimpl::Session::BuildResponseObject() { + // holds error codes - all init to OK to prevent errors + // when checking unset values + CURLcode e[3]; memset(e, CURLcode::CURLE_OK, sizeof(e)); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Called ParseResponseHeader() for Session (0x%p).\n", + __func__, this + ); + #endif + + // fill status line and reason + this->ParseResponseHeader(this->header_string_, &this->status_line, &this->reason); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Called curl_easy_getinfo() for Session (0x%p) to get status code.\n", + __func__, this + ); + #endif + + // get status code + e[0] = curl_easy_getinfo(this->curl_, CURLINFO_RESPONSE_CODE, &this->status_code); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Called curl_easy_getinfo() for Session (0x%p) to get elapsed time.\n", + __func__, this + ); + #endif + + // get elapsed time + e[1] = curl_easy_getinfo(this->curl_, CURLINFO_TOTAL_TIME, &this->elapsed); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Called curl_easy_getinfo() for Session (0x%p) to get effective url.\n", + __func__, this + ); + #endif + + // get url + char* effective_url = nullptr; + e[2] = curl_easy_getinfo(this->curl_, CURLINFO_EFFECTIVE_URL, &effective_url); + this->url_str = (effective_url ? effective_url : ""); + + ErrorCheck(e, 3, "liboai::netimpl::Session::BuildResponseObject()"); + + // fill content + this->content = this->response_string_; + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Constructed response object.\n", + __func__ + ); + #endif + + return liboai::Response { + std::move(this->url_str), + std::move(this->content), + std::move(this->status_line), + std::move(this->reason), + this->status_code, + this->elapsed + }; +} + +liboai::Response liboai::netimpl::Session::Complete() { + this->hasBody = false; + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Called BuildResponseObject().\n", + __func__ + ); + #endif + + return this->BuildResponseObject(); +} + +liboai::Response liboai::netimpl::Session::CompleteDownload() { + // holds error codes - all init to OK to prevent errors + // when checking unset values + CURLcode e[2]; memset(e, CURLcode::CURLE_OK, sizeof(e)); + + e[0] = curl_easy_setopt(this->curl_, CURLOPT_HEADERFUNCTION, nullptr); + e[1] = curl_easy_setopt(this->curl_, CURLOPT_HEADERDATA, 0); + + ErrorCheck(e, 2, "liboai::netimpl::Session::CompleteDownload()"); + + this->hasBody = false; + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Called BuildResponseObject().\n", + __func__ + ); + #endif + + return this->BuildResponseObject(); +} + +void liboai::netimpl::Session::PrepareGet() { + // holds error codes - all init to OK to prevent errors + // when checking unset values + CURLcode e[5]; memset(e, CURLcode::CURLE_OK, sizeof(e)); + + if (this->hasBody) { + e[0] = curl_easy_setopt(this->curl_, CURLOPT_NOBODY, 0L); + e[1] = curl_easy_setopt(this->curl_, CURLOPT_CUSTOMREQUEST, "GET"); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Set CURLOPT_NOBODY and CURLOPT_CUSTOMREQUEST for Session (0x%p) to 0L and \"GET\".\n", + __func__, this + ); + #endif + } + else { + e[2] = curl_easy_setopt(this->curl_, CURLOPT_NOBODY, 0L); + e[3] = curl_easy_setopt(this->curl_, CURLOPT_CUSTOMREQUEST, nullptr); + e[4] = curl_easy_setopt(this->curl_, CURLOPT_HTTPGET, 1L); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Set CURLOPT_NOBODY, CURLOPT_CUSTOMREQUEST and CURLOPT_HTTPGET for Session (0x%p) to 0L, nullptr and 1L.\n", + __func__, this + ); + #endif + } + + ErrorCheck(e, 5, "liboai::netimpl::Session::PrepareGet()"); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Called Prepare().\n", + __func__ + ); + #endif + + this->Prepare(); +} + +liboai::Response liboai::netimpl::Session::Get() { + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Called PrepareGet().\n", + __func__ + ); + #endif + + this->PrepareGet(); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Called Perform().\n", + __func__ + ); + #endif + + this->Perform(); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Called Complete().\n", + __func__ + ); + #endif + + return Complete(); +} + +void liboai::netimpl::Session::PreparePost() { + // holds error codes - all init to OK to prevent errors + // when checking unset values + CURLcode e[4]; memset(e, CURLcode::CURLE_OK, sizeof(e)); + + e[0] = curl_easy_setopt(this->curl_, CURLOPT_NOBODY, 0L); + if (this->hasBody) { + e[1] = curl_easy_setopt(this->curl_, CURLOPT_CUSTOMREQUEST, nullptr); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Set CURLOPT_NOBODY and CURLOPT_CUSTOMREQUEST for Session (0x%p) to 0L and nullptr.\n", + __func__, this + ); + #endif + } + else { + e[2] = curl_easy_setopt(this->curl_, CURLOPT_POSTFIELDS, ""); + e[3] = curl_easy_setopt(this->curl_, CURLOPT_CUSTOMREQUEST, "POST"); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Set CURLOPT_NOBODY, CURLOPT_POSTFIELDS and CURLOPT_CUSTOMREQUEST for Session (0x%p) to 0L, \"\" and \"POST\".\n", + __func__, this + ); + #endif + } + + ErrorCheck(e, 4, "liboai::netimpl::Session::PreparePost()"); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Called Prepare().\n", + __func__ + ); + #endif + + this->Prepare(); +} + +liboai::Response liboai::netimpl::Session::Post() { + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Called PreparePost().\n", + __func__ + ); + #endif + + this->PreparePost(); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Called Perform().\n", + __func__ + ); + #endif + + Perform(); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Called Complete().\n", + __func__ + ); + #endif + + return Complete(); +} + +void liboai::netimpl::Session::PrepareDelete() { + // holds error codes - all init to OK to prevent errors + // when checking unset values + CURLcode e[3]; memset(e, CURLcode::CURLE_OK, sizeof(e)); + + e[0] = curl_easy_setopt(this->curl_, CURLOPT_HTTPGET, 0L); + e[1] = curl_easy_setopt(this->curl_, CURLOPT_NOBODY, 0L); + e[2] = curl_easy_setopt(this->curl_, CURLOPT_CUSTOMREQUEST, "DELETE"); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Set CURLOPT_HTTPGET, CURLOPT_NOBODY and CURLOPT_CUSTOMREQUEST for Session (0x%p) to 0L, 0L and \"DELETE\".\n", + __func__, this + ); + #endif + + ErrorCheck(e, 3, "liboai::netimpl::Session::PrepareDelete()"); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Called Prepare().\n", + __func__ + ); + #endif + + this->Prepare(); +} + +liboai::Response liboai::netimpl::Session::Delete() { + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Called PrepareDelete().\n", + __func__ + ); + #endif + + this->PrepareDelete(); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Called Perform().\n", + __func__ + ); + #endif + + Perform(); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Called Complete().\n", + __func__ + ); + #endif + + return Complete(); +} + +void liboai::netimpl::Session::PrepareDownload(std::ofstream& file) { + // holds error codes - all init to OK to prevent errors + // when checking unset values + CURLcode e[5]; memset(e, CURLcode::CURLE_OK, sizeof(e)); + + e[0] = curl_easy_setopt(this->curl_, CURLOPT_NOBODY, 0L); + e[1] = curl_easy_setopt(this->curl_, CURLOPT_HTTPGET, 1); + e[2] = curl_easy_setopt(this->curl_, CURLOPT_WRITEFUNCTION, liboai::netimpl::components::writeFileFunction); + e[3] = curl_easy_setopt(this->curl_, CURLOPT_WRITEDATA, &file); + e[4] = curl_easy_setopt(this->curl_, CURLOPT_CUSTOMREQUEST, nullptr); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Set CURLOPT_NOBODY, CURLOPT_HTTPGET, CURLOPT_WRITEFUNCTION, CURLOPT_WRITEDATA and CURLOPT_CUSTOMREQUEST for Session (0x%p) to 0L, 1L, liboai::netimpl::components::writeFileFunction, &file and nullptr.\n", + __func__, this + ); + #endif + + ErrorCheck(e, 5, "liboai::netimpl::Session::PrepareDownload()"); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Called PrepareDownloadInternal().\n", + __func__ + ); + #endif + + this->PrepareDownloadInternal(); +} + +liboai::Response liboai::netimpl::Session::Download(std::ofstream& file) { + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Called PrepareDownload().\n", + __func__ + ); + #endif + + this->PrepareDownload(file); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Called Perform().\n", + __func__ + ); + #endif + + this->Perform(); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Called CompleteDownload().\n", + __func__ + ); + #endif + + return CompleteDownload(); +} + +void liboai::netimpl::Session::ClearContext() { + if (curl_) { + curl_easy_reset(curl_); + } + status_code = 0; + elapsed = 0.0; + status_line.clear(); + content.clear(); + url_str.clear(); + reason.clear(); + + if (this->headers) { + curl_slist_free_all(this->headers); + this->headers = nullptr; + +#if defined(LIBOAI_DEBUG) + _liboai_dbg("[dbg] [@%s] curl_slist_free_all() called.\n", __func__); +#endif + } + +#if LIBCURL_VERSION_MAJOR < 7 || \ + (LIBCURL_VERSION_MAJOR == 7 && LIBCURL_VERSION_MINOR < 56) + if (this->form) { + curl_formfree(this->form); + this->form = nullptr; + +#if defined(LIBOAI_DEBUG) + _liboai_dbg("[dbg] [@%s] curl_formfree() called.\n", __func__); +#endif + } +#endif + +#if LIBCURL_VERSION_MAJOR > 7 || \ + (LIBCURL_VERSION_MAJOR == 7 && LIBCURL_VERSION_MINOR >= 56) + if (this->mime) { + curl_mime_free(this->mime); + this->mime = nullptr; + +#if defined(LIBOAI_DEBUG) + _liboai_dbg("[dbg] [@%s] curl_mime_free() called.\n", __func__); +#endif + } +#endif + + hasBody = false; + parameter_string_.clear(); + url_.clear(); + response_string_.clear(); + header_string_.clear(); + write_ = netimpl::components::WriteCallback{}; +} + +void liboai::netimpl::Session::ParseResponseHeader(const std::string& headers, std::string* status_line, std::string* reason) { + std::vector<std::string> lines; + std::istringstream stream(headers); + { + std::string line; + while (std::getline(stream, line, '\n')) { + lines.push_back(line); + } + } + + for (std::string& line : lines) { + if (line.substr(0, 5) == "HTTP/") { + // set the status_line if it was given + if ((status_line != nullptr) || (reason != nullptr)) { + line.resize(std::min<size_t>(line.size(), line.find_last_not_of("\t\n\r ") + 1)); + if (status_line != nullptr) { + *status_line = line; + } + + // set the reason if it was given + if (reason != nullptr) { + const size_t pos1 = line.find_first_of("\t "); + size_t pos2 = std::string::npos; + if (pos1 != std::string::npos) { + pos2 = line.find_first_of("\t ", pos1 + 1); + } + if (pos2 != std::string::npos) { + line.erase(0, pos2 + 1); + *reason = line; + } + } + } + } + + if (line.length() > 0) { + const size_t found = line.find(':'); + if (found != std::string::npos) { + std::string value = line.substr(found + 1); + value.erase(0, value.find_first_not_of("\t ")); + value.resize(std::min<size_t>(value.size(), value.find_last_not_of("\t\n\r ") + 1)); + } + } + } + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Parsed response header.\n", + __func__ + ); + #endif +} + +void liboai::netimpl::Session::SetOption(const components::Url& url) { + this->SetUrl(url); +} + +void liboai::netimpl::Session::SetUrl(const components::Url& url) { + this->url_ = url.str(); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Set base URL for Session (0x%p) to \"%s\".\n", + __func__, this, this->url_.c_str() + ); + #endif +} + +void liboai::netimpl::Session::SetOption(const components::Body& body) { + this->SetBody(body); +} + +void liboai::netimpl::Session::SetBody(const components::Body& body) { + // holds error codes - all init to OK to prevent errors + // when checking unset values + CURLcode e[2]; memset(e, CURLcode::CURLE_OK, sizeof(e)); + + this->hasBody = true; + e[0] = curl_easy_setopt(this->curl_, CURLOPT_POSTFIELDSIZE_LARGE, static_cast<curl_off_t>(body.str().length())); + e[1] = curl_easy_setopt(this->curl_, CURLOPT_POSTFIELDS, body.c_str()); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Set CURLOPT_POSTFIELDSIZE_LARGE and CURLOPT_POSTFIELDS for Session (0x%p) to %lld and \"%s\".\n", + __func__, this, static_cast<curl_off_t>(body.str().length()), body.c_str() + ); + #endif + + ErrorCheck(e, 2, "liboai::netimpl::Session::SetBody()"); +} + +void liboai::netimpl::Session::SetOption(components::Body&& body) { + this->SetBody(std::move(body)); +} + +void liboai::netimpl::Session::SetBody(components::Body&& body) { + // holds error codes - all init to OK to prevent errors + // when checking unset values + CURLcode e[2]; memset(e, CURLcode::CURLE_OK, sizeof(e)); + + this->hasBody = true; + e[0] = curl_easy_setopt(this->curl_, CURLOPT_POSTFIELDSIZE_LARGE, static_cast<curl_off_t>(body.str().length())); + e[1] = curl_easy_setopt(this->curl_, CURLOPT_COPYPOSTFIELDS, body.c_str()); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Set CURLOPT_POSTFIELDSIZE_LARGE and CURLOPT_COPYPOSTFIELDS for Session (0x%p) to %lld and \"%s\".\n", + __func__, this, static_cast<curl_off_t>(body.str().length()), body.c_str() + ); + #endif + + ErrorCheck(e, 2, "liboai::netimpl::Session::SetBody()"); +} + +void liboai::netimpl::Session::SetOption(const components::Multipart& multipart) { + this->SetMultipart(multipart); +} + +void liboai::netimpl::Session::SetMultipart(const components::Multipart& multipart) { + #if LIBCURL_VERSION_MAJOR < 7 || (LIBCURL_VERSION_MAJOR == 7 && LIBCURL_VERSION_MINOR < 56) + CURLFORMcode fe[2]; memset(fe, CURLFORMcode::CURL_FORMADD_OK, sizeof(fe)); + CURLcode e; + + curl_httppost* lastptr = nullptr; + + for (const auto& part : multipart.parts) { + std::vector<curl_forms> formdata; + if (!part.content_type.empty()) { + formdata.push_back({CURLFORM_CONTENTTYPE, part.content_type.c_str()}); + } + if (part.is_file) { + CURLFORMcode f; + for (const auto& file : part.files) { + formdata.push_back({CURLFORM_COPYNAME, part.name.c_str()}); + formdata.push_back({CURLFORM_FILE, file.filepath.c_str()}); + if (file.hasOverridedFilename()) { + formdata.push_back({CURLFORM_FILENAME, file.overrided_filename.c_str()}); + } + formdata.push_back({CURLFORM_END, nullptr}); + f = curl_formadd(&this->form, &lastptr, CURLFORM_ARRAY, formdata.data(), CURLFORM_END); + + // check each file + ErrorCheck(f, "liboai::netimpl::Session::SetMultipart() @ is_file[formadd]"); + + formdata.clear(); + } + } else if (part.is_buffer) { + fe[0] = curl_formadd(&this->form, &lastptr, CURLFORM_COPYNAME, part.name.c_str(), CURLFORM_BUFFER, part.value.c_str(), CURLFORM_BUFFERPTR, part.data, CURLFORM_BUFFERLENGTH, part.datalen, CURLFORM_END); + } else { + formdata.push_back({CURLFORM_COPYNAME, part.name.c_str()}); + formdata.push_back({CURLFORM_COPYCONTENTS, part.value.c_str()}); + formdata.push_back({CURLFORM_END, nullptr}); + fe[1] = curl_formadd(&this->form, &lastptr, CURLFORM_ARRAY, formdata.data(), CURLFORM_END); + } + } + e = curl_easy_setopt(this->curl_, CURLOPT_HTTPPOST, this->form); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Set multipart for Session (0x%p) using curl_formadd() and CURLOPT_HTTPPOST.\n", + __func__, this + ); + #endif + + ErrorCheck(fe, 2, "liboai::netimpl::Session::SetMultipart()"); + ErrorCheck(e, "liboai::netimpl::Session::SetMultipart()"); + + this->hasBody = true; + #endif + + #if LIBCURL_VERSION_MAJOR > 7 || (LIBCURL_VERSION_MAJOR == 7 && LIBCURL_VERSION_MINOR >= 56) + CURLcode e[6]; memset(e, CURLcode::CURLE_OK, sizeof(e)); + + this->mime = curl_mime_init(this->curl_); + if (!this->mime) { + throw liboai::exception::OpenAIException( + "curl_mime_init() failed", + liboai::exception::EType::E_CURLERROR, + "liboai::netimpl::Session::SetMultipart()" + ); + } + + for (const auto& part : multipart.parts) { + std::vector<curl_mimepart*> mimedata; + if (!part.content_type.empty()) { + mimedata.push_back(curl_mime_addpart(this->mime)); + e[0] = curl_mime_type(mimedata.back(), part.content_type.c_str()); + } + if (part.is_file) { + CURLcode fe[3]; memset(fe, CURLcode::CURLE_OK, sizeof(fe)); + for (const auto& file : part.files) { + mimedata.push_back(curl_mime_addpart(this->mime)); + fe[0] = curl_mime_name(mimedata.back(), part.name.c_str()); + fe[1] = curl_mime_filedata(mimedata.back(), file.filepath.c_str()); + if (file.hasOverridedFilename()) { + fe[2] = curl_mime_filename(mimedata.back(), file.overrided_filename.c_str()); + } + + // check each file + ErrorCheck(fe, 3, "liboai::netimpl::Session::SetMultipart() @ is_file[mime]"); + } + } + else if (part.is_buffer) { + mimedata.push_back(curl_mime_addpart(this->mime)); + e[1] = curl_mime_name(mimedata.back(), part.name.c_str()); + e[2] = curl_mime_filename(mimedata.back(), part.value.c_str()); + e[3] = curl_mime_data(mimedata.back(), reinterpret_cast<const char*>(part.data), part.datalen); + } + else { + mimedata.push_back(curl_mime_addpart(this->mime)); + e[3] = curl_mime_name(mimedata.back(), part.name.c_str()); + e[4] = curl_mime_data(mimedata.back(), part.value.c_str(), CURL_ZERO_TERMINATED); + } + } + e[5] = curl_easy_setopt(this->curl_, CURLOPT_MIMEPOST, this->mime); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Set multipart for Session (0x%p) using curl_mime_addpart() and CURLOPT_MIMEPOST.\n", + __func__, this + ); + #endif + + ErrorCheck(e, 6, "liboai::netimpl::Session::SetMultipart()"); + + this->hasBody = true; + #endif +} + +void liboai::netimpl::Session::SetOption(components::Multipart&& multipart) { + this->SetMultipart(std::move(multipart)); +} + +void liboai::netimpl::Session::SetMultipart(components::Multipart&& multipart) { + #if LIBCURL_VERSION_MAJOR < 7 || (LIBCURL_VERSION_MAJOR == 7 && LIBCURL_VERSION_MINOR < 56) + CURLFORMcode fe[2]; memset(fe, CURLFORMcode::CURL_FORMADD_OK, sizeof(fe)); + CURLcode e; + + curl_httppost* lastptr = nullptr; + + for (const auto& part : multipart.parts) { + std::vector<curl_forms> formdata; + if (!part.content_type.empty()) { + formdata.push_back({ CURLFORM_CONTENTTYPE, part.content_type.c_str() }); + } + if (part.is_file) { + CURLFORMcode f; + for (const auto& file : part.files) { + formdata.push_back({ CURLFORM_COPYNAME, part.name.c_str() }); + formdata.push_back({ CURLFORM_FILE, file.filepath.c_str() }); + if (file.hasOverridedFilename()) { + formdata.push_back({ CURLFORM_FILENAME, file.overrided_filename.c_str() }); + } + formdata.push_back({ CURLFORM_END, nullptr }); + f = curl_formadd(&this->form, &lastptr, CURLFORM_ARRAY, formdata.data(), CURLFORM_END); + + // check each file + ErrorCheck(f, "liboai::netimpl::Session::SetMultipart() @ is_file[formadd]"); + + formdata.clear(); + } + } + else if (part.is_buffer) { + fe[0] = curl_formadd(&this->form, &lastptr, CURLFORM_COPYNAME, part.name.c_str(), CURLFORM_BUFFER, part.value.c_str(), CURLFORM_BUFFERPTR, part.data, CURLFORM_BUFFERLENGTH, part.datalen, CURLFORM_END); + } + else { + formdata.push_back({ CURLFORM_COPYNAME, part.name.c_str() }); + formdata.push_back({ CURLFORM_COPYCONTENTS, part.value.c_str() }); + formdata.push_back({ CURLFORM_END, nullptr }); + fe[1] = curl_formadd(&this->form, &lastptr, CURLFORM_ARRAY, formdata.data(), CURLFORM_END); + } + } + e = curl_easy_setopt(this->curl_, CURLOPT_HTTPPOST, this->form); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Set multipart for Session (0x%p) using curl_formadd() and CURLOPT_HTTPPOST.\n", + __func__, this + ); + #endif + + ErrorCheck(fe, 2, "liboai::netimpl::Session::SetMultipart()"); + ErrorCheck(e, "liboai::netimpl::Session::SetMultipart()"); + + this->hasBody = true; + #endif + + #if LIBCURL_VERSION_MAJOR > 7 || (LIBCURL_VERSION_MAJOR == 7 && LIBCURL_VERSION_MINOR >= 56) + CURLcode e[6]; memset(e, CURLcode::CURLE_OK, sizeof(e)); + curl_mimepart* _part = nullptr; + + this->mime = curl_mime_init(this->curl_); + if (!this->mime) { + throw liboai::exception::OpenAIException( + "curl_mime_init() failed", + liboai::exception::EType::E_CURLERROR, + "liboai::netimpl::Session::SetMultipart()" + ); + } + + for (const auto& part : multipart.parts) { + std::vector<curl_mimepart*> mimedata; + if (!part.content_type.empty()) { + mimedata.push_back(curl_mime_addpart(this->mime)); + e[0] = curl_mime_type(mimedata.back(), part.content_type.c_str()); + } + if (part.is_file) { + CURLcode fe[3]; memset(fe, CURLcode::CURLE_OK, sizeof(fe)); + for (const auto& file : part.files) { + mimedata.push_back(curl_mime_addpart(this->mime)); + fe[0] = curl_mime_name(mimedata.back(), part.name.c_str()); + fe[1] = curl_mime_filedata(mimedata.back(), file.filepath.c_str()); + if (file.hasOverridedFilename()) { + fe[2] = curl_mime_filename(mimedata.back(), file.overrided_filename.c_str()); + } + + // check each file + ErrorCheck(fe, 3, "liboai::netimpl::Session::SetMultipart() @ is_file[mime]"); + } + } + else if (part.is_buffer) { + mimedata.push_back(curl_mime_addpart(this->mime)); + e[1] = curl_mime_name(mimedata.back(), part.name.c_str()); + e[2] = curl_mime_filename(mimedata.back(), part.value.c_str()); + e[3] = curl_mime_data(mimedata.back(), reinterpret_cast<const char*>(part.data), part.datalen); + } + else { + mimedata.push_back(curl_mime_addpart(this->mime)); + e[3] = curl_mime_name(mimedata.back(), part.name.c_str()); + e[4] = curl_mime_data(mimedata.back(), part.value.c_str(), CURL_ZERO_TERMINATED); + } + } + e[5] = curl_easy_setopt(this->curl_, CURLOPT_MIMEPOST, this->mime); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Set multipart for Session (0x%p) using curl_mime_addpart() and CURLOPT_MIMEPOST.\n", + __func__, this + ); + #endif + + ErrorCheck(e, 6, "liboai::netimpl::Session::SetMultipart()"); + + this->hasBody = true; + #endif +} + +std::string liboai::netimpl::CurlHolder::urlEncode(const std::string& s) { + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] URL-encode string \"%s\".\n", + __func__, s.c_str() + ); + #endif + + char* output = curl_easy_escape(this->curl_, s.c_str(), static_cast<int>(s.length())); + if (output) { + std::string result = output; + curl_free(output); + return result; + } + return ""; +} + +std::string liboai::netimpl::CurlHolder::urlDecode(const std::string& s) { + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] URL-decode string \"%s\".\n", + __func__, s.c_str() + ); + #endif + + char* output = curl_easy_unescape(this->curl_, s.c_str(), static_cast<int>(s.length()), nullptr); + if (output) { + std::string result = output; + curl_free(output); + return result; + } + return ""; +} + +std::string liboai::netimpl::components::urlEncodeHelper(const std::string& s) { + CurlHolder c; + return c.urlEncode(s); +} + +std::string liboai::netimpl::components::urlDecodeHelper(const std::string& s) { + CurlHolder c; + return c.urlDecode(s); +} + +size_t liboai::netimpl::components::writeUserFunction(char* ptr, size_t size, size_t nmemb, const WriteCallback* write) { + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Called with %zu bytes.\n", + __func__, size * nmemb + ); + #endif + + size *= nmemb; + return (*write)({ ptr, size }) ? size : 0; +} + +size_t liboai::netimpl::components::writeFunction(char* ptr, size_t size, size_t nmemb, std::string* data) { + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Called with %zu bytes.\n", + __func__, size * nmemb + ); + #endif + + size *= nmemb; + data->append(ptr, size); + return size; +} + +size_t liboai::netimpl::components::writeFileFunction(char* ptr, size_t size, size_t nmemb, std::ofstream* file) { + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Called with %zu bytes.\n", + __func__, size * nmemb + ); + #endif + + size *= nmemb; + file->write(ptr, static_cast<std::streamsize>(size)); + return size; +} + +long liboai::netimpl::components::Timeout::Milliseconds() const { + static_assert(std::is_same<std::chrono::milliseconds, decltype(this->ms)>::value, "Following casting expects milliseconds."); + + if (ms.count() > static_cast<std::chrono::milliseconds::rep>((std::numeric_limits<long>::max)())) { + throw std::overflow_error("cpr::Timeout: timeout value overflow: " + std::to_string(ms.count()) + " ms."); + } + + if (ms.count() < static_cast<std::chrono::milliseconds::rep>((std::numeric_limits<long>::min)())) { + throw std::underflow_error("cpr::Timeout: timeout value underflow: " + std::to_string(ms.count()) + " ms."); + } + + return static_cast<long>(ms.count()); +} + +liboai::netimpl::components::Files::iterator liboai::netimpl::components::Files::begin() { + return this->files.begin(); +} + +liboai::netimpl::components::Files::iterator liboai::netimpl::components::Files::end() { + return this->files.end(); +} + +liboai::netimpl::components::Files::const_iterator liboai::netimpl::components::Files::begin() const { + return this->files.begin(); +} + +liboai::netimpl::components::Files::const_iterator liboai::netimpl::components::Files::end() const { + return this->files.end(); +} + +liboai::netimpl::components::Files::const_iterator liboai::netimpl::components::Files::cbegin() const { + return this->files.cbegin(); +} + +liboai::netimpl::components::Files::const_iterator liboai::netimpl::components::Files::cend() const { + return this->files.cend(); +} + +void liboai::netimpl::components::Files::emplace_back(const File& file) { + this->files.emplace_back(file); +} + +void liboai::netimpl::components::Files::push_back(const File& file) { + this->files.push_back(file); +} + +void liboai::netimpl::components::Files::pop_back() { + this->files.pop_back(); +} + +liboai::netimpl::components::Multipart::Multipart(const std::initializer_list<Part>& parts) + : parts{ parts } {} + +liboai::netimpl::components::Parameters::Parameters(const std::initializer_list<Parameter>& parameters) { + this->Add(parameters); +} + +void liboai::netimpl::components::Parameters::Add(const std::initializer_list<Parameter>& parameters) { + for (const auto& parameter : parameters) { + this->parameters_.emplace_back(parameter.key, parameter.value); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Added parameter \"%s\" with value \"%s\".\n", + __func__, parameter.key.c_str(), parameter.value.c_str() + ); + #endif + } +} + +void liboai::netimpl::components::Parameters::Add(const Parameter& parameter) { + this->parameters_.emplace_back(parameter.key, parameter.value); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Added parameter \"%s\" with value \"%s\".\n", + __func__, parameter.key.c_str(), parameter.value.c_str() + ); + #endif +} + +bool liboai::netimpl::components::Parameters::Empty() const { + return this->parameters_.empty(); +} + +std::string liboai::netimpl::components::Parameters::BuildParameterString() const { + std::string parameter_string; + + if (this->parameters_.size() == 1) { + parameter_string += this->parameters_.front().key + "=" + this->parameters_.front().value; + } + else { + for (const auto& parameter : this->parameters_) { + parameter_string += parameter.key + "=" + parameter.value + "&"; + } + parameter_string.pop_back(); + } + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Built parameter string \"%s\".\n", + __func__, parameter_string.c_str() + ); + #endif + + return parameter_string; +} + +void liboai::netimpl::Session::SetOption(const components::Header& header) { + this->SetHeader(header); +} + +void liboai::netimpl::Session::SetHeader(const components::Header& header) { + CURLcode e; + + for (const std::pair<const std::string, std::string>& item : header) { + std::string header_string = item.first; + if (item.second.empty()) { + header_string += ";"; + } else { + header_string += ": " + item.second; + } + + curl_slist* temp = curl_slist_append(this->headers, header_string.c_str()); + if (temp) { + this->headers = temp; + } + } + + curl_slist* temp; +// Causes cURL error for simple GET requests +// curl_slist* temp = curl_slist_append(this->headers, "Transfer-Encoding: chunked"); +// if (temp) { +// this->headers = temp; +// } + + // remove preset curl headers for files >1MB + temp = curl_slist_append(this->headers, "Expect:"); + if (temp) { + this->headers = temp; + } + + e = curl_easy_setopt(this->curl_, CURLOPT_HTTPHEADER, this->headers); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Set headers.\n", + __func__ + ); + #endif + + ErrorCheck(e, "liboai::netimpl::Session::SetHeader()"); +} + +void liboai::netimpl::Session::SetOption(const components::Parameters& parameters) { + this->SetParameters(parameters); +} + +void liboai::netimpl::Session::SetParameters(const components::Parameters& parameters) { + if (!parameters.Empty()) { + this->parameter_string_ = parameters.BuildParameterString(); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Set parameters.\n", + __func__ + ); + #endif + } +} + +void liboai::netimpl::Session::SetOption(components::Parameters&& parameters) { + this->SetParameters(std::move(parameters)); +} + +void liboai::netimpl::Session::SetParameters(components::Parameters&& parameters) { + if (!parameters.Empty()) { + this->parameter_string_ = parameters.BuildParameterString(); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Set parameters.\n", + __func__ + ); + #endif + } +} + +void liboai::netimpl::Session::SetOption(const components::Timeout& timeout) { + this->SetTimeout(timeout); +} + +void liboai::netimpl::Session::SetTimeout(const components::Timeout& timeout) { + const long ms = timeout.Milliseconds(); + CURLcode e = curl_easy_setopt(this->curl_, CURLOPT_TIMEOUT_MS, ms); + ErrorCheck(e, "liboai::netimpl::Session::SetTimeout()"); + + /* Connection phase (DNS/TCP/TLS) is governed separately; without this, some stacks + can sit in connect/handshake longer than expected while the transfer timer is idle. */ + if (ms > 0) { + /* Avoid std::min — Windows headers may define min() as a macro. */ + const long connect_ms = (ms < 120000L) ? ms : 120000L; + e = curl_easy_setopt(this->curl_, CURLOPT_CONNECTTIMEOUT_MS, connect_ms); + ErrorCheck(e, "liboai::netimpl::Session::SetTimeout() (connect)"); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Set connect timeout to %ld milliseconds\n", + __func__, connect_ms + ); + #endif + } + + e = curl_easy_setopt(this->curl_, CURLOPT_NOSIGNAL, 1L); + ErrorCheck(e, "liboai::netimpl::Session::SetTimeout() (nosignal)"); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Set timeout to %ld milliseconds\n", + __func__, ms + ); + #endif +} + +void liboai::netimpl::Session::SetOption(const components::Proxies& proxies) { + this->SetProxies(proxies); +} + +void liboai::netimpl::Session::SetProxies(const components::Proxies& proxies) { + this->proxies_ = proxies; + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Set proxies.\n", + __func__ + ); + #endif +} + +void liboai::netimpl::Session::SetOption(components::Proxies&& proxies) { + this->SetProxies(std::move(proxies)); +} + +void liboai::netimpl::Session::SetProxies(components::Proxies&& proxies) { + this->proxies_ = std::move(proxies); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Set proxies.\n", + __func__ + ); + #endif +} + +void liboai::netimpl::Session::SetOption(const components::ProxyAuthentication& proxy_auth) { + this->SetProxyAuthentication(proxy_auth); +} + +void liboai::netimpl::Session::SetProxyAuthentication(const components::ProxyAuthentication& proxy_auth) { + this->proxyAuth_ = proxy_auth; + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Set proxy authentication.\n", + __func__ + ); + #endif +} + +void liboai::netimpl::Session::SetOption(components::ProxyAuthentication&& proxy_auth) { + this->SetProxyAuthentication(std::move(proxy_auth)); +} + +void liboai::netimpl::Session::SetProxyAuthentication(components::ProxyAuthentication&& proxy_auth) { + this->proxyAuth_ = std::move(proxy_auth); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Set proxy authentication.\n", + __func__ + ); + #endif +} + +void liboai::netimpl::Session::SetOption(const components::WriteCallback& write) { + this->SetWriteCallback(write); +} + +void liboai::netimpl::Session::SetWriteCallback(const components::WriteCallback& write) { + if (write.callback) { + CURLcode e[2]; memset(e, CURLcode::CURLE_OK, sizeof(e)); + + e[0] = curl_easy_setopt(this->curl_, CURLOPT_WRITEFUNCTION, components::writeUserFunction); + this->write_ = write; + e[1] = curl_easy_setopt(this->curl_, CURLOPT_WRITEDATA, &this->write_); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Set user supplied write callback.\n", + __func__ + ); + #endif + + ErrorCheck(e, 2, "liboai::netimpl::Session::SetWriteCallback()"); + } +} + +void liboai::netimpl::Session::SetOption(components::WriteCallback&& write) { + this->SetWriteCallback(std::move(write)); +} + +void liboai::netimpl::Session::SetWriteCallback(components::WriteCallback&& write) { + if (write.callback) { + CURLcode e[2]; memset(e, CURLcode::CURLE_OK, sizeof(e)); + + e[0] = curl_easy_setopt(this->curl_, CURLOPT_WRITEFUNCTION, components::writeUserFunction); + this->write_ = std::move(write); + e[1] = curl_easy_setopt(this->curl_, CURLOPT_WRITEDATA, &this->write_); + + #if defined(LIBOAI_DEBUG) + _liboai_dbg( + "[dbg] [@%s] Set user supplied write callback.\n", + __func__ + ); + #endif + + ErrorCheck(e, 2, "liboai::netimpl::Session::SetWriteCallback()"); + } +} + +liboai::netimpl::components::Proxies::Proxies(const std::initializer_list<std::pair<const std::string, std::string>>& hosts) + : hosts_{ hosts } {} + +liboai::netimpl::components::Proxies::Proxies(const std::map<std::string, std::string>& hosts) + : hosts_{hosts} {} + +bool liboai::netimpl::components::Proxies::has(const std::string& protocol) const { + return hosts_.count(protocol) > 0; +} + +const std::string& liboai::netimpl::components::Proxies::operator[](const std::string& protocol) { + return hosts_[protocol]; +} + +liboai::netimpl::components::EncodedAuthentication::~EncodedAuthentication() noexcept { + this->SecureStringClear(this->username); + this->SecureStringClear(this->password); +} + +const std::string& liboai::netimpl::components::EncodedAuthentication::GetUsername() const { + return this->username; +} + +const std::string& liboai::netimpl::components::EncodedAuthentication::GetPassword() const { + return this->password; +} + +#if defined(__STDC_LIB_EXT1__) +void liboai::netimpl::components::EncodedAuthentication::SecureStringClear(std::string& s) { + if (s.empty()) { + return; + } + memset_s(&s.front(), s.length(), 0, s.length()); + s.clear(); +} +#elif defined(_WIN32) +void liboai::netimpl::components::EncodedAuthentication::SecureStringClear(std::string& s) { + if (s.empty()) { + return; + } + SecureZeroMemory(&s.front(), s.length()); + s.clear(); +} +#else +#if defined(__clang__) +#pragma clang optimize off // clang +#elif defined(__GNUC__) || defined(__MINGW32__) || defined(__MINGW32__) || defined(__MINGW64__) +#pragma GCC push_options // g++ +#pragma GCC optimize("O0") // g++ +#endif +void liboai::netimpl::components::EncodedAuthentication::SecureStringClear(std::string& s) { + if (s.empty()) { + return; + } + + char* ptr = &(s[0]); + memset(ptr, '\0', s.length()); + s.clear(); +} + +#if defined(__clang__) +#pragma clang optimize on // clang +#elif defined(__GNUC__) || defined(__MINGW32__) || defined(__MINGW32__) || defined(__MINGW64__) +#pragma GCC pop_options // g++ +#endif +#endif + +bool liboai::netimpl::components::ProxyAuthentication::has(const std::string& protocol) const { + return proxyAuth_.count(protocol) > 0; +} + +const char* liboai::netimpl::components::ProxyAuthentication::GetUsername(const std::string& protocol) { + return proxyAuth_[protocol].username.c_str(); +} + +const char* liboai::netimpl::components::ProxyAuthentication::GetPassword(const std::string& protocol) { + return proxyAuth_[protocol].password.c_str(); +} + +void liboai::netimpl::ErrorCheck(CURLcode* ecodes, size_t size, std::string_view where) { + if (ecodes) { + for (size_t i = 0; i < size; ++i) { + if (ecodes[i] != CURLE_OK) { + throw liboai::exception::OpenAIException( + curl_easy_strerror(ecodes[i]), + liboai::exception::EType::E_CURLERROR, + where + ); + } + } + } +} + +void liboai::netimpl::ErrorCheck(CURLcode ecode, std::string_view where) { + if (ecode != CURLE_OK) { + throw liboai::exception::OpenAIException( + curl_easy_strerror(ecode), + liboai::exception::EType::E_CURLERROR, + where + ); + } +} + +#if LIBCURL_VERSION_MAJOR < 7 || (LIBCURL_VERSION_MAJOR == 7 && LIBCURL_VERSION_MINOR < 56) + void liboai::netimpl::ErrorCheck(CURLFORMcode* ecodes, size_t size, std::string_view where) { + if (ecodes) { + for (size_t i = 0; i < size; ++i) { + if (ecodes[i] != CURL_FORMADD_OK) { + throw liboai::exception::OpenAIException( + "curl_formadd() failed.", + liboai::exception::EType::E_CURLERROR, + where + ); + } + } + } + } + + void liboai::netimpl::ErrorCheck(CURLFORMcode ecode, std::string_view where) { + if (ecode != CURL_FORMADD_OK) { + throw liboai::exception::OpenAIException( + "curl_formadd() failed.", + liboai::exception::EType::E_CURLERROR, + where + ); + } + } +#endif diff --git a/packages/media/cpp/packages/liboai/liboai/core/response.cpp b/packages/media/cpp/packages/liboai/liboai/core/response.cpp new file mode 100644 index 00000000..8a0d19dd --- /dev/null +++ b/packages/media/cpp/packages/liboai/liboai/core/response.cpp @@ -0,0 +1,113 @@ +#include "../include/core/response.h" + +liboai::Response::Response(const liboai::Response& other) noexcept + : status_code(other.status_code), elapsed(other.elapsed), status_line(other.status_line), + content(other.content), url(other.url), reason(other.reason), raw_json(other.raw_json) {} + +liboai::Response::Response(liboai::Response&& other) noexcept + : status_code(other.status_code), elapsed(other.elapsed), status_line(std::move(other.status_line)), + content(std::move(other.content)), url(std::move(other.url)), reason(std::move(other.reason)), raw_json(std::move(other.raw_json)) {} + +liboai::Response::Response(std::string&& url, std::string&& content, std::string&& status_line, std::string&& reason, long status_code, double elapsed) noexcept(false) + : status_code(status_code), elapsed(elapsed), status_line(std::move(status_line)), + content(std::move(content)), url(url), reason(std::move(reason)) +{ + try { + if (!this->content.empty()) { + if (this->content[0] == '{') { + this->raw_json = nlohmann::json::parse(this->content); + } + else { + this->raw_json = nlohmann::json(); + } + } + else { + this->raw_json = nlohmann::json(); + } + } + catch (nlohmann::json::parse_error& e) { + throw liboai::exception::OpenAIException( + e.what(), + liboai::exception::EType::E_FAILURETOPARSE, + "liboai::Response::Response(std::string&&, std::string&&, ...)" + ); + } + + // check the response for errors -- nothrow on success + this->CheckResponse(); +} + +liboai::Response& liboai::Response::operator=(const liboai::Response& other) noexcept { + this->status_code = other.status_code; + this->elapsed = other.elapsed; + this->status_line = other.status_line; + this->content = other.content; + this->url = other.url; + this->reason = other.reason; + this->raw_json = other.raw_json; + + return *this; +} + +liboai::Response& liboai::Response::operator=(liboai::Response&& other) noexcept { + this->status_code = other.status_code; + this->elapsed = other.elapsed; + this->status_line = std::move(other.status_line); + this->content = std::move(other.content); + this->url = std::move(other.url); + this->reason = std::move(other.reason); + this->raw_json = std::move(other.raw_json); + + return *this; +} + +namespace liboai { + +std::ostream& operator<<(std::ostream& os, const Response& r) { + !r.raw_json.empty() ? os << r.raw_json.dump(4) : os << "null"; + return os; +} + +} + +void liboai::Response::CheckResponse() const noexcept(false) { + if (this->status_code == 429) { + throw liboai::exception::OpenAIRateLimited( + !this->reason.empty() ? this->reason : "Rate limited", + liboai::exception::EType::E_RATELIMIT, + "liboai::Response::CheckResponse()" + ); + } + else if (this->status_code == 0) { + throw liboai::exception::OpenAIException( + "A connection error occurred", + liboai::exception::EType::E_CONNECTIONERROR, + "liboai::Response::CheckResponse()" + ); + } + else if (this->status_code < 200 || this->status_code >= 300) { + if (this->raw_json.contains("error")) { + try { + throw liboai::exception::OpenAIException( + this->raw_json["error"]["message"].get<std::string>(), + liboai::exception::EType::E_APIERROR, + "liboai::Response::CheckResponse()" + ); + } + catch (nlohmann::json::parse_error& e) { + throw liboai::exception::OpenAIException( + e.what(), + liboai::exception::EType::E_FAILURETOPARSE, + "liboai::Response::CheckResponse()" + ); + } + } + else { + throw liboai::exception::OpenAIException( + !this->reason.empty() ? this->reason : "An unknown error occurred", + liboai::exception::EType::E_BADREQUEST, + "liboai::Response::CheckResponse()" + ); + } + } +} diff --git a/packages/media/cpp/packages/liboai/liboai/include/components/audio.h b/packages/media/cpp/packages/liboai/liboai/include/components/audio.h new file mode 100644 index 00000000..4861bafd --- /dev/null +++ b/packages/media/cpp/packages/liboai/liboai/include/components/audio.h @@ -0,0 +1,199 @@ +#pragma once + +/* + audio.h : Audio component class for OpenAI. + This class contains all the methods for the Audio component + of the OpenAI API. This class provides access to 'Audio' + endpoints on the OpenAI API and should be accessed via the + liboai.h header file through an instantiated liboai::OpenAI + object after setting necessary authentication information + through the liboai::Authorization::Authorizer() singleton + object. +*/ + +#include "../core/authorization.h" +#include "../core/response.h" + +namespace liboai { + class Audio final : private Network { + public: + Audio(const std::string &root): Network(root) {} + ~Audio() = default; + Audio(const Audio&) = delete; + Audio(Audio&&) = delete; + + Audio& operator=(const Audio&) = delete; + Audio& operator=(Audio&&) = delete; + + /* + @brief Transcribes audio into the input language. + + @param *file The audio file to transcribe. + @param *model The model to use for transcription. + Only 'whisper-1' is currently available. + @param prompt An optional text to guide the model's style + or continue a previous audio segment. The + prompt should match the audio language. + @param response_format The format of the transcript output. + @param temperature The sampling temperature, between 0 and 1. + Higher values like 0.8 will make the output + more random, while lower values like 0.2 + will make it more focused and deterministic. + If set to 0, the model will use log probability + to automatically increase the temperature until + certain thresholds are hit. + @param language The language of the audio file. + + @returns A liboai::Response object containing the + data in JSON format. + */ + LIBOAI_EXPORT liboai::Response transcribe( + const std::filesystem::path& file, + const std::string& model, + std::optional<std::string> prompt = std::nullopt, + std::optional<std::string> response_format = std::nullopt, + std::optional<float> temperature = std::nullopt, + std::optional<std::string> language = std::nullopt + ) const & noexcept(false); + + /* + @brief Asynchronously transcribes audio into the input language. + + @param *file The audio file to transcribe. + @param *model The model to use for transcription. + Only 'whisper-1' is currently available. + @param prompt An optional text to guide the model's style + or continue a previous audio segment. The + prompt should match the audio language. + @param response_format The format of the transcript output. + @param temperature The sampling temperature, between 0 and 1. + Higher values like 0.8 will make the output + more random, while lower values like 0.2 + will make it more focused and deterministic. + If set to 0, the model will use log probability + to automatically increase the temperature until + certain thresholds are hit. + @param language The language of the audio file. + + @returns A liboai::Response future containing the + data in JSON format. + */ + LIBOAI_EXPORT liboai::FutureResponse transcribe_async( + const std::filesystem::path& file, + const std::string& model, + std::optional<std::string> prompt = std::nullopt, + std::optional<std::string> response_format = std::nullopt, + std::optional<float> temperature = std::nullopt, + std::optional<std::string> language = std::nullopt + ) const& noexcept(false); + + /* + @brief Translates audio into English. + + @param *file The audio file to translate. + @param *model The model to use for translation. + Only 'whisper-1' is currently available. + @param prompt An optional text to guide the model's style + or continue a previous audio segment. + @param response_format The format of the transcript output. + @param temperature The sampling temperature, between 0 and 1. + Higher values like 0.8 will make the output + more random, while lower values like 0.2 + will make it more focused and deterministic. + If set to 0, the model will use log probability + to automatically increase the temperature until + certain thresholds are hit. + + @returns A liboai::Response object containing the + data in JSON format. + */ + LIBOAI_EXPORT liboai::Response translate( + const std::filesystem::path& file, + const std::string& model, + std::optional<std::string> prompt = std::nullopt, + std::optional<std::string> response_format = std::nullopt, + std::optional<float> temperature = std::nullopt + ) const & noexcept(false); + + /* + @brief Asynchronously translates audio into English. + + @param *file The audio file to translate. + @param *model The model to use for translation. + Only 'whisper-1' is currently available. + @param prompt An optional text to guide the model's style + or continue a previous audio segment. + @param response_format The format of the transcript output. + @param temperature The sampling temperature, between 0 and 1. + Higher values like 0.8 will make the output + more random, while lower values like 0.2 + will make it more focused and deterministic. + If set to 0, the model will use log probability + to automatically increase the temperature until + certain thresholds are hit. + + @returns A liboai::Response future containing the + data in JSON format. + */ + LIBOAI_EXPORT liboai::FutureResponse translate_async( + const std::filesystem::path& file, + const std::string& model, + std::optional<std::string> prompt = std::nullopt, + std::optional<std::string> response_format = std::nullopt, + std::optional<float> temperature = std::nullopt + ) const& noexcept(false); + + /* + @brief Turn text into lifelike spoken audio. + + @param *model The model to use for translation. + Only 'tts-1' and 'tts-1-hd' are currently available. + @param *voice The voice to use when generating the audio. + Supported voices are alloy, echo, fable, onyx, nova, and shimmer. + @param *input The text to generate audio for. + The maximum length is 4096 characters. + @param response_format The format to audio in. + Supported formats are mp3, opus, aac, flac, wav, and pcm. + @param speed The speed of the generated audio. + Select a value from 0.25 to 4.0. 1.0 is the default. + + @returns A liboai::Response object containing the + data in JSON format. + */ + LIBOAI_EXPORT liboai::Response speech( + const std::string& model, + const std::string& voice, + const std::string& input, + std::optional<std::string> response_format = std::nullopt, + std::optional<float> speed = std::nullopt + ) const& noexcept(false); + + /* + @brief Asynchronously turn text into lifelike spoken audio. + + @param *model The model to use for translation. + Only 'tts-1' and 'tts-1-hd' are currently available. + @param *voice The voice to use when generating the audio. + Supported voices are alloy, echo, fable, onyx, nova, and shimmer. + @param *input The text to generate audio for. + The maximum length is 4096 characters. + @param response_format The format to audio in. + Supported formats are mp3, opus, aac, flac, wav, and pcm. + @param speed The speed of the generated audio. + Select a value from 0.25 to 4.0. 1.0 is the default. + + @returns A liboai::Response object containing the + data in JSON format. + */ + LIBOAI_EXPORT liboai::FutureResponse speech_async( + const std::string& model, + const std::string& voice, + const std::string& input, + std::optional<std::string> response_format = std::nullopt, + std::optional<float> speed = std::nullopt + ) const& noexcept(false); + + private: + Authorization& auth_ = Authorization::Authorizer(); + }; +} diff --git a/packages/media/cpp/packages/liboai/liboai/include/components/azure.h b/packages/media/cpp/packages/liboai/liboai/include/components/azure.h new file mode 100644 index 00000000..8e064020 --- /dev/null +++ b/packages/media/cpp/packages/liboai/liboai/include/components/azure.h @@ -0,0 +1,304 @@ +#pragma once + +/* + azure.h : Azure component class for OpenAI. + Azure provides their own API for access to the OpenAI API. + This class provides methods that, provided that the proper + Azure authentication information has been set, allows users + to access the OpenAI API through Azure. +*/ + +#include "../core/authorization.h" +#include "../core/response.h" +#include "chat.h" + +namespace liboai { + class Azure final : private Network { + public: + Azure(const std::string &root): Network(root) {} + NON_COPYABLE(Azure) + NON_MOVABLE(Azure) + ~Azure() = default; + + using ChatStreamCallback = std::function<bool(std::string, intptr_t, Conversation&)>; + using StreamCallback = std::function<bool(std::string, intptr_t)>; + + /* + @brief Given a prompt, the model will return one or more + predicted completions, and can also return the + probabilities of alternative tokens at each position. + + @param *resource_name The name of your Azure OpenAI Resource. + @param *deployment_id The deployment name you chose when you deployed the model. + @param *api_version The API version to use for this operation. This follows the YYYY-MM-DD format. + @param Refer to liboai::Completions::create for more information on the remaining parameters. + + @returns A liboai::Response object containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::Response create_completion( + const std::string& resource_name, + const std::string& deployment_id, + const std::string& api_version, + std::optional<std::string> prompt = std::nullopt, + std::optional<std::string> suffix = std::nullopt, + std::optional<uint16_t> max_tokens = std::nullopt, + std::optional<float> temperature = std::nullopt, + std::optional<float> top_p = std::nullopt, + std::optional<uint16_t> n = std::nullopt, + std::optional<StreamCallback> stream = std::nullopt, + std::optional<uint8_t> logprobs = std::nullopt, + std::optional<bool> echo = std::nullopt, + std::optional<std::vector<std::string>> stop = std::nullopt, + std::optional<float> presence_penalty = std::nullopt, + std::optional<float> frequency_penalty = std::nullopt, + std::optional<uint16_t> best_of = std::nullopt, + std::optional<std::unordered_map<std::string, int8_t>> logit_bias = std::nullopt, + std::optional<std::string> user = std::nullopt + ) const & noexcept(false); + + /* + @brief Given a prompt, the model will asynchronously return + one or more predicted completions, and can also return the + probabilities of alternative tokens at each position. + + @param *resource_name The name of your Azure OpenAI Resource. + @param *deployment_id The deployment name you chose when you deployed the model. + @param *api_version The API version to use for this operation. This follows the YYYY-MM-DD format. + @param Refer to liboai::Completions::create for more information on the remaining parameters. + + @returns A liboai::Response object containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::FutureResponse create_completion_async( + const std::string& resource_name, + const std::string& deployment_id, + const std::string& api_version, + std::optional<std::string> prompt = std::nullopt, + std::optional<std::string> suffix = std::nullopt, + std::optional<uint16_t> max_tokens = std::nullopt, + std::optional<float> temperature = std::nullopt, + std::optional<float> top_p = std::nullopt, + std::optional<uint16_t> n = std::nullopt, + std::optional<StreamCallback> stream = std::nullopt, + std::optional<uint8_t> logprobs = std::nullopt, + std::optional<bool> echo = std::nullopt, + std::optional<std::vector<std::string>> stop = std::nullopt, + std::optional<float> presence_penalty = std::nullopt, + std::optional<float> frequency_penalty = std::nullopt, + std::optional<uint16_t> best_of = std::nullopt, + std::optional<std::unordered_map<std::string, int8_t>> logit_bias = std::nullopt, + std::optional<std::string> user = std::nullopt + ) const & noexcept(false); + + /* + @brief Creates an embedding vector representing the input text. + + @param *resource_name The name of your Azure OpenAI Resource. + @param *deployment_id The deployment name you chose when you deployed the model. + @param *api_version The API version to use for this operation. This follows the YYYY-MM-DD format. + @param *input Input text to get embeddings for, encoded as a string. The number of input tokens + varies depending on what model you are using. + @param Refer to liboai::Embeddings::create for more information on the remaining parameters. + + @return A liboai::Response object containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::Response create_embedding( + const std::string& resource_name, + const std::string& deployment_id, + const std::string& api_version, + const std::string& input, + std::optional<std::string> user = std::nullopt + ) const & noexcept(false); + + /* + @brief Asynchronously creates an embedding vector representing the input text. + + @param *resource_name The name of your Azure OpenAI Resource. + @param *deployment_id The deployment name you chose when you deployed the model. + @param *api_version The API version to use for this operation. This follows the YYYY-MM-DD format. + @param *input Input text to get embeddings for, encoded as a string. The number of input tokens + varies depending on what model you are using. + @param Refer to liboai::Embeddings::create for more information on the remaining parameters. + + @return A liboai::Response object containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::FutureResponse create_embedding_async( + const std::string& resource_name, + const std::string& deployment_id, + const std::string& api_version, + const std::string& input, + std::optional<std::string> user = std::nullopt + ) const & noexcept(false); + + /* + @brief Creates a completion for the chat message. + + @param *resource_name The name of your Azure OpenAI Resource. + @param *deployment_id The deployment name you chose when you deployed the model. + @param *api_version The API version to use for this operation. This follows the YYYY-MM-DD format. + @param *conversation A Conversation object containing the conversation data. + @param Refer to liboai::Chat::create for more information on the remaining parameters. + + @returns A liboai::Response object containing the + data in JSON format. + */ + LIBOAI_EXPORT liboai::Response create_chat_completion( + const std::string& resource_name, + const std::string& deployment_id, + const std::string& api_version, + Conversation& conversation, + std::optional<std::string> function_call = std::nullopt, + std::optional<float> temperature = std::nullopt, + std::optional<uint16_t> n = std::nullopt, + std::optional<ChatStreamCallback> stream = std::nullopt, + std::optional<std::vector<std::string>> stop = std::nullopt, + std::optional<uint16_t> max_tokens = std::nullopt, + std::optional<float> presence_penalty = std::nullopt, + std::optional<float> frequency_penalty = std::nullopt, + std::optional<std::unordered_map<std::string, int8_t>> logit_bias = std::nullopt, + std::optional<std::string> user = std::nullopt + ) const & noexcept(false); + + /* + @brief Asynchronously creates a completion for the chat message. + + @param *resource_name The name of your Azure OpenAI Resource. + @param *deployment_id The deployment name you chose when you deployed the model. + @param *api_version The API version to use for this operation. This follows the YYYY-MM-DD format. + @param *conversation A Conversation object containing the conversation data. + @param Refer to liboai::Chat::create for more information on the remaining parameters. + + @returns A liboai::Response object containing the + data in JSON format. + */ + LIBOAI_EXPORT liboai::FutureResponse create_chat_completion_async( + const std::string& resource_name, + const std::string& deployment_id, + const std::string& api_version, + Conversation& conversation, + std::optional<std::string> function_call = std::nullopt, + std::optional<float> temperature = std::nullopt, + std::optional<uint16_t> n = std::nullopt, + std::optional<ChatStreamCallback> stream = std::nullopt, + std::optional<std::vector<std::string>> stop = std::nullopt, + std::optional<uint16_t> max_tokens = std::nullopt, + std::optional<float> presence_penalty = std::nullopt, + std::optional<float> frequency_penalty = std::nullopt, + std::optional<std::unordered_map<std::string, int8_t>> logit_bias = std::nullopt, + std::optional<std::string> user = std::nullopt + ) const & noexcept(false); + + /* + @brief Generate a batch of images from a text caption. + Image generation is currently only available with api-version=2023-06-01-preview. + + @param *resource_name The name of your Azure OpenAI Resource. + @param *api_version The API version to use for this operation. This follows the YYYY-MM-DD format. + @param *prompt The text to create an image from. + @param n The number of images to create. + @param size The size of the image to create. + + @returns A liboai::Response object containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::Response request_image_generation( + const std::string& resource_name, + const std::string& api_version, + const std::string& prompt, + std::optional<uint8_t> n = std::nullopt, + std::optional<std::string> size = std::nullopt + ) const & noexcept(false); + + /* + @brief Asynchronously generate a batch of images from a text caption. + Image generation is currently only available with api-version=2023-06-01-preview. + + @param *resource_name The name of your Azure OpenAI Resource. + @param *api_version The API version to use for this operation. This follows the YYYY-MM-DD format. + @param *prompt The text to create an image from. + @param n The number of images to create. + @param size The size of the image to create. + + @returns A liboai::Response object containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::FutureResponse request_image_generation_async( + const std::string& resource_name, + const std::string& api_version, + const std::string& prompt, + std::optional<uint8_t> n = std::nullopt, + std::optional<std::string> size = std::nullopt + ) const & noexcept(false); + + /* + @brief Retrieve the results (URL) of a previously called image generation operation. + + @param *resource_name The name of your Azure OpenAI Resource. + @param *api_version The API version to use for this operation. This follows the YYYY-MM-DD format. + @param *operation_id The GUID that identifies the original image generation request. + + @returns A liboai::Response object containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::Response get_generated_image( + const std::string& resource_name, + const std::string& api_version, + const std::string& operation_id + ) const & noexcept(false); + + /* + @brief Asynchronously retrieve the results (URL) of a previously called image generation operation. + + @param *resource_name The name of your Azure OpenAI Resource. + @param *api_version The API version to use for this operation. This follows the YYYY-MM-DD format. + @param *operation_id The GUID that identifies the original image generation request. + + @returns A liboai::Response object containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::FutureResponse get_generated_image_async( + const std::string& resource_name, + const std::string& api_version, + const std::string& operation_id + ) const & noexcept(false); + + /* + @brief Deletes the corresponding image from the Azure server. + + @param *resource_name The name of your Azure OpenAI Resource. + @param *api_version The API version to use for this operation. This follows the YYYY-MM-DD format. + @param *operation_id The GUID that identifies the original image generation request. + + @returns A liboai::Response object containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::Response delete_generated_image( + const std::string& resource_name, + const std::string& api_version, + const std::string& operation_id + ) const & noexcept(false); + + /* + @brief Asynchronously deletes the corresponding image from the Azure server. + + @param *resource_name The name of your Azure OpenAI Resource. + @param *api_version The API version to use for this operation. This follows the YYYY-MM-DD format. + @param *operation_id The GUID that identifies the original image generation request. + + @returns A liboai::Response object containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::FutureResponse delete_generated_image_async( + const std::string& resource_name, + const std::string& api_version, + const std::string& operation_id + ) const & noexcept(false); + + private: + Authorization& auth_ = Authorization::Authorizer(); + using StrippedStreamCallback = std::function<bool(std::string, intptr_t)>; + }; +} \ No newline at end of file diff --git a/packages/media/cpp/packages/liboai/liboai/include/components/chat.h b/packages/media/cpp/packages/liboai/liboai/include/components/chat.h new file mode 100644 index 00000000..c04f1fbd --- /dev/null +++ b/packages/media/cpp/packages/liboai/liboai/include/components/chat.h @@ -0,0 +1,982 @@ +#pragma once + +/* + chat.h : Chat component header file + This class contains all the methods for the Chat component + of the OpenAI API. This class provides access to 'Chat' + endpoints on the OpenAI API and should be accessed via the + liboai.h header file through an instantiated liboai::OpenAI + object after setting necessary authentication information + through the liboai::Authorization::Authorizer() singleton + object. +*/ + +#include "../core/authorization.h" +#include "../core/response.h" + +#include <limits> +#include <optional> +#include <nlohmann/json.hpp> + +namespace liboai { + /* + @brief Class containing methods for building Function objects to supply + to the OpenAI ChatCompletions component class via the associated + Conversation class. + */ + class Functions final { + public: + Functions(); + Functions(const Functions& other); + Functions(Functions&& old) noexcept; + template <class... _Fname, + std::enable_if_t<std::conjunction_v<std::is_convertible<_Fname, std::string_view>...>, int> = 0> + Functions(_Fname... function_names) { auto result = this->AddFunctions(function_names...); } + ~Functions() = default; + + Functions& operator=(const Functions& other); + Functions& operator=(Functions&& old) noexcept; + + /* + @brief Denotes a parameter of a function, which includes + the parameter's name, type, description, and an optional + enumeration. + + @param name The name of the parameter. + @param type The type of the parameter. + @param description The description of the parameter. + @param enumeration An optional enumeration of possible + values for the parameter. + */ + struct FunctionParameter { + FunctionParameter() = default; + FunctionParameter( + std::string_view name, + std::string_view type, + std::string_view description, + std::optional<std::vector<std::string>> enumeration = std::nullopt + ) : name(name), type(type), description(description), enumeration(enumeration) {} + FunctionParameter(const FunctionParameter& other) = default; + FunctionParameter(FunctionParameter&& old) noexcept = default; + ~FunctionParameter() = default; + + FunctionParameter& operator=(const FunctionParameter& other) = default; + FunctionParameter& operator=(FunctionParameter&& old) noexcept = default; + + std::string name; + std::string type; + std::string description; + std::optional<std::vector<std::string>> enumeration; + }; + + /* + @brief Adds a function named 'function_name' to the list of + functions. This function, once added, can then be + referenced in subsequent 'Functions' class method calls + by the name provided here. + + @param *function_name The name of the function to add. + + @returns True/False denoting whether the function was added + successfully. + */ + [[nodiscard]] + LIBOAI_EXPORT bool AddFunction(std::string_view function_name) & noexcept(false); + + /* + @brief Same as AddFunction, but allows for adding multiple + functions at once. + + @param *function_names The name of the function to add. + + @returns True/False denoting whether the functions were added + successfully. + */ + [[nodiscard]] + LIBOAI_EXPORT bool AddFunctions(std::initializer_list<std::string_view> function_names) & noexcept(false); + + /* + @brief Same as AddFunction, but allows for adding multiple + functions at once. + + @param *function_names The name of the function to add. + + @returns True/False denoting whether the functions were added + successfully. + */ + [[nodiscard]] + LIBOAI_EXPORT bool AddFunctions(std::vector<std::string> function_names) & noexcept(false); + + /* + @brief Same as AddFunction, but allows for adding multiple + functions at once. + + @param *function_names The name of the function to add. + + @returns True/False denoting whether the functions were added + successfully. + */ + template <class... _Fnames, + std::enable_if_t<std::conjunction_v<std::is_convertible<_Fnames, std::string_view>...>, int> = 0> + [[nodiscard]] bool AddFunctions(_Fnames... function_names) & noexcept(false) { + return this->AddFunctions({ function_names... }); + } + + /* + @brief Pops the specified function from the list of functions. + This will also remove any associated name, description, + parameters, and so on as it involves removing the entire + 'function_name' key from the JSON object. + + @param *function_name The name of the function to pop. + + @returns True/False denoting whether the function was popped + successfully. + */ + [[nodiscard]] + LIBOAI_EXPORT bool PopFunction(std::string_view function_name) & noexcept(false); + + /* + @brief Same as PopFunction, but allows for popping multiple + functions at once. + + @param *function_names The name of the function to pop. + + @returns True/False denoting whether the functions were popped + successfully. + */ + [[nodiscard]] + LIBOAI_EXPORT bool PopFunctions(std::initializer_list<std::string_view> function_names) & noexcept(false); + + /* + @brief Same as PopFunction, but allows for popping multiple + functions at once. + + @param *function_names The name of the function to pop. + + @returns True/False denoting whether the functions were popped + successfully. + */ + [[nodiscard]] + LIBOAI_EXPORT bool PopFunctions(std::vector<std::string> function_names) & noexcept(false); + + /* + @brief Same as PopFunction, but allows for popping multiple + functions at once. + + @param *function_names The name of the function to pop. + + @returns True/False denoting whether the functions were popped + successfully. + */ + template <class... _Fnames, + std::enable_if_t<std::conjunction_v<std::is_convertible<_Fnames, std::string_view>...>, int> = 0> + [[nodiscard]] bool PopFunctions(_Fnames... function_names) & noexcept(false) { + return this->PopFunctions({ function_names... }); + } + + /* + @brief Sets a previously added function's description. + + @param *target The name of the function to set the description of. + @param *description The description to set for the function. + + @returns True/False denoting whether the description was set + successfully. + */ + [[nodiscard]] + LIBOAI_EXPORT bool SetDescription(std::string_view target, std::string_view description) & noexcept(false); + + /* + @brief Pops a previously added function's description. + + @param *target The name of the function to pop the description of. + + @returns True/False denoting whether the description was popped + successfully. + */ + [[nodiscard]] + LIBOAI_EXPORT bool PopDescription(std::string_view target) & noexcept(false); + + /* + @brief Sets which set function parameters are required. + + @param *target The name of the function to set the required parameters of. + @param *params A series of parameter names to set as required. + + @returns True/False denoting whether the required parameters were set + successfully. + */ + [[nodiscard]] + LIBOAI_EXPORT bool SetRequired(std::string_view target, std::initializer_list<std::string_view> params) & noexcept(false); + + /* + @brief Sets which set function parameters are required. + + @param *target The name of the function to set the required parameters of. + @param *params A series of parameter names to set as required. + + @returns True/False denoting whether the required parameters were set + successfully. + */ + [[nodiscard]] + LIBOAI_EXPORT bool SetRequired(std::string_view target, std::vector<std::string> params) & noexcept(false); + + /* + @brief Sets which set function parameters are required. + + @param *target The name of the function to set the required parameters of. + @param *params A series of parameter names to set as required. + + @returns True/False denoting whether the required parameters were set + successfully. + */ + template <class... _Rp, + std::enable_if_t<std::conjunction_v<std::is_convertible<_Rp, std::string_view>...>, int> = 0> + [[nodiscard]] bool SetRequired(std::string_view target, _Rp... params) & noexcept(false) { + return SetRequired(target, { params... }); + } + + /* + @brief Pops previously set required function parameters. + + @param *target The name of the function to pop the required parameters of. + + @returns True/False denoting whether the required parameters were popped + successfully. + */ + [[nodiscard]] + LIBOAI_EXPORT bool PopRequired(std::string_view target) & noexcept(false); + + /* + @brief Appends a parameter to a previously set series of required function + parameters. This function should only be called if required parameters + have already been set for 'target' via SetRequired(). + + @param *target The name of the function to append the required parameter to. + @param *param The name of the parameter to append to the required parameters. + + @returns True/False denoting whether the required parameter was appended + successfully. + */ + [[nodiscard]] + LIBOAI_EXPORT bool AppendRequired(std::string_view target, std::string_view param) & noexcept(false); + + /* + @brief Appends multiple parameters to a previously set series of required function + parameters. This function should only be called if required parameters have + already been set for 'target' via SetRequired(). + + @param *target The name of the function to append the required parameter to. + @param *params The name of the parameters to append to the required parameters. + + @returns True/False denoting whether the required parameter was appended + successfully. + */ + [[nodiscard]] + LIBOAI_EXPORT bool AppendRequired(std::string_view target, std::initializer_list<std::string_view> params) & noexcept(false); + + /* + @brief Appends multiple parameters to a previously set series of required function + parameters. This function should only be called if required parameters have + already been set for 'target' via SetRequired(). + + @param *target The name of the function to append the required parameter to. + @param *params The name of the parameters to append to the required parameters. + + @returns True/False denoting whether the required parameter was appended + successfully. + */ + [[nodiscard]] + LIBOAI_EXPORT bool AppendRequired(std::string_view target, std::vector<std::string> params) & noexcept(false); + + /* + @brief Appends multiple parameters to a previously set series of required function + parameters. This function should only be called if required parameters have + already been set for 'target' via SetRequired(). + + @param *target The name of the function to append the required parameter to. + @param *params The name of the parameters to append to the required parameters. + + @returns True/False denoting whether the required parameter was appended + successfully. + */ + template <class... _Rp, + std::enable_if_t<std::conjunction_v<std::is_convertible<_Rp, std::string_view>...>, int> = 0> + [[nodiscard]] bool AppendRequired(std::string_view target, _Rp... params) & noexcept(false) { + return AppendRequired(target, { params... }); + } + + /* + @brief Adds a single parameter to an added function. + + @param *target The name of the function to add the parameter to. + @param *parameter The parameter to add to the function. + + @returns True/False denoting whether the parameter was added + successfully. + */ + [[nodiscard]] + LIBOAI_EXPORT bool SetParameter(std::string_view target, FunctionParameter parameter) & noexcept(false); + + /* + @brief Adds a series of parameters to an added function. + + @param *target The name of the function to add the parameters to. + @param *parameters The parameters to add to the function. + + @returns True/False denoting whether the parameters were added + successfully. + */ + [[nodiscard]] + LIBOAI_EXPORT bool SetParameters(std::string_view target, std::initializer_list<FunctionParameter> parameters) & noexcept(false); + + /* + @brief Adds a series of parameters to an added function. + + @param *target The name of the function to add the parameters to. + @param *parameters The parameters to add to the function. + + @returns True/False denoting whether the parameters were added + successfully. + */ + [[nodiscard]] + LIBOAI_EXPORT bool SetParameters(std::string_view target, std::vector<FunctionParameter> parameters) & noexcept(false); + + /* + @brief Adds a series of parameters to an added function. + + @param *target The name of the function to add the parameters to. + @param *parameters The parameters to add to the function. + + @returns True/False denoting whether the parameters were added + successfully. + */ + template <class... _Fp, + std::enable_if_t<std::conjunction_v<std::is_same<_Fp, FunctionParameter>...>, int> = 0> + [[nodiscard]] bool SetParameters(std::string_view target, _Fp... parameters) & noexcept(false) { + return SetParameters(target, { parameters... }); + } + + /* + @brief Pops all of a function's set parameters. + This function removes set 'required' values and anything + else that falls under the category of 'parameters' as a + result of removing the entire 'parameters' section. + + @param *target The name of the function to pop the parameters of. + + @returns True/False denoting whether the parameters were popped + successfully. + */ + [[nodiscard]] + LIBOAI_EXPORT bool PopParameters(std::string_view target) & noexcept(false); + + /* + @brief Pops one or more of a function's set parameters. + + @param *target The name of the function to pop the parameters of. + @param *params The names of the parameters to pop. + + @returns True/False denoting whether the parameters were popped + successfully. + */ + [[nodiscard]] + LIBOAI_EXPORT bool PopParameters(std::string_view target, std::initializer_list<std::string_view> param_names) & noexcept(false); + + /* + @brief Pops one or more of a function's set parameters. + + @param *target The name of the function to pop the parameters of. + @param *params The names of the parameters to pop. + + @returns True/False denoting whether the parameters were popped + successfully. + */ + [[nodiscard]] + LIBOAI_EXPORT bool PopParameters(std::string_view target, std::vector<std::string> param_names) & noexcept(false); + + /* + @brief Pops one or more of a function's set parameters. + + @param *target The name of the function to pop the parameters of. + @param *params The names of the parameters to pop. + + @returns True/False denoting whether the parameters were popped + successfully. + */ + template <class... _Pname, + std::enable_if_t<std::conjunction_v<std::is_convertible<_Pname, std::string_view>...>, int> = 0> + [[nodiscard]] bool PopParameters(std::string_view target, _Pname... param_names) & noexcept(false) { + return PopParameters(target, { param_names... }); + } + + /* + @brief Appends a single parameter to a previously added function. + + @param *target The name of the function to append the parameter to. + @param *parameter The parameter to append to the function. + + @returns True/False denoting whether the parameter was appended + successfully. + */ + [[nodiscard]] + LIBOAI_EXPORT bool AppendParameter(std::string_view target, FunctionParameter parameter) & noexcept(false); + + /* + @brief Appends a series of parameters to a previously added function. + + @param *target The name of the function to append the parameters to. + @param *parameters The parameters to append to the function. + + @returns True/False denoting whether the parameters were appended + successfully. + */ + [[nodiscard]] + LIBOAI_EXPORT bool AppendParameters(std::string_view target, std::initializer_list<FunctionParameter> parameters) & noexcept(false); + + /* + @brief Appends a series of parameters to a previously added function. + + @param *target The name of the function to append the parameters to. + @param *parameters The parameters to append to the function. + + @returns True/False denoting whether the parameters were appended + successfully. + */ + [[nodiscard]] + LIBOAI_EXPORT bool AppendParameters(std::string_view target, std::vector<FunctionParameter> parameters) & noexcept(false); + + /* + @brief Appends a series of parameters to a previously added function. + + @param *target The name of the function to append the parameters to. + @param *parameters The parameters to append to the function. + + @returns True/False denoting whether the parameters were appended + successfully. + */ + template <class... _Fp, + std::enable_if_t<std::conjunction_v<std::is_same<_Fp, FunctionParameter>...>, int> = 0> + [[nodiscard]] bool AppendParameters(std::string_view target, _Fp... parameters) & noexcept(false) { + return AppendParameters(target, { parameters... }); + } + + /* + @brief Returns the JSON object of the internal conversation. + */ + LIBOAI_EXPORT const nlohmann::json& GetJSON() const & noexcept; + + private: + using index = std::size_t; + [[nodiscard]] index GetFunctionIndex(std::string_view function_name) const & noexcept(false); + + nlohmann::json _functions; + }; + + /* + @brief Class containing, and used for keeping track of, the chat history. + An object of this class should be created, set with system and user data, + and provided to ChatCompletion::create (system is optional). + + The general usage of this class is as follows: + 1. Create a ChatCompletion::Conversation object. + 2. Set the user data, which is the user's input - such as + a question or a command as well as optionally set the + system data to guide how the assistant responds. + 3. Provide the ChatCompletion::Conversation object to + ChatCompletion::create. + 4. Update the ChatCompletion::Conversation object with + the response from the API - either the object or the + response content can be used to update the object. + 5. Retrieve the assistant's response from the + ChatCompletion::Conversation object. + 6. Repeat steps 2, 3, 4 and 5 until the conversation is + complete. + + After providing the object to ChatCompletion::create, the object will + be updated with the 'assistant' response - this response is the + assistant's response to the user's input. A developer could then + retrieve this response and display it to the user, and then set the + next user input in the object and pass it back to ChatCompletion::create, + if desired. + */ + class Conversation final { + public: + Conversation(); + Conversation(const Conversation& other); + Conversation(Conversation&& old) noexcept; + Conversation(std::string_view system_data); + Conversation(std::string_view system_data, std::string_view user_data); + Conversation(std::string_view system_data, std::initializer_list<std::string_view> user_data); + Conversation(std::initializer_list<std::string_view> user_data); + explicit Conversation(const std::vector<std::string>& user_data); + ~Conversation() = default; + + Conversation& operator=(const Conversation& other); + Conversation& operator=(Conversation&& old) noexcept; + + friend std::ostream& operator<<(std::ostream& os, const Conversation& conv); + + + /* + @brief Changes the content of the first system message + in the conversation. This method updates the content + of the first system message in the conversation, if + it exists and is of type "system". If the first message + is not a system message or the conversation is empty, + the method will return false. + + @param new_data A string_view containing the new content + for the system message. Must be non-empty. + + @returns True/False denoting whether the first system + message was changed successfully. + */ + [[nodiscard]] + LIBOAI_EXPORT bool ChangeFirstSystemMessage(std::string_view new_data) & noexcept(false); + + /* + @brief Sets the system data for the conversation. + This method sets the system data for the conversation. + The system data is the data that helps set the behavior + of the assistant so it knows how to respond. + + @param *data The system data to set. + + @returns True/False denoting whether the system data was set + successfully. + */ + [[nodiscard]] + LIBOAI_EXPORT bool SetSystemData(std::string_view data) & noexcept(false); + + /* + @brief Removes the set system data from the top of the conversation. + The system data must be the first data set, if used, + in order to be removed. If the system data is not + the first data set, this method will return false. + + @returns True/False denoting whether the system data was + removed successfully. + */ + [[nodiscard]] + LIBOAI_EXPORT bool PopSystemData() & noexcept(false); + + /* + @brief Adds user input to the conversation. + This method adds user input to the conversation. + The user input is the user's input - such as a question + or a command. + + If using a system prompt, the user input should be + provided after the system prompt is set - i.e. after + SetSystemData() is called. + + @param *data The user input to add. + + @returns True/False denoting whether the user input was + added successfully. + */ + [[nodiscard]] + LIBOAI_EXPORT bool AddUserData(std::string_view data) & noexcept(false); + + /* + @brief Adds user input to the conversation. + This method adds user input to the conversation. + The user input is the user's input - such as a question + or a command. + + If using a system prompt, the user input should be + provided after the system prompt is set - i.e. after + SetSystemData() is called. + + @param *data The user input to add. + @param *name The name of the author of this message. + name is required if role is function, and + it should be the name of the function whose + response is in the content. + + @returns True/False denoting whether the user input was + added successfully. + */ + [[nodiscard]] + LIBOAI_EXPORT bool AddUserData( + std::string_view data, + std::string_view name + ) & noexcept(false); + + /* + @brief Removes the last added user data. + + @returns True/False denoting whether the user data was removed. + */ + [[nodiscard]] + LIBOAI_EXPORT bool PopUserData() & noexcept(false); + + /* + @brief Gets the last response from the assistant. + This method gets the last response from the assistant. + The response is the assistant's response to the user's + input. + */ + LIBOAI_EXPORT std::string GetLastResponse() const & noexcept; + + /* + @brief Returns whether the most recent response, following + a call to Update, contains a function_call or not. + + It is important to note that, when making use of functions, + a developer must call this method to determine whether + the response contains a function call or if it contains a + regular response. If the response contains a function call, + + + @returns True/False denoting whether the most recent response + contains a function_call or not. + */ + [[nodiscard]] + LIBOAI_EXPORT bool LastResponseIsFunctionCall() const & noexcept; + + /* + @brief Returns the name of the function_call in the most recent + response. This should only be called if LastResponseIsFunctionCall() + returns true. + */ + [[nodiscard]] + LIBOAI_EXPORT std::string GetLastFunctionCallName() const & noexcept(false); + + /* + @brief Returns the arguments of the function_call in the most + recent response in their raw JSON form. This should only + be called if LastResponseIsFunctionCall() returns true. + */ + [[nodiscard]] + LIBOAI_EXPORT std::string GetLastFunctionCallArguments() const & noexcept(false); + + /* + @brief Removes the last assistant response. + + @returns True/False denoting whether the last response was removed. + */ + [[nodiscard]] + LIBOAI_EXPORT bool PopLastResponse() & noexcept(false); + + /* + @brief Updates the conversation given JSON data. + This method updates the conversation given JSON data. + The JSON data should be the JSON 'messages' data returned + from the OpenAI API. + + This method should only be used if AppendStreamData was NOT + used immediately before it. + + For instance, if we made a call to create*(), and provided a + callback function to stream and, within this callback, we used + AppendStreamData to update the conversation per message, we + would NOT want to use this method. In this scenario, the + AppendStreamData method would have already updated the + conversation, so this method would be a bad idea to call + afterwards. + + @param *history The JSON data to update the conversation with. + This should be the 'messages' array of data returned + from a call to ChatCompletion::create. + + @returns True/False denoting whether the conversation was updated. + */ + [[nodiscard]] + LIBOAI_EXPORT bool Update(std::string_view history) & noexcept(false); + + /* + @brief Updates the conversation given a Response object. + This method updates the conversation given a Response object. + This method should only be used if AppendStreamData was NOT + used immediately before it. + + For instance, if we made a call to create*(), and provided a + callback function to stream and, within this callback, we used + AppendStreamData to update the conversation per message, we + would NOT want to use this method. In this scenario, the + AppendStreamData method would have already updated the + conversation, so this method would be a bad idea to call + afterwards. + + @param *response The Response to update the conversation with. + This should be the Response returned from a call + to ChatCompletion::create. + + @returns True/False denoting whether the update was successful. + */ + [[nodiscard]] + LIBOAI_EXPORT bool Update(const Response& response) & noexcept(false); + + /* + @brief Exports the entire conversation to a JSON string. + This method exports the conversation to a JSON string. + The JSON string can be used to save the conversation + to a file. The exported string contains both the + conversation and included functions, if any. + + @returns The JSON string representing the conversation. + */ + [[nodiscard]] + LIBOAI_EXPORT std::string Export() const & noexcept(false); + + /* + @brief Imports a conversation from a JSON string. + This method imports a conversation from a JSON string. + The JSON string should be the JSON string returned + from a call to Export(). + + @param *json The JSON string to import the conversation from. + + @returns True/False denoting whether the conversation was imported. + */ + [[nodiscard]] + LIBOAI_EXPORT bool Import(std::string_view json) & noexcept(false); + + /* + @brief Appends stream data (SSEs) from streamed methods. + This method updates the conversation given a token from a + streamed method. This method should be used when using + streamed methods such as ChatCompletion::create or + create_async with a callback supplied. This function should + be called from within the stream's callback function + receiving the SSEs. + + @param *token Streamed token (data) to update the conversation with. + */ + [[nodiscard]] + LIBOAI_EXPORT bool AppendStreamData(std::string data) & noexcept(false); + + + /* + @brief Appends stream data (SSEs) from streamed methods. + This method updates the conversation given a token from a + streamed method. This method should be used when using + streamed methods such as ChatCompletion::create or + create_async with a callback supplied. This function should + be called from within the stream's callback function + receiving the SSEs. + + @param *token Streamed token (data) to update the conversation with. + @param *delta output parameter. The delta to append to the conversation. + @param *completed output parameter. Whether the stream is completed. + */ + [[nodiscard]] + LIBOAI_EXPORT bool AppendStreamData(std::string data, std::string& delta, bool& completed) & noexcept(false); + + /* + @brief Sets the functions to be used for the conversation. + This method sets the functions to be used for the conversation. + + @param *functions The functions to set. + + @returns True/False denoting whether the functions were set. + */ + [[nodiscard]] + LIBOAI_EXPORT bool SetFunctions(Functions functions) & noexcept(false); + + /* + @brief Pops any previously set functions. + + @param *functions The functions to set. + */ + LIBOAI_EXPORT void PopFunctions() & noexcept(false); + + /* + @brief Returns the raw JSON dump of the internal conversation object + in string format. + */ + LIBOAI_EXPORT std::string GetRawConversation() const & noexcept; + + /* + @brief Returns the JSON object of the internal conversation. + */ + LIBOAI_EXPORT const nlohmann::json& GetJSON() const & noexcept; + + /* + @brief Returns the raw JSON dump of the internal functions object + in string format - if one exists. + */ + LIBOAI_EXPORT std::string GetRawFunctions() const & noexcept; + + /* + @brief Returns the JSON object of the set functions. + */ + LIBOAI_EXPORT const nlohmann::json& GetFunctionsJSON() const & noexcept; + + /* + @brief Returns whether the conversation has functions or not. this function call from ChatComplete + */ + [[nodiscard]] constexpr bool HasFunctions() const & noexcept { return this->_functions ? true : false; } + + /** + * @brief Sets the maximum history size for the conversation. + * + * @param size The maximum number of messages allowed in the conversation history. + * Older messages will be removed when the limit is exceeded. + */ + void SetMaxHistorySize(size_t size) noexcept { _max_history_size = size; } + + private: + friend class ChatCompletion; friend class Azure; + [[nodiscard]] std::vector<std::string> SplitStreamedData(std::string data) const noexcept(false); + void RemoveStrings(std::string& s, std::string_view p) const noexcept(false); + void EraseExtra(); + /* + @brief split full stream data that read from remote server. + @returns vector of string that contains the split data that will contains the last termination string(data: "DONE"). + */ + [[nodiscard]] std::vector<std::string> SplitFullStreamedData(std::string data) const noexcept(false); + bool ParseStreamData(std::string data, std::string& delta, bool& completed); + + nlohmann::json _conversation; + std::optional<nlohmann::json> _functions = std::nullopt; + bool _last_resp_is_fc = false; + std::string _last_incomplete_buffer; + size_t _max_history_size = (std::numeric_limits<size_t>::max)(); + }; + + class ChatCompletion final : private Network { + public: + ChatCompletion(const std::string &root): Network(root) {} + NON_COPYABLE(ChatCompletion) + NON_MOVABLE(ChatCompletion) + ~ChatCompletion() = default; + + using ChatStreamCallback = std::function<bool(std::string, intptr_t, Conversation&)>; + + /* + @brief Creates a completion for the chat message. + + @param *model ID of the model to use. Currently, + only gpt-3.5-turbo and gpt-3.5-turbo-0301 + are supported. + @param *conversation A Conversation object containing the + conversation data. + @param function_call Controls how the model responds to function calls. "none" + means the model does not call a function, and responds to + the end-user. "auto" means the model can pick between an + end-user or calling a function. + @param temperature What sampling temperature to use, + between 0 and 2. Higher values like 0.8 will + make the output more random, while lower values + like 0.2 will make it more focused and deterministic. + @param top_p 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. + @param n How many chat completion choices to generate for each + input message. + @param stream If set, partial message deltas will be sent, like in + ChatGPT. Tokens will be sent as data-only server-sent + vents as they become available, with the stream terminated + by a data: [DONE] message. + @param stop to 4 sequences where the API will stop generating further + tokens. + @param max_tokens The maximum number of tokens allowed for the generated answer. + By default, the number of tokens the model can return will be + (4096 - prompt tokens). + @param presence_penalty 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. + @param frequency_penalty 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. + @param logit_bias Modify the likelihood of specified tokens appearing in the completion. + @param user The user ID to associate with the request. This is used to + prevent abuse of the API. + @param response_format Optional chat completion `response_format` body, e.g. + `{"type":"json_object"}` or `{"type":"json_schema","json_schema":{...}}` + (OpenAI structured outputs). + + @returns A liboai::Response object containing the + data in JSON format. + */ + LIBOAI_EXPORT liboai::Response create( + const std::string& model, + Conversation& conversation, + std::optional<std::string> function_call = std::nullopt, + std::optional<float> temperature = std::nullopt, + std::optional<float> top_p = std::nullopt, + std::optional<uint16_t> n = std::nullopt, + std::optional<ChatStreamCallback> stream = std::nullopt, + std::optional<std::vector<std::string>> stop = std::nullopt, + std::optional<uint16_t> max_tokens = std::nullopt, + std::optional<float> presence_penalty = std::nullopt, + std::optional<float> frequency_penalty = std::nullopt, + std::optional<std::unordered_map<std::string, int8_t>> logit_bias = std::nullopt, + std::optional<std::string> user = std::nullopt, + std::optional<nlohmann::json> response_format = std::nullopt + ) const & noexcept(false); + + /* + @brief Asynchronously creates a completion for the chat message. + + @param *model ID of the model to use. Currently, + only gpt-3.5-turbo and gpt-3.5-turbo-0301 + are supported. + @param *conversation A Conversation object containing the + conversation data. + @param function_call Controls how the model responds to function calls. "none" + means the model does not call a function, and responds to + the end-user. "auto" means the model can pick between an + end-user or calling a function. + @param temperature What sampling temperature to use, + between 0 and 2. Higher values like 0.8 will + make the output more random, while lower values + like 0.2 will make it more focused and deterministic. + @param top_p 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. + @param n How many chat completion choices to generate for each + input message. + @param stream If set, partial message deltas will be sent, like in + ChatGPT. Tokens will be sent as data-only server-sent + vents as they become available, with the stream terminated + by a data: [DONE] message. + @param stop to 4 sequences where the API will stop generating further + tokens. + @param max_tokens The maximum number of tokens allowed for the generated answer. + By default, the number of tokens the model can return will be + (4096 - prompt tokens). + @param presence_penalty 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. + @param frequency_penalty 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. + @param logit_bias Modify the likelihood of specified tokens appearing in the completion. + @param user The user ID to associate with the request. This is used to + prevent abuse of the API. + @param response_format See synchronous create(). + + @returns A liboai::Response future containing the + data in JSON format. + */ + LIBOAI_EXPORT liboai::FutureResponse create_async( + const std::string& model, + Conversation& conversation, + std::optional<std::string> function_call = std::nullopt, + std::optional<float> temperature = std::nullopt, + std::optional<float> top_p = std::nullopt, + std::optional<uint16_t> n = std::nullopt, + std::optional<ChatStreamCallback> stream = std::nullopt, + std::optional<std::vector<std::string>> stop = std::nullopt, + std::optional<uint16_t> max_tokens = std::nullopt, + std::optional<float> presence_penalty = std::nullopt, + std::optional<float> frequency_penalty = std::nullopt, + std::optional<std::unordered_map<std::string, int8_t>> logit_bias = std::nullopt, + std::optional<std::string> user = std::nullopt, + std::optional<nlohmann::json> response_format = std::nullopt + ) const & noexcept(false); + + private: + Authorization& auth_ = Authorization::Authorizer(); + using StrippedStreamCallback = std::function<bool(std::string, intptr_t)>; + }; +} diff --git a/packages/media/cpp/packages/liboai/liboai/include/components/completions.h b/packages/media/cpp/packages/liboai/liboai/include/components/completions.h new file mode 100644 index 00000000..2804af11 --- /dev/null +++ b/packages/media/cpp/packages/liboai/liboai/include/components/completions.h @@ -0,0 +1,171 @@ +#pragma once + +/* + completions.h : Completions component class for OpenAI. + This class contains all the methods for the Completions component + of the OpenAI API. This class provides access to 'Completions' + endpoints on the OpenAI API and should be accessed via the + liboai.h header file through an instantiated liboai::OpenAI + object after setting necessary authentication information + through the liboai::Authorization::Authorizer() singleton + object. +*/ + +#include "../core/authorization.h" +#include "../core/response.h" + +namespace liboai { + class Completions final : private Network { + public: + Completions(const std::string &root): Network(root) {} + NON_COPYABLE(Completions) + NON_MOVABLE(Completions) + ~Completions() = default; + + using StreamCallback = std::function<bool(std::string, intptr_t)>; + + /* + @brief Given a prompt, the model will return one or more + predicted completions, and can also return the + probabilities of alternative tokens at each position. + + @param *model The model to use for completion. + @param prompt The prompt(s) to generate completions for. + @param suffix The suffix that comes after a completion of inserted text. + @param max_tokens The maximum number of tokens to generate in a completion. + @param temperature The temperature for the model. Higher values will result in more + creative completions, while lower values will result in more + repetitive completions. + @param top_p The top_p for the model. This is the probability mass that the + model will consider when making predictions. Lower values will + result in more creative completions, while higher values will + result in more repetitive completions. + @param n The number of completions to generate. + @param stream Stream partial progress back to the client. A callback function + that is called each time new data is received from the API. If + no callback is supplied, this parameter is disabled and the + API will wait until the completion is finished before returning + the response. + + @param logprobs The number of log probabilities to return for each token. + @param echo Whether to include the prompt in the returned completion. + @param stop A list of tokens that the model will stop generating completions + at. This can be a single token or a list of tokens. + @param presence_penalty The presence penalty for the model. This is a 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. + @param frequency_penalty The frequency penalty for the model. This is a 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. + @param best_of Generates best_of completions server-side and returns the "best" + one. When used with n, best_of controls the number of candidate + completions and n specifies how many to return � best_of must be + greater than n + + Because this parameter generates many completions, it can quickly + consume your token quota. Use carefully and ensure that you have + reasonable settings for max_tokens and stop. + @param logit_bias Modify the likelihood of specified tokens appearing in the completion. + Accepts a json object that maps tokens (specified by their token ID + in the GPT tokenizer) to an associated bias value from -100 to 100. + @param user A unique identifier representing your end-user. + + @returns A liboai::Response object containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::Response create( + const std::string& model_id, + std::optional<std::string> prompt = std::nullopt, + std::optional<std::string> suffix = std::nullopt, + std::optional<uint16_t> max_tokens = std::nullopt, + std::optional<float> temperature = std::nullopt, + std::optional<float> top_p = std::nullopt, + std::optional<uint16_t> n = std::nullopt, + std::optional<StreamCallback> stream = std::nullopt, + std::optional<uint8_t> logprobs = std::nullopt, + std::optional<bool> echo = std::nullopt, + std::optional<std::vector<std::string>> stop = std::nullopt, + std::optional<float> presence_penalty = std::nullopt, + std::optional<float> frequency_penalty = std::nullopt, + std::optional<uint16_t> best_of = std::nullopt, + std::optional<std::unordered_map<std::string, int8_t>> logit_bias = std::nullopt, + std::optional<std::string> user = std::nullopt + ) const & noexcept(false); + + /* + @brief Given a prompt, the model will return one or more + predicted completions asynchronously, and can also + return the probabilities of alternative tokens at each + position. + + @param *model The model to use for completion. + @param prompt The prompt(s) to generate completions for. + @param suffix The suffix that comes after a completion of inserted text. + @param max_tokens The maximum number of tokens to generate in a completion. + @param temperature The temperature for the model. Higher values will result in more + creative completions, while lower values will result in more + repetitive completions. + @param top_p The top_p for the model. This is the probability mass that the + model will consider when making predictions. Lower values will + result in more creative completions, while higher values will + result in more repetitive completions. + @param n The number of completions to generate. + @param stream Stream partial progress back to the client. A callback function + that is called each time new data is received from the API. If + no callback is supplied, this parameter is disabled and the + API will wait until the completion is finished before returning + the response. + + @param logprobs The number of log probabilities to return for each token. + @param echo Whether to include the prompt in the returned completion. + @param stop A list of tokens that the model will stop generating completions + at. This can be a single token or a list of tokens. + @param presence_penalty The presence penalty for the model. This is a 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. + @param frequency_penalty The frequency penalty for the model. This is a 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. + @param best_of Generates best_of completions server-side and returns the "best" + one. When used with n, best_of controls the number of candidate + completions and n specifies how many to return � best_of must be + greater than n + + Because this parameter generates many completions, it can quickly + consume your token quota. Use carefully and ensure that you have + reasonable settings for max_tokens and stop. + @param logit_bias Modify the likelihood of specified tokens appearing in the completion. + Accepts a json object that maps tokens (specified by their token ID + in the GPT tokenizer) to an associated bias value from -100 to 100. + @param user A unique identifier representing your end-user. + + @returns A liboai::Response future containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::FutureResponse create_async( + const std::string& model_id, + std::optional<std::string> prompt = std::nullopt, + std::optional<std::string> suffix = std::nullopt, + std::optional<uint16_t> max_tokens = std::nullopt, + std::optional<float> temperature = std::nullopt, + std::optional<float> top_p = std::nullopt, + std::optional<uint16_t> n = std::nullopt, + std::optional<StreamCallback> stream = std::nullopt, + std::optional<uint8_t> logprobs = std::nullopt, + std::optional<bool> echo = std::nullopt, + std::optional<std::vector<std::string>> stop = std::nullopt, + std::optional<float> presence_penalty = std::nullopt, + std::optional<float> frequency_penalty = std::nullopt, + std::optional<uint16_t> best_of = std::nullopt, + std::optional<std::unordered_map<std::string, int8_t>> logit_bias = std::nullopt, + std::optional<std::string> user = std::nullopt + ) const & noexcept(false); + + private: + Authorization& auth_ = Authorization::Authorizer(); + }; +} \ No newline at end of file diff --git a/packages/media/cpp/packages/liboai/liboai/include/components/edits.h b/packages/media/cpp/packages/liboai/liboai/include/components/edits.h new file mode 100644 index 00000000..61c2f155 --- /dev/null +++ b/packages/media/cpp/packages/liboai/liboai/include/components/edits.h @@ -0,0 +1,90 @@ +#pragma once + +/* + edits.h : Edits component class for OpenAI. + This class contains all the methods for the Edits component + of the OpenAI API. This class provides access to 'Edits' + endpoints on the OpenAI API and should be accessed via the + liboai.h header file through an instantiated liboai::OpenAI + object after setting necessary authentication information + through the liboai::Authorization::Authorizer() singleton + object. +*/ + +#include "../core/authorization.h" +#include "../core/response.h" + +namespace liboai { + class Edits final : private Network { + public: + Edits(const std::string &root): Network(root) {} + NON_COPYABLE(Edits) + NON_MOVABLE(Edits) + ~Edits() = default; + + /* + @brief Creates a new edit for the provided input, + instruction, and parameters + + @param *model The model to use for the edit. + @param input The input text to edit. + @param instruction The instruction to edit the input. + @param n The number of edits to return. + @param temperature Higher values means the model will take more + risks. Try 0.9 for more creative applications, + and 0 (argmax sampling) for ones with a + well-defined answer. + @param top_p 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. + + @return A liboai::Response object containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::Response create( + const std::string& model_id, + std::optional<std::string> input = std::nullopt, + std::optional<std::string> instruction = std::nullopt, + std::optional<uint16_t> n = std::nullopt, + std::optional<float> temperature = std::nullopt, + std::optional<float> top_p = std::nullopt + ) const & noexcept(false); + + /* + @brief Asynchronously creates a new edit for the + provided input, instruction, and parameters + + @param *model The model to use for the edit. + @param input The input text to edit. + @param instruction The instruction to edit the input. + @param n The number of edits to return. + @param temperature Higher values means the model will take more + risks. Try 0.9 for more creative applications, + and 0 (argmax sampling) for ones with a + well-defined answer. + @param top_p 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. + + @return A liboai::Response future containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::FutureResponse create_async( + const std::string& model_id, + std::optional<std::string> input = std::nullopt, + std::optional<std::string> instruction = std::nullopt, + std::optional<uint16_t> n = std::nullopt, + std::optional<float> temperature = std::nullopt, + std::optional<float> top_p = std::nullopt + ) const & noexcept(false); + + private: + Authorization& auth_ = Authorization::Authorizer(); + }; +} \ No newline at end of file diff --git a/packages/media/cpp/packages/liboai/liboai/include/components/embeddings.h b/packages/media/cpp/packages/liboai/liboai/include/components/embeddings.h new file mode 100644 index 00000000..5deff3cc --- /dev/null +++ b/packages/media/cpp/packages/liboai/liboai/include/components/embeddings.h @@ -0,0 +1,60 @@ +#pragma once + +/* + embeddings.h : Embeddings component class for OpenAI. + This class contains all the methods for the Embeddings component + of the OpenAI API. This class provides access to 'Embeddings' + endpoints on the OpenAI API and should be accessed via the + liboai.h header file through an instantiated liboai::OpenAI + object after setting necessary authentication information + through the liboai::Authorization::Authorizer() singleton + object. +*/ + +#include "../core/authorization.h" +#include "../core/response.h" + +namespace liboai { + class Embeddings final : private Network { + public: + Embeddings(const std::string &root): Network(root) {} + NON_COPYABLE(Embeddings) + NON_MOVABLE(Embeddings) + ~Embeddings() = default; + + /* + @brief Creates an embedding vector representing the input text. + + @param *model The model to use for the edit. + @param input The input text to edit. + @param user A unique identifier representing your end-user + + @return A liboai::Response object containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::Response create( + const std::string& model_id, + std::optional<std::string> input = std::nullopt, + std::optional<std::string> user = std::nullopt + ) const & noexcept(false); + + /* + @brief Asynchronously creates an embedding vector representing the input text. + + @param *model The model to use for the edit. + @param input The input text to edit. + @param user A unique identifier representing your end-user + + @return A liboai::Response future containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::FutureResponse create_async( + const std::string& model_id, + std::optional<std::string> input = std::nullopt, + std::optional<std::string> user = std::nullopt + ) const & noexcept(false); + + private: + Authorization& auth_ = Authorization::Authorizer(); + }; +} \ No newline at end of file diff --git a/packages/media/cpp/packages/liboai/liboai/include/components/files.h b/packages/media/cpp/packages/liboai/liboai/include/components/files.h new file mode 100644 index 00000000..6effb62c --- /dev/null +++ b/packages/media/cpp/packages/liboai/liboai/include/components/files.h @@ -0,0 +1,157 @@ +#pragma once + +/* + files.h : Files component class for OpenAI. + This class contains all the methods for the Files component + of the OpenAI API. This class provides access to 'Files' + endpoints on the OpenAI API and should be accessed via the + liboai.h header file through an instantiated liboai::OpenAI + object after setting necessary authentication information + through the liboai::Authorization::Authorizer() singleton + object. +*/ + +#include "../core/authorization.h" +#include "../core/response.h" + +namespace liboai { + class Files final : private Network { + public: + Files(const std::string &root): Network(root) {} + NON_COPYABLE(Files) + NON_MOVABLE(Files) + ~Files() = default; + + /* + @brief Returns a list of files that belong to the user's organization. + + @return A liboai::Response object containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::Response list() const & noexcept(false); + + /* + @brief Asynchronously returns a list of files that belong to the + user's organization. + + @return A liboai::Response future containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::FutureResponse list_async() const & noexcept(false); + + /* + @brief Upload a file that contains document(s) to be + used across various endpoints/features. Currently, + the size of all the files uploaded by one organization + can be up to 1 GB. + + @param file The JSON Lines file to be uploaded (path). + @param purpose The intended purpose of the uploaded documents. + + @return A liboai::Response object containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::Response create( + const std::filesystem::path& file, + const std::string& purpose + ) const & noexcept(false); + + /* + @brief Asynchronously upload a file that contains document(s) + to be used across various endpoints/features. Currently, + the size of all the files uploaded by one organization + can be up to 1 GB. + + @param file The JSON Lines file to be uploaded (path). + @param purpose The intended purpose of the uploaded documents. + + @return A liboai::Response future containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::FutureResponse create_async( + const std::filesystem::path& file, + const std::string& purpose + ) const & noexcept(false); + + /* + @brief Delete [remove] a file. + + @param *file_id The ID of the file to use for this request + + @return A liboai::Response object containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::Response remove( + const std::string& file_id + ) const & noexcept(false); + + /* + @brief Asynchronously delete [remove] a file. + + @param *file_id The ID of the file to use for this request + + @return A liboai::Response future containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::FutureResponse remove_async( + const std::string& file_id + ) const & noexcept(false); + + /* + @brief Returns information about a specific file. + + @param *file_id The ID of the file to use for this request + + @return A liboai::Response object containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::Response retrieve( + const std::string& file_id + ) const & noexcept(false); + + /* + @brief Asynchronously returns information about a specific file. + + @param *file_id The ID of the file to use for this request + + @return A liboai::Response future containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::FutureResponse retrieve_async( + const std::string& file_id + ) const & noexcept(false); + + /* + @brief Downloads the contents of the specified file + to the specified path. + + @param *file_id The ID of the file to use for this request + @param *save_to The path to save the file to + + @return a boolean value indicating whether the file was + successfully downloaded or not. + */ + LIBOAI_EXPORT bool download( + const std::string& file_id, + const std::string& save_to + ) const & noexcept(false); + + /* + @brief Asynchronously downloads the contents of the specified file + to the specified path. + + @param *file_id The ID of the file to use for this request + @param *save_to The path to save the file to + + @return a boolean future indicating whether the file was + successfully downloaded or not. + */ + LIBOAI_EXPORT std::future<bool> download_async( + const std::string& file_id, + const std::string& save_to + ) const & noexcept(false); + + private: + Authorization& auth_ = Authorization::Authorizer(); + }; +} \ No newline at end of file diff --git a/packages/media/cpp/packages/liboai/liboai/include/components/fine_tunes.h b/packages/media/cpp/packages/liboai/liboai/include/components/fine_tunes.h new file mode 100644 index 00000000..7768eb8d --- /dev/null +++ b/packages/media/cpp/packages/liboai/liboai/include/components/fine_tunes.h @@ -0,0 +1,232 @@ +#pragma once + +/* + fine_tunes.h : Fine-tunes component class for OpenAI. + This class contains all the methods for the Fine-tunes component + of the OpenAI API. This class provides access to 'Fine-tunes' + endpoints on the OpenAI API and should be accessed via the + liboai.h header file through an instantiated liboai::OpenAI + object after setting necessary authentication information + through the liboai::Authorization::Authorizer() singleton + object. +*/ + +#include "../core/authorization.h" +#include "../core/response.h" + +namespace liboai { + class FineTunes final : private Network { + public: + FineTunes(const std::string &root): Network(root) {} + NON_COPYABLE(FineTunes) + NON_MOVABLE(FineTunes) + ~FineTunes() = default; + + using StreamCallback = std::function<bool(std::string, intptr_t)>; + + /* + @brief Creates a job that fine-tunes a specified model from a + given dataset. + + @param *training_file The ID of an uploaded file that contains + training data. + @param validation_file The ID of an uploaded file that contains + validation data. + @param model The name of the base model to fine-tune. + @param n_epochs The number of epochs to train for. + @param batch_size The batch size to use for training. + @param learning_rate_multiplier The learning rate multiplier to use for training. + @param prompt_loss_weight The prompt loss weight to use for training. + @param compute_classification_metrics If set, we calculate classification-specific metrics + such as accuracy and F-1 score using the validation + set at the end of every epoch. + @param classification_n_classes The number of classes in the classification task. + @param classification_positive_class The positive class in binary classification. + @param classification_betas If this is provided, we calculate F-beta scores at the + specified beta values. + @param suffix A suffix to append to the model name. + + @return A liboai::Response object containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::Response create( + const std::string& training_file, + std::optional<std::string> validation_file = std::nullopt, + std::optional<std::string> model_id = std::nullopt, + std::optional<uint8_t> n_epochs = std::nullopt, + std::optional<uint16_t> batch_size = std::nullopt, + std::optional<float> learning_rate_multiplier = std::nullopt, + std::optional<float> prompt_loss_weight = std::nullopt, + std::optional<bool> compute_classification_metrics = std::nullopt, + std::optional<uint16_t> classification_n_classes = std::nullopt, + std::optional<std::string> classification_positive_class = std::nullopt, + std::optional<std::vector<float>> classification_betas = std::nullopt, + std::optional<std::string> suffix = std::nullopt + ) const & noexcept(false); + + /* + @brief Asynchronously creates a job that fine-tunes a specified + model from a given dataset. + + @param *training_file The ID of an uploaded file that contains + training data. + @param validation_file The ID of an uploaded file that contains + validation data. + @param model The name of the base model to fine-tune. + @param n_epochs The number of epochs to train for. + @param batch_size The batch size to use for training. + @param learning_rate_multiplier The learning rate multiplier to use for training. + @param prompt_loss_weight The prompt loss weight to use for training. + @param compute_classification_metrics If set, we calculate classification-specific metrics + such as accuracy and F-1 score using the validation + set at the end of every epoch. + @param classification_n_classes The number of classes in the classification task. + @param classification_positive_class The positive class in binary classification. + @param classification_betas If this is provided, we calculate F-beta scores at the + specified beta values. + @param suffix A suffix to append to the model name. + + @return A liboai::Response future containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::FutureResponse create_async( + const std::string& training_file, + std::optional<std::string> validation_file = std::nullopt, + std::optional<std::string> model_id = std::nullopt, + std::optional<uint8_t> n_epochs = std::nullopt, + std::optional<uint16_t> batch_size = std::nullopt, + std::optional<float> learning_rate_multiplier = std::nullopt, + std::optional<float> prompt_loss_weight = std::nullopt, + std::optional<bool> compute_classification_metrics = std::nullopt, + std::optional<uint16_t> classification_n_classes = std::nullopt, + std::optional<std::string> classification_positive_class = std::nullopt, + std::optional<std::vector<float>> classification_betas = std::nullopt, + std::optional<std::string> suffix = std::nullopt + ) const & noexcept(false); + + /* + @brief List your organization's fine-tuning jobs + + @return A liboai::Response object containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::Response list() const & noexcept(false); + + /* + @brief Asynchronously list your organization's fine-tuning jobs + + @return A liboai::Response future containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::FutureResponse list_async() const & noexcept(false); + + /* + @brief Returns information about a specific file. + + @param *fine_tune_id The ID of the fine-tune job + + @return A liboai::Response object containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::Response retrieve( + const std::string& fine_tune_id + ) const & noexcept(false); + + /* + @brief Asynchronously returns information about a specific file. + + @param *fine_tune_id The ID of the fine-tune job + + @return A liboai::Response future containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::FutureResponse retrieve_async( + const std::string& fine_tune_id + ) const & noexcept(false); + + /* + @brief Immediately cancel a fine-tune job. + + @param *fine_tune_id The ID of the fine-tune job to cancel + + @return A liboai::Response object containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::Response cancel( + const std::string& fine_tune_id + ) const & noexcept(false); + + /* + @brief Immediately cancel a fine-tune job asynchronously. + + @param *fine_tune_id The ID of the fine-tune job to cancel + + @return A liboai::Response future containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::FutureResponse cancel_async( + const std::string& fine_tune_id + ) const & noexcept(false); + + /* + @brief Get fine-grained status updates for a fine-tune job. + + @param *fine_tune_id The ID of the fine-tune job to get events for. + @param stream Callback to stream events for the fine-tune job. + If no callback is supplied, this parameter is + disabled and the API will wait until the completion + is finished before returning the response. + + @return A liboai::Response future containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::Response list_events( + const std::string& fine_tune_id, + std::optional<StreamCallback> stream = std::nullopt + ) const & noexcept(false); + + /* + @brief Asynchronously get fine-grained status updates for a fine-tune job. + + @param *fine_tune_id The ID of the fine-tune job to get events for. + @param stream Callback to stream events for the fine-tune job. + If no callback is supplied, this parameter is + disabled and the API will wait until the completion + is finished before returning the response. + + @return A liboai::Response future containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::FutureResponse list_events_async( + const std::string& fine_tune_id, + std::optional<StreamCallback> stream = std::nullopt + ) const & noexcept(false); + + /* + @brief Delete a fine-tuned model. You must have the Owner role in your organization. + + @param *model The model to delete + + @return A liboai::Response future containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::Response remove( + const std::string& model + ) const & noexcept(false); + + /* + @brief Asynchronously deletes a fine-tuned model. You must have the Owner role in your organization. + + @param *model The model to delete + + @return A liboai::Response future containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::FutureResponse remove_async( + const std::string& model + ) const & noexcept(false); + + private: + Authorization& auth_ = Authorization::Authorizer(); + }; +} \ No newline at end of file diff --git a/packages/media/cpp/packages/liboai/liboai/include/components/images.h b/packages/media/cpp/packages/liboai/liboai/include/components/images.h new file mode 100644 index 00000000..a81d522b --- /dev/null +++ b/packages/media/cpp/packages/liboai/liboai/include/components/images.h @@ -0,0 +1,164 @@ +#pragma once + +/* + images.h : Images component class for OpenAI. + This class contains all the methods for the Images component + of the OpenAI API. This class provides access to 'Images' + endpoints on the OpenAI API and should be accessed via the + liboai.h header file through an instantiated liboai::OpenAI + object after setting necessary authentication information + through the liboai::Authorization::Authorizer() singleton + object. +*/ + +#include "../core/authorization.h" +#include "../core/response.h" + +namespace liboai { + class Images final : private Network { + public: + Images(const std::string &root): Network(root) {} + NON_COPYABLE(Images) + NON_MOVABLE(Images) + ~Images() = default; + + /* + @brief Images component method to create an image from + provided text. + + @param *prompt The text to create an image from. + @param n The number of images to create. + @param size The size of the image to create. + @param response_format The format of the response. + @param user A unique identifier representing an end-user. + + @return A liboai::Response object containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::Response create( + const std::string& prompt, + std::optional<uint8_t> n = std::nullopt, + std::optional<std::string> size = std::nullopt, + std::optional<std::string> response_format = std::nullopt, + std::optional<std::string> user = std::nullopt + ) const & noexcept(false); + + /* + @brief Images component method to asynchronously create an + image from provided text. + + @param *prompt The text to create an image from. + @param n The number of images to create. + @param size The size of the image to create. + @param response_format The format of the response. + @param user A unique identifier representing an end-user. + + @return A liboai::Response future containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::FutureResponse create_async( + const std::string& prompt, + std::optional<uint8_t> n = std::nullopt, + std::optional<std::string> size = std::nullopt, + std::optional<std::string> response_format = std::nullopt, + std::optional<std::string> user = std::nullopt + ) const & noexcept(false); + + /* + @brief Images component method to produce an edited + image from a provided base image and mask image + according to given text. + + @param *image The image to edit (path). + @param *prompt The text description of the desired image. + @param mask The mask to edit the image with (path). + @param n The number of images to create. + @param size The size of the image to create. + @param response_format The format of the response. + @param user A unique identifier representing an end-user. + + @return A liboai::Response object containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::Response create_edit( + const std::filesystem::path& image, + const std::string& prompt, + std::optional<std::filesystem::path> mask = std::nullopt, + std::optional<uint8_t> n = std::nullopt, + std::optional<std::string> size = std::nullopt, + std::optional<std::string> response_format = std::nullopt, + std::optional<std::string> user = std::nullopt + ) const & noexcept(false); + + /* + @brief Images component method to asynchronously + produce an edited image from a provided base + image and mask image according to given text. + + @param *image The image to edit (path). + @param *prompt The text description of the desired image. + @param mask The mask to edit the image with (path). + @param n The number of images to create. + @param size The size of the image to create. + @param response_format The format of the response. + @param user A unique identifier representing an end-user. + + @return A liboai::Response future containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::FutureResponse create_edit_async( + const std::filesystem::path& image, + const std::string& prompt, + std::optional<std::filesystem::path> mask = std::nullopt, + std::optional<uint8_t> n = std::nullopt, + std::optional<std::string> size = std::nullopt, + std::optional<std::string> response_format = std::nullopt, + std::optional<std::string> user = std::nullopt + ) const & noexcept(false); + + /* + @brief Images component method to produce a variation + of a supplied image. + + @param *image The image to produce a variation of (path). + @param n The number of images to create. + @param size The size of the image to create. + @param response_format The format of the response. + @param user A unique identifier representing an end-user. + + @return A liboai::Response object containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::Response create_variation( + const std::filesystem::path& image, + std::optional<uint8_t> n = std::nullopt, + std::optional<std::string> size = std::nullopt, + std::optional<std::string> response_format = std::nullopt, + std::optional<std::string> user = std::nullopt + ) const & noexcept(false); + + /* + @brief Images component method to asynchronously produce + a variation of a supplied image. + + @param *image The image to produce a variation of (path). + @param n The number of images to create. + @param size The size of the image to create. + @param response_format The format of the response. + @param user A unique identifier representing an end-user. + + @return A liboai::Response future containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::FutureResponse create_variation_async( + const std::filesystem::path& image, + std::optional<uint8_t> n = std::nullopt, + std::optional<std::string> size = std::nullopt, + std::optional<std::string> response_format = std::nullopt, + std::optional<std::string> user = std::nullopt + ) const & noexcept(false); + + private: + Authorization& auth_ = Authorization::Authorizer(); + }; +} \ No newline at end of file diff --git a/packages/media/cpp/packages/liboai/liboai/include/components/models.h b/packages/media/cpp/packages/liboai/liboai/include/components/models.h new file mode 100644 index 00000000..d020b6bf --- /dev/null +++ b/packages/media/cpp/packages/liboai/liboai/include/components/models.h @@ -0,0 +1,68 @@ +#pragma once + +/* + models.h : Models component class for OpenAI. + This class contains all the methods for the Models component + of the OpenAI API. This class provides access to 'Models' + endpoints on the OpenAI API and should be accessed via the + liboai.h header file through an instantiated liboai::OpenAI + object after setting necessary authentication information + through the liboai::Authorization::Authorizer() singleton + object. +*/ + +#include "../core/authorization.h" +#include "../core/response.h" + +namespace liboai { + class Models final : private Network { + public: + Models(const std::string &root): Network(root) {} + NON_COPYABLE(Models) + NON_MOVABLE(Models) + ~Models() = default; + + /* + @brief List all available models. + + @returns A liboai::Response object containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::Response list() const & noexcept(false); + + /* + @brief Asynchronously list all available models. + + @returns A liboai::Response future containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::FutureResponse list_async() const & noexcept(false); + + /* + @brief Retrieve a specific model's information. + + #param *model The model to retrieve information for. + + @returns A liboai::Response object containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::Response retrieve( + const std::string& model + ) const & noexcept(false); + + /* + @brief Asynchronously retrieve a specific model's information. + + @param *model The model to retrieve information for. + + @returns A liboai::Response future containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::FutureResponse retrieve_async( + const std::string& model + ) const & noexcept(false); + + private: + Authorization& auth_ = Authorization::Authorizer(); + }; +} \ No newline at end of file diff --git a/packages/media/cpp/packages/liboai/liboai/include/components/moderations.h b/packages/media/cpp/packages/liboai/liboai/include/components/moderations.h new file mode 100644 index 00000000..34dcf50a --- /dev/null +++ b/packages/media/cpp/packages/liboai/liboai/include/components/moderations.h @@ -0,0 +1,58 @@ +#pragma once + +/* + moderations.h : Moderations component class for OpenAI. + This class contains all the methods for the Moderations component + of the OpenAI API. This class provides access to 'Moderations' + endpoints on the OpenAI API and should be accessed via the + liboai.h header file through an instantiated liboai::OpenAI + object after setting necessary authentication information + through the liboai::Authorization::Authorizer() singleton + object. +*/ + +#include "../core/authorization.h" +#include "../core/response.h" + +namespace liboai { + class Moderations final : private Network { + public: + Moderations(const std::string &root): Network(root) {} + NON_COPYABLE(Moderations) + NON_MOVABLE(Moderations) + ~Moderations() = default; + + /* + @brief Create a new moderation and classify + if the given text is safe or unsafe. + + @param *input The text to be moderated. + @param model The model to use for the moderation. + + @return A liboai::Response object containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::Response create( + const std::string& input, + std::optional<std::string> model = std::nullopt + ) const & noexcept(false); + + /* + @brief Asynchronously creates a new moderation and classifies + if the given text is safe or unsafe. + + @param *input The text to be moderated. + @param model The model to use for the moderation. + + @return A liboai::Response future containing the image(s) + data in JSON format. + */ + LIBOAI_EXPORT liboai::FutureResponse create_async( + const std::string& input, + std::optional<std::string> model = std::nullopt + ) const & noexcept(false); + + private: + Authorization& auth_ = Authorization::Authorizer(); + }; +} \ No newline at end of file diff --git a/packages/media/cpp/packages/liboai/liboai/include/components/responses.h b/packages/media/cpp/packages/liboai/liboai/include/components/responses.h new file mode 100644 index 00000000..8d733b87 --- /dev/null +++ b/packages/media/cpp/packages/liboai/liboai/include/components/responses.h @@ -0,0 +1,193 @@ +#pragma once + +/* + responses.h : Responses component class for OpenAI. + This class provides access to the Responses API endpoints. + It is available via liboai.h through an instantiated liboai::OpenAI + object after setting necessary authentication information. +*/ + +#include "../core/authorization.h" +#include "../core/response.h" + +namespace liboai { + class Responses final : private Network { + public: + using StreamCallback = std::function<bool(std::string, intptr_t)>; + + Responses(const std::string &root): Network(root) {} + NON_COPYABLE(Responses) + NON_MOVABLE(Responses) + ~Responses() = default; + + /* + @brief Builds a Responses API request payload. + + @param *model The model ID to use. + @param *input Input for the response (string or array of items). + @param instructions Optional system-level instructions. + @param reasoning Optional reasoning configuration. + @param text Optional text output configuration. + @param max_output_tokens Optional max output tokens to generate. + @param temperature Optional sampling temperature. + @param top_p Optional nucleus sampling value. + @param seed Optional deterministic seed. + @param tools Optional tools array. + @param tool_choice Optional tool choice configuration. + @param parallel_tool_calls Optional parallel tool calls toggle. + @param store Optional storage toggle. + @param previous_response_id Optional prior response ID for continuity. + @param include Optional include array. + @param metadata Optional metadata object. + @param user Optional user ID. + @param truncation Optional truncation setting. + @param stream Optional stream flag. + + @return A JSON object representing the request payload. + */ + LIBOAI_EXPORT static nlohmann::json build_request( + const std::string& model, + const nlohmann::json& input, + std::optional<std::string> instructions = std::nullopt, + std::optional<nlohmann::json> reasoning = std::nullopt, + std::optional<nlohmann::json> text = std::nullopt, + std::optional<uint32_t> max_output_tokens = std::nullopt, + std::optional<float> temperature = std::nullopt, + std::optional<float> top_p = std::nullopt, + std::optional<uint32_t> seed = std::nullopt, + std::optional<nlohmann::json> tools = std::nullopt, + std::optional<nlohmann::json> tool_choice = std::nullopt, + std::optional<bool> parallel_tool_calls = std::nullopt, + std::optional<bool> store = std::nullopt, + std::optional<std::string> previous_response_id = std::nullopt, + std::optional<nlohmann::json> include = std::nullopt, + std::optional<nlohmann::json> metadata = std::nullopt, + std::optional<std::string> user = std::nullopt, + std::optional<std::string> truncation = std::nullopt, + std::optional<bool> stream = std::nullopt + ); + + /* + @brief Creates a response using the Responses API. + + @param *model The model ID to use. + @param *input Input for the response (string or array of items). + @param instructions Optional system-level instructions. + @param reasoning Optional reasoning configuration. + @param text Optional text output configuration. + @param max_output_tokens Optional max output tokens to generate. + @param temperature Optional sampling temperature. + @param top_p Optional nucleus sampling value. + @param seed Optional deterministic seed. + @param tools Optional tools array. + @param tool_choice Optional tool choice configuration. + @param parallel_tool_calls Optional parallel tool calls toggle. + @param store Optional storage toggle. + @param previous_response_id Optional prior response ID for continuity. + @param include Optional include array. + @param metadata Optional metadata object. + @param user Optional user ID. + @param truncation Optional truncation setting. + @param stream Optional stream callback for SSE responses. + + @return A liboai::Response object containing response data. + */ + LIBOAI_EXPORT liboai::Response create( + const std::string& model, + const nlohmann::json& input, + std::optional<std::string> instructions = std::nullopt, + std::optional<nlohmann::json> reasoning = std::nullopt, + std::optional<nlohmann::json> text = std::nullopt, + std::optional<uint32_t> max_output_tokens = std::nullopt, + std::optional<float> temperature = std::nullopt, + std::optional<float> top_p = std::nullopt, + std::optional<uint32_t> seed = std::nullopt, + std::optional<nlohmann::json> tools = std::nullopt, + std::optional<nlohmann::json> tool_choice = std::nullopt, + std::optional<bool> parallel_tool_calls = std::nullopt, + std::optional<bool> store = std::nullopt, + std::optional<std::string> previous_response_id = std::nullopt, + std::optional<nlohmann::json> include = std::nullopt, + std::optional<nlohmann::json> metadata = std::nullopt, + std::optional<std::string> user = std::nullopt, + std::optional<std::string> truncation = std::nullopt, + std::optional<StreamCallback> stream = std::nullopt + ) const & noexcept(false); + + /* + @brief Creates a response using the Responses API from a raw JSON payload. + + @param *request The raw JSON payload for the request. + @param stream Optional stream callback for SSE responses. + + @return A liboai::Response object containing response data. + */ + LIBOAI_EXPORT liboai::Response create( + const nlohmann::json& request, + std::optional<StreamCallback> stream = std::nullopt + ) const & noexcept(false); + + /* + @brief Asynchronously creates a response using the Responses API. + + @param *model The model ID to use. + @param *input Input for the response (string or array of items). + @param instructions Optional system-level instructions. + @param reasoning Optional reasoning configuration. + @param text Optional text output configuration. + @param max_output_tokens Optional max output tokens to generate. + @param temperature Optional sampling temperature. + @param top_p Optional nucleus sampling value. + @param seed Optional deterministic seed. + @param tools Optional tools array. + @param tool_choice Optional tool choice configuration. + @param parallel_tool_calls Optional parallel tool calls toggle. + @param store Optional storage toggle. + @param previous_response_id Optional prior response ID for continuity. + @param include Optional include array. + @param metadata Optional metadata object. + @param user Optional user ID. + @param truncation Optional truncation setting. + @param stream Optional stream callback for SSE responses. + + @return A liboai::FutureResponse containing future response data. + */ + LIBOAI_EXPORT liboai::FutureResponse create_async( + const std::string& model, + const nlohmann::json& input, + std::optional<std::string> instructions = std::nullopt, + std::optional<nlohmann::json> reasoning = std::nullopt, + std::optional<nlohmann::json> text = std::nullopt, + std::optional<uint32_t> max_output_tokens = std::nullopt, + std::optional<float> temperature = std::nullopt, + std::optional<float> top_p = std::nullopt, + std::optional<uint32_t> seed = std::nullopt, + std::optional<nlohmann::json> tools = std::nullopt, + std::optional<nlohmann::json> tool_choice = std::nullopt, + std::optional<bool> parallel_tool_calls = std::nullopt, + std::optional<bool> store = std::nullopt, + std::optional<std::string> previous_response_id = std::nullopt, + std::optional<nlohmann::json> include = std::nullopt, + std::optional<nlohmann::json> metadata = std::nullopt, + std::optional<std::string> user = std::nullopt, + std::optional<std::string> truncation = std::nullopt, + std::optional<StreamCallback> stream = std::nullopt + ) const & noexcept(false); + + /* + @brief Asynchronously creates a response using the Responses API from a raw JSON payload. + + @param *request The raw JSON payload for the request. + @param stream Optional stream callback for SSE responses. + + @return A liboai::FutureResponse containing future response data. + */ + LIBOAI_EXPORT liboai::FutureResponse create_async( + const nlohmann::json& request, + std::optional<StreamCallback> stream = std::nullopt + ) const & noexcept(false); + + private: + Authorization& auth_ = Authorization::Authorizer(); + }; +} diff --git a/packages/media/cpp/packages/liboai/liboai/include/core/authorization.h b/packages/media/cpp/packages/liboai/liboai/include/core/authorization.h new file mode 100644 index 00000000..b5b0c340 --- /dev/null +++ b/packages/media/cpp/packages/liboai/liboai/include/core/authorization.h @@ -0,0 +1,246 @@ +#pragma once + +/* + authorization.h : liboai authorization header. + This header file provides declarations for authorization + directives for authorizing requests with the OpenAI API. + Each component class makes use of a single object accessed + via liboai::Authorization::Authorizer() to retrieve and use + user-set authorization information to successfully complete + component API requests. +*/ + +#define _CRT_SECURE_NO_WARNINGS + +#include <iostream> +#include <string> +#include <fstream> +#include <filesystem> +#include "network.h" + +namespace liboai { + class Authorization final { + public: // cons/des, operator deletions + Authorization() = default; + NON_COPYABLE(Authorization) + NON_MOVABLE(Authorization) + ~Authorization(); + + public: // member methods + /* + @brief Singleton paradigm access method. + @return A reference to the singleton instance of this class + to be used in all component classes. + */ + static Authorization& Authorizer() noexcept { + static Authorization instance; + return instance; + } + + /* + @brief Sets the authorization key for the OpenAI API + as the passed string. + @param key : The authorization key to use in component calls. + @returns True if the key was set successfully, false otherwise. + */ + [[nodiscard]] + LIBOAI_EXPORT bool SetKey(std::string_view key) noexcept; + + /* + @brief Sets the authorization key for the Azure OpenAI API + as the passed string. + @param key : The authorization key to use in Azure component calls. + @returns True if the key was set successfully, false otherwise. + */ + [[nodiscard]] + LIBOAI_EXPORT bool SetAzureKey(std::string_view key) noexcept; + + /* + @brief Sets the Active Directory authorization token for the Azure OpenAI API + as the passed string. + @param key : The authorization key to use in Azure component calls. + @returns True if the key was set successfully, false otherwise. + */ + [[nodiscard]] + LIBOAI_EXPORT bool SetAzureKeyAD(std::string_view key) noexcept; + + /* + @brief Sets the authorization key for the OpenAI API + as the first line present in the file at the passed path. + @param path : The path to the file containing the authorization key. + @returns True if the key was set successfully, false otherwise. + */ + [[nodiscard]] + LIBOAI_EXPORT bool SetKeyFile(const std::filesystem::path& path) noexcept; + + /* + @brief Sets the authorization key for the Azure OpenAI API + as the first line present in the file at the passed path. + @param key : The path to the file containing the authorization key. + @returns True if the key was set successfully, false otherwise. + */ + [[nodiscard]] + LIBOAI_EXPORT bool SetAzureKeyFile(const std::filesystem::path& path) noexcept; + + /* + @brief Sets the Active Directory authorization token for the Azure OpenAI API + as the first line present in the file at the passed path. + @param key : The path to the file containing the authorization key. + @returns True if the key was set successfully, false otherwise. + */ + [[nodiscard]] + LIBOAI_EXPORT bool SetAzureKeyFileAD(const std::filesystem::path& path) noexcept; + + /* + @brief Sets the authorization key for the OpenAI API + as the value stored in the environment variable with + the passed name. + @param var : The name of the environment variable to + retrieve the authorization key from. + @returns True if the key was set successfully, false otherwise. + */ + [[nodiscard]] + LIBOAI_EXPORT bool SetKeyEnv(std::string_view var) noexcept; + + /* + @brief Sets the authorization key for the Azure OpenAI API + as the value stored in the environment variable with + the passed name. + @param var : The name of the environment variable to + retrieve the authorization key from. + @returns True if the key was set successfully, false otherwise. + */ + [[nodiscard]] + LIBOAI_EXPORT bool SetAzureKeyEnv(std::string_view var) noexcept; + + /* + @brief Sets the Active Directory authorization token for the Azure OpenAI API + as the value stored in the environment variable with + the passed name. + @param var : The name of the environment variable to + retrieve the authorization key from. + @returns True if the key was set successfully, false otherwise. + */ + [[nodiscard]] + LIBOAI_EXPORT bool SetAzureKeyEnvAD(std::string_view var) noexcept; + + /* + @brief Sets the organization identifier as the passed + string for use in component calls. + @param org : The organization identifier to use in + component calls. + @returns True if the ID was set successfully, false otherwise. + */ + [[nodiscard]] + LIBOAI_EXPORT bool SetOrganization(std::string_view org) noexcept; + + /* + @brief Sets the organization identifier as the first + line present in the file at the passed path for use + in component calls. + @param path : The path to the file containing the + organization identifier. + @returns True if the ID was set successfully, false otherwise. + */ + [[nodiscard]] + LIBOAI_EXPORT bool SetOrganizationFile(const std::filesystem::path& path) noexcept; + + /* + @brief Sets the organization identifier as the value + stored in the environment variable with the passed + name for use in component calls. + @param var : The name of the environment variable to + retrieve the organization identifier from. + @returns True if the ID was set successfully, false otherwise. + */ + [[nodiscard]] + LIBOAI_EXPORT bool SetOrganizationEnv(std::string_view var) noexcept; + + /* + @brief Sets proxies to use for component calls. + @param hosts : The hosts to use as proxies in + paired { "protocol", "host" } format. + */ + LIBOAI_EXPORT void SetProxies(const std::initializer_list<std::pair<const std::string, std::string>>& hosts) noexcept; + + /* + @brief Sets proxies to use for component calls. + @param hosts : The hosts to use as proxies in + paired { "protocol", "host" } format. + */ + LIBOAI_EXPORT void SetProxies(std::initializer_list<std::pair<const std::string, std::string>>&& hosts) noexcept; + + /* + @brief Sets proxies to use for component calls. + @param hosts : The hosts to use as proxies in + paired { "protocol", "host" } format. + */ + LIBOAI_EXPORT void SetProxies(const std::map<std::string, std::string>& hosts) noexcept; + + /* + @brief Sets proxies to use for component calls. + @param hosts : The hosts to use as proxies in + paired { "protocol", "host" } format. + */ + LIBOAI_EXPORT void SetProxies(std::map<std::string, std::string>&& hosts) noexcept; + + /* + @brief Sets authentication information for proxies per-protocol. + + @param proto_up : A {protocol, {uname, passwd}} map to use for + authentication with proxies on a per-protocol basis. + */ + LIBOAI_EXPORT void SetProxyAuth(const std::map<std::string, netimpl::components::EncodedAuthentication>& proto_up) noexcept; + + /* + @brief Sets the timeout for component calls in milliseconds. + */ + LIBOAI_EXPORT void SetMaxTimeout(int32_t ms) noexcept { this->timeout_ = netimpl::components::Timeout(ms); } + + /* + @brief Returns currently the set authorization key. + */ + constexpr const std::string& GetKey() const noexcept { return this->key_; } + + /* + @brief Returns the currently set organization identifier. + */ + constexpr const std::string& GetOrganization() const noexcept { return this->org_; } + + /* + @returns The currently set proxies. + */ + netimpl::components::Proxies GetProxies() const noexcept { return this->proxies_; } + + /* + @returns The currently set proxy authentication information. + */ + netimpl::components::ProxyAuthentication GetProxyAuth() const noexcept { return this->proxyAuth_; } + + /* + @returns The currently set timeout. + */ + netimpl::components::Timeout GetMaxTimeout() const noexcept { return this->timeout_; } + + /* + @returns An authorization header with the + currently set authorization information for use + in component calls. + */ + constexpr const netimpl::components::Header& GetAuthorizationHeaders() const noexcept { return this->openai_auth_headers_; } + + /* + @returns An authorization header with the + currently set Azure authorization information for use + in Azure component calls. + */ + constexpr const netimpl::components::Header& GetAzureAuthorizationHeaders() const noexcept { return this->azure_auth_headers_; } + + private: // member variables + std::string key_, org_; + netimpl::components::Header openai_auth_headers_, azure_auth_headers_; + netimpl::components::Proxies proxies_; + netimpl::components::ProxyAuthentication proxyAuth_; + netimpl::components::Timeout timeout_ = { 30000 }; + }; +} \ No newline at end of file diff --git a/packages/media/cpp/packages/liboai/liboai/include/core/exception.h b/packages/media/cpp/packages/liboai/liboai/include/core/exception.h new file mode 100644 index 00000000..286a328d --- /dev/null +++ b/packages/media/cpp/packages/liboai/liboai/include/core/exception.h @@ -0,0 +1,86 @@ +#pragma once + +/* + exception.h : liboai exception header. + This header file provides declarations for exception + directives for handling exceptions thrown by liboai + component classes. +*/ + +#include <iostream> +#include <exception> +#include <memory> + +#if defined(LIBOAI_DEBUG) + #define _liboai_dbg(fmt, ...) printf(fmt, __VA_ARGS__); +#endif + +namespace liboai { + namespace exception { + enum class EType : uint8_t { + E_FAILURETOPARSE, + E_BADREQUEST, + E_APIERROR, + E_RATELIMIT, + E_CONNECTIONERROR, + E_FILEERROR, + E_CURLERROR + }; + + constexpr const char* _etype_strs_[7] = { + "E_FAILURETOPARSE:0x00", + "E_BADREQUEST:0x01", + "E_APIERROR:0x02", + "E_RATELIMIT:0x03", + "E_CONNECTIONERROR:0x04", + "E_FILEERROR:0x05", + "E_CURLERROR:0x06" + }; + + class OpenAIException : public std::exception { + public: + OpenAIException() = default; + OpenAIException(const OpenAIException& rhs) noexcept + : error_type_(rhs.error_type_), data_(rhs.data_), locale_(rhs.locale_) { this->fmt_str_ = (this->locale_ + ": " + this->data_ + " (" + this->GetETypeString(this->error_type_) + ")"); } + OpenAIException(OpenAIException&& rhs) noexcept + : error_type_(rhs.error_type_), data_(std::move(rhs.data_)), locale_(std::move(rhs.locale_)) { this->fmt_str_ = (this->locale_ + ": " + this->data_ + " (" + this->GetETypeString(this->error_type_) + ")"); } + OpenAIException(std::string_view data, EType error_type, std::string_view locale) noexcept + : error_type_(error_type), data_(data), locale_(locale) { this->fmt_str_ = (this->locale_ + ": " + this->data_ + " (" + this->GetETypeString(this->error_type_) + ")"); } + + const char* what() const noexcept override { + return this->fmt_str_.c_str(); + } + + constexpr const char* GetETypeString(EType type) const noexcept { + return _etype_strs_[static_cast<uint8_t>(type)]; + } + + private: + EType error_type_; + std::string data_, locale_, fmt_str_; + }; + + class OpenAIRateLimited : public std::exception { + public: + OpenAIRateLimited() = default; + OpenAIRateLimited(const OpenAIRateLimited& rhs) noexcept + : error_type_(rhs.error_type_), data_(rhs.data_), locale_(rhs.locale_) { this->fmt_str_ = (this->locale_ + ": " + this->data_ + " (" + this->GetETypeString(this->error_type_) + ")"); } + OpenAIRateLimited(OpenAIRateLimited&& rhs) noexcept + : error_type_(rhs.error_type_), data_(std::move(rhs.data_)), locale_(std::move(rhs.locale_)) { this->fmt_str_ = (this->locale_ + ": " + this->data_ + " (" + this->GetETypeString(this->error_type_) + ")"); } + OpenAIRateLimited(std::string_view data, EType error_type, std::string_view locale) noexcept + : error_type_(error_type), data_(data), locale_(locale) { this->fmt_str_ = (this->locale_ + ": " + this->data_ + " (" + this->GetETypeString(this->error_type_) + ")"); } + + const char* what() const noexcept override { + return this->fmt_str_.c_str(); + } + + constexpr const char* GetETypeString(EType type) const noexcept { + return _etype_strs_[static_cast<uint8_t>(type)]; + } + + private: + EType error_type_; + std::string data_, locale_, fmt_str_; + }; + } +} diff --git a/packages/media/cpp/packages/liboai/liboai/include/core/netimpl.h b/packages/media/cpp/packages/liboai/liboai/include/core/netimpl.h new file mode 100644 index 00000000..c5f82f63 --- /dev/null +++ b/packages/media/cpp/packages/liboai/liboai/include/core/netimpl.h @@ -0,0 +1,759 @@ +#pragma once + +/* + Copyright (c) 2017-2021 Huu Nguyen + Copyright (c) 2022 libcpr and many other contributors + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + + netimpl.h : Holds the internal network control-flow implementation. + This header file provides the internal interface(s) used to + allow files such as network.h to properly work. It contains + the internal cURL network wrapping functionality and all + other network-related functionality. + + This was created to remove the dependency on the library + cURL for People (CPR). +*/ + +#include <fstream> +#include <optional> +#include <mutex> +#include <future> +#include <sstream> +#include <curl/curl.h> +#include "response.h" + +namespace liboai { + namespace netimpl { + static bool _flag = false; + + void ErrorCheck(CURLcode* ecodes, size_t size, std::string_view where); + void ErrorCheck(CURLcode ecode, std::string_view where); + + #if LIBCURL_VERSION_MAJOR < 7 || (LIBCURL_VERSION_MAJOR == 7 && LIBCURL_VERSION_MINOR < 56) + void ErrorCheck(CURLFORMcode* ecodes, size_t size, std::string_view where); + void ErrorCheck(CURLFORMcode ecode, std::string_view where); + #endif + + class CurlHolder { + public: + CurlHolder(); + NON_COPYABLE(CurlHolder) + NON_MOVABLE(CurlHolder) + virtual ~CurlHolder(); + + std::string urlEncode(const std::string& s); + std::string urlDecode(const std::string& s); + + private: + static std::mutex& curl_easy_get_mutex_() { + static std::mutex g_curl_mutex; + return g_curl_mutex; + } + + protected: + CURL* curl_ = nullptr; + }; + + /* + Contains all components that can be passed to below free methods + Get, Post, and Delete such as Url, Headers, Body, Multipart, + etc. + */ + namespace components { + template <class T> + class StringHolder { + public: + StringHolder() = default; + explicit StringHolder(std::string str) : str_(std::move(str)) {} + explicit StringHolder(std::string_view str) : str_(str) {} + explicit StringHolder(const char* str) : str_(str) {} + StringHolder(const char* str, size_t len) : str_(str, len) {} + StringHolder(const std::initializer_list<std::string> args) { + str_ = std::accumulate(args.begin(), args.end(), str_); + } + StringHolder(const StringHolder& other) = default; + StringHolder(StringHolder&& old) noexcept = default; + virtual ~StringHolder() = default; + + StringHolder& operator=(StringHolder&& old) noexcept = default; + StringHolder& operator=(const StringHolder& other) = default; + + explicit operator std::string() const { + return str_; + } + + T operator+(const char* rhs) const { + return T(str_ + rhs); + } + + T operator+(const std::string& rhs) const { + return T(str_ + rhs); + } + + T operator+(const StringHolder<T>& rhs) const { + return T(str_ + rhs.str_); + } + + void operator+=(const char* rhs) { + str_ += rhs; + } + void operator+=(const std::string& rhs) { + str_ += rhs; + } + void operator+=(const StringHolder<T>& rhs) { + str_ += rhs; + } + + bool operator==(const char* rhs) const { + return str_ == rhs; + } + bool operator==(const std::string& rhs) const { + return str_ == rhs; + } + bool operator==(const StringHolder<T>& rhs) const { + return str_ == rhs.str_; + } + + bool operator!=(const char* rhs) const { + return str_.c_str() != rhs; + } + bool operator!=(const std::string& rhs) const { + return str_ != rhs; + } + bool operator!=(const StringHolder<T>& rhs) const { + return str_ != rhs.str_; + } + + const std::string& str() { + return str_; + } + const std::string& str() const { + return str_; + } + const char* c_str() const { + return str_.c_str(); + } + const char* data() const { + return str_.data(); + } + + protected: + std::string str_{}; + }; + + struct File final { + File(const File& other) { + this->filepath = other.filepath; + this->overrided_filename = other.overrided_filename; + } + File(File&& old) noexcept { + this->filepath = std::move(old.filepath); + this->overrided_filename = std::move(old.overrided_filename); + } + explicit File(std::string p_filepath, const std::string& p_overrided_filename = {}) : filepath(std::move(p_filepath)), overrided_filename(p_overrided_filename) {} + + File& operator=(const File& other) { + this->filepath = other.filepath; + this->overrided_filename = other.overrided_filename; + return *this; + } + + File& operator=(File&& old) noexcept { + this->filepath = std::move(old.filepath); + this->overrided_filename = std::move(old.overrided_filename); + return *this; + } + + std::string filepath; + std::string overrided_filename; + + bool hasOverridedFilename() const noexcept { + return !overrided_filename.empty(); + }; + }; + + class Files final { + public: + Files() = default; + Files(const Files& other) { + this->files = other.files; + } + Files(Files&& old) noexcept { + this->files = std::move(old.files); + } + Files(const File& p_file) : files{ p_file } {}; + Files(const std::initializer_list<File>& p_files) : files{ p_files } {}; + Files(const std::initializer_list<std::string>& p_filepaths) { + for (const std::string& filepath : p_filepaths) { + files.emplace_back(File(filepath)); + } + }; + ~Files() noexcept = default; + + Files& operator=(const Files& other) { + this->files = other.files; + return *this; + } + Files& operator=(Files&& old) noexcept { + this->files = std::move(old.files); + return *this; + } + + using iterator = std::vector<File>::iterator; + using const_iterator = std::vector<File>::const_iterator; + + iterator begin(); + iterator end(); + const_iterator begin() const; + const_iterator end() const; + const_iterator cbegin() const; + const_iterator cend() const; + void emplace_back(const File& file); + void push_back(const File& file); + void pop_back(); + + private: + std::vector<File> files; + }; + + class Url final : public StringHolder<Url> { + public: + Url() = default; + Url(std::string url) : StringHolder<Url>(std::move(url)) {} + Url(std::string_view url) : StringHolder<Url>(url) {} + Url(const char* url) : StringHolder<Url>(url) {} + Url(const char* str, size_t len) : StringHolder<Url>(std::string(str, len)) {} + Url(const std::initializer_list<std::string> args) : StringHolder<Url>(args) {} + Url(const Url& other) = default; + Url(Url&& old) noexcept = default; + ~Url() override = default; + + Url& operator=(Url&& old) noexcept = default; + Url& operator=(const Url& other) = default; + }; + + class Body final : public StringHolder<Body> { + public: + Body() = default; + Body(const Body& other) { this->str_ = other.str_; } + Body(Body&& old) noexcept { this->str_ = std::move(old.str_); } + Body(std::string body) : StringHolder<Body>(std::move(body)) {} + Body(std::string_view body) : StringHolder<Body>(body) {} + Body(const char* body) : StringHolder<Body>(body) {} + Body(const char* str, size_t len) : StringHolder<Body>(str, len) {} + Body(const std::initializer_list<std::string> args) : StringHolder<Body>(args) {} + Body(const File& file) { + std::ifstream is(file.filepath, std::ifstream::binary); + if (!is) { + throw std::invalid_argument("Can't open the file for HTTP request body!"); + } + + is.seekg(0, std::ios::end); + const std::streampos length = is.tellg(); + is.seekg(0, std::ios::beg); + std::string buffer; + buffer.resize(static_cast<size_t>(length)); + is.read(buffer.data(), length); + str_ = std::move(buffer); + } + ~Body() override = default; + + Body& operator=(Body&& old) noexcept { + this->str_ = std::move(old.str_); + return *this; + } + Body& operator=(const Body& other) { + this->str_ = other.str_; + return *this; + } + }; + + struct Buffer final { + using data_t = const unsigned char*; + + template <typename Iterator> + Buffer(Iterator begin, Iterator end, std::filesystem::path&& p_filename) + : data{ reinterpret_cast<data_t>(&(*begin)) }, datalen{ static_cast<long>(std::distance(begin, end)) }, filename(std::move(p_filename)) { + is_random_access_iterator(begin, end); + static_assert(sizeof(*begin) == 1, "Only byte buffers can be used"); + } + + template <typename Iterator> + typename std::enable_if<std::is_same<typename std::iterator_traits<Iterator>::iterator_category, std::random_access_iterator_tag>::value>::type is_random_access_iterator(Iterator /* begin */, Iterator /* end */) {} + + data_t data; + long datalen; + const std::filesystem::path filename; + }; + + struct Part final { + Part(const Part& other) { + this->name = other.name; + this->value = other.value; + this->content_type = other.content_type; + this->data = other.data; + this->datalen = other.datalen; + this->is_file = other.is_file; + this->is_buffer = other.is_buffer; + this->files = other.files; + } + Part(Part&& old) noexcept { + this->name = std::move(old.name); + this->value = std::move(old.value); + this->content_type = std::move(old.content_type); + this->data = old.data; + this->datalen = old.datalen; + this->is_file = old.is_file; + this->is_buffer = old.is_buffer; + this->files = std::move(old.files); + } + Part(const std::string& p_name, const std::string& p_value, const std::string& p_content_type = {}) : name{ p_name }, value{ p_value }, content_type{ p_content_type }, is_file{ false }, is_buffer{ false } {} + Part(const std::string& p_name, const std::int32_t& p_value, const std::string& p_content_type = {}) : name{ p_name }, value{ std::to_string(p_value) }, content_type{ p_content_type }, is_file{ false }, is_buffer{ false } {} + Part(const std::string& p_name, const Files& p_files, const std::string& p_content_type = {}) : name{ p_name }, value{}, content_type{ p_content_type }, is_file{ true }, is_buffer{ false }, files{ p_files } {} + Part(const std::string& p_name, Files&& p_files, const std::string& p_content_type = {}) : name{ p_name }, value{}, content_type{ p_content_type }, is_file{ true }, is_buffer{ false }, files{ std::move(p_files) } {} + Part(const std::string& p_name, const Buffer& buffer, const std::string& p_content_type = {}) : name{ p_name }, value{ buffer.filename.string() }, content_type{ p_content_type }, data{ buffer.data }, datalen{ buffer.datalen }, is_file{ false }, is_buffer{ true } {} + + Part& operator=(const Part& other) { + this->name = other.name; + this->value = other.value; + this->content_type = other.content_type; + this->data = other.data; + this->datalen = other.datalen; + this->is_file = other.is_file; + this->is_buffer = other.is_buffer; + this->files = other.files; + return *this; + } + Part& operator=(Part&& old) noexcept { + this->name = std::move(old.name); + this->value = std::move(old.value); + this->content_type = std::move(old.content_type); + this->data = old.data; + this->datalen = old.datalen; + this->is_file = old.is_file; + this->is_buffer = old.is_buffer; + this->files = std::move(old.files); + return *this; + } + + std::string name; + std::string value; + std::string content_type; + Buffer::data_t data{ nullptr }; + long datalen{ 0 }; + bool is_file; + bool is_buffer; + + Files files; + }; + + class Multipart final { + public: + Multipart() = default; + Multipart(const Multipart& other) { + this->parts = other.parts; + } + Multipart(Multipart&& old) noexcept { + this->parts = std::move(old.parts); + } + + Multipart& operator=(const Multipart& other) { + this->parts = other.parts; + return *this; + } + Multipart& operator=(Multipart&& old) noexcept { + this->parts = std::move(old.parts); + return *this; + } + + Multipart(const std::initializer_list<Part>& parts); + + std::vector<Part> parts; + }; + + struct CaseInsensitiveCompare { + bool operator()(const std::string& a, const std::string& b) const noexcept { + return std::lexicographical_compare(a.begin(), a.end(), b.begin(), b.end(), [](unsigned char ac, unsigned char bc) { + return std::tolower(ac) < std::tolower(bc); + }); + } + }; + using Header = std::map<std::string, std::string, CaseInsensitiveCompare>; + + struct Parameter final { + Parameter() = default; + Parameter(const Parameter& other) { + this->key = other.key; + this->value = other.value; + } + Parameter(Parameter&& old) noexcept { + this->key = std::move(old.key); + this->value = std::move(old.value); + } + Parameter(std::string p_key, std::string p_value) : key{ std::move(p_key) }, value{ std::move(p_value) } {} + + Parameter& operator=(const Parameter& other) { + this->key = other.key; + this->value = other.value; + return *this; + } + Parameter& operator=(Parameter&& old) noexcept { + this->key = std::move(old.key); + this->value = std::move(old.value); + return *this; + } + + std::string key; + std::string value; + }; + + class Parameters final { + public: + Parameters() = default; + Parameters(const Parameters& other) { + this->parameters_ = other.parameters_; + } + Parameters(Parameters&& old) noexcept { + this->parameters_ = std::move(old.parameters_); + } + Parameters(const std::initializer_list<Parameter>& parameters); + + Parameters& operator=(const Parameters& other) { + this->parameters_ = other.parameters_; + return *this; + } + Parameters& operator=(Parameters&& old) noexcept { + this->parameters_ = std::move(old.parameters_); + return *this; + } + + void Add(const std::initializer_list<Parameter>& parameters); + void Add(const Parameter& parameter); + bool Empty() const; + + std::string BuildParameterString() const; + + private: + std::vector<Parameter> parameters_; + }; + + class Timeout final { + public: + Timeout(const std::chrono::milliseconds& duration) : ms{ duration } {} + Timeout(const std::int32_t& milliseconds) : Timeout{ std::chrono::milliseconds(milliseconds) } {} + + long Milliseconds() const; + + std::chrono::milliseconds ms; + }; + + class Proxies final { + public: + Proxies() = default; + Proxies(const Proxies& other) { + this->hosts_ = other.hosts_; + } + Proxies(Proxies&& old) noexcept { + this->hosts_ = std::move(old.hosts_); + } + Proxies(const std::initializer_list<std::pair<const std::string, std::string>>& hosts); + Proxies(const std::map<std::string, std::string>& hosts); + + Proxies& operator=(const Proxies& other) { + this->hosts_ = other.hosts_; + return *this; + } + Proxies& operator=(Proxies&& old) noexcept { + this->hosts_ = std::move(old.hosts_); + return *this; + } + + bool has(const std::string& protocol) const; + const std::string& operator[](const std::string& protocol); + + private: + std::map<std::string, std::string> hosts_; + }; + + std::string urlEncodeHelper(const std::string& s); + std::string urlDecodeHelper(const std::string& s); + + class ProxyAuthentication; + class EncodedAuthentication final { + friend ProxyAuthentication; + + public: + EncodedAuthentication() = default; + EncodedAuthentication(const EncodedAuthentication& other) { + this->username = other.username; + this->password = other.password; + } + EncodedAuthentication(EncodedAuthentication&& old) noexcept { + this->username = std::move(old.username); + this->password = std::move(old.password); + } + EncodedAuthentication(const std::string& p_username, const std::string& p_password) : username(urlEncodeHelper(p_username)), password(urlEncodeHelper(p_password)) {} + virtual ~EncodedAuthentication() noexcept; + + EncodedAuthentication& operator=(EncodedAuthentication&& old) noexcept { + this->username = std::move(old.username); + this->password = std::move(old.password); + return *this; + } + EncodedAuthentication& operator=(const EncodedAuthentication& other) { + this->username = other.username; + this->password = other.password; + return *this; + } + + [[nodiscard]] const std::string& GetUsername() const; + [[nodiscard]] const std::string& GetPassword() const; + + void SecureStringClear(std::string& str); + + private: + std::string username; + std::string password; + }; + + class ProxyAuthentication final { + public: + ProxyAuthentication() = default; + ProxyAuthentication(const ProxyAuthentication& other) { + this->proxyAuth_ = other.proxyAuth_; + } + ProxyAuthentication(ProxyAuthentication&& old) noexcept { + this->proxyAuth_ = std::move(old.proxyAuth_); + } + ProxyAuthentication(const std::initializer_list<std::pair<const std::string, EncodedAuthentication>>& auths) : proxyAuth_{auths} {} + explicit ProxyAuthentication(const std::map<std::string, EncodedAuthentication>& auths) : proxyAuth_{auths} {} + + ProxyAuthentication& operator=(const ProxyAuthentication& other) { + this->proxyAuth_ = other.proxyAuth_; + return *this; + } + ProxyAuthentication& operator=(ProxyAuthentication&& old) noexcept { + this->proxyAuth_ = std::move(old.proxyAuth_); + return *this; + } + + [[nodiscard]] bool has(const std::string& protocol) const; + const char* GetUsername(const std::string& protocol); + const char* GetPassword(const std::string& protocol); + + private: + std::map<std::string, EncodedAuthentication> proxyAuth_; + }; + + class WriteCallback final { + public: + WriteCallback() = default; + WriteCallback(const WriteCallback& other) : callback(other.callback), userdata(other.userdata) {} + WriteCallback(WriteCallback&& old) noexcept : callback(std::move(old.callback)), userdata(std::move(old.userdata)) {} + WriteCallback(std::function<bool(std::string data, intptr_t userdata)> p_callback, intptr_t p_userdata = 0) + : userdata(p_userdata), callback(std::move(p_callback)) {} + + WriteCallback& operator=(const WriteCallback& other) { + this->callback = other.callback; + this->userdata = other.userdata; + return *this; + } + WriteCallback& operator=(WriteCallback&& old) noexcept { + this->callback = std::move(old.callback); + this->userdata = std::move(old.userdata); + return *this; + } + + [[nodiscard]] bool operator()(std::string data) const { + return callback(std::move(data), userdata); + } + + intptr_t userdata{}; + std::function<bool(std::string data, intptr_t userdata)> callback; + }; + size_t writeUserFunction(char* ptr, size_t size, size_t nmemb, const WriteCallback* write); + size_t writeFunction(char* ptr, size_t size, size_t nmemb, std::string* data); + size_t writeFileFunction(char* ptr, size_t size, size_t nmemb, std::ofstream* file); + }; + + /* + Class for sessions; each session is a single request. + Each call to Network::Request should follow the + following schema: + + 1. Create a session object. + 2. Set the session's options. + 3. Call the session's X() method where X is the + request method (GET, POST, etc.). + 4. Return the resulting Response object. + */ + class Session final : private CurlHolder { + public: + Session() = default; + ~Session() override; + + liboai::Response Get(); + liboai::Response Post(); + liboai::Response Delete(); + liboai::Response Download(std::ofstream& file); + void ClearContext(); + + + private: + template <class... _Options> + friend void set_options(Session&, _Options&&...); + + void Prepare(); + void PrepareDownloadInternal(); + CURLcode Perform(); + liboai::Response BuildResponseObject(); + liboai::Response Complete(); + liboai::Response CompleteDownload(); + + void PrepareGet(); + void PreparePost(); + void PrepareDelete(); + void PrepareDownload(std::ofstream& file); + + void ParseResponseHeader(const std::string& headers, std::string* status_line, std::string* reason); + + void SetOption(const components::Url& url); + void SetUrl(const components::Url& url); + + void SetOption(const components::Body& body); + void SetBody(const components::Body& body); + void SetOption(components::Body&& body); + void SetBody(components::Body&& body); + + void SetOption(const components::Multipart& multipart); + void SetMultipart(const components::Multipart& multipart); + void SetOption(components::Multipart&& multipart); + void SetMultipart(components::Multipart&& multipart); + + void SetOption(const components::Header& header); + void SetHeader(const components::Header& header); + + void SetOption(const components::Parameters& parameters); + void SetParameters(const components::Parameters& parameters); + void SetOption(components::Parameters&& parameters); + void SetParameters(components::Parameters&& parameters); + + void SetOption(const components::Timeout& timeout); + void SetTimeout(const components::Timeout& timeout); + + void SetOption(const components::Proxies& proxies); + void SetProxies(const components::Proxies& proxies); + void SetOption(components::Proxies&& proxies); + void SetProxies(components::Proxies&& proxies); + + void SetOption(const components::ProxyAuthentication& proxy_auth); + void SetProxyAuthentication(const components::ProxyAuthentication& proxy_auth); + void SetOption(components::ProxyAuthentication&& proxy_auth); + void SetProxyAuthentication(components::ProxyAuthentication&& proxy_auth); + + void SetOption(const components::WriteCallback& write); + void SetWriteCallback(const components::WriteCallback& write); + void SetOption(components::WriteCallback&& write); + void SetWriteCallback(components::WriteCallback&& write); + + long status_code = 0; double elapsed = 0.0; + std::string status_line{}, content{}, url_str{}, reason{}; + + // internally-used members... + curl_slist* headers = nullptr; + #if LIBCURL_VERSION_MAJOR < 7 || (LIBCURL_VERSION_MAJOR == 7 && LIBCURL_VERSION_MINOR < 56) + curl_httppost* form = nullptr; + #endif + + #if LIBCURL_VERSION_MAJOR > 7 || (LIBCURL_VERSION_MAJOR == 7 && LIBCURL_VERSION_MINOR >= 56) + curl_mime* mime = nullptr; + #endif + + bool hasBody = false; + std::string parameter_string_, url_, + response_string_, header_string_; + components::Proxies proxies_; + components::ProxyAuthentication proxyAuth_; + components::WriteCallback write_; + }; + + template <class... _Options> + liboai::Response Get(_Options&&... options) { + Session session; + set_options(session, std::forward<_Options>(options)...); + return session.Get(); + } + + template <class... _Options> + liboai::Response GetWithSession(Session& session, _Options&&... options) { + session.ClearContext(); + set_options(session, std::forward<_Options>(options)...); + return session.Get(); + } + + template <class... _Options> + liboai::Response Post(_Options&&... options) { + Session session; + set_options(session, std::forward<_Options>(options)...); + return session.Post(); + } + + template <class... _Options> + liboai::Response PostWithSession(Session& session, _Options&&... options) { + session.ClearContext(); + set_options(session, std::forward<_Options>(options)...); + return session.Post(); + } + + template <class... _Options> + liboai::Response Delete(_Options&&... options) { + Session session; + set_options(session, std::forward<_Options>(options)...); + return session.Delete(); + } + + template <class... _Options> + liboai::Response DeleteWithSession(Session& session, _Options&&... options) { + session.ClearContext(); + set_options(session, std::forward<_Options>(options)...); + return session.Delete(); + } + + template <class... _Options> + liboai::Response Download(std::ofstream& file, _Options&&... options) { + Session session; + set_options(session, std::forward<_Options>(options)...); + return session.Download(file); + } + + template <class... _Options> + liboai::Response DownloadWithSession(Session& session, std::ofstream& file, _Options&&... options) { + session.ClearContext(); + set_options(session, std::forward<_Options>(options)...); + return session.Download(file); + } + + template <class... _Options> + void set_options(Session& session, _Options&&... opts) { + (session.SetOption(std::forward<_Options>(opts)), ...); + } + } +} diff --git a/packages/media/cpp/packages/liboai/liboai/include/core/network.h b/packages/media/cpp/packages/liboai/liboai/include/core/network.h new file mode 100644 index 00000000..4658c183 --- /dev/null +++ b/packages/media/cpp/packages/liboai/liboai/include/core/network.h @@ -0,0 +1,264 @@ +#pragma once + +/* + network.h : liboai network implementation. + This header file provides declarations for the abstracted liboai + Network implementation. Each component class will inherit from + this class to make use of the network functionality provided by + it. + + For instance, making a call to liboai::Image::Create(...) will + make use of both this class to send the request to the OpenAI API + and liboai::Authorization to provide the user's authorization + information to successfully complete the request. +*/ + +#include <optional> +#include <future> +#include "netimpl.h" + +namespace liboai { + class Network { + public: + /* + @brief Initialise the Network instance to use + the provided API url. + + @param root The URL to direct API calls to. + */ + Network(const std::string &root) noexcept: openai_root_(root) {} + NON_COPYABLE(Network) + NON_MOVABLE(Network) + + /* + @brief Function to download a file at 'from' + to file path 'to.' Useful for downloading + images from the OpenAI API given a URL to + 'from.' + + This function is not to be confused with + liboai::File::download(...) which is used + to download .jsonl files from the OpenAI API. + + @param *to The path and filename to download the file to. + @param *from Where to download the file data from + (such as a URL). + + @returns Bool indicating success or failure. + */ + [[nodiscard]] + static inline bool Download( + const std::string& to, + const std::string& from, + netimpl::components::Header authorization + ) noexcept(false) { + std::ofstream file(to, std::ios::binary); + Response res; + res = netimpl::Download( + file, + netimpl::components::Url{ from }, + std::move(authorization) + ); + + return res.status_code == 200; + } + + [[nodiscard]] + static inline bool DownloadWithSession( + const std::string& to, + const std::string& from, + netimpl::components::Header authorization, + netimpl::Session& session + ) noexcept(false) { + std::ofstream file(to, std::ios::binary); + Response res; + res = netimpl::DownloadWithSession( + session, + file, + netimpl::components::Url{ from }, + std::move(authorization) + ); + + return res.status_code == 200; + } + + /* + @brief Function to asynchronously download a + file at 'from' to file path 'to.' Useful + for downloading images from the OpenAI API + given a URL to 'from.' + + This function is not to be confused with + liboai::File::download(...) which is used + to download .jsonl files from the OpenAI API. + + @param *to The path and filename to download the file to. + @param *from Where to download the file data from + (such as a URL). + + @returns Future bool indicating success or failure. + */ + [[nodiscard]] + static inline std::future<bool> DownloadAsync( + const std::string& to, + const std::string& from, + netimpl::components::Header authorization + ) noexcept(false) { + return std::async( + std::launch::async, [&]() -> bool { + std::ofstream file(to, std::ios::binary); + Response res; + res = netimpl::Download( + file, + netimpl::components::Url{ from }, + std::move(authorization) + ); + + return res.status_code == 200; + } + ); + } + + [[nodiscard]] + static inline std::future<bool> DownloadAsyncWithSession( + const std::string& to, + const std::string& from, + netimpl::components::Header authorization, + netimpl::Session& session + ) noexcept(false) { + return std::async( + std::launch::async, [&]() -> bool { + std::ofstream file(to, std::ios::binary); + Response res; + res = netimpl::DownloadWithSession( + session, + file, + netimpl::components::Url{ from }, + std::move(authorization) + ); + + return res.status_code == 200; + } + ); + } + + protected: + enum class Method : uint8_t { + HTTP_GET, // GET + HTTP_POST, // POST + HTTP_DELETE // DELETE + }; + + template <class... _Params, + std::enable_if_t<std::conjunction_v<std::negation<std::is_lvalue_reference<_Params>>...>, int> = 0> + inline Response Request( + const Method& http_method, + const std::string& root, + const std::string& endpoint, + const std::string& content_type, + std::optional<netimpl::components::Header> headers = std::nullopt, + _Params&&... parameters + ) const { + netimpl::components::Header _headers = { { "Content-Type", content_type } }; + if (headers) { + if (headers.value().size() != 0) { + for (auto& i : headers.value()) { + _headers.insert(std::move(i)); + } + } + } + + Response res; + if constexpr (sizeof...(parameters) > 0) { + res = Network::MethodSchema<netimpl::components::Header&&, _Params&&...>::_method[static_cast<uint8_t>(http_method)]( + netimpl::components::Url { root + endpoint }, + std::move(_headers), + std::forward<_Params>(parameters)... + ); + } + else { + res = Network::MethodSchema<netimpl::components::Header&&>::_method[static_cast<uint8_t>(http_method)]( + netimpl::components::Url { root + endpoint }, + std::move(_headers) + ); + } + + return res; + } + + + template <class... _Params, + std::enable_if_t<std::conjunction_v<std::negation<std::is_lvalue_reference<_Params>>...>, int> = 0> + inline Response RequestWithSession( + const Method& http_method, + const std::string& root, + const std::string& endpoint, + const std::string& content_type, + netimpl::Session& session, + std::optional<netimpl::components::Header> headers = std::nullopt, + _Params&&... parameters + ) const { + netimpl::components::Header _headers = { { "Content-Type", content_type } }; + if (headers) { + if (headers.value().size() != 0) { + for (auto& i : headers.value()) { + _headers.insert(std::move(i)); + } + } + } + + Response res; + if constexpr (sizeof...(parameters) > 0) { + res = Network::MethodSchemaWithSession<netimpl::components::Header&&, _Params&&...>::_method[static_cast<uint8_t>(http_method)]( + session, + netimpl::components::Url { root + endpoint }, + std::move(_headers), + std::forward<_Params>(parameters)... + ); + } + else { + res = Network::MethodSchemaWithSession<netimpl::components::Header&&>::_method[static_cast<uint8_t>(http_method)]( + session, + netimpl::components::Url { root + endpoint }, + std::move(_headers) + ); + } + + return res; + } + + /* + @brief Function to validate the existence and validity of + a file located at a provided file path. This is used + in functions that take a file path as a parameter + to ensure that the file exists and is valid. + */ + bool Validate(const std::filesystem::path& path) const { + // checks if the file exists, is a regular file, and is not empty + if (std::filesystem::exists(path) && std::filesystem::is_regular_file(path)) { + return std::filesystem::file_size(path) > 0; + } + return false; + } + + const std::string openai_root_; + const std::string azure_root_ = ".openai.azure.com/openai"; + + private: + template <class... T> struct MethodSchema { + inline static std::function<Response(netimpl::components::Url&&, T...)> _method[3] = { + netimpl::Get <netimpl::components::Url&&, T...>, + netimpl::Post <netimpl::components::Url&&, T...>, + netimpl::Delete <netimpl::components::Url&&, T...> + }; + }; + + template <class... T> struct MethodSchemaWithSession { + inline static std::function<Response(netimpl::Session&, netimpl::components::Url&&, T...)> _method[3] = { + netimpl::GetWithSession <netimpl::components::Url&&, T...>, + netimpl::PostWithSession <netimpl::components::Url&&, T...>, + netimpl::DeleteWithSession <netimpl::components::Url&&, T...> + }; + }; + }; +} \ No newline at end of file diff --git a/packages/media/cpp/packages/liboai/liboai/include/core/response.h b/packages/media/cpp/packages/liboai/liboai/include/core/response.h new file mode 100644 index 00000000..1ad29a8a --- /dev/null +++ b/packages/media/cpp/packages/liboai/liboai/include/core/response.h @@ -0,0 +1,122 @@ +#pragma once + +/* + response.h : liboai response container implementation. + This header file provides declarations for the liboai Response + implementation. Each component class will include this header + and use the Response class to return data to the user. + + For instance, making a call to liboai::Image::Create(...) will + return a liboai::Response object. The user can then check the + object and retrieve the data found in the response as needed. + + This class will construct itself from the output of + liboai::Network::Request(...) (cpr::Response) and parse it + into a usable format for the user to access via this class. +*/ + +#if defined(__linux__) || defined(__APPLE__) + #define LIBOAI_EXPORT +#else + #define LIBOAI_EXPORT __declspec(dllexport) +#endif + +#define NON_COPYABLE(Class) Class(const Class&) = delete; Class& operator=(const Class&) = delete; +#define NON_MOVABLE(Class) Class(Class&&) = delete; Class& operator=(Class&&) = delete; + +#include <iostream> +#include <optional> +#include <future> +#include <nlohmann/json.hpp> +#include "exception.h" + +namespace liboai { + template <typename T, typename = void> struct has_value_type : std::false_type {}; + template <typename T> struct has_value_type<T, std::void_t<typename T::value_type>> : std::true_type {}; + template <typename T> inline constexpr const bool has_value_type_v = has_value_type<T>::value; + + class JsonConstructor final { + public: + JsonConstructor() {} + JsonConstructor(const JsonConstructor& other) noexcept : _json(other._json) {} + JsonConstructor(JsonConstructor&& old) noexcept : _json(std::move(old._json)) {} + + template <class _Ty> + void push_back(std::string_view key, const _Ty& value) { + if constexpr (std::is_same_v<_Ty, std::optional<std::function<bool(std::string, intptr_t)>>>) { + if (value) { + this->_json[key.data()] = true; + } + } + else if constexpr (std::is_same_v<_Ty, std::function<bool(std::string, intptr_t)>>) { + if (value) { + this->_json[key.data()] = true; + } + } + else { + this->_json[key.data()] = value; + } + } + + template <class _Ty, + std::enable_if_t<std::conjunction_v<has_value_type<_Ty>, std::is_same<_Ty, std::optional<typename _Ty::value_type>>>, int> = 0> + void push_back(std::string_view key, _Ty&& value) { + if (value) { + this->_json[key.data()] = std::forward<typename _Ty::value_type>(value.value()); + } + } + + std::string dump() const { + return this->_json.dump(4); + } + + private: + nlohmann::json _json; + }; + + class Response final { + public: + Response() = default; + Response(const liboai::Response& other) noexcept; + Response(liboai::Response&& old) noexcept; + Response( + std::string&& url, + std::string&& content, + std::string&& status_line, + std::string&& reason, + long status_code, + double elapsed + ) noexcept(false); + + Response& operator=(const liboai::Response& other) noexcept; + Response& operator=(liboai::Response&& old) noexcept; + + /* + @brief Transparent operator[] wrapper to nlohmann::json to + access the Response object as if it were a json object. + */ + template <class _Ty> + nlohmann::json::const_reference operator[](const _Ty& key) const noexcept { + return this->raw_json[key]; + } + + /* + @brief std::ostream operator<< overload to allow for + pretty printing of the Response object. + */ + LIBOAI_EXPORT friend std::ostream& operator<<(std::ostream& os, const Response& r); + + public: + long status_code = 0; double elapsed = 0.0; + std::string status_line{}, content{}, url{}, reason{}; + nlohmann::json raw_json{}; + + private: + /* + @brief Used internally during construction to check the response + for errors and throw exceptions if necessary. + */ + LIBOAI_EXPORT void CheckResponse() const noexcept(false); + }; + using FutureResponse = std::future<liboai::Response>; +} \ No newline at end of file diff --git a/packages/media/cpp/packages/liboai/liboai/include/liboai.h b/packages/media/cpp/packages/liboai/liboai/include/liboai.h new file mode 100644 index 00000000..54736bec --- /dev/null +++ b/packages/media/cpp/packages/liboai/liboai/include/liboai.h @@ -0,0 +1,146 @@ +#pragma once + +/* + Copyright (c) 2012-2022 Johnny (pseud. Dread) and others + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + liboai.h : main library header. + This header file provides an interface to all component classes + in the library. It is the only header file that needs to be + included in order to use the library. +*/ + +#include "components/audio.h" +#include "components/azure.h" +#include "components/chat.h" +#include "components/completions.h" +#include "components/edits.h" +#include "components/embeddings.h" +#include "components/files.h" +#include "components/fine_tunes.h" +#include "components/images.h" +#include "components/models.h" +#include "components/moderations.h" +#include "components/responses.h" + +namespace liboai { + class OpenAI { + public: + OpenAI(const std::string &root = "https://api.openai.com/v1"): + Audio(std::make_unique<liboai::Audio>(root)), + Azure(std::make_unique<liboai::Azure>(root)), + ChatCompletion(std::make_unique<liboai::ChatCompletion>(root)), + Completion(std::make_unique<liboai::Completions>(root)), + Edit(std::make_unique<liboai::Edits>(root)), + Embedding(std::make_unique<liboai::Embeddings>(root)), + File(std::make_unique<liboai::Files>(root)), + FineTune(std::make_unique<liboai::FineTunes>(root)), + Image(std::make_unique<liboai::Images>(root)), + Model(std::make_unique<liboai::Models>(root)), + Moderation(std::make_unique<liboai::Moderations>(root)), + Responses(std::make_unique<liboai::Responses>(root)) + {} + OpenAI(OpenAI const&) = delete; + OpenAI(OpenAI&&) = delete; + void operator=(OpenAI const&) = delete; + void operator=(OpenAI&&) = delete; + + public: // component interfaces + /* + @brief A pointer to the Audio component class that + provides access to its OpenAI API endpoints. + */ + std::unique_ptr<liboai::Audio> Audio; + + /* + @brief A pointer to the Azure component class that + provides access to its API endpoints. + */ + std::unique_ptr<liboai::Azure> Azure; + + /* + @brief A pointer to the Chat component class that + provides access to its OpenAI API endpoints. + */ + std::unique_ptr<liboai::ChatCompletion> ChatCompletion; + + /* + @brief A pointer to the Completions component class that + provides access to its OpenAI API endpoints. + */ + std::unique_ptr<liboai::Completions> Completion; + + /* + @brief A pointer to the Edits component class that + provides access to its OpenAI API endpoints. + */ + std::unique_ptr<liboai::Edits> Edit; + + /* + @brief A pointer to the Embeddings component class that + provides access to its OpenAI API endpoints. + */ + std::unique_ptr<liboai::Embeddings> Embedding; + + /* + @brief A pointer to the Files component class that + provides access to its OpenAI API endpoints. + */ + std::unique_ptr<liboai::Files> File; + + /* + @brief A pointer to the FineTunes component class that + provides access to its OpenAI API endpoints. + */ + std::unique_ptr<liboai::FineTunes> FineTune; + + /* + @brief A pointer to the Images component class that + provides access to its OpenAI API endpoints. + */ + std::unique_ptr<liboai::Images> Image; + + /* + @brief A pointer to the Models component class that + provides access to its OpenAI API endpoints. + */ + std::unique_ptr<liboai::Models> Model; + + /* + @brief A pointer to the Moderations component class that + provides access to its OpenAI API endpoints. + */ + std::unique_ptr<liboai::Moderations> Moderation; + + /* + @brief A pointer to the Responses component class that + provides access to its OpenAI API endpoints. + */ + std::unique_ptr<liboai::Responses> Responses; + + public: + /* + @brief Convenience reference to the Authorization class + singleton used to set authorization information. + */ + Authorization& auth = Authorization::Authorizer(); + }; +} diff --git a/packages/media/cpp/packages/liboai/shell.nix b/packages/media/cpp/packages/liboai/shell.nix new file mode 100644 index 00000000..2827d99e --- /dev/null +++ b/packages/media/cpp/packages/liboai/shell.nix @@ -0,0 +1,14 @@ +{ pkgs ? import <nixpkgs> {} }: pkgs.mkShell { + buildInputs = with pkgs; [ + gcc + cmake + ninja + clang-tools + lldb + + zlib + curl + nlohmann_json + ]; +} + diff --git a/packages/media/cpp/packages/logger/CMakeLists.txt b/packages/media/cpp/packages/logger/CMakeLists.txt new file mode 100644 index 00000000..4fb71e15 --- /dev/null +++ b/packages/media/cpp/packages/logger/CMakeLists.txt @@ -0,0 +1,21 @@ +include(FetchContent) + +FetchContent_Declare( + spdlog + GIT_REPOSITORY https://github.com/gabime/spdlog.git + GIT_TAG v1.15.1 + GIT_SHALLOW TRUE +) +FetchContent_MakeAvailable(spdlog) + +add_library(logger STATIC + src/logger.cpp +) + +target_include_directories(logger + PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include +) + +target_link_libraries(logger + PUBLIC spdlog::spdlog +) diff --git a/packages/media/cpp/packages/logger/include/logger/logger.h b/packages/media/cpp/packages/logger/include/logger/logger.h new file mode 100644 index 00000000..6888e03e --- /dev/null +++ b/packages/media/cpp/packages/logger/include/logger/logger.h @@ -0,0 +1,22 @@ +#pragma once + +#include <string> + +namespace logger { + +/// Initialize the default logger (call once at startup). +void init(const std::string &app_name = "polymech", const std::string &log_level = "info"); + +/// Initialize logger with stderr sink (use in worker/IPC mode). +void init_stderr(const std::string &app_name = "polymech-worker", const std::string &log_level = "info"); + +/// Initialize logger with stderr and file sink (use in UDS worker mode). +void init_uds(const std::string &app_name = "polymech-worker", const std::string &log_level = "info", const std::string &log_file = "logs/uds.json"); + +/// Log at various levels. +void info(const std::string &msg); +void warn(const std::string &msg); +void error(const std::string &msg); +void debug(const std::string &msg); + +} // namespace logger diff --git a/packages/media/cpp/packages/logger/src/logger.cpp b/packages/media/cpp/packages/logger/src/logger.cpp new file mode 100644 index 00000000..e82a3122 --- /dev/null +++ b/packages/media/cpp/packages/logger/src/logger.cpp @@ -0,0 +1,57 @@ +#include "logger/logger.h" + +#include <spdlog/sinks/stdout_color_sinks.h> +#include <spdlog/sinks/basic_file_sink.h> +#include <spdlog/spdlog.h> +#include <filesystem> + + +namespace logger { + +static void apply_log_level(const std::string& level) { + if (level == "debug") spdlog::set_level(spdlog::level::debug); + else if (level == "warn") spdlog::set_level(spdlog::level::warn); + else if (level == "error") spdlog::set_level(spdlog::level::err); + else spdlog::set_level(spdlog::level::info); +} + +void init(const std::string &app_name, const std::string &log_level) { + auto console = spdlog::stdout_color_mt(app_name); + spdlog::set_default_logger(console); + apply_log_level(log_level); + spdlog::set_pattern("[%H:%M:%S] [%^%l%$] %v"); +} + +void init_stderr(const std::string &app_name, const std::string &log_level) { + auto console = spdlog::stderr_color_mt(app_name); + spdlog::set_default_logger(console); + apply_log_level(log_level); + spdlog::set_pattern("[%H:%M:%S] [%^%l%$] %v"); +} + +void init_uds(const std::string &app_name, const std::string &log_level, const std::string &log_file) { + auto console_sink = std::make_shared<spdlog::sinks::stderr_color_sink_mt>(); + + std::filesystem::path log_path(log_file); + std::error_code ec; + std::filesystem::create_directories(log_path.parent_path(), ec); + + auto file_sink = std::make_shared<spdlog::sinks::basic_file_sink_mt>(log_file, false); // false = append + + std::vector<spdlog::sink_ptr> sinks {console_sink, file_sink}; + auto multi_logger = std::make_shared<spdlog::logger>(app_name, sinks.begin(), sinks.end()); + + spdlog::set_default_logger(multi_logger); + apply_log_level(log_level); + spdlog::set_pattern("[%Y-%m-%d %H:%M:%S.%e] [%^%l%$] %v"); + // Ensure logs are flushed immediately to file + spdlog::flush_every(std::chrono::seconds(1)); + spdlog::flush_on(spdlog::level::info); +} + +void info(const std::string &msg) { spdlog::info(msg); } +void warn(const std::string &msg) { spdlog::warn(msg); } +void error(const std::string &msg) { spdlog::error(msg); } +void debug(const std::string &msg) { spdlog::debug(msg); } + +} // namespace logger diff --git a/packages/media/cpp/packages/polymech/CMakeLists.txt b/packages/media/cpp/packages/polymech/CMakeLists.txt new file mode 100644 index 00000000..0b8d1e78 --- /dev/null +++ b/packages/media/cpp/packages/polymech/CMakeLists.txt @@ -0,0 +1,9 @@ +add_library(polymech STATIC + src/polymech.cpp +) + +target_include_directories(polymech + PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include +) + +target_link_libraries(polymech PUBLIC postgres logger) diff --git a/packages/media/cpp/packages/polymech/include/polymech/polymech.h b/packages/media/cpp/packages/polymech/include/polymech/polymech.h new file mode 100644 index 00000000..338d8c8c --- /dev/null +++ b/packages/media/cpp/packages/polymech/include/polymech/polymech.h @@ -0,0 +1,16 @@ +#pragma once + +#include <string> +#include <vector> + +namespace polymech { + +/// Fetch all rows from the "pages" table. +/// Returns raw JSON array string from Supabase. +std::string fetch_pages(); + +/// Fetch pages with a specific select clause and optional filter. +std::string fetch_pages(const std::string &select, + const std::string &filter = "", int limit = 0); + +} // namespace polymech diff --git a/packages/media/cpp/packages/polymech/src/polymech.cpp b/packages/media/cpp/packages/polymech/src/polymech.cpp new file mode 100644 index 00000000..4568bb65 --- /dev/null +++ b/packages/media/cpp/packages/polymech/src/polymech.cpp @@ -0,0 +1,17 @@ +#include "polymech/polymech.h" +#include "logger/logger.h" +#include "postgres/postgres.h" + + +namespace polymech { + +std::string fetch_pages() { return fetch_pages("*"); } + +std::string fetch_pages(const std::string &select, const std::string &filter, + int limit) { + logger::debug("polymech::fetch_pages → select=" + select + + " filter=" + filter + " limit=" + std::to_string(limit)); + return postgres::query("pages", select, filter, limit); +} + +} // namespace polymech diff --git a/packages/media/cpp/packages/postgres/CMakeLists.txt b/packages/media/cpp/packages/postgres/CMakeLists.txt new file mode 100644 index 00000000..0f5d1e7a --- /dev/null +++ b/packages/media/cpp/packages/postgres/CMakeLists.txt @@ -0,0 +1,11 @@ +add_library(postgres STATIC + src/postgres.cpp +) + +target_include_directories(postgres + PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include +) + +target_link_libraries(postgres + PUBLIC logger http json +) diff --git a/packages/media/cpp/packages/postgres/include/postgres/postgres.h b/packages/media/cpp/packages/postgres/include/postgres/postgres.h new file mode 100644 index 00000000..fe84d04a --- /dev/null +++ b/packages/media/cpp/packages/postgres/include/postgres/postgres.h @@ -0,0 +1,46 @@ +#pragma once + +#include <string> +#include <vector> + +namespace postgres { + +/// Supabase connection configuration. +struct Config { + std::string supabase_url; + std::string supabase_key; +}; + +/// Initialize the Supabase client with URL and API key. +void init(const Config &config); + +/// Ping the Supabase REST API. Returns "ok" on success, error message on +/// failure. +std::string ping(); + +/// Query a table via the PostgREST API. +/// Returns the raw JSON response body. +/// @param table Table name (e.g. "profiles") +/// @param select Comma-separated columns (e.g. "id,username"), or "*" +/// @param filter PostgREST filter (e.g. "id=eq.abc"), or "" for no filter +/// @param limit Max rows (0 = no limit) +std::string query(const std::string &table, const std::string &select = "*", + const std::string &filter = "", int limit = 0); + +/// Insert a row into a table. Body is a JSON object string. +/// Returns the created row as JSON. +std::string insert(const std::string &table, const std::string &json_body); + +/// Upsert a row into a table. Body is a JSON array or object string. +/// Returns the upserted array as JSON. +std::string upsert(const std::string &table, const std::string &json_body, const std::string &on_conflict = ""); + +/// Update rows in a table. Body is a JSON object string. +/// Returns the updated rows as JSON. +std::string update(const std::string &table, const std::string &json_body, const std::string &filter); + +/// Delete rows from a table. +/// Returns the deleted rows as JSON. +std::string del(const std::string &table, const std::string &filter); + +} // namespace postgres diff --git a/packages/media/cpp/packages/postgres/src/postgres.cpp b/packages/media/cpp/packages/postgres/src/postgres.cpp new file mode 100644 index 00000000..dfa4021c --- /dev/null +++ b/packages/media/cpp/packages/postgres/src/postgres.cpp @@ -0,0 +1,236 @@ +#include "postgres/postgres.h" +#include "http/http.h" +#include "logger/logger.h" +#include "json/json.h" + +#include <curl/curl.h> +#include <stdexcept> + +namespace postgres { + +static Config s_config; +static bool s_initialized = false; + +void init(const Config &config) { + s_config = config; + s_initialized = true; + logger::debug("postgres::init → " + config.supabase_url); +} + +static void ensure_init() { + if (!s_initialized) { + throw std::runtime_error("postgres::init() must be called first"); + } +} + +/// Build the REST URL for a table query. +static std::string build_url(const std::string &table, + const std::string &select, + const std::string &filter, int limit) { + std::string url = s_config.supabase_url + "/rest/v1/" + table; + url += "?select=" + select; + if (!filter.empty()) { + url += "&" + filter; + } + if (limit > 0) { + url += "&limit=" + std::to_string(limit); + } + return url; +} + +/// Make an authenticated GET request to the Supabase REST API. +static http::Response supabase_get(const std::string &url) { + // We need custom headers, so we use curl directly + CURL *curl = curl_easy_init(); + http::Response resp{}; + if (!curl) { + resp.status_code = -1; + resp.body = "curl_easy_init failed"; + return resp; + } + + struct curl_slist *headers = nullptr; + headers = + curl_slist_append(headers, ("apikey: " + s_config.supabase_key).c_str()); + headers = curl_slist_append( + headers, ("Authorization: Bearer " + s_config.supabase_key).c_str()); + + auto write_cb = [](void *contents, size_t size, size_t nmemb, void *userp) { + auto *out = static_cast<std::string *>(userp); + out->append(static_cast<char *>(contents), size * nmemb); + return size * nmemb; + }; + + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + curl_easy_setopt( + curl, CURLOPT_WRITEFUNCTION, + static_cast<size_t (*)(void *, size_t, size_t, void *)>(+write_cb)); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &resp.body); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L); + + CURLcode res = curl_easy_perform(curl); + if (res != CURLE_OK) { + resp.status_code = -1; + resp.body = curl_easy_strerror(res); + } else { + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &resp.status_code); + } + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + return resp; +} + +/// Make an authenticated request with a JSON body (POST, PATCH, DELETE). +static http::Response supabase_request(const std::string &method, + const std::string &url, + const std::string &body, + const std::string &prefer_header) { + CURL *curl = curl_easy_init(); + http::Response resp{}; + if (!curl) { + resp.status_code = -1; + resp.body = "curl_easy_init failed"; + return resp; + } + + struct curl_slist *headers = nullptr; + if (!body.empty()) { + headers = curl_slist_append(headers, "Content-Type: application/json"); + } + if (!prefer_header.empty()) { + headers = curl_slist_append(headers, ("Prefer: " + prefer_header).c_str()); + } + headers = + curl_slist_append(headers, ("apikey: " + s_config.supabase_key).c_str()); + headers = curl_slist_append( + headers, ("Authorization: Bearer " + s_config.supabase_key).c_str()); + + auto write_cb = [](void *contents, size_t size, size_t nmemb, void *userp) { + auto *out = static_cast<std::string *>(userp); + out->append(static_cast<char *>(contents), size * nmemb); + return size * nmemb; + }; + + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, method.c_str()); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + if (!body.empty()) { + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.c_str()); + } + curl_easy_setopt( + curl, CURLOPT_WRITEFUNCTION, + static_cast<size_t (*)(void *, size_t, size_t, void *)>(+write_cb)); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &resp.body); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L); + + CURLcode res = curl_easy_perform(curl); + if (res != CURLE_OK) { + resp.status_code = -1; + resp.body = curl_easy_strerror(res); + } else { + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &resp.status_code); + } + + curl_slist_free_all(headers); + curl_easy_cleanup(curl); + return resp; +} + +std::string ping() { + ensure_init(); + // Lightweight check: query profiles with limit=0 to verify connectivity + auto resp = supabase_get(s_config.supabase_url + + "/rest/v1/profiles?select=id&limit=0"); + if (resp.status_code >= 200 && resp.status_code < 300) { + logger::info("postgres::ping → ok (HTTP " + + std::to_string(resp.status_code) + ")"); + return "ok"; + } + logger::error("postgres::ping → HTTP " + std::to_string(resp.status_code) + + ": " + resp.body); + return "error: HTTP " + std::to_string(resp.status_code); +} + +std::string query(const std::string &table, const std::string &select, + const std::string &filter, int limit) { + ensure_init(); + auto url = build_url(table, select, filter, limit); + logger::debug("postgres::query → " + url); + + auto resp = supabase_get(url); + if (resp.status_code >= 200 && resp.status_code < 300) { + return resp.body; + } + logger::error("postgres::query → HTTP " + std::to_string(resp.status_code) + + ": " + resp.body); + return resp.body; +} + +std::string insert(const std::string &table, const std::string &json_body) { + ensure_init(); + auto url = s_config.supabase_url + "/rest/v1/" + table; + logger::debug("postgres::insert → " + url); + + auto resp = supabase_request("POST", url, json_body, "return=representation"); + if (resp.status_code >= 200 && resp.status_code < 300) { + return resp.body; + } + logger::error("postgres::insert → HTTP " + std::to_string(resp.status_code) + + ": " + resp.body); + return resp.body; +} + +std::string upsert(const std::string &table, const std::string &json_body, const std::string &on_conflict) { + ensure_init(); + auto url = s_config.supabase_url + "/rest/v1/" + table; + if (!on_conflict.empty()) { + url += "?on_conflict=" + on_conflict; + } + logger::debug("postgres::upsert → " + url); + + auto resp = supabase_request("POST", url, json_body, "return=minimal, resolution=merge-duplicates"); + if (resp.status_code >= 200 && resp.status_code < 300) { + return resp.body; + } + logger::error("postgres::upsert → HTTP " + std::to_string(resp.status_code) + + ": " + resp.body); + return resp.body; +} + +std::string update(const std::string &table, const std::string &json_body, const std::string &filter) { + ensure_init(); + auto url = s_config.supabase_url + "/rest/v1/" + table; + if (!filter.empty()) { + url += "?" + filter; + } + logger::debug("postgres::update → " + url); + + auto resp = supabase_request("PATCH", url, json_body, "return=representation"); + if (resp.status_code >= 200 && resp.status_code < 300) { + return resp.body; + } + logger::error("postgres::update → HTTP " + std::to_string(resp.status_code) + + ": " + resp.body); + return resp.body; +} + +std::string del(const std::string &table, const std::string &filter) { + ensure_init(); + auto url = s_config.supabase_url + "/rest/v1/" + table; + if (!filter.empty()) { + url += "?" + filter; + } + logger::debug("postgres::del → " + url); + + auto resp = supabase_request("DELETE", url, "", "return=representation"); + if (resp.status_code >= 200 && resp.status_code < 300) { + return resp.body; + } + logger::error("postgres::del → HTTP " + std::to_string(resp.status_code) + + ": " + resp.body); + return resp.body; +} + +} // namespace postgres diff --git a/packages/media/cpp/polymech.md b/packages/media/cpp/polymech.md new file mode 100644 index 00000000..d79ee001 --- /dev/null +++ b/packages/media/cpp/polymech.md @@ -0,0 +1,315 @@ +# Polymech C++ Gridsearch Worker — Design + +## Goal + +Port the [gridsearch-worker.ts](../src/products/locations/gridsearch-worker.ts) pipeline to native C++, running as a **CLI subcommand** (`polymech-cli gridsearch`) while keeping all logic in internal libraries under `packages/`. The worker communicates progress via the [IPC framing protocol](./packages/ipc/) and writes results to Supabase via the existing [postgres](./packages/postgres/) package. + +--- + +## Status + +| Package | Status | Tests | Assertions | +|---------|--------|-------|------------| +| `geo` | ✅ Done | 23 | 77 | +| `gadm_reader` | ✅ Done | 18 | 53 | +| `grid` | ✅ Done | 13 | 105 | +| `search` | ✅ Done | 8 | 13 | +| CLI `gridsearch` | ✅ Done | — | dry-run verified (3ms) | +| IPC `gridsearch` | ✅ Done | 1 | 30 | +| **Total** | | **63** | **278** | + +--- + +## Existing C++ Inventory + +| Package | Provides | +|---------|----------| +| `ipc` | Length-prefixed JSON over stdio | +| `postgres` | Supabase PostgREST: `query`, `insert` | +| `http` | libcurl `GET`/`POST` | +| `json` | RapidJSON validate/prettify | +| `logger` | spdlog (stdout or **stderr** in worker mode) | +| `html` | HTML parser | + +--- + +## TypeScript Pipeline (Reference) + +``` +GADM Resolve → Grid Generate → SerpAPI Search → Enrich → Supabase Upsert +``` + +| Phase | Input | Output | Heavy work | +|-------|-------|--------|------------| +| **1. GADM Resolve** | GID list + target level | `GridFeature[]` (GeoJSON polygons with GHS props) | Read pre-cached JSON files from `cache/gadm/boundary_{GID}_{LEVEL}.json` | +| **2. Grid Generate** | `GridFeature[]` + settings | `GridSearchHop[]` (waypoints: lat/lng/radius) | Centroid, bbox, distance, area, point-in-polygon, cell sorting | +| **3. Search** | Waypoints + query + SerpAPI key | Place results (JSON) | HTTP calls to `serpapi.com`, per-waypoint caching | +| **4. Enrich** | Place results | Enriched data (emails, pages) | HTTP scraping | +| **5. Persist** | Enriched places | Supabase `places` + `grid_search_runs` | PostgREST upsert | + +--- + +## Implemented Packages + +### 1. `packages/geo` — Geometry primitives ✅ + +Header + `.cpp`, no external deps. Implements the **turf.js subset** used by the grid generator. + +```cpp +namespace geo { + +struct Coord { double lon, lat; }; +struct BBox { double minLon, minLat, maxLon, maxLat; }; + +BBox bbox(const std::vector<Coord>& ring); +Coord centroid(const std::vector<Coord>& ring); +double area_sq_m(const std::vector<Coord>& ring); +double distance_km(Coord a, Coord b); +bool point_in_polygon(Coord pt, const std::vector<Coord>& ring); + +std::vector<BBox> square_grid(BBox extent, double cellSizeKm); +std::vector<BBox> hex_grid(BBox extent, double cellSizeKm); +std::vector<Coord> buffer_circle(Coord center, double radiusKm, int steps = 6); +} // namespace geo +``` + +**Rationale**: ~200 lines avoids pulling GEOS/Boost.Geometry. Adopts `pip.h` ray-casting pattern from `packages/gadm/cpp/` without the GDAL/GEOS/PROJ dependency (~700MB). + +--- + +### 2. `packages/gadm_reader` — Boundary resolver ✅ + +Reads pre-cached GADM boundary JSON from disk. No network calls. + +```cpp +namespace gadm { + +struct Feature { + std::string gid, name; + int level; + std::vector<std::vector<geo::Coord>> rings; + double ghsPopulation, ghsBuiltWeight; + geo::Coord ghsPopCenter, ghsBuiltCenter; + std::vector<std::array<double, 3>> ghsPopCenters; // [lon, lat, weight] + std::vector<std::array<double, 3>> ghsBuiltCenters; + double areaSqKm; +}; + +BoundaryResult load_boundary(const std::string& gid, int targetLevel, + const std::string& cacheDir = "cache/gadm"); +} // namespace gadm +``` + +Handles `Polygon`/`MultiPolygon`, GHS enrichment fields, fallback resolution by country code prefix. + +--- + +### 3. `packages/grid` — Grid generator ✅ + +Direct port of [grid-generator.ts](../../shared/src/products/places/grid-generator.ts). + +```cpp +namespace grid { + +struct Waypoint { int step; double lng, lat, radius_km; }; +struct GridOptions { + std::string gridMode; // "hex", "square", "admin", "centers" + double cellSize; // km + double cellOverlap, centroidOverlap; + int maxCellsLimit; + double maxElevation, minDensity, minGhsPop, minGhsBuilt; + std::string ghsFilterMode; // "AND" | "OR" + bool allowMissingGhs, bypassFilters; + std::string pathOrder; // "zigzag", "snake", "spiral-out", "spiral-in", "shortest" + bool groupByRegion; +}; +struct GridResult { std::vector<Waypoint> waypoints; int validCells, skippedCells; std::string error; }; + +GridResult generate(const std::vector<gadm::Feature>& features, const GridOptions& opts); +} // namespace grid +``` + +**4 modes**: `admin` (centroid + radius), `centers` (GHS deduplicated), `hex`, `square` (tessellation + PIP) +**5 sort algorithms**: `zigzag`, `snake`, `spiral-out`, `spiral-in`, `shortest` (greedy NN) + +--- + +### 4. `packages/search` — SerpAPI client + config ✅ + +```cpp +namespace search { + +struct Config { + std::string serpapi_key, geocoder_key, bigdata_key; + std::string postgres_url, supabase_url, supabase_service_key; +}; + +Config load_config(const std::string& path = "config/postgres.toml"); + +struct SearchOptions { + std::string query; + double lat, lng; + int zoom = 13, limit = 20; + std::string engine = "google_maps", hl = "en", google_domain = "google.com"; +}; + +struct MapResult { + std::string title, place_id, data_id, address, phone, website, type; + std::vector<std::string> types; + double rating; int reviews; + GpsCoordinates gps; +}; + +SearchResult search_google_maps(const Config& cfg, const SearchOptions& opts); +} // namespace search +``` + +Reads `[services].SERPAPI_KEY`, `GEO_CODER_KEY`, `BIG_DATA_KEY` from `config/postgres.toml`. HTTP pagination via `http::get()`, JSON parsing with RapidJSON. + +--- + +## CLI Subcommands ✅ + +### 1. `gridsearch` (One-shot execution) + +``` +polymech-cli gridsearch <GID> <QUERY> [OPTIONS] + +Positionals: + GID GADM GID (e.g. ESP.1.1_1) — ignored when --settings is used + QUERY Search query — ignored when --settings is used + +Options: + -l, --level INT Target GADM level (default: 0) + -m, --mode TEXT Grid mode: hex|square|admin|centers (default: hex) + -s, --cell-size FLOAT Cell size in km (default: 5.0) + --limit INT Max results per area (default: 20) + -z, --zoom INT Google Maps zoom (default: 13) + --sort TEXT Path order: snake|zigzag|spiral-out|spiral-in|shortest + -c, --config TEXT TOML config path (default: config/postgres.toml) + --cache-dir TEXT GADM cache directory (default: cache/gadm) + --settings TEXT JSON settings file (matches TypeScript GuidedPreset shape) + --enrich Run enrichment pipeline (meta + email) after search + --persistence-postgres Persist run data natively via Postgres + -o, --output TEXT Output JSON file (default: gridsearch-HH-MM.json in cwd) + --dry-run Generate grid only, skip SerpAPI search +``` + +### 2. `worker` (IPC Daemon execution) + +``` +polymech-cli worker [OPTIONS] + +Options: + --daemon Run persistent daemon pool (tier-based) + -c, --config TEXT TOML config path (default: config/postgres.toml) + --user-uid TEXT User ID to bind this daemon to (needed for place owner) + --uds TEXT Run over Unix Domain Socket / Named Pipe (TCP on Windows) at the given path +``` + +### Execution flow + +``` +1. load_config(configPath) → Config (TOML) +2. gadm::load_boundary(gid, level) → features[] +3. grid::generate(features, opts) → waypoints[] +4. --dry-run → output JSON array and exit +5. For each waypoint → search::search_google_maps(cfg, sopts) +6. Stream JSON summary to stdout +``` + +### Example + +```bash +polymech-cli gridsearch ABW "recycling" --dry-run +# → [{"step":1,"lat":12.588582,"lng":-70.040465,"radius_km":3.540}, ...] +# [info] Dry-run complete in 3ms +``` + +### IPC worker mode + +The `worker` subcommand natively routes multiplexed asynchronous `gridsearch` payloads. When launched via `--uds <path>`, it provisions a high-performance Asio streaming server (AF_UNIX sockets on POSIX, TCP sockets on Windows). Event frames (`grid-ready`, `waypoint-start`, `location`, `node`, etc) emit bi-directionally utilizing the IPC bridging protocol, dropping locking blockades completely. + +--- + +## Exposed Configuration / Tuning Parameters + +As we integrate deeper with the core business logic, the Node orchestrator and internal services should configure and enforce limits on the underlying C++ concurrent engine. Relevant configuration surfaces we need to expose for the primary ecosystem libraries include: + +### 1. Taskflow (`https://github.com/taskflow/taskflow`) +- **`executor_threads` (`num_workers`)**: The size of the `tf::Executor` thread pool. As Gridsearch is heavily I/O network bound (HTTP calls for search/enrichment), setting this significantly higher than `std::thread::hardware_concurrency()` may aggressively improve HTTP ingestion throughput globally. +- **`max_concurrent_jobs_per_user`**: A structural limit dictating how many concurrent gridsearch invocation graphs a single tenant/user can enqueue and run actively to prevent monopolization. +- **`http_concurrency_throttle`**: Task limits enforced upon node scraping or SerpAPI requests per-pipeline graph to avoid widespread `429 Too Many Requests` bans. + +### 2. Moodycamel ConcurrentQueue (`https://github.com/cameron314/concurrentqueue`) +- **`queue_depth_max` / `backpressure`**: Since Moodycamel queue memory allocates dynamically and lock-free to any capacity, we must mandate a hard software ceiling/backpressure limit over the Node-to-C++ IPC layer. If Node blindly streams jobs faster than Taskflow can execute them, the daemon will eventually OOM. +- **`bulk_dequeue_size`**: Exposing tuning parameters for the dispatch thread on how many concurrent IPC tasks should be sucked out of the queue simultaneously. + +### 3. Boost.Asio (`https://github.com/chriskohlhoff/asio`) +- **`ipc_timeout_ms` (Read/Write)**: Mandatory timeouts for the IPC socket layer. If the orchestrator stalls, crashes, or goes silent, Asio must reap the connection and automatically GC the in-flight tasks to prevent Zombie worker processes. +- **`max_ipc_connections`**: Absolute limit on simultaneous orchestration pipelines dialing into a single Worker Pod. +- **`buffer_size_max`**: Soft constraints on async payload allocations so a malformed 200MB JSON frame from Node.js doesn't memory-spike the `asio::read` operations abruptly. + +--- + +## Build Integration + +### Dependency graph + +``` + ┌──────────┐ + │ polymech │ (the lib) + │ -cli │ (the binary) + └────┬─────┘ + ┌────────────┼────────────────┐ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ search │ │ grid │ │ ipc │ + └────┬─────┘ └────┬─────┘ └──────────┘ + │ │ + ▼ ▼ + ┌──────────┐ ┌───────────────┐ + │ http │ │ gadm_reader │ + └──────────┘ └────┬──────────┘ + ▼ + ┌──────────┐ + │ geo │ ← no deps (math only) + └──────────┘ + ┌──────────┐ + │ json │ ← RapidJSON + └──────────┘ +``` + +All packages depend on `logger` and `json` implicitly. + +--- + +## Testing + +### Unit tests (Catch2) + +Catch2 targets live in `tests/CMakeLists.txt` (e.g. `test_logger`, `test_html`, `test_postgres`, `test_json`, `test_http`, `test_polymech`, `test_cmd_kbot`, `test_ipc`, `test_functional`, e2e targets). The old geo / gadm_reader / grid / search / enrichers / `test_postgres_live` suites were removed with those package implementations. + +### Integration test (Node.js) + +- Existing `orchestrator/test-ipc.mjs` validates spawn/lifecycle/ping/job +- `orchestrator/test-gridsearch-ipc.mjs` validates full pipeline via IPC (8 event types + job result) +- `orchestrator/test-gridsearch-ipc-uds.mjs` validates high-throughput Unix Domain Sockets mapping, backpressure boundaries, and soft cancellation injections utilizing `action: cancel` frames mid-flight. + +--- + +## IPC Cancellation & Dynamic Job Tuning + +The high-performance UDS daemon now natively tracks and intercepts JSON `action: cancel` frames referencing specific `jobId`s to gracefully exit Taskflow jobs mid-flight. +Dynamic tuning limits, such as memory buffering boundaries or threading capacities, are inherently validated and bound by hard ceilings established inside the `[system]` constraint block of `config/postgres.toml`. + +--- + +## Deferred (Phase 2) + +| Item | Reason | +|------|--------| +| SerpAPI response caching | State store managed by orchestrator for now | +| Protobuf framing | JSON IPC sufficient for current throughput | +| Multi-threaded search | Sequential is fine for SerpAPI rate limits | +| GEOS integration | Custom geo is sufficient for grid math | diff --git a/packages/media/cpp/ref/images/__tests__/e2e.test.ts b/packages/media/cpp/ref/images/__tests__/e2e.test.ts new file mode 100644 index 00000000..8c17949c --- /dev/null +++ b/packages/media/cpp/ref/images/__tests__/e2e.test.ts @@ -0,0 +1,293 @@ +import { describe, it, expect } from 'vitest'; +import { app } from '@/index.js'; +import fs from 'fs/promises'; +import path from 'path'; + +describe('Images Product E2E', async () => { + const TEST_DIR = path.resolve(process.cwd(), 'tests/resize'); + + // Get all JPG files + // Note: This needs to be done before tests or inside a test that generates others, + // but in Vitest clean way is to maintain list or just map array. + // Since we need async fs, we can just do it in the suite body if top-level await is supported, + // or inside a single test that loops. + // Better pattern for dynamic tests: + + const files = await fs.readdir(TEST_DIR); + const jpgFiles = files.filter(f => f.toLowerCase().endsWith('.jpg')); + + if (jpgFiles.length === 0) { + it('should have test assets', () => { + console.warn('No JPG files found in tests/resize'); + }); + } + + jpgFiles.forEach(filename => { + it(`should upload and resize ${filename}`, async () => { + const filePath = path.join(TEST_DIR, filename); + const stats = await fs.stat(filePath); + expect(stats.isFile()).toBe(true); + + const fileContent = await fs.readFile(filePath); + const file = new File([fileContent], filename, { type: 'image/jpeg' }); + + const form = new FormData(); + form.append('file', file); + form.append('width', '200'); + form.append('height', '200'); + form.append('format', 'webp'); + + const serverUrl = process.env.SERVER_URL || 'http://localhost:3333'; + const postReq = new Request(`${serverUrl}/api/images`, { + method: 'POST', + body: form + }); + + // Execute Request + const res = await app.request(postReq); + + // Verify Redirect + expect(res.status).toBe(303); + const location = res.headers.get('location'); + expect(location).toBeTruthy(); + expect(location).toContain('/api/images/cache/'); + + // Follow Redirect + // console.log(`Following redirect to: ${location}`); + const getReq = new Request(`${serverUrl}${location}`); + const res2 = await app.request(getReq); + + console.log(res2.url); + // Verify Image Response + expect(res2.status).toBe(200); + expect(res2.headers.get('content-type')).toBe('image/webp'); + + const blob = await res2.blob(); + expect(blob.size).toBeGreaterThan(0); + }); + }); + + it('should use preset if provided', async () => { + const filename = jpgFiles[0]; // Use first available test file + const filePath = path.join(TEST_DIR, filename); + + const form = new FormData(); + const fileContent = await fs.readFile(filePath); + form.append('file', new File([fileContent], filename, { type: 'image/jpeg' })); + // form.append('preset', 'desktop:thumb'); // Now URL param + + const serverUrl = process.env.SERVER_URL || 'http://localhost:3333'; + const postReq = new Request(`${serverUrl}/api/images?preset=desktop:thumb`, { + method: 'POST', + body: form + }); + + const res = await app.request(postReq); + expect(res.status).toBe(303); + const location = res.headers.get('location'); + expect(location).toContain('.avif'); // Preset uses avif + + const getReq = new Request(`${serverUrl}${location}`); + const res2 = await app.request(getReq); + expect(res2.status).toBe(200); + expect(res2.headers.get('content-type')).toBe('image/avif'); // Verify format override + }); + + it('should allow overriding preset values', async () => { + const filename = jpgFiles[0]; + const filePath = path.join(TEST_DIR, filename); + + const form = new FormData(); + const fileContent = await fs.readFile(filePath); + form.append('file', new File([fileContent], filename, { type: 'image/jpeg' })); + form.append('format', 'webp'); // Override 'avif' from desktop:thumb + form.append('width', '50'); // Override 150 from desktop:thumb + + const serverUrl = process.env.SERVER_URL || 'http://localhost:3333'; + // preset=desktop:thumb normally implies 150x150 avif + const postReq = new Request(`${serverUrl}/api/images?preset=desktop:thumb`, { + method: 'POST', + body: form + }); + + const res = await app.request(postReq); + expect(res.status).toBe(303); + const location = res.headers.get('location'); + expect(location).toContain('.webp'); // Should be webp + + const getReq = new Request(`${serverUrl}${location}`); + const res2 = await app.request(getReq); + expect(res2.status).toBe(200); + expect(res2.headers.get('content-type')).toBe('image/webp'); + + + // We could also check dimensions if we parsed the image, but the cache hash check implicitly checks params + // hash includes `w50` + }); + + it('should forward to supabase and return valid json', async () => { + + const filename = jpgFiles[0]; + const filePath = path.join(TEST_DIR, filename); + + const form = new FormData(); + const fileContent = await fs.readFile(filePath); + form.append('file', new File([fileContent], filename, { type: 'image/jpeg' })); + + const serverUrl = process.env.SERVER_URL || 'http://localhost:3333'; + const postReq = new Request(`${serverUrl}/api/images?preset=desktop:thumb&forward=supabase`, { + method: 'POST', + body: form + }); + + const res = await app.request(postReq); + + // Ensure we actually got a success response, otherwise fail + if (res.status !== 200) { + const text = await res.text(); + console.error('Supabase response:', res.status, text); + } + expect(res.status).toBe(200); + + if (res.status === 200) { + const data: any = await res.json(); + + expect(data.url).toBeTruthy(); + expect(data.width).toBe(150); // desktop:thumb + expect(data.filename).toBeTruthy(); + + console.log('Supabase Upload URL:', data.url); + + // Cleanup: Delete from Supabase + // Import dynamically to avoid top-level dependency if not needed + const { supabase } = await import('../../../commons/supabase.js'); + const bucket = process.env.SUPABASE_BUCKET || 'pictures'; + + // data.filename should be 'cache/...' or just filename depending on implementation + // In index.ts we returned `storagePath` as `filename` field? Let's verify index.ts + // Yes: `filename: storagePath` which is `cache/${filename}` + + const { error } = await supabase.storage + .from(bucket) + .remove([data.filename]); + + if (error) { + console.error('Failed to cleanup Supabase test file:', error); + } else { + console.log('Cleaned up Supabase test file:', data.filename); + } + } else { + const text = await res.text(); + console.warn('Supabase test skipped or failed (check env vars):', res.status, text); + // If internal server error due to missing creds, we might want to skip or fail gently + // But user asked for this test, so failing is appropriate if creds are missing but expected. + } + }); + it('should transform image with operations', async () => { + const filename = jpgFiles[0]; + const filePath = path.join(TEST_DIR, filename); + + const form = new FormData(); + const fileContent = await fs.readFile(filePath); + const file = new File([fileContent], filename, { type: 'image/jpeg' }); + + const operations = [ + { type: 'rotate', angle: 90 }, + { type: 'resize', width: 100, height: 100, fit: 'cover' } + ]; + + form.append('file', file); + form.append('operations', JSON.stringify(operations)); + + const serverUrl = process.env.SERVER_URL || 'http://localhost:3333'; + const postReq = new Request(`${serverUrl}/api/images/transform`, { + method: 'POST', + body: form + }); + + const res = await app.request(postReq); + expect(res.status).toBe(200); + + const contentType = res.headers.get('content-type'); + expect(contentType).toMatch(/image\/.*/); + + const blob = await res.blob(); + expect(blob.size).toBeGreaterThan(0); + + // Basic check that it returned *something* successfully + }); + + it('should adjust image brightness and contrast', async () => { + const filename = jpgFiles[0]; + const filePath = path.join(TEST_DIR, filename); + + const form = new FormData(); + const fileContent = await fs.readFile(filePath); + const file = new File([fileContent], filename, { type: 'image/jpeg' }); + + const operations = [ + { type: 'adjust', brightness: 1.2, contrast: 1.1 } + ]; + + form.append('file', file); + form.append('operations', JSON.stringify(operations)); + + const serverUrl = process.env.SERVER_URL || 'http://localhost:3333'; + const postReq = new Request(`${serverUrl}/api/images/transform`, { + method: 'POST', + body: form + }); + + const res = await app.request(postReq); + expect(res.status).toBe(200); + + const contentType = res.headers.get('content-type'); + expect(contentType).toMatch(/image\/.*/); + + const blob = await res.blob(); + expect(blob.size).toBeGreaterThan(0); + }); + + it('should extract exif data during supabase forward', async () => { + const filenames = jpgFiles.filter(f => f.includes('exif')); + if (filenames.length === 0) { + console.warn('Skipping EXIF test, test asset exif.jpg not found'); + return; + } + const filename = filenames[0]; + const filePath = path.join(TEST_DIR, filename); + + const form = new FormData(); + const fileContent = await fs.readFile(filePath); + form.append('file', new File([fileContent], filename, { type: 'image/jpeg' })); + + const serverUrl = process.env.SERVER_URL || 'http://localhost:3333'; + const postReq = new Request(`${serverUrl}/api/images?forward=supabase`, { + method: 'POST', + body: form + }); + + const res = await app.request(postReq); + expect(res.status).toBe(200); + + const data: any = await res.json(); + expect(data.url).toBeTruthy(); + expect(data.meta).toBeTruthy(); + + console.log('Returned Meta:', data.meta); + + // Verify some expected EXIF data if possible + // The file IMG20250911123721.jpg implies a date 2025-09-11 + if (data.meta.dateTaken) { + const date = new Date(data.meta.dateTaken); + expect(date.getFullYear()).toBe(2025); + } + + // Cleanup + if (data.filename) { + const { supabase } = await import('../../../commons/supabase.js'); + const bucket = process.env.SUPABASE_BUCKET || 'pictures'; + await supabase.storage.from(bucket).remove([data.filename]); + } + }); +}); diff --git a/packages/media/cpp/ref/images/db-images-pg.ts b/packages/media/cpp/ref/images/db-images-pg.ts new file mode 100644 index 00000000..d1f191d2 --- /dev/null +++ b/packages/media/cpp/ref/images/db-images-pg.ts @@ -0,0 +1,22 @@ +/** + * Postgres helpers for the **images** product (`products/images/index.ts`). + * + * **Where the rest lives:** picture CRUD, listing, versions, comments, likes, cache, and the + * `/api/pictures/*` route handlers are implemented in + * `../serving/db/db-pictures-pg.ts` — do not duplicate that layer here. + * + * This file only covers SQL still used from `images/index.ts` that was not covered there: + * legacy thumbnail URL resolution (`/api/images/cache/<uuid>_thumb.jpg` → current `image_url`). + */ +import { getServicePool } from '../../commons/postgres.js'; + +/** `SELECT image_url FROM pictures WHERE id = $1` — for legacy cache URL rewriting. */ +export async function fetchPictureImageUrlByIdPg(pictureId: string): Promise<string | null> { + const pool = getServicePool(); + const res = await pool.query( + `SELECT image_url FROM public.pictures WHERE id = $1::uuid LIMIT 1`, + [pictureId], + ); + const url = res.rows[0]?.image_url; + return typeof url === 'string' && url.length > 0 ? url : null; +} diff --git a/packages/media/cpp/ref/images/index.ts b/packages/media/cpp/ref/images/index.ts new file mode 100644 index 00000000..697b228c --- /dev/null +++ b/packages/media/cpp/ref/images/index.ts @@ -0,0 +1,1194 @@ +import { Context } from 'hono'; +import { AbstractProduct } from '../AbstractProduct.js'; +import { postImageRoute, getImageRoute, postResponsiveImageRoute, getResponsiveImageRoute, getImageLogsRoute, streamImageLogsRoute, renderImageRoute, postTransformRoute } from './routes.js'; +import { createLogHandlers } from '../../commons/log-routes-factory.js'; +import { CachedHandler } from '../../commons/decorators.js'; +import sharp from 'sharp'; +import fs from 'fs/promises'; +import path from 'path'; +import { createHash } from 'crypto'; +import { logger } from './logger.js'; +import { getPresets } from './presets.js'; +import { hasWorker, dispatchToWorker } from '../../commons/worker-ipc.js'; +import { requireAuth } from '../storage/api/vfs-auth.js'; +import { createVFS, resolveMountByName, SYSTEM_MOUNT_OWNER_ID, type IExtendedMount } from '../storage/api/vfs-core.js'; +import { ensureVfsSettings, saveVfsSettings } from '../storage/api/acl-helpers.js'; +import { readVfsFileBuffer } from '../storage/api/vfs-read.js'; + +import 'dotenv/config'; + +const CACHE_DIR = path.join(process.cwd(), 'cache'); +const CACHE_TTL = 31536000; // 1 year + +// Map to track ongoing fetch requests by URL to prevent thundering herd +const ongoingFetches = new Map<string, Promise<Buffer>>(); +// Map to track ongoing render operations by hash key — prevents concurrent +// processing (and concurrent writes to the same cache file) for identical variants +const ongoingRenders = new Map<string, Promise<Buffer>>(); + +// Server-side concurrency limiter for Sharp / libvips work. +// Sharp uses the libuv thread pool (UV_THREADPOOL_SIZE, default 4) — many overlapping +// encodes still compete for CPU and can starve other HTTP handlers on the same process. +// Tune IMAGE_ENCODE_MAX_CONCURRENT (default 2) so the event loop still gets turns; raise +// UV_THREADPOOL_SIZE in the process env only if you raise this and still see backlog. +// IMAGE_ENCODE_YIELD_MS — pause before Sharp (default 50ms, minimum 50) so other requests run. +const MAX_CONCURRENT_ENCODES = Math.max( + 1, + Math.min(32, parseInt(process.env.IMAGE_ENCODE_MAX_CONCURRENT || '2', 10) || 2), +); +const ENCODE_YIELD_MS = Math.max( + 50, + parseInt(process.env.IMAGE_ENCODE_YIELD_MS || '50', 10) || 50, +); +let _encodeActive = 0; +const _encodeQueue: (() => void)[] = []; + +function _encodeQueueStats() { + return { + encodeActive: _encodeActive, + encodeWaiting: _encodeQueue.length, + encodeMax: MAX_CONCURRENT_ENCODES, + }; +} + +/** Delay before Sharp so the event loop can flush I/O and other handlers (min 50ms). */ +function yieldBeforeEncode(): Promise<void> { + return new Promise((resolve) => setTimeout(resolve, ENCODE_YIELD_MS)); +} + +function _acquireEncode(reqId?: string): Promise<{ waitMs: number }> { + const t0 = performance.now(); + if (_encodeActive < MAX_CONCURRENT_ENCODES) { + _encodeActive++; + return Promise.resolve({ waitMs: 0 }); + } + const stats = _encodeQueueStats(); + logger.info( + { reqId, ...stats, note: 'encode queue — other /api work can still be accepted; this request waits' }, + 'encode: waiting for slot', + ); + return new Promise<{ waitMs: number }>((resolve) => { + _encodeQueue.push(() => { + const waitMs = performance.now() - t0; + if (waitMs > 50 || _encodeQueue.length > 0) { + logger.info({ reqId, waitMs: Math.round(waitMs), ..._encodeQueueStats() }, 'encode: slot granted'); + } + resolve({ waitMs }); + }); + }); +} + +function _releaseEncode(): void { + if (_encodeQueue.length > 0) { + _encodeQueue.shift()!(); + } else { + _encodeActive--; + } +} + +/** + * Try to read a VFS file directly (no HTTP) when the URL points to this server. + * Returns the Buffer or null if the URL isn't a local VFS path. + */ +async function tryLocalVfsRead(url: string): Promise<Buffer | null> { + const baseUrl = (process.env.SERVER_IMAGE_API_URL || process.env.SERVER_URL_R || '').replace(/\/$/, ''); + if (!baseUrl || !url.startsWith(baseUrl)) return null; + + const remainder = url.slice(baseUrl.length); + const vfsMatch = remainder.match(/^\/api\/vfs\/get\/([^/]+)\/(.+)$/); + if (!vfsMatch) return null; + + const mountName = decodeURIComponent(vfsMatch[1]); + const subpath = decodeURIComponent(vfsMatch[2]); + + try { + const buf = await readVfsFileBuffer(mountName, subpath); + logger.info({ mountName, subpath, sizeKB: Math.round(buf.length / 1024) }, 'fetch: local VFS (no HTTP)'); + return buf; + } catch (err: any) { + if (err.code === 'ENOENT') { + logger.warn({ mountName, subpath }, 'Local VFS read: file not found'); + } else { + logger.error({ err, mountName, subpath }, 'Local VFS read failed, falling back to HTTP'); + } + return null; + } +} + +/** + * Sanitize and validate an image URL. + * Strips markdown artifacts, null bytes, and validates protocol/credentials. + * Returns the cleaned URL or an error string starting with "ERR:". + */ +export function sanitizeImageUrl(raw: string): string { + let url = raw; + // Strip null bytes + url = url.replace(/\0/g, ''); + // Strip markdown image syntax: ![alt](url) → url + url = url.replace(/^!\[.*?\]\((.+?)\)$/, '$1'); + // Strip markdown link syntax: [text](url) → url + url = url.replace(/^\[.*?\]\((.+?)\)$/, '$1'); + // Strip angle brackets / parens / whitespace + url = url.trim().replace(/^[<(]+|[>)]+$/g, ''); + // Length cap + if (url.length > 2048) return 'ERR:URL too long'; + + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return `ERR:Invalid URL: ${url}`; + } + // Protocol whitelist + if (!['http:', 'https:'].includes(parsed.protocol)) { + return `ERR:Invalid URL protocol: ${parsed.protocol}`; + } + // Block embedded credentials + if (parsed.username || parsed.password) { + return 'ERR:URLs with credentials are not allowed'; + } + // Block cloud metadata endpoints (SSRF) + const hostname = parsed.hostname.toLowerCase(); + if ( + hostname === '169.254.169.254' || + hostname === 'metadata.google.internal' + ) { + return 'ERR:URL points to a blocked address'; + } + return url; +} + +/** + * Ensures an image variant is cached. + * This function will: + * 1. Generate the hash/filename for the requested variant + * 2. Check if it exists in cache + * 3. If not, dispatch to a worker to process it (or process inline if no workers) + * 4. Return the filename + */ +export async function _ensureCachedImage(inputBuffer: Buffer, width?: number, height?: number, format = 'jpeg', fit = 'inside'): Promise<{ filename: string, hit: boolean }> { + const hash = createHash('sha256') + .update(inputBuffer) + .update(`w${width}h${height}f${format}`) + .digest('hex'); + + const filename = `${hash}.${format}`; + const filepath = path.join(CACHE_DIR, filename); + + // 1. Check if it already exists + try { + await fs.access(filepath); + return { filename, hit: true }; // Cache hit + } catch { + // Cache miss + } + + // 2. Process + if (await hasWorker('images')) { + // Create a copy of the buffer into a regular ArrayBuffer because SharedArrayBuffer isn't officially supposed to be sent this way + const arrayBuffer = new ArrayBuffer(inputBuffer.length); + const view = new Uint8Array(arrayBuffer); + view.set(inputBuffer); + + await dispatchToWorker('images', 'process_image', { + buffer: arrayBuffer, width, height, format, fit + }, [arrayBuffer]); // transfer memory + } else { + // Inline fallback + const pipeline = sharp(inputBuffer); + if (width || height) { + pipeline.resize({ + width: width, + height: height, + withoutEnlargement: true, + fit: fit as keyof sharp.FitEnum + }); + } + // Speed-tuned format options — avif defaults to effort:4 which is extremely slow + const formatOpts: Record<string, any> = { + avif: { effort: 2 }, + webp: { effort: 4 }, + }; + pipeline.toFormat(format as keyof sharp.FormatEnum, formatOpts[format] || {}); + const processedBuffer = await pipeline.toBuffer(); + await fs.writeFile(filepath, processedBuffer); + } + + return { filename, hit: false }; +} + +/** + * Coalesced fetch helper to prevent multiple concurrent requests for the same URL. + */ +async function fetchImageCoalesced(url: string): Promise<Buffer> { + // Fast path: read local VFS files directly (no HTTP round-trip) + const localBuf = await tryLocalVfsRead(url); + if (localBuf) return localBuf; + + if (ongoingFetches.has(url)) { + logger.debug({ url }, 'fetch: coalescing (joining existing request)'); + return ongoingFetches.get(url)!; + } + + const fetchPromise = (async () => { + const controller = new AbortController(); + const timeoutMs = process.env.IMAGE_FETCH_TIMEOUT_MS ? parseInt(process.env.IMAGE_FETCH_TIMEOUT_MS) : 10000; + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const fetchRes = await fetch(url, { signal: controller.signal }); + clearTimeout(timeout); + + if (!fetchRes.ok) { + logger.error({ + msg: 'Failed to fetch URL', + url, + status: fetchRes.status, + statusText: fetchRes.statusText, + headers: Object.fromEntries(fetchRes.headers.entries()) + }); + throw new Error(`Failed to fetch URL: ${fetchRes.statusText} : ${url}`); + } + const arrayBuffer = await fetchRes.arrayBuffer(); + return Buffer.from(arrayBuffer); + } catch (err) { + clearTimeout(timeout); + throw err; + } + })(); + + ongoingFetches.set(url, fetchPromise); + // Clean up AFTER the full body is consumed (not just after headers) + const cleanup = () => { ongoingFetches.delete(url); }; + void fetchPromise.then(cleanup, cleanup); + + return fetchPromise; +} + +/** + * Backward-compat for legacy picture thumbnail URLs: + * /api/images/cache/<pictureId>_thumb.jpg + * If such cache entries are missing, resolve the current picture.image_url from DB. + */ +async function resolveLegacyPictureThumbUrl(rawUrl: string): Promise<string> { + try { + const parsed = new URL(rawUrl); + const match = parsed.pathname.match(/\/api\/images\/cache\/([0-9a-fA-F-]{36})_thumb\.jpg$/); + if (!match) return rawUrl; + const pictureId = match[1]; + const { fetchPictureImageUrlByIdPg } = await import('./db-images-pg.js'); + const nextUrl = await fetchPictureImageUrlByIdPg(pictureId); + if (nextUrl && typeof nextUrl === 'string') { + logger.debug({ rawUrl, nextUrl, pictureId }, 'Resolved legacy thumb URL to picture.image_url'); + return nextUrl; + } + return rawUrl; + } catch { + return rawUrl; + } +} + +export async function ensureCachedImage(inputBuffer: Buffer, width: number, height: number | undefined, format: string): Promise<string> { + const { filename } = await _ensureCachedImage(inputBuffer, width, height, format); + return filename; +} + +export async function _ensureCachedImageFromUrl(url: string, width: number | undefined, height: number | undefined, format: string): Promise<{ filename: string; hit: boolean }> { + let inputBuffer: Buffer; + try { + const urlHash = createHash('sha256').update(url).digest('hex'); + const sourceFilename = `source_${urlHash}`; + const sourcePath = path.join(CACHE_DIR, sourceFilename); + + try { + inputBuffer = await fs.readFile(sourcePath); + } catch { + inputBuffer = await fetchImageCoalesced(url); + await fs.writeFile(sourcePath, inputBuffer).catch(e => { + logger.error({ err: e }, 'Failed to write source cache'); + }); + } + } catch (err: any) { + throw new Error(`Failed to ensure cached image from url ${url} : ${err.message}`); + } + + return await _ensureCachedImage(inputBuffer, width, height, format); +} + +export async function ensureCachedImageFromUrl(url: string, width: number | undefined, height: number | undefined, format: string): Promise<string> { + const { filename } = await _ensureCachedImageFromUrl(url, width, height, format); + return filename; +} + +export class ImagesProduct extends AbstractProduct<any> { + id = 'images'; + jobOptions = {}; + actions = {}; + workers = []; + routes!: any[]; + + constructor() { + super(); + + const { getHandler, streamHandler } = createLogHandlers(path.join(process.cwd(), 'logs', 'images.json')); + + this.routes = [ + { definition: postImageRoute, handler: this.handlePostImage.bind(this) }, + { definition: getImageRoute, handler: this.handleGetImage.bind(this) }, + { definition: postResponsiveImageRoute, handler: this.handlePostResponsive.bind(this) }, + { definition: getResponsiveImageRoute, handler: CachedHandler(this.handleGetResponsive.bind(this), { ttl: 300, skipAuth: true }) }, + { definition: getImageLogsRoute, handler: getHandler }, + { definition: streamImageLogsRoute, handler: streamHandler }, + { definition: renderImageRoute, handler: this.handleRenderImage.bind(this) }, + { definition: postTransformRoute, handler: this.handleTransformImage.bind(this) } + ]; + + + // Public endpoint registration is now handled by the Public() decorator in createRouteBody + } + + async onStart() { + // Ensure cache directory exists + try { + await fs.mkdir(CACHE_DIR, { recursive: true }); + } catch (err) { + logger.error({ err }, 'Failed to create cache directory'); + } + } + + hash(data: any): string { + return 'images-hash'; + } + + meta(userId: string): any { + return { userId }; + } + + async handleJob(action: string, msg: any): Promise<any> { + if (action === 'process_image') { + const { buffer, width, height, format, fit } = msg; + const inputBuffer = Buffer.from(buffer); + + const hash = createHash('sha256') + .update(inputBuffer) + .update(`w${width}h${height}f${format}`) + .digest('hex'); + + const filename = `${hash}.${format}`; + const filepath = path.join(CACHE_DIR, filename); + + await this.performProcessImage(inputBuffer, filepath, { width, height, format, fit }); + return { filename }; + } + + if (action === 'render_image') { + const { buffer, url, width, height, format, square, contain } = msg; + const inputBuffer = Buffer.from(buffer); + + const hashKey = createHash('sha256') + .update(url) + .update(`w${width}h${height}f${format}${square ? 'sq' : ''}${contain ? 'ct' : ''}`) + .digest('hex'); + + const filename = `${hashKey}.${format}`; + const filepath = path.join(CACHE_DIR, filename); + + await this.performRenderImage(inputBuffer, filepath, { width, height, format, square, contain }); + return { filename }; + } + + return super.handleJob(action, msg); + } + + private async performProcessImage(inputBuffer: Buffer, filepath: string, options: { width?: number, height?: number, format: string, fit: keyof sharp.FitEnum }) { + const pipeline = sharp(inputBuffer); + if (options.width || options.height) { + pipeline.resize({ + width: options.width, + height: options.height, + withoutEnlargement: true, + fit: options.fit + }); + } + // Speed-tuned format options — avif defaults to effort:4 which is extremely slow + const formatOpts: Record<string, any> = { + avif: { effort: 2 }, + webp: { effort: 4 }, + }; + pipeline.toFormat(options.format as keyof sharp.FormatEnum, formatOpts[options.format] || {}); + const processedBuffer = await pipeline.toBuffer(); + await fs.writeFile(filepath, processedBuffer); + } + + private async performRenderImage(inputBuffer: Buffer, filepath: string, options: { width?: number, height?: number, format: string, square: boolean, contain: boolean }): Promise<Buffer> { + let pipeline = sharp(inputBuffer); + const bgColor = { r: 255, g: 255, b: 255, alpha: 1 }; + + if (options.square && options.width) { + pipeline = pipeline.resize({ + width: options.width, + height: options.width, + fit: 'cover', + withoutEnlargement: true, + }); + } else if (options.contain && (options.width || options.height)) { + pipeline = pipeline.resize({ + width: options.width, + height: options.height, + fit: 'contain', + background: bgColor, + withoutEnlargement: true, + }); + } else if (options.width || options.height) { + pipeline = pipeline.resize({ + width: options.width, + height: options.height, + withoutEnlargement: true, + fit: 'inside' + }); + } + + // Speed-tuned format options — avif defaults to effort:4 which is extremely slow + const formatOpts: Record<string, any> = { + avif: { effort: 2 }, + webp: { effort: 4 }, + }; + pipeline = pipeline.toFormat(options.format as keyof sharp.FormatEnum, formatOpts[options.format] || {}); + const processedBuffer = await pipeline.toBuffer(); + // Atomic write: temp file then rename to prevent corrupt reads from concurrent requests + const tmpPath = `${filepath}.${process.pid}.${Date.now()}.tmp`; + await fs.writeFile(tmpPath, processedBuffer); + await fs.rename(tmpPath, filepath); + return processedBuffer; + } + + async handlePostImage(c: Context) { + const start = performance.now(); + try { + const body = await c.req.parseBody(); + const file = body['file']; + const presets = getPresets(); + const presetName = c.req.query('preset'); + const forward = c.req.query('forward') || process.env.IMAGE_UPLOAD_TARGET || ''; + const useCache = c.req.query('cache') !== 'false'; + const isOriginal = c.req.query('original') === 'true'; + + let preset: any = {}; + if (presetName && presets[presetName]) { + preset = presets[presetName]; + } + + if (!(file instanceof File)) { + return c.text('No file uploaded', 400); + } + + const buffer = await file.arrayBuffer(); + const inputBuffer = Buffer.from(buffer); + + // Detect metadata for defaults + const meta = await sharp(inputBuffer).metadata(); + // Extract EXIF from original input buffer before Sharp processing strips metadata. + const { extractImageMetadata } = await import('./metadata.js'); + const inputExifMeta = await extractImageMetadata(inputBuffer); + + // Precedence: Explicit > Preset > Default (Original Format / 2048px) + const width = body['width'] ? parseInt(body['width'] as string) : (preset['width'] || (isOriginal ? undefined : 2048)); + const height = body['height'] ? parseInt(body['height'] as string) : (preset['height'] || undefined); + const format = (body['format'] as string) || (preset['format'] || meta.format || 'jpeg'); + const fit = (preset['fit'] || 'inside') as keyof sharp.FitEnum; + + // Generate hash for filename based on content + params + const hash = createHash('sha256') + .update(inputBuffer) + .update(`w${width}h${height}f${format}`) + .digest('hex'); + + const filename = `${hash}.${format}`; + const filepath = path.join(CACHE_DIR, filename); + + let processedBuffer: Buffer | null = null; + + // 1. Try Cache + let cacheHit = false; + if (useCache) { + try { + processedBuffer = await fs.readFile(filepath); + cacheHit = true; + logger.debug({ filename }, 'Image cache hit - read from disk'); + } catch { + // Not found in cache + } + } + + let workerOffload = false; + if (!processedBuffer) { + // 2. Process if no cache + logger.debug({ filename }, 'Image cache miss - processing'); + + if (await hasWorker('images')) { + workerOffload = true; + logger.debug({ filename }, 'Offloading image processing to worker thread'); + const res = await dispatchToWorker('images', 'process_image', { + buffer, width, height, format, fit + }, [buffer]); // Zero-copy transfer of the ArrayBuffer + + // The worker wrote to cache, load it for Supabase forwarding later + processedBuffer = await fs.readFile(filepath); + } else { + // Inline fallback if worker failed or workers=0 + await this.performProcessImage(inputBuffer, filepath, { width, height, format, fit }); + processedBuffer = await fs.readFile(filepath); + } + } + + if (!processedBuffer) { + throw new Error('Image processing failed to produce buffer'); + } + + // --- 1. VFS FORWARDING --- + if (forward === 'vfs') { + try { + const user = await requireAuth(c); + if (!user?.id) { + return c.json({ error: 'Unauthorized' }, 401); + } + + const mountName = process.env.IMAGE_VFS_STORE || 'images'; + const resolved = resolveMountByName(mountName, user.id); + if (!resolved) { + return c.json({ error: `VFS mount '${mountName}' not found` }, 404); + } + + const subdir = user.id; + const storageFilename = filename; + + // Ensure least-privilege per-user grant exists on shared mount before writing as the user. + const mountPath = path.resolve((resolved.mount as IExtendedMount).path); + const settingsOwner = resolved.ownerId || SYSTEM_MOUNT_OWNER_ID; + const settings = await ensureVfsSettings(mountPath, settingsOwner); + const grantPath = `/${subdir}`; + const needsGrant = !settings.acl.some((entry: any) => { + const entryPath = ((entry?.path || '/').startsWith('/') ? entry.path : `/${entry.path}`); + return entry.userId === user.id && entryPath === grantPath; + }); + if (needsGrant) { + settings.acl.push({ + path: grantPath, + permissions: ['read', 'mkdir', 'write'], + userId: user.id, + } as any); + await saveVfsSettings(mountPath, settings); + } + + const ownerId = resolved.ownerId || SYSTEM_MOUNT_OWNER_ID; + const vfs = await createVFS(resolved.mount as IExtendedMount, ownerId, user.id); + await vfs.mkdir(subdir, { recursive: true }); + await vfs.writefile(`${subdir}/${storageFilename}`, processedBuffer); + + const serverBase = (process.env.IMAGE_VFS_URL || process.env.SERVER_IMAGE_API_URL_R || process.env.SERVER_URL_R || 'https://service.polymech.info').replace(/\/$/, ''); + const publicUrl = `${serverBase}/api/vfs/get/${encodeURIComponent(mountName)}/${encodeURIComponent(subdir)}/${encodeURIComponent(storageFilename)}`; + + const sharpMeta = await sharp(processedBuffer).metadata(); + + logger.info({ + mountName, + subdir, + storageFilename, + bufferSize: processedBuffer.length, + format, + }, 'Uploaded image to VFS storage'); + + return c.json({ + url: publicUrl, + width: sharpMeta.width, + height: sharpMeta.height, + format: sharpMeta.format, + size: sharpMeta.size, + filename: `${subdir}/${storageFilename}`, + meta: inputExifMeta, + }); + } catch (err: any) { + logger.error({ err }, 'VFS forwarding error'); + return c.json({ error: err?.message || 'Failed to upload to VFS storage' }, 502); + } + } + + // --- 2. SUPABASE FORWARDING --- + if (forward === 'supabase') { + try { + // Check env vars before import + if (!process.env.SUPABASE_URL || !process.env.SUPABASE_SERVICE_KEY) { + throw new Error('Missing Supabase credentials in server environment'); + } + + const bucket = process.env.SUPABASE_BUCKET || 'pictures'; + const storagePath = `cache/${filename}`; + const supabaseUrl = process.env.SUPABASE_URL; + const supabaseKey = process.env.SUPABASE_SERVICE_KEY; + + logger.info({ + bucket, + storagePath, + bufferSize: processedBuffer.length, + format, + }, 'Uploading to Supabase storage (direct fetch)'); + + // Direct fetch to Supabase Storage REST API + // Bypasses @supabase/storage-js SDK which has opaque error handling with Node.js Buffers + const uploadUrl = `${supabaseUrl}/storage/v1/object/${bucket}/${storagePath}`; + const uploadRes = await fetch(uploadUrl, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${supabaseKey}`, + 'apikey': supabaseKey, + 'Content-Type': `image/${format}`, + 'x-upsert': 'true', + 'cache-control': 'max-age=3600', + }, + body: new Uint8Array(processedBuffer), + }); + + if (!uploadRes.ok) { + const errorBody = await uploadRes.text(); + logger.error({ + status: uploadRes.status, + statusText: uploadRes.statusText, + body: errorBody.substring(0, 500), + bucket, + storagePath, + }, 'Supabase storage upload failed'); + return c.json({ + error: 'Failed to upload to external storage', + details: errorBody.substring(0, 500), + status: uploadRes.status, + }, 502); + } + + // Construct public URL (same logic as SDK's getPublicUrl) + const publicUrl = `${supabaseUrl}/storage/v1/object/public/${bucket}/${storagePath}`; + + const sharpMeta = await sharp(processedBuffer).metadata(); + + return c.json({ + url: publicUrl, + width: sharpMeta.width, + height: sharpMeta.height, + format: sharpMeta.format, + size: sharpMeta.size, + filename: storagePath, + meta: inputExifMeta + }); + + } catch (err: any) { + logger.error({ err }, 'Supabase forwarding error'); + const status = err.message.includes('Missing Supabase') ? 500 : 502; + return c.json({ error: err.message }, status as any); + } + } + + // --- 3. LOCAL CACHING (Handled above) --- + // processedBuffer is already written to cache if it was processed, or read from cache if hit. + + const elapsed = performance.now() - start; + const ss = Math.floor(elapsed / 1000); + const ms = Math.floor(elapsed % 1000); + const duration = `${ss}:${ms.toString().padStart(3, '0')}`; + + const url = `/api/images/cache/${filename}`; + logger.info({ url, duration, cacheHit, workerOffload, format, width, height }, 'handlePostImage complete'); + return c.redirect(url, 303); + + } catch (err: any) { + logger.error({ err }, 'Image processing failed'); + return c.text(err.message, 500); + } + } + + async handleGetImage(c: Context) { + const filename = c.req.param('filename'); + if (!filename) return c.text('Filename required', 400); + + // Sanitize filename to prevent directory traversal + const safeFilename = path.basename(filename); + const filepath = path.join(CACHE_DIR, safeFilename); + + try { + const content = await fs.readFile(filepath); + // Infer mimetype from extension logic or just 'application/octet-stream' / specific type + // Basic inference: + const ext = path.extname(safeFilename).slice(1); + let mime = 'application/octet-stream'; + if (['jpg', 'jpeg'].includes(ext)) mime = 'image/jpeg'; + else if (ext === 'png') mime = 'image/png'; + else if (ext === 'webp') mime = 'image/webp'; + else if (ext === 'gif') mime = 'image/gif'; + else if (ext === 'avif') mime = 'image/avif'; + + c.header('Content-Type', mime); + c.header('Cache-Control', `public, max-age=${CACHE_TTL}, immutable`); + return c.body(content); + } catch (err) { + return c.text('Not found', 404); + } + } + + async handlePostResponsive(c: Context) { + const start = performance.now(); + const variantStats: any[] = []; + + try { + const body = await c.req.parseBody(); + const file = body['file']; + const url = body['url'] as string; + const sizesJson = body['sizes'] as string; + const formatsJson = body['formats'] as string; + + let inputBuffer: Buffer; + + if (file instanceof File) { + const buffer = await file.arrayBuffer(); + inputBuffer = Buffer.from(buffer); + } else if (url) { + const resolvedUrl = await resolveLegacyPictureThumbUrl(url); + const urlHash = createHash('sha256').update(resolvedUrl).digest('hex'); + const sourceFilename = `source_${urlHash}`; + const sourcePath = path.join(CACHE_DIR, sourceFilename); + + try { + inputBuffer = await fs.readFile(sourcePath); + } catch { + inputBuffer = await fetchImageCoalesced(resolvedUrl); + // Cache the source image + await fs.writeFile(sourcePath, inputBuffer).catch(e => { + logger.error({ err: e }, 'Failed to write source cache'); + }); + } + } else { + return c.text('No file or URL provided', 400); + } + + // Defaults + const sizes: number[] = sizesJson ? JSON.parse(sizesJson) : [180, 640, 1024, 2048]; + const formats: string[] = formatsJson ? JSON.parse(formatsJson) : ['avif', 'webp']; + + + const meta = await sharp(inputBuffer).metadata(); + const originalFormat = meta.format || 'jpeg'; + + // Allow original format in output if requested or implicit + const targetFormats = formats.map(f => f === 'original' || f === 'jpg' ? (originalFormat === 'jpeg' ? 'jpeg' : originalFormat) : f); + + // Deduplicate + const uniqueFormats = [...new Set(targetFormats)]; + const uniqueSizes = [...new Set(sizes)].sort((a, b) => a - b); + + const sources: { srcset: string; type: string }[] = []; + let fallbackSrc = ''; + let fallbackWidth = 0; + let fallbackHeight = 0; + let fallbackFormat = ''; + + // Generate all variants + for (const format of uniqueFormats) { + const srcSetParts: string[] = []; + + for (const width of uniqueSizes) { + const variantStart = performance.now(); + let filename; + const baseUrl = process.env.SERVER_IMAGE_API_URL || 'https://service.polymech.info'; + const LAZY_THRESHOLD = 600; + const isLazy = url && width > LAZY_THRESHOLD; + + if (isLazy) { + // LAZY GENERATION: Return Dynamic Render URL + // Do NOT process large images eagerly if we have a URL source + const renderUrl = `${baseUrl}/api/images/render?url=${encodeURIComponent(url)}&width=${width}&format=${format}`; + srcSetParts.push(`${renderUrl} ${width}w`); + + variantStats.push({ width, format, lazy: true, duration: performance.now() - variantStart }); + + // For fallback calculation, we assume the requested width is what we get + // We skip reading meta/file access + if (!fallbackSrc || (format === 'jpeg' && fallbackFormat !== 'jpeg') || (width > fallbackWidth && format === fallbackFormat)) { + fallbackSrc = renderUrl; + fallbackWidth = width; + fallbackHeight = 0; // Unknown height without checking, but it's lazy + fallbackFormat = format; + } + continue; + } + + logger.debug(`Ensure image cached : ${url}`) + + // EAGER GENERATION + try { + const res = await _ensureCachedImage(inputBuffer, width, undefined, format); + filename = res.filename; + variantStats.push({ width, format, lazy: false, hit: res.hit, duration: performance.now() - variantStart }); + } catch (e: any) { + logger.error({ err: e }, 'Failed to cache image variant'); + variantStats.push({ width, format, error: e.message, duration: performance.now() - variantStart }); + continue; + } + + const cachedUrl = `${baseUrl}/api/images/cache/${filename}`; + srcSetParts.push(`${cachedUrl} ${width}w`); + + // Update fallback to the largest version of the first format (or preferred format) + if (!fallbackSrc || (format === 'jpeg' && fallbackFormat !== 'jpeg') || (width > fallbackWidth && format === fallbackFormat)) { + fallbackSrc = cachedUrl; + fallbackWidth = width; // Use requested width as nominal fallback width + fallbackFormat = format; + } + } + + sources.push({ + srcset: srcSetParts.join(', '), + type: `image/${format}` + }); + } + + const totalDuration = performance.now() - start; + const seconds = Math.floor(totalDuration / 1000); + const ms = Math.floor(totalDuration % 1000); + const durationFormatted = `${seconds}:${ms.toString().padStart(3, '0')}`; + + const performanceStats = { + totalDuration: durationFormatted, + variants: variantStats.map(v => { + const vSeconds = Math.floor(v.duration / 1000); + const vMs = Math.floor(v.duration % 1000); + return { ...v, duration: `${vSeconds}:${vMs.toString().padStart(3, '0')}` }; + }), + url: url ? url : 'file-upload' + }; + + logger.debug({ + msg: 'Responsive image generation complete', + performance: performanceStats + }); + + return c.json({ + img: { + src: fallbackSrc, + width: fallbackWidth, + height: fallbackHeight, + format: fallbackFormat + }, + sources, + stats: performanceStats + }); + + } catch (err: any) { + logger.error({ err }, 'Responsive image generation failed'); + return c.text(err.message, 500); + } + } + + + async handleGetResponsive(c: Context) { + const start = performance.now(); + const variantStats: any[] = []; + + try { + const rawUrl = c.req.query('url'); + if (!rawUrl) return c.text('URL required', 400); + + const url = sanitizeImageUrl(rawUrl); + if (url.startsWith('ERR:')) return c.text(url.slice(4), 400); + const sourceUrl = await resolveLegacyPictureThumbUrl(url); + + const sizesJson = c.req.query('sizes'); + const formatsJson = c.req.query('formats'); + + // Fetch source image (with coalescing + source-cache) + const urlHash = createHash('sha256').update(sourceUrl).digest('hex'); + const sourceFilename = `source_${urlHash}`; + const sourcePath = path.join(CACHE_DIR, sourceFilename); + + let inputBuffer: Buffer; + try { + inputBuffer = await fs.readFile(sourcePath); + } catch { + inputBuffer = await fetchImageCoalesced(sourceUrl); + await fs.writeFile(sourcePath, inputBuffer).catch(e => { + logger.error({ err: e }, 'Failed to write source cache'); + }); + } + + // Defaults + const sizes: number[] = sizesJson ? JSON.parse(sizesJson) : [180, 640, 1024, 2048]; + const formats: string[] = formatsJson ? JSON.parse(formatsJson) : ['avif', 'webp']; + + const meta = await sharp(inputBuffer).metadata(); + const originalFormat = meta.format || 'jpeg'; + + const targetFormats = formats.map(f => f === 'original' || f === 'jpg' ? (originalFormat === 'jpeg' ? 'jpeg' : originalFormat) : f); + const uniqueFormats = [...new Set(targetFormats)]; + const uniqueSizes = [...new Set(sizes)].sort((a: number, b: number) => a - b); + + const sources: { srcset: string; type: string }[] = []; + let fallbackSrc = ''; + let fallbackWidth = 0; + let fallbackHeight = 0; + let fallbackFormat = ''; + + const baseUrl = process.env.SERVER_IMAGE_API_URL || 'https://service.polymech.info'; + const LAZY_THRESHOLD = 600; + + for (const format of uniqueFormats) { + const srcSetParts: string[] = []; + + for (const width of uniqueSizes) { + const variantStart = performance.now(); + const isLazy = width > LAZY_THRESHOLD; + + if (isLazy) { + const renderUrl = `${baseUrl}/api/images/render?url=${encodeURIComponent(sourceUrl)}&width=${width}&format=${format}`; + srcSetParts.push(`${renderUrl} ${width}w`); + variantStats.push({ width, format, lazy: true, duration: performance.now() - variantStart }); + + if (!fallbackSrc || (format === 'jpeg' && fallbackFormat !== 'jpeg') || (width > fallbackWidth && format === fallbackFormat)) { + fallbackSrc = renderUrl; + fallbackWidth = width; + fallbackHeight = 0; + fallbackFormat = format; + } + + // NOTE: background pre-warm removed — concurrent writes to the same + // cache file (pre-warm + real render request) produced corrupt AVIF. + // Revisit with write-to-temp-then-rename (atomic) if needed. + + continue; + } + + // EAGER GENERATION + try { + const res = await _ensureCachedImage(inputBuffer, width, undefined, format); + const filename = res.filename; + variantStats.push({ width, format, lazy: false, hit: res.hit, duration: performance.now() - variantStart }); + + const cachedUrl = `${baseUrl}/api/images/cache/${filename}`; + srcSetParts.push(`${cachedUrl} ${width}w`); + + if (!fallbackSrc || (format === 'jpeg' && fallbackFormat !== 'jpeg') || (width > fallbackWidth && format === fallbackFormat)) { + fallbackSrc = cachedUrl; + fallbackWidth = width; + fallbackFormat = format; + } + } catch (e: any) { + logger.error({ err: e }, 'Failed to cache image variant'); + variantStats.push({ width, format, error: e.message, duration: performance.now() - variantStart }); + continue; + } + } + + sources.push({ + srcset: srcSetParts.join(', '), + type: `image/${format}` + }); + } + + const totalDuration = performance.now() - start; + const seconds = Math.floor(totalDuration / 1000); + const ms = Math.floor(totalDuration % 1000); + const durationFormatted = `${seconds}:${ms.toString().padStart(3, '0')}`; + + const performanceStats = { + totalDuration: durationFormatted, + variants: variantStats.map(v => { + const vSeconds = Math.floor(v.duration / 1000); + const vMs = Math.floor(v.duration % 1000); + return { ...v, duration: `${vSeconds}:${vMs.toString().padStart(3, '0')}` }; + }), + url: sourceUrl + }; + + logger.debug({ + msg: 'Responsive image generation complete (GET)', + performance: performanceStats + }); + + // Cache the JSON response — deterministic for the same url+sizes+formats + c.header('Cache-Control', `public, max-age=${CACHE_TTL}, immutable`); + + return c.json({ + img: { + src: fallbackSrc, + width: fallbackWidth, + height: fallbackHeight, + format: fallbackFormat + }, + sources, + stats: performanceStats + }); + + } catch (err: any) { + logger.error({ err }, 'Responsive image generation (GET) failed'); + return c.text(err.message, 500); + } + } + + async handleRenderImage(c: Context) { + const start = performance.now(); + const reqId = Math.random().toString(36).slice(2, 8); + const fmtMs = (v: number) => `${Math.floor(v / 1000)}:${Math.floor(v % 1000).toString().padStart(3, '0')}`; + const url = c.req.query('url'); + if (!url) return c.text('URL required', 400); + try { + const widthStr = c.req.query('width'); + const heightStr = c.req.query('height'); + const formatStr = c.req.query('format'); + const square = c.req.query('square') === 'true'; + const contain = c.req.query('contain') === 'true'; + + const width = widthStr ? parseInt(widthStr) : undefined; + const height = heightStr ? parseInt(heightStr) : undefined; + const format = formatStr || 'jpeg'; + + const hashKey = createHash('sha256') + .update(url) + .update(`w${width}h${height}f${format}${square ? 'sq' : ''}${contain ? 'ct' : ''}`) + .digest('hex'); + + const filename = `${hashKey}.${format}`; + const filepath = path.join(CACHE_DIR, filename); + + logger.info({ reqId, url, width, height, format, hash: hashKey.slice(0, 12) }, 'render: incoming'); + + // 1. Check disk cache + try { + const content = await fs.readFile(filepath); + if (content.length === 0) { + logger.warn({ reqId, filepath }, 'render: cached file is empty (corrupt), treating as miss'); + await fs.unlink(filepath).catch(() => {}); + } else { + logger.info({ reqId, sizeKB: Math.round(content.length / 1024), elapsed: fmtMs(performance.now() - start) }, 'render: cache HIT'); + c.header('Content-Type', `image/${format}`); + c.header('Cache-Control', `public, max-age=${CACHE_TTL}, immutable`); + return c.body(content); + } + } catch { + // cache miss + } + + // 2. Coalesce concurrent renders for the same variant + let processedBuffer: Buffer; + + if (ongoingRenders.has(hashKey)) { + logger.info({ reqId, hash: hashKey.slice(0, 12) }, 'render: coalescing (joining in-flight render)'); + processedBuffer = await ongoingRenders.get(hashKey)!; + } else { + const renderPromise = (async () => { + // 3. Fetch source image + const fetchStart = performance.now(); + logger.info({ reqId, url }, 'render: fetching source'); + const inputBuffer = await fetchImageCoalesced(url); + const fetchMs = performance.now() - fetchStart; + logger.info({ reqId, fetchMs: fmtMs(fetchMs), srcKB: Math.round(inputBuffer.length / 1024) }, 'render: source fetched'); + + // 4. Encode — global slot limit (see IMAGE_ENCODE_MAX_CONCURRENT) + event-loop yield + try { + await _acquireEncode(reqId); + await yieldBeforeEncode(); + const encodeStart = performance.now(); + const buf = await this.performRenderImage(inputBuffer, filepath, { width, height, format, square, contain }); + const encodeMs = performance.now() - encodeStart; + logger.info( + { reqId, encodeMs: fmtMs(encodeMs), outKB: Math.round(buf.length / 1024), ..._encodeQueueStats() }, + 'render: encoded', + ); + return buf; + } finally { + _releaseEncode(); + } + })(); + + ongoingRenders.set(hashKey, renderPromise); + try { + processedBuffer = await renderPromise; + } finally { + ongoingRenders.delete(hashKey); + } + } + + const elapsed = performance.now() - start; + logger.info({ reqId, elapsed: fmtMs(elapsed), sizeKB: Math.round(processedBuffer.length / 1024), width, format }, 'render: complete (miss)'); + + c.header('Content-Type', `image/${format}`); + c.header('Cache-Control', `public, max-age=${CACHE_TTL}, immutable`); + return c.body(processedBuffer as any); + + } catch (err: any) { + const elapsed = performance.now() - start; + logger.error({ err, url, elapsed: fmtMs(elapsed), reqId }, 'render: FAILED'); + return c.text('Internal Server Error', 500); + } + } + + async handleTransformImage(c: Context) { + try { + const body = await c.req.parseBody(); + const file = body['file']; + const operationsJson = body['operations'] as string; + + if (!(file instanceof File)) { + return c.text('No file uploaded', 400); + } + + const buffer = await file.arrayBuffer(); + const inputBuffer = Buffer.from(buffer); + const operations = operationsJson ? JSON.parse(operationsJson) : []; + + let pipeline = sharp(inputBuffer); + + for (const op of operations) { + if (op.type === 'rotate') { + pipeline = pipeline.rotate(op.angle); + } else if (op.type === 'resize') { + pipeline = pipeline.resize({ + width: op.width, + height: op.height, + fit: op.fit || 'contain', + background: { r: 0, g: 0, b: 0, alpha: 0 } + }); + } else if (op.type === 'crop') { + pipeline = pipeline.extract({ + left: Math.round(op.x), + top: Math.round(op.y), + width: Math.round(op.width), + height: Math.round(op.height) + }); + } else if (op.type === 'flip') { + if (op.direction === 'horizontal') pipeline = pipeline.flop(); + if (op.direction === 'vertical') pipeline = pipeline.flip(); + } else if (op.type === 'adjust') { + if (op.brightness) { + // Sharp's modulate brightness is a multiplier (1.0 = original) if using recent versions, + // or it might be different. + // Actually pipeline.modulate({ brightness: 0.5 }) usually means 0.5x brightness? + // Let's check docs or assume standard multiplier. + // Wait, common Sharp modulate brightness is additive? No, documentation says "brightness: brightness multiplier". + pipeline = pipeline.modulate({ brightness: op.brightness }); + } + if (op.contrast) { + // Contrast: C * (pixel - 128) + 128 => C * pixel + (128 - 128*C) + // This keeps mid-grey fixed. + const a = op.contrast; + const b = 128 * (1 - a); + pipeline = pipeline.linear(a, b); + } + } + } + + const processedBuffer = await pipeline.toBuffer(); + const meta = await sharp(processedBuffer).metadata(); + + c.header('Content-Type', `image/${meta.format}`); + return c.body(processedBuffer as any); + + } catch (err: any) { + logger.error({ err }, 'Image transformation failed'); + return c.text(err.message, 500); + } + } +} diff --git a/packages/media/cpp/ref/images/logger.ts b/packages/media/cpp/ref/images/logger.ts new file mode 100644 index 00000000..5568d339 --- /dev/null +++ b/packages/media/cpp/ref/images/logger.ts @@ -0,0 +1,30 @@ +import pino from 'pino'; +import path from 'path'; + +const logFile = path.join(process.cwd(), 'logs', 'images.json'); + +const fileTransport = pino.transport({ + target: 'pino/file', + options: { destination: logFile, mkdir: true } +}); + +const consoleTransport = pino.transport({ + target: 'pino-pretty', + options: { + colorize: true, + ignore: 'pid,hostname', + destination: 1, + }, +}); + +export const logger = pino( + { + level: process.env.PINO_LOG_LEVEL || 'info', + base: { product: 'images' }, + timestamp: pino.stdTimeFunctions.isoTime, + }, + pino.multistream([ + { stream: fileTransport, level: 'info' }, + { stream: consoleTransport, level: 'info' }, + ]) +); diff --git a/packages/media/cpp/ref/images/metadata.ts b/packages/media/cpp/ref/images/metadata.ts new file mode 100644 index 00000000..849b1dfa --- /dev/null +++ b/packages/media/cpp/ref/images/metadata.ts @@ -0,0 +1,120 @@ +import ExifReader from 'exifreader'; +import path from 'path'; + +// Extract date from EXIF data +export async function extractImageDate(exifData: any): Promise<{ year: number, dateTaken: Date | null }> { + let dateTaken: Date | null = null; + + try { + const dateTimeOriginal = exifData?.['DateTimeOriginal']?.description; + const dateTime = exifData?.['DateTime']?.description; + const createDate = exifData?.['CreateDate']?.description; + + // Parse EXIF date (format: "YYYY:MM:DD HH:MM:SS") + const exifDateString = dateTimeOriginal || dateTime || createDate; + if (exifDateString) { + const [datePart, timePart] = exifDateString.split(' '); + const [year, month, day] = datePart.split(':').map(Number); + const [hour, minute, second] = (timePart || '00:00:00').split(':').map(Number); + dateTaken = new Date(year, month - 1, day, hour, minute, second); + } + } catch (e) { + // Warning managed by caller + } + + // Fallback if no date found is null, handled by caller + return { + year: dateTaken ? dateTaken.getFullYear() : new Date().getFullYear(), + dateTaken + }; +} + +// Extract comprehensive metadata from image file or buffer +export async function extractImageMetadata(input: string | Buffer): Promise<{ + year: number; + dateTaken: Date | null; + title: string; + description: string; + keywords: string[]; + width?: number; + height?: number; + exifRaw?: any; + gps?: { lat: number | null, lon: number | null }; + rotation?: number; +}> { + + let exifRaw: any = null; + try { + // ExifReader.load supports Buffer or filePath + // TS has trouble with the union type 'string | Buffer' against the overloads + if (typeof input === 'string') { + exifRaw = await ExifReader.load(input); + } else { + exifRaw = await ExifReader.load(input); + } + } catch (e) { + console.warn(`Error loading EXIF data:`, e); + exifRaw = {}; + } + + // Get date information + const { year, dateTaken } = await extractImageDate(exifRaw); + + // Metadata Priority Logic + const keywordsStr = exifRaw?.['LastKeywordXMP']?.description || exifRaw?.iptc?.Keywords?.description || ''; + const keywords = keywordsStr ? keywordsStr.split(',').map((k: string) => k.trim()) : []; + + const exifDescription = exifRaw?.['ImageDescription']?.description || ''; + const width = exifRaw?.['Image Width']?.value; + const height = exifRaw?.['Image Height']?.value; + + const title = exifRaw?.title?.description || ''; + const description = exifDescription || exifRaw?.iptc?.['Caption/Abstract']?.description || ''; + + // GPS + let lat: number | null = null; + let lon: number | null = null; + if (exifRaw?.['GPSLatitude'] && exifRaw?.['GPSLongitude']) { + // ExifReader provides convenient description for these? + // Actually usually it's an array of numbers. ExifReader might detail it. + // description often is "44, 23.4, 0" + // Let's rely on documentation or standard output. ExifReader usually returns array of numbers. + // But the user snippet used .description. Let's start with basic extraction or use the raw if available. + // Actually for simplicity let's store the description string if we want, but the prompt asked for "location" + // Let's try to extract components if they exist as description, otherwise null. + // Ideally we want decimal degrees. + // ExifReader usually offers decoded values. + // TODO: Validate what ExifReader.load returns for GPS in this version. + // Common pattern: + // GPSLatitude: { description: 45.123, value: [45, 12, 30], ... } + } + + const orientation = exifRaw?.['Orientation']?.value || 1; + // Map EXIF orientation to rotation degrees + let rotation = 0; + switch (orientation) { + case 3: rotation = 180; break; + case 6: rotation = 90; break; + case 8: rotation = 270; break; + } + + // Clean up RAW to avoid massive JSON + // We can keep 'exif' object but maybe remove binary buffers (ICC, thumbnail) + const cleanExif = { ...exifRaw }; + delete cleanExif['Thumbnail']; + delete cleanExif['MakerNote']; + delete cleanExif['UserComment']; + + return { + year, + dateTaken, + title, + description, + keywords, + width, + height, + exifRaw: cleanExif, + gps: { lat, lon }, // Placeholder for now, can refine if user wants precise geo-decoding + rotation + }; +} diff --git a/packages/media/cpp/ref/images/presets.ts b/packages/media/cpp/ref/images/presets.ts new file mode 100644 index 00000000..755f96d1 --- /dev/null +++ b/packages/media/cpp/ref/images/presets.ts @@ -0,0 +1,26 @@ +import { logger } from './logger.js'; + +export interface ImagePreset { + width?: number; + height?: number; + format?: string; + fit?: 'cover' | 'contain' | 'fill' | 'inside' | 'outside'; +} + +const DEFAULTS: Record<string, ImagePreset> = { + 'desktop:thumb': { width: 150, height: 150, format: 'avif', fit: 'cover' }, + 'desktop:medium': { width: 800, format: 'avif', fit: 'inside' }, + 'desktop:large': { width: 1920, format: 'avif', fit: 'inside' }, +}; + +export const getPresets = (): Record<string, ImagePreset> => { + let envPresets = {}; + if (process.env.IMAGE_PRESETS) { + try { + envPresets = JSON.parse(process.env.IMAGE_PRESETS); + } catch (e) { + logger.warn('Failed to parse IMAGE_PRESETS from env'); + } + } + return { ...DEFAULTS, ...envPresets }; +}; diff --git a/packages/media/cpp/ref/images/routes.ts b/packages/media/cpp/ref/images/routes.ts new file mode 100644 index 00000000..61ab1f2f --- /dev/null +++ b/packages/media/cpp/ref/images/routes.ts @@ -0,0 +1,283 @@ +import { createRoute, z } from '@hono/zod-openapi'; +import { createLogRoutes } from '../../commons/log-routes-factory.js'; +import { Public, Admin } from '../../commons/decorators.js'; + +export const { getRoute: getImageLogsRoute, streamRoute: streamImageLogsRoute } = createLogRoutes('Images', '/api/images/logs'); + +/** + * Factory function to create an image service route with optional decorators + */ +function createRouteBody( + method: string, + path: string, + tags: string[], + summary: string, + description: string, + request: any, + responses: any, + publicRoute: boolean = false, + adminRoute: boolean = false +) { + let route = createRoute({ + method: method as any, + path, + tags, + summary, + description, + request, + responses + }); + + if (publicRoute) { + route = Public(route); + } + + if (adminRoute) { + route = Admin(route); + } + + return route; +} + +export const postImageRoute = createRouteBody( + 'post', + '/api/images', + ['Images'], + 'Upload and resize image', + 'Upload an image and get a resized version via redirect.', + { + query: z.object({ + preset: z.string().optional().openapi({ description: 'Predefined preset (e.g., desktop:thumb)' }), + forward: z.string().optional().openapi({ description: 'Forward to external storage (e.g., supabase)' }), + cache: z.string().optional().default('true').openapi({ description: 'Use cache (true/false)' }) + }), + body: { + content: { + 'multipart/form-data': { + schema: z.object({ + file: z.any().openapi({ + type: 'string', + format: 'binary', + description: 'Image file' + }), + width: z.string().optional(), + height: z.string().optional(), + format: z.string().optional(), + }) + } + } + } + }, + { + 303: { + description: 'Redirect to processed image (local cache)', + }, + 200: { + description: 'Image processed and uploaded', + content: { + 'application/json': { + schema: z.object({ + url: z.string(), + width: z.number().optional(), + height: z.number().optional(), + format: z.string().optional(), + size: z.number().optional(), + filename: z.string().optional() + }) + } + } + }, + 500: { + description: 'Server error', + } + }, + false // private - requires auth +); + +export const getImageRoute = createRouteBody( + 'get', + '/api/images/cache/:filename', + ['Images'], + 'Get cached image', + 'Serving processed images from cache map.', + { + params: z.object({ + filename: z.string() + }) + }, + { + 200: { + description: 'Image file', + }, + 404: { + description: 'Not found', + } + }, + true // public - serve cached images +); + +export const postResponsiveImageRoute = createRouteBody( + 'post', + '/api/images/responsive', + ['Images'], + 'Generate responsive images', + 'Upload an image and get a list of responsive versions.', + { + body: { + content: { + 'multipart/form-data': { + schema: z.object({ + file: z.any().openapi({ + type: 'string', + format: 'binary', + description: 'Image file' + }), + sizes: z.string().optional().openapi({ description: 'JSON array of widths, e.g. [640, 1024]' }), + formats: z.string().optional().openapi({ description: 'JSON array of formats, e.g. ["webp", "jpg"]' }), + }) + } + } + } + }, + { + 200: { + description: 'Responsive images generated', + content: { + 'application/json': { + schema: z.object({ + img: z.object({ + src: z.string(), + width: z.number(), + height: z.number(), + format: z.string(), + }), + sources: z.array(z.object({ + srcset: z.string(), + type: z.string() + })) + }) + } + } + }, + 303: { + description: 'Redirect to cached image', + }, + 500: { + description: 'Server error', + } + }, + false // private - file upload requires auth +); + +export const getResponsiveImageRoute = createRouteBody( + 'get', + '/api/images/responsive', + ['Images'], + 'Generate responsive images (GET)', + 'Generate responsive image variants from a remote URL. Cacheable alternative to the POST endpoint.', + { + query: z.object({ + url: z.string().openapi({ description: 'Remote URL to generate responsive images from' }), + sizes: z.string().optional().openapi({ description: 'JSON array of widths, e.g. [640, 1024]' }), + formats: z.string().optional().openapi({ description: 'JSON array of formats, e.g. ["webp", "avif"]' }), + }) + }, + { + 200: { + description: 'Responsive images generated', + content: { + 'application/json': { + schema: z.object({ + img: z.object({ + src: z.string(), + width: z.number(), + height: z.number(), + format: z.string(), + }), + sources: z.array(z.object({ + srcset: z.string(), + type: z.string() + })) + }) + } + } + }, + 400: { + description: 'Missing URL parameter', + }, + 500: { + description: 'Server error', + } + }, + true // public - cacheable GET endpoint +); + +export const renderImageRoute = createRouteBody( + 'get', + '/api/images/render', + ['Images'], + 'Render lazy-optimized image', + 'Fetch, resize, and serve an image from a remote URL. Intended for use in srcSet.', + { + query: z.object({ + url: z.string().openapi({ description: 'Remote URL to fetch' }), + width: z.string().optional().openapi({ description: 'Target width' }), + height: z.string().optional().openapi({ description: 'Target height' }), + format: z.string().optional().openapi({ description: 'Output format (jpeg, webp, etc.)' }) + }) + }, + { + 200: { + description: 'Image content', + content: { + 'image/*': { + schema: z.string().openapi({ format: 'binary' }) + } + } + }, + 500: { + description: 'Server error' + } + }, + true // public - image rendering/proxy +); + +export const postTransformRoute = createRouteBody( + 'post', + '/api/images/transform', + ['Images'], + 'Transform image', + 'Apply operations (resize, crop, rotate) to an uploaded image.', + { + body: { + content: { + 'multipart/form-data': { + schema: z.object({ + file: z.any().openapi({ + type: 'string', + format: 'binary', + description: 'Image file' + }), + operations: z.string().openapi({ + description: 'JSON array of operations: [{ type: "rotate", angle: 90 }, { type: "resize", width: 100 }, { type: "crop", x: 0, y: 0, width: 100, height: 100 }]' + }) + }) + } + } + } + }, + { + 200: { + description: 'Transformed image', + content: { + 'image/*': { + schema: z.string().openapi({ format: 'binary' }) + } + } + }, + 500: { + description: 'Server error', + } + }, + false // private - requires auth +); diff --git a/packages/media/cpp/scripts/run-7b.sh b/packages/media/cpp/scripts/run-7b.sh new file mode 100644 index 00000000..6e61427d --- /dev/null +++ b/packages/media/cpp/scripts/run-7b.sh @@ -0,0 +1,9 @@ +llama-server.exe \ + --hf-repo paultimothymooney/Qwen2.5-7B-Instruct-Q4_K_M-GGUF \ + --hf-file qwen2.5-7b-instruct-q4_k_m.gguf \ + -t 16 \ + -c 2048 \ + -b 512 \ + --temp 0.2 \ + --top-p 0.9 \ + --port 8888 diff --git a/packages/media/cpp/scripts/setup-7b.sh b/packages/media/cpp/scripts/setup-7b.sh new file mode 100644 index 00000000..0be849ee --- /dev/null +++ b/packages/media/cpp/scripts/setup-7b.sh @@ -0,0 +1,4 @@ +mkdir -p models/qwen2.5-7b +cd models/qwen2.5-7b + +curl -L -O https://huggingface.co/Qwen/Qwen2.5-7B-Instruct-GGUF/resolve/main/qwen2.5-7b-instruct-q4_k_m.gguf diff --git a/packages/media/cpp/src/cmd_kbot.cpp b/packages/media/cpp/src/cmd_kbot.cpp new file mode 100644 index 00000000..ef0209c5 --- /dev/null +++ b/packages/media/cpp/src/cmd_kbot.cpp @@ -0,0 +1,189 @@ +#include "cmd_kbot.h" +#include "logger/logger.h" +#include <CLI/CLI.hpp> +#include <rapidjson/document.h> +#include <rapidjson/stringbuffer.h> +#include <rapidjson/writer.h> +#include <toml++/toml.h> +#include <iostream> +#include <algorithm> +#include <cctype> +#include <cmath> +#include <limits> + +namespace polymech { + +// Helper to reliably extract API keys for any router from postgres.toml [services] section +static std::string get_api_key_for_router(const toml::table& cfg, const std::string& router) { + if (router == "ollama") return "ollama"; + std::string key = router.empty() ? "OPENROUTER" : router; + std::transform(key.begin(), key.end(), key.begin(), [](unsigned char c){ return std::toupper(c); }); + + if (key == "OPENAI") { + auto val = cfg["services"]["OPENAI_KEY"].value_or(std::string("")); + if (!val.empty()) return val; + } + + return cfg["services"][key].value_or(std::string("")); +} + +// Global states for CLI mode +static kbot::KBotOptions g_kbot_opts; +static kbot::KBotRunOptions g_run_opts; + +static CLI::App* ai_cmd = nullptr; +static CLI::App* run_cmd = nullptr; + +CLI::App* setup_cmd_kbot(CLI::App& app) { + auto* kbot_cmd = app.add_subcommand("kbot", "KBot AI & Task runner"); + + // ── AI Subcommand ── + ai_cmd = kbot_cmd->add_subcommand("ai", "Run KBot AI tasks"); + ai_cmd->add_option("-p,--path", g_kbot_opts.path, "Target directory")->default_val("."); + ai_cmd->add_option("--prompt", g_kbot_opts.prompt, "The prompt. Supports file paths and vars."); + ai_cmd->add_option("-c,--config", g_kbot_opts.config_path, "Config file for API Keys")->default_val("config/postgres.toml"); + /* Same destination as TS `onCompletion`: write LLM text here (--dst wins if both appear in IPC JSON). */ + ai_cmd->add_option( + "--dst,--output", + g_kbot_opts.dst, + "Write completion result to this path (${MODEL}, ${ROUTER} expanded). Same as --output."); + ai_cmd->add_option("--append", g_kbot_opts.append, "How to handle output if --dst exists: concat|merge|replace")->default_val("concat"); + ai_cmd->add_option("--wrap", g_kbot_opts.wrap, "Specify how to wrap output: meta|none")->default_val("none"); + ai_cmd->add_option("--each", g_kbot_opts.each, "Iterate over items (GLOB, JSON, array)"); + ai_cmd->add_option("--disable", g_kbot_opts.disable, "Disable tools categories"); + ai_cmd->add_option("--disableTools", g_kbot_opts.disable_tools, "Specific tools to disable"); + ai_cmd->add_option("--tools", g_kbot_opts.tools, "Tools to use"); + ai_cmd->add_option("--include", g_kbot_opts.include_globs, "Glob patterns or paths to include"); + ai_cmd->add_option("--exclude", g_kbot_opts.exclude_globs, "Glob patterns or paths to exclude"); + ai_cmd->add_option("--globExtension", g_kbot_opts.glob_extension, "Glob extension behavior (e.g. match-cpp)"); + ai_cmd->add_option("--api_key", g_kbot_opts.api_key, "Explicit API key to use"); + ai_cmd->add_option("--model", g_kbot_opts.model, "AI model to use"); + ai_cmd->add_option("--router", g_kbot_opts.router, "Router to use (openai, openrouter, deepseek)")->default_val("openrouter"); + ai_cmd->add_option("--mode", g_kbot_opts.mode, "Chat completion mode")->default_val("tools"); + ai_cmd->add_option( + "--response-format", + g_kbot_opts.response_format_json, + R"(Chat completions response_format JSON, e.g. {"type":"json_object"} for structured output)"); + ai_cmd->add_flag("--dry", g_kbot_opts.dry_run, "Dry run"); + + // ── Run Subcommand ── + run_cmd = kbot_cmd->add_subcommand("run", "Run a .vscode/launch.json configuration"); + run_cmd->add_option("-c,--config", g_run_opts.config, "Config name")->default_val("default"); + run_cmd->add_flag("--dry", g_run_opts.dry, "Dry run"); + run_cmd->add_flag("--list", g_run_opts.list, "List available configs"); + run_cmd->add_option("--projectPath", g_run_opts.project_path, "Project path")->default_val("."); + run_cmd->add_option("--logFilePath", g_run_opts.log_file_path, "Log file path"); + + return kbot_cmd; +} + +bool is_kbot_ai_parsed() { return ai_cmd != nullptr && ai_cmd->parsed(); } +bool is_kbot_run_parsed() { return run_cmd != nullptr && run_cmd->parsed(); } + +int run_cmd_kbot_ai() { + // Fallback logic if API key isn't explicitly provided on CLI + if (g_kbot_opts.api_key.empty() && !g_kbot_opts.config_path.empty()) { + try { + auto cfg = toml::parse_file(g_kbot_opts.config_path); + g_kbot_opts.api_key = get_api_key_for_router(cfg, g_kbot_opts.router); + logger::debug("Loaded API Key from fallback config: " + g_kbot_opts.config_path); + } catch (const std::exception& e) { + logger::warn("Failed to load generic fallback kbot config: " + std::string(e.what())); + } + } + + return kbot::run_kbot_ai_pipeline(g_kbot_opts, kbot::KBotCallbacks{}); +} + +int run_cmd_kbot_run() { + return kbot::run_kbot_run_pipeline(g_run_opts, kbot::KBotCallbacks{}); +} + +int run_kbot_ai_ipc(const std::string& payload, const std::string& jobId, const kbot::KBotCallbacks& cb) { + kbot::KBotOptions opts; + opts.job_id = jobId; + opts.config_path = "config/postgres.toml"; // Fixed path for IPC worker + + // Optional: Parse JSON from payload to overwrite opts variables using rapidjson + rapidjson::Document doc; + doc.Parse(payload.c_str()); + if (!doc.HasParseError() && doc.IsObject()) { + if (doc.HasMember("prompt") && doc["prompt"].IsString()) opts.prompt = doc["prompt"].GetString(); + if (doc.HasMember("path") && doc["path"].IsString()) opts.path = doc["path"].GetString(); + if (doc.HasMember("include") && doc["include"].IsArray()) { + for (const auto& v : doc["include"].GetArray()) { + if (v.IsString()) opts.include_globs.push_back(v.GetString()); + } + } + if (doc.HasMember("exclude") && doc["exclude"].IsArray()) { + for (const auto& v : doc["exclude"].GetArray()) { + if (v.IsString()) opts.exclude_globs.push_back(v.GetString()); + } + } + if (doc.HasMember("dry_run") && doc["dry_run"].IsBool()) opts.dry_run = doc["dry_run"].GetBool(); + if (doc.HasMember("api_key") && doc["api_key"].IsString()) opts.api_key = doc["api_key"].GetString(); + if (doc.HasMember("router") && doc["router"].IsString()) opts.router = doc["router"].GetString(); + if (doc.HasMember("model") && doc["model"].IsString()) opts.model = doc["model"].GetString(); + if (doc.HasMember("base_url") && doc["base_url"].IsString()) opts.base_url = doc["base_url"].GetString(); + else if (doc.HasMember("baseURL") && doc["baseURL"].IsString()) opts.base_url = doc["baseURL"].GetString(); + /* Single numeric path: RapidJSON may store JSON integers as int, uint64, or double — GetDouble() is consistent. */ + if (doc.HasMember("llm_timeout_ms") && doc["llm_timeout_ms"].IsNumber() && !doc["llm_timeout_ms"].IsNull()) { + const rapidjson::Value& v = doc["llm_timeout_ms"]; + const double d = v.GetDouble(); + if (d > 0.0 && std::isfinite(d)) { + const long long ms = static_cast<long long>(std::llround(d)); + const int cap = std::numeric_limits<int>::max(); + if (ms > 0 && ms <= static_cast<long long>(cap)) { + opts.llm_timeout_ms = static_cast<int>(ms); + } + } + } + if (doc.HasMember("response_format")) { + const rapidjson::Value& rf = doc["response_format"]; + if (rf.IsObject()) { + rapidjson::StringBuffer sb; + rapidjson::Writer<rapidjson::StringBuffer> w(sb); + rf.Accept(w); + opts.response_format_json.assign(sb.GetString(), sb.GetSize()); + } else if (rf.IsString()) { + opts.response_format_json = rf.GetString(); + } + } + if (doc.HasMember("dst") && doc["dst"].IsString()) + opts.dst = doc["dst"].GetString(); + if (doc.HasMember("output") && doc["output"].IsString()) { + if (opts.dst.empty()) + opts.dst = doc["output"].GetString(); + } + } + + if (opts.api_key.empty()) { + try { + auto cfg = toml::parse_file(opts.config_path); + opts.api_key = get_api_key_for_router(cfg, opts.router); + } catch (...) {} + } + + logger::info("Receiving AI task over IPC... job: " + jobId); + if (opts.llm_timeout_ms > 0) { + logger::info("kbot-ai IPC: llm_timeout_ms=" + std::to_string(opts.llm_timeout_ms)); + } + return kbot::run_kbot_ai_pipeline(opts, cb); +} + +int run_kbot_run_ipc(const std::string& payload, const std::string& jobId, const kbot::KBotCallbacks& cb) { + kbot::KBotRunOptions opts; + opts.job_id = jobId; + + rapidjson::Document doc; + doc.Parse(payload.c_str()); + if (!doc.HasParseError() && doc.IsObject()) { + if (doc.HasMember("config") && doc["config"].IsString()) opts.config = doc["config"].GetString(); + if (doc.HasMember("dry") && doc["dry"].IsBool()) opts.dry = doc["dry"].GetBool(); + } + + logger::info("Receiving run task over IPC... job: " + jobId); + return kbot::run_kbot_run_pipeline(opts, cb); +} + +} // namespace polymech diff --git a/packages/media/cpp/src/cmd_kbot.h b/packages/media/cpp/src/cmd_kbot.h new file mode 100644 index 00000000..32c60708 --- /dev/null +++ b/packages/media/cpp/src/cmd_kbot.h @@ -0,0 +1,28 @@ +#pragma once + +#include <CLI/CLI.hpp> +#include <string> +#include <functional> +#include "kbot.h" + +namespace polymech { + +/// Attach kbot subcommands to the main app +CLI::App* setup_cmd_kbot(CLI::App& app); + +/// CLI Entry points +int run_cmd_kbot_ai(); +int run_cmd_kbot_run(); + +/// IPC / UDS Entry points (implemented in src/cmd_kbot*.cpp — not in libkbot; compile those TU into your binary). +int run_kbot_ai_ipc(const std::string& payload, const std::string& jobId, const kbot::KBotCallbacks& cb); +int run_kbot_run_ipc(const std::string& payload, const std::string& jobId, const kbot::KBotCallbacks& cb); + +/// Standalone UDS/TCP server for KBot (orchestrator tests, LLM worker). +int run_cmd_kbot_uds(const std::string& pipe_path); + +/// Helper to check parsed state +bool is_kbot_ai_parsed(); +bool is_kbot_run_parsed(); + +} // namespace polymech diff --git a/packages/media/cpp/src/cmd_kbot_uds.cpp b/packages/media/cpp/src/cmd_kbot_uds.cpp new file mode 100644 index 00000000..7de318d3 --- /dev/null +++ b/packages/media/cpp/src/cmd_kbot_uds.cpp @@ -0,0 +1,370 @@ +// cmd_kbot_uds.cpp — UDS/TCP worker for KBot LLM IPC (length-prefixed JSON frames). +// Framing matches orchestrator tests: [uint32_le length][utf-8 JSON object with id, type, payload]. + +#include "cmd_kbot.h" +#include "concurrentqueue.h" +#include "logger/logger.h" +#include "rapidjson/document.h" +#include "rapidjson/stringbuffer.h" +#include "rapidjson/writer.h" +#include <asio.hpp> +#include <atomic> +#include <chrono> +#include <cstdio> +#include <mutex> +#include <spdlog/sinks/base_sink.h> +#include <spdlog/spdlog.h> +#include <taskflow/taskflow.hpp> +#include <thread> +#include <unordered_map> + +namespace polymech { +namespace { + +#ifdef _WIN32 +using ipc_endpoint = asio::ip::tcp::endpoint; +using ipc_acceptor = asio::ip::tcp::acceptor; +using ipc_socket = asio::ip::tcp::socket; +#else +using ipc_endpoint = asio::local::stream_protocol::endpoint; +using ipc_acceptor = asio::local::stream_protocol::acceptor; +using ipc_socket = asio::local::stream_protocol::socket; +#endif + +std::shared_ptr<ipc_socket> g_active_uds_socket; +std::mutex g_uds_socket_mutex; + +std::string json_escape_log_line(const std::string &s) { + rapidjson::StringBuffer buf; + rapidjson::Writer<rapidjson::StringBuffer> w(buf); + w.String(s.c_str(), static_cast<rapidjson::SizeType>(s.length())); + std::string out(buf.GetString(), buf.GetSize()); + if (out.size() >= 2 && out.front() == '"' && out.back() == '"') + return out.substr(1, out.size() - 2); + return out; +} + +template <typename Mutex> +class kbot_uds_sink : public spdlog::sinks::base_sink<Mutex> { +protected: + void sink_it_(const spdlog::details::log_msg &msg) override { + spdlog::memory_buf_t formatted; + this->formatter_->format(msg, formatted); + std::string text = fmt::to_string(formatted); + if (!text.empty() && text.back() == '\n') + text.pop_back(); + + std::lock_guard<std::mutex> lock(g_uds_socket_mutex); + if (!g_active_uds_socket) + return; + try { + std::string escaped = json_escape_log_line(text); + std::string frame = "{\"type\":\"log\",\"data\":\"" + escaped + "\"}"; + uint32_t len = static_cast<uint32_t>(frame.size()); + asio::write(*g_active_uds_socket, asio::buffer(&len, 4)); + asio::write(*g_active_uds_socket, asio::buffer(frame)); + } catch (...) { + } + } + void flush_() override {} +}; + +using kbot_uds_sink_mt = kbot_uds_sink<std::mutex>; + +struct KbotUdsJob { + std::string payload; + std::string job_id; + std::shared_ptr<ipc_socket> socket; + std::shared_ptr<std::atomic<bool>> cancel_token; +}; + +std::string request_id_string(const rapidjson::Document &doc) { + if (doc.HasMember("id") && doc["id"].IsString()) + return doc["id"].GetString(); + if (doc.HasMember("jobId") && doc["jobId"].IsString()) + return doc["jobId"].GetString(); + return "kbot-uds-" + + std::to_string( + std::chrono::system_clock::now().time_since_epoch().count()); +} + +void write_raw_frame(const std::shared_ptr<ipc_socket> &sock, + const std::string &json_body) { + uint32_t len = static_cast<uint32_t>(json_body.size()); + std::lock_guard<std::mutex> lock(g_uds_socket_mutex); + asio::write(*sock, asio::buffer(&len, 4)); + asio::write(*sock, asio::buffer(json_body)); +} + +} // namespace + +int run_cmd_kbot_uds(const std::string &pipe_path) { + logger::info("Starting KBot UDS on " + pipe_path); + std::atomic<bool> running{true}; + asio::io_context io_context; + std::shared_ptr<ipc_acceptor> acceptor; + + try { +#ifdef _WIN32 + int port = 4000; + try { + port = std::stoi(pipe_path); + } catch (...) { + } + ipc_endpoint ep(asio::ip::tcp::v4(), static_cast<unsigned short>(port)); + acceptor = std::make_shared<ipc_acceptor>(io_context, ep); + logger::info("KBot UDS: bound TCP 127.0.0.1:" + std::to_string(port)); +#else + std::remove(pipe_path.c_str()); + ipc_endpoint ep(pipe_path); + acceptor = std::make_shared<ipc_acceptor>(io_context, ep); +#endif + } catch (const std::exception &e) { + logger::error(std::string("KBot UDS bind failed: ") + e.what()); + return 1; + } + + const int k_frame_max = 50 * 1024 * 1024; + const int k_queue_depth_max = 10000; + int threads = static_cast<int>(std::thread::hardware_concurrency()); + if (threads <= 0) + threads = 2; + tf::Executor executor(threads); + + moodycamel::ConcurrentQueue<KbotUdsJob> queue; + + auto log_sink = std::make_shared<kbot_uds_sink_mt>(); + log_sink->set_pattern("%^%l%$ %v"); + spdlog::default_logger()->sinks().push_back(log_sink); + + std::thread uds_job_queue_thread([&]() { + KbotUdsJob job; + while (running.load()) { + if (!queue.try_dequeue(job)) { + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + continue; + } + + tf::Taskflow tf; + tf.emplace([job]() { + { + std::lock_guard<std::mutex> lock(g_uds_socket_mutex); + g_active_uds_socket = job.socket; + } + + kbot::KBotCallbacks cb; + cb.onEvent = [sock = job.socket, jid = job.job_id]( + const std::string &type, const std::string &json) { + try { + std::string resolved_id = + (type == "job_result" || type == "error") ? jid : "0"; + std::string msg = "{\"id\":\"" + resolved_id + + "\",\"type\":\"" + type + "\",\"payload\":" + + json + "}"; + uint32_t len = static_cast<uint32_t>(msg.size()); + std::lock_guard<std::mutex> lock(g_uds_socket_mutex); + asio::write(*sock, asio::buffer(&len, 4)); + asio::write(*sock, asio::buffer(msg)); + } catch (...) { + } + }; + + rapidjson::Document doc; + doc.Parse(job.payload.c_str()); + if (doc.HasParseError()) { + cb.onEvent("error", "\"invalid JSON payload\""); + std::lock_guard<std::mutex> lock(g_uds_socket_mutex); + g_active_uds_socket.reset(); + return; + } + + std::string job_type; + if (doc.HasMember("type") && doc["type"].IsString()) + job_type = doc["type"].GetString(); + + if (job_type == "job") { + std::string payload_str = "{}"; + if (doc.HasMember("payload")) { + rapidjson::StringBuffer sbuf; + rapidjson::Writer<rapidjson::StringBuffer> writer(sbuf); + doc["payload"].Accept(writer); + payload_str = sbuf.GetString(); + } + cb.onEvent("job_result", payload_str); + } else if (job_type == "kbot-ai") { + kbot::KBotCallbacks ai_cb; + ai_cb.onEvent = [&cb](const std::string &t, const std::string &j) { + cb.onEvent(t, j); + }; + std::string payload_str = "{}"; + if (doc.HasMember("payload")) { + if (doc["payload"].IsString()) { + payload_str = doc["payload"].GetString(); + } else { + rapidjson::StringBuffer sbuf; + rapidjson::Writer<rapidjson::StringBuffer> writer(sbuf); + doc["payload"].Accept(writer); + payload_str = sbuf.GetString(); + } + } + polymech::run_kbot_ai_ipc(payload_str, job.job_id, ai_cb); + } else if (job_type == "kbot-run") { + kbot::KBotCallbacks run_cb; + run_cb.onEvent = [&cb](const std::string &t, const std::string &j) { + cb.onEvent(t, j); + }; + std::string payload_str = "{}"; + if (doc.HasMember("payload")) { + if (doc["payload"].IsString()) { + payload_str = doc["payload"].GetString(); + } else { + rapidjson::StringBuffer sbuf; + rapidjson::Writer<rapidjson::StringBuffer> writer(sbuf); + doc["payload"].Accept(writer); + payload_str = sbuf.GetString(); + } + } + polymech::run_kbot_run_ipc(payload_str, job.job_id, run_cb); + } else { + rapidjson::StringBuffer sbuf; + rapidjson::Writer<rapidjson::StringBuffer> w(sbuf); + w.StartObject(); + w.Key("message"); + std::string m = "unsupported type: " + job_type; + w.String(m.c_str(), static_cast<rapidjson::SizeType>(m.size())); + w.EndObject(); + cb.onEvent("error", sbuf.GetString()); + } + + { + std::lock_guard<std::mutex> lock(g_uds_socket_mutex); + g_active_uds_socket.reset(); + } + }); + + executor.run(tf).wait(); + } + }); + + logger::info("KBot UDS ready; waiting for connections…"); + while (running.load()) { + auto socket = std::make_shared<ipc_socket>(io_context); + asio::error_code ec; + acceptor->accept(*socket, ec); + if (ec || !running.load()) + break; + + logger::info("KBot UDS client connected"); + + std::thread( + [socket, &queue, &running, acceptor, k_frame_max, k_queue_depth_max]() { + std::unordered_map<std::string, std::shared_ptr<std::atomic<bool>>> + socket_jobs; + + try { + { + std::string ready = + R"({"id":"0","type":"ready","payload":{}})"; + uint32_t rlen = static_cast<uint32_t>(ready.size()); + std::lock_guard<std::mutex> lock(g_uds_socket_mutex); + asio::write(*socket, asio::buffer(&rlen, 4)); + asio::write(*socket, asio::buffer(ready)); + } + + while (true) { + uint32_t len = 0; + asio::read(*socket, asio::buffer(&len, 4)); + if (len == 0 || + len > static_cast<uint32_t>(k_frame_max)) + break; + + std::string raw(len, '\0'); + asio::read(*socket, asio::buffer(raw.data(), len)); + + rapidjson::Document doc; + doc.Parse(raw.c_str()); + + if (!doc.HasParseError()) { + std::string action; + if (doc.HasMember("action") && doc["action"].IsString()) + action = doc["action"].GetString(); + else if (doc.HasMember("type") && doc["type"].IsString()) + action = doc["type"].GetString(); + + auto id_for = [&doc]() -> std::string { + if (doc.HasMember("id") && doc["id"].IsString()) + return doc["id"].GetString(); + return "0"; + }; + + if (action == "ping") { + std::string res_id = id_for(); + std::string ack = "{\"id\":\"" + res_id + + "\",\"type\":\"pong\",\"payload\":{}}"; + write_raw_frame(socket, ack); + continue; + } + if (action == "nonsense") { + std::string res_id = id_for(); + std::string ack = "{\"id\":\"" + res_id + + "\",\"type\":\"error\",\"payload\":{}}"; + write_raw_frame(socket, ack); + continue; + } + if (action == "cancel") { + if (doc.HasMember("jobId") && doc["jobId"].IsString()) { + std::string jid = doc["jobId"].GetString(); + if (socket_jobs.count(jid) && socket_jobs[jid]) { + *socket_jobs[jid] = true; + std::string ack = + "{\"type\":\"cancel_ack\",\"data\":\"" + jid + "\"}"; + write_raw_frame(socket, ack); + } + } + continue; + } + if (action == "stop" || action == "shutdown") { + logger::info("KBot UDS: shutdown requested"); + std::string res_id = id_for(); + std::string ack = "{\"id\":\"" + res_id + + "\",\"type\":\"shutdown_ack\"," + "\"payload\":{}}"; + write_raw_frame(socket, ack); + running.store(false); + try { + acceptor->close(); + } catch (...) { + } + break; + } + } else { + continue; + } + + std::string jid = request_id_string(doc); + auto cancel_token = std::make_shared<std::atomic<bool>>(false); + socket_jobs[jid] = cancel_token; + + KbotUdsJob job{raw, jid, socket, cancel_token}; + while (queue.size_approx() >= + static_cast<size_t>(k_queue_depth_max)) { + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + queue.enqueue(std::move(job)); + } + } catch (const std::exception &) { + for (auto &kv : socket_jobs) { + if (kv.second) + *kv.second = true; + } + } + }) + .detach(); + } + + running.store(false); + uds_job_queue_thread.join(); + return 0; +} + +} // namespace polymech diff --git a/packages/media/cpp/src/main.cpp b/packages/media/cpp/src/main.cpp new file mode 100644 index 00000000..c1436b88 --- /dev/null +++ b/packages/media/cpp/src/main.cpp @@ -0,0 +1,271 @@ +#include <iostream> +#include <fstream> +#include <string> + +#include <laserpants/dotenv/dotenv.h> +#include <chrono> +#include <set> +#include <ctime> +#include <iomanip> +#include <sstream> +#include <rapidjson/document.h> + +#include <CLI/CLI.hpp> +#include <toml++/toml.hpp> + +#include "html/html.h" +#include "http/http.h" +#include "ipc/ipc.h" +#include "logger/logger.h" +#include "postgres/postgres.h" +#include "json/json.h" +#include "cmd_kbot.h" + +#ifndef PROJECT_VERSION +#define PROJECT_VERSION "0.1.0" +#endif + +int main(int argc, char *argv[]) { + /* Optional .env next to cwd — do not override variables already set in the shell. */ + dotenv::init(dotenv::Preserve); + + CLI::App app{"kbot — KBot C++ CLI", "kbot"}; + app.set_version_flag("-v,--version", PROJECT_VERSION); + + std::string log_level = "info"; + app.add_option("--log-level", log_level, "Set log level (debug/info/warn/error)")->default_val("info"); + + // Subcommand: parse HTML + std::string html_input; + auto *parse_cmd = app.add_subcommand("parse", "Parse HTML and list elements"); + parse_cmd->add_option("html", html_input, "HTML string to parse")->required(); + + // Subcommand: select from HTML + std::string select_input; + std::string selector; + auto *select_cmd = + app.add_subcommand("select", "CSS-select elements from HTML"); + select_cmd->add_option("html", select_input, "HTML string")->required(); + select_cmd->add_option("selector", selector, "CSS selector")->required(); + + // Subcommand: config — read a TOML file + std::string config_path; + auto *config_cmd = + app.add_subcommand("config", "Read and display a TOML config file"); + config_cmd->add_option("file", config_path, "Path to TOML file")->required(); + + // Subcommand: fetch — HTTP GET a URL + std::string fetch_url; + auto *fetch_cmd = + app.add_subcommand("fetch", "HTTP GET a URL and print the response"); + fetch_cmd->add_option("url", fetch_url, "URL to fetch")->required(); + + // Subcommand: json — prettify JSON + std::string json_input; + auto *json_cmd = app.add_subcommand("json", "Prettify a JSON string"); + json_cmd->add_option("input", json_input, "JSON string")->required(); + + // Subcommand: db — connect to Supabase and query + std::string db_config_path = "config/postgres.toml"; + std::string db_table; + int db_limit = 10; + auto *db_cmd = + app.add_subcommand("db", "Connect to Supabase and query a table"); + db_cmd->add_option("-c,--config", db_config_path, "TOML config path") + ->default_val("config/postgres.toml"); + db_cmd->add_option("table", db_table, "Table to query (optional)"); + db_cmd->add_option("-l,--limit", db_limit, "Row limit")->default_val(10); + + // Subcommand: worker — IPC mode (spawned by Node.js orchestrator) + std::string uds_path; + + auto *worker_cmd = app.add_subcommand( + "worker", "Run as IPC worker (stdin/stdout length-prefixed JSON)"); + worker_cmd->add_option("--uds", uds_path, + "Listen on TCP port (Windows) or Unix socket path"); + + // Subcommand: kbot — AI workflows & task configurations + auto* kbot_cmd = polymech::setup_cmd_kbot(app); + (void)kbot_cmd; + + CLI11_PARSE(app, argc, argv); + + // Worker mode uses stderr for logs to keep stdout clean for IPC frames + if (worker_cmd->parsed()) { + if (!uds_path.empty()) { + logger::init_uds("polymech-uds", log_level, "../logs/uds.json"); + } else { + logger::init_stderr("polymech-worker", log_level); + } + } else { + logger::init("polymech-cli", log_level); + } + + // ── worker mode ───────────────────────────────────────────────────────── + if (worker_cmd->parsed()) { + logger::info("Worker mode: listening on stdin"); + + if (!uds_path.empty()) { + logger::info("Worker mode: UDS Server active on " + uds_path); + int rc = polymech::run_cmd_kbot_uds(uds_path); + return rc; + } + + // Send a "ready" message so the orchestrator knows we're alive + ipc::write_message({"0", "ready", "{}"}); + + while (true) { + ipc::Message req; + if (!ipc::read_message(req)) { + logger::info("Worker: stdin closed, exiting"); + break; + } + + logger::debug("Worker recv: type=" + req.type + " id=" + req.id); + + if (req.type == "ping") { + ipc::write_message({req.id, "pong", "{}"}); + + } else if (req.type == "job") { + // Stub: echo the payload back as job_result + ipc::write_message({req.id, "job_result", req.payload}); + + } else if (req.type == "kbot-ai") { + logger::info("Worker: kbot-ai job received"); + std::string req_id = req.id; + polymech::kbot::KBotCallbacks cb; + cb.onEvent = [&req_id](const std::string& type, const std::string& json) { + if (type == "job_result") { + ipc::write_message({req_id, "job_result", json}); + } else { + ipc::write_message({"0", type, json}); + } + }; + int rc = polymech::run_kbot_ai_ipc(req.payload, req.id, cb); + if (rc != 0) { + ipc::write_message({req.id, "error", "{\"message\":\"kbot ai pipeline failed\"}"}); + } + + } else if (req.type == "kbot-run") { + logger::info("Worker: kbot-run job received"); + std::string req_id = req.id; + polymech::kbot::KBotCallbacks cb; + cb.onEvent = [&req_id](const std::string& type, const std::string& json) { + if (type == "job_result") { + ipc::write_message({req_id, "job_result", json}); + } else { + ipc::write_message({"0", type, json}); + } + }; + int rc = polymech::run_kbot_run_ipc(req.payload, req.id, cb); + if (rc != 0) { + ipc::write_message({req.id, "error", "{\"message\":\"kbot run pipeline failed\"}"}); + } + + } else if (req.type == "shutdown") { + ipc::write_message({req.id, "shutdown_ack", "{}"}); + logger::info("Worker: shutdown requested, exiting"); + break; + + } else { + // Unknown type — respond with error + ipc::write_message( + {req.id, "error", + "{\"message\":\"unknown type: " + req.type + "\"}"}); + } + } + + return 0; + } + + // ── existing subcommands ──────────────────────────────────────────────── + if (parse_cmd->parsed()) { + auto elements = html::parse(html_input); + logger::info("Parsed " + std::to_string(elements.size()) + " elements"); + for (const auto &el : elements) { + std::cout << "<" << el.tag << "> " << el.text << "\n"; + } + return 0; + } + + if (select_cmd->parsed()) { + auto matches = html::select(select_input, selector); + logger::info("Matched " + std::to_string(matches.size()) + " elements"); + for (const auto &m : matches) { + std::cout << m << "\n"; + } + return 0; + } + + if (config_cmd->parsed()) { + try { + auto tbl = toml::parse_file(config_path); + logger::info("Loaded config: " + config_path); + std::cout << tbl << "\n"; + } catch (const toml::parse_error &err) { + logger::error("TOML parse error: " + std::string(err.what())); + return 1; + } + return 0; + } + + if (fetch_cmd->parsed()) { + auto resp = http::get(fetch_url); + logger::info("HTTP " + std::to_string(resp.status_code) + " from " + + fetch_url); + if (json::is_valid(resp.body)) { + std::cout << json::prettify(resp.body) << "\n"; + } else { + std::cout << resp.body << "\n"; + } + return 0; + } + + if (json_cmd->parsed()) { + if (!json::is_valid(json_input)) { + logger::error("Invalid JSON input"); + return 1; + } + std::cout << json::prettify(json_input) << "\n"; + return 0; + } + + if (db_cmd->parsed()) { + try { + auto cfg = toml::parse_file(db_config_path); + postgres::Config pg_cfg; + pg_cfg.supabase_url = cfg["supabase"]["url"].value_or(std::string("")); + pg_cfg.supabase_key = + cfg["supabase"]["publishable_key"].value_or(std::string("")); + postgres::init(pg_cfg); + + auto status = postgres::ping(); + logger::info("Supabase: " + status); + + if (!db_table.empty()) { + auto result = postgres::query(db_table, "*", "", db_limit); + if (json::is_valid(result)) { + std::cout << json::prettify(result) << "\n"; + } else { + std::cout << result << "\n"; + } + } + } catch (const std::exception &e) { + logger::error(std::string("db error: ") + e.what()); + return 1; + } + return 0; + } + + // ── kbot subcommand ────────────────────────────────────────────────── + if (polymech::is_kbot_ai_parsed()) { + return polymech::run_cmd_kbot_ai(); + } + if (polymech::is_kbot_run_parsed()) { + return polymech::run_cmd_kbot_run(); + } + + // No subcommand — show help + std::cout << app.help() << "\n"; + return 0; +} diff --git a/packages/media/cpp/src/sys_metrics.cpp b/packages/media/cpp/src/sys_metrics.cpp new file mode 100644 index 00000000..e31d255d --- /dev/null +++ b/packages/media/cpp/src/sys_metrics.cpp @@ -0,0 +1,36 @@ +#include "sys_metrics.h" + +#ifdef _WIN32 +#define NOMINMAX +#include <windows.h> +#include <psapi.h> +#pragma comment(lib, "psapi.lib") + +namespace polymech { +size_t get_current_rss_mb() { + PROCESS_MEMORY_COUNTERS info; + if (GetProcessMemoryInfo(GetCurrentProcess(), &info, sizeof(info))) { + return (size_t)(info.WorkingSetSize) / (1024 * 1024); + } + return 0; +} + +uint64_t get_cpu_time_ms() { + FILETIME creationTime, exitTime, kernelTime, userTime; + if (GetProcessTimes(GetCurrentProcess(), &creationTime, &exitTime, &kernelTime, &userTime)) { + ULARGE_INTEGER kernel, user; + kernel.LowPart = kernelTime.dwLowDateTime; + kernel.HighPart = kernelTime.dwHighDateTime; + user.LowPart = userTime.dwLowDateTime; + user.HighPart = userTime.dwHighDateTime; + return (kernel.QuadPart + user.QuadPart) / 10000; + } + return 0; +} +} +#else +namespace polymech { +size_t get_current_rss_mb() { return 0; } +uint64_t get_cpu_time_ms() { return 0; } +} +#endif diff --git a/packages/media/cpp/src/sys_metrics.h b/packages/media/cpp/src/sys_metrics.h new file mode 100644 index 00000000..871ab6e1 --- /dev/null +++ b/packages/media/cpp/src/sys_metrics.h @@ -0,0 +1,8 @@ +#pragma once +#include <cstddef> +#include <cstdint> + +namespace polymech { +size_t get_current_rss_mb(); +uint64_t get_cpu_time_ms(); +} diff --git a/packages/media/cpp/tests/CMakeLists.txt b/packages/media/cpp/tests/CMakeLists.txt new file mode 100644 index 00000000..65369880 --- /dev/null +++ b/packages/media/cpp/tests/CMakeLists.txt @@ -0,0 +1,54 @@ +# ── Test targets ────────────────────────────────────────────────────────────── +include(CTest) +include(Catch) + +# pthread is required on Linux for Catch2 tests +find_package(Threads REQUIRED) + +# Unit tests — one per package +add_executable(test_logger unit/test_logger.cpp) +target_link_libraries(test_logger PRIVATE Catch2::Catch2WithMain logger Threads::Threads) +catch_discover_tests(test_logger) + +add_executable(test_html unit/test_html.cpp) +target_link_libraries(test_html PRIVATE Catch2::Catch2WithMain html Threads::Threads) +catch_discover_tests(test_html) + +add_executable(test_postgres unit/test_postgres.cpp) +target_link_libraries(test_postgres PRIVATE Catch2::Catch2WithMain postgres Threads::Threads) +catch_discover_tests(test_postgres) + +add_executable(test_json unit/test_json.cpp) +target_link_libraries(test_json PRIVATE Catch2::Catch2WithMain json Threads::Threads) +catch_discover_tests(test_json) + +add_executable(test_http unit/test_http.cpp) +target_link_libraries(test_http PRIVATE Catch2::Catch2WithMain http Threads::Threads) +catch_discover_tests(test_http) + +# Functional test — end-to-end CLI +add_executable(test_functional functional/test_cli.cpp) +target_link_libraries(test_functional PRIVATE Catch2::Catch2WithMain CLI11::CLI11 tomlplusplus::tomlplusplus logger html postgres http json Threads::Threads) +catch_discover_tests(test_functional) + +# E2E test — real Supabase connection (requires config/postgres.toml + network) +add_executable(test_supabase e2e/test_supabase.cpp) +target_link_libraries(test_supabase PRIVATE Catch2::Catch2WithMain tomlplusplus::tomlplusplus logger postgres json Threads::Threads) +catch_discover_tests(test_supabase WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) + +add_executable(test_polymech unit/test_polymech.cpp) +target_link_libraries(test_polymech PRIVATE Catch2::Catch2WithMain polymech postgres Threads::Threads) +catch_discover_tests(test_polymech) + +add_executable(test_cmd_kbot unit/test_cmd_kbot.cpp ../src/cmd_kbot.cpp) +target_link_libraries(test_cmd_kbot PRIVATE Catch2::Catch2WithMain CLI11::CLI11 logger tomlplusplus::tomlplusplus kbot Threads::Threads) +catch_discover_tests(test_cmd_kbot) + +# E2E test — polymech fetch_pages from live Supabase +add_executable(test_polymech_e2e e2e/test_polymech_e2e.cpp) +target_link_libraries(test_polymech_e2e PRIVATE Catch2::Catch2WithMain tomlplusplus::tomlplusplus logger postgres polymech json Threads::Threads) +catch_discover_tests(test_polymech_e2e WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) + +add_executable(test_ipc unit/test_ipc.cpp) +target_link_libraries(test_ipc PRIVATE Catch2::Catch2WithMain ipc Threads::Threads) +catch_discover_tests(test_ipc) diff --git a/packages/media/cpp/tests/e2e/test_polymech_e2e.cpp b/packages/media/cpp/tests/e2e/test_polymech_e2e.cpp new file mode 100644 index 00000000..1562dccb --- /dev/null +++ b/packages/media/cpp/tests/e2e/test_polymech_e2e.cpp @@ -0,0 +1,34 @@ +#include <catch2/catch_test_macros.hpp> +#include <toml++/toml.hpp> + +#include "logger/logger.h" +#include "polymech/polymech.h" +#include "postgres/postgres.h" +#include "json/json.h" + +// ── E2E: fetch pages from live Supabase ───────────────────────────────── + +TEST_CASE("E2E: fetch all pages", "[e2e]") { + logger::init("e2e-polymech"); + + // Load config + auto cfg = toml::parse_file("config/postgres.toml"); + postgres::Config pg_cfg; + pg_cfg.supabase_url = cfg["supabase"]["url"].value_or(std::string("")); + pg_cfg.supabase_key = + cfg["supabase"]["publishable_key"].value_or(std::string("")); + + REQUIRE(!pg_cfg.supabase_url.empty()); + REQUIRE(!pg_cfg.supabase_key.empty()); + + postgres::init(pg_cfg); + + auto result = polymech::fetch_pages(); + + // Should return valid JSON + REQUIRE(json::is_valid(result)); + + // Should be an array (even if empty) + REQUIRE(result.front() == '['); + REQUIRE(result.back() == ']'); +} diff --git a/packages/media/cpp/tests/e2e/test_supabase.cpp b/packages/media/cpp/tests/e2e/test_supabase.cpp new file mode 100644 index 00000000..9caa984d --- /dev/null +++ b/packages/media/cpp/tests/e2e/test_supabase.cpp @@ -0,0 +1,50 @@ +#include <catch2/catch_test_macros.hpp> +#include <fstream> +#include <sstream> + +#include <toml++/toml.hpp> + +#include "logger/logger.h" +#include "postgres/postgres.h" +#include "json/json.h" + + +// ── E2E: Supabase connect via config/postgres.toml ────────────────────────── + +TEST_CASE("E2E: connect to Supabase and ping", "[e2e][postgres]") { + logger::init("e2e-test"); + + // Read config — path relative to CWD (project root) + auto cfg = toml::parse_file("config/postgres.toml"); + postgres::Config pg_cfg; + pg_cfg.supabase_url = cfg["supabase"]["url"].value_or(std::string("")); + pg_cfg.supabase_key = + cfg["supabase"]["publishable_key"].value_or(std::string("")); + + REQUIRE(!pg_cfg.supabase_url.empty()); + REQUIRE(!pg_cfg.supabase_key.empty()); + + postgres::init(pg_cfg); + + auto status = postgres::ping(); + logger::info("E2E ping result: " + status); + CHECK(status == "ok"); +} + +TEST_CASE("E2E: query profiles table", "[e2e][postgres]") { + logger::init("e2e-test"); + + auto cfg = toml::parse_file("config/postgres.toml"); + postgres::Config pg_cfg; + pg_cfg.supabase_url = cfg["supabase"]["url"].value_or(std::string("")); + pg_cfg.supabase_key = + cfg["supabase"]["publishable_key"].value_or(std::string("")); + + postgres::init(pg_cfg); + + auto result = postgres::query("profiles", "id,username", "", 3); + logger::info("E2E query result: " + result); + + // Should be valid JSON array + CHECK(json::is_valid(result)); +} diff --git a/packages/media/cpp/tests/functional/test_cli.cpp b/packages/media/cpp/tests/functional/test_cli.cpp new file mode 100644 index 00000000..f0ea3e3e --- /dev/null +++ b/packages/media/cpp/tests/functional/test_cli.cpp @@ -0,0 +1,74 @@ +#include <catch2/catch_test_macros.hpp> +#include <fstream> +#include <sstream> + +#include <toml++/toml.hpp> + +#include "html/html.h" +#include "logger/logger.h" +#include "postgres/postgres.h" + +// ── Functional: full pipeline tests ───────────────────────────────────────── + +TEST_CASE("Full pipeline: parse HTML and select", "[functional]") { + const std::string input = + "<html><body>" + "<h1>Title</h1>" + "<ul><li class=\"item\">A</li><li class=\"item\">B</li></ul>" + "</body></html>"; + + // Parse should find elements + auto elements = html::parse(input); + REQUIRE(!elements.empty()); + + // Select by class should find 2 items + auto items = html::select(input, ".item"); + REQUIRE(items.size() == 2); + CHECK(items[0] == "A"); + CHECK(items[1] == "B"); +} + +TEST_CASE("Full pipeline: TOML config round-trip", "[functional]") { + // Write a temp TOML file + const std::string toml_content = "[server]\n" + "host = \"localhost\"\n" + "port = 8080\n" + "\n" + "[database]\n" + "name = \"test_db\"\n"; + + std::string tmp_path = "test_config_tmp.toml"; + { + std::ofstream out(tmp_path); + REQUIRE(out.is_open()); + out << toml_content; + } + + // Parse it + auto tbl = toml::parse_file(tmp_path); + + CHECK(tbl["server"]["host"].value_or("") == std::string("localhost")); + CHECK(tbl["server"]["port"].value_or(0) == 8080); + CHECK(tbl["database"]["name"].value_or("") == std::string("test_db")); + + // Serialize back + std::ostringstream ss; + ss << tbl; + auto serialized = ss.str(); + CHECK(serialized.find("localhost") != std::string::npos); + + // Cleanup + std::remove(tmp_path.c_str()); +} + +TEST_CASE("Full pipeline: logger + postgres integration", "[functional]") { + REQUIRE_NOTHROW(logger::init("functional-test")); + + // Init with a dummy config (no real connection) + postgres::Config cfg; + cfg.supabase_url = "https://example.supabase.co"; + cfg.supabase_key = "test-key"; + REQUIRE_NOTHROW(postgres::init(cfg)); + + REQUIRE_NOTHROW(logger::info("Functional test: postgres init ok")); +} diff --git a/packages/media/cpp/tests/unit/test_cmd_kbot.cpp b/packages/media/cpp/tests/unit/test_cmd_kbot.cpp new file mode 100644 index 00000000..9546818d --- /dev/null +++ b/packages/media/cpp/tests/unit/test_cmd_kbot.cpp @@ -0,0 +1,60 @@ +#include <catch2/catch_test_macros.hpp> +#include <CLI/CLI.hpp> +#include "../../src/cmd_kbot.h" + +using namespace polymech; + +TEST_CASE("KBot CLI AI Command Parsing", "[kbot]") { + CLI::App app{"polymech-cli"}; + auto* kbot_cmd = setup_cmd_kbot(app); + REQUIRE(kbot_cmd != nullptr); + + SECTION("Default values for kbot ai") { + int argc = 3; + const char* argv[] = {"polymech-cli", "kbot", "ai"}; + REQUIRE_NOTHROW(app.parse(argc, argv)); + + REQUIRE(is_kbot_ai_parsed() == true); + REQUIRE(is_kbot_run_parsed() == false); + // We can't access g_kbot_opts easily since it's static in cmd_kbot.cpp, + // but testing that it doesn't throw is a good start. + // In a real app we might pass options structs around instead of globals. + } + + SECTION("Arguments for kbot ai") { + int argc = 7; + const char* argv[] = { + "polymech-cli", "kbot", "ai", + "--prompt", "hello world", + "--mode", "chat" + }; + REQUIRE_NOTHROW(app.parse(argc, argv)); + REQUIRE(is_kbot_ai_parsed() == true); + } +} + +TEST_CASE("KBot CLI Run Command Parsing", "[kbot]") { + CLI::App app{"polymech-cli"}; + auto* kbot_cmd = setup_cmd_kbot(app); + REQUIRE(kbot_cmd != nullptr); + + SECTION("Default values for kbot run") { + int argc = 3; + const char* argv[] = {"polymech-cli", "kbot", "run"}; + REQUIRE_NOTHROW(app.parse(argc, argv)); + + REQUIRE(is_kbot_run_parsed() == true); + REQUIRE(is_kbot_ai_parsed() == false); + } + + SECTION("Arguments for kbot run") { + int argc = 6; + const char* argv[] = { + "polymech-cli", "kbot", "run", + "-c", "frontend-dev", + "--dry" + }; + REQUIRE_NOTHROW(app.parse(argc, argv)); + REQUIRE(is_kbot_run_parsed() == true); + } +} diff --git a/packages/media/cpp/tests/unit/test_html.cpp b/packages/media/cpp/tests/unit/test_html.cpp new file mode 100644 index 00000000..e49f8953 --- /dev/null +++ b/packages/media/cpp/tests/unit/test_html.cpp @@ -0,0 +1,452 @@ +#include <catch2/catch_test_macros.hpp> +#include <string> +#include <thread> +#include <vector> + +#include "html/html.h" +#include "html/html2md.h" + +// ═══════════════════════════════════════════════════════ +// html::parse / html::select (existing) +// ═══════════════════════════════════════════════════════ + +TEST_CASE("html::parse returns elements from valid HTML", "[html]") { + auto elements = + html::parse("<html><body><h1>Title</h1><p>Body</p></body></html>"); + + REQUIRE(elements.size() >= 2); + + bool found_h1 = false; + bool found_p = false; + for (const auto &el : elements) { + if (el.tag == "h1" && el.text == "Title") + found_h1 = true; + if (el.tag == "p" && el.text == "Body") + found_p = true; + } + CHECK(found_h1); + CHECK(found_p); +} + +TEST_CASE("html::parse returns empty for empty input", "[html]") { + auto elements = html::parse(""); + REQUIRE(elements.empty()); +} + +TEST_CASE("html::parse handles nested elements", "[html]") { + auto elements = html::parse("<div><span>Nested</span></div>"); + + bool found_span = false; + for (const auto &el : elements) { + if (el.tag == "span" && el.text == "Nested") { + found_span = true; + } + } + CHECK(found_span); +} + +TEST_CASE("html::select finds elements by CSS selector", "[html][select]") { + auto matches = html::select("<ul><li>A</li><li>B</li><li>C</li></ul>", "li"); + + REQUIRE(matches.size() == 3); + CHECK(matches[0] == "A"); + CHECK(matches[1] == "B"); + CHECK(matches[2] == "C"); +} + +TEST_CASE("html::select returns empty for no matches", "[html][select]") { + auto matches = html::select("<p>Hello</p>", "h1"); + REQUIRE(matches.empty()); +} + +TEST_CASE("html::select works with class selector", "[html][select]") { + auto matches = html::select( + R"(<div><span class="a">X</span><span class="b">Y</span></div>)", ".a"); + + REQUIRE(matches.size() == 1); + CHECK(matches[0] == "X"); +} + +// ═══════════════════════════════════════════════════════ +// html2md — conversion & large-chunk robustness +// ═══════════════════════════════════════════════════════ + +TEST_CASE("html2md basic conversion", "[html2md]") { + std::string md = html2md::Convert("<h1>Hello</h1><p>World</p>"); + CHECK(md.find("Hello") != std::string::npos); + CHECK(md.find("World") != std::string::npos); +} + +TEST_CASE("html2md empty input", "[html2md]") { + std::string md = html2md::Convert(""); + CHECK(md.empty()); +} + +TEST_CASE("html2md whitespace-only input", "[html2md]") { + std::string md = html2md::Convert(" \n\t "); + // Should return empty or whitespace — must not crash + CHECK(md.size() < 20); +} + +// ---------- large payload stress tests ---------- + +static std::string make_paragraphs(size_t count) { + std::string html; + html.reserve(count * 40); + for (size_t i = 0; i < count; ++i) { + html += "<p>Paragraph number "; + html += std::to_string(i); + html += " with some filler text.</p>\n"; + } + return html; +} + +static std::string make_large_html(size_t target_bytes) { + // Build a chunk of roughly target_bytes by repeating a row + const std::string row = "<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor.</p>\n"; + std::string html; + html.reserve(target_bytes + 256); + html += "<html><body>"; + while (html.size() < target_bytes) { + html += row; + } + html += "</body></html>"; + return html; +} + +TEST_CASE("html2md handles 64KB HTML", "[html2md][large]") { + auto html = make_large_html(64 * 1024); + REQUIRE(html.size() >= 64 * 1024); + std::string md = html2md::Convert(html); + CHECK(!md.empty()); + CHECK(md.find("Lorem ipsum") != std::string::npos); +} + +TEST_CASE("html2md handles 512KB HTML", "[html2md][large]") { + auto html = make_large_html(512 * 1024); + std::string md = html2md::Convert(html); + CHECK(!md.empty()); +} + +TEST_CASE("html2md handles 1MB HTML", "[html2md][large]") { + auto html = make_large_html(1024 * 1024); + std::string md = html2md::Convert(html); + CHECK(!md.empty()); +} + +TEST_CASE("html2md 10K paragraphs", "[html2md][large]") { + auto html = make_paragraphs(10000); + std::string md = html2md::Convert(html); + CHECK(!md.empty()); + CHECK(md.find("Paragraph number 9999") != std::string::npos); +} + +// ---------- deeply nested HTML ---------- + +TEST_CASE("html2md deeply nested divs (500 levels)", "[html2md][large]") { + const int depth = 500; + std::string html; + for (int i = 0; i < depth; ++i) html += "<div>"; + html += "deep content"; + for (int i = 0; i < depth; ++i) html += "</div>"; + + std::string md = html2md::Convert(html); + CHECK(md.find("deep content") != std::string::npos); +} + +// ---------- wide table ---------- + +TEST_CASE("html2md wide table (200 columns)", "[html2md][large]") { + std::string html = "<table><tr>"; + for (int i = 0; i < 200; ++i) { + html += "<td>C" + std::to_string(i) + "</td>"; + } + html += "</tr></table>"; + + std::string md = html2md::Convert(html); + CHECK(!md.empty()); + CHECK(md.find("C0") != std::string::npos); + CHECK(md.find("C199") != std::string::npos); +} + +// ---------- concurrent conversion ---------- + +TEST_CASE("html2md concurrent conversions are thread-safe", "[html2md][threads]") { + const int num_threads = 8; + const std::string html = make_large_html(32 * 1024); // 32KB each + std::vector<std::string> results(num_threads); + std::vector<std::thread> threads; + + for (int i = 0; i < num_threads; ++i) { + threads.emplace_back([&results, &html, i]() { + results[i] = html2md::Convert(html); + }); + } + + for (auto &t : threads) t.join(); + + for (int i = 0; i < num_threads; ++i) { + CHECK(!results[i].empty()); + CHECK(results[i].find("Lorem ipsum") != std::string::npos); + } +} + +// ═══════════════════════════════════════════════════════ +// html2md — malformed / faulty HTML robustness +// ═══════════════════════════════════════════════════════ + +TEST_CASE("html2md unclosed tags", "[html2md][faulty]") { + std::string md = html2md::Convert("<p>Hello <b>bold <i>italic"); + CHECK(md.find("Hello") != std::string::npos); + CHECK(md.find("bold") != std::string::npos); +} + +TEST_CASE("html2md mismatched/overlapping tags", "[html2md][faulty]") { + std::string md = html2md::Convert("<b>bold <i>both</b> italic</i>"); + CHECK(md.find("bold") != std::string::npos); +} + +TEST_CASE("html2md broken attributes", "[html2md][faulty]") { + std::string md = html2md::Convert(R"(<a href="http://example.com class="bad>Link</a>)"); + // must not crash — output may vary + (void)md; +} + +TEST_CASE("html2md bare text (no tags)", "[html2md][faulty]") { + std::string md = html2md::Convert("Just plain text, no HTML at all."); + CHECK(md.find("Just plain text") != std::string::npos); +} + +TEST_CASE("html2md random binary noise", "[html2md][faulty]") { + // Full 0-255 byte range — previously crashed on MSVC debug builds due to + // signed char passed to isspace() without unsigned cast. Fixed in html2md.cpp. + std::string noise(4096, '\0'); + for (size_t i = 0; i < noise.size(); ++i) { + noise[i] = static_cast<char>((i * 131 + 17) % 256); + } + std::string md = html2md::Convert(noise); + // No assertion on content — just survival + (void)md; +} + +TEST_CASE("html2md truncated document", "[html2md][faulty]") { + std::string html = "<html><body><table><tr><td>Cell1</td><td>Cell2"; + // abruptly ends mid-table + std::string md = html2md::Convert(html); + CHECK(md.find("Cell1") != std::string::npos); +} + +TEST_CASE("html2md script and style tags", "[html2md][faulty]") { + std::string html = R"( + <p>Before</p> + <script>alert('xss');</script> + <style>.foo { color: red; }</style> + <p>After</p> + )"; + std::string md = html2md::Convert(html); + CHECK(md.find("Before") != std::string::npos); + CHECK(md.find("After") != std::string::npos); + // script/style content should be stripped + CHECK(md.find("alert") == std::string::npos); +} + +TEST_CASE("html2md null bytes in input", "[html2md][faulty]") { + std::string html = "<p>Hello"; + html += '\0'; + html += "World</p>"; + // html2md may stop at null or handle it — must not crash + std::string md = html2md::Convert(html); + (void)md; +} + +// ═══════════════════════════════════════════════════════ +// html2md — web scraper real-world edge cases +// ═══════════════════════════════════════════════════════ + +TEST_CASE("html2md UTF-8 multibyte (CJK, Arabic, emoji)", "[html2md][scraper]") { + std::string html = + "<h1>日本語テスト</h1>" + "<p>مرحبا بالعالم</p>" + "<p>Ñoño señor über straße</p>" + "<p>Emoji: 🚀🔥💀👻 and 中文混合English</p>"; + std::string md = html2md::Convert(html); + CHECK(md.find("Emoji") != std::string::npos); +} + +TEST_CASE("html2md BOM prefix", "[html2md][scraper]") { + // UTF-8 BOM (EF BB BF) prepended — common from Windows-origin pages + std::string html = "\xEF\xBB\xBF<html><body><p>Content after BOM</p></body></html>"; + std::string md = html2md::Convert(html); + CHECK(md.find("Content after BOM") != std::string::npos); +} + +TEST_CASE("html2md entity soup", "[html2md][scraper]") { + std::string html = + "<p>Price: €10 & <20> items</p>" + "<p>   indented — dashes – more</p>" + "<p>Bad entity: ¬real; and 󴈿 and &#xZZZZ;</p>"; + std::string md = html2md::Convert(html); + CHECK(md.find("Price") != std::string::npos); +} + +TEST_CASE("html2md CDATA and comments", "[html2md][scraper]") { + std::string html = + "<p>Before</p>" + "<!-- <script>alert('xss')</script> -->" + "<![CDATA[This is raw <data> & stuff]]>" + "<!-- multi\nline\ncomment -->" + "<p>After</p>"; + std::string md = html2md::Convert(html); + CHECK(md.find("Before") != std::string::npos); + CHECK(md.find("After") != std::string::npos); +} + +TEST_CASE("html2md deeply nested inline tags", "[html2md][scraper]") { + // Real pages sometimes have insanely nested spans from WYSIWYG editors + std::string html = "<p>"; + for (int i = 0; i < 100; ++i) html += "<span><b><i><em><strong>"; + html += "deep text"; + for (int i = 0; i < 100; ++i) html += "</strong></em></i></b></span>"; + html += "</p>"; + std::string md = html2md::Convert(html); + // 100 layers of bold/italic produce tons of ** and * markers — + // just verify no crash and non-empty output + CHECK(!md.empty()); +} + +TEST_CASE("html2md huge single line (no newlines)", "[html2md][scraper]") { + // Minified HTML — one giant line, 200KB + std::string html; + html.reserve(200 * 1024); + html += "<html><body>"; + for (int i = 0; i < 5000; ++i) { + html += "<div><span class=\"c" + std::to_string(i) + "\">item" + + std::to_string(i) + "</span></div>"; + } + html += "</body></html>"; + std::string md = html2md::Convert(html); + CHECK(md.find("item0") != std::string::npos); + CHECK(md.find("item4999") != std::string::npos); +} + +TEST_CASE("html2md data URI in img src", "[html2md][scraper]") { + std::string html = + "<p>Before image</p>" + "<img src=\"data:image/png;base64,iVBORw0KGgoAAAANSU" + "hEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwAD" + "hgGAWjR9awAAAABJRU5ErkJggg==\" alt=\"pixel\">" + "<p>After image</p>"; + std::string md = html2md::Convert(html); + CHECK(md.find("Before image") != std::string::npos); + CHECK(md.find("After image") != std::string::npos); +} + +TEST_CASE("html2md mixed Latin-1 and UTF-8 bytes", "[html2md][scraper]") { + // Latin-1 encoded chars (0x80-0xFF) that are NOT valid UTF-8 + // Common when scraping pages with wrong charset declaration + std::string html = "<p>caf\xe9 na\xefve r\xe9sum\xe9</p>"; // café naïve résumé in Latin-1 + std::string md = html2md::Convert(html); + CHECK(md.find("caf") != std::string::npos); +} + +TEST_CASE("html2md HTML with HTTP headers prepended", "[html2md][scraper]") { + // Sometimes raw HTTP responses leak into scraper output + std::string html = + "HTTP/1.1 200 OK\r\n" + "Content-Type: text/html; charset=utf-8\r\n" + "Content-Length: 42\r\n" + "\r\n" + "<html><body><p>Real content</p></body></html>"; + std::string md = html2md::Convert(html); + CHECK(md.find("Real content") != std::string::npos); +} + +TEST_CASE("html2md Google Maps / Places markup soup", "[html2md][scraper]") { + // Simplified version of real Google Places HTML with data attributes, + // inline styles, aria labels, and deeply nested structure + std::string html = R"( + <div class="section-result" data-result-index="0" jsaction="pane.resultSection.click"> + <div class="section-result-title"> + <span><span>Müller's Büro & Café</span></span> + </div> + <div class="section-result-details"> + <span class="section-result-location">Königstraße 42, München</span> + <span class="section-result-rating"> + <span aria-label="4.5 stars">★★★★☆</span> + <span>(1,234)</span> + </span> + </div> + <div style="display:none" aria-hidden="true"> + <script type="application/ld+json">{"@type":"LocalBusiness","name":"test"}</script> + </div> + </div> + )"; + std::string md = html2md::Convert(html); + CHECK(md.find("Café") != std::string::npos); + CHECK(md.find("München") != std::string::npos); +} + +// ═══════════════════════════════════════════════════════ +// html2md — output amplification & pathological input +// ═══════════════════════════════════════════════════════ + +TEST_CASE("html2md nested blockquotes (output amplification)", "[html2md][amplification]") { + // Each <blockquote> nesting adds a ">" prefix per line in markdown. + // 50 deep = each line gets 50 ">" prefixes — tests that output doesn't + // explode exponentially. + std::string html; + for (int i = 0; i < 50; ++i) html += "<blockquote>"; + html += "<p>deep quote</p>"; + for (int i = 0; i < 50; ++i) html += "</blockquote>"; + auto md = html2md::Convert(html); + // Output size should be reasonable — not exponential. + // 50 levels * "> " prefix = ~100 chars + text < 1 KB + CHECK(md.size() < 4096); + CHECK(!md.empty()); +} + +TEST_CASE("html2md very long attribute value", "[html2md][amplification]") { + // 1 MB href — tests ExtractAttributeFromTagLeftOf won't choke + std::string long_url(1024 * 1024, 'A'); + std::string html = "<a href=\"" + long_url + "\">Click</a>"; + auto md = html2md::Convert(html); + // Must survive without crash + CHECK(!md.empty()); +} + +TEST_CASE("html2md 10K unclosed p tags", "[html2md][amplification]") { + // Each unclosed <p> generates "\n\n" — tests that md_ doesn't + // grow beyond reasonable bounds + std::string html; + html.reserve(50000); + for (int i = 0; i < 10000; ++i) html += "<p>text"; + auto md = html2md::Convert(html); + CHECK(!md.empty()); + // Should contain the text, output gets big but not catastrophic + CHECK(md.find("text") != std::string::npos); +} + +TEST_CASE("html2md output-to-input ratio check", "[html2md][amplification]") { + // Verify that for normal, representative HTML, output is smaller + // than input (html2md strips tags, so markdown should be leaner) + std::string html; + html.reserve(100 * 1024); + html += "<html><body>"; + for (int i = 0; i < 1000; ++i) { + html += "<div class=\"wrapper\"><p class=\"content\">Paragraph " + + std::to_string(i) + " with some text.</p></div>\n"; + } + html += "</body></html>"; + auto md = html2md::Convert(html); + // Markdown should be smaller than HTML (we stripped all the divs/classes) + CHECK(md.size() < html.size()); + CHECK(md.size() > 0); +} + +TEST_CASE("html2md pathological repeated angle brackets", "[html2md][amplification]") { + // Incomplete tags: lots of "<" without closing ">" — stresses tag parser + std::string html(8192, '<'); + auto md = html2md::Convert(html); + // Must not infinite-loop — just survive + (void)md; +} diff --git a/packages/media/cpp/tests/unit/test_http.cpp b/packages/media/cpp/tests/unit/test_http.cpp new file mode 100644 index 00000000..3ef5e234 --- /dev/null +++ b/packages/media/cpp/tests/unit/test_http.cpp @@ -0,0 +1,17 @@ +#include <catch2/catch_test_macros.hpp> + +#include "http/http.h" + +TEST_CASE("http::get returns a response", "[http]") { + // This test requires network, so we test error handling for invalid URL + auto resp = http::get("http://0.0.0.0:1/nonexistent"); + // Should fail gracefully with status -1 + CHECK(resp.status_code == -1); + CHECK(!resp.body.empty()); +} + +TEST_CASE("http::post returns a response", "[http]") { + auto resp = http::post("http://0.0.0.0:1/nonexistent", R"({"test": true})"); + CHECK(resp.status_code == -1); + CHECK(!resp.body.empty()); +} diff --git a/packages/media/cpp/tests/unit/test_ipc.cpp b/packages/media/cpp/tests/unit/test_ipc.cpp new file mode 100644 index 00000000..b2e98c11 --- /dev/null +++ b/packages/media/cpp/tests/unit/test_ipc.cpp @@ -0,0 +1,89 @@ +#include <catch2/catch_test_macros.hpp> + +#include "ipc/ipc.h" + +#include <cstring> + +TEST_CASE("ipc::encode produces a 4-byte LE length prefix", "[ipc]") { + ipc::Message msg{"1", "ping", "{}"}; + auto frame = ipc::encode(msg); + + REQUIRE(frame.size() > 4); + + // First 4 bytes are the LE length of the JSON body + uint32_t body_len = static_cast<uint32_t>(frame[0]) | + (static_cast<uint32_t>(frame[1]) << 8) | + (static_cast<uint32_t>(frame[2]) << 16) | + (static_cast<uint32_t>(frame[3]) << 24); + + REQUIRE(body_len == frame.size() - 4); +} + +TEST_CASE("ipc::encode → decode round-trip", "[ipc]") { + ipc::Message original{"42", "job", R"({"action":"resize","width":800})"}; + auto frame = ipc::encode(original); + + // Strip the 4-byte length prefix for decode + ipc::Message decoded; + bool ok = ipc::decode(frame.data() + 4, frame.size() - 4, decoded); + + REQUIRE(ok); + REQUIRE(decoded.id == "42"); + REQUIRE(decoded.type == "job"); + // payload should round-trip (may be compacted) + REQUIRE(decoded.payload.find("resize") != std::string::npos); + REQUIRE(decoded.payload.find("800") != std::string::npos); +} + +TEST_CASE("ipc::decode rejects invalid JSON", "[ipc]") { + std::string garbage = "this is not json"; + ipc::Message out; + bool ok = ipc::decode(reinterpret_cast<const uint8_t *>(garbage.data()), + garbage.size(), out); + REQUIRE_FALSE(ok); +} + +TEST_CASE("ipc::decode rejects JSON missing required fields", "[ipc]") { + // Valid JSON but missing "id" and "type" + std::string json = R"({"foo":"bar"})"; + ipc::Message out; + bool ok = ipc::decode(reinterpret_cast<const uint8_t *>(json.data()), + json.size(), out); + REQUIRE_FALSE(ok); +} + +TEST_CASE("ipc::decode handles missing payload gracefully", "[ipc]") { + std::string json = R"({"id":"1","type":"ping"})"; + ipc::Message out; + bool ok = ipc::decode(reinterpret_cast<const uint8_t *>(json.data()), + json.size(), out); + REQUIRE(ok); + REQUIRE(out.id == "1"); + REQUIRE(out.type == "ping"); + REQUIRE(out.payload == "{}"); +} + +TEST_CASE("ipc::encode with empty payload", "[ipc]") { + ipc::Message msg{"0", "ready", ""}; + auto frame = ipc::encode(msg); + + ipc::Message decoded; + bool ok = ipc::decode(frame.data() + 4, frame.size() - 4, decoded); + + REQUIRE(ok); + REQUIRE(decoded.id == "0"); + REQUIRE(decoded.type == "ready"); +} + +TEST_CASE("ipc::decode with vector overload", "[ipc]") { + std::string json = R"({"id":"99","type":"shutdown","payload":{}})"; + std::vector<uint8_t> data(json.begin(), json.end()); + + ipc::Message out; + bool ok = ipc::decode(data, out); + + REQUIRE(ok); + REQUIRE(out.id == "99"); + REQUIRE(out.type == "shutdown"); + REQUIRE(out.payload == "{}"); +} diff --git a/packages/media/cpp/tests/unit/test_json.cpp b/packages/media/cpp/tests/unit/test_json.cpp new file mode 100644 index 00000000..1b726609 --- /dev/null +++ b/packages/media/cpp/tests/unit/test_json.cpp @@ -0,0 +1,46 @@ +#include <catch2/catch_test_macros.hpp> + +#include "json/json.h" + +TEST_CASE("json::is_valid accepts valid JSON", "[json]") { + CHECK(json::is_valid(R"({"key": "value"})")); + CHECK(json::is_valid("[]")); + CHECK(json::is_valid("123")); + CHECK(json::is_valid("\"hello\"")); +} + +TEST_CASE("json::is_valid rejects invalid JSON", "[json]") { + CHECK_FALSE(json::is_valid("{invalid}")); + CHECK_FALSE(json::is_valid("{key: value}")); +} + +TEST_CASE("json::get_string extracts string values", "[json]") { + auto val = + json::get_string(R"({"name": "polymech", "version": "1.0"})", "name"); + CHECK(val == "polymech"); +} + +TEST_CASE("json::get_string returns empty for missing key", "[json]") { + auto val = json::get_string(R"({"name": "polymech"})", "missing"); + CHECK(val.empty()); +} + +TEST_CASE("json::get_int extracts int values", "[json]") { + auto val = json::get_int(R"({"port": 8080, "name": "test"})", "port"); + CHECK(val == 8080); +} + +TEST_CASE("json::keys lists top-level keys", "[json]") { + auto k = json::keys(R"({"a": 1, "b": 2, "c": 3})"); + REQUIRE(k.size() == 3); + CHECK(k[0] == "a"); + CHECK(k[1] == "b"); + CHECK(k[2] == "c"); +} + +TEST_CASE("json::prettify formats JSON", "[json]") { + auto pretty = json::prettify(R"({"a":1})"); + REQUIRE(!pretty.empty()); + // Pretty output should contain newlines + CHECK(pretty.find('\n') != std::string::npos); +} diff --git a/packages/media/cpp/tests/unit/test_logger.cpp b/packages/media/cpp/tests/unit/test_logger.cpp new file mode 100644 index 00000000..b2e8f4da --- /dev/null +++ b/packages/media/cpp/tests/unit/test_logger.cpp @@ -0,0 +1,22 @@ +#include <catch2/catch_test_macros.hpp> + +#include "logger/logger.h" + +TEST_CASE("logger::init does not throw", "[logger]") { + REQUIRE_NOTHROW(logger::init("test")); +} + +TEST_CASE("logger functions do not throw after init", "[logger]") { + logger::init("test"); + + REQUIRE_NOTHROW(logger::info("info message")); + REQUIRE_NOTHROW(logger::warn("warn message")); + REQUIRE_NOTHROW(logger::error("error message")); + REQUIRE_NOTHROW(logger::debug("debug message")); +} + +TEST_CASE("logger::init can be called multiple times", "[logger]") { + REQUIRE_NOTHROW(logger::init("first")); + REQUIRE_NOTHROW(logger::init("second")); + REQUIRE_NOTHROW(logger::info("after re-init")); +} diff --git a/packages/media/cpp/tests/unit/test_polymech.cpp b/packages/media/cpp/tests/unit/test_polymech.cpp new file mode 100644 index 00000000..15a8098a --- /dev/null +++ b/packages/media/cpp/tests/unit/test_polymech.cpp @@ -0,0 +1,10 @@ +#include "polymech/polymech.h" +#include "postgres/postgres.h" +#include <catch2/catch_test_macros.hpp> + + +// Unit test — no network required +TEST_CASE("polymech::fetch_pages throws without init", "[polymech]") { + // postgres::init has not been called, so fetch_pages should throw + REQUIRE_THROWS(polymech::fetch_pages()); +} diff --git a/packages/media/cpp/tests/unit/test_postgres.cpp b/packages/media/cpp/tests/unit/test_postgres.cpp new file mode 100644 index 00000000..d839e92e --- /dev/null +++ b/packages/media/cpp/tests/unit/test_postgres.cpp @@ -0,0 +1,9 @@ +#include <catch2/catch_test_macros.hpp> + +#include "postgres/postgres.h" + +// Unit tests use a no-op init — no network required +TEST_CASE("postgres::ping throws without init", "[postgres]") { + // If called without init, should throw + CHECK_THROWS(postgres::ping()); +}