From 35c6439dea3b71366a971c40cbb334d6c651f606 Mon Sep 17 00:00:00 2001 From: JP Maia Date: Sat, 7 Mar 2026 09:23:39 -0300 Subject: [PATCH] Update README and examples for aiXplainKit v2 - Enhanced README with a new quick start guide, installation instructions, and detailed API key setup options. - Added multiple example files demonstrating usage of models, agents, tools, indexing, error handling, and advanced agent features. - Updated .gitignore to exclude new documentation files. - Introduced a new `Aixplain` class as the main entry point for the SDK, replacing the previous `AiXplainKit` class. --- .gitignore | 1 + Examples/01-QuickStart.swift | 31 + Examples/02-Models.swift | 39 + Examples/03-Agents.swift | 57 ++ Examples/04-Tools.swift | 62 ++ Examples/05-Index.swift | 64 ++ Examples/06-ErrorHandling.swift | 65 ++ Examples/07-TeamAgents.swift | 49 ++ Examples/08-AdvancedAgent.swift | 75 ++ README.md | 142 +++- Sources/aiXplainKit/Agents/Agent.swift | 377 ++++++++++ .../aiXplainKit/Agents/AgentRunResult.swift | 62 ++ Sources/aiXplainKit/Agents/AgentTask.swift | 25 + .../Agents/ConversationMessage.swift | 32 + Sources/aiXplainKit/Agents/OutputFormat.swift | 10 + Sources/aiXplainKit/Aixplain.swift | 46 ++ .../Auth/AuthenticationScheme.swift | 18 + Sources/aiXplainKit/Auth/Credential.swift | 64 ++ .../aiXplainKit/Client/AixplainClient.swift | 174 +++++ .../Client/ClientConfiguration.swift | 28 + Sources/aiXplainKit/Client/HTTPMethod.swift | 13 + Sources/aiXplainKit/Client/Response.swift | 21 + Sources/aiXplainKit/Client/RetryPolicy.swift | 21 + Sources/aiXplainKit/Enums/AIFunction.swift | 17 + Sources/aiXplainKit/Enums/AnyCodable.swift | 76 ++ Sources/aiXplainKit/Enums/AssetStatus.swift | 25 + .../aiXplainKit/Enums/ResponseStatus.swift | 10 + Sources/aiXplainKit/Enums/Supplier.swift | 14 + Sources/aiXplainKit/Enums/ToolType.swift | 11 + Sources/aiXplainKit/Errors/APIError.swift | 86 +++ Sources/aiXplainKit/Errors/Agents+error.swift | 87 --- .../aiXplainKit/Errors/AixplainError.swift | 33 + Sources/aiXplainKit/Errors/AuthError.swift | 19 + Sources/aiXplainKit/Errors/File+Error.swift | 52 -- .../aiXplainKit/Errors/FileUploadError.swift | 16 + Sources/aiXplainKit/Errors/Model+Error.swift | 107 --- .../aiXplainKit/Errors/Networking+Error.swift | 57 -- .../aiXplainKit/Errors/Pipeline+Error.swift | 77 -- .../aiXplainKit/Errors/ResourceError.swift | 14 + Sources/aiXplainKit/Errors/TimeoutError.swift | 18 + .../aiXplainKit/Errors/ValidationError.swift | 14 + .../aiXplainKit/Extensions/URL+MimeType.swift | 60 -- .../aiXplainKit/Index/EmbeddingModel.swift | 53 ++ Sources/aiXplainKit/Index/Index.swift | 193 +++++ Sources/aiXplainKit/Index/IndexFilter.swift | 71 ++ .../aiXplainKit/Index/IndexSearchResult.swift | 52 ++ Sources/aiXplainKit/Index/Record.swift | 70 ++ .../aiXplainKit/Manager/APIKeyManager.swift | 117 --- .../Manager/FileManager/File+SizeLimit.swift | 88 --- .../Manager/FileManager/FileManager.swift | 244 ------- Sources/aiXplainKit/Models/Model.swift | 267 +++++++ Sources/aiXplainKit/Models/ModelResult.swift | 73 ++ Sources/aiXplainKit/Models/ModelTypes.swift | 57 ++ .../Modules/Agents/Agents+CRUD.swift | 149 ---- .../aiXplainKit/Modules/Agents/Agents.swift | 314 -------- .../Modules/Agents/Input/AgentInputable.swift | 11 - .../Agents/Input/Data+AgentInputable.swift | 14 - .../Input/Dictonary+AgentInputable.swift | 68 -- .../Agents/Input/String+AgentInputable.swift | 52 -- .../Agents/Input/URL+AgentInputable.swift | 107 --- .../Agents/Tools/CreateAgentTool.swift | 57 -- .../Agents/Tools/Model+AgentUsable.swift | 13 - .../Agents/Tools/Pipeline+AgentUsable.swift | 16 - .../Modules/Agents/Tools/Tool.swift | 88 --- .../Tools/UtilityModel+AgentUsable.swift | 13 - Sources/aiXplainKit/Modules/Asset/Asset.swift | 60 -- .../aiXplainKit/Modules/Asset/Function.swift | 12 - .../aiXplainKit/Modules/Asset/License.swift | 33 - .../aiXplainKit/Modules/Asset/Pricing.swift | 49 -- .../aiXplainKit/Modules/Asset/Privacy.swift | 36 - .../aiXplainKit/Modules/Asset/Suplier.swift | 59 -- .../aiXplainKit/Modules/Asset/Version.swift | 13 - .../Modules/Index/EmbeddingModel.swift | 89 --- .../Modules/Index/IndexFilter.swift | 135 ---- .../Modules/Index/IndexModel.swift | 236 ------ .../Modules/Index/IndexerModel.swift | 25 - .../aiXplainKit/Modules/Index/Record.swift | 139 ---- .../Modules/Model/Input/ModelInput+Data.swift | 13 - .../Model/Input/ModelInput+Dictonary.swift | 56 -- .../Model/Input/ModelInput+Record.swift | 20 - .../Model/Input/ModelInput+String.swift | 39 - .../Modules/Model/Input/ModelInput+URL.swift | 57 -- .../Modules/Model/Input/ModelInput.swift | 32 - .../Modules/Model/Input/ModelParameter.swift | 43 -- .../Input/UtilityModelInputInformation.swift | 78 -- Sources/aiXplainKit/Modules/Model/Model.swift | 285 -------- .../Modules/Model/Query/ModelQuery.swift | 134 ---- .../aiXplainKit/Modules/Model/Utility.swift | 339 --------- .../Agent/Agent+RunParameters.swift | 114 --- .../Model/Model+RunParameters.swift | 48 -- .../NetworkingParametersProtocol.swift | 36 - .../Pipeline/Pipeline+RunParameters.swift | 41 -- .../Modules/Parameters/RunParameters.swift | 42 -- .../Input/PipelineInput+Dictionary.swift | 66 -- .../Pipeline/Input/PipelineInput+String.swift | 40 - .../Pipeline/Input/PipelineInput+URL.swift | 56 -- .../Pipeline/Input/PipelineInput.swift | 33 - .../Modules/Pipeline/Pipeline.swift | 187 ----- .../Modules/Pipeline/PipelineNode.swift | 48 -- .../Modules/TeamAgents/TeamAgent+CRUD.swift | 84 --- .../Modules/TeamAgents/TeamAgent.swift | 370 ---------- .../Networking/Networking+Endpoint.swift | 111 --- .../Networking/Networking+Metadata.swift | 64 -- .../aiXplainKit/Networking/Networking.swift | 166 ----- .../AgentExecuteResponse.swift | 20 - .../ResponseDecoders/AgentOutput.swift | 264 ------- .../FunctionListResponse.swift | 5 - .../ResponseDecoders/IndexSearchOutput.swift | 45 -- .../ModelExecuteResponse.swift | 41 -- .../ResponseDecoders/ModelOutput.swift | 74 -- .../PipelineExecuteResponse.swift | 30 - .../ResponseDecoders/PipelineOutput.swift | 65 -- .../Agent/AgentProvider+BuildAgents.swift | 93 --- .../Provider/Agent/AgentProvider.swift | 205 ------ .../Provider/Indexing/IndexProvider.swift | 70 -- .../Model/ModelProvider+Utility.swift | 96 --- .../Provider/Model/ModelProvider.swift | 213 ------ .../Provider/PipelineProvider.swift | 92 --- .../TeamAgentProvider+BuildTeamAgent.swift | 69 -- .../TeamAgent/TeamAgentProvider.swift | 109 --- .../Resources/AgentToolConvertible.swift | 9 + .../aiXplainKit/Resources/AgentToolDict.swift | 42 ++ .../aiXplainKit/Resources/BaseResource.swift | 259 +++++++ Sources/aiXplainKit/Resources/Page.swift | 21 + Sources/aiXplainKit/Resources/RunResult.swift | 46 ++ Sources/aiXplainKit/Tools/Action.swift | 92 +++ Sources/aiXplainKit/Tools/Integration.swift | 93 +++ Sources/aiXplainKit/Tools/Tool.swift | 136 ++++ .../Essential/DiscoverEssential.md | 20 - .../Essential/PipelineEssential.md | 33 - .../Essential/TeamAPIKeyGuide.md | 38 - .../Resources/NavigateAPIKey.png | Bin 99855 -> 0 bytes .../TextToTextPipeline2.swift | 6 - .../TextToTextPipeline3.swift | 7 - .../TextToTextPipeline4.swift | 21 - .../DiscoverExample.swift | 1 - .../DiscoverExample2.swift | 3 - .../DiscoverExample3.swift | 5 - .../DiscoverExample4.swift | 9 - .../DiscoverExample5.swift | 7 - .../DiscoverExample6.swift | 10 - .../TextToTextPipeline1.swift | 4 - .../aiXplain101/TextToTextModel.tutorial | 66 -- .../aiXplain101/TextToTextPipeline.tutorial | 74 -- .../aiXplain101/aiXplain101.tutorial | 14 - .../aiXplainKit.docc/aiXplainKit.md | 50 +- Sources/aiXplainKit/aiXplainKit.swift | 30 - .../aiXplainKitTests/E2E/AgentE2ETests.swift | 94 +++ .../aiXplainKitTests/E2E/ClientE2ETests.swift | 81 +++ .../aiXplainKitTests/E2E/IndexE2ETests.swift | 95 +++ .../aiXplainKitTests/E2E/ModelE2ETests.swift | 79 ++ Tests/aiXplainKitTests/E2E/ToolE2ETests.swift | 70 ++ .../Modules/Agents/AgentBuildingTests.swift | 140 ---- .../Modules/Agents/AgentOutputTests.swift | 540 -------------- .../Modules/Agents/AgentsFunctionTests.swift | 215 ------ .../Modules/Model/ModelFunctionalTests.swift | 119 --- .../Model/ModelUtilityFunctionTests.swift | 111 --- .../Modules/PipelineFunctionalTests.swift | 81 --- .../AgentsProviderFunctionalTests.swift | 39 - .../ModelProviderFunctionalTests.swift | 47 -- .../Unit/Agents/AgentTests.swift | 193 +++++ .../Unit/Auth/CredentialTests.swift | 136 ++++ .../Unit/Client/ClientTests.swift | 188 +++++ .../Unit/Errors/ErrorTests.swift | 181 +++++ .../Unit/Index/IndexTests.swift | 154 ++++ .../APIKeyManager+ReloadState.swift | 22 - .../Unit/MockServices/MockNetworking.swift | 57 -- .../Unit/MockServices/MockResponses.swift | 16 - .../Unit/Models/ModelTests.swift | 108 +++ .../Unit/Modules/ModelTests.swift | 117 --- .../Unit/Networking/NetworkingTests.swift | 59 -- .../Unit/Provider/ModelProviderTests.swift | 58 -- .../Unit/Resources/ResourceTests.swift | 269 +++++++ .../Unit/Tools/ToolTests.swift | 120 +++ .../XCTestCase+TempFile.swift | 37 - docs/rfcs/README.md | 97 +++ docs/rfcs/RFC-0001-auth-and-credentials.md | 230 ++++++ ...0002-client-configuration-and-transport.md | 406 +++++++++++ .../RFC-0003-agents-v2-api-and-lifecycle.md | 687 ++++++++++++++++++ ...C-0004-resources-tools-schema-alignment.md | 510 +++++++++++++ ...RFC-0005-error-model-and-contract-tests.md | 463 ++++++++++++ ...FC-0006-clean-slate-implementation-plan.md | 297 ++++++++ docs/rfcs/RFC-0007-models-v2-api.md | 461 ++++++++++++ .../RFC-0008-tools-and-integrations-v2-api.md | 440 +++++++++++ docs/rfcs/RFC-0009-index-and-search-v2-api.md | 330 +++++++++ 185 files changed, 9071 insertions(+), 8807 deletions(-) create mode 100644 Examples/01-QuickStart.swift create mode 100644 Examples/02-Models.swift create mode 100644 Examples/03-Agents.swift create mode 100644 Examples/04-Tools.swift create mode 100644 Examples/05-Index.swift create mode 100644 Examples/06-ErrorHandling.swift create mode 100644 Examples/07-TeamAgents.swift create mode 100644 Examples/08-AdvancedAgent.swift create mode 100644 Sources/aiXplainKit/Agents/Agent.swift create mode 100644 Sources/aiXplainKit/Agents/AgentRunResult.swift create mode 100644 Sources/aiXplainKit/Agents/AgentTask.swift create mode 100644 Sources/aiXplainKit/Agents/ConversationMessage.swift create mode 100644 Sources/aiXplainKit/Agents/OutputFormat.swift create mode 100644 Sources/aiXplainKit/Aixplain.swift create mode 100644 Sources/aiXplainKit/Auth/AuthenticationScheme.swift create mode 100644 Sources/aiXplainKit/Auth/Credential.swift create mode 100644 Sources/aiXplainKit/Client/AixplainClient.swift create mode 100644 Sources/aiXplainKit/Client/ClientConfiguration.swift create mode 100644 Sources/aiXplainKit/Client/HTTPMethod.swift create mode 100644 Sources/aiXplainKit/Client/Response.swift create mode 100644 Sources/aiXplainKit/Client/RetryPolicy.swift create mode 100644 Sources/aiXplainKit/Enums/AIFunction.swift create mode 100644 Sources/aiXplainKit/Enums/AnyCodable.swift create mode 100644 Sources/aiXplainKit/Enums/AssetStatus.swift create mode 100644 Sources/aiXplainKit/Enums/ResponseStatus.swift create mode 100644 Sources/aiXplainKit/Enums/Supplier.swift create mode 100644 Sources/aiXplainKit/Enums/ToolType.swift create mode 100644 Sources/aiXplainKit/Errors/APIError.swift delete mode 100644 Sources/aiXplainKit/Errors/Agents+error.swift create mode 100644 Sources/aiXplainKit/Errors/AixplainError.swift create mode 100644 Sources/aiXplainKit/Errors/AuthError.swift delete mode 100644 Sources/aiXplainKit/Errors/File+Error.swift create mode 100644 Sources/aiXplainKit/Errors/FileUploadError.swift delete mode 100644 Sources/aiXplainKit/Errors/Model+Error.swift delete mode 100644 Sources/aiXplainKit/Errors/Networking+Error.swift delete mode 100644 Sources/aiXplainKit/Errors/Pipeline+Error.swift create mode 100644 Sources/aiXplainKit/Errors/ResourceError.swift create mode 100644 Sources/aiXplainKit/Errors/TimeoutError.swift create mode 100644 Sources/aiXplainKit/Errors/ValidationError.swift delete mode 100644 Sources/aiXplainKit/Extensions/URL+MimeType.swift create mode 100644 Sources/aiXplainKit/Index/EmbeddingModel.swift create mode 100644 Sources/aiXplainKit/Index/Index.swift create mode 100644 Sources/aiXplainKit/Index/IndexFilter.swift create mode 100644 Sources/aiXplainKit/Index/IndexSearchResult.swift create mode 100644 Sources/aiXplainKit/Index/Record.swift delete mode 100644 Sources/aiXplainKit/Manager/APIKeyManager.swift delete mode 100644 Sources/aiXplainKit/Manager/FileManager/File+SizeLimit.swift delete mode 100644 Sources/aiXplainKit/Manager/FileManager/FileManager.swift create mode 100644 Sources/aiXplainKit/Models/Model.swift create mode 100644 Sources/aiXplainKit/Models/ModelResult.swift create mode 100644 Sources/aiXplainKit/Models/ModelTypes.swift delete mode 100644 Sources/aiXplainKit/Modules/Agents/Agents+CRUD.swift delete mode 100644 Sources/aiXplainKit/Modules/Agents/Agents.swift delete mode 100644 Sources/aiXplainKit/Modules/Agents/Input/AgentInputable.swift delete mode 100644 Sources/aiXplainKit/Modules/Agents/Input/Data+AgentInputable.swift delete mode 100644 Sources/aiXplainKit/Modules/Agents/Input/Dictonary+AgentInputable.swift delete mode 100644 Sources/aiXplainKit/Modules/Agents/Input/String+AgentInputable.swift delete mode 100644 Sources/aiXplainKit/Modules/Agents/Input/URL+AgentInputable.swift delete mode 100644 Sources/aiXplainKit/Modules/Agents/Tools/CreateAgentTool.swift delete mode 100644 Sources/aiXplainKit/Modules/Agents/Tools/Model+AgentUsable.swift delete mode 100644 Sources/aiXplainKit/Modules/Agents/Tools/Pipeline+AgentUsable.swift delete mode 100644 Sources/aiXplainKit/Modules/Agents/Tools/Tool.swift delete mode 100644 Sources/aiXplainKit/Modules/Agents/Tools/UtilityModel+AgentUsable.swift delete mode 100644 Sources/aiXplainKit/Modules/Asset/Asset.swift delete mode 100644 Sources/aiXplainKit/Modules/Asset/Function.swift delete mode 100644 Sources/aiXplainKit/Modules/Asset/License.swift delete mode 100644 Sources/aiXplainKit/Modules/Asset/Pricing.swift delete mode 100644 Sources/aiXplainKit/Modules/Asset/Privacy.swift delete mode 100644 Sources/aiXplainKit/Modules/Asset/Suplier.swift delete mode 100644 Sources/aiXplainKit/Modules/Asset/Version.swift delete mode 100644 Sources/aiXplainKit/Modules/Index/EmbeddingModel.swift delete mode 100644 Sources/aiXplainKit/Modules/Index/IndexFilter.swift delete mode 100644 Sources/aiXplainKit/Modules/Index/IndexModel.swift delete mode 100644 Sources/aiXplainKit/Modules/Index/IndexerModel.swift delete mode 100644 Sources/aiXplainKit/Modules/Index/Record.swift delete mode 100644 Sources/aiXplainKit/Modules/Model/Input/ModelInput+Data.swift delete mode 100644 Sources/aiXplainKit/Modules/Model/Input/ModelInput+Dictonary.swift delete mode 100644 Sources/aiXplainKit/Modules/Model/Input/ModelInput+Record.swift delete mode 100644 Sources/aiXplainKit/Modules/Model/Input/ModelInput+String.swift delete mode 100644 Sources/aiXplainKit/Modules/Model/Input/ModelInput+URL.swift delete mode 100644 Sources/aiXplainKit/Modules/Model/Input/ModelInput.swift delete mode 100644 Sources/aiXplainKit/Modules/Model/Input/ModelParameter.swift delete mode 100644 Sources/aiXplainKit/Modules/Model/Input/UtilityModelInputInformation.swift delete mode 100644 Sources/aiXplainKit/Modules/Model/Model.swift delete mode 100644 Sources/aiXplainKit/Modules/Model/Query/ModelQuery.swift delete mode 100644 Sources/aiXplainKit/Modules/Model/Utility.swift delete mode 100644 Sources/aiXplainKit/Modules/Parameters/Agent/Agent+RunParameters.swift delete mode 100644 Sources/aiXplainKit/Modules/Parameters/Model/Model+RunParameters.swift delete mode 100644 Sources/aiXplainKit/Modules/Parameters/NetworkingParametersProtocol.swift delete mode 100644 Sources/aiXplainKit/Modules/Parameters/Pipeline/Pipeline+RunParameters.swift delete mode 100644 Sources/aiXplainKit/Modules/Parameters/RunParameters.swift delete mode 100644 Sources/aiXplainKit/Modules/Pipeline/Input/PipelineInput+Dictionary.swift delete mode 100644 Sources/aiXplainKit/Modules/Pipeline/Input/PipelineInput+String.swift delete mode 100644 Sources/aiXplainKit/Modules/Pipeline/Input/PipelineInput+URL.swift delete mode 100644 Sources/aiXplainKit/Modules/Pipeline/Input/PipelineInput.swift delete mode 100644 Sources/aiXplainKit/Modules/Pipeline/Pipeline.swift delete mode 100644 Sources/aiXplainKit/Modules/Pipeline/PipelineNode.swift delete mode 100644 Sources/aiXplainKit/Modules/TeamAgents/TeamAgent+CRUD.swift delete mode 100644 Sources/aiXplainKit/Modules/TeamAgents/TeamAgent.swift delete mode 100644 Sources/aiXplainKit/Networking/Networking+Endpoint.swift delete mode 100644 Sources/aiXplainKit/Networking/Networking+Metadata.swift delete mode 100644 Sources/aiXplainKit/Networking/Networking.swift delete mode 100644 Sources/aiXplainKit/Networking/ResponseDecoders/AgentExecuteResponse.swift delete mode 100644 Sources/aiXplainKit/Networking/ResponseDecoders/AgentOutput.swift delete mode 100644 Sources/aiXplainKit/Networking/ResponseDecoders/FunctionListResponse.swift delete mode 100644 Sources/aiXplainKit/Networking/ResponseDecoders/IndexSearchOutput.swift delete mode 100644 Sources/aiXplainKit/Networking/ResponseDecoders/ModelExecuteResponse.swift delete mode 100644 Sources/aiXplainKit/Networking/ResponseDecoders/ModelOutput.swift delete mode 100644 Sources/aiXplainKit/Networking/ResponseDecoders/PipelineExecuteResponse.swift delete mode 100644 Sources/aiXplainKit/Networking/ResponseDecoders/PipelineOutput.swift delete mode 100644 Sources/aiXplainKit/Provider/Agent/AgentProvider+BuildAgents.swift delete mode 100644 Sources/aiXplainKit/Provider/Agent/AgentProvider.swift delete mode 100644 Sources/aiXplainKit/Provider/Indexing/IndexProvider.swift delete mode 100644 Sources/aiXplainKit/Provider/Model/ModelProvider+Utility.swift delete mode 100644 Sources/aiXplainKit/Provider/Model/ModelProvider.swift delete mode 100644 Sources/aiXplainKit/Provider/PipelineProvider.swift delete mode 100644 Sources/aiXplainKit/Provider/TeamAgent/TeamAgentProvider+BuildTeamAgent.swift delete mode 100644 Sources/aiXplainKit/Provider/TeamAgent/TeamAgentProvider.swift create mode 100644 Sources/aiXplainKit/Resources/AgentToolConvertible.swift create mode 100644 Sources/aiXplainKit/Resources/AgentToolDict.swift create mode 100644 Sources/aiXplainKit/Resources/BaseResource.swift create mode 100644 Sources/aiXplainKit/Resources/Page.swift create mode 100644 Sources/aiXplainKit/Resources/RunResult.swift create mode 100644 Sources/aiXplainKit/Tools/Action.swift create mode 100644 Sources/aiXplainKit/Tools/Integration.swift create mode 100644 Sources/aiXplainKit/Tools/Tool.swift delete mode 100644 Sources/aiXplainKit/aiXplainKit.docc/Essential/DiscoverEssential.md delete mode 100644 Sources/aiXplainKit/aiXplainKit.docc/Essential/PipelineEssential.md delete mode 100644 Sources/aiXplainKit/aiXplainKit.docc/Essential/TeamAPIKeyGuide.md delete mode 100644 Sources/aiXplainKit/aiXplainKit.docc/Resources/NavigateAPIKey.png delete mode 100644 Sources/aiXplainKit/aiXplainKit.docc/Resources/TextToTextPipeline/TextToTextPipeline2.swift delete mode 100644 Sources/aiXplainKit/aiXplainKit.docc/Resources/TextToTextPipeline/TextToTextPipeline3.swift delete mode 100644 Sources/aiXplainKit/aiXplainKit.docc/Resources/TextToTextPipeline/TextToTextPipeline4.swift delete mode 100644 Sources/aiXplainKit/aiXplainKit.docc/Resources/TextToTextTutorialModel/DiscoverExample.swift delete mode 100644 Sources/aiXplainKit/aiXplainKit.docc/Resources/TextToTextTutorialModel/DiscoverExample2.swift delete mode 100644 Sources/aiXplainKit/aiXplainKit.docc/Resources/TextToTextTutorialModel/DiscoverExample3.swift delete mode 100644 Sources/aiXplainKit/aiXplainKit.docc/Resources/TextToTextTutorialModel/DiscoverExample4.swift delete mode 100644 Sources/aiXplainKit/aiXplainKit.docc/Resources/TextToTextTutorialModel/DiscoverExample5.swift delete mode 100644 Sources/aiXplainKit/aiXplainKit.docc/Resources/TextToTextTutorialModel/DiscoverExample6.swift delete mode 100644 Sources/aiXplainKit/aiXplainKit.docc/Resources/TextToTextTutorialModel/TextToTextPipeline1.swift delete mode 100644 Sources/aiXplainKit/aiXplainKit.docc/aiXplain101/TextToTextModel.tutorial delete mode 100644 Sources/aiXplainKit/aiXplainKit.docc/aiXplain101/TextToTextPipeline.tutorial delete mode 100644 Sources/aiXplainKit/aiXplainKit.docc/aiXplain101/aiXplain101.tutorial delete mode 100644 Sources/aiXplainKit/aiXplainKit.swift create mode 100644 Tests/aiXplainKitTests/E2E/AgentE2ETests.swift create mode 100644 Tests/aiXplainKitTests/E2E/ClientE2ETests.swift create mode 100644 Tests/aiXplainKitTests/E2E/IndexE2ETests.swift create mode 100644 Tests/aiXplainKitTests/E2E/ModelE2ETests.swift create mode 100644 Tests/aiXplainKitTests/E2E/ToolE2ETests.swift delete mode 100644 Tests/aiXplainKitTests/Functional/Modules/Agents/AgentBuildingTests.swift delete mode 100644 Tests/aiXplainKitTests/Functional/Modules/Agents/AgentOutputTests.swift delete mode 100644 Tests/aiXplainKitTests/Functional/Modules/Agents/AgentsFunctionTests.swift delete mode 100644 Tests/aiXplainKitTests/Functional/Modules/Model/ModelFunctionalTests.swift delete mode 100644 Tests/aiXplainKitTests/Functional/Modules/Model/ModelUtilityFunctionTests.swift delete mode 100644 Tests/aiXplainKitTests/Functional/Modules/PipelineFunctionalTests.swift delete mode 100644 Tests/aiXplainKitTests/Functional/Provider/AgentsProviderFunctionalTests.swift delete mode 100644 Tests/aiXplainKitTests/Functional/Provider/ModelProviderFunctionalTests.swift create mode 100644 Tests/aiXplainKitTests/Unit/Agents/AgentTests.swift create mode 100644 Tests/aiXplainKitTests/Unit/Auth/CredentialTests.swift create mode 100644 Tests/aiXplainKitTests/Unit/Client/ClientTests.swift create mode 100644 Tests/aiXplainKitTests/Unit/Errors/ErrorTests.swift create mode 100644 Tests/aiXplainKitTests/Unit/Index/IndexTests.swift delete mode 100644 Tests/aiXplainKitTests/Unit/MockServices/APIKeyManager+ReloadState.swift delete mode 100644 Tests/aiXplainKitTests/Unit/MockServices/MockNetworking.swift delete mode 100644 Tests/aiXplainKitTests/Unit/MockServices/MockResponses.swift create mode 100644 Tests/aiXplainKitTests/Unit/Models/ModelTests.swift delete mode 100644 Tests/aiXplainKitTests/Unit/Modules/ModelTests.swift delete mode 100644 Tests/aiXplainKitTests/Unit/Networking/NetworkingTests.swift delete mode 100644 Tests/aiXplainKitTests/Unit/Provider/ModelProviderTests.swift create mode 100644 Tests/aiXplainKitTests/Unit/Resources/ResourceTests.swift create mode 100644 Tests/aiXplainKitTests/Unit/Tools/ToolTests.swift delete mode 100644 Tests/aiXplainKitTests/XCTestCase+TempFile.swift create mode 100644 docs/rfcs/README.md create mode 100644 docs/rfcs/RFC-0001-auth-and-credentials.md create mode 100644 docs/rfcs/RFC-0002-client-configuration-and-transport.md create mode 100644 docs/rfcs/RFC-0003-agents-v2-api-and-lifecycle.md create mode 100644 docs/rfcs/RFC-0004-resources-tools-schema-alignment.md create mode 100644 docs/rfcs/RFC-0005-error-model-and-contract-tests.md create mode 100644 docs/rfcs/RFC-0006-clean-slate-implementation-plan.md create mode 100644 docs/rfcs/RFC-0007-models-v2-api.md create mode 100644 docs/rfcs/RFC-0008-tools-and-integrations-v2-api.md create mode 100644 docs/rfcs/RFC-0009-index-and-search-v2-api.md diff --git a/.gitignore b/.gitignore index 0023a53..6017c6b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ DerivedData/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc +docs/rfcs/credentials.md diff --git a/Examples/01-QuickStart.swift b/Examples/01-QuickStart.swift new file mode 100644 index 0000000..b8168ed --- /dev/null +++ b/Examples/01-QuickStart.swift @@ -0,0 +1,31 @@ +/// # Quick Start +/// +/// The simplest way to use aiXplainKit v2. +/// +/// Set your API key via environment variable: +/// export TEAM_API_KEY="your-api-key" +/// +/// Or pass it explicitly to `Aixplain(apiKey:)`. + +import aiXplainKit + +@main +struct QuickStart { + static func main() async throws { + + // 1. Initialize the SDK + let aix = try Aixplain(apiKey: "your-team-api-key") + + // 2. Run a model + let model = try await Model.get("669a63646eb56306647e1091", context: aix) // GPT-4o Mini + let result = try await model.run(text: "Say hello in French") + print("Model output:", result.data ?? "no output") + + // 3. Run an agent + let agents = try await Agent.search(pageSize: 1, context: aix) + if let agent = agents.results.first { + let response = try await agent.run("What can you help me with?") + print("Agent says:", response.data?.output ?? "no output") + } + } +} diff --git a/Examples/02-Models.swift b/Examples/02-Models.swift new file mode 100644 index 0000000..0a175cb --- /dev/null +++ b/Examples/02-Models.swift @@ -0,0 +1,39 @@ +/// # Working with Models +/// +/// Search, fetch, run, and stream AI models. + +import aiXplainKit + +@main +struct ModelsExample { + static func main() async throws { + let aix = try Aixplain() + + // --- Search models --- + let page = try await Model.search(query: "gpt", pageSize: 5, context: aix) + print("Found \(page.total) models matching 'gpt'") + for model in page.results { + print(" - \(model.name ?? "?") [\(model.id ?? "")]") + } + + // --- Get a specific model --- + let gpt4oMini = try await Model.get("669a63646eb56306647e1091", context: aix) + print("\nModel: \(gpt4oMini.name ?? "?")") + print(" Host: \(gpt4oMini.host ?? "?")") + print(" Streaming: \(gpt4oMini.supportsStreaming ?? false)") + print(" Connection: \(gpt4oMini.connectionType ?? [])") + + // --- Run a model --- + let result = try await gpt4oMini.run(text: "Explain quantum computing in one sentence") + print("\nResult: \(result.data?.description ?? "no data")") + print(" Credits used: \(result.usedCredits ?? 0)") + print(" Run time: \(result.runTime ?? 0)s") + + // --- Use a model as an agent tool --- + let toolDict = gpt4oMini.asAgentTool() + print("\nAs agent tool:") + print(" ID: \(toolDict.id)") + print(" Type: \(toolDict.type)") + print(" Supplier: \(toolDict.supplier)") + } +} diff --git a/Examples/03-Agents.swift b/Examples/03-Agents.swift new file mode 100644 index 0000000..699063c --- /dev/null +++ b/Examples/03-Agents.swift @@ -0,0 +1,57 @@ +/// # Working with Agents +/// +/// Search, fetch, run agents, manage sessions, and use tools with agents. + +import aiXplainKit + +@main +struct AgentsExample { + static func main() async throws { + let aix = try Aixplain() + + // --- Search agents --- + let page = try await Agent.search(pageSize: 5, context: aix) + print("Found \(page.total) agents") + + // --- Get a specific agent --- + guard let agentId = page.results.first?.id else { + print("No agents found") + return + } + let agent = try await Agent.get(agentId, context: aix) + print("Agent: \(agent.name ?? "?")") + print(" Status: \(agent.status)") + print(" LLM: \(agent.llmId)") + print(" Instructions: \(agent.instructions ?? "(none)")") + + // --- Run the agent --- + let result = try await agent.run("What is the capital of France?") + print("\nAgent response: \(result.data?.output ?? "no output")") + print(" Session: \(result.sessionId ?? "none")") + print(" Credits: \(result.usedCredits)") + + // --- Multi-turn conversation --- + let sessionId = try await agent.generateSessionId() + print("\nSession ID: \(sessionId)") + + let turn1 = try await agent.run("My name is Alice", sessionId: sessionId) + print("Turn 1: \(turn1.data?.output ?? "")") + + let turn2 = try await agent.run("What is my name?", sessionId: sessionId) + print("Turn 2: \(turn2.data?.output ?? "")") + + // --- Create a new agent with a model as a tool --- + let model = try await Model.get("669a63646eb56306647e1091", context: aix) + let newAgent = Agent( + name: "My Assistant", + instructions: "You are a helpful assistant. Use the provided tools when needed.", + tools: [model], + context: aix + ) + // newAgent.save() would persist it to the platform + print("\nNew agent payload preview:") + let payload = try newAgent.buildSavePayload() + print(" Name: \(payload["name"] ?? "?")") + print(" Tools: \((payload["tools"] as? [[String: Any]])?.count ?? 0) tool(s)") + } +} diff --git a/Examples/04-Tools.swift b/Examples/04-Tools.swift new file mode 100644 index 0000000..4a50daf --- /dev/null +++ b/Examples/04-Tools.swift @@ -0,0 +1,62 @@ +/// # Working with Tools +/// +/// Search tools, use them with agents, and work with integrations. + +import aiXplainKit + +@main +struct ToolsExample { + static func main() async throws { + let aix = try Aixplain() + + // --- Search tools --- + let page = try await Tool.searchTools(pageSize: 5, context: aix) + print("Found \(page.total) tools") + for tool in page.results { + print(" - \(tool.name ?? "?") [\(tool.id ?? "")]") + print(" Actions available: \(tool.actionsAvailable ?? false)") + print(" Allowed actions: \(tool.allowedActions)") + } + + // --- Get a specific tool --- + if let toolId = page.results.first?.id { + let tool = try await Tool.getTool(toolId, context: aix) + print("\nTool details: \(tool.name ?? "?")") + + // Convert to agent tool format + let agentTool = tool.asAgentTool() + print(" As agent tool type: \(agentTool.type)") + if let actions = agentTool.actions { + print(" Actions: \(actions)") + } + + // List actions (if available) + if tool.actionsAvailable == true { + let actions = try await tool.listActions() + print(" Available actions:") + for action in actions { + print(" - \(action.name ?? action.slug ?? "?")") + } + } + } + + // --- Use multiple tools with an agent --- + let model = try await Model.get("669a63646eb56306647e1091", context: aix) + let tools: [any AgentToolConvertible] = [model] + if let firstTool = page.results.first { + let allTools: [any AgentToolConvertible] = [model, firstTool] + let agent = Agent( + name: "Multi-tool Agent", + instructions: "Use available tools to answer questions", + tools: allTools, + context: aix + ) + let payload = try agent.buildSavePayload() + let toolsList = payload["tools"] as? [[String: Any]] ?? [] + print("\nAgent with \(toolsList.count) tools:") + for t in toolsList { + print(" - \(t["name"] ?? "?") (type: \(t["type"] ?? "?"))") + } + } + } +} diff --git a/Examples/05-Index.swift b/Examples/05-Index.swift new file mode 100644 index 0000000..cb17572 --- /dev/null +++ b/Examples/05-Index.swift @@ -0,0 +1,64 @@ +/// # Working with Index & Search +/// +/// Create indexes, add records, and perform semantic search. + +import aiXplainKit + +@main +struct IndexExample { + static func main() async throws { + let aix = try Aixplain() + + // --- Create records --- + let records = [ + Record(text: "Swift is a programming language developed by Apple"), + Record(text: "Python is widely used for data science and AI"), + Record(text: "Rust focuses on memory safety and performance"), + Record(text: "TypeScript adds types to JavaScript"), + ] + print("Created \(records.count) records") + + // --- Record with metadata --- + let taggedRecord = Record( + text: "Go is a statically typed language from Google", + attributes: ["category": "languages", "year": "2009"] + ) + print("Tagged record: \(taggedRecord.value)") + print(" Attributes: \(taggedRecord.attributes)") + + // --- Build search filters --- + let filters = IndexFilter.builder() + .where("category", .equals("languages")) + .where("year", .greaterThan("2005")) + .build() + print("\nFilters: \(filters.map { $0.toDict() })") + + // --- Subscript shorthand --- + let quickFilter = IndexFilter["language", .contains("en")] + print("Quick filter: \(quickFilter.toDict())") + + // --- Embedding models --- + print("\nAvailable embedding models:") + print(" OpenAI Ada 002: \(EmbeddingModel.openaiAda002.id)") + print(" BGE-M3: \(EmbeddingModel.bgeM3.id)") + print(" Multilingual E5: \(EmbeddingModel.multilingualE5Large.id)") + + // --- Work with an existing index --- + // let index = try await Index.get("your-index-id", context: aix) + // let results = try await index.search("What is Swift?", topK: 3) + // for hit in results.hits { + // print(" [\(hit.score)] \(hit.data)") + // } + + // --- Create a new index --- + // let newIndex = try await Index.create( + // name: "Programming Languages", + // description: "Knowledge base about programming languages", + // embedding: .openaiAda002, + // context: aix + // ) + // try await newIndex.upsert(records) + // let count = try await newIndex.count() + // print("Index has \(count) documents") + } +} diff --git a/Examples/06-ErrorHandling.swift b/Examples/06-ErrorHandling.swift new file mode 100644 index 0000000..2fda435 --- /dev/null +++ b/Examples/06-ErrorHandling.swift @@ -0,0 +1,65 @@ +/// # Error Handling +/// +/// How to handle errors from the aiXplain SDK. + +import aiXplainKit + +@main +struct ErrorHandlingExample { + static func main() async throws { + // --- Credential errors --- + do { + _ = try Credential(scheme: .teamKey("")) + } catch let error as AuthError { + print("Auth error: \(error.errorDescription ?? "")") + // "API key must not be empty." + } + + do { + _ = try Credential.resolve(environment: [:]) + } catch let error as AuthError { + print("Auth error: \(error.errorDescription ?? "")") + // "API key is required. Pass it as an argument or set the TEAM_API_KEY environment variable." + } + + // --- API errors with the unified error type --- + let aix = try Aixplain() + do { + _ = try await Agent.get("nonexistent-agent-id", context: aix) + } catch let error as AixplainError { + switch error { + case .auth(let authError): + print("Authentication failed: \(authError)") + case .api(let apiError): + print("API error [\(apiError.statusCode)]: \(apiError.message)") + if let rid = apiError.requestId { + print(" Request ID: \(rid)") + } + // User-friendly message for UI + print(" User message: \(apiError.userMessage)") + case .validation(let valError): + print("Validation: \(valError.message)") + case .timeout(let timeoutError): + print("Timeout: \(timeoutError.message)") + if let url = timeoutError.pollingURL { + print(" Polling URL: \(url)") + } + case .fileUpload(let uploadError): + print("Upload failed: \(uploadError.message)") + case .resource(let resourceError): + print("Resource error: \(resourceError.message)") + } + + // Or use the unified userMessage + print("User-facing: \(error.userMessage)") + } + + // --- Validation before requests --- + let agent = Agent(name: "Test") + do { + try agent.ensureValidState() + } catch { + print("Expected: \(error)") // Agent not saved yet + } + } +} diff --git a/Examples/07-TeamAgents.swift b/Examples/07-TeamAgents.swift new file mode 100644 index 0000000..44d1b2a --- /dev/null +++ b/Examples/07-TeamAgents.swift @@ -0,0 +1,49 @@ +/// # Team Agents (Multi-Agent Systems) +/// +/// In v2, team agents are just agents with subagents. No separate class. + +import aiXplainKit + +@main +struct TeamAgentsExample { + static func main() async throws { + let aix = try Aixplain() + + // --- A TeamAgent is just an Agent with subagents --- + // The typealias exists for discoverability: + // public typealias TeamAgent = Agent + + // --- Check if an agent is a team agent --- + let agent = Agent(name: "Solo Agent", context: aix) + print("Is team agent: \(agent.isTeamAgent)") // false + + // --- Create a team agent from existing agents --- + let agents = try await Agent.search(pageSize: 3, context: aix) + guard agents.results.count >= 2 else { + print("Need at least 2 agents to form a team") + return + } + + let subAgent1 = agents.results[0] + let subAgent2 = agents.results[1] + + let team = Agent( + name: "Research Team", + instructions: "Coordinate the sub-agents to answer research questions", + context: aix + ) + team.subagents = [subAgent1, subAgent2] + + print("Team agent: \(team.name ?? "?")") + print(" Is team: \(team.isTeamAgent)") // true + print(" Subagents: \(team.subagents.count)") + for sub in team.subagents { + print(" - \(sub.name ?? "?") [\(sub.id ?? "")]") + } + + // --- The save payload includes agent references --- + let payload = try team.buildSavePayload() + let agentRefs = payload["agents"] as? [[String: Any]] ?? [] + print("\nSave payload agent refs: \(agentRefs.count)") + } +} diff --git a/Examples/08-AdvancedAgent.swift b/Examples/08-AdvancedAgent.swift new file mode 100644 index 0000000..3ebdde2 --- /dev/null +++ b/Examples/08-AdvancedAgent.swift @@ -0,0 +1,75 @@ +/// # Advanced Agent Features +/// +/// Tasks, conversation history, output formats, and cloning. + +import aiXplainKit + +@main +struct AdvancedAgentExample { + static func main() async throws { + let aix = try Aixplain() + + // --- Agent with tasks --- + let researchTask = AgentTask( + name: "research", + instructions: "Find relevant information about the topic", + expectedOutput: "A list of key facts", + dependencies: [] + ) + let summarizeTask = AgentTask( + name: "summarize", + instructions: "Summarize the research findings", + expectedOutput: "A concise summary paragraph", + dependencies: ["research"] // depends on research completing first + ) + + let agent = Agent(name: "Research Agent", instructions: "Complete tasks in order", context: aix) + agent.tasks = [researchTask, summarizeTask] + agent.outputFormat = .markdown + + print("Agent with \(agent.tasks.count) tasks:") + for task in agent.tasks { + print(" - \(task.name) (deps: \(task.dependencies))") + } + + // --- Output formats --- + print("\nOutput formats:") + print(" .text -> '\(OutputFormat.text.rawValue)'") + print(" .json -> '\(OutputFormat.json.rawValue)'") + print(" .markdown -> '\(OutputFormat.markdown.rawValue)'") + + // --- Conversation history validation --- + let validHistory = [ + ConversationMessage(role: .user, content: "Hello"), + ConversationMessage(role: .assistant, content: "Hi! How can I help?"), + ConversationMessage(role: .user, content: "Tell me a joke"), + ] + + do { + try ConversationMessage.validateHistory(validHistory) + print("\nHistory is valid (\(validHistory.count) messages)") + } catch { + print("Invalid history: \(error)") + } + + // --- Clone an agent --- + let agents = try await Agent.search(pageSize: 1, context: aix) + if let original = agents.results.first { + let fetched = try await Agent.get(original.id!, context: aix) + let cloned = fetched.clone(name: "Copy of \(fetched.name ?? "Agent")") + print("\nCloned agent:") + print(" Original: \(fetched.name ?? "?") [id: \(fetched.id ?? "")]") + print(" Clone: \(cloned.name ?? "?") [id: \(cloned.id ?? "nil")]") + print(" Clone status: \(cloned.status)") // always .draft + } + + // --- Run with variables --- + // agent.run(query: "Analyze this topic", + // variables: ["topic": "climate change", "depth": "detailed"]) + + // --- Run with conversation history --- + // agent.run(query: "Continue the analysis", + // sessionId: sessionId, + // history: validHistory) + } +} diff --git a/README.md b/README.md index b058845..cdf7a45 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,145 @@ -# aiXplainKit +# aiXplainKit v2 -aiXplainKit enables Swift programmers to add AI functions to their software with ease. +Swift SDK for the [aiXplain](https://aixplain.com/) AI platform. -## Overview +## Quick Start -aiXplainKit is a software development kit (SDK) for the [aiXplain](https://aixplain.com/) platform. With aiXplainKit, developers can quickly and easily: +```swift +import aiXplainKit + +let aix = try Aixplain(apiKey: "your-team-api-key") + +// Run a model +let model = try await Model.get("model-id", context: aix) +let result = try await model.run(text: "Translate this to French") -- [Discover](https://aixplain.com/platform/discovery/) aiXplain’s ever-expanding catalog of 35,000+ ready-to-use AI models and utilize them. -- [Design](https://aixplain.com/platform/studio/) their own custom pipelines and run them. +// Run an agent +let agent = try await Agent.get("agent-id", context: aix) +let response = try await agent.run("What can you help me with?") +print(response.data?.output ?? "") +``` + +## Installation +Add to your `Package.swift`: + +```swift +dependencies: [ + .package(url: "https://github.com/aixplain/aiXplainKit.git", from: "2.0.0") +] +``` ## API Key Setup -Before you can use the aixplain SDK, you'll need to obtain an API key from our platform. For details refer this [Team API Key Guide](). -Once you get the API key, you'll need to add this API key as an environment variable on your system. +Get your team API key from the [aiXplain platform](https://platform.aixplain.com/). + +**Option 1: Environment variable** +```bash +export TEAM_API_KEY="your-key-here" +``` +```swift +let aix = try Aixplain() // resolves from environment +``` + +**Option 2: Explicit parameter** +```swift +let aix = try Aixplain(apiKey: "your-key-here") +``` + +## Features + +### Models + +```swift +// Search models +let page = try await Model.search(query: "gpt", pageSize: 10, context: aix) + +// Run a model +let model = try await Model.get("model-id", context: aix) +let result = try await model.run(text: "Hello, world!") +``` + +### Agents ```swift -AiXplainKit.shared.keyManager.TEAM_API_KEY = "" +// Run an agent +let agent = try await Agent.get("agent-id", context: aix) +let result = try await agent.run("Summarize this article") + +// Multi-turn conversation +let sessionId = try await agent.generateSessionId() +let turn1 = try await agent.run("My name is Alice", sessionId: sessionId) +let turn2 = try await agent.run("What's my name?", sessionId: sessionId) + +// Create a new agent with tools +let agent = Agent(name: "Helper", instructions: "You are helpful", tools: [model], context: aix) +try await agent.save() ``` -Alternatively, you can set the API key as an environment variable in Xcode. This approach keeps your API key separate from your code, which can be beneficial for security and portability. Check on how to do this on the ``APIKeyManager`` +### Tools -## Topics +```swift +// Search tools +let tools = try await Tool.searchTools(pageSize: 5, context: aix) + +// Use a model as a tool for an agent +let toolDict = model.asAgentTool() +``` + +### Index & Search + +```swift +// Create records and search +let records = [ + Record(text: "Swift is a programming language"), + Record(text: "Python is used for AI"), +] + +let index = try await Index.get("index-id", context: aix) +try await index.upsert(records) +let results = try await index.search("What is Swift?", topK: 5) +``` -### Essential +### Error Handling -- [TeamAPIKeyGuide]() -- [Pipeline]() +```swift +do { + let agent = try await Agent.get("bad-id", context: aix) +} catch let error as AixplainError { + print(error.userMessage) // user-friendly message +} +``` + +## Examples + +See the [`Examples/`](Examples/) directory for complete working examples: + +| Example | Description | +|---------|-------------| +| [01-QuickStart](Examples/01-QuickStart.swift) | Minimal setup and first API call | +| [02-Models](Examples/02-Models.swift) | Search, fetch, run, and stream models | +| [03-Agents](Examples/03-Agents.swift) | Agents, sessions, and multi-turn conversations | +| [04-Tools](Examples/04-Tools.swift) | Tools, integrations, and agent tool composition | +| [05-Index](Examples/05-Index.swift) | Indexing, records, filters, and semantic search | +| [06-ErrorHandling](Examples/06-ErrorHandling.swift) | Unified error handling patterns | +| [07-TeamAgents](Examples/07-TeamAgents.swift) | Multi-agent teams (subagents) | +| [08-AdvancedAgent](Examples/08-AdvancedAgent.swift) | Tasks, history, output formats, cloning | + +## Architecture + +Aligned with [aiXplain Python SDK v2](https://github.com/aixplain/aiXplain/tree/main/aixplain/v2). See [`docs/rfcs/`](docs/rfcs/) for the full RFC series. + +``` +Aixplain (entry point) + └── AixplainClient (HTTP transport) + ├── Agent (get/search/save/run/delete) + ├── Model (get/search/run/stream) + ├── Tool (get/search/run, subclass of Model) + ├── Integration (get/connect → Tool) + └── Index (get/create/search/upsert) +``` +## Requirements +- Swift 5.9+ +- iOS 15+ / macOS 12+ / watchOS 8+ / tvOS 15+ / visionOS 1+ diff --git a/Sources/aiXplainKit/Agents/Agent.swift b/Sources/aiXplainKit/Agents/Agent.swift new file mode 100644 index 0000000..7384eba --- /dev/null +++ b/Sources/aiXplainKit/Agents/Agent.swift @@ -0,0 +1,377 @@ +import Foundation +import OSLog + +/// Agent resource -- the primary product surface of the aiXplain SDK. +/// +/// Mirrors Python v2 `Agent(BaseResource, SearchResourceMixin, GetResourceMixin, +/// DeleteResourceMixin, RunnableResourceMixin)` from `agent.py`. +/// +/// Agents with non-empty `subagents` are team agents (no separate class). +public final class Agent: BaseResource { + private static let logger = Logger(subsystem: "aiXplainKit", category: "Agent") + public override class var resourcePath: String { "v2/agents" } + + public static let defaultLLM = "669a63646eb56306647e1091" + + // MARK: - Agent fields + + public var instructions: String? + public var status: AssetStatus = .draft + public var teamId: Int? + public var llmId: String = Agent.defaultLLM + public var tools: [any AgentToolConvertible] = [] + public var subagents: [Agent] = [] + public var tasks: [AgentTask] = [] + public var outputFormat: OutputFormat = .text + public var maxIterations: Int = 5 + public var maxTokens: Int = 2048 + public var createdAt: String? + public var updatedAt: String? + + /// TeamAgent is just an Agent with subagents. + public var isTeamAgent: Bool { !subagents.isEmpty } + + // MARK: - Init + + public required init(id: String? = nil, name: String? = nil, description: String? = nil, context: Aixplain? = nil) { + super.init(id: id, name: name, description: description, context: context) + } + + public required convenience init() { + self.init(id: nil, name: nil, description: nil, context: nil) + } + + public init( + name: String, + instructions: String = "", + llmId: String = Agent.defaultLLM, + tools: [any AgentToolConvertible] = [], + description: String = "", + context: Aixplain? = nil + ) { + super.init(id: nil, name: name, description: description, context: context) + self.instructions = instructions + self.llmId = llmId + self.tools = tools + } + + // MARK: - Get + + public class func get(_ id: String, context: Aixplain) async throws -> Agent { + try await performGet(id, context: context, type: Agent.self) + } + + // MARK: - Search + + public class func search( + query: String? = nil, + pageNumber: Int = 0, + pageSize: Int = 20, + context: Aixplain + ) async throws -> Page { + var filters: [String: Any] = [ + "pageNumber": pageNumber, + "pageSize": pageSize + ] + if let q = query { filters["q"] = q } + return try await performSearch(filters: filters, context: context, type: Agent.self) + } + + // MARK: - Save + + @discardableResult + public override func save() async throws -> Self { + try beforeSave() + return try await super.save() + } + + public func save(asDraft: Bool) async throws -> Agent { + status = asDraft ? .draft : .onboarded + return try await save() + } + + func beforeSave() throws { + // Validate subagent dependencies + for sub in subagents where sub.id == nil { + throw AixplainError.validation(ValidationError( + "Subagent '\(sub.name ?? "unnamed")' must be saved before saving the team agent." + )) + } + } + + public override func buildSavePayload() throws -> [String: Any] { + var payload: [String: Any] = [:] + if let id { payload["id"] = id } + if let name { payload["name"] = name } + if let description { payload["description"] = description } + if let instructions { payload["instructions"] = instructions } + payload["status"] = status.rawValue + payload["model"] = ["id": llmId] + payload["outputFormat"] = outputFormat.rawValue + payload["maxIterations"] = maxIterations + payload["maxTokens"] = maxTokens + + let convertedTools = tools.map { $0.asAgentTool() } + let toolDicts: [[String: Any]] = try convertedTools.map { tool in + let data = try JSONEncoder().encode(tool) + guard let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw AixplainError.validation(ValidationError("Failed to serialize tool")) + } + return dict + } + payload["tools"] = toolDicts + + if !subagents.isEmpty { + payload["agents"] = subagents.compactMap { agent -> [String: Any]? in + guard let id = agent.id else { return nil } + return ["id": id, "inspectors": [] as [Any]] + } + } + + if !tasks.isEmpty { + let tasksData = try JSONEncoder().encode(tasks) + payload["tasks"] = try JSONSerialization.jsonObject(with: tasksData) + } + + return payload + } + + // MARK: - Run + + /// Run the agent with a simple query string. + public func run(_ query: String, sessionId: String? = nil) async throws -> AgentRunResult { + try await run(query: query, sessionId: sessionId) + } + + /// Run the agent with full parameters. + public func run( + query: String, + sessionId: String? = nil, + variables: [String: Any]? = nil, + history: [ConversationMessage]? = nil, + outputFormat: OutputFormat? = nil, + maxTokens: Int? = nil, + maxIterations: Int? = nil + ) async throws -> AgentRunResult { + try beforeRun() + try ensureValidState() + let ctx = try ensureContext() + + let payload = try buildRunPayload( + query: query, + sessionId: sessionId, + variables: variables, + history: history, + outputFormat: outputFormat, + maxTokens: maxTokens, + maxIterations: maxIterations + ) + + let path = "\(Self.resourcePath)/\(encodedId)/run" + let response = try await ctx.client.post(path, json: payload) + + let status = response["status"] as? String ?? "IN_PROGRESS" + if status == ResponseStatus.failed.rawValue { + throw APIError.fromFailedOperation(response) + } + + let result = AgentRunResult.from(response) + if result.completed { return result } + + if let pollURL = result.url, pollURL.hasPrefix("http") { + return try await pollAgent(pollURL) + } + + return result + } + + /// Run async -- returns immediately without polling. + public func runAsync( + query: String, + sessionId: String? = nil, + variables: [String: Any]? = nil + ) async throws -> AgentRunResult { + try beforeRun() + try ensureValidState() + let ctx = try ensureContext() + + let payload = try buildRunPayload(query: query, sessionId: sessionId, variables: variables) + let path = "\(Self.resourcePath)/\(encodedId)/run" + let response = try await ctx.client.post(path, json: payload) + return AgentRunResult.from(response) + } + + // MARK: - Session + + /// Generate a unique session ID. + /// Format: `"{agentId}_{timestamp}"` (matches Python v2). + public func generateSessionId(history: [ConversationMessage]? = nil) async throws -> String { + if id == nil { + try await save(asDraft: true) + } + + if let history { + try ConversationMessage.validateHistory(history) + } + + let timestamp = Self.timestampString() + let sessionId = "\(id!)_\(timestamp)" + + if let history, !history.isEmpty { + _ = try? await runAsync( + query: "/", + sessionId: sessionId + ) + } + + return sessionId + } + + // MARK: - Clone + + public override func clone(name: String? = nil) -> Self { + let cloned = Self.init( + id: nil, + name: name ?? self.name, + description: self.description, + context: self.context + ) + cloned.instructions = self.instructions + cloned.llmId = self.llmId + cloned.outputFormat = self.outputFormat + cloned.maxIterations = self.maxIterations + cloned.maxTokens = self.maxTokens + cloned.status = .draft + return cloned + } + + // MARK: - Deserialization + + public override class func from(dict: [String: Any], context: Aixplain) throws -> Self { + let instance = Self.init( + id: dict["id"] as? String, + name: dict["name"] as? String, + description: dict["description"] as? String, + context: context + ) + instance.instructions = dict["instructions"] as? String + instance.teamId = dict["teamId"] as? Int + instance.createdAt = dict["createdAt"] as? String + instance.updatedAt = dict["updatedAt"] as? String + instance.maxIterations = dict["maxIterations"] as? Int ?? 5 + instance.maxTokens = dict["maxTokens"] as? Int ?? 2048 + + if let statusStr = dict["status"] as? String { + instance.status = AssetStatus(rawValue: statusStr) ?? .draft + } + if let modelDict = dict["model"] as? [String: Any], let llmId = modelDict["id"] as? String { + instance.llmId = llmId + } else if let llmId = dict["llmId"] as? String { + instance.llmId = llmId + } + if let fmt = dict["outputFormat"] as? String { + instance.outputFormat = OutputFormat(rawValue: fmt) ?? .text + } + if let taskList = dict["tasks"] as? [[String: Any]] { + instance.tasks = taskList.compactMap { taskDict in + guard let name = taskDict["name"] as? String else { return nil } + return AgentTask( + name: name, + instructions: taskDict["description"] as? String, + expectedOutput: taskDict["expectedOutput"] as? String, + dependencies: taskDict["dependencies"] as? [String] ?? [] + ) + } + } + return instance + } + + // MARK: - Private + + private func beforeRun() throws { + if status == .draft && isModified { + Agent.logger.info("Auto-saving draft agent before run") + } + } + + func buildRunPayload( + query: String, + sessionId: String? = nil, + variables: [String: Any]? = nil, + history: [ConversationMessage]? = nil, + outputFormat: OutputFormat? = nil, + maxTokens: Int? = nil, + maxIterations: Int? = nil + ) throws -> [String: Any] { + var inputData: [String: Any] = ["input": query] + if let variables { inputData.merge(variables) { _, new in new } } + + var executionParams: [String: Any] = [ + "outputFormat": (outputFormat ?? self.outputFormat).rawValue, + "maxTokens": maxTokens ?? self.maxTokens, + "maxIterations": maxIterations ?? self.maxIterations, + "maxTime": 300 + ] + + var payload: [String: Any] = [ + "id": id!, + "query": inputData, + "executionParams": executionParams, + "runResponseGeneration": true + ] + + if let sessionId { payload["sessionId"] = sessionId } + + if let history { + try ConversationMessage.validateHistory(history) + let historyDicts = try history.map { msg -> [String: Any] in + let data = try JSONEncoder().encode(msg) + return try JSONSerialization.jsonObject(with: data) as! [String: Any] + } + payload["history"] = historyDicts + } + + return payload + } + + private func pollAgent(_ pollURL: String, timeout: TimeInterval = 300, waitTime: TimeInterval = 0.5) async throws -> AgentRunResult { + let startTime = Date() + var currentWait = max(waitTime, 0.2) + let ctx = try ensureContext() + + while Date().timeIntervalSince(startTime) < timeout { + let response = try await ctx.client.get(pollURL) + let status = response["status"] as? String ?? "IN_PROGRESS" + + if status == ResponseStatus.failed.rawValue { + throw APIError.fromFailedOperation(response) + } + + if let err = response["supplierError"] as? String, !err.isEmpty { + throw AixplainError.api(APIError(message: "Supplier error: \(err)", error: err)) + } + + if response["completed"] as? Bool == true { + return AgentRunResult.from(response) + } + + try await Task.sleep(nanoseconds: UInt64(currentWait * 1_000_000_000)) + currentWait = min(currentWait * 1.1, 60) + } + + throw AixplainError.timeout(TimeoutError( + "Agent polling timed out after \(Int(timeout))s", + pollingURL: pollURL, + timeout: timeout + )) + } + + private static func timestampString() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMddHHmmss" + return formatter.string(from: Date()) + } +} + +/// TeamAgent is just an Agent with subagents. +public typealias TeamAgent = Agent diff --git a/Sources/aiXplainKit/Agents/AgentRunResult.swift b/Sources/aiXplainKit/Agents/AgentRunResult.swift new file mode 100644 index 0000000..b51490d --- /dev/null +++ b/Sources/aiXplainKit/Agents/AgentRunResult.swift @@ -0,0 +1,62 @@ +import Foundation + +/// Result from running an agent. +/// +/// Mirrors Python v2 `AgentRunResult(Result)` from `agent.py`. +public struct AgentRunResult: @unchecked Sendable { + public let status: String + public let completed: Bool + public let data: AgentResponseData? + public let sessionId: String? + public let requestId: String? + public let usedCredits: Double + public let runTime: Double + public let errorMessage: String? + public let supplierError: String? + public let url: String? + public let rawData: [String: Any]? + + /// Parse from a polling/run response dictionary. + public static func from(_ dict: [String: Any]) -> AgentRunResult { + var responseData: AgentResponseData? = nil + if let dataDict = dict["data"] as? [String: Any] { + responseData = AgentResponseData( + input: dataDict["input"] as? String, + output: dataDict["output"] as? String, + steps: dataDict["steps"] as? [[String: Any]], + sessionId: dataDict["sessionId"] as? String ?? dataDict["session_id"] as? String + ) + } else if let dataStr = dict["data"] as? String, !dataStr.hasPrefix("http") { + responseData = AgentResponseData(output: dataStr) + } + + return AgentRunResult( + status: dict["status"] as? String ?? "IN_PROGRESS", + completed: dict["completed"] as? Bool ?? false, + data: responseData, + sessionId: dict["sessionId"] as? String, + requestId: dict["requestId"] as? String, + usedCredits: dict["usedCredits"] as? Double ?? 0, + runTime: dict["runTime"] as? Double ?? 0, + errorMessage: dict["errorMessage"] as? String, + supplierError: dict["supplierError"] as? String, + url: dict["url"] as? String ?? (dict["data"] as? String), + rawData: dict + ) + } +} + +/// Data structure for agent response content. +public struct AgentResponseData: @unchecked Sendable { + public let input: String? + public let output: String? + public let steps: [[String: Any]]? + public let sessionId: String? + + public init(input: String? = nil, output: String? = nil, steps: [[String: Any]]? = nil, sessionId: String? = nil) { + self.input = input + self.output = output + self.steps = steps + self.sessionId = sessionId + } +} diff --git a/Sources/aiXplainKit/Agents/AgentTask.swift b/Sources/aiXplainKit/Agents/AgentTask.swift new file mode 100644 index 0000000..7783fc7 --- /dev/null +++ b/Sources/aiXplainKit/Agents/AgentTask.swift @@ -0,0 +1,25 @@ +import Foundation + +/// A task definition for agent workflows with dependency support. +/// +/// Mirrors Python v2 `Task` dataclass from `agent.py`. +public struct AgentTask: Codable, Sendable { + public let name: String + public var instructions: String? + public var expectedOutput: String? + public var dependencies: [String] + + enum CodingKeys: String, CodingKey { + case name + case instructions = "description" + case expectedOutput + case dependencies + } + + public init(name: String, instructions: String? = nil, expectedOutput: String? = nil, dependencies: [String] = []) { + self.name = name + self.instructions = instructions + self.expectedOutput = expectedOutput + self.dependencies = dependencies + } +} diff --git a/Sources/aiXplainKit/Agents/ConversationMessage.swift b/Sources/aiXplainKit/Agents/ConversationMessage.swift new file mode 100644 index 0000000..dc174b2 --- /dev/null +++ b/Sources/aiXplainKit/Agents/ConversationMessage.swift @@ -0,0 +1,32 @@ +import Foundation + +/// Message role in a conversation. +public enum MessageRole: String, Codable, Sendable { + case user + case assistant +} + +/// A single message in a conversation history. +/// +/// Mirrors Python v2 `ConversationMessage` TypedDict from `agent.py`. +public struct ConversationMessage: Codable, Sendable { + public let role: MessageRole + public let content: String + + public init(role: MessageRole, content: String) { + self.role = role + self.content = content + } + + /// Validate a list of conversation messages. + /// Mirrors Python v2 `validate_history()`. + public static func validateHistory(_ history: [ConversationMessage]) throws { + for (index, message) in history.enumerated() { + guard !message.content.isEmpty else { + throw AixplainError.validation(ValidationError( + "'content' at index \(index) must not be empty." + )) + } + } + } +} diff --git a/Sources/aiXplainKit/Agents/OutputFormat.swift b/Sources/aiXplainKit/Agents/OutputFormat.swift new file mode 100644 index 0000000..b42d479 --- /dev/null +++ b/Sources/aiXplainKit/Agents/OutputFormat.swift @@ -0,0 +1,10 @@ +import Foundation + +/// Output format options for agent responses. +/// +/// Mirrors Python v2 `OutputFormat` from `agent.py`. +public enum OutputFormat: String, Codable, Sendable { + case markdown + case text + case json +} diff --git a/Sources/aiXplainKit/Aixplain.swift b/Sources/aiXplainKit/Aixplain.swift new file mode 100644 index 0000000..42042b1 --- /dev/null +++ b/Sources/aiXplainKit/Aixplain.swift @@ -0,0 +1,46 @@ +import Foundation + +/// Main entry point for the aiXplain Swift SDK v2. +/// +/// Mirrors Python v2 `Aixplain` class in `core.py`. +/// +/// ```swift +/// let aix = try Aixplain(apiKey: "your-team-key") +/// let agent = try await aix.Agent.get("agent-id") +/// let result = try await agent.run("Hello!") +/// ``` +public final class Aixplain: @unchecked Sendable { + public let client: AixplainClient + public let apiKey: String + public let backendURL: URL + public let modelURL: URL + + /// Initialize with explicit API key or environment fallback. + /// + /// - Parameters: + /// - apiKey: Team API key. If nil, resolved from `TEAM_API_KEY` env var. + /// - backendURL: Override the default backend URL. + /// - modelURL: Override the default model execution URL. + public init( + apiKey: String? = nil, + backendURL: URL? = nil, + modelURL: URL? = nil + ) throws { + let credential = try Credential.resolve(apiKey: apiKey) + var config = ClientConfiguration.default + if let url = backendURL { config.backendURL = url } + if let url = modelURL { config.modelsRunURL = url } + + self.apiKey = credential.scheme.key + self.backendURL = config.backendURL.absoluteString + .hasSuffix("/") ? config.backendURL : URL(string: config.backendURL.absoluteString + "/")! + self.modelURL = config.modelsRunURL + self.client = AixplainClient(credential: credential, configuration: config) + } + + // Resource accessors will be added in subsequent phases. + // Phase 4 (RFC-0007): public lazy var Model = ... + // Phase 5 (RFC-0008): public lazy var Tool = ... + // Phase 6 (RFC-0003): public lazy var Agent = ... + // Phase 7 (RFC-0009): public lazy var Index = ... +} diff --git a/Sources/aiXplainKit/Auth/AuthenticationScheme.swift b/Sources/aiXplainKit/Auth/AuthenticationScheme.swift new file mode 100644 index 0000000..c7924a2 --- /dev/null +++ b/Sources/aiXplainKit/Auth/AuthenticationScheme.swift @@ -0,0 +1,18 @@ +import Foundation + +/// How the SDK authenticates against the aiXplain platform. +/// +/// Matches Python v2 contract: exactly one key type per client instance. +/// - `aixplainKey`: Platform-scoped key → header `x-aixplain-key` +/// - `teamKey`: Team-scoped key → header `x-api-key` +public enum AuthenticationScheme: Sendable, Codable, Equatable { + case aixplainKey(String) + case teamKey(String) + + var key: String { + switch self { + case .aixplainKey(let k), .teamKey(let k): + return k + } + } +} diff --git a/Sources/aiXplainKit/Auth/Credential.swift b/Sources/aiXplainKit/Auth/Credential.swift new file mode 100644 index 0000000..a24a2c6 --- /dev/null +++ b/Sources/aiXplainKit/Auth/Credential.swift @@ -0,0 +1,64 @@ +import Foundation + +/// Resolved, validated credential ready for use in HTTP requests. +/// +/// Immutable once created -- matches Python v2 pattern where session headers +/// are set once during `__init__` and reused for all requests. +/// +/// Resolution order (mirrors Python v2 `core.py`): +/// 1. Explicit `apiKey` parameter → `.teamKey` +/// 2. `TEAM_API_KEY` environment variable → `.teamKey` +/// 3. `AIXPLAIN_API_KEY` environment variable → `.aixplainKey` +/// 4. Throw `AuthError.noCredentialFound` +public struct Credential: Sendable, Equatable, Codable { + public let scheme: AuthenticationScheme + + /// Validates and creates a credential. + /// + /// - Throws: `AuthError.emptyKey` if the key string is empty or whitespace-only. + public init(scheme: AuthenticationScheme) throws { + guard !scheme.key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw AuthError.emptyKey + } + self.scheme = scheme + } + + /// Builds the HTTP headers for this credential. + /// + /// Returns a dictionary containing the auth header and `Content-Type`. + /// Matches the Python v2 header contract: + /// - `aixplainKey` → `x-aixplain-key: ` + /// - `teamKey` → `x-api-key: ` + public func authHeaders() -> [String: String] { + var headers = ["Content-Type": "application/json"] + switch scheme { + case .aixplainKey(let key): + headers["x-aixplain-key"] = key + case .teamKey(let key): + headers["x-api-key"] = key + } + return headers + } + + /// Resolves a credential from an explicit value or environment variables. + /// + /// - Parameters: + /// - apiKey: Explicit API key (takes priority). Resolved as `.teamKey`. + /// - environment: Environment dictionary (defaults to `ProcessInfo`). + /// - Throws: `AuthError.noCredentialFound` if no key can be resolved. + public static func resolve( + apiKey: String? = nil, + environment: [String: String] = ProcessInfo.processInfo.environment + ) throws -> Credential { + if let key = apiKey, !key.isEmpty { + return try Credential(scheme: .teamKey(key)) + } + if let envKey = environment["TEAM_API_KEY"], !envKey.isEmpty { + return try Credential(scheme: .teamKey(envKey)) + } + if let envKey = environment["AIXPLAIN_API_KEY"], !envKey.isEmpty { + return try Credential(scheme: .aixplainKey(envKey)) + } + throw AuthError.noCredentialFound + } +} diff --git a/Sources/aiXplainKit/Client/AixplainClient.swift b/Sources/aiXplainKit/Client/AixplainClient.swift new file mode 100644 index 0000000..f98963c --- /dev/null +++ b/Sources/aiXplainKit/Client/AixplainClient.swift @@ -0,0 +1,174 @@ +import Foundation +import OSLog + +/// Central HTTP client for the aiXplain platform. +/// +/// Mirrors Python v2 `AixplainClient`: single session, shared auth headers, retry logic. +/// Uses `URLSession` directly (resolved question: simplest approach). +public final class AixplainClient: @unchecked Sendable { + public let credential: Credential + public let configuration: ClientConfiguration + + private let session: URLSession + private let logger = Logger(subsystem: "aiXplainKit", category: "Client") + + public init(credential: Credential, configuration: ClientConfiguration = .default) { + self.credential = credential + self.configuration = configuration + + let sessionConfig = URLSessionConfiguration.default + sessionConfig.timeoutIntervalForRequest = configuration.timeoutInterval + self.session = URLSession(configuration: sessionConfig) + } + + // MARK: - Public API + + /// Raw request returning `Response`. Mirrors Python v2 `request_raw()`. + public func requestRaw( + method: HTTPMethod, + path: String, + body: Data? = nil, + additionalHeaders: [String: String] = [:] + ) async throws -> Response { + let url = try resolveURL(path) + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + request.httpBody = body + + for (field, value) in credential.authHeaders() { + request.setValue(value, forHTTPHeaderField: field) + } + request.setValue(configuration.userAgent, forHTTPHeaderField: "User-Agent") + for (field, value) in additionalHeaders { + request.setValue(value, forHTTPHeaderField: field) + } + + let retryPolicy = configuration.retryPolicy + var lastError: Error? + + for attempt in 0...retryPolicy.maxRetries { + do { + logger.debug("\(method.rawValue) \(url.absoluteString) (attempt \(attempt))") + let (data, urlResponse) = try await session.data(for: request) + + guard let httpResponse = urlResponse as? HTTPURLResponse else { + throw AixplainError.api(APIError(message: "Invalid HTTP response")) + } + + let response = Response(data: data, httpResponse: httpResponse) + + if response.isSuccess { + return response + } + + if method.isRetryable && retryPolicy.retryableStatusCodes.contains(response.statusCode) && attempt < retryPolicy.maxRetries { + let delay = retryPolicy.delay(for: attempt) + logger.warning("Retryable status \(response.statusCode), waiting \(delay)s") + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + continue + } + + throw APIError.fromHTTPResponse(data: data, statusCode: response.statusCode) + + } catch let error as AixplainError { + throw error + } catch { + lastError = error + if method.isRetryable && attempt < retryPolicy.maxRetries { + let delay = retryPolicy.delay(for: attempt) + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + continue + } + } + } + + throw lastError ?? AixplainError.api(APIError(message: "Request failed after \(retryPolicy.maxRetries) retries")) + } + + /// JSON-decoded request. Mirrors Python v2 `request()` which auto-calls `.json()`. + public func request( + method: HTTPMethod, + path: String, + body: Data? = nil, + additionalHeaders: [String: String] = [:] + ) async throws -> [String: Any] { + let response = try await requestRaw(method: method, path: path, body: body, additionalHeaders: additionalHeaders) + return try response.json() + } + + /// GET request. Mirrors Python v2 `get()`. + public func get(_ path: String) async throws -> [String: Any] { + try await request(method: .get, path: path) + } + + /// POST request with JSON body. Mirrors Python v2 `post()`. + public func post(_ path: String, json payload: Any) async throws -> [String: Any] { + let body = try JSONSerialization.data(withJSONObject: payload) + return try await request(method: .post, path: path, body: body) + } + + /// POST request with Encodable body. + public func post(_ path: String, body: T) async throws -> [String: Any] { + let data = try JSONEncoder().encode(body) + return try await request(method: .post, path: path, body: data) + } + + /// Streaming request for SSE. Mirrors Python v2 `request_stream()`. + public func requestStream( + method: HTTPMethod, + path: String, + body: Data? = nil + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + Task { + do { + let url = try self.resolveURL(path) + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + request.httpBody = body + for (field, value) in self.credential.authHeaders() { + request.setValue(value, forHTTPHeaderField: field) + } + + let (bytes, urlResponse) = try await self.session.bytes(for: request) + guard let httpResponse = urlResponse as? HTTPURLResponse, (200..<300).contains(httpResponse.statusCode) else { + continuation.finish(throwing: AixplainError.api(APIError( + message: "Stream request failed", + statusCode: (urlResponse as? HTTPURLResponse)?.statusCode ?? 0 + ))) + return + } + + for try await line in bytes.lines { + if line.isEmpty { continue } + if let data = line.data(using: .utf8) { + continuation.yield(data) + } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } + + // MARK: - Private + + /// Resolves URL: absolute paths pass through, relative paths join with backendURL. + private func resolveURL(_ path: String) throws -> URL { + if path.hasPrefix("http://") || path.hasPrefix("https://") { + guard let url = URL(string: path) else { + throw AixplainError.validation(ValidationError("Invalid URL: \(path)")) + } + return url + } + let base = configuration.backendURL.absoluteString.hasSuffix("/") + ? configuration.backendURL.absoluteString + : configuration.backendURL.absoluteString + "/" + guard let url = URL(string: base + path) else { + throw AixplainError.validation(ValidationError("Invalid URL: \(path)")) + } + return url + } +} diff --git a/Sources/aiXplainKit/Client/ClientConfiguration.swift b/Sources/aiXplainKit/Client/ClientConfiguration.swift new file mode 100644 index 0000000..ec39c6b --- /dev/null +++ b/Sources/aiXplainKit/Client/ClientConfiguration.swift @@ -0,0 +1,28 @@ +import Foundation + +/// Transport settings for the aiXplain client. +/// +/// Default URLs match Python v2 `Aixplain` class attributes in `core.py`. +public struct ClientConfiguration: Sendable { + public var backendURL: URL + public var modelsRunURL: URL + public var timeoutInterval: TimeInterval + public var retryPolicy: RetryPolicy + public var userAgent: String + + public init( + backendURL: URL = URL(string: "https://platform-api.aixplain.com")!, + modelsRunURL: URL = URL(string: "https://models.aixplain.com/api/v2/execute")!, + timeoutInterval: TimeInterval = 30, + retryPolicy: RetryPolicy = .default, + userAgent: String = "aiXplainKit-Swift/2.0" + ) { + self.backendURL = backendURL + self.modelsRunURL = modelsRunURL + self.timeoutInterval = timeoutInterval + self.retryPolicy = retryPolicy + self.userAgent = userAgent + } + + public static let `default` = ClientConfiguration() +} diff --git a/Sources/aiXplainKit/Client/HTTPMethod.swift b/Sources/aiXplainKit/Client/HTTPMethod.swift new file mode 100644 index 0000000..fa161bf --- /dev/null +++ b/Sources/aiXplainKit/Client/HTTPMethod.swift @@ -0,0 +1,13 @@ +import Foundation + +public enum HTTPMethod: String, Sendable { + case get = "GET" + case post = "POST" + case put = "PUT" + case delete = "DELETE" + + /// Python v2 only retries GET and POST. + var isRetryable: Bool { + self == .get || self == .post + } +} diff --git a/Sources/aiXplainKit/Client/Response.swift b/Sources/aiXplainKit/Client/Response.swift new file mode 100644 index 0000000..a9ce1e5 --- /dev/null +++ b/Sources/aiXplainKit/Client/Response.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Unified response from the aiXplain client. +public struct Response: @unchecked Sendable { + public let data: Data + public let httpResponse: HTTPURLResponse + + public var statusCode: Int { httpResponse.statusCode } + public var isSuccess: Bool { (200..<300).contains(statusCode) } + + public func decode(_ type: T.Type, using decoder: JSONDecoder = .init()) throws -> T { + try decoder.decode(type, from: data) + } + + public func json() throws -> [String: Any] { + guard let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw AixplainError.validation(ValidationError("Response body is not a JSON object")) + } + return dict + } +} diff --git a/Sources/aiXplainKit/Client/RetryPolicy.swift b/Sources/aiXplainKit/Client/RetryPolicy.swift new file mode 100644 index 0000000..9f40c5e --- /dev/null +++ b/Sources/aiXplainKit/Client/RetryPolicy.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Retry configuration matching Python v2 `create_retry_session()` defaults. +public struct RetryPolicy: Sendable { + public var maxRetries: Int + public var backoffFactor: Double + public var retryableStatusCodes: Set + + public init(maxRetries: Int = 5, backoffFactor: Double = 0.1, retryableStatusCodes: Set = [500, 502, 503, 504]) { + self.maxRetries = maxRetries + self.backoffFactor = backoffFactor + self.retryableStatusCodes = retryableStatusCodes + } + + public static let `default` = RetryPolicy() + + /// Delay for a given attempt (exponential backoff). + func delay(for attempt: Int) -> TimeInterval { + backoffFactor * pow(2.0, Double(attempt)) + } +} diff --git a/Sources/aiXplainKit/Enums/AIFunction.swift b/Sources/aiXplainKit/Enums/AIFunction.swift new file mode 100644 index 0000000..aaec49b --- /dev/null +++ b/Sources/aiXplainKit/Enums/AIFunction.swift @@ -0,0 +1,17 @@ +import Foundation + +/// AI functions supported by the platform. +/// +/// Mirrors Python v2 `Function` enum from `enums.py`. +public enum AIFunction: String, Codable, Sendable { + case search = "SEARCH" + case translation = "TRANSLATION" + case sentimentAnalysis = "SENTIMENT_ANALYSIS" + case classification = "CLASSIFICATION" + case questionAnswering = "QUESTION_ANSWERING" + case textGeneration = "TEXT_GENERATION" + case speechRecognition = "SPEECH_RECOGNITION" + case imageClassification = "IMAGE_CLASSIFICATION" + case objectDetection = "OBJECT_DETECTION" + case utilities +} diff --git a/Sources/aiXplainKit/Enums/AnyCodable.swift b/Sources/aiXplainKit/Enums/AnyCodable.swift new file mode 100644 index 0000000..8e75ae3 --- /dev/null +++ b/Sources/aiXplainKit/Enums/AnyCodable.swift @@ -0,0 +1,76 @@ +import Foundation + +/// Type-erased `Codable` wrapper for heterogeneous JSON values. +/// +/// Wraps `Any` (strings, numbers, bools, arrays, dictionaries, null) with +/// `Codable` conformance via `JSONSerialization`. Used across the SDK for +/// fields where the API returns dynamic JSON shapes. +public struct AnyCodable: @unchecked Sendable, Equatable { + public let value: Any + + public init(_ value: Any) { + self.value = value + } + + public static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool { + switch (lhs.value, rhs.value) { + case (let l as String, let r as String): return l == r + case (let l as Int, let r as Int): return l == r + case (let l as Double, let r as Double): return l == r + case (let l as Bool, let r as Bool): return l == r + case (is NSNull, is NSNull): return true + default: return false + } + } +} + +extension AnyCodable: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + value = NSNull() + } else if let bool = try? container.decode(Bool.self) { + value = bool + } else if let int = try? container.decode(Int.self) { + value = int + } else if let double = try? container.decode(Double.self) { + value = double + } else if let string = try? container.decode(String.self) { + value = string + } else if let array = try? container.decode([AnyCodable].self) { + value = array.map(\.value) + } else if let dict = try? container.decode([String: AnyCodable].self) { + value = dict.mapValues(\.value) + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported JSON value") + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch value { + case is NSNull: + try container.encodeNil() + case let v as Bool: + try container.encode(v) + case let v as Int: + try container.encode(v) + case let v as Double: + try container.encode(v) + case let v as String: + try container.encode(v) + case let v as [Any]: + try container.encode(v.map { AnyCodable($0) }) + case let v as [String: Any]: + try container.encode(v.mapValues { AnyCodable($0) }) + default: + try container.encodeNil() + } + } +} + +extension AnyCodable: CustomStringConvertible { + public var description: String { + "\(value)" + } +} diff --git a/Sources/aiXplainKit/Enums/AssetStatus.swift b/Sources/aiXplainKit/Enums/AssetStatus.swift new file mode 100644 index 0000000..e1fc7e0 --- /dev/null +++ b/Sources/aiXplainKit/Enums/AssetStatus.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Asset status values shared across all resources (Agent, Model, Tool). +/// +/// Mirrors Python v2 `AssetStatus` enum from `enums.py`. +public enum AssetStatus: String, Codable, Sendable { + case draft + case hidden + case scheduled + case onboarding + case onboarded + case pending + case failed + case training + case rejected + case enabling + case deleting + case disabled + case deleted + case inProgress = "in_progress" + case completed + case canceling + case canceled + case deprecatedDraft = "deprecated_draft" +} diff --git a/Sources/aiXplainKit/Enums/ResponseStatus.swift b/Sources/aiXplainKit/Enums/ResponseStatus.swift new file mode 100644 index 0000000..7329967 --- /dev/null +++ b/Sources/aiXplainKit/Enums/ResponseStatus.swift @@ -0,0 +1,10 @@ +import Foundation + +/// Response status for polling operations. +/// +/// Mirrors Python v2 `ResponseStatus` enum from `enums.py`. +public enum ResponseStatus: String, Codable, Sendable { + case inProgress = "IN_PROGRESS" + case success = "SUCCESS" + case failed = "FAILED" +} diff --git a/Sources/aiXplainKit/Enums/Supplier.swift b/Sources/aiXplainKit/Enums/Supplier.swift new file mode 100644 index 0000000..8d9b993 --- /dev/null +++ b/Sources/aiXplainKit/Enums/Supplier.swift @@ -0,0 +1,14 @@ +import Foundation + +/// AI model suppliers. +/// +/// Mirrors Python v2 `Supplier` enum from `enums.py`. +public enum Supplier: String, Codable, Sendable { + case openai = "OPENAI" + case anthropic = "ANTHROPIC" + case google = "GOOGLE" + case meta = "META" + case huggingface = "HUGGINGFACE" + case cohere = "COHERE" + case aixplain = "AIXPLAIN" +} diff --git a/Sources/aiXplainKit/Enums/ToolType.swift b/Sources/aiXplainKit/Enums/ToolType.swift new file mode 100644 index 0000000..0556bda --- /dev/null +++ b/Sources/aiXplainKit/Enums/ToolType.swift @@ -0,0 +1,11 @@ +import Foundation + +/// Tool type categories for agent tool serialization. +/// +/// Mirrors Python v2 `ToolDict["type"]` literal values. +public enum ToolType: String, Codable, Sendable { + case model + case pipeline + case utility + case tool +} diff --git a/Sources/aiXplainKit/Errors/APIError.swift b/Sources/aiXplainKit/Errors/APIError.swift new file mode 100644 index 0000000..03c2eeb --- /dev/null +++ b/Sources/aiXplainKit/Errors/APIError.swift @@ -0,0 +1,86 @@ +import Foundation + +/// HTTP/API-level error with full context. +/// +/// Mirrors Python v2 `APIError`: carries status code, response body, and request ID. +public struct APIError: Error, Sendable, LocalizedError, CustomStringConvertible { + public let message: String + public let statusCode: Int + public let error: String? + public let requestId: String? + private let _responseData: ResponseData? + + /// Thread-safe wrapper for the response dictionary. + struct ResponseData: @unchecked Sendable { + let value: [String: Any] + } + + public var responseData: [String: Any]? { _responseData?.value } + + public init( + message: String, + statusCode: Int = 0, + responseData: [String: Any]? = nil, + error: String? = nil, + requestId: String? = nil + ) { + self.message = message + self.statusCode = statusCode + self._responseData = responseData.map { ResponseData(value: $0) } + self.error = error ?? message + self.requestId = requestId + } + + public var userMessage: String { + if let err = error, !err.isEmpty { return err } + return message + } + + public var errorDescription: String? { message } + + public var description: String { + var parts = ["APIError(\(statusCode)): \(message)"] + if let rid = requestId { parts.append("requestId=\(rid)") } + return parts.joined(separator: ", ") + } + + // MARK: - Factories + + /// Build from a polling response with status == "FAILED". + /// Mirrors Python v2 `create_operation_failed_error(response)`. + public static func fromFailedOperation(_ response: [String: Any]) -> AixplainError { + let errorMsg = (response["supplierError"] as? String) + ?? (response["supplier_error"] as? String) + ?? (response["error_message"] as? String) + ?? (response["error"] as? String) + ?? "Operation failed" + + return .api(APIError( + message: "Operation failed: \(errorMsg)", + statusCode: response["statusCode"] as? Int ?? 0, + responseData: response, + error: errorMsg, + requestId: response["requestId"] as? String + )) + } + + /// Build from a non-2xx HTTP response. + /// Mirrors Python v2 `client.py` error handling in `request_raw()`. + public static func fromHTTPResponse(data: Data, statusCode: Int) -> AixplainError { + if let errorObj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + return .api(APIError( + message: errorObj["message"] as? String + ?? errorObj["error"] as? String + ?? "Request failed with status \(statusCode)", + statusCode: errorObj["statusCode"] as? Int ?? statusCode, + responseData: errorObj, + error: errorObj["error"] as? String, + requestId: errorObj["requestId"] as? String + )) + } + return .api(APIError( + message: String(data: data, encoding: .utf8) ?? "Request failed with status \(statusCode)", + statusCode: statusCode + )) + } +} diff --git a/Sources/aiXplainKit/Errors/Agents+error.swift b/Sources/aiXplainKit/Errors/Agents+error.swift deleted file mode 100644 index 860b2db..0000000 --- a/Sources/aiXplainKit/Errors/Agents+error.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// Agents+error.swift -// aiXplainKit -// -// Created by Joao Maia on 12/11/24. -// -import Foundation - -/// Errors related to model interactions. -enum AgentsError: Error, Equatable { - /// No API key was provided for making API calls. - case missingAPIKey - - /// No backend URL was provided for the backend service. - case missingBackendURL - - /// No Model Run URL was provided for the Run service. - case missingModelRunURL - - /// The provided URL is malformed. - case invalidURL(url: String?) - - /// Error during the recoding of model.run response while schedulling the run. - case failToDecodeRunResponse - - /// This error is thrown when the model is polling the response for the job created at `Model.run` did not receive a response/output in the desired time. - case pollingTimeoutOnModelResponse(pollingURL: URL) - - /// Fail to decode ModelOutput during the polling phase. - case failToDecodeModelOutputDuringPollingPhase(error: String?) - - /// Error reported by the supplier or service. - case supplierError(error: String) - - /// Error reportet when using a file/URL as input and something went wrong - case failToGenerateAFilePayload(error: String) - - /// An unsupported value type was encountered while transforming the dictonary into a model input - case typeNotRecognizedWhileCreatingACombinedInput - - /// An error occurred during input encoding. - case inputEncodingError - - case invalidInput(error:String) - - case errorOnDelete(error:String) - - case errorOnUpdate(error:String) - - case teamOfAgentsHasNoAgents - - var localizedDescription: String { - switch self { - case .missingAPIKey: - return "No API key was provided to make API calls. Please set a key using `AiXplainKit.keyManager`." - case .missingBackendURL: - return "No URL was provided for the backend service. Please set a URL using `AiXplainKit.keyManager`." - case .invalidURL(let url): - guard let url = url else { return "Invalid URL." } - return "The provided URL is malformed: \(url)" - case .failToDecodeRunResponse: - return "Error during the recoding of model.run response while schedulling the run." - case .pollingTimeoutOnModelResponse(pollingURL: let pollingURL): - return "The model did not respond with the output within the expected time during the polling phase. You can try to get the data by the following URL: \(pollingURL.absoluteString)" - case .failToDecodeModelOutputDuringPollingPhase(error: let error): - return "An error occurred while decoding the model output during the polling phase." + (error.map { " Details: \($0)" } ?? "") - case .supplierError(let error): - return "An error ocurred from the suplier side: \(error)." - case .missingModelRunURL: - return "No URL was provided for the Model Run service. Please set a URL using `AiXplainKit.keyManager`." - case .failToGenerateAFilePayload(error: let error): - return "Something went wrong while generating a payload for the model from a file: \(error)" - case .typeNotRecognizedWhileCreatingACombinedInput: - return "An unsupported value type was encountered during dictonary model input generation. Please ensure that all values in the dictonary are either URLs or strings." - case .inputEncodingError: - return "An error occurred during input encoding. Please ensure that all values in the dictonary are either URLs or strings." - case .invalidInput(error: let error): - return "Invalid input. \(error)" - case .errorOnDelete(error: let error): - return "Agent couldn't be deleted. Check if you own the Agent and try again. Error: \(error)" - case .errorOnUpdate(error: let error): - return "Agent couldn't be updated. Check if you own the Agent and try again. Error: \(error)" - case .teamOfAgentsHasNoAgents: - return "The team of agents has no agents." - } - } -} diff --git a/Sources/aiXplainKit/Errors/AixplainError.swift b/Sources/aiXplainKit/Errors/AixplainError.swift new file mode 100644 index 0000000..ec08143 --- /dev/null +++ b/Sources/aiXplainKit/Errors/AixplainError.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Root error for all aiXplain SDK v2 operations. +/// +/// Mirrors Python v2 `AixplainV2Error` hierarchy with Swift enum exhaustive matching. +public enum AixplainError: Error, Sendable, LocalizedError { + case auth(AuthError) + case api(APIError) + case validation(ValidationError) + case timeout(TimeoutError) + case fileUpload(FileUploadError) + case resource(ResourceError) + + public var errorDescription: String? { userMessage } + + /// User-facing message for display in UI, distinct from developer-facing `localizedDescription`. + public var userMessage: String { + switch self { + case .auth(let e): + return e.errorDescription ?? "Authentication failed" + case .api(let e): + return e.userMessage + case .validation(let e): + return e.message + case .timeout(let e): + return e.message + case .fileUpload(let e): + return e.message + case .resource(let e): + return e.message + } + } +} diff --git a/Sources/aiXplainKit/Errors/AuthError.swift b/Sources/aiXplainKit/Errors/AuthError.swift new file mode 100644 index 0000000..87190ed --- /dev/null +++ b/Sources/aiXplainKit/Errors/AuthError.swift @@ -0,0 +1,19 @@ +import Foundation + +/// Authentication errors thrown during credential resolution and validation. +public enum AuthError: Error, Sendable, LocalizedError, Equatable { + case noCredentialFound + case emptyKey + case bothKeysProvided + + public var errorDescription: String? { + switch self { + case .noCredentialFound: + return "API key is required. Pass it as an argument or set the TEAM_API_KEY environment variable." + case .emptyKey: + return "API key must not be empty." + case .bothKeysProvided: + return "Either `aixplainKey` or `teamKey` should be set, not both." + } + } +} diff --git a/Sources/aiXplainKit/Errors/File+Error.swift b/Sources/aiXplainKit/Errors/File+Error.swift deleted file mode 100644 index 8df5867..0000000 --- a/Sources/aiXplainKit/Errors/File+Error.swift +++ /dev/null @@ -1,52 +0,0 @@ -/* -AiXplainKit Library. ---- - -aiXplain SDK enables Swift programmers to add AI functions -to their software. - -Copyright 2024 The aiXplain SDK authors - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. - AUTHOR: João Pedro Maia - */ -import Foundation - -/// Represents errors that can occur during file operations. -enum FileError: Error { - /// The file size exceeds the maximum allowed limit. - case fileSizeExceedsLimit - - /// An error occurred while generating the payload for obtaining the pre-signed URL for uploading the file. - case payloadGenerationFailed(description: String) - - /// Failed to obtain the pre-signed URL for uploading the file to S3. - case couldNotGetTheS3PreSignedURL - - /// The bucket name for the S3 upload was not found. - case bucketNameNotFound - - /// A description of the error. - var errorDescription: String { - switch self { - case .fileSizeExceedsLimit: - return "The file size exceeds the maximum allowed limit." - case .payloadGenerationFailed(let description): - return "An error occurred while generating the payload for obtaining the pre-signed URL: \(description)" - case .couldNotGetTheS3PreSignedURL: - return "Failed to obtain the pre-signed URL for uploading the file to S3." - case .bucketNameNotFound: - return "The bucket name for the S3 upload was not found." - } - } -} diff --git a/Sources/aiXplainKit/Errors/FileUploadError.swift b/Sources/aiXplainKit/Errors/FileUploadError.swift new file mode 100644 index 0000000..220fc42 --- /dev/null +++ b/Sources/aiXplainKit/Errors/FileUploadError.swift @@ -0,0 +1,16 @@ +import Foundation + +/// File upload operation failure. +/// +/// Mirrors Python v2 `FileUploadError`. +public struct FileUploadError: Error, Sendable, LocalizedError, Equatable { + public let message: String + public let fileName: String? + + public init(_ message: String, fileName: String? = nil) { + self.message = message + self.fileName = fileName + } + + public var errorDescription: String? { message } +} diff --git a/Sources/aiXplainKit/Errors/Model+Error.swift b/Sources/aiXplainKit/Errors/Model+Error.swift deleted file mode 100644 index a70a14e..0000000 --- a/Sources/aiXplainKit/Errors/Model+Error.swift +++ /dev/null @@ -1,107 +0,0 @@ -/* -AiXplainKit Library. ---- - -aiXplain SDK enables Swift programmers to add AI functions -to their software. - -Copyright 2024 The aiXplain SDK authors - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. - AUTHOR: João Pedro Maia - */ -import Foundation - -/// Errors related to model interactions. -enum ModelError: Error, Equatable { - /// No API key was provided for making API calls. - case missingAPIKey - - /// No backend URL was provided for the backend service. - case missingBackendURL - - /// No Model Run URL was provided for the Run service. - case missingModelRunURL - - /// The provided URL is malformed. - case invalidURL(url: String?) - - /// Error during the recoding of model.run response while schedulling the run. - case failToDecodeRunResponse - - /// This error is thrown when the model is polling the response for the job created at `Model.run` did not receive a response/output in the desired time. - case pollingTimeoutOnModelResponse(pollingURL: URL) - - /// Fail to decode ModelOutput during the polling phase. - case failToDecodeModelOutputDuringPollingPhase(error: String?) - - /// Error reported by the supplier or service. - case supplierError(error: String) - - /// Error reportet when using a file/URL as input and something went wrong - case failToGenerateAFilePayload(error: String) - - /// An unsupported value type was encountered while transforming the dictonary into a model input - case typeNotRecognizedWhileCreatingACombinedInput - - /// An error occurred during input encoding. - case inputEncodingError - - case noResponse(endpoint: String) - - case missingModelUtilityID - - case modelUtilityCreationError(error: String) - - case failToCallModelExecuteFromUtility(error: String) - - case unableToUpdateModelUtility(error: String) - - var localizedDescription: String { - switch self { - case .missingAPIKey: - return "No API key was provided to make API calls. Please set a key using `AiXplainKit.keyManager`." - case .missingBackendURL: - return "No URL was provided for the backend service. Please set a URL using `AiXplainKit.keyManager`." - case .invalidURL(let url): - guard let url = url else { return "Invalid URL." } - return "The provided URL is malformed: \(url)" - case .failToDecodeRunResponse: - return "Error during the recoding of model.run response while schedulling the run." - case .pollingTimeoutOnModelResponse(pollingURL: let pollingURL): - return "The model did not respond with the output within the expected time during the polling phase. You can try to get the data by the following URL: \(pollingURL.absoluteString)" - case .failToDecodeModelOutputDuringPollingPhase(error: let error): - return "An error occurred while decoding the model output during the polling phase." + (error.map { " Details: \($0)" } ?? "") - case .supplierError(let error): - return "An error ocurred from the suplier side: \(error)." - case .missingModelRunURL: - return "No URL was provided for the Model Run service. Please set a URL using `AiXplainKit.keyManager`." - case .failToGenerateAFilePayload(error: let error): - return "Something went wrong while generating a payload for the model from a file: \(error)" - case .typeNotRecognizedWhileCreatingACombinedInput: - return "An unsupported value type was encountered during dictonary model input generation. Please ensure that all values in the dictonary are either URLs or strings." - case .inputEncodingError: - return "Encoding of the input failed." - case .noResponse(endpoint: let endpoint): - return "No response was received from the \(endpoint)." - case .missingModelUtilityID: - return "No model Uility ID was returned from the server." - case .modelUtilityCreationError(error: let error): - return "No model utility ID was returned from the server. \(error)" - case .failToCallModelExecuteFromUtility(error: let error): - return "Fail to call model execute from utility: \(error)." - case .unableToUpdateModelUtility(error: let error): - return "Unable to update model utility: \(error)." - } - } -} diff --git a/Sources/aiXplainKit/Errors/Networking+Error.swift b/Sources/aiXplainKit/Errors/Networking+Error.swift deleted file mode 100644 index 3ea84cd..0000000 --- a/Sources/aiXplainKit/Errors/Networking+Error.swift +++ /dev/null @@ -1,57 +0,0 @@ -/* -AiXplainKit Library. ---- - -aiXplain SDK enables Swift programmers to add AI functions -to their software. - -Copyright 2024 The aiXplain SDK authors - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation - -/// This enum represents different types of errors that can occur during network operations. -enum NetworkingError: Error, Equatable { - - /// Indicates that the HTTP response received was invalid. - case invalidHttpResponse - - /// Indicates that an invalid HTTP status code was received from the network request. - /// The associated value stores the specific status code. - case invalidStatusCode(statusCode: Int) - - /// Indicates that the provided URL is malformed. - /// The associated value stores the malformed URL string. - case invalidURL(url: String?) - - /// Indicates that the maximum number of retries for the network request has been reached. - case maxRetryReached - - /// A localized description of the error. - var localizedDescription: String { - switch self { - case .invalidStatusCode(let statusCode): - return "Invalid status code received: \(statusCode)" - case .invalidURL(let url): - guard let urlString = url else { return "Invalid URL." } - return "The provided URL is malformed: \(urlString)" - case .invalidHttpResponse: - return "The provided HTTP response is invalid" - case .maxRetryReached: - return "The maximum number of retries for the network request has been reached." - } - } -} diff --git a/Sources/aiXplainKit/Errors/Pipeline+Error.swift b/Sources/aiXplainKit/Errors/Pipeline+Error.swift deleted file mode 100644 index 8a265e7..0000000 --- a/Sources/aiXplainKit/Errors/Pipeline+Error.swift +++ /dev/null @@ -1,77 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation - -enum PipelineError: Error, Equatable { - /// No API key was provided for making API calls. - case missingAPIKey - - /// No backend URL was provided for the backend service. - case missingBackendURL - - /// The provided URL is malformed. - case invalidURL(url: String?) - - /// Error during the recoding of model.run response while schedulling the run. - case failToDecodeRunResponse - - /// This error is thrown when the model is polling the response for the job created at `Model.run` did not receive a response/output in the desired time. - case pollingTimeoutOnModelResponse(pollingURL: URL) - - /// Fail to decode ModelOutput during the polling phase. - case failToDecodeModelOutputDuringPollingPhase(error: String?) - - /// Error reported by the supplier or service. - case supplierError(error: String) - - /// An unsupported value type was encountered during input payload generation. - case typeNotRecognizedWhileCreatingACombinedInput - - /// An error occurred during input encoding. - case inputEncodingError - - var localizedDescription: String { - switch self { - case .missingAPIKey: - return "No API key was provided to make API calls. Please set a key using `AiXplainKit.keyManager`." - case .missingBackendURL: - return "No URL was provided for the backend service. Please set a URL using `AiXplainKit.keyManager`." - case .invalidURL(let url): - guard let url = url else { return "Invalid URL." } - return "The provided URL is malformed: \(url)" - case .failToDecodeRunResponse: - return "Error during the recoding of model.run response while schedulling the run." - case .pollingTimeoutOnModelResponse(pollingURL: let pollingURL): - return "The model did not respond with the output within the expected time during the polling phase. You can try to get the data by the following URL: \(pollingURL.absoluteString)" - case .failToDecodeModelOutputDuringPollingPhase(error: let error): - return "An error occurred while decoding the model output during the polling phase." + (error.map { " Details: \($0)" } ?? "") - case .supplierError(let error): - return "An error ocurred from the suplier side: \(error)." - case .typeNotRecognizedWhileCreatingACombinedInput: - return "An unsupported value type was encountered during input payload generation. Please ensure that all input values are either URLs or strings." - case .inputEncodingError: - return "An error occurred during input encoding. Please check the input data and try again." - } - } -} diff --git a/Sources/aiXplainKit/Errors/ResourceError.swift b/Sources/aiXplainKit/Errors/ResourceError.swift new file mode 100644 index 0000000..a29e557 --- /dev/null +++ b/Sources/aiXplainKit/Errors/ResourceError.swift @@ -0,0 +1,14 @@ +import Foundation + +/// Resource-level operation failure (not found, invalid state, context missing). +/// +/// Mirrors Python v2 `ResourceError`. +public struct ResourceError: Error, Sendable, LocalizedError, Equatable { + public let message: String + + public init(_ message: String) { + self.message = message + } + + public var errorDescription: String? { message } +} diff --git a/Sources/aiXplainKit/Errors/TimeoutError.swift b/Sources/aiXplainKit/Errors/TimeoutError.swift new file mode 100644 index 0000000..5296978 --- /dev/null +++ b/Sources/aiXplainKit/Errors/TimeoutError.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Polling exceeded its retry/time budget. +/// +/// Mirrors Python v2 `TimeoutError`. +public struct TimeoutError: Error, Sendable, LocalizedError { + public let message: String + public let pollingURL: String? + public let timeout: TimeInterval? + + public init(_ message: String, pollingURL: String? = nil, timeout: TimeInterval? = nil) { + self.message = message + self.pollingURL = pollingURL + self.timeout = timeout + } + + public var errorDescription: String? { message } +} diff --git a/Sources/aiXplainKit/Errors/ValidationError.swift b/Sources/aiXplainKit/Errors/ValidationError.swift new file mode 100644 index 0000000..35aee96 --- /dev/null +++ b/Sources/aiXplainKit/Errors/ValidationError.swift @@ -0,0 +1,14 @@ +import Foundation + +/// Client-side validation error thrown before a request is sent. +/// +/// Mirrors Python v2 `ValidationError`. +public struct ValidationError: Error, Sendable, LocalizedError, Equatable { + public let message: String + + public init(_ message: String) { + self.message = message + } + + public var errorDescription: String? { message } +} diff --git a/Sources/aiXplainKit/Extensions/URL+MimeType.swift b/Sources/aiXplainKit/Extensions/URL+MimeType.swift deleted file mode 100644 index df539dd..0000000 --- a/Sources/aiXplainKit/Extensions/URL+MimeType.swift +++ /dev/null @@ -1,60 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - */ - -import Foundation -import UniformTypeIdentifiers -extension URL { - /** - Returns the MIME type associated with the file at the URL's path. - - The `mimeType()` method returns a `String` representing the MIME type of the file at the URL's path. If the file extension is recognized by the system, the method returns the preferred MIME type for that extension. If the file extension is not recognized, the method returns the default MIME type `"application/octet-stream"`. - - MIME types, or Multipurpose Internet Mail Extensions, are used to identify the type of data being transmitted over the Internet. They are especially important when transferring files, as they allow recipient applications to determine how to handle the incoming data. - - - Semantics: - - If the URL's path extension is recognized by the system, the method returns the preferred MIME type for that extension. - - If the URL's path extension is not recognized, the method returns `"application/octet-stream"`. - - If the URL does not have a path extension, the method returns `"application/octet-stream"`. - - - Returns: The MIME type associated with the file at the URL's path. - - - Examples: - ```swift - let fileURL = URL(fileURLWithPath: "/path/to/file.pdf") - let mimeType = fileURL.mimeType() // Returns "application/pdf" - ``` - In this example, the `mimeType()` method returns `"application/pdf"` because the `.pdf` extension is recognized by the system, and the preferred MIME type for PDF files is `"application/pdf"`. - - ```swift - let fileURL = URL(fileURLWithPath: "/path/to/file.unknown") - let mimeType = fileURL.mimeType() // Returns "application/octet-stream" - ``` - In this example, the `mimeType()` method returns `"application/octet-stream"` because the `.unknown` extension is not recognized by the system, and `"application/octet-stream"` is the default MIME type for unknown file types. - */ - public func mimeType() -> String { - if let mimeType = UTType(filenameExtension: self.pathExtension)?.preferredMIMEType { - return mimeType - } else { - return "application/octet-stream" - } - } -} diff --git a/Sources/aiXplainKit/Index/EmbeddingModel.swift b/Sources/aiXplainKit/Index/EmbeddingModel.swift new file mode 100644 index 0000000..bd8c544 --- /dev/null +++ b/Sources/aiXplainKit/Index/EmbeddingModel.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Supported embedding models for index creation. +/// +/// Adapted from v1 with Swift `camelCase` convention (resolved question). +public enum EmbeddingModel: Sendable, Identifiable, Hashable { + case snowflakeArcticEmbedMLong + case openaiAda002 + case snowflakeArcticEmbedLV20 + case jinaClipV2Multimodal + case multilingualE5Large + case bgeM3 + case aixplainLegalEmbeddings + case custom(id: String) + + public var id: String { modelId } + + var modelId: String { + switch self { + case .snowflakeArcticEmbedMLong: return "6658d40729985c2cf72f42ec" + case .openaiAda002: return "6734c55df127847059324d9e" + case .snowflakeArcticEmbedLV20: return "678a4f8547f687504744960a" + case .jinaClipV2Multimodal: return "67c5f705d8f6a65d6f74d732" + case .multilingualE5Large: return "67efd0772a0a850afa045af3" + case .bgeM3: return "67efd4f92a0a850afa045af7" + case .aixplainLegalEmbeddings: return "681254b668e47e7844c1f15a" + case .custom(let id): return id + } + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(modelId) + } + + public static func == (lhs: EmbeddingModel, rhs: EmbeddingModel) -> Bool { + lhs.modelId == rhs.modelId + } +} + +/// Index engine backend. +/// +/// Renamed from v1 `AiXplainEngine` for clarity. +public enum IndexEngine: Sendable { + case air + case custom(id: String) + + var id: String { + switch self { + case .air: return "66eae6656eb56311f2595011" + case .custom(let id): return id + } + } +} diff --git a/Sources/aiXplainKit/Index/Index.swift b/Sources/aiXplainKit/Index/Index.swift new file mode 100644 index 0000000..e182160 --- /dev/null +++ b/Sources/aiXplainKit/Index/Index.swift @@ -0,0 +1,193 @@ +import Foundation +import OSLog + +/// Index resource -- standalone type wrapping a Model (resolved question: not a subclass). +/// +/// Provides search (text/image), upsert, getDocument, and count operations. +/// An index is backed by a model with `function == "search"`. +public final class Index: @unchecked Sendable { + private static let logger = Logger(subsystem: "aiXplainKit", category: "Index") + + public var id: String? + public var name: String? + public var description: String? + public var context: Aixplain? + + public init(id: String? = nil, name: String? = nil, description: String? = nil, context: Aixplain? = nil) { + self.id = id + self.name = name + self.description = description + self.context = context + } + + // MARK: - Get + + public static func get(_ id: String, context: Aixplain) async throws -> Index { + let model = try await Model.get(id, context: context) + return Index(id: model.id, name: model.name, description: model.description, context: context) + } + + // MARK: - Create + + /// Create a new index using an engine model and embedding model. + /// Mirrors v1 `IndexProvider.create()` flow. + public static func create( + name: String, + description: String, + embedding: EmbeddingModel = .openaiAda002, + engine: IndexEngine = .air, + context: Aixplain + ) async throws -> Index { + let engineModel = try await Model.get(engine.id, context: context) + + let payload: [String: Any] = [ + "data": name, + "description": description, + "model": embedding.modelId + ] + let result = try await engineModel.run(parameters: payload) + + guard let output = result.data?.value as? String, !output.isEmpty else { + throw AixplainError.resource(ResourceError("Failed to create index: no model ID returned")) + } + + return try await Index.get(output, context: context) + } + + // MARK: - Search (text) + + public func search(_ query: String, topK: Int = 10, filters: [IndexFilter] = []) async throws -> IndexSearchResult { + let payload: [String: Any] = [ + "action": "search", + "data": query, + "data_type": "text", + "filters": filters.map { $0.toDict() }, + "payload": [ + "uri": "", + "top_k": topK, + "value_type": "text" + ] + ] + return try await runIndexOperation(payload) + } + + // MARK: - Search (image) + + public func search(imageURL: URL, topK: Int = 10, filters: [IndexFilter] = []) async throws -> IndexSearchResult { + let payload: [String: Any] = [ + "action": "search", + "data": "", + "data_type": "image", + "filters": filters.map { $0.toDict() }, + "payload": [ + "uri": imageURL.absoluteString, + "top_k": topK, + "value_type": "image" + ] + ] + return try await runIndexOperation(payload) + } + + // MARK: - Upsert + + @discardableResult + public func upsert(_ records: [Record]) async throws -> Bool { + let recordDicts = records.map { $0.toDictionary() } + let payload: [String: Any] = ["action": "ingest", "data": recordDicts] + let result = try await executeModel(payload) + let output = result["data"] as? String ?? "" + return output.lowercased().contains("success") + } + + // MARK: - Get Document + + public func getDocument(_ documentId: String) async throws -> Record? { + let payload: [String: Any] = ["action": "get_document", "data": documentId] + let result = try await executeModel(payload) + guard let output = result["data"] as? String, !output.isEmpty else { return nil } + return Record(text: output, id: documentId) + } + + // MARK: - Count + + public func count() async throws -> Int { + let payload: [String: Any] = ["action": "count", "data": ""] + let result = try await executeModel(payload) + if let output = result["data"] as? String { return Int(output) ?? -1 } + if let output = result["data"] as? Int { return output } + return -1 + } + + // MARK: - Subscript + + public subscript(documentId: String) -> Record? { + get async throws { + try await getDocument(documentId) + } + } + + // MARK: - Private + + private func runIndexOperation(_ payload: [String: Any]) async throws -> IndexSearchResult { + let result = try await executeAndPoll(payload) + return IndexSearchResult.from(result) + } + + private func executeModel(_ payload: [String: Any]) async throws -> [String: Any] { + try await executeAndPoll(payload) + } + + private func executeAndPoll(_ payload: [String: Any]) async throws -> [String: Any] { + guard let id else { + throw AixplainError.validation(ValidationError("Index has not been saved yet.")) + } + guard let ctx = context else { + throw AixplainError.resource(ResourceError("Context is required for index operations.")) + } + + let runURL = "\(ctx.modelURL.absoluteString)/\(id)" + let body = try JSONSerialization.data(withJSONObject: payload) + let response = try await ctx.client.requestRaw(method: .post, path: runURL, body: body) + let responseDict = try JSONSerialization.jsonObject(with: response.data) as? [String: Any] ?? [:] + + let status = responseDict["status"] as? String ?? "IN_PROGRESS" + if status == ResponseStatus.failed.rawValue { + throw APIError.fromFailedOperation(responseDict) + } + + if responseDict["completed"] as? Bool == true { + return responseDict + } + + guard let pollURL = responseDict["data"] as? String, pollURL.hasPrefix("http") else { + if let pollingURL = responseDict["url"] as? String { + return try await pollIndex(pollingURL, context: ctx) + } + return responseDict + } + + return try await pollIndex(pollURL, context: ctx) + } + + private func pollIndex(_ pollURL: String, context ctx: Aixplain, timeout: TimeInterval = 300) async throws -> [String: Any] { + let startTime = Date() + var waitTime = 0.5 + + while Date().timeIntervalSince(startTime) < timeout { + let response = try await ctx.client.get(pollURL) + let status = response["status"] as? String ?? "IN_PROGRESS" + + if status == ResponseStatus.failed.rawValue { + throw APIError.fromFailedOperation(response) + } + if response["completed"] as? Bool == true { + return response + } + + try await Task.sleep(nanoseconds: UInt64(waitTime * 1_000_000_000)) + waitTime = min(waitTime * 1.1, 60) + } + + throw AixplainError.timeout(TimeoutError("Index operation timed out", pollingURL: pollURL, timeout: timeout)) + } +} diff --git a/Sources/aiXplainKit/Index/IndexFilter.swift b/Sources/aiXplainKit/Index/IndexFilter.swift new file mode 100644 index 0000000..9867d4f --- /dev/null +++ b/Sources/aiXplainKit/Index/IndexFilter.swift @@ -0,0 +1,71 @@ +import Foundation + +/// Comparison operators for index field filters. +public enum FieldOperator: Sendable { + case equals(String) + case notEquals(String) + case contains(String) + case notContains(String) + case greaterThan(String) + case lessThan(String) + case greaterThanOrEquals(String) + case lessThanOrEquals(String) + + var value: String { + switch self { + case .equals(let v), .notEquals(let v), .contains(let v), .notContains(let v), + .greaterThan(let v), .lessThan(let v), .greaterThanOrEquals(let v), .lessThanOrEquals(let v): + return v + } + } + + var operatorString: String { + switch self { + case .equals: return "==" + case .notEquals: return "!=" + case .contains: return "in" + case .notContains: return "not in" + case .greaterThan: return ">" + case .lessThan: return "<" + case .greaterThanOrEquals: return ">=" + case .lessThanOrEquals: return "<=" + } + } +} + +/// A filter for constraining index search queries. +/// +/// Adapted from v1 with builder pattern support. +public struct IndexFilter: Sendable { + public let fieldName: String + public let operation: FieldOperator + + public init(fieldName: String, operation: FieldOperator) { + self.fieldName = fieldName + self.operation = operation + } + + /// Subscript shorthand: `IndexFilter["author", .equals("Woolf")]` + public static subscript(fieldName: String, operation: FieldOperator) -> IndexFilter { + IndexFilter(fieldName: fieldName, operation: operation) + } + + public func toDict() -> [String: String] { + ["field": fieldName, "value": operation.value, "operator": operation.operatorString] + } + + public static func builder() -> IndexFilterBuilder { IndexFilterBuilder() } +} + +/// Builder for chaining index filters. +public class IndexFilterBuilder { + private var filters: [IndexFilter] = [] + + @discardableResult + public func `where`(_ field: String, _ op: FieldOperator) -> IndexFilterBuilder { + filters.append(IndexFilter(fieldName: field, operation: op)) + return self + } + + public func build() -> [IndexFilter] { filters } +} diff --git a/Sources/aiXplainKit/Index/IndexSearchResult.swift b/Sources/aiXplainKit/Index/IndexSearchResult.swift new file mode 100644 index 0000000..8439fe4 --- /dev/null +++ b/Sources/aiXplainKit/Index/IndexSearchResult.swift @@ -0,0 +1,52 @@ +import Foundation + +/// Search result from an index query. +public struct IndexSearchResult: @unchecked Sendable { + public let hits: [SearchHit] + public let rawData: [String: Any]? + + public init(hits: [SearchHit], rawData: [String: Any]? = nil) { + self.hits = hits + self.rawData = rawData + } + + /// Parse from API response. + public static func from(_ dict: [String: Any]) -> IndexSearchResult { + var hits: [SearchHit] = [] + if let dataDict = dict["data"] as? [String: Any], + let results = dataDict["results"] as? [[String: Any]] ?? dataDict["documents"] as? [[String: Any]] { + hits = results.compactMap { SearchHit.from($0) } + } else if let dataStr = dict["data"] as? String, + let jsonData = dataStr.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any], + let results = parsed["results"] as? [[String: Any]] ?? parsed["documents"] as? [[String: Any]] { + hits = results.compactMap { SearchHit.from($0) } + } + return IndexSearchResult(hits: hits, rawData: dict) + } +} + +/// A single search hit from an index query. +public struct SearchHit: Sendable { + public let documentId: String + public let score: Double + public let data: String + public let attributes: [String: String] + + public init(documentId: String, score: Double = 0, data: String = "", attributes: [String: String] = [:]) { + self.documentId = documentId + self.score = score + self.data = data + self.attributes = attributes + } + + public static func from(_ dict: [String: Any]) -> SearchHit? { + guard let docId = dict["document_id"] as? String ?? dict["documentId"] as? String else { return nil } + return SearchHit( + documentId: docId, + score: dict["score"] as? Double ?? 0, + data: dict["data"] as? String ?? "", + attributes: dict["attributes"] as? [String: String] ?? [:] + ) + } +} diff --git a/Sources/aiXplainKit/Index/Record.swift b/Sources/aiXplainKit/Index/Record.swift new file mode 100644 index 0000000..5e51958 --- /dev/null +++ b/Sources/aiXplainKit/Index/Record.swift @@ -0,0 +1,70 @@ +import Foundation + +/// A single item in an index -- text or image with metadata. +/// +/// Adapted from v1 `Record` with Sendable conformance and cleanup. +public struct Record: Codable, Identifiable, Sendable { + + public enum DataType: String, Codable, Sendable { + case text + case image + } + + public let id: String + public let dataType: DataType + public let value: String + public let attributes: [String: String] + public let uri: String? + + public init(text: String, attributes: [String: String] = [:], id: String = UUID().uuidString) { + self.id = id + self.dataType = .text + self.value = text + self.attributes = attributes + self.uri = nil + } + + public init(imageURL: URL, attributes: [String: String] = [:], id: String = UUID().uuidString) { + self.id = id + self.dataType = .image + self.value = "" + self.attributes = attributes + self.uri = imageURL.absoluteString + } + + // MARK: - Codable + + private enum CodingKeys: String, CodingKey { + case data, dataType, documentID = "document_id", uri, attributes + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(value, forKey: .data) + try container.encode(dataType.rawValue, forKey: .dataType) + try container.encode(id, forKey: .documentID) + try container.encodeIfPresent(uri, forKey: .uri) + try container.encode(attributes, forKey: .attributes) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + value = try container.decode(String.self, forKey: .data) + let typeStr = try container.decode(String.self, forKey: .dataType) + dataType = DataType(rawValue: typeStr) ?? .text + id = try container.decode(String.self, forKey: .documentID) + uri = try container.decodeIfPresent(String.self, forKey: .uri) + attributes = try container.decodeIfPresent([String: String].self, forKey: .attributes) ?? [:] + } + + /// Dictionary for API payloads. + public func toDictionary() -> [String: Any] { + [ + "data": value, + "dataType": dataType.rawValue, + "document_id": id, + "attributes": attributes, + "uri": uri ?? "" + ] + } +} diff --git a/Sources/aiXplainKit/Manager/APIKeyManager.swift b/Sources/aiXplainKit/Manager/APIKeyManager.swift deleted file mode 100644 index 975100e..0000000 --- a/Sources/aiXplainKit/Manager/APIKeyManager.swift +++ /dev/null @@ -1,117 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation - -/** - A singleton class responsible for managing API keys used by the application. - - The `APIKeyManager` class provides a centralized place to store and retrieve various API keys required by the application. It supports loading API keys from environment variables and manually setting them in code. - - - Important: Ensure that you securely store and manage your API keys. Avoid committing API keys to version control systems or distributing them with your application. - - ## Setting the keys - - An example of how to use the `APIKeyManager` to retrieve and set API keys. - - To set the API keys using Xcode environment variables, follow these steps: - - 1. In Xcode, select your project in the Project Navigator. - 2. Select your target, then click the "Info" tab. - 3. Under the "Configurations" section, click the "+" button in the bottom-left corner. - 4. In the newly added row, set the "Name" to the desired API key name (e.g., "TEAM_API_KEY") and the "Value" to your API key. - 5. Repeat step 4 for each API key you need to set. - - With the environment variables set, the `APIKeyManager` will automatically load and use the API keys from the corresponding environment variables. - - You can also set the API keys directly in code if needed: - - ```swift - AiXplainKit.shared.keyManager.TEAM_API_KEY = "" -``` - - ### - - - */ -public final class APIKeyManager { - - /// The shared instance of the APIKeyManager. - public static var shared = APIKeyManager() - - /// The base URL for the backend API. - public var BACKEND_URL: URL? - - /// The URL for the models run API endpoint. - public var MODELS_RUN_URL: URL? - - public var TEAM_API_KEY: String? - public var AIXPLAIN_API_KEY: String? - public var PIPELINE_API_KEY: String? - public var MODEL_API_KEY: String? - - /// The API token for Hugging Face (optional). - public var HF_TOKEN: String? - - /// Initializes an APIKeyManager with nil values for its properties. - internal init() { - loadAPIKeysFromProcessInfo() - } - - /// Fetches API keys and URLs from ProcessInfo and populates the relevant properties. - private func loadAPIKeysFromProcessInfo() { - // Load URLs from environment, falling back to defaults if not found - if let backendURLString = ProcessInfo.processInfo.environment["BACKEND_URL"], - let url = URL(string: backendURLString) { - self.BACKEND_URL = url - } else { - self.BACKEND_URL = URL(string: "https://platform-api.aixplain.com") - } - - if let modelsURLString = ProcessInfo.processInfo.environment["MODELS_RUN_URL"], - let url = URL(string: modelsURLString) { - self.MODELS_RUN_URL = url - } else { - self.MODELS_RUN_URL = URL(string: "https://models.aixplain.com/api/v1/execute/") - } - - // Load API keys - self.TEAM_API_KEY = ProcessInfo.processInfo.environment["TEAM_API_KEY"] - self.AIXPLAIN_API_KEY = ProcessInfo.processInfo.environment["AIXPLAIN_API_KEY"] - self.PIPELINE_API_KEY = ProcessInfo.processInfo.environment["PIPELINE_API_KEY"] - self.MODEL_API_KEY = ProcessInfo.processInfo.environment["MODEL_API_KEY"] - - self.HF_TOKEN = ProcessInfo.processInfo.environment["HF_TOKEN"] - } - - /// Clean all keys provided - public func clear() { - self.TEAM_API_KEY = nil - self.AIXPLAIN_API_KEY = nil - self.PIPELINE_API_KEY = nil - self.MODEL_API_KEY = nil - - self.HF_TOKEN = nil - } - -} diff --git a/Sources/aiXplainKit/Manager/FileManager/File+SizeLimit.swift b/Sources/aiXplainKit/Manager/FileManager/File+SizeLimit.swift deleted file mode 100644 index c5e141a..0000000 --- a/Sources/aiXplainKit/Manager/FileManager/File+SizeLimit.swift +++ /dev/null @@ -1,88 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation - -extension FileUploadManager { - - /// Defines the file size limit for different types of files based on their MIME types. - enum FileSizeLimit: String { - /// Represents audio files. - case audio - /// Represents application files (e.g., documents, executables). - case application - /// Represents video files. - case video - /// Represents image files. - case image - /// Represents other types of files. - case other - - /// The maximum file size limit in bytes for the given file type. - var limit: Int { - let megabyte25 = 26214400 - let megabyte50 = 52428800 - let megabyte300 = 314572800 - - switch self { - case .audio: - return megabyte50 - case .application: - return megabyte25 - case .video: - return megabyte300 - case .image: - return megabyte25 - case .other: - return megabyte50 - } - } - - /// Checks if the file at the given URL is under the size limit based on its MIME type. - /// - /// This function uses the `mimeType` function to determine the MIME type of the file and compares its size with the corresponding limit defined in the `FileSizeLimit` enum. - /// - /// - Parameter url: The URL of the file to check. - /// - Throws: An `NSError` if the file size exceeds the limit for its MIME type. - static func check(fileAt fileURL: URL) throws -> Bool { - let mimeType: String = fileURL.mimeType() - var fileSizeLimit: FileSizeLimit - - if let mimeTypePart = mimeType.split(separator: "/").first, - let fileSizeLimitCase = FileSizeLimit(rawValue: String(mimeTypePart)) { - fileSizeLimit = fileSizeLimitCase - } else { - fileSizeLimit = .other - } - - let filePath = fileURL.path - let fileSize = try FileManager.default.attributesOfItem(atPath: filePath)[.size] as? Int ?? 0 - - if fileSize > fileSizeLimit.limit { - return false - } else { - return true - } - } - } -} diff --git a/Sources/aiXplainKit/Manager/FileManager/FileManager.swift b/Sources/aiXplainKit/Manager/FileManager/FileManager.swift deleted file mode 100644 index da6c78e..0000000 --- a/Sources/aiXplainKit/Manager/FileManager/FileManager.swift +++ /dev/null @@ -1,244 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation -import OSLog - -/// This class is responsible for managing the file uploads related operations for the Model and Pipeline. -public final class FileUploadManager { - - let networking: Networking - let logger: Logger - - public init() { - self.networking = Networking() - self.logger = Logger(subsystem: "AiXplain", category: "FileManager") - } - - init(networking: Networking) { - self.networking = networking - self.logger = Logger(subsystem: "AiXplain", category: "FileManager") - } - - // Uploads a file located at the specified local URL. - /// - Parameters: - /// - localFileURL: The local URL of the file to be uploaded. - /// - isTemporary: A boolean indicating whether the file is temporary or not. - /// - tags: A dictionary containing tags associated with the file. - /// - license: The license associated with the file. - /// - Returns: The URL of the uploaded file in cloud storage. - /// - Throws: - /// - `FileUploadError.fileSizeExceedsLimit` if the file size exceeds the maximum allowed limit. - /// - Other errors related to networking, payload generation, or missing bucket name. - func uploadFile(at localUrl: URL, temporary: Bool = true, tags: [String: String] = [:], license: License? = nil) async throws -> URL { - if try FileSizeLimit.check(fileAt: localUrl) == false { - logger.error("\(FileError.fileSizeExceedsLimit.errorDescription)") - throw FileError.fileSizeExceedsLimit - } - - let headers = ["Content-Type": localUrl.mimeType()] - - let payload = try Data(contentsOf: localUrl) - - let preSignedURL = try await getPreSignedURLs(at: localUrl, temporary: temporary, tags: tags, license: license) - - let response = try await networking.put(url: preSignedURL.uploadURL, body: payload, headers: headers) - - guard let httpResponse = response.1 as? HTTPURLResponse else { - logger.error("\(NetworkingError.invalidHttpResponse.localizedDescription)") - throw NetworkingError.invalidHttpResponse - } - - if httpResponse.statusCode != 200 { - logger.error("\(NetworkingError.invalidStatusCode(statusCode: httpResponse.statusCode).localizedDescription)") - throw NetworkingError.invalidStatusCode(statusCode: httpResponse.statusCode) - } - - logger.info("Successfully uploaded \(localUrl.lastPathComponent) to cloud storage") - - let s3Link = try constructS3Link(from: preSignedURL.downloadURL) - guard let s3URL = URL(string: s3Link) else { - throw FileError.bucketNameNotFound - } - - return s3URL - } - - /// Obtains a pre-signed URL for uploading the file to the cloud storage. - /// - /// - Parameters: - /// - localUrl: The local URL of the file to be uploaded. - /// - isTemporary: A boolean indicating whether the file is temporary or not. - /// - tags: A dictionary containing tags associated with the file. - /// - license: The license associated with the file. - /// - /// - Returns: The pre-signed URL for uploading the file, or `nil` if an error occurs. - /// - /// - Throws: - /// - `ModelError.missingBackendURL` if the backend URL is missing. - /// - `ModelError.invalidURL` if the constructed URL is invalid. - /// - `FileError.couldNotGenerateThePayloadForThePreSignedS3URL` if the payload for obtaining the pre-signed URL cannot be generated. - /// - Other errors related to network operations or header construction. - private func getPreSignedURL(at localUrl: URL, temporary: Bool = true, tags: [String: String] = [:], license: License? = nil) async throws -> URL { - let headers: [String: String] = try networking.buildHeader() - var payload: [String: String] = [:] - - guard let url = APIKeyManager.shared.BACKEND_URL else { - throw ModelError.missingBackendURL - } - - let endpoint = Networking.Endpoint.fileUpload(isTemporary: temporary) - guard let url = URL(string: url.absoluteString + endpoint.path) else { - throw ModelError.invalidURL(url: url.absoluteString + endpoint.path) - } - - let originalName = localUrl.lastPathComponent.replacingOccurrences(of: " ", with: "_") - - if temporary { - payload = ["contentType": localUrl.mimeType(), "originalName": originalName] - } else { - payload = ["contentType": localUrl.mimeType(), "originalName": originalName, "tags": tags.map { "\($0.key),\($0.value)" }.joined(separator: "\n"), "license": license?.name ?? ""] - } - - guard let jsonPayload = try? JSONSerialization.data(withJSONObject: payload, options: []) else { - logger.error("Could not generate the payload to obtain the S3 Pre Signed URL: \(String(describing: payload))") - throw FileError.payloadGenerationFailed(description: String(describing: payload)) - } - - logger.debug("Creating a temp URL with the following payload:\(payload.description)") - let response = try await networking.post(url: url, headers: headers, body: jsonPayload) - - if let json = try? JSONSerialization.jsonObject(with: response.0, options: []) as? [String: Any] { - if let uploadUrl = json["uploadUrl"] as? String { - logger.debug("Pre-Signed URL: \(uploadUrl)") - guard let url = URL(string: uploadUrl) else { - throw FileError.couldNotGetTheS3PreSignedURL - } - return url - } - } - - throw FileError.couldNotGetTheS3PreSignedURL - } - - - - - - public func getPreSignedURLs(at localUrl: URL, temporary: Bool = true, tags: [String: String] = [:], license: License? = nil) async throws -> (downloadURL: URL, uploadURL: URL) { - let headers: [String: String] = try networking.buildHeader() - var payload: [String: String] = [:] - - guard let url = APIKeyManager.shared.BACKEND_URL else { - throw ModelError.missingBackendURL - } - - let endpoint = Networking.Endpoint.fileUpload(isTemporary: temporary) - guard let url = URL(string: url.absoluteString + endpoint.path) else { - throw ModelError.invalidURL(url: url.absoluteString + endpoint.path) - } - - let originalName = localUrl.lastPathComponent.replacingOccurrences(of: " ", with: "_") - - if temporary { - payload = ["contentType": localUrl.mimeType(), "originalName": originalName] - } else { - payload = ["contentType": localUrl.mimeType(), "originalName": originalName, "tags": tags.map { "\($0.key),\($0.value)" }.joined(separator: "\n"), "license": license?.name ?? ""] - } - - guard let jsonPayload = try? JSONSerialization.data(withJSONObject: payload, options: []) else { - logger.error("Could not generate the payload to obtain the S3 Pre Signed URL: \(String(describing: payload))") - throw FileError.payloadGenerationFailed(description: String(describing: payload)) - } - - logger.debug("Creating a temp URL with the following payload:\(payload.description)") - let response = try await networking.post(url: url, headers: headers, body: jsonPayload) - - if let json = try? JSONSerialization.jsonObject(with: response.0, options: []) as? [String: Any] { - if let uploadUrl = json["uploadUrl"] as? String, let downloadUrl = json["downloadUrl"] as? String { - logger.debug("Pre-Signed Download URL: \(uploadUrl)") - guard let purl = URL(string: uploadUrl) else { - throw FileError.couldNotGetTheS3PreSignedURL - } - guard let durl = URL(string: downloadUrl) else { - throw FileError.couldNotGetTheS3PreSignedURL - } - return (downloadURL:durl,uploadURL:purl) - } - } - - throw FileError.couldNotGetTheS3PreSignedURL - } - - /// Extracts the bucket name from the pre-signed URL and constructs the S3 link. - /// - /// - Parameter presignedUrl: The pre-signed URL containing the bucket name. - /// - Returns: The constructed S3 link. - /// - Throws: An error if the bucket name cannot be extracted from the pre-signed URL. - private func constructS3Link(from presignedUrl: URL) throws -> String { - - let pattern = "https://(.*?).s3.amazonaws.com" - - guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { - throw NetworkingError.invalidURL(url: nil) - } - - let range = NSRange(presignedUrl.absoluteString.startIndex.. URL { - var url = url - switch url.absoluteString { - case let link where link.starts(with: "s3://"): - break - case let link where link.starts(with: "http://"): - break - case let link where link.starts(with: "https://"): - break - default: - let fileManager = FileUploadManager() - url = try await fileManager.uploadFile(at: url) - } - return url - } - -} diff --git a/Sources/aiXplainKit/Models/Model.swift b/Sources/aiXplainKit/Models/Model.swift new file mode 100644 index 0000000..943cc34 --- /dev/null +++ b/Sources/aiXplainKit/Models/Model.swift @@ -0,0 +1,267 @@ +import Foundation +import OSLog + +/// AI Model resource. +/// +/// Mirrors Python v2 `Model(BaseResource, SearchResourceMixin, GetResourceMixin, +/// RunnableResourceMixin, ToolableMixin)` from `model.py`. +public class Model: BaseResource, AgentToolConvertible { + private static let logger = Logger(subsystem: "aiXplainKit", category: "Model") + public override class var resourcePath: String { "v2/models" } + + // MARK: - Model-specific fields + + public var serviceName: String? + public var status: AssetStatus? + public var host: String? + public var developer: String? + public var vendor: VendorInfo? + public var function: AIFunction? + public var pricing: ModelPricing? + public var version: ModelVersion? + public var functionType: String? + public var type: String? = "model" + public var supportsStreaming: Bool? + public var connectionType: [String]? + public var createdAt: String? + public var updatedAt: String? + + public var isSyncOnly: Bool { + guard let ct = connectionType else { return false } + return ct.contains("synchronous") && !ct.contains("asynchronous") + } + + public var isAsyncCapable: Bool { + guard let ct = connectionType else { return true } + return ct.contains("asynchronous") + } + + // MARK: - Init + + public required init(id: String? = nil, name: String? = nil, description: String? = nil, context: Aixplain? = nil) { + super.init(id: id, name: name, description: description, context: context) + } + + public required convenience init() { + self.init(id: nil, name: nil, description: nil, context: nil) + } + + // MARK: - Get + + public class func get(_ id: String, context: Aixplain) async throws -> Model { + try await performGet(id, context: context, type: Model.self) + } + + // MARK: - Search + + public class func search( + query: String? = nil, + pageNumber: Int = 0, + pageSize: Int = 20, + context: Aixplain + ) async throws -> Page { + var filters: [String: Any] = [ + "pageNumber": pageNumber, + "pageSize": pageSize, + "sort": [[:] as [String: Any]] + ] + if let q = query { + filters["q"] = q + } + return try await performSearch(filters: filters, context: context, type: Model.self) + } + + // MARK: - Run + + /// Build the run URL. Uses `context.modelURL` + model ID (matches Python v2). + public func buildRunURL() throws -> String { + try ensureValidState() + let ctx = try ensureContext() + return "\(ctx.modelURL.absoluteString)/\(id!)" + } + + /// Build the run payload. + open func buildRunPayload(text: String? = nil, data: Any? = nil, parameters: [String: Any] = [:]) throws -> [String: Any] { + var payload: [String: Any] = parameters + if let text { + payload["text"] = text + } + if let data { + payload["data"] = data + } + return payload + } + + /// Run the model synchronously (async+poll or sync direct depending on `connectionType`). + public func run(text: String? = nil, data: Any? = nil, parameters: [String: Any] = [:]) async throws -> ModelResult { + let payload = try buildRunPayload(text: text, data: data, parameters: parameters) + let runURL = try buildRunURL() + let ctx = try ensureContext() + + let response = try await ctx.client.post(runURL, json: payload) + + let status = response["status"] as? String ?? "IN_PROGRESS" + let completed = response["completed"] as? Bool ?? false + + if status == ResponseStatus.failed.rawValue { + throw APIError.fromFailedOperation(response) + } + + if completed { + return ModelResult.from(response) + } + + if let pollURL = response["data"] as? String, pollURL.hasPrefix("http") { + return try await pollModel(pollURL) + } + if let pollURL = response["url"] as? String { + return try await pollModel(pollURL) + } + + return ModelResult.from(response) + } + + /// Run asynchronously -- returns immediately with polling URL. + public func runAsync(text: String? = nil, data: Any? = nil, parameters: [String: Any] = [:]) async throws -> ModelResult { + let payload = try buildRunPayload(text: text, data: data, parameters: parameters) + let runURL = try buildRunURL() + let ctx = try ensureContext() + + let response = try await ctx.client.post(runURL, json: payload) + return ModelResult.from(response) + } + + /// Stream model responses as SSE chunks. + public func runStream(text: String? = nil, parameters: [String: Any] = [:]) -> AsyncThrowingStream { + AsyncThrowingStream { [self] continuation in + Task { + do { + let payload = try self.buildRunPayload(text: text, parameters: parameters) + var payloadWithStream = payload + if payloadWithStream["options"] == nil { + payloadWithStream["options"] = [String: Any]() + } + if var options = payloadWithStream["options"] as? [String: Any] { + options["stream"] = true + payloadWithStream["options"] = options + } + + let runURL = try self.buildRunURL() + let body = try JSONSerialization.data(withJSONObject: payloadWithStream) + let ctx = try self.ensureContext() + + let stream = ctx.client.requestStream(method: .post, path: runURL, body: body) + for try await lineData in stream { + guard let line = String(data: lineData, encoding: .utf8) else { continue } + let trimmed = line.hasPrefix("data:") ? String(line.dropFirst(5)).trimmingCharacters(in: .whitespaces) : line + if trimmed == "[DONE]" { break } + if let jsonData = trimmed.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any], + let content = json["data"] as? String { + if content == "[DONE]" { break } + continuation.yield(StreamChunk(status: .inProgress, data: content)) + } else if !trimmed.isEmpty { + continuation.yield(StreamChunk(status: .inProgress, data: trimmed)) + } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } + + // MARK: - AgentToolConvertible + + public func asAgentTool() -> AgentToolDict { + AgentToolDict( + id: id ?? "", + name: name ?? "", + description: description ?? "", + supplier: vendor?.code ?? "aixplain", + function: function?.rawValue ?? "", + type: .model, + version: version?.id ?? "", + assetId: id ?? "" + ) + } + + // MARK: - Deserialization + + public override class func from(dict: [String: Any], context: Aixplain) throws -> Self { + let instance = Self.init( + id: dict["id"] as? String, + name: dict["name"] as? String, + description: dict["description"] as? String, + context: context + ) + instance.serviceName = dict["serviceName"] as? String + instance.host = dict["host"] as? String + instance.developer = dict["developer"] as? String + instance.functionType = dict["functionType"] as? String + instance.type = dict["type"] as? String + instance.supportsStreaming = dict["supportsStreaming"] as? Bool + instance.connectionType = dict["connectionType"] as? [String] + instance.createdAt = dict["createdAt"] as? String + instance.updatedAt = dict["updatedAt"] as? String + + if let statusStr = dict["status"] as? String { + instance.status = AssetStatus(rawValue: statusStr) + } + if let vendorDict = dict["vendor"] as? [String: Any] { + instance.vendor = VendorInfo( + id: (vendorDict["id"] as? Int).map(String.init) ?? vendorDict["id"] as? String, + name: vendorDict["name"] as? String, + code: vendorDict["code"] as? String + ) + } + if let funcDict = dict["function"] as? [String: Any], let funcId = funcDict["id"] as? String { + instance.function = AIFunction(rawValue: funcId) + } + if let pricingDict = dict["pricing"] as? [String: Any] { + instance.pricing = ModelPricing( + price: pricingDict["price"] as? Double, + unitType: pricingDict["unitType"] as? String, + unitTypeScale: pricingDict["unitTypeScale"] as? String + ) + } + if let versionDict = dict["version"] as? [String: Any] { + instance.version = ModelVersion(name: versionDict["name"] as? String, id: versionDict["id"] as? String) + } else if let versionStr = dict["version"] as? String { + instance.version = ModelVersion(name: versionStr, id: versionStr) + } + + return instance + } + + // MARK: - Polling + + private func pollModel(_ pollURL: String, timeout: TimeInterval = 300, waitTime: TimeInterval = 0.5) async throws -> ModelResult { + let startTime = Date() + var currentWait = max(waitTime, 0.2) + let ctx = try ensureContext() + + while Date().timeIntervalSince(startTime) < timeout { + let response = try await ctx.client.get(pollURL) + let status = response["status"] as? String ?? "IN_PROGRESS" + + if status == ResponseStatus.failed.rawValue { + throw APIError.fromFailedOperation(response) + } + + if response["completed"] as? Bool == true { + return ModelResult.from(response) + } + + try await Task.sleep(nanoseconds: UInt64(currentWait * 1_000_000_000)) + currentWait = min(currentWait * 1.1, 60) + } + + throw AixplainError.timeout(TimeoutError( + "Model polling timed out after \(Int(timeout))s", + pollingURL: pollURL, + timeout: timeout + )) + } +} diff --git a/Sources/aiXplainKit/Models/ModelResult.swift b/Sources/aiXplainKit/Models/ModelResult.swift new file mode 100644 index 0000000..327e21e --- /dev/null +++ b/Sources/aiXplainKit/Models/ModelResult.swift @@ -0,0 +1,73 @@ +import Foundation + +/// Result from running a model. Extends `RunResult` with model-specific fields. +/// +/// Mirrors Python v2 `ModelResult(Result)` from `model.py`. +public struct ModelResult: @unchecked Sendable { + public let status: String + public let completed: Bool + public let data: AnyCodable? + public let url: String? + public let errorMessage: String? + public let supplierError: String? + public let runTime: Double? + public let usedCredits: Double? + public let usage: TokenUsage? + public let sessionId: String? + public let requestId: String? + public let rawData: [String: Any]? + + public init( + status: String, + completed: Bool, + data: AnyCodable? = nil, + url: String? = nil, + errorMessage: String? = nil, + supplierError: String? = nil, + runTime: Double? = nil, + usedCredits: Double? = nil, + usage: TokenUsage? = nil, + sessionId: String? = nil, + requestId: String? = nil, + rawData: [String: Any]? = nil + ) { + self.status = status + self.completed = completed + self.data = data + self.url = url + self.errorMessage = errorMessage + self.supplierError = supplierError + self.runTime = runTime + self.usedCredits = usedCredits + self.usage = usage + self.sessionId = sessionId + self.requestId = requestId + self.rawData = rawData + } + + /// Parse from a polling/run response dictionary. + public static func from(_ dict: [String: Any]) -> ModelResult { + var usage: TokenUsage? = nil + if let usageDict = dict["usage"] as? [String: Any], + let pt = usageDict["prompt_tokens"] as? Int, + let ct = usageDict["completion_tokens"] as? Int, + let tt = usageDict["total_tokens"] as? Int { + usage = TokenUsage(promptTokens: pt, completionTokens: ct, totalTokens: tt) + } + + return ModelResult( + status: dict["status"] as? String ?? "IN_PROGRESS", + completed: dict["completed"] as? Bool ?? false, + data: (dict["data"]).map { AnyCodable($0) }, + url: dict["url"] as? String, + errorMessage: dict["errorMessage"] as? String, + supplierError: dict["supplierError"] as? String, + runTime: dict["runTime"] as? Double, + usedCredits: dict["usedCredits"] as? Double, + usage: usage, + sessionId: dict["sessionId"] as? String, + requestId: dict["requestId"] as? String, + rawData: dict + ) + } +} diff --git a/Sources/aiXplainKit/Models/ModelTypes.swift b/Sources/aiXplainKit/Models/ModelTypes.swift new file mode 100644 index 0000000..77c8d0b --- /dev/null +++ b/Sources/aiXplainKit/Models/ModelTypes.swift @@ -0,0 +1,57 @@ +import Foundation + +/// Vendor/supplier metadata from the API response. +public struct VendorInfo: Codable, Sendable { + public let id: String? + public let name: String? + public let code: String? + + public init(id: String? = nil, name: String? = nil, code: String? = nil) { + self.id = id + self.name = name + self.code = code + } +} + +/// Model version information. +public struct ModelVersion: Codable, Sendable { + public let name: String? + public let id: String? + + public init(name: String? = nil, id: String? = nil) { + self.name = name + self.id = id + } +} + +/// Pricing information for a model. +public struct ModelPricing: Codable, Sendable { + public let price: Double? + public let unitType: String? + public let unitTypeScale: String? + + public init(price: Double? = nil, unitType: String? = nil, unitTypeScale: String? = nil) { + self.price = price + self.unitType = unitType + self.unitTypeScale = unitTypeScale + } +} + +/// Token usage statistics from a model run. +public struct TokenUsage: Codable, Sendable { + public let promptTokens: Int + public let completionTokens: Int + public let totalTokens: Int + + enum CodingKeys: String, CodingKey { + case promptTokens = "prompt_tokens" + case completionTokens = "completion_tokens" + case totalTokens = "total_tokens" + } +} + +/// A single chunk from an SSE stream. +public struct StreamChunk: Sendable { + public let status: ResponseStatus + public let data: String +} diff --git a/Sources/aiXplainKit/Modules/Agents/Agents+CRUD.swift b/Sources/aiXplainKit/Modules/Agents/Agents+CRUD.swift deleted file mode 100644 index c77eafd..0000000 --- a/Sources/aiXplainKit/Modules/Agents/Agents+CRUD.swift +++ /dev/null @@ -1,149 +0,0 @@ -// -// File.swift -// aiXplainKit -// -// Created by Joao Maia on 07/01/25. -// - -import Foundation -//MARK: - Deploy, update, delete -extension Agent { - /// Deploys the agent by changing its status to "onboarded" and updating it on the server. - /// - /// This method transitions the agent from its current state to an "onboarded" status, - /// making it ready for use. The change is synchronized with the server. - /// - /// - Throws: Any error that occurs during the update process, including networking errors. - /// - /// # Example - /// ```swift - /// let agent = // ... existing agent - /// do { - /// try await agent.deploy() - /// print("Agent deployed successfully") - /// } catch { - /// print("Failed to deploy agent: \(error)") - /// } - /// ``` - public func deploy() async throws { - self.status = "onboarded" - try await self.update() - } - - /// Adds new tools to the agent and updates it on the server. - /// - /// This method allows you to add additional tools to an existing agent. The tools are appended - /// to the agent's current set of tools and the changes are synchronized with the server. - /// - /// - Parameter tools: An array of `CreateAgentTool` objects representing the new tools to add. - /// - /// - Throws: Any error that occurs during the update process, including networking errors. - /// - /// # Example - /// ```swift - /// let agent = // ... existing agent - /// let newTools = [ - /// CreateAgentTool(name: "Calculator", description: "Performs basic math"), - /// CreateAgentTool(name: "Weather", description: "Gets weather info") - /// ] - /// - /// do { - /// try await agent.appendTools(newTools) - /// print("Tools added successfully") - /// } catch { - /// print("Failed to add tools: \(error)") - /// } - /// ``` - public func appendTools(_ tools: [CreateAgentTool]) async throws { - tools.forEach { tool in - let convertedTool = tool.convertToTool() - self.assets.append(convertedTool) - } - - try await update() - } - - /// Updates the agent on the server with the current state of the agent object. - /// - /// This method synchronizes the agent's current state with the server. It encodes the agent - /// object into JSON format and sends it to the server for updating. The response is then decoded - /// into an `Agent` object and assigned to the current instance. - /// - /// - Throws: Any error that occurs during the update process, including networking errors. - /// - /// # Example - /// ```swift - public func update()async throws { - let headers = try networking.buildHeader() - - guard let url = APIKeyManager.shared.BACKEND_URL else { - throw ModelError.missingBackendURL - } - - let endpoint = Networking.Endpoint.agents(agentIdentifier: self.id).path - guard let url = URL(string: url.absoluteString + endpoint) else { - throw ModelError.invalidURL(url: url.absoluteString) - } - - let payload = try JSONEncoder().encode(self) - - let response = try await networking.put(url: url, body: payload, headers: headers) - - if let httpUrlResponse = response.1 as? HTTPURLResponse, - !(200...299).contains(httpUrlResponse.statusCode) { - throw NetworkingError.invalidStatusCode(statusCode: httpUrlResponse.statusCode) - } - - do { - let decodedResponse = try JSONDecoder().decode(Agent.self, from: response.0) - self.id = decodedResponse.id - self.status = decodedResponse.status - self.assets = decodedResponse.assets - } catch { - throw AgentsError.errorOnUpdate(error: "") - } - } - - - /// Deletes the agent from the server. - /// - /// This method sends a DELETE request to remove the agent from the aiXplain platform. - /// Once deleted, the agent cannot be recovered. - /// - /// - Throws: - /// - `ModelError.missingBackendURL` if the backend URL is not configured - /// - `ModelError.invalidURL` if the constructed URL is invalid - /// - `AgentsError.errorOnDelete` if the deletion request fails - /// - Any networking errors that occur during the request - /// - /// # Example - /// ```swift - /// do { - /// try await agent.delete() - /// print("Agent successfully deleted") - /// } catch { - /// print("Failed to delete agent: \(error)") - /// } - /// ``` - public func delete() async throws { - let networking = networking ?? Networking() - let headers = try networking.buildHeader() - - guard let url = APIKeyManager.shared.BACKEND_URL else { - throw ModelError.missingBackendURL - } - - let endpoint = Networking.Endpoint.agents(agentIdentifier: self.id).path - guard let url = URL(string: url.absoluteString + endpoint) else { - throw ModelError.invalidURL(url: url.absoluteString) - } - - let response = try await networking.delete(url: url, headers: headers) - - if let httpUrlResponse = response.1 as? HTTPURLResponse, - !(200...299).contains(httpUrlResponse.statusCode) { - throw AgentsError.errorOnDelete(error: "") - } - } - -} diff --git a/Sources/aiXplainKit/Modules/Agents/Agents.swift b/Sources/aiXplainKit/Modules/Agents/Agents.swift deleted file mode 100644 index f62f8c6..0000000 --- a/Sources/aiXplainKit/Modules/Agents/Agents.swift +++ /dev/null @@ -1,314 +0,0 @@ -// -// Agents.swift -// aiXplainKit -// -// Created by Joao Maia on 11/11/24. -// - -import Foundation -import os - -/// A class representing an agent in the aiXplain ecosystem. -/// -/// The `Agent` class is designed to manage and execute tasks for an agent, including handling inputs, configuring parameters, -/// and retrieving results. It supports various execution methods and includes utilities for polling and network handling. -public final class Agent: Codable { - - // MARK: - Properties - - /// The unique identifier of the agent. - public var id: String - - /// The name of the agent. - public var name: String - - /// The current status of the agent. - public var status: String - - /// The identifier of the team associated with the agent. - public let teamId: Int - - /// A description of the agent. - public var description: String - - /// The identifier of the associated large language model (LLM). - public let llmId: String - - /// The timestamp when the agent was created. - public let createdAt: Date - - /// The timestamp when the agent was last updated. - public let updatedAt: Date - - /// The role or instructions that define the agent's behavior and purpose. - public var role: String - - /// A logger instance for recording events and debugging information. - private let logger: Logger - - /// The assets associated with the agent. - public var assets: [Tool] - - /// The networking service responsible for making API calls and handling URL sessions. - var networking: Networking - - - // MARK: - Initializers - - /// Creates an instance of `Agent` from a decoder. - /// - /// - Parameter decoder: The decoder to use for decoding the agent data. - /// - Throws: An error if the decoding process fails. - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - id = try container.decode(String.self, forKey: .id) - name = try container.decode(String.self, forKey: .name) - status = try container.decode(String.self, forKey: .status) - teamId = try container.decode(Int.self, forKey: .teamId) - description = try container.decodeIfPresent(String.self, forKey: .description) ?? "No description" - llmId = try container.decode(String.self, forKey: .llmId) - assets = try container.decodeIfPresent([Tool].self, forKey: .assets) ?? [] - - let dateFormatter = ISO8601DateFormatter() - dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - - let createdAtString = try container.decode(String.self, forKey: .createdAt) - createdAt = dateFormatter.date(from: createdAtString) ?? Date() - - let updatedAtString = try container.decode(String.self, forKey: .updatedAt) - updatedAt = dateFormatter.date(from: updatedAtString) ?? Date() - - role = try container.decodeIfPresent(String.self, forKey: .role) ?? "" - - logger = Logger(subsystem: "AiXplain", category: "Agent(\(name))") - networking = Networking() - } - - /// Creates a new instance of `Agent` with specific properties. - /// - /// - Parameters: - /// - id: The unique identifier of the agent. - /// - name: The name of the agent. - /// - status: The current status of the agent. - /// - teamId: The identifier of the associated team. - /// - description: A brief description of the agent. - /// - llmId: The identifier of the associated large language model. - /// - createdAt: The creation timestamp of the agent. - /// - updatedAt: The last update timestamp of the agent. - /// - assets: The assets associated with the agent. - /// - role: The role that defines the agent's behavior and purpose. - public init(id: String, name: String, status: String, teamId: Int, description: String, llmId: String, createdAt: Date, updatedAt: Date, assets: [Tool] = [], role: String = "") { - self.id = id - self.name = name - self.status = status - self.teamId = teamId - self.description = description - self.llmId = llmId - self.assets = assets - self.createdAt = createdAt - self.updatedAt = updatedAt - self.role = role - self.logger = Logger(subsystem: "AiXplain", category: "Agent(\(name))") - self.networking = Networking() - } - - /// Encodes the `Agent` instance into the provided encoder. - /// - /// - Parameter encoder: The encoder to use for encoding the agent. - /// - Throws: An error if the encoding process fails. - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - - try container.encode(id, forKey: .id) - try container.encode(name, forKey: .name) - try container.encode(status, forKey: .status) - try container.encode(teamId, forKey: .teamId) - try container.encode(description, forKey: .description) - try container.encode(llmId, forKey: .llmId) - try container.encode(assets, forKey: .assets) - try container.encode(createdAt, forKey: .createdAt) - try container.encode(updatedAt, forKey: .updatedAt) - try container.encode(role, forKey: .role) - } - - /// Private keys for encoding and decoding the `Agent` properties. - private enum CodingKeys: String, CodingKey { - case id, name, status, teamId, description, llmId, createdAt, updatedAt, assets, role - } -} - - -// MARK: - Agent Execution - -extension Agent { - - /// Executes the agent with specified input and parameters. - /// - /// This method sends the given input to the agent, configured with the provided parameters, - /// and returns the result of the execution. - /// - /// - Parameters: - /// - agentInput: The input conforming to `AgentInputable` used for the agent execution. - /// - sessionID: An optional session identifier, useful for tracking execution context. - /// - parameters: Parameters to configure the agent execution, including polling and timeout settings. - /// - Returns: The `AgentOutput` containing the result of the agent execution. - /// - Throws: Errors related to networking, invalid input, or execution failure. - /// - /// # Example - /// ```swift - /// let agentInput:String = "Hello World" - /// let parameters = AgentRunParameters() - /// do { - /// let output = try await agent.run(agentInput, sessionID: "12345", parameters: parameters) - /// print("Execution result: \(output)") - /// } catch { - /// print("Failed to execute agent: \(error)") - /// } - /// ``` - public func run(_ agentInput: any AgentInputable, sessionID: String? = nil, parameters: AgentRunParameters = .defaultParameters) async throws -> AgentOutput { - let headers = try self.networking.buildHeader() - let payload = try await agentInput.generateInputPayloadForAgent(using: parameters, withID: sessionID) - - guard let backendURL = APIKeyManager.shared.BACKEND_URL else { - throw AgentsError.missingBackendURL - } - - guard let url = URL(string: backendURL.absoluteString + Networking.Endpoint.agentRun(agentIdentifier: self.id).path ) else { - throw AgentsError.invalidURL(url: backendURL.absoluteString) - } - - logger.debug("Creating an execution with the following payload \(String(data: payload, encoding: .utf8) ?? "-")") - networking.parameters = parameters - let response = try await networking.post(url: url, headers: headers, body: payload) - - if let httpResponse = response.1 as? HTTPURLResponse, - httpResponse.statusCode != 201 { - throw NetworkingError.invalidStatusCode(statusCode: httpResponse.statusCode) - } - - let decodedResponse = try JSONDecoder().decode(AgentExecuteResponse.self, from: response.0) - - guard let pollingURL = decodedResponse.maybeUrl else { - throw ModelError.failToDecodeRunResponse - } - - logger.info("Successfully created an execution") - return try await polling(from: pollingURL, - maxRetry: parameters.maxPollingRetries, - waitTime: parameters.pollingWaitTimeInSeconds) - } - - /// Executes the agent with a query and optional content inputs. - /// - /// This method allows the agent to process a query and dynamically replace placeholders in the query - /// with provided content. The content can include URLs, text, or other input types. - /// - /// - Parameters: - /// - query: A query string for the agent to process. Placeholders in the format `{{key}}` can be replaced by `content` values. - /// - content: A dictionary of additional inputs (e.g., files, URLs, text) to be included in the query. - /// - sessionID: An optional session identifier, useful for tracking execution context. - /// - parameters: Parameters to configure the agent execution, including polling and timeout settings. - /// - Returns: The `AgentOutput` containing the result of the agent execution. - /// - Throws: Errors related to networking, invalid input, or execution failure. - /// - /// # Example - /// ```swift - /// let query = "What is the history of the text in the figure {{poem}}? Please be descriptive." - /// let content: [String: AgentInputable] = [ - /// "poem": URL(string: "file:/Users/joao/Downloads/RumiPoemImage.jpeg")! - /// ] - /// let parameters = AgentRunParameters() - /// do { - /// let output = try await agent.run(query: query, content: content, sessionID: "12345", parameters: parameters) - /// print("Execution result: \(output)") - /// } catch { - /// print("Failed to execute agent: \(error)") - /// } - /// ``` - public func run(query: String, content: [String: AgentInputable] = [:], sessionID: String? = nil, parameters: AgentRunParameters = .defaultParameters) async throws -> AgentOutput { - if content.count > 3{ - throw AgentsError.invalidInput(error: "Only up to 3 content items are supported") - } - if query.isEmpty { - throw AgentsError.invalidInput(error: "Query cannot be empty") - } - - var modifiedQuery = query - - for (key, value) in content { - let formattedValue: String - switch value { - case let url as URL: - formattedValue = try await url.uploadToS3IfNeedIt().absoluteString - case let string as String: - formattedValue = string - default: - formattedValue = "\(value)" - } - - if modifiedQuery.contains("{{\(key)}}") { - modifiedQuery = modifiedQuery.replacingOccurrences(of: "{{\(key)}}", with: " \(formattedValue) ") - } else { - modifiedQuery.append(" \(formattedValue) ") - } - } - - return try await run(modifiedQuery.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines), sessionID: sessionID, parameters: parameters) - } - - /// Polls a given URL to monitor the agent's execution and retrieve results. - /// - /// This method continuously polls the provided URL at specified intervals until the execution - /// is complete or the maximum number of retries is reached. - /// - /// - Parameters: - /// - url: The URL to poll for results. - /// - maxRetry: The maximum number of retries allowed during polling (default is `300` retries). - /// - waitTime: The time interval (in seconds) between polling attempts (default is `0.5` seconds). - /// - Returns: The `AgentOutput` containing the result of the agent execution. - /// - Throws: Errors related to timeouts, network failures, or invalid responses. - /// - /// # Example - /// ```swift - /// let pollingURL = URL(string: "https://api.example.com/agents/results/12345")! - /// do { - /// let output = try await agent.polling(from: pollingURL, maxRetry: 100, waitTime: 1.0) - /// print("Polling result: \(output)") - /// } catch { - /// print("Polling failed: \(error)") - /// } - /// ``` - private func polling(from url: URL, maxRetry: Int = 300, waitTime: Double = 0.5) async throws -> AgentOutput { - let headers = try self.networking.buildHeader() - var attempts = 0 - - logger.info("Starting polling job") - repeat { - let response = try await networking.get(url: url, headers: headers) - logger.debug("(\(attempts)/\(maxRetry)) Polling...") - - if let json = try? JSONSerialization.jsonObject(with: response.0, options: []) as? [String: Any], - let completed = json["completed"] as? Bool { - if let _ = json["error"] as? String, let supplierError = json["supplierError"] as? String { - throw AgentsError.supplierError(error: supplierError) - } - - if completed { - do { - let decodedResponse = try JSONDecoder().decode(AgentOutput.self, from: response.0) - logger.info("Polling job finished.") - return decodedResponse - } catch { - throw AgentsError.failToDecodeModelOutputDuringPollingPhase(error: String(describing: error)) - } - } - } - - try await Task.sleep(nanoseconds: UInt64(max(0.2, waitTime) * 1_000_000_000)) - attempts += 1 - } while attempts < maxRetry - - throw ModelError.pollingTimeoutOnModelResponse(pollingURL: url) - } -} diff --git a/Sources/aiXplainKit/Modules/Agents/Input/AgentInputable.swift b/Sources/aiXplainKit/Modules/Agents/Input/AgentInputable.swift deleted file mode 100644 index 044af83..0000000 --- a/Sources/aiXplainKit/Modules/Agents/Input/AgentInputable.swift +++ /dev/null @@ -1,11 +0,0 @@ -// -// File.swift -// aiXplainKit -// -// Created by Joao Maia on 18/11/24. -// - -import Foundation -public protocol AgentInputable{ - func generateInputPayloadForAgent(using:AgentRunParameters, withID:String?) async throws -> Data -} diff --git a/Sources/aiXplainKit/Modules/Agents/Input/Data+AgentInputable.swift b/Sources/aiXplainKit/Modules/Agents/Input/Data+AgentInputable.swift deleted file mode 100644 index eea6051..0000000 --- a/Sources/aiXplainKit/Modules/Agents/Input/Data+AgentInputable.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// File.swift -// aiXplainKit -// -// Created by Joao Maia on 18/11/24. -// - -import Foundation - -extension Data:AgentInputable { - public func generateInputPayloadForAgent(using:AgentRunParameters,withID:String? = nil) async throws -> Data { - return self - } -} diff --git a/Sources/aiXplainKit/Modules/Agents/Input/Dictonary+AgentInputable.swift b/Sources/aiXplainKit/Modules/Agents/Input/Dictonary+AgentInputable.swift deleted file mode 100644 index bd48c4b..0000000 --- a/Sources/aiXplainKit/Modules/Agents/Input/Dictonary+AgentInputable.swift +++ /dev/null @@ -1,68 +0,0 @@ -// -// File.swift -// aiXplainKit -// -// Created by Joao Maia on 20/11/24. -// - -import Foundation - -/// Extends `Dictionary` to conform to the `AgentInputable` protocol when both keys and values meet specific criteria. -/// -/// This allows dictionaries with `String` keys and `AgentInputable` values to be used as inputs for agents, -/// enabling them to process structured data such as multiple file URLs or text inputs. -extension Dictionary: AgentInputable where Key == String, Value == AgentInputable { - - /// Generates a JSON payload for an agent execution using the dictionary as input. - /// - /// The method processes each key-value pair in the dictionary. For values that are URLs, it uploads the file - /// to S3 if necessary and includes the resulting URL in the payload. String values are added directly. - /// - /// - Parameters: - /// - using: The `AgentRunParameters` containing additional configuration for the agent execution. - /// - withID: An optional session ID to include in the payload. - /// - Returns: A `Data` object containing the JSON representation of the payload. - /// - Throws: - /// - `AgentsError.typeNotRecognizedWhileCreatingACombinedInput` if a value in the dictionary is not supported. - /// - `AgentsError.inputEncodingError` if the payload cannot be serialized to JSON. - /// - Errors related to file upload for URL values. - /// - /// # Example - /// ```swift - /// let input: [String: AgentInputable] = [ - /// "text": "Hello, world!", - /// "file": URL(fileURLWithPath: "/path/to/file.txt") - /// ] - /// let parameters = AgentRunParameters() - /// do { - /// let payload = try await input.generateInputPayloadForAgent(using: parameters, withID: "session-123") - /// print(String(data: payload, encoding: .utf8)!) // JSON representation of the payload - /// } catch { - /// print("Failed to generate input payload: \(error)") - /// } - /// ``` - public func generateInputPayloadForAgent(using: AgentRunParameters, withID: String?) async throws -> Data { - var parsedSequence: [String: String] = [:] - let fileUploadManager = FileUploadManager() - - for (_, keyValuePair) in self.enumerated() { - let (key, value) = keyValuePair - - switch value { - case let url as URL: - let remoteURL = try await fileUploadManager.uploadDataIfNeedIt(from: url) - parsedSequence.updateValue(remoteURL.absoluteString.removingPercentEncoding ?? remoteURL.absoluteString , forKey: key) - case let string as String: - parsedSequence.updateValue(string, forKey: key) - default: - throw AgentsError.typeNotRecognizedWhileCreatingACombinedInput - } - } - - guard let jsonData = try? JSONSerialization.data(withJSONObject: parsedSequence, options: []) else { - throw AgentsError.inputEncodingError - } - - return jsonData - } -} diff --git a/Sources/aiXplainKit/Modules/Agents/Input/String+AgentInputable.swift b/Sources/aiXplainKit/Modules/Agents/Input/String+AgentInputable.swift deleted file mode 100644 index 8a6ba39..0000000 --- a/Sources/aiXplainKit/Modules/Agents/Input/String+AgentInputable.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// File.swift -// aiXplainKit -// -// Created by Joao Maia on 18/11/24. -// - -import Foundation - -/// Extends the `String` type to conform to the `AgentInputable` protocol. -/// -/// This allows strings to be used as inputs for agents, enabling them to generate payloads -/// for execution with the specified parameters. -extension String: AgentInputable { - - /// Generates a JSON payload to be used as input for an agent. - /// - /// This method converts the string into a query payload, merging it with the provided agent - /// run parameters and optionally adding a session ID. The result is serialized into JSON format. - /// - /// - Parameters: - /// - using: The `AgentRunParameters` containing additional configuration for the agent execution. - /// - id: An optional session ID to include in the payload. - /// - Returns: A `Data` object containing the JSON representation of the payload. - /// - /// # Example - /// ```swift - /// let query = "What is the history of AI?" - /// let parameters = AgentRunParameters() - /// let sessionID = "session-12345" - /// - /// let payload = query.generateInputPayloadForAgent(using: parameters, withID: sessionID) - /// print(String(data: payload, encoding: .utf8)!) // JSON representation of the payload - /// ``` - public func generateInputPayloadForAgent(using: AgentRunParameters, withID id: String? = nil) -> Data { - var payload = ["query": self] - - for (key, value) in using.runParametersIterator() { - payload.updateValue("\(value)", forKey: key) - } - - if let id = id { - payload.updateValue(id, forKey: "sessionId") - } - - guard let jsonData = try? JSONSerialization.data(withJSONObject: payload, options: []) else { - return Data() - } - - return jsonData - } -} diff --git a/Sources/aiXplainKit/Modules/Agents/Input/URL+AgentInputable.swift b/Sources/aiXplainKit/Modules/Agents/Input/URL+AgentInputable.swift deleted file mode 100644 index db7dc0b..0000000 --- a/Sources/aiXplainKit/Modules/Agents/Input/URL+AgentInputable.swift +++ /dev/null @@ -1,107 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation - -/// Extends the `URL` type to conform to the `AgentInputable` protocol. -/// -/// This allows URLs to be used as inputs for agents, enabling them to handle file paths, HTTP links, -/// or S3 URIs dynamically. It includes functionality for generating payloads and handling uploads to S3 storage. -extension URL: AgentInputable { - - /// Generates a JSON payload for an agent execution using the URL as input. - /// - /// If the URL is a local file, it uploads the file to S3 and includes the resulting URL in the payload. - /// URLs that are already HTTP/HTTPS or S3 paths are directly used without modification. - /// - /// - Parameters: - /// - using: The `AgentRunParameters` containing additional configuration for the agent execution. - /// - id: An optional session ID to include in the payload. - /// - Returns: A `Data` object containing the JSON representation of the payload. - /// - Throws: - /// - `ModelError.failToGenerateAFilePayload` if the payload cannot be serialized to JSON. - /// - Errors related to file upload if the URL is a local file. - /// - /// # Example - /// ```swift - /// let fileURL = URL(fileURLWithPath: "/path/to/file.txt") - /// let parameters = AgentRunParameters() - /// let payload = try await fileURL.generateInputPayloadForAgent(using: parameters, withID: "session-123") - /// print(String(data: payload, encoding: .utf8)!) // JSON representation of the payload - /// ``` - public func generateInputPayloadForAgent(using: AgentRunParameters, withID id: String? = nil) async throws -> Data { - var payload = ["query": self.absoluteString] - - switch self.absoluteString { - case let link where link.starts(with: "s3://"): - break - case let link where link.starts(with: "http://"): - break - case let link where link.starts(with: "https://"): - break - default: - let fileManager = FileUploadManager() - let s3URL = try await fileManager.uploadFile(at: self) - payload.updateValue(s3URL.absoluteString.removingPercentEncoding ?? s3URL.absoluteString, forKey: "data") - } - - guard let jsonData = try? JSONSerialization.data(withJSONObject: payload, options: []) else { - throw ModelError.failToGenerateAFilePayload(error: String(describing: payload)) - } - - return jsonData - } - - /// Uploads the file at the current URL to S3 if it is a local file path. - /// - /// This method checks the type of URL and performs the upload only if the URL is a local file. - /// URLs that are already S3, HTTP, or HTTPS links are returned as-is. - /// - /// - Returns: A `URL` pointing to the uploaded file on S3, or the original URL if no upload is needed. - /// - Throws: Errors related to file upload if the URL is a local file. - /// - /// # Example - /// ```swift - /// let fileURL = URL(fileURLWithPath: "/path/to/local/file.txt") - /// do { - /// let s3URL = try await fileURL.uploadToS3IfNeedIt() - /// print("S3 URL: \(s3URL)") - /// } catch { - /// print("Failed to upload file: \(error)") - /// } - /// ``` - func uploadToS3IfNeedIt() async throws -> URL { - switch self.absoluteString { - case let link where link.starts(with: "s3://"): - break - case let link where link.starts(with: "http://"): - break - case let link where link.starts(with: "https://"): - break - default: - let fileManager = FileUploadManager() - return try await fileManager.uploadFile(at: self) - } - return self - } -} diff --git a/Sources/aiXplainKit/Modules/Agents/Tools/CreateAgentTool.swift b/Sources/aiXplainKit/Modules/Agents/Tools/CreateAgentTool.swift deleted file mode 100644 index 4aa8502..0000000 --- a/Sources/aiXplainKit/Modules/Agents/Tools/CreateAgentTool.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// File.swift -// aiXplainKit -// -// Created by Joao Maia on 06/01/25. -// - -import Foundation - -public enum CreateAgentTool:AgentUsableTool{ - case model(_ model: Model, description:String) - case pipeline(_ Pipeline: Pipeline, description:String) - case asset(id:String, description:String) - case utility(_ model:UtilityModel, description:String) - case tool(_ tool:AgentUsableTool, description:String) - - public var description: String{ - switch self { - case .model(_, let description): - return description - case .pipeline(_, let description): - return description - case .asset(_, let description): - return description - case .utility(_, let description): - return description - case .tool(_, let description): - return description - } - } - - - public func convertToTool() -> Tool { - switch self { - case .model(let model, let description): - var tool = model.convertToTool() - tool.description = description - return tool - case .utility(let model, let description): - var tool = model.convertToTool() - tool.description = description - return tool - case .pipeline(let pipeline, let description): - var tool = pipeline.convertToTool() - tool.description = description - return tool - case .asset(let id, let description): - return Tool(id: id, description: description) - case .tool(let tool, let description): - var tool = tool.convertToTool() - tool.description = description - return tool - } - } - - -} diff --git a/Sources/aiXplainKit/Modules/Agents/Tools/Model+AgentUsable.swift b/Sources/aiXplainKit/Modules/Agents/Tools/Model+AgentUsable.swift deleted file mode 100644 index 70d9665..0000000 --- a/Sources/aiXplainKit/Modules/Agents/Tools/Model+AgentUsable.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// File.swift -// aiXplainKit -// -// Created by Joao Maia on 06/01/25. -// - -import Foundation -extension Model:AgentUsableTool{ - public func convertToTool() -> Tool { - return Tool(id: self.id, type: .model, function: function,supplier: self.supplier, version: self.version?.id) - } -} diff --git a/Sources/aiXplainKit/Modules/Agents/Tools/Pipeline+AgentUsable.swift b/Sources/aiXplainKit/Modules/Agents/Tools/Pipeline+AgentUsable.swift deleted file mode 100644 index f69639b..0000000 --- a/Sources/aiXplainKit/Modules/Agents/Tools/Pipeline+AgentUsable.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// File.swift -// aiXplainKit -// -// Created by Joao Maia on 06/01/25. -// - -import Foundation -extension Pipeline:AgentUsableTool{ - public func convertToTool() -> Tool { - return Tool(id: self.id, type: .pipiline, function: Function(id: "pipeline", name: "pipeline")) - } - - - -} diff --git a/Sources/aiXplainKit/Modules/Agents/Tools/Tool.swift b/Sources/aiXplainKit/Modules/Agents/Tools/Tool.swift deleted file mode 100644 index 302e03e..0000000 --- a/Sources/aiXplainKit/Modules/Agents/Tools/Tool.swift +++ /dev/null @@ -1,88 +0,0 @@ -// -// File.swift -// aiXplainKit -// -// Created by Joao Maia on 06/01/25. -// - -import Foundation - -/// Protocol defining requirements for objects that can be used as tools by an agent -public protocol AgentUsableTool { - /// Converts the implementing type into a Tool object - func convertToTool() -> Tool -} - -/// Represents a specialized software or resource designed to assist AI agents in executing specific tasks or functions based on user commands. -public struct Tool: Codable, AgentUsableTool { - /// Unique identifier for the tool - public var id: String - - /// The type of tool (model or pipeline) - var type: ToolType = .model - - /// Optional identifier for the function this tool provides - public var function: String? - - /// Optional supplier information for the tool - public var supplier: Supplier? - - /// Description of the tool's purpose and capabilities - public var description: String = "" - - /// Optional version identifier for the tool - public var version: String? - - private enum CodingKeys: String, CodingKey { - case id = "assetId" - case type - case function - case supplier - case description - case version - } - - /// Creates a Tool instance from a decoder - /// - Parameter decoder: The decoder containing the tool data - /// - Throws: DecodingError if decoding fails - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decodeIfPresent(String.self, forKey: .id) ?? "" - type = try container.decodeIfPresent(ToolType.self, forKey: .type) ?? .model - function = try? container.decode(String.self, forKey: .function) - supplier = try? container.decode(Supplier.self, forKey: .supplier) - description = try container.decodeIfPresent(String.self, forKey: .description) ?? "" - version = try? container.decode(String.self, forKey: .version) - } - - /// Creates a Tool instance with the specified parameters - /// - Parameters: - /// - id: Unique identifier for the tool - /// - type: Type of tool (defaults to .model) - /// - function: Optional function associated with the tool - /// - supplier: Optional supplier information - /// - description: Description of the tool (defaults to empty string) - /// - version: Optional version identifier - init(id: String, type: ToolType = .model, function: Function? = nil, supplier: Supplier? = nil, description: String = "", version: String? = nil) { - self.id = id - self.type = type - self.function = function?.id - self.supplier = supplier - self.description = description - self.version = version - } - - /// Implements AgentUsableTool protocol by returning self - public func convertToTool() -> Tool { - self - } -} - -/// Defines the types of tools available in the system -enum ToolType: String, Codable { - /// Represents an AI model tool - case model - - /// Represents a pipeline tool - case pipiline -} diff --git a/Sources/aiXplainKit/Modules/Agents/Tools/UtilityModel+AgentUsable.swift b/Sources/aiXplainKit/Modules/Agents/Tools/UtilityModel+AgentUsable.swift deleted file mode 100644 index 434be94..0000000 --- a/Sources/aiXplainKit/Modules/Agents/Tools/UtilityModel+AgentUsable.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// File.swift -// aiXplainKit -// -// Created by Joao Maia on 07/01/25. -// - -import Foundation -extension UtilityModel:AgentUsableTool{ - public func convertToTool() -> Tool { - return Tool(id: self.id, type: .model, function: Function(id: "utilities", name: "Utilites"),supplier: self.supplier, version: self.version) - } -} diff --git a/Sources/aiXplainKit/Modules/Asset/Asset.swift b/Sources/aiXplainKit/Modules/Asset/Asset.swift deleted file mode 100644 index 966579e..0000000 --- a/Sources/aiXplainKit/Modules/Asset/Asset.swift +++ /dev/null @@ -1,60 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation - -/// A protocol defining the basic information for an asset -protocol Asset { - /// The unique identifier of the asset. - var id: String { get } - - /// The name of the asset. - var name: String { get } - - /// A description of the asset's purpose and functionality. - var modelDescription: String { get } - - /// The supplier of the asset, providing information about its source. - var supplier: Supplier { get } - - /// The version of the asset. - var version: String {get} - - /// The license information associated with the asset, if applicable. - var license: License? { get } - - /// The privacy level of the asset. - var privacy: Privacy? { get } - - /// The pricing information for using the asset. - var pricing: Pricing { get } -} - -/// A protocol for assets that can be encoded and decoded using the `Codable` protocol. -protocol CodableAsset: Asset, Codable {} - -/// A protocol for assets that can be encoded using the `Encodable` protocol. -protocol EncodableAsset: Asset, Encodable {} - -/// A protocol for assets that can be decoded using the `Decodable` protocol. -protocol DecodableAsset: Asset, Decodable {} diff --git a/Sources/aiXplainKit/Modules/Asset/Function.swift b/Sources/aiXplainKit/Modules/Asset/Function.swift deleted file mode 100644 index 760de06..0000000 --- a/Sources/aiXplainKit/Modules/Asset/Function.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// File.swift -// aiXplainKit -// -// Created by Joao Maia on 06/01/25. -// - -import Foundation -public struct Function: Codable{ - public let id: String - public let name:String -} diff --git a/Sources/aiXplainKit/Modules/Asset/License.swift b/Sources/aiXplainKit/Modules/Asset/License.swift deleted file mode 100644 index b08293f..0000000 --- a/Sources/aiXplainKit/Modules/Asset/License.swift +++ /dev/null @@ -1,33 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation - -/// A representation of a asset license -public struct License: Codable { - /// The name of the license. - let name: String - - /// The unique identifier of the license. - let identifier: String -} diff --git a/Sources/aiXplainKit/Modules/Asset/Pricing.swift b/Sources/aiXplainKit/Modules/Asset/Pricing.swift deleted file mode 100644 index 666a054..0000000 --- a/Sources/aiXplainKit/Modules/Asset/Pricing.swift +++ /dev/null @@ -1,49 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation - -/// Represents the pricing information for an given Asset. -public struct Pricing: Codable { - /// The price of the asset - public let price: Float - - /// The unit of measurement for the price (e.g., "USD", "EUR", "TOKENS"). - public let unitType: String? - - /// The scale of the unit (e.g., "micro", "milli"). - public let unitScale: String? - - /// Initializes a new `Pricing` struct. - /// - /// - Parameters: - /// - price: The price of the given asset. - /// - unit: The unit of measurement for the price. - /// - unitScale: The scale of the unit (optional). - init(price: Float, unitType: String?, unitScale: String? = nil) { - self.price = price - self.unitType = unitType - self.unitScale = unitScale - } - -} diff --git a/Sources/aiXplainKit/Modules/Asset/Privacy.swift b/Sources/aiXplainKit/Modules/Asset/Privacy.swift deleted file mode 100644 index 7854bd8..0000000 --- a/Sources/aiXplainKit/Modules/Asset/Privacy.swift +++ /dev/null @@ -1,36 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation - -/// Represents the different privacy levels of assets. -public enum Privacy: String, Codable { - /// Publicly accessible asset. - case PUBLIC - - /// Private asset, not accessible to the public. - case PRIVATE - - /// Asset with restricted access, requiring specific permissions. - case RESTRICTED -} diff --git a/Sources/aiXplainKit/Modules/Asset/Suplier.swift b/Sources/aiXplainKit/Modules/Asset/Suplier.swift deleted file mode 100644 index 9ef9ad2..0000000 --- a/Sources/aiXplainKit/Modules/Asset/Suplier.swift +++ /dev/null @@ -1,59 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation - -/// Represents a supplier of a asset. -public struct Supplier: Codable { - /// The unique identifier of the supplier. - let id: Int - - /// The name of the supplier. - public let name: String - - /// A unique code associated with the supplier. - let code: String - - - init(id: Int, name: String, code: String) { - self.id = id - self.name = name - self.code = code - } - - /// Creates a new `Supplier` instance by decoding from the given decoder. - /// - Parameter decoder: The decoder to read data from. - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - id = try container.decodeIfPresent(Int.self, forKey: .id) ?? UUID().uuidString.hashValue - name = try container.decodeIfPresent(String.self, forKey: .name) ?? "unknown" - code = try container.decodeIfPresent(String.self, forKey: .code) ?? "unknown" - } - - /// Defines the coding keys for the `Supplier` struct. - private enum CodingKeys: String, CodingKey { - case id, name, code - } - -} diff --git a/Sources/aiXplainKit/Modules/Asset/Version.swift b/Sources/aiXplainKit/Modules/Asset/Version.swift deleted file mode 100644 index 455f9cd..0000000 --- a/Sources/aiXplainKit/Modules/Asset/Version.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// File.swift -// aiXplainKit -// -// Created by Joao Maia on 20/01/25. -// - -import Foundation - -public struct Version:Codable{ - public let name:String? - public let id:String? -} diff --git a/Sources/aiXplainKit/Modules/Index/EmbeddingModel.swift b/Sources/aiXplainKit/Modules/Index/EmbeddingModel.swift deleted file mode 100644 index 0033b1e..0000000 --- a/Sources/aiXplainKit/Modules/Index/EmbeddingModel.swift +++ /dev/null @@ -1,89 +0,0 @@ -// -// File.swift -// aiXplainKit -// -// Created by Joao Maia on 12/05/25. -// - -import Foundation - -/// A catalogue of embedding models supported by aiXplain. -/// -/// Use instances of `EmbeddingModel` when creating vector indexes via -/// `IndexProvider.create(name:description:embedding:engine:)`. Each case -/// encapsulates the identifier required by the aiXplain backend. You can also -/// supply an arbitrary model identifier through the ``custom(id:)`` case. -/// -/// ```swift -/// // Create an index backed by the OpenAI Ada v2 embedding model -/// let index = try await IndexProvider().create( -/// name: "Books", -/// description: "Embeddings for the public-domain library", -/// embedding: .OPENAI_ADA002 -/// ) -/// ``` -/// -/// To discover the identifiers for new models, consult the aiXplain console or -/// contact support. - -public enum EmbeddingModel:CaseIterable,Identifiable,Hashable{ - public static var allCases: [EmbeddingModel] = [.SNOWFLAKE_ARCTIC_EMBED_M_LONG, - .OPENAI_ADA002, - .SNOWFLAKE_ARCTIC_EMBED_L_V2_0, - .JINA_CLIP_V2_MULTIMODAL, - .MULTILINGUAL_E5_LARGE, - .BGE_M3, - .AIXPLAIN_LEGAL_EMBEDDINGS] - - - case SNOWFLAKE_ARCTIC_EMBED_M_LONG - case OPENAI_ADA002 - case SNOWFLAKE_ARCTIC_EMBED_L_V2_0 - case JINA_CLIP_V2_MULTIMODAL - case MULTILINGUAL_E5_LARGE - case BGE_M3 - case AIXPLAIN_LEGAL_EMBEDDINGS - case custom(id: String) - - /// The backend identifier corresponding to the embedding model. - /// - /// When the enum case is ``custom(id:)``, the supplied identifier is - /// returned verbatim; otherwise the constant identifier defined by - /// aiXplain is provided. - var modelId: String { - switch self { - case .SNOWFLAKE_ARCTIC_EMBED_M_LONG: - return "6658d40729985c2cf72f42ec" - case .OPENAI_ADA002: - return "6734c55df127847059324d9e" - case .SNOWFLAKE_ARCTIC_EMBED_L_V2_0: - return "678a4f8547f687504744960a" - case .JINA_CLIP_V2_MULTIMODAL: - return "67c5f705d8f6a65d6f74d732" - case .MULTILINGUAL_E5_LARGE: - return "67efd0772a0a850afa045af3" - case .BGE_M3: - return "67efd4f92a0a850afa045af7" - case .AIXPLAIN_LEGAL_EMBEDDINGS: - return "681254b668e47e7844c1f15a" - case .custom(id: let id): - return id - } - } - - public var id:String { - modelId - } - - /// Retrieves the underlying `Model` instance associated with the selected - /// embedding model. - /// - /// This method performs a network call through ``ModelProvider``. - /// - /// - Returns: A fully populated ``Model`` ready to be executed. - /// - Throws: An error if the model cannot be fetched from the backend. - func getModel() async throws -> Model { - return try await ModelProvider().get(self.modelId) - } - -} diff --git a/Sources/aiXplainKit/Modules/Index/IndexFilter.swift b/Sources/aiXplainKit/Modules/Index/IndexFilter.swift deleted file mode 100644 index a3138c1..0000000 --- a/Sources/aiXplainKit/Modules/Index/IndexFilter.swift +++ /dev/null @@ -1,135 +0,0 @@ -// -// File.swift -// aiXplainKit -// -// Created by Joao Maia on 13/05/25. -// - -import Foundation - -/// A filter used to constrain index search queries. -/// -/// `IndexFilter` represents a predicate that is evaluated against a document's -/// metadata field when executing an search. -/// Each filter is composed of the field name that should be inspected and an -/// [`IndexFieldOperator`](#) that describes the comparison that must hold true -/// for a document to be returned in the search results. -/// -/// You create an instance by passing a metadata field name and an operator: -/// -/// ```swift -/// let filter = IndexFilter(fieldName: "sourceLanguage", operation: .equals(value: "en")) -/// ``` -/// -/// — or, more concisely, using the subscript shortcut: -/// -/// ```swift -/// let filter = IndexFilter["sourceLanguage", .equals(value: "en")] -/// ``` -/// -/// Pass the resulting filter to `IndexModel.search(_:top_k:filters:)` to limit -/// the pool of candidate records. -public struct IndexFilter{ - let fieldName: String - let operation: IndexFieldOperator - - /// Creates an `IndexFilter` using a subscript-style shorthand. - /// - /// This convenience allows for a terser syntax when building filter - /// collections: - /// - /// ```swift - /// let filters: [IndexFilter] = [ - /// IndexFilter["author", .equals(value: "Virginia Woolf")], - /// IndexFilter["year", .greaterThan(value: "1920")] - /// ] - /// ``` - /// - /// - Parameters: - /// - fieldName: The name of the metadata field to query. - /// - operation: The comparison operation to apply. - static subscript (fieldName: String,operation:IndexFieldOperator) -> IndexFilter { - return IndexFilter(fieldName: fieldName, operation: operation) - } - - /// Converts the filter into a dictionary expected by the backend API. - /// - /// - Returns: A `[String : String]` representation suitable for JSON - /// serialization. - public func toDict()->[String:String]{ - return [ - "field" : fieldName, - "value": operation.value, - "operator": operation.toString - ] - } - -} - -/// The comparison operations that can be applied to an index field. -/// -/// Each case embeds the **value** that will be compared against the field -/// contents. For instance, `.greaterThan(value: "10")` translates to the -/// predicate *field > "10"*. -public enum IndexFieldOperator { - case equals(value:String) - case notEquals(value:String) - case contains(value:String) - case notContains(value:String) - case greaterThan(value:String) - case lessThan(value:String) - case greaterThanOrEquals(value:String) - case lessThanOrEquals(value:String) - - /// The value component of the comparison operation. - /// - /// For example, in the case `.equals(value: "en")`, the `value` returned - /// is `"en"`. - var value:String{ - switch self { - case .equals(value: let v): - return v - case .notEquals(value: let v): - return v - case .contains(value: let v): - return v - case .notContains(value: let v): - return v - case .greaterThan(value: let v): - return v - case .lessThan(value: let v): - return v - case .greaterThanOrEquals(value: let v): - return v - case .lessThanOrEquals(value: let v): - return v - } - } - - /// Returns the string representation expected by the backend for the - /// operator. - /// - /// For instance, `.greaterThan` becomes `">"`. - var toString:String{ - switch self { - case .equals: - return "==" - case .notEquals: - return "!=" - case .contains: - return "in" - case .notContains: - return "not in" - case .greaterThan: - return ">" - case .lessThan: - return "<" - case .greaterThanOrEquals: - return ">=" - case .lessThanOrEquals: - return "<=" - } - } - - -} diff --git a/Sources/aiXplainKit/Modules/Index/IndexModel.swift b/Sources/aiXplainKit/Modules/Index/IndexModel.swift deleted file mode 100644 index ea73c55..0000000 --- a/Sources/aiXplainKit/Modules/Index/IndexModel.swift +++ /dev/null @@ -1,236 +0,0 @@ -// -// File.swift -// aiXplainKit -// -// Created by Joao Maia on 12/05/25. -// - -import Foundation - -public class IndexModel:Model { - public override init(id: String, name: String, description: String, supplier: Supplier, version: Version? = nil, license: License? = nil, privacy: Privacy? = nil, pricing: Pricing, hostedBy: String, developedBy: String, networking: Networking) { - super.init(id: id, name: name, description: description, supplier: supplier, pricing: pricing, hostedBy: hostedBy, developedBy: developedBy, networking: networking) - } - - public init?(from model: Model, bypass: Bool = false){ - if model.function?.id != "search" || bypass { - return nil - } - super.init(id: model.id, name: model.name, description: model.description, supplier: model.supplier, pricing: model.pricing, hostedBy: model.hostedBy, developedBy: model.developedBy, networking: model.networking) - } - - - required public init(from decoder: Decoder) throws { - try super.init(from: decoder) - } - - - -} - -//MARK: - Index model operations -extension IndexModel{ - /// Searches textual records using the provided full-text `query`. - /// - /// - Parameters: - /// - query: The free-form text that will be matched against the indexed - /// corpus. - /// - top_k: The maximum amount of results to be returned. Defaults to - /// `10`. **Note**: The underlying service expects the snake- - /// cased key `top_k`, therefore the parameter label is kept - /// as-is to avoid further transformations. - /// - filters: Optional metadata filters that will be applied server-side - /// before the similarity computation. - /// - Returns: A fully decoded `IndexSearchOutput` instance. - /// - Throws: `NetworkingError` or `ModelError` if any step in the execution - /// pipeline fails. - public func search(_ query:String, top_k:Int = 10, filters:[IndexFilter] = []) async throws -> IndexSearchOutput{ - - //TODO: Images - - - let data:[String:Any] = [ - "action" : "search", - "data" : query, - "data_type" : "text", - "filters": filters.map({$0.toDict()}), //This created a list of [string:string] - "payload": [ - "uri" : "", - "top_k" : top_k, - "value_type" : "text" - ] - ] - - // JSONEncoder can't handle `[String: Any]`; use `JSONSerialization` instead - let encodedData = try JSONSerialization.data(withJSONObject: data, options: []) - - - return try await self.runSearch(encodedData) - - } - - /// Searches image records using the image located at `query`. - /// - /// The image will be uploaded to the aiXplain storage bucket first (if not - /// already hosted) and subsequently used as the search anchor. - /// - /// - Parameters: - /// - query: The local **file** `URL` of the image or a remote `URL` - /// previously uploaded. - /// - top_k: The maximum amount of results to be returned. Defaults to - /// `10`. - /// - filters: Optional metadata filters. - /// - /// - Throws: `ModelError.invalidURL` if the provided URL does not reference - /// an image; any error thrown by `FileUploadManager` or the - /// networking layer. - public func search(_ query:URL, top_k:Int = 10, filters:[IndexFilter] = []) async throws -> IndexSearchOutput{ - - guard query.mimeType().hasPrefix("image/") else { - throw ModelError.invalidURL(url: query.absoluteString) - } - - let fileLink = try await FileUploadManager().uploadDataIfNeedIt(from: query) - - - - let data:[String:Any] = [ - "action" : "search", - "data" : "", - "data_type" : "image", - "filters": filters.map({$0.toDict()}), //This created a list of [string:string] - "payload": [ - "uri" : fileLink.absoluteString, - "top_k" : top_k, - "value_type" : "image" - ] - ] - - - // JSONEncoder can't handle `[String: Any]`; use `JSONSerialization` instead - let encodedData = try JSONSerialization.data(withJSONObject: data, options: []) - - - return try await self.runSearch(encodedData) - - } - - -// upsert documents, return true if succed, TODO: Better docs - @discardableResult - public func upsert(_ documents: [Record]) async throws ->Bool{ - - let payload:[String:ModelInput] = ["action": "ingest", "data": documents] - let result = try await self.run(payload) - - return result.output == "success" - } - - - public func get(documentID:String) async throws ->Record?{ - - let data:[String:ModelInput] = ["action": "get_document", "data": documentID] - let response = try await self.run(data) - - #if DEBUG - debugPrint("[aiXplainKit] get(documentID:) response ->", response) - #endif - if response.output.isEmpty{ - return nil - } - return Record(text: response.output,id: documentID) - } - -//Count how many objects in this index - public func count() async throws ->Int{ - let data:[String:ModelInput] = ["action": "count", "data": ""] - let response = try await self.run(data) - return Int(response.output) ?? -1 - } - - -} - - -//MARK: Swifty features -extension IndexModel{ -// Cannot find type 'async' in scope - public subscript(id: String) -> Record? { - get async throws { - try await self.get(documentID: id) - } - } -} - - -//MARK: Index Model Custom Model run -extension IndexModel{ - - //This is a custom run method only used in search functions - private func runSearch(_ data:Data) async throws ->IndexSearchOutput{ - let headers = try self.networking.buildHeader() - let payload = data - guard let url = APIKeyManager.shared.MODELS_RUN_URL else { - throw ModelError.missingModelRunURL - } - - guard let url = URL(string: url.absoluteString + self.id) else { - throw ModelError.invalidURL(url: url.absoluteString) - } - - - - let response = try await networking.post(url: url, headers: headers, body: payload) - - if let httpUrlResponse = response.1 as? HTTPURLResponse, - httpUrlResponse.statusCode != 201 { - throw NetworkingError.invalidStatusCode(statusCode: httpUrlResponse.statusCode) - } - - let decodedResponse = try JSONDecoder().decode(ModelExecuteResponse.self, from: response.0) - - guard let pollingURL = decodedResponse.pollingURL else { - throw ModelError.failToDecodeRunResponse - } - - return try await pollingSearch(from: pollingURL) - } - - - - private func pollingSearch(from url: URL, maxRetry: Int = 300, waitTime: Double = 0.5) async throws -> IndexSearchOutput { - let headers = try self.networking.buildHeader() - - var itr = 0 - - - repeat { - let response = try await networking.get(url: url, headers: headers) - - if let json = try? JSONSerialization.jsonObject(with: response.0, options: []) as? [String: Any], - let completed = json["completed"] as? Bool { - - if let _ = json["error"] as? String, let supplierError = json["supplierError"] as? String { - throw ModelError.supplierError(error: supplierError) - } - - if completed { - do { - let decodedResponse = try JSONDecoder().decode(IndexSearchOutput.self, from: response.0) - return decodedResponse - } catch { - throw ModelError.failToDecodeModelOutputDuringPollingPhase(error: String(describing: error)) - } - } - } - - try await Task.sleep(nanoseconds: UInt64(max(0.2, waitTime) * 1_000_000_000)) - itr+=1 - } while itr < maxRetry - - throw ModelError.pollingTimeoutOnModelResponse(pollingURL: url) - } - - - -} diff --git a/Sources/aiXplainKit/Modules/Index/IndexerModel.swift b/Sources/aiXplainKit/Modules/Index/IndexerModel.swift deleted file mode 100644 index f3d0702..0000000 --- a/Sources/aiXplainKit/Modules/Index/IndexerModel.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// File.swift -// aiXplainKit -// -// Created by Joao Maia on 12/05/25. -// - -import Foundation -public enum AiXplainEngine{ - case AIR - case custom(id:String) - - var id:String{ - switch self { - case .AIR: - return "66eae6656eb56311f2595011" - case .custom(id: let id): - return id - } - } - - public func getModel() async throws -> Model { - return try await ModelProvider().get(self.id) - } -} diff --git a/Sources/aiXplainKit/Modules/Index/Record.swift b/Sources/aiXplainKit/Modules/Index/Record.swift deleted file mode 100644 index 6cb9183..0000000 --- a/Sources/aiXplainKit/Modules/Index/Record.swift +++ /dev/null @@ -1,139 +0,0 @@ -// -// File.swift -// aiXplainKit -// -// Created by Joao Maia on 13/05/25. -// - -import Foundation - -/// A `Record` represents a single item in the index – either text or an image reference – together with -/// optional metadata that can later be used for filtering or retrieval. -/// -/// The struct is lightweight and `Codable`, so it can be (de)serialised when sent over the network. -public struct Record: Codable, Identifiable { - - // MARK: - Public Types - - /// The underlying type of the record's payload. - public enum RecordDataType: String, Codable { - case text = "text" - case image = "image" - } - - // MARK: - Public Stored Properties - - public let id: String - public private(set) var recordDataType: RecordDataType - public private(set) var value: String - public private(set) var attributes: [String: String] - public private(set) var uri: URL? - - // MARK: - Initialisers - - /// Creates a textual record. - public init(text: String, - attributes: [String: String] = [:], - id: String = UUID().uuidString) { - self.id = id - self.recordDataType = .text - self.value = text - self.attributes = attributes - self.uri = nil - } - - /// Creates an image-based record. - /// - /// The referenced file will be uploaded to the aiXplain storage bucket (if - /// necessary) and the resulting remote `URL` stored in `uri`. - /// - /// - Parameters: - /// - image: A local file `URL` or a remote `URL` already pointing to - /// an image resource. - /// - attributes: Optional metadata associated with the record. - /// - id: The identifier for the record. A random UUID is used if - /// none is supplied. - /// - Throws: `ModelError.invalidURL` when the provided `image` does not - /// represent a supported image MIME type or any error thrown by - /// `FileUploadManager`. - public init(image: URL, - attributes: [String: String] = [:], - id: String = UUID().uuidString) async throws { - - guard image.mimeType().hasPrefix("image/") else { - throw ModelError.invalidURL(url: image.absoluteString) - } - - let uploadedURL = try await FileUploadManager().uploadDataIfNeedIt(from: image) - - self.id = id - self.recordDataType = .image - self.value = "" - self.attributes = attributes - self.uri = uploadedURL - } - - /// Asynchronously extracts text from a remote resource and initialises a textual record with the result. - /// - /// The extraction closure allows callers to supply their own extraction logic (OCR, web scraping, etc.) - /// while keeping the model agnostic. - /// - /// - Parameters: - /// - url: The source `URL`. - /// - attributes: Optional metadata to attach. - /// - extraction: A closure that receives the `URL` and returns the extracted string. - public init(from url: URL, - attributes: [String: String] = [:], - using extraction: (URL) async throws -> String) async throws { - let extractedText = try await extraction(url) - self.init(text: extractedText, attributes: attributes) - } - - // MARK: - Codable - - private enum CodingKeys: String, CodingKey { - case data = "data" - case dataType = "dataType" - case documentID = "document_id" - case uri = "uri" - case attributes = "attributes" - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(value, forKey: .data) - try container.encode(recordDataType.rawValue, forKey: .dataType) - try container.encode(id, forKey: .documentID) - try container.encodeIfPresent(uri?.absoluteString, forKey: .uri) - try container.encode(attributes, forKey: .attributes) - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.value = try container.decode(String.self, forKey: .data) - self.recordDataType = RecordDataType(rawValue: try container.decode(String.self, forKey: .dataType)) ?? .text - self.id = try container.decode(String.self, forKey: .documentID) - if let uriString = try container.decodeIfPresent(String.self, forKey: .uri) { - self.uri = URL(string: uriString) - } else { - self.uri = nil - } - self.attributes = try container.decodeIfPresent([String: String].self, forKey: .attributes) ?? [:] - } - - // MARK: - Convenience - - /// Returns a dictionary representation mirroring the expected server‑side format. - public func toDictionary() -> [String: Any] { - var dict: [String: Any] = [ - "data": value, - "dataType": recordDataType.rawValue, - "document_id": id, - "attributes": attributes, - "uri" : uri?.absoluteString ?? "" - - ] - - return dict - } -} diff --git a/Sources/aiXplainKit/Modules/Model/Input/ModelInput+Data.swift b/Sources/aiXplainKit/Modules/Model/Input/ModelInput+Data.swift deleted file mode 100644 index a017f3d..0000000 --- a/Sources/aiXplainKit/Modules/Model/Input/ModelInput+Data.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// File.swift -// aiXplainKit -// -// Created by Joao Maia on 13/05/25. -// - -import Foundation -extension Data: ModelInput{ - public func generateInputPayloadForModel() async throws -> Data { - return self - } -} diff --git a/Sources/aiXplainKit/Modules/Model/Input/ModelInput+Dictonary.swift b/Sources/aiXplainKit/Modules/Model/Input/ModelInput+Dictonary.swift deleted file mode 100644 index 46414a6..0000000 --- a/Sources/aiXplainKit/Modules/Model/Input/ModelInput+Dictonary.swift +++ /dev/null @@ -1,56 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation - -extension Dictionary: ModelInput where Key == String, Value == ModelInput { - public func generateInputPayloadForModel() async throws -> Data { - var parsedSequence: [String: Any] = [:] - let fileUploadManager = FileUploadManager() - - for (_, keyValuePair) in self.enumerated() { - let (key, value) = keyValuePair - - switch value { - case let url as URL: - let remoteURL = try await fileUploadManager.uploadDataIfNeedIt(from: url) - parsedSequence.updateValue(remoteURL.absoluteString.removingPercentEncoding ?? remoteURL.absoluteString, forKey: key) - case let string as String: - parsedSequence.updateValue(string, forKey: key) - case let record as Record: - parsedSequence.updateValue([record.toDictionary()], forKey: key) - case let records as [Record]: - let dictArray = records.map { $0.toDictionary() } - parsedSequence.updateValue(dictArray, forKey: key) - default: - throw ModelError.typeNotRecognizedWhileCreatingACombinedInput - } - } - - guard let jsonData = try? JSONSerialization.data(withJSONObject: parsedSequence, options: []) else { - throw ModelError.inputEncodingError - } - - return jsonData - } -} diff --git a/Sources/aiXplainKit/Modules/Model/Input/ModelInput+Record.swift b/Sources/aiXplainKit/Modules/Model/Input/ModelInput+Record.swift deleted file mode 100644 index 99dde0d..0000000 --- a/Sources/aiXplainKit/Modules/Model/Input/ModelInput+Record.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// File.swift -// aiXplainKit -// -// Created by Joao Maia on 13/05/25. -// - -import Foundation - -extension Record:ModelInput { - public func generateInputPayloadForModel() async throws -> Data { - return try JSONEncoder().encode(self) - } -} - -extension Array:ModelInput where Element == Record { - public func generateInputPayloadForModel() async throws -> Data { - return try JSONEncoder().encode(self) - } -} diff --git a/Sources/aiXplainKit/Modules/Model/Input/ModelInput+String.swift b/Sources/aiXplainKit/Modules/Model/Input/ModelInput+String.swift deleted file mode 100644 index d5d381a..0000000 --- a/Sources/aiXplainKit/Modules/Model/Input/ModelInput+String.swift +++ /dev/null @@ -1,39 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation -/// An extension that conforms `String` to the `ModelInput` protocol. -extension String: ModelInput { - /// Generates an input payload data for the model by wrapping the string value in a dictionary with the key "data". - /// - /// - Returns: The input payload data for the model. - public func generateInputPayloadForModel() -> Data { - let payload = ["data": self] - - guard let jsonData = try? JSONSerialization.data(withJSONObject: payload, options: []) else { - return Data() - } - - return jsonData - } -} diff --git a/Sources/aiXplainKit/Modules/Model/Input/ModelInput+URL.swift b/Sources/aiXplainKit/Modules/Model/Input/ModelInput+URL.swift deleted file mode 100644 index e978a33..0000000 --- a/Sources/aiXplainKit/Modules/Model/Input/ModelInput+URL.swift +++ /dev/null @@ -1,57 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation - -/// An extension that conforms `URL` to the `ModelInput` protocol. -extension URL: ModelInput { - - /// Generates an input payload data for the model. - /// - /// - Returns: An empty `Data` instance. - public func generateInputPayloadForModel() async throws -> Data { - var payload = ["data": self.absoluteString] - - switch self.absoluteString { - case let link where link.starts(with: "s3://"): - break - case let link where link.starts(with: "http://"): - break - case let link where link.starts(with: "https://"): - break - default: - let fileManager = FileUploadManager() - let s3URL = try await fileManager.uploadFile(at: self) - - payload.updateValue(s3URL.absoluteString.removingPercentEncoding ?? s3URL.absoluteString, forKey: "data") - } - - guard let jsonData = try? JSONSerialization.data(withJSONObject: payload, options: []) else { - throw ModelError.failToGenerateAFilePayload(error: String(describing: payload)) - } - - return jsonData - - } - -} diff --git a/Sources/aiXplainKit/Modules/Model/Input/ModelInput.swift b/Sources/aiXplainKit/Modules/Model/Input/ModelInput.swift deleted file mode 100644 index c1024fe..0000000 --- a/Sources/aiXplainKit/Modules/Model/Input/ModelInput.swift +++ /dev/null @@ -1,32 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation - -/// A protocol that defines the requirements for an object to be used as input for a model. -public protocol ModelInput { - /// Generates an input payload data for the model. - /// - /// - Returns: The input payload data for the model. - func generateInputPayloadForModel() async throws -> Data -} diff --git a/Sources/aiXplainKit/Modules/Model/Input/ModelParameter.swift b/Sources/aiXplainKit/Modules/Model/Input/ModelParameter.swift deleted file mode 100644 index abe2ae2..0000000 --- a/Sources/aiXplainKit/Modules/Model/Input/ModelParameter.swift +++ /dev/null @@ -1,43 +0,0 @@ -// -// File.swift -// aiXplainKit -// -// Created by Joao Maia on 03/01/25. -// - -import Foundation - -/// A struct representing a parameter for a model with its properties and constraints -public struct ModelParameter: Codable, Hashable, Equatable { - /// The name of the parameter - public let name: String - - /// Whether this parameter is required - public let required: Bool - - /// Whether this parameter has fixed values - public let isFixed: Bool - - /// Possible values for this parameter - public let values: [Double] - - /// Default values for this parameter - public let defaultValues: [Double] - - /// Available options for this parameter - public let availableOptions: [String] - - /// The data type of this parameter - public let dataType: String - - /// The data subtype of this parameter - public let dataSubType: String - - /// Whether this parameter accepts multiple values - public let multipleValues: Bool - - private enum CodingKeys: String, CodingKey { - case name, required, isFixed, values, defaultValues - case availableOptions, dataType, dataSubType, multipleValues - } -} diff --git a/Sources/aiXplainKit/Modules/Model/Input/UtilityModelInputInformation.swift b/Sources/aiXplainKit/Modules/Model/Input/UtilityModelInputInformation.swift deleted file mode 100644 index 6f3b720..0000000 --- a/Sources/aiXplainKit/Modules/Model/Input/UtilityModelInputInformation.swift +++ /dev/null @@ -1,78 +0,0 @@ -// -// File.swift -// aiXplainKit -// -// Created by Joao Maia on 02/01/25. -// - -import Foundation - -/// Represents different types of inputs that can be provided to a utility model -public enum UtilityModelInput { - /// A text input with a name and optional description - case text(name: String, description: String = "") - /// A numeric input with a name and optional description - case number(name: String, description: String = "") - /// A boolean input with a name and optional description -// case boolean(name: String, description: String = "") - //TODO: Implenent audio, video, image, label, number - - /// Converts the UtilityModelInput into a UtilityModelInputInformation object - /// - Returns: A UtilityModelInputInformation object containing the input's details - func encode() -> UtilityModelInputInformation { - switch self { - case .text(name: let name, description: let description): - return UtilityModelInputInformation(name: name, description: description, type: .text) - case .number(name: let name, description: let description): - return UtilityModelInputInformation(name: String(name), description: description, type: .number) - } - } -} - -/// The possible data types for utility model inputs -public enum UtilityModelInputType: String, Codable { - /// Text input type - case text - /// Numeric input type - case number - /// Boolean input type - case boolean -} - -/// Contains information about an input parameter for a utility model -public class UtilityModelInputInformation: Codable { - /// The name of the input parameter - public let name: String - /// A description of the input parameter - public let description: String - /// The data type of the input parameter - public var type: UtilityModelInputType = .text - - /// Creates a new UtilityModelInputInformation instance - /// - Parameters: - /// - name: The name of the input parameter - /// - description: A description of the input parameter - /// - type: The data type of the input parameter (defaults to .text) - public init(name: String, description: String, type: UtilityModelInputType = .text) { - self.name = name - self.description = description - self.type = type - } - - /// Keys used for encoding and decoding - enum CodingKeys: String, CodingKey { - case name - case description - case type - } - - /// Encodes this instance into the given encoder - /// - Parameter encoder: The encoder to write data to - /// - Throws: An error if encoding fails - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(name, forKey: .name) - try container.encode(description, forKey: .description) - try container.encode(type.rawValue, forKey: .type) - } -} diff --git a/Sources/aiXplainKit/Modules/Model/Model.swift b/Sources/aiXplainKit/Modules/Model/Model.swift deleted file mode 100644 index 479af65..0000000 --- a/Sources/aiXplainKit/Modules/Model/Model.swift +++ /dev/null @@ -1,285 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation -import OSLog - -/** - This is ready-to-use AI model. - -## Overview -The `Model` class represents a ready to use AI Model on the aiXplain Platform. It provides functionality to run the Model and handle its execution. - -## Usage -1. Initialize a `Model` object with the necessary parameters. -2. Call the `run(_:id:parameters:)` method to execute the pipeline. - -## Example -```swift -let model = ModelProvider.get("ModelID") -let input = "Hello World" -do { - let output = try await model.run(input) - // Handle model output -} catch { - // Handle errors -}``` - */ -public class Model:Codable, CustomStringConvertible { - - /// Unique identifier for the model. - public var id: String - - /// Name of the model. - public var name: String - - /// Description of the model's functionality. - public let modelDescription: String - - /// The entity that provides the model. - public var supplier: Supplier - - /// Version of the model. - public var version: Version? - - /// Optional license information associated with the model. - public let license: License? - - /// Optional privacy information associated with the model. - public let privacy: Privacy? - - /// Information about the model's pricing. - public let pricing: Pricing - - /// The networking service responsible for making API calls and handling URL sessions. - var networking: Networking - - /// The entity or platform hosting the model. - public let hostedBy: String - - /// The organization or individual who developed the model. - public let developedBy: String - - /// Parameters that can be passed to the model during execution - public let parameters: [ModelParameter] - - public let function:Function? - - private let logger: Logger - - public var description: String = "" - - public var debugDescription: String { - var description = "Model:\n" - description += " ID: \(id)\n" - description += " Name: \(name)\n" - description += " Description: \(self.modelDescription)\n" - description += " Hosted By: \(hostedBy)\n" - description += " Developed By: \(developedBy)\n" - description += " Version: \(version.debugDescription)\n" - description += " Pricing: \(pricing)\n" - if !parameters.isEmpty { - description += " Parameters:\n" - for param in parameters { - description += " - \(param.name) (\(param.dataType))\n" - description += " Required: \(param.required)\n" - if !param.availableOptions.isEmpty { - description += " Options: \(param.availableOptions.joined(separator: ", "))\n" - } - if !param.defaultValues.isEmpty { - description += " Default Values: \(param.defaultValues)\n" - } - } - } - return description - } - - // MARK: - Initialization - - /// Creates a new `Model` instance from the provided decoder. Mainly used to decode JSON data. - /// - Parameter decoder: The decoder to use for decoding the model. - /// - Throws: `DecodingError` if there are any issues during decoding. - required public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - - id = try container.decodeIfPresent(String.self, forKey: .id) ?? "" - name = try container.decode(String.self, forKey: .name) - modelDescription = try container.decodeIfPresent(String.self, forKey: .description) ?? "An ML Model" - description = modelDescription - supplier = try container.decodeIfPresent(Supplier.self, forKey: .supplier) ?? Supplier(id: 0, name: "no", code: "") - version = try container.decodeIfPresent(Version.self, forKey: .version) - pricing = try container.decode(Pricing.self, forKey: .pricing) - hostedBy = try container.decode(String.self, forKey: .hostedBy) - developedBy = try container.decode(String.self, forKey: .developedBy) - parameters = (try? container.decodeIfPresent([ModelParameter].self, forKey: .params)) ?? [] - - function = try? container.decodeIfPresent(Function.self, forKey: .function) - - privacy = nil - license = nil - logger = Logger(subsystem: "AiXplain", category: "Model(\(name)") - networking = Networking() - } - - /// Creates a new `Model` instance with the provided parameters. - /// - Parameters: - /// - id: Unique identifier for the model. - /// - name: Name of the model. - /// - description: Description of the model's functionality. - /// - supplier: The entity that provides the model. - /// - version: Version of the model. - /// - license: Optional license information associated with the model. - /// - privacy: Optional privacy information associated with the model. - /// - pricing: Information about the model's pricing. - /// - hostedBy: The entity or platform hosting the model. - /// - developedBy: The organization or individual who developed the model. - /// - networking: Networking service used for API calls. - public init(id: String, name: String, description: String, supplier: Supplier, version: Version? = nil, license: License? = nil, privacy: Privacy? = nil, pricing: Pricing, hostedBy: String, developedBy: String, networking: Networking) { - self.id = id - self.name = name - self.modelDescription = description - self.description = modelDescription - self.supplier = supplier - self.version = version - self.license = license - self.privacy = privacy - self.pricing = pricing - self.hostedBy = hostedBy - self.developedBy = developedBy - self.parameters = [] - self.logger = Logger(subsystem: "AiXplain", category: "Model(\(name)") - self.networking = networking - self.function = nil - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(id, forKey: .id) - try container.encode(name, forKey: .name) - try container.encode(modelDescription, forKey: .description) - try container.encode(supplier, forKey: .supplier) - try container.encode(version, forKey: .version) - try container.encode(license, forKey: .license) - try container.encode(privacy, forKey: .privacy) - try container.encode(pricing, forKey: .pricing) - try container.encode(hostedBy, forKey: .hostedBy) - try container.encode(developedBy, forKey: .developedBy) - try container.encode(parameters, forKey: .params) - try container.encode(function, forKey: .function) - } - - // Private enum for coding keys to improve readability and maintainability. - private enum CodingKeys: String, CodingKey { - case id, name, description, supplier, version, license, privacy, pricing, hostedBy, developedBy, params, function - } -} - -// MARK: - Model Execution - -extension Model { - // Runs the model with the provided input and parameters. - /// - Parameters: - /// - modelInput: The input data for the model. - /// - modelRunIdentifier: A unique identifier for the model run. Default is "model_process". - /// - runParameters: Parameters for the model run, such as polling wait time and retry limits. - /// - /// - Returns: The output of the model run. - /// - /// - Throws: - /// - ModelError: If there are any issues related to the model itself. - /// - NetworkingError: If there are any networking issues during the model run. - public func run(_ modelInput: ModelInput, id: String = "model_process", parameters: ModelRunParameters = .defaultParameters) async throws -> ModelOutput { - let headers = try self.networking.buildHeader() - let payload = try await modelInput.generateInputPayloadForModel() - guard let url = APIKeyManager.shared.MODELS_RUN_URL else { - throw ModelError.missingModelRunURL - } - - guard let url = URL(string: url.absoluteString + self.id) else { - throw ModelError.invalidURL(url: url.absoluteString) - } - - logger.debug("Creating a execution with the following payload \(String(data: payload, encoding: .utf8) ?? "-")") - networking.parameters = parameters - let response = try await networking.post(url: url, headers: headers, body: payload) - - if let httpUrlResponse = response.1 as? HTTPURLResponse, - httpUrlResponse.statusCode != 201 { - throw NetworkingError.invalidStatusCode(statusCode: httpUrlResponse.statusCode) - } - - let decodedResponse = try JSONDecoder().decode(ModelExecuteResponse.self, from: response.0) - - guard let pollingURL = decodedResponse.pollingURL else { - throw ModelError.failToDecodeRunResponse - } - logger.info("Successfully created a execution") - return try await polling(from: pollingURL, - maxRetry: parameters.maxPollingRetries, - waitTime: parameters.pollingWaitTimeInSeconds) - - } - - /// Keeps polling the platform to check whether an asynchronous model run is complete. - /// - Parameters: - /// - url: The URL to poll for the model run result. - /// - maxRetries: The maximum number of retries before giving up. Default is 300. - /// - waitTime: The time to wait between retries in seconds. Default is 0.5 seconds. - /// - Returns: The output of the model run. - /// - Throws: `ModelError` or `NetworkingError` if there are any issues during polling. - private func polling(from url: URL, maxRetry: Int = 300, waitTime: Double = 0.5) async throws -> ModelOutput { - let headers = try self.networking.buildHeader() - - var itr = 0 - - logger.info("Starting polling job") - repeat { - let response = try await networking.get(url: url, headers: headers) - - logger.debug("(\(itr)/\(maxRetry))Polling...") - print("Pooling JSON: \(String(data:response.0,encoding:.utf8))") - if let json = try? JSONSerialization.jsonObject(with: response.0, options: []) as? [String: Any], - let completed = json["completed"] as? Bool { - - if let _ = json["error"] as? String, let supplierError = json["supplierError"] as? String { - throw ModelError.supplierError(error: supplierError) - } - - if completed { - do { - let decodedResponse = try JSONDecoder().decode(ModelOutput.self, from: response.0) - logger.info("Polling job finished.") - return decodedResponse - } catch { - throw ModelError.failToDecodeModelOutputDuringPollingPhase(error: String(describing: error)) - } - } - } - - try await Task.sleep(nanoseconds: UInt64(max(0.2, waitTime) * 1_000_000_000)) - itr+=1 - } while itr < maxRetry - - throw ModelError.pollingTimeoutOnModelResponse(pollingURL: url) - } -} diff --git a/Sources/aiXplainKit/Modules/Model/Query/ModelQuery.swift b/Sources/aiXplainKit/Modules/Model/Query/ModelQuery.swift deleted file mode 100644 index 9f023b4..0000000 --- a/Sources/aiXplainKit/Modules/Model/Query/ModelQuery.swift +++ /dev/null @@ -1,134 +0,0 @@ -// -// File.swift -// -// -// Created by Joao Pedro Monteiro Maia on 22/05/24. -// - -import Foundation - -/// Represents a query to be sent to a model. -/// -/// The `ModelQuery` struct is used to encapsulate the parameters needed for querying a model, including optional filters, -/// pagination settings, and specific functions to execute. It also includes utilities for building the query -/// as a JSON payload. -public struct ModelQuery { - // MARK: - Properties - - /// The search query string. - var query: String? - - /// The current page number for paginated results (default is `0`). - var pageNumber: Int = 0 - - /// The number of results per page (default is `40`). - var pageSize: Int = 40 - - /// A list of specific functions to be executed by the model. - var functions: [String] - - var sortBy:SortByParameter? = nil - - var sortOrder:SortOrderParameter? = nil - - // Initializes a new instance of `ModelQuery` with the specified parameters. - /// - /// Use this initializer to configure a query with optional search filters, pagination settings, - /// sorting preferences, and a list of functions to execute. - /// - /// - Parameters: - /// - query: An optional search query string. Defaults to `nil`. - /// - pageNumber: The current page number for paginated results. Defaults to `0`. - /// - pageSize: The number of results per page. Defaults to `40`. - /// - functions: A list of specific functions to be executed by the model. - /// - sortBy: An optional sorting parameter that specifies the attribute by which results are sorted. Defaults to `nil`. - /// - sortOrder: An optional sorting order (e.g., ascending or descending). Defaults to `nil`. - /// - /// # Example - /// ```swift - /// let query = ModelQuery( - /// query: "example", - /// pageNumber: 1, - /// pageSize: 20, - /// functions: ["function1", "function2"], - /// sortBy: .creationDate, - /// sortOrder: .ascending - /// ) - /// ``` - /// - /// This creates a query configured to search for the term "example," request results - /// from page 1 with 20 items per page, and sort by creation date in ascending order. - public init(query: String? = nil, pageNumber: Int = 0, pageSize: Int = 40, functions: [String], sortBy: ModelQuery.SortByParameter? = nil, sortOrder: ModelQuery.SortOrderParameter? = nil) { - self.query = query - self.pageNumber = pageNumber - self.pageSize = pageSize - self.functions = functions - self.sortBy = sortBy - self.sortOrder = sortOrder - } - - - // MARK: - Methods - - /// Builds the query as a JSON payload. - /// - /// This method constructs a JSON object with the query parameters, including pagination settings, - /// the search query, and the list of functions. It then serializes the object into `Data`. - /// - /// - Returns: A `Data` object representing the JSON payload of the query. - /// - Throws: - /// - `PipelineError.inputEncodingError` if the JSON serialization fails. - /// - /// # Example - /// ```swift - /// let query = ModelQuery(query: "example", pageNumber: 1, pageSize: 20, functions: ["function1", "function2"]) - /// do { - /// let jsonData = try query.buildQuery() - /// print(String(data: jsonData, encoding: .utf8)!) // JSON representation of the query - /// } catch { - /// print("Failed to build query: \(error)") - /// } - /// ``` - public func buildQuery() throws -> Data { - var body: [String: Decodable] = ["pageNumber": pageNumber, "pageSize": pageSize] - - if !functions.isEmpty { - body["functions"] = functions - } - - if let q = query { - body["q"] = q - } - - if let sortBy { - body["sortBy"] = sortBy.rawValue - } - - if let sortOrder { - body["sortOrder"] = sortOrder.rawValue - } - - - - guard let jsonData = try? JSONSerialization.data(withJSONObject: body, options: []) else { - throw PipelineError.inputEncodingError // Ensure proper error is implemented - } - - return jsonData - } -} - - -//MARK: Query parameters -extension ModelQuery{ - public enum SortByParameter:String{ - case creationDate = "createdAt" - case price = "normalizedPrice" - case popularity = "totalSubscribed" - } - - public enum SortOrderParameter:String{ - case ascending = "asc" - case descending = "desc" - } -} diff --git a/Sources/aiXplainKit/Modules/Model/Utility.swift b/Sources/aiXplainKit/Modules/Model/Utility.swift deleted file mode 100644 index 9198d7c..0000000 --- a/Sources/aiXplainKit/Modules/Model/Utility.swift +++ /dev/null @@ -1,339 +0,0 @@ -import Foundation -import OSLog - - - -public final class UtilityModel: Codable { - public var id: String - public let name: String - public var code: String - public var description: String - public var inputs: [UtilityModelInputInformation] - public var outputExamples: String - public let supplier: Supplier? - public var version: String? - public let isSubscribed: Bool = false - private var modelInstance:Model? - public var status:String = "draft" - - - enum CodingKeys: String, CodingKey { - case id - case name - case description - case inputs - case code - case outputExamples = "outputDescription" - case supplier - case version - case isSubscribed - case status - } - - - - public init(id: String, name: String, code: String, description: String, inputs: [UtilityModelInput], outputExamples: String, supplier: Supplier? = nil, version: String? = nil) { - self.id = id - self.name = name - self.code = code - self.description = description - self.inputs = inputs.map{$0.encode()} - self.outputExamples = outputExamples - self.supplier = supplier - self.version = version - } - - public init(id: String, name: String, code: String, description: String, inputs: [UtilityModelInputInformation], outputExamples: String, supplier: Supplier? = nil, version: String? = nil, status:String = "draft") { - self.id = id - self.name = name - self.code = code - self.description = description - self.inputs = inputs - self.outputExamples = outputExamples - self.supplier = supplier - self.version = version - self.status = status - } - - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(String.self, forKey: .id) - name = try container.decode(String.self, forKey: .name) - description = try container.decode(String.self, forKey: .description) - inputs = try container.decode([UtilityModelInputInformation].self, forKey: .inputs) - code = try container.decode(String.self, forKey: .code) - outputExamples = try container.decode(String.self, forKey: .outputExamples) - supplier = try container.decodeIfPresent(Supplier.self, forKey: .supplier) - version = try container.decodeIfPresent(String.self, forKey: .version) - status = try container.decodeIfPresent(String.self, forKey: .status) ?? "draft" - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(name, forKey: .name) - try container.encode(description, forKey: .description) - - try container.encode(inputs, forKey: .inputs) - - try container.encode(code, forKey: .code) - try container.encode(outputExamples, forKey: .outputExamples) - try container.encode(status, forKey: .status) - } - - - public convenience init(from model: Model){ - var inputs:[UtilityModelInputInformation] = [] - - model.parameters.forEach{ param in - inputs.append(UtilityModelInputInformation(name: param.name, description: "", type: UtilityModelInputType(rawValue: param.dataType) ?? .text)) - } - - self.init(id: model.id, name: model.name, code: "", description: model.description, inputs: inputs, outputExamples: "",version: model.version?.id ?? "") - self.modelInstance = model - - Task{ - try await updateCode() - try await syncStatus() - } - } - - - - @discardableResult - public func updateCode() async throws -> String?{ - if let model = modelInstance, - let version = model.version, - let versionUrl = URL(string: version.id ?? "") { - let (data, response) = try await URLSession.shared.data(from: versionUrl) - - if let httpResponse = response as? HTTPURLResponse, - !(200...299).contains(httpResponse.statusCode) { - throw NetworkingError.invalidStatusCode(statusCode: httpResponse.statusCode) - } - - if let codeString = String(data: data, encoding: .utf8) { - self.code = codeString - return codeString - } - - return nil - } - return nil - } - - - public func updateModelInstance() async throws{ - self.modelInstance = try await ModelProvider().get(self.id) - } - - @discardableResult - public func syncStatus(networking: Networking? = nil) async throws{ - let networking = networking ?? Networking() - let headers: [String: String] = try networking.buildHeader() - - guard let url = APIKeyManager.shared.BACKEND_URL else { - throw ModelError.missingBackendURL - } - - let endpoint = Networking.Endpoint.model(modelIdentifier: self.id) - guard let url = URL(string: url.absoluteString + endpoint.path) else { - throw ModelError.invalidURL(url: url.absoluteString + endpoint.path) - } - - let response = try await networking.get(url: url, headers: headers) - - if let httpUrlResponse = response.1 as? HTTPURLResponse, - httpUrlResponse.statusCode != 200 { - throw NetworkingError.invalidStatusCode(statusCode: httpUrlResponse.statusCode) - } - - struct statusResponse: Decodable{ - var status: String - } - - do { - let fetchedModel = try JSONDecoder().decode(statusResponse.self, from: response.0) - self.status = fetchedModel.status - } catch { - throw error - } - } - -} - -//MARK: Update & Delete -extension UtilityModel{ - - /// Updates the utility model on the server with its current state. - /// - /// This method synchronizes the current state of the utility model with the server by: - /// 1. Updating the code from the model version URL - /// 2. Encoding the model into JSON - /// 3. Sending a PUT request to update the server - /// 4. Updating the local ID with the response - /// - /// - Parameter networking: Optional networking instance to use. If nil, creates a new one. - /// - Returns: The ID of the updated utility model - /// - Throws: - /// - `ModelError.missingBackendURL` if the backend URL is not configured - /// - `ModelError.invalidURL` if the constructed URL is invalid - /// - `NetworkingError.invalidStatusCode` if response status code is not 2xx - /// - `ModelError.unableToUpdateModelUtility` if response decoding fails - /// - /// # Example - /// ```swift - /// do { - /// let newId = try await utilityModel.update() - /// print("Model updated with ID: \(newId)") - /// } catch { - /// print("Failed to update model: \(error)") - /// } - /// ``` - @discardableResult - public func update(networking: Networking? = nil) async throws -> String{ - let networking = networking ?? Networking() - if code.isEmpty{ - try await self.updateCode() - } - let headers = try networking.buildHeader() - - guard let url = APIKeyManager.shared.BACKEND_URL else { - throw ModelError.missingBackendURL - } - - let endpoint = Networking.Endpoint.utilities.path - guard let url = URL(string: url.absoluteString + endpoint + "/" + self.id) else { - throw ModelError.invalidURL(url: url.absoluteString) - } - - let payload = try JSONEncoder().encode(self) - - - let response = try await networking.put(url: url, body: payload, headers: headers) - - if let httpUrlResponse = response.1 as? HTTPURLResponse, - !(200...299).contains(httpUrlResponse.statusCode) { - throw NetworkingError.invalidStatusCode(statusCode: httpUrlResponse.statusCode) - } - - struct IDResponse: Codable { - let id: String - } - - do { - let decodedResponse = try JSONDecoder().decode(IDResponse.self, from: response.0) - self.id = decodedResponse.id - self.modelInstance = try await ModelProvider().get(self.id) - return id - } catch { - throw ModelError.unableToUpdateModelUtility(error: error.localizedDescription) - } - - } - - /// Deploy model - public func deploy() async throws { - self.status = "onboarded" - try await self.update() - } - - /// Deletes the utility model from the server. - /// - /// This method sends a DELETE request to remove the utility model from the aiXplain platform. - /// Once deleted, the model cannot be recovered. - /// - /// - Parameter networking: Optional networking instance for making the request. If nil, creates a new instance. - /// - /// - Throws: - /// - `ModelError.missingBackendURL` if the backend URL is not configured - /// - `ModelError.invalidURL` if the constructed URL is invalid - /// - `NetworkingError.invalidStatusCode` if response status code is not 2xx - /// - `ModelError.unableToUpdateModelUtility` if response decoding fails - /// - /// # Example - /// ```swift - /// do { - /// try await utilityModel.delete() - /// print("Model successfully deleted") - /// } catch { - /// print("Failed to delete model: \(error)") - /// } - /// ``` - public func delete(networking: Networking? = nil) async throws{ - let networking = networking ?? Networking() - let headers = try networking.buildHeader() - - guard let url = APIKeyManager.shared.BACKEND_URL else { - throw ModelError.missingBackendURL - } - - let endpoint = Networking.Endpoint.utilities.path - guard let url = URL(string: url.absoluteString + endpoint + "/" + self.id) else { - throw ModelError.invalidURL(url: url.absoluteString) - } - - - let response = try await networking.delete(url: url, headers: headers) - - if let httpUrlResponse = response.1 as? HTTPURLResponse, - !(200...299).contains(httpUrlResponse.statusCode) { - throw NetworkingError.invalidStatusCode(statusCode: httpUrlResponse.statusCode) - } - - struct IDResponse: Codable { - let id: String - } - - do { - let decodedResponse = try JSONDecoder().decode(IDResponse.self, from: response.0) - self.id = decodedResponse.id - - } catch { - throw ModelError.unableToUpdateModelUtility(error: error.localizedDescription) - } - } -} - -//MARK: Utility Model Execution -extension UtilityModel{ - /// Executes the utility model with the given input and parameters. - /// - /// This method runs the model either using a cached instance or by fetching a new instance from the server. - /// - /// - Parameters: - /// - modelInput: The input data to process - /// - id: Optional identifier for the model process (defaults to "model_process") - /// - parameters: Optional run parameters (defaults to .defaultParameters) - /// - /// - Returns: A ModelOutput containing the results of the model execution - /// - /// - Throws: - /// - `ModelError.failToCallModelExecuteFromUtility` if the model execution fails - /// - /// # Example - /// ```swift - /// do { - /// let input = ModelInput(/* input parameters */) - /// let output = try await utilityModel.run(input) - /// print("Model execution successful") - /// } catch { - /// print("Model execution failed: \(error)") - /// } - /// ``` - public func run(_ modelInput: ModelInput, id: String = "model_process", parameters: ModelRunParameters = .defaultParameters) async throws -> ModelOutput { - if let model = modelInstance { - return try await model.run(modelInput, id:id, parameters: parameters) - } - do { - let model = try await ModelProvider().get(self.id) - self.modelInstance = model - return try await model.run(modelInput, id:id, parameters: parameters) - }catch { - throw ModelError.failToCallModelExecuteFromUtility(error: error.localizedDescription) - } - } - -} - - diff --git a/Sources/aiXplainKit/Modules/Parameters/Agent/Agent+RunParameters.swift b/Sources/aiXplainKit/Modules/Parameters/Agent/Agent+RunParameters.swift deleted file mode 100644 index 8298a24..0000000 --- a/Sources/aiXplainKit/Modules/Parameters/Agent/Agent+RunParameters.swift +++ /dev/null @@ -1,114 +0,0 @@ -// -// File.swift -// aiXplainKit -// -// Created by Joao Maia on 18/11/24. -// - -import Foundation - -/// A struct representing the parameters used to run an agent. -/// -/// The `AgentRunParameters` struct includes configurations for polling, networking, and agent execution. These parameters define how the agent should behave during its lifecycle, such as timeouts, retry limits, and additional settings for execution. -public struct AgentRunParameters: RunParameters, NetworkingParametersProtocol { - - // MARK: - Polling Parameters - - /// The time interval (in seconds) to wait before attempting another polling operation. - public var pollingWaitTimeInSeconds: TimeInterval - - /// The maximum number of retries allowed for polling operations. - public var maxPollingRetries: Int - - // MARK: - Networking Parameters - - /// The timeout interval (in seconds) for network requests. - public var networkTimeoutInSecondsInterval: TimeInterval - - /// The maximum number of retries allowed for network calls. - public var maxNetworkCallRetries: Int - - // MARK: - Agent Execution Parameters - - /// The chat history data. - public var history: Data? - - /// The name of the model process. - public var name: String - - /// The timeout interval (in seconds) for the agent execution. - public var timeout: Float - - /// Additional parameters for agent execution, serialized as data. - public var parameters: Data? - - /// The maximum number of tokens that the agent can generate. - public var maxTokens: Int - - /// The maximum number of iterations the agent is allowed to execute. - public var maxIterations: Int - - // MARK: - Default Parameters - - /// A set of default parameters for running an agent. - public static let defaultParameters: AgentRunParameters = AgentRunParameters() - - // MARK: - Initializer - - /// Initializes a new `AgentRunParameters` instance with specified values. - /// - /// - Parameters: - /// - pollingWaitTimeInSeconds: Time interval between polling attempts (default is `0.5` seconds). - /// - maxPollingRetries: Maximum number of retries for polling operations (default is `300` retries). - /// - networkTimeoutInSecondsInterval: Timeout for network requests (default is `10` seconds). - /// - maxNetworkCallRetries: Maximum number of retries for network calls (default is `2` retries). - /// - history: Optional chat history data (default is `nil`). - /// - name: The name of the model process (default is `"model_process"`). - /// - timeout: Timeout for agent execution (default is `300` seconds). - /// - parameters: Additional parameters for execution (default is `nil`). - /// - maxTokens: Maximum number of tokens the agent can generate (default is `2500`). - /// - maxIterations: Maximum number of iterations for the agent (default is `10`). - public init( - pollingWaitTimeInSeconds: TimeInterval = 0.5, - maxPollingRetries: Int = 300, - networkTimeoutInSecondsInterval: TimeInterval = 10, - maxNetworkCallRetries: Int = 2, - history: Data? = nil, - name: String = "model_process", - timeout: Float = 300, - parameters: Data? = nil, - maxTokens: Int = 2500, - maxIterations: Int = 10 - ) { - self.pollingWaitTimeInSeconds = pollingWaitTimeInSeconds - self.maxPollingRetries = maxPollingRetries - self.networkTimeoutInSecondsInterval = networkTimeoutInSecondsInterval - self.maxNetworkCallRetries = maxNetworkCallRetries - self.history = history - self.name = name - self.timeout = timeout - self.parameters = parameters - self.maxTokens = maxTokens - self.maxIterations = maxIterations - } - - // MARK: - Utilities - - /// Returns an array of parameter names and their corresponding values. - /// - /// - Returns: A collection of tuples containing parameter names and values. - func runParametersIterator() -> [(paramName: String, value: Any)] { - let selectedProperties: [String: Any?] = [ - "history": history, - "name": name, - "timeout": timeout, - "parameters": parameters, - "max_tokens": maxTokens, - "max_iterations": maxIterations - ] - return selectedProperties.compactMap { key, value in - guard let value = value else { return nil } - return (paramName: key, value: value) - } - } -} diff --git a/Sources/aiXplainKit/Modules/Parameters/Model/Model+RunParameters.swift b/Sources/aiXplainKit/Modules/Parameters/Model/Model+RunParameters.swift deleted file mode 100644 index 7db5273..0000000 --- a/Sources/aiXplainKit/Modules/Parameters/Model/Model+RunParameters.swift +++ /dev/null @@ -1,48 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation - -public struct ModelRunParameters: RunParameters, NetworkingParametersProtocol { - - /// The time interval (in seconds) to wait before attempting another polling operation. - public var pollingWaitTimeInSeconds: TimeInterval = 0.5 - - /// The maximum number of retries allowed for polling operations. - public var maxPollingRetries: Int = 300 - - /// The timeout interval (in seconds) for network requests. - public var networkTimeoutInSecondsInterval: TimeInterval = 10 - - /// The maximum number of retries allowed for network calls. - public var maxNetworkCallRetries: Int = 2 - - public static let defaultParameters: ModelRunParameters = ModelRunParameters() - - public init(pollingWaitTimeInSeconds: TimeInterval = 0.5, maxPollingRetries: Int = 300, networkTimeoutInSecondsInterval: TimeInterval = 10, maxNetworkCallRetries: Int = 2) { - self.pollingWaitTimeInSeconds = pollingWaitTimeInSeconds - self.maxPollingRetries = maxPollingRetries - self.networkTimeoutInSecondsInterval = networkTimeoutInSecondsInterval - self.maxNetworkCallRetries = maxNetworkCallRetries - } -} diff --git a/Sources/aiXplainKit/Modules/Parameters/NetworkingParametersProtocol.swift b/Sources/aiXplainKit/Modules/Parameters/NetworkingParametersProtocol.swift deleted file mode 100644 index 4a6ca3b..0000000 --- a/Sources/aiXplainKit/Modules/Parameters/NetworkingParametersProtocol.swift +++ /dev/null @@ -1,36 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation -public protocol NetworkingParametersProtocol { - /// The timeout interval (in seconds) for network requests. - var networkTimeoutInSecondsInterval: TimeInterval {get set} - - /// The maximum number of retries allowed for network calls. - var maxNetworkCallRetries: Int {get set} -} - -public struct NetworkingParameters: NetworkingParametersProtocol { - public var networkTimeoutInSecondsInterval: TimeInterval = 10 - public var maxNetworkCallRetries: Int = 2 -} diff --git a/Sources/aiXplainKit/Modules/Parameters/Pipeline/Pipeline+RunParameters.swift b/Sources/aiXplainKit/Modules/Parameters/Pipeline/Pipeline+RunParameters.swift deleted file mode 100644 index 0541650..0000000 --- a/Sources/aiXplainKit/Modules/Parameters/Pipeline/Pipeline+RunParameters.swift +++ /dev/null @@ -1,41 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation - -public struct PipelineRunParameters: RunParameters, NetworkingParametersProtocol { - - /// The time interval (in seconds) to wait before attempting another polling operation. - public var pollingWaitTimeInSeconds: TimeInterval = 0.5 - - /// The maximum number of retries allowed for polling operations. - public var maxPollingRetries: Int = 300 - - /// The timeout interval (in seconds) for network requests. - public var networkTimeoutInSecondsInterval: TimeInterval = 10 - - /// The maximum number of retries allowed for network calls. - public var maxNetworkCallRetries: Int = 2 - - public static let defaultParameters: PipelineRunParameters = PipelineRunParameters() -} diff --git a/Sources/aiXplainKit/Modules/Parameters/RunParameters.swift b/Sources/aiXplainKit/Modules/Parameters/RunParameters.swift deleted file mode 100644 index 66e9c53..0000000 --- a/Sources/aiXplainKit/Modules/Parameters/RunParameters.swift +++ /dev/null @@ -1,42 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation - -/** - A protocol defining the parameters for running a run or operation. - - This protocol establishes a set of properties that describe the configuration settings - required to execute a run, such as the amount of time to wait between polling attempts, - the number of retries allowed for polling and network calls, and the timeout interval - for network requests. - */ -public protocol RunParameters { - - /// The time interval (in seconds) to wait before attempting another polling operation. - var pollingWaitTimeInSeconds: TimeInterval { get set } - - /// The maximum number of retries allowed for polling operations. - var maxPollingRetries: Int { get set } - -} diff --git a/Sources/aiXplainKit/Modules/Pipeline/Input/PipelineInput+Dictionary.swift b/Sources/aiXplainKit/Modules/Pipeline/Input/PipelineInput+Dictionary.swift deleted file mode 100644 index 9807165..0000000 --- a/Sources/aiXplainKit/Modules/Pipeline/Input/PipelineInput+Dictionary.swift +++ /dev/null @@ -1,66 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation - -extension Dictionary: PipelineInput where Key == String, Value == PipelineInput { - - /// Generates an input payload for the pipeline based on the dictionary's key-value pairs. - /// - /// This method iterates through the dictionary's key-value pairs and constructs a payload that can be used as input for the pipeline. If the value is a URL, it uploads the data and includes the remote URL in the payload. If the value is a string, it includes the string directly in the payload. Other types are not supported and will result in an error. - /// - /// - Returns: The generated input payload as `Data`. - /// - Throws: `PipelineError.typeNotRecognizedWhileCreatingACombinedInput` if an unsupported value type is encountered. - /// `FileUploadError` if an error occurs during file upload. - /// `PipelineError.inputEncodingError` if an error occurs during JSON encoding. - public func generateInputPayloadForPipeline() async throws -> Data { - var valuesSequence: [[String: String]] = [] - let fileUploadManager = FileUploadManager() - - for (_, keyValuePair) in self.enumerated() { - let (key, value) = keyValuePair - var valuesDict: [String: String] = [:] - - switch value { - case let url as URL: - let remoteURL = try await fileUploadManager.uploadDataIfNeedIt(from: url) - valuesDict.updateValue(remoteURL.absoluteString.removingPercentEncoding ?? remoteURL.absoluteString, forKey: "value") - case let string as String: - valuesDict.updateValue(string, forKey: "value") - default: - throw PipelineError.typeNotRecognizedWhileCreatingACombinedInput - } - - valuesDict.updateValue(key, forKey: "nodeId") - valuesSequence.append(valuesDict) - } - - let ouputDict = ["data": valuesSequence] - - guard let jsonData = try? JSONSerialization.data(withJSONObject: ouputDict, options: []) else { - throw PipelineError.inputEncodingError - } - - return jsonData - } -} diff --git a/Sources/aiXplainKit/Modules/Pipeline/Input/PipelineInput+String.swift b/Sources/aiXplainKit/Modules/Pipeline/Input/PipelineInput+String.swift deleted file mode 100644 index 22e2dc0..0000000 --- a/Sources/aiXplainKit/Modules/Pipeline/Input/PipelineInput+String.swift +++ /dev/null @@ -1,40 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation -/// An extension that conforms `String` to the `ModelInput` protocol. -extension String: PipelineInput { - /// Generates an input payload data for the model by wrapping the string value in a dictionary with the key "data". - /// - /// - Returns: The input payload data for the model. - public func generateInputPayloadForPipeline() -> Data { - let payload = ["data": self] - - guard let jsonData = try? JSONSerialization.data(withJSONObject: payload, options: []) else { - return Data() - } - - return jsonData - } - -} diff --git a/Sources/aiXplainKit/Modules/Pipeline/Input/PipelineInput+URL.swift b/Sources/aiXplainKit/Modules/Pipeline/Input/PipelineInput+URL.swift deleted file mode 100644 index b10d3cb..0000000 --- a/Sources/aiXplainKit/Modules/Pipeline/Input/PipelineInput+URL.swift +++ /dev/null @@ -1,56 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation - -/// An extension that conforms `URL` to the `PipelineInput` protocol. -extension URL: PipelineInput { - - /// Generates an input payload data for the pipeline. - /// - /// - Returns: An empty `Data` instance. - public func generateInputPayloadForPipeline() async throws -> Data { - var payload = ["data": self.absoluteString] - - switch self.absoluteString { - case let link where link.starts(with: "s3://"): - break - case let link where link.starts(with: "http://"): - break - case let link where link.starts(with: "https://"): - break - default: - let fileManager = FileUploadManager() - let s3URL = try await fileManager.uploadFile(at: self) - - payload.updateValue(s3URL.absoluteString.removingPercentEncoding ?? s3URL.absoluteString, forKey: "data") - } - - guard let jsonData = try? JSONSerialization.data(withJSONObject: payload, options: []) else { - throw ModelError.failToGenerateAFilePayload(error: String(describing: payload)) - } - - return jsonData - - } -} diff --git a/Sources/aiXplainKit/Modules/Pipeline/Input/PipelineInput.swift b/Sources/aiXplainKit/Modules/Pipeline/Input/PipelineInput.swift deleted file mode 100644 index 065de06..0000000 --- a/Sources/aiXplainKit/Modules/Pipeline/Input/PipelineInput.swift +++ /dev/null @@ -1,33 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation - -/// A protocol that defines the requirements for an object to be used as input for a pipeline. -public protocol PipelineInput { - - /// Generates an input payload data for the pipeline. - /// - /// - Returns: The input payload data for the pipeline. - func generateInputPayloadForPipeline() async throws -> Data -} diff --git a/Sources/aiXplainKit/Modules/Pipeline/Pipeline.swift b/Sources/aiXplainKit/Modules/Pipeline/Pipeline.swift deleted file mode 100644 index 5bc5994..0000000 --- a/Sources/aiXplainKit/Modules/Pipeline/Pipeline.swift +++ /dev/null @@ -1,187 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation -import OSLog - -/** - A custom pipeline that can be created on the aiXplain Platform. - - ## Overview - The `Pipeline` class represents a custom pipeline on the aiXplain Platform. It provides functionality to run the pipeline and handle its execution. - - ## Usage - 1. Initialize a `Pipeline` object with the necessary parameters. - 2. Call the `run(_:id:parameters:)` method to execute the pipeline. - - ## Example - ```swift - let pipeline = PipelineFactory.get("PipelineID") - let input = "Hello World" - do { - let output = try await pipeline.run(input) - // Handle pipeline output - } catch { - // Handle errors - }``` - */ -public final class Pipeline: Decodable, CustomStringConvertible { - /// The unique identifier of the pipeline. - public var id: String - - /// The API key generated for pipeline usage. - private let apiKey: String - - /// An array of input nodes in the pipeline. - public let inputNodes: [PipelineNode] - - /// An array of output nodes in the pipeline. - public let outputNodes: [PipelineNode] - - /// The networking service responsible for making API calls. - var networking: Networking - - /// The logger used for logging pipeline events. - private let logger: Logger - - enum CodingKeys: String, CodingKey { - case id - case nodes - case subscription - case apiKey - } - - /// Initializes a pipeline object from decoder. - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let subscriptionContainer = try container.nestedContainer(keyedBy: CodingKeys.self, forKey: .subscription) - id = try subscriptionContainer.decode(String.self, forKey: .id) - apiKey = try subscriptionContainer.decode(String.self, forKey: .apiKey) - inputNodes = try container.decode([PipelineNode].self, forKey: .nodes).filter {$0.type == "INPUT"} - outputNodes = try container.decode([PipelineNode].self, forKey: .nodes).filter {$0.type == "OUTPUT"} - networking = Networking() - logger = Logger(subsystem: "AiXplain", category: "Pipeline") - } - - public var description: String { - var description = "Pipeline:\n" - description += " ID: \(id)\n" - description += " <- Input:\n" - inputNodes.forEach { node in - description += "\t[\(node.number)]\(node.label):\(node.type)\n" - } - description += " -> output:\n" - outputNodes.forEach { node in - description += "\t[\(node.number)]\(node.label):\(node.type)\n" - } - return description - } - -} - -// MARK: - Pipeline Execution - -extension Pipeline { - - /** - Runs the pipeline with the provided input. - - - Parameters: - - pipelineInput: The input data for the pipeline. - - executionIdentifier: The identifier for the pipeline execution (default value: "model_process"). - - parameters: Additional parameters for the pipeline execution (default value: nil). - - Returns: A `PipelineOutput` object representing the output of the pipeline. - - Throws: Throws an error if the pipeline execution fails. - */ - public func run(_ pipelineInput: PipelineInput, id: String = "model_process", parameters: PipelineRunParameters = PipelineRunParameters.defaultParameters) async throws -> PipelineOutput { - let headers = try self.networking.buildHeader() - let payload = try await pipelineInput.generateInputPayloadForPipeline() - - guard let url = APIKeyManager.shared.BACKEND_URL else { - throw ModelError.missingModelRunURL - } - - self.networking.parameters = parameters - let endpoint = Networking.Endpoint.pipelineRun(pipelineIdentifier: self.id).path - guard let url = URL(string: url.absoluteString + endpoint) else { - throw PipelineError.invalidURL(url: url.absoluteString) - } - - logger.debug("Creating a execution with the following payload \(String(data: payload, encoding: .utf8) ?? "-")") - let response = try await networking.post(url: url, headers: headers, body: payload) - - if let httpUrlResponse = response.1 as? HTTPURLResponse, - httpUrlResponse.statusCode != 201 { - throw NetworkingError.invalidStatusCode(statusCode: httpUrlResponse.statusCode) - } - - let decodedResponse = try JSONDecoder().decode(PipelineExecuteResponse.self, from: response.0) - - guard let pollingURL = decodedResponse.url else { - throw PipelineError.failToDecodeRunResponse - } - logger.info("Successfully created a execution") - return try await self.polling(from: pollingURL, maxRetry: parameters.maxPollingRetries, waitTime: parameters.pollingWaitTimeInSeconds) - } - - /** - Polls the specified URL for pipeline output. - - - Parameters: - - url: The URL to poll for pipeline output. - - maxRetry: The maximum number of polling retries (default value: 300). - - waitTime: The time to wait between polling attempts in seconds (default value: 0.5). - - Returns: A `PipelineOutput` object representing the output of the pipeline. - - Throws: Throws an error if polling fails or times out. - */ - private func polling(from url: URL, maxRetry: Int = 300, waitTime: Double = 0.5) async throws -> PipelineOutput { - let headers = try self.networking.buildHeader() - - var itr = 0 - logger.info("Starting polling job") - repeat { - let response = try await networking.get(url: url, headers: headers) - print(String(data: response.0, encoding: .utf8) ?? "-") - logger.debug("(\(itr)/\(maxRetry))Polling...") - if let json = try? JSONSerialization.jsonObject(with: response.0, options: []) as? [String: Any], - let completed = json["completed"] as? Bool { - if let _ = json["error"] as? String, let supplierError = json["supplierError"] as? String { - throw ModelError.supplierError(error: supplierError) - } - - if completed { - - let partialyDecodedResponse = PipelineOutput(from: response.0) - logger.info("Polling job finished.") - return partialyDecodedResponse - - } - } - - try await Task.sleep(nanoseconds: UInt64(max(0.2, waitTime) * 1_000_000_000)) - itr+=1 - } while itr < maxRetry - - throw PipelineError.pollingTimeoutOnModelResponse(pollingURL: url) - } -} diff --git a/Sources/aiXplainKit/Modules/Pipeline/PipelineNode.swift b/Sources/aiXplainKit/Modules/Pipeline/PipelineNode.swift deleted file mode 100644 index f264a1a..0000000 --- a/Sources/aiXplainKit/Modules/Pipeline/PipelineNode.swift +++ /dev/null @@ -1,48 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation - -public struct PipelineNode: Decodable, Identifiable, Hashable { - let number: Int - let label: String - let dataType: [String] - let type: String - - public var id: String { - return "\(number)" - } - - enum CodingKeys: String, CodingKey { - case number, label, dataType, type - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - number = try container.decode(Int.self, forKey: .number) - label = try container.decode(String.self, forKey: .label) - dataType = try container.decodeIfPresent([String].self, forKey: .dataType) ?? [] - type = try container.decode(String.self, forKey: .type) - } - -} diff --git a/Sources/aiXplainKit/Modules/TeamAgents/TeamAgent+CRUD.swift b/Sources/aiXplainKit/Modules/TeamAgents/TeamAgent+CRUD.swift deleted file mode 100644 index a98d952..0000000 --- a/Sources/aiXplainKit/Modules/TeamAgents/TeamAgent+CRUD.swift +++ /dev/null @@ -1,84 +0,0 @@ -// -// File.swift -// aiXplainKit -// -// Created by Joao Maia on 27/01/25. -// - -import Foundation - -/// Extension providing CRUD (Create, Read, Update, Delete) operations for TeamAgent -extension TeamAgent { - - - /// Updates the team agent's information on the server. - /// - Throws: Various errors that might occur during the update process: - /// - `ModelError.missingBackendURL`: If the backend URL is not configured - /// - `ModelError.invalidURL`: If the constructed URL is invalid - /// - `NetworkingError.invalidStatusCode`: If the server responds with a non-2xx status code - /// - `AgentsError.errorOnUpdate`: If there's an error decoding the server response - public func update() async throws { - let headers = try networking.buildHeader() - - guard let url = APIKeyManager.shared.BACKEND_URL else { - throw ModelError.missingBackendURL - } - - let endpoint = Networking.Endpoint.agentCommunities(agentIdentifier: self.id).path - guard let url = URL(string: url.absoluteString + endpoint) else { - throw ModelError.invalidURL(url: url.absoluteString) - } - - let payload = try self.encode() - - let response = try await networking.put(url: url, body: payload, headers: headers) - - if let httpUrlResponse = response.1 as? HTTPURLResponse, - !(200...299).contains(httpUrlResponse.statusCode) { - throw NetworkingError.invalidStatusCode(statusCode: httpUrlResponse.statusCode) - } - - do { - let decodedResponse = try JSONDecoder().decode(TeamAgent.self, from: response.0) - self.update(id: decodedResponse.id) - self.update(agents: decodedResponse.agents) - self.status = decodedResponse.status - } catch { - throw AgentsError.errorOnUpdate(error: "") - } - } - - /// Deploys the team agent by changing its status to "onboarded" and updating it on the server. - /// - Throws: Any error that might occur during the update process. - /// See ``update()`` for possible errors. - public func deploy() async throws { - self.status = "onboarded" - try await self.update() - } - - /// Deletes the team agent from the server. - /// - Throws: Various errors that might occur during the deletion process: - /// - `ModelError.missingBackendURL`: If the backend URL is not configured - /// - `ModelError.invalidURL`: If the constructed URL is invalid - /// - `AgentsError.errorOnDelete`: If the server responds with a non-2xx status code - public func delete() async throws { - let networking = networking ?? Networking() - let headers = try networking.buildHeader() - - guard let url = APIKeyManager.shared.BACKEND_URL else { - throw ModelError.missingBackendURL - } - - let endpoint = Networking.Endpoint.agentCommunities(agentIdentifier: self.id).path - guard let url = URL(string: url.absoluteString + endpoint) else { - throw ModelError.invalidURL(url: url.absoluteString) - } - - let response = try await networking.delete(url: url, headers: headers) - - if let httpUrlResponse = response.1 as? HTTPURLResponse, - !(200...299).contains(httpUrlResponse.statusCode) { - throw AgentsError.errorOnDelete(error: "") - } - } -} diff --git a/Sources/aiXplainKit/Modules/TeamAgents/TeamAgent.swift b/Sources/aiXplainKit/Modules/TeamAgents/TeamAgent.swift deleted file mode 100644 index 23d5839..0000000 --- a/Sources/aiXplainKit/Modules/TeamAgents/TeamAgent.swift +++ /dev/null @@ -1,370 +0,0 @@ -// -// File.swift -// aiXplainKit -// -// Created by Joao Maia on 24/01/25. -// - -import Foundation -import OSLog - -/// Team Agents are a sophisticated type of agent on the aiXplain platform, designed for handling complex, -/// multi-step tasks that require coordination between multiple components. By leveraging a system of -/// specialized agents, tools, and workflows, Team Agents excel in scenarios that demand advanced task -/// planning, quality control, and adaptability. -public class TeamAgent: Codable { - /// Unique identifier for the team agent - private(set) public var id: String - - /// Name of the team agent - public let name: String - - /// Array of agent identifiers that are part of this team - private(set) public var agents: [String] - - /// Description of the team agent's purpose and capabilities - public let description: String? - - /// Identifier for the language model associated with this team agent - public let llmID: String? - - /// Information about the supplier of this team agent - public let supplier: Supplier? - - /// Version information for this team agent - public let version: Version? - - /// Identifier for the supervisor agent that oversees the team's operations - public let supervisorId: String? - - /// Identifier for the planner agent that coordinates task execution - public let plannerId: String? - - - /// A logger instance for recording events and debugging information. - private let logger: Logger - - /// The networking service responsible for making API calls and handling URL sessions. - var networking: Networking - - public var status:String = "draft" - - public var useMentalistAndInspector:Bool = true - - - /// Creates a new team agent with the specified parameters. - /// - Parameters: - /// - id: The unique identifier for the team agent. - /// - name: The name of the team agent. - /// - agents: An array of agent identifiers that are part of this team. - /// - description: An optional description of the team agent's purpose or capabilities. - /// - llmID: An optional identifier for the language model associated with this team agent. - /// - supplier: An optional supplier information for this team agent. - /// - version: An optional version information for this team agent. - /// - status: The status of the team agent, defaults to "draft". - /// - useMentalistAndInspector: A flag indicating whether to use mentalist and inspector features, defaults to true. - init(id: String, name: String, agents: [String], description: String? = nil, llmID: String? = nil, supplier: Supplier? = nil, version: Version? = nil, status: String = "draft", useMentalistAndInspector: Bool = true) { - self.id = id - self.name = name - self.agents = agents - self.description = description - self.llmID = llmID - self.supplier = supplier - self.version = version - self.status = status - self.useMentalistAndInspector = useMentalistAndInspector - self.logger = Logger(subsystem: "aiXplainKit", category: "TeamAgent") - self.networking = Networking() - self.supervisorId = llmID - self.plannerId = llmID - } - - private enum CodingKeys: String, CodingKey { - case id - case name - case agents - case description - case llmID = "llm_id" - case supplier - case version - case status - case useMentalistAndInspector = "use_mentalist_and_inspector" - case supervisorId = "supervisor_id" - case plannerId = "planner_id" - } - - public required init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(String.self, forKey: .id) - name = try container.decode(String.self, forKey: .name) - - // Decode agents array from response format - let agentsArray = try container.decode([AgentResponse].self, forKey: .agents) - agents = agentsArray.map { $0.assetId } - - description = try container.decodeIfPresent(String.self, forKey: .description) - llmID = try container.decodeIfPresent(String.self, forKey: .llmID) - supplier = try container.decodeIfPresent(Supplier.self, forKey: .supplier) - version = try container.decodeIfPresent(Version.self, forKey: .version) - status = try container.decodeIfPresent(String.self, forKey: .status) ?? "draft" - useMentalistAndInspector = try container.decodeIfPresent(Bool.self, forKey: .useMentalistAndInspector) ?? true - supervisorId = try container.decodeIfPresent(String.self, forKey: .supervisorId) - plannerId = try container.decodeIfPresent(String.self, forKey: .plannerId) - logger = Logger(subsystem: "AiXplain", category: "Agent(\(name))") - networking = Networking() - } - - private struct AgentResponse: Codable { - let assetId: String - let type: String - let number: Int - let label: String - - private enum CodingKeys: String, CodingKey { - case assetId - case type - case number - case label - } - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(id, forKey: .id) - try container.encode(name, forKey: .name) - try container.encode(agents, forKey: .agents) - try container.encodeIfPresent(description, forKey: .description) - try container.encodeIfPresent(llmID, forKey: .llmID) - try container.encodeIfPresent(supplier, forKey: .supplier) - try container.encodeIfPresent(version, forKey: .version) - try container.encode(status, forKey: .status) - try container.encode(useMentalistAndInspector, forKey: .useMentalistAndInspector) - try container.encodeIfPresent(supervisorId, forKey: .supervisorId) - try container.encodeIfPresent(plannerId, forKey: .plannerId) - } - - - func encode() throws -> Data { - let agentDicts = agents.enumerated().map { (idx, agent) in - [ - "assetId": agent, - "number": idx, - "type": "AGENT", - "label": "AGENT" - ] as [String: Any] - } - - var dict: [String: Any] = [ - "id": id, - "name": name, - "agents": agentDicts, - "links": [], - "description": description ?? "", - "llmId": llmID ?? "", - "supervisorId": supervisorId ?? llmID ?? "" - ] - - if useMentalistAndInspector { - dict["plannerId"] = plannerId ?? llmID ?? "" - } - - if let supplier = supplier { - dict["supplier"] = supplier - } - - if let version = version { - dict["version"] = version - } - - dict["status"] = status - - return try JSONSerialization.data(withJSONObject: dict) - } - - - func update(id:String){ - self.id = id - } - - func update(agents:[String]){ - self.agents = agents - } - - func removeAgent(where removingFunction:(String)->Bool){ - agents.removeAll(where: { agendID in - return removingFunction(agendID) - }) - } -} - -//MARK: - Execution -extension TeamAgent{ - /// Executes the agent with specified input and parameters. - /// - /// This method sends the given input to the agent, configured with the provided parameters, - /// and returns the result of the execution. - /// - /// - Parameters: - /// - agentInput: The input conforming to `AgentInputable` used for the agent execution. - /// - sessionID: An optional session identifier, useful for tracking execution context. - /// - parameters: Parameters to configure the agent execution, including polling and timeout settings. - /// - Returns: The `AgentOutput` containing the result of the agent execution. - /// - Throws: Errors related to networking, invalid input, or execution failure. - /// - /// # Example - /// ```swift - /// let agentInput:String = "Hello World" - /// let parameters = AgentRunParameters() - /// do { - /// let output = try await agent.run(agentInput, sessionID: "12345", parameters: parameters) - /// print("Execution result: \(output)") - /// } catch { - /// print("Failed to execute agent: \(error)") - /// } - /// ``` - public func run(_ agentInput: any AgentInputable, sessionID: String? = nil, parameters: AgentRunParameters = .defaultParameters) async throws -> AgentOutput { - let headers = try self.networking.buildHeader() - let payload = try await agentInput.generateInputPayloadForAgent(using: parameters, withID: sessionID) - - guard let backendURL = APIKeyManager.shared.BACKEND_URL else { - throw AgentsError.missingBackendURL - } - - guard let url = URL(string: backendURL.absoluteString + Networking.Endpoint.agentCommunityRun(agentIdentifier: self.id).path) else { - throw AgentsError.invalidURL(url: backendURL.absoluteString) - } - - logger.debug("Creating an execution with the following payload \(String(data: payload, encoding: .utf8) ?? "-")") - networking.parameters = parameters - let response = try await networking.post(url: url, headers: headers, body: payload) - - if let httpResponse = response.1 as? HTTPURLResponse, - httpResponse.statusCode != 201 { - throw NetworkingError.invalidStatusCode(statusCode: httpResponse.statusCode) - } - - let decodedResponse = try JSONDecoder().decode(AgentExecuteResponse.self, from: response.0) - - guard let pollingURL = decodedResponse.maybeUrl else { - throw ModelError.failToDecodeRunResponse - } - - logger.info("Successfully created an execution") - return try await polling(from: pollingURL, - maxRetry: parameters.maxPollingRetries, - waitTime: parameters.pollingWaitTimeInSeconds) - } - - /// Executes the agent with a query and optional content inputs. - /// - /// This method allows the agent to process a query and dynamically replace placeholders in the query - /// with provided content. The content can include URLs, text, or other input types. - /// - /// - Parameters: - /// - query: A query string for the agent to process. Placeholders in the format `{{key}}` can be replaced by `content` values. - /// - content: A dictionary of additional inputs (e.g., files, URLs, text) to be included in the query. - /// - sessionID: An optional session identifier, useful for tracking execution context. - /// - parameters: Parameters to configure the agent execution, including polling and timeout settings. - /// - Returns: The `AgentOutput` containing the result of the agent execution. - /// - Throws: Errors related to networking, invalid input, or execution failure. - /// - /// # Example - /// ```swift - /// let query = "What is the history of the text in the figure {{poem}}? Please be descriptive." - /// let content: [String: AgentInputable] = [ - /// "poem": URL(string: "file:/Users/joao/Downloads/RumiPoemImage.jpeg")! - /// ] - /// let parameters = AgentRunParameters() - /// do { - /// let output = try await agent.run(query: query, content: content, sessionID: "12345", parameters: parameters) - /// print("Execution result: \(output)") - /// } catch { - /// print("Failed to execute agent: \(error)") - /// } - /// ``` - public func run(query: String, content: [String: AgentInputable] = [:], sessionID: String? = nil, parameters: AgentRunParameters = .defaultParameters) async throws -> AgentOutput { - if content.count > 3{ - throw AgentsError.invalidInput(error: "Only up to 3 content items are supported") - } - if query.isEmpty { - throw AgentsError.invalidInput(error: "Query cannot be empty") - } - - var modifiedQuery = query - - for (key, value) in content { - let formattedValue: String - switch value { - case let url as URL: - formattedValue = try await url.uploadToS3IfNeedIt().absoluteString - case let string as String: - formattedValue = string - default: - formattedValue = "\(value)" - } - - if modifiedQuery.contains("{{\(key)}}") { - modifiedQuery = modifiedQuery.replacingOccurrences(of: "{{\(key)}}", with: " \(formattedValue) ") - } else { - modifiedQuery.append(" \(formattedValue) ") - } - } - - return try await run(modifiedQuery.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines), sessionID: sessionID, parameters: parameters) - } - - /// Polls a given URL to monitor the agent's execution and retrieve results. - /// - /// This method continuously polls the provided URL at specified intervals until the execution - /// is complete or the maximum number of retries is reached. - /// - /// - Parameters: - /// - url: The URL to poll for results. - /// - maxRetry: The maximum number of retries allowed during polling (default is `300` retries). - /// - waitTime: The time interval (in seconds) between polling attempts (default is `0.5` seconds). - /// - Returns: The `AgentOutput` containing the result of the agent execution. - /// - Throws: Errors related to timeouts, network failures, or invalid responses. - /// - /// # Example - /// ```swift - /// let pollingURL = URL(string: "https://api.example.com/agents/results/12345")! - /// do { - /// let output = try await agent.polling(from: pollingURL, maxRetry: 100, waitTime: 1.0) - /// print("Polling result: \(output)") - /// } catch { - /// print("Polling failed: \(error)") - /// } - /// ``` - private func polling(from url: URL, maxRetry: Int = 300, waitTime: Double = 0.5) async throws -> AgentOutput { - let headers = try self.networking.buildHeader() - var attempts = 0 - - logger.info("Starting polling job") - repeat { - let response = try await networking.get(url: url, headers: headers) - logger.debug("(\(attempts)/\(maxRetry)) Polling...") - - if let json = try? JSONSerialization.jsonObject(with: response.0, options: []) as? [String: Any], - let completed = json["completed"] as? Bool { - if let _ = json["error"] as? String, let supplierError = json["supplierError"] as? String { - throw AgentsError.supplierError(error: supplierError) - } - - if completed { - do { - let decodedResponse = try JSONDecoder().decode(AgentOutput.self, from: response.0) - logger.info("Polling job finished.") - return decodedResponse - } catch { - throw AgentsError.failToDecodeModelOutputDuringPollingPhase(error: String(describing: error)) - } - } - } - - try await Task.sleep(nanoseconds: UInt64(max(0.2, waitTime) * 1_000_000_000)) - attempts += 1 - } while attempts < maxRetry - - throw ModelError.pollingTimeoutOnModelResponse(pollingURL: url) - } -} diff --git a/Sources/aiXplainKit/Networking/Networking+Endpoint.swift b/Sources/aiXplainKit/Networking/Networking+Endpoint.swift deleted file mode 100644 index e0e2641..0000000 --- a/Sources/aiXplainKit/Networking/Networking+Endpoint.swift +++ /dev/null @@ -1,111 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation - -/// This extension adds the default endpoints called by the SDK -extension Networking { - - /// Represents the different endpoints used by the SDK - enum Endpoint { - /// Represents the endpoint for retrieving a specific model - case model(modelIdentifier: String) - - /// Represents the endpoint for retrieving functions - case functionEndpoint - - /// Represents the endpoint for file upload - /// - parameter isTemporary: A boolean value indicating whether the upload is temporary or not - case fileUpload(isTemporary: Bool) - - /// Represents the endpoint for executing a specific model - /// - parameter modelIdentifier: The identifier of the model to be executed - case execute(modelIdentifier: String) - - case pipelines(pipelineIdentifier: String) - - case pipelineRun(pipelineIdentifier: String) - - case paginateModels - - case agents(agentIdentifier: String) - - case agentRun(agentIdentifier: String) - - case paginateAgents - - case utilities - - case functions - - case agentCommunities(agentIdentifier: String) - - case agentCommunityRun(agentIdentifier: String) - - case paginateTeamAgents - - /// The path for the endpoint - var path: String { - switch self { - case .model(let modelIdentifier): - return "/sdk/models/\(modelIdentifier)" - case .functionEndpoint: - return "/sdk/functions" - case .fileUpload(let isTemporary): - let temporaryUploadPath = "/sdk/file/upload/temp-url" - let permanentUploadPath = "/sdk/file/upload-url" - return isTemporary ? temporaryUploadPath : permanentUploadPath - case .execute(let modelIdentifier): - return "/execute/\(modelIdentifier)" - case .pipelines(pipelineIdentifier: let pipelineIdentifier): - return "/sdk/pipelines/\(pipelineIdentifier)" - case .pipelineRun(pipelineIdentifier: let pipelineIdentifier): - return "/assets/pipeline/execution/run/\(pipelineIdentifier)" - case .paginateModels: - return "/sdk/models/paginate" - case .agents(agentIdentifier: let agentIdentifier): - if agentIdentifier.isEmpty { - return "/sdk/agents" - } - return "/sdk/agents/\(agentIdentifier)" - case .paginateAgents: - return "/sdk/agents" - case .utilities: - return "/sdk/utilities" - case .functions: - return "/sdk/functions" - case .agentCommunities(agentIdentifier: let teamAgentIdentifier): - if teamAgentIdentifier.isEmpty { - return "/sdk/agent-communities" - } - return "/sdk/agent-communities/\(teamAgentIdentifier)" - case .agentCommunityRun(agentIdentifier: let agentIdentifier): - return "/sdk/agent-communities/\(agentIdentifier)/run" - case .agentRun(agentIdentifier: let agentIdentifier): - return "/sdk/agents/\(agentIdentifier)/run" - case .paginateTeamAgents: - return "/sdk/agent-communities" - } - } - } -} diff --git a/Sources/aiXplainKit/Networking/Networking+Metadata.swift b/Sources/aiXplainKit/Networking/Networking+Metadata.swift deleted file mode 100644 index 52137d0..0000000 --- a/Sources/aiXplainKit/Networking/Networking+Metadata.swift +++ /dev/null @@ -1,64 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation - -extension Networking { - /// Generates the headers required for making API calls. - /// - /// This function constructs the necessary headers by retrieving the AI Explain API key and the Team API key from the `APIKeyManager` class. If both keys are available, it returns a dictionary containing the headers with the respective keys and the "Content-Type" header set to "application/json". - /// - /// - Returns: A dictionary containing the headers required for API calls. - /// - Throws: `ModelError.missingAPIKey` if neither API key is available. - func buildHeader() throws -> [String: String] { - var headers: [String: String]? - if let aiXplainKey = APIKeyManager.shared.AIXPLAIN_API_KEY { - headers = ["x-aixplain-key": "\(aiXplainKey)", "Content-Type": "application/json"] - } - - if let teamKey = APIKeyManager.shared.TEAM_API_KEY { - headers = ["Authorization": "Token \(teamKey)", "Content-Type": "application/json"] - } - - guard let headers else { - throw ModelError.missingAPIKey - } - - return headers - } - - /// Builds the URL for a specific endpoint. - /// - /// This function constructs the URL for a given `Endpoint` by retrieving the base URL from the `APIKeyManager` class and appending the endpoint's path to it. - /// - /// - Parameter endpoint: The `Endpoint` for which the URL should be constructed. - /// - Returns: The constructed URL for the specified endpoint, or `nil` if the base URL is missing. - /// - Throws: `ModelError.missingBackendURL` if the base URL is not available. - func buildUrl(for endpoint: Endpoint) throws -> URL? { - - guard let url = APIKeyManager.shared.BACKEND_URL else { - throw ModelError.missingBackendURL - } - return URL(string: url.absoluteString + endpoint.path) - } -} diff --git a/Sources/aiXplainKit/Networking/Networking.swift b/Sources/aiXplainKit/Networking/Networking.swift deleted file mode 100644 index 1fb2123..0000000 --- a/Sources/aiXplainKit/Networking/Networking.swift +++ /dev/null @@ -1,166 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation -import OSLog - -/// A class responsible for making network requests. -public class Networking { - - public var parameters: NetworkingParametersProtocol = NetworkingParameters() - private let logger = Logger(subsystem: "AiXplainKit", category: "Networking") - - /// Fetches data from the specified URL using the GET method. - /// - url: The URL of the resource to fetch data from. - /// - headers: Optional dictionary of headers to include in the request (default: empty). - /// - Throws: Any error that may occur during the network request. - /// - Returns: A tuple containing the retrieved data and the URL response. - public func get(url: URL, headers: [String: String] = [:]) async throws -> (Data, URLResponse) { - var request = URLRequest(url: url) - request.httpMethod = "GET" - request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData - request.timeoutInterval = parameters.networkTimeoutInSecondsInterval - - for (header, value) in headers { - request.setValue(value, forHTTPHeaderField: header) - } - - var retryCount: Int = 0 - repeat { - do { - logger.debug("GET request to \(url)") - return try await URLSession.shared.data(for: request) - } catch { - try? await Task.sleep(nanoseconds: UInt64(parameters.networkTimeoutInSecondsInterval * 1_000_000_000)) - } - retryCount += 1 - } while retryCount <= parameters.maxNetworkCallRetries - - throw NetworkingError.maxRetryReached - - } - - /// Posts data to the specified URL using the POST method. - /// - /// - Parameters: - /// - url: The URL of the resource to post data to. - /// - headers: Optional dictionary of headers to include in the request (default: empty). - /// - body: Optional data to send in the request body (default: nil). - /// - /// - Throws: Any error that may occur during the network request. - /// - Returns: A tuple containing the retrieved data and the URL response. - public func post(url: URL, headers: [String: String] = [:], body: Data? = nil) async throws -> (Data, URLResponse) { - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData - request.timeoutInterval = parameters.networkTimeoutInSecondsInterval - - for (header, value) in headers { - request.setValue(value, forHTTPHeaderField: header) - } - - if let body = body { - request.httpBody = body - } - - var retryCount: Int = 0 - repeat { - do { - logger.debug("POST request to \(url)") - return try await URLSession.shared.data(for: request) - } catch { - try? await Task.sleep(nanoseconds: UInt64(parameters.networkTimeoutInSecondsInterval * 1_000_000_000)) - } - retryCount += 1 - } while retryCount <= parameters.maxNetworkCallRetries - - throw NetworkingError.maxRetryReached - } - - /// Sends data to the specified URL using the PUT method. - /// - /// - Parameters: - /// - url: The URL of the resource to send data to. - /// - body: The data to be uploaded. - /// - headers: Optional dictionary of headers to include in the request (default: empty). - /// - /// - Throws: Any error that may occur during the network request. - /// - /// - Returns: The URL response. - public func put(url: URL, body: Data, headers: [String: String] = [:]) async throws -> (Data, URLResponse) { - var request = URLRequest(url: url) - request.httpMethod = "PUT" - request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData - request.timeoutInterval = parameters.networkTimeoutInSecondsInterval - - for (header, value) in headers { - request.setValue(value, forHTTPHeaderField: header) - } - - var retryCount: Int = 0 - repeat { - do { - logger.debug("PUT request to \(url)") - return try await URLSession.shared.upload(for: request, from: body) - } catch { - try? await Task.sleep(nanoseconds: UInt64(parameters.networkTimeoutInSecondsInterval * 1_000_000_000)) - } - retryCount += 1 - } while retryCount <= parameters.maxNetworkCallRetries - - throw NetworkingError.maxRetryReached - } - - /// Deletes a resource at the specified URL using the DELETE method. - /// - /// - Parameters: - /// - url: The URL of the resource to delete. - /// - headers: Optional dictionary of headers to include in the request (default: empty). - /// - /// - Throws: Any error that may occur during the network request. - /// - Returns: A tuple containing the retrieved data and the URL response. - public func delete(url: URL, headers: [String: String] = [:]) async throws -> (Data, URLResponse) { - var request = URLRequest(url: url) - request.httpMethod = "DELETE" - request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData - request.timeoutInterval = parameters.networkTimeoutInSecondsInterval - - for (header, value) in headers { - request.setValue(value, forHTTPHeaderField: header) - } - - var retryCount: Int = 0 - repeat { - do { - logger.debug("DELETE request to \(url)") - return try await URLSession.shared.data(for: request) - } catch { - try? await Task.sleep(nanoseconds: UInt64(parameters.networkTimeoutInSecondsInterval * 1_000_000_000)) - } - retryCount += 1 - } while retryCount <= parameters.maxNetworkCallRetries - - throw NetworkingError.maxRetryReached - } - -} diff --git a/Sources/aiXplainKit/Networking/ResponseDecoders/AgentExecuteResponse.swift b/Sources/aiXplainKit/Networking/ResponseDecoders/AgentExecuteResponse.swift deleted file mode 100644 index 6adcbc7..0000000 --- a/Sources/aiXplainKit/Networking/ResponseDecoders/AgentExecuteResponse.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// File.swift -// aiXplainKit -// -// Created by Joao Maia on 18/11/24. -// - -import Foundation -struct AgentExecuteResponse:Decodable{ - let requestId:String - let sessionId:String - let data:String - - var maybeUrl:URL?{ - guard let url = URL(string: data) else{ - return nil - } - return url - } -} diff --git a/Sources/aiXplainKit/Networking/ResponseDecoders/AgentOutput.swift b/Sources/aiXplainKit/Networking/ResponseDecoders/AgentOutput.swift deleted file mode 100644 index 4e7f5b7..0000000 --- a/Sources/aiXplainKit/Networking/ResponseDecoders/AgentOutput.swift +++ /dev/null @@ -1,264 +0,0 @@ -// This file was generated from JSON Schema using quicktype, do not modify it directly. -// To parse the JSON, add this file to your project and do: -// -// let agentOutput = try AgentOutput(json) - -import Foundation - -// MARK: - AgentOutput -public struct AgentOutput: Codable { - public let completed: Bool - public let status: String - public let data: DataClass -} - -// MARK: AgentOutput convenience initializers and mutators - -extension AgentOutput { - init(data: Data) throws { - self = try newJSONDecoder().decode(AgentOutput.self, from: data) - } - - init(_ json: String, using encoding: String.Encoding = .utf8) throws { - guard let data = json.data(using: encoding) else { - throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil) - } - try self.init(data: data) - } - - init(fromURL url: URL) throws { - try self.init(data: try Data(contentsOf: url)) - } - - func with( - completed: Bool? = nil, - status: String? = nil, - data: DataClass? = nil - ) -> AgentOutput { - return AgentOutput( - completed: completed ?? self.completed, - status: status ?? self.status, - data: data ?? self.data - ) - } - - func jsonData() throws -> Data { - return try newJSONEncoder().encode(self) - } - - func jsonString(encoding: String.Encoding = .utf8) throws -> String? { - return String(data: try self.jsonData(), encoding: encoding) - } -} - -// MARK: - DataClass -public struct DataClass: Codable { - public let input, output, sessionID: String - public let intermediateSteps: [IntermediateStep] - - enum CodingKeys: String, CodingKey { - case input, output - case sessionID = "session_id" - case intermediateSteps = "intermediate_steps" - } -} - -// MARK: DataClass convenience initializers and mutators - -extension DataClass { - init(data: Data) throws { - self = try newJSONDecoder().decode(DataClass.self, from: data) - } - - init(_ json: String, using encoding: String.Encoding = .utf8) throws { - guard let data = json.data(using: encoding) else { - throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil) - } - try self.init(data: data) - } - - init(fromURL url: URL) throws { - try self.init(data: try Data(contentsOf: url)) - } - - func with( - input: String? = nil, - output: String? = nil, - sessionID: String? = nil, - intermediateSteps: [IntermediateStep]? = nil - ) -> DataClass { - return DataClass( - input: input ?? self.input, - output: output ?? self.output, - sessionID: sessionID ?? self.sessionID, - intermediateSteps: intermediateSteps ?? self.intermediateSteps - ) - } - - func jsonData() throws -> Data { - return try newJSONEncoder().encode(self) - } - - func jsonString(encoding: String.Encoding = .utf8) throws -> String? { - return String(data: try self.jsonData(), encoding: encoding) - } -} - -// MARK: - IntermediateStep -public struct IntermediateStep: Codable { - public let agent, input, output: String - public let toolSteps: [ToolStep]? - public let thought: String? - public let runTime, usedCredits: Double - - enum CodingKeys: String, CodingKey { - case agent, input, output - case toolSteps = "tool_steps" - case thought, runTime, usedCredits - } -} - -// MARK: IntermediateStep convenience initializers and mutators - -extension IntermediateStep { - init(data: Data) throws { - self = try newJSONDecoder().decode(IntermediateStep.self, from: data) - } - - init(_ json: String, using encoding: String.Encoding = .utf8) throws { - guard let data = json.data(using: encoding) else { - throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil) - } - try self.init(data: data) - } - - init(fromURL url: URL) throws { - try self.init(data: try Data(contentsOf: url)) - } - - func with( - agent: String? = nil, - input: String? = nil, - output: String? = nil, - toolSteps: [ToolStep]? = nil, - thought: String?? = nil, - runTime: Double? = nil, - usedCredits: Double? = nil - ) -> IntermediateStep { - return IntermediateStep( - agent: agent ?? self.agent, - input: input ?? self.input, - output: output ?? self.output, - toolSteps: toolSteps ?? self.toolSteps, - thought: thought ?? self.thought, - runTime: runTime ?? self.runTime, - usedCredits: usedCredits ?? self.usedCredits - ) - } - - func jsonData() throws -> Data { - return try newJSONEncoder().encode(self) - } - - func jsonString(encoding: String.Encoding = .utf8) throws -> String? { - return String(data: try self.jsonData(), encoding: encoding) - } -} - -// MARK: - ToolStep -public struct ToolStep: Codable { - public let tool, input, output: String - public let runTime, usedCredits: JSONNull? -} - -// MARK: ToolStep convenience initializers and mutators - -extension ToolStep { - init(data: Data) throws { - self = try newJSONDecoder().decode(ToolStep.self, from: data) - } - - init(_ json: String, using encoding: String.Encoding = .utf8) throws { - guard let data = json.data(using: encoding) else { - throw NSError(domain: "JSONDecoding", code: 0, userInfo: nil) - } - try self.init(data: data) - } - - init(fromURL url: URL) throws { - try self.init(data: try Data(contentsOf: url)) - } - - func with( - tool: String? = nil, - input: String? = nil, - output: String? = nil, - runTime: JSONNull?? = nil, - usedCredits: JSONNull?? = nil - ) -> ToolStep { - return ToolStep( - tool: tool ?? self.tool, - input: input ?? self.input, - output: output ?? self.output, - runTime: runTime ?? self.runTime, - usedCredits: usedCredits ?? self.usedCredits - ) - } - - func jsonData() throws -> Data { - return try newJSONEncoder().encode(self) - } - - func jsonString(encoding: String.Encoding = .utf8) throws -> String? { - return String(data: try self.jsonData(), encoding: encoding) - } -} - -// MARK: - Helper functions for creating encoders and decoders - -func newJSONDecoder() -> JSONDecoder { - let decoder = JSONDecoder() - if #available(iOS 10.0, OSX 10.12, tvOS 10.0, watchOS 3.0, *) { - decoder.dateDecodingStrategy = .iso8601 - } - return decoder -} - -func newJSONEncoder() -> JSONEncoder { - let encoder = JSONEncoder() - if #available(iOS 10.0, OSX 10.12, tvOS 10.0, watchOS 3.0, *) { - encoder.dateEncodingStrategy = .iso8601 - } - return encoder -} - -// MARK: - Encode/decode helpers - -public class JSONNull: Codable, Hashable { - - public static func == (lhs: JSONNull, rhs: JSONNull) -> Bool { - return true - } - - public var hashValue: Int { - return 0 - } - - public func hash(into hasher: inout Hasher) { - // No-op - } - - public init() {} - - public required init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if !container.decodeNil() { - throw DecodingError.typeMismatch(JSONNull.self, DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Wrong type for JSONNull")) - } - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - try container.encodeNil() - } -} diff --git a/Sources/aiXplainKit/Networking/ResponseDecoders/FunctionListResponse.swift b/Sources/aiXplainKit/Networking/ResponseDecoders/FunctionListResponse.swift deleted file mode 100644 index 2ed497e..0000000 --- a/Sources/aiXplainKit/Networking/ResponseDecoders/FunctionListResponse.swift +++ /dev/null @@ -1,5 +0,0 @@ -import Foundation - -public struct FunctionListResponse: Codable { - public let results: [Function] -} diff --git a/Sources/aiXplainKit/Networking/ResponseDecoders/IndexSearchOutput.swift b/Sources/aiXplainKit/Networking/ResponseDecoders/IndexSearchOutput.swift deleted file mode 100644 index dea867c..0000000 --- a/Sources/aiXplainKit/Networking/ResponseDecoders/IndexSearchOutput.swift +++ /dev/null @@ -1,45 +0,0 @@ -// This file was generated from JSON Schema using quicktype, do not modify it directly. -// To parse the JSON, add this file to your project and do: -// -// let indexSearchOutput = try? JSONDecoder().decode(IndexSearchOutput.self, from: jsonData) - -import Foundation - -// MARK: - IndexSearchOutput -public struct IndexSearchOutput: Codable { - public let details: [Detail] - public let status: String - public let completed: Bool - public let data: String - public let runTime, usedCredits: Double - - public init(details: [Detail], status: String, completed: Bool, data: String, runTime: Double, usedCredits: Double) { - self.details = details - self.status = status - self.completed = completed - self.data = data - self.runTime = runTime - self.usedCredits = usedCredits - } -} - -// MARK: - Detail -public struct Detail: Codable { - public let score: Double - public let data, document: String - public let metadata: [String:String] - - public init(score: Double, data: String, document: String, metadata: [String:String]) { - self.score = score - self.data = data - self.document = document - self.metadata = metadata - } -} - -// MARK: - Metadata -public struct Metadata: Codable { - - public init() { - } -} diff --git a/Sources/aiXplainKit/Networking/ResponseDecoders/ModelExecuteResponse.swift b/Sources/aiXplainKit/Networking/ResponseDecoders/ModelExecuteResponse.swift deleted file mode 100644 index 9c85d73..0000000 --- a/Sources/aiXplainKit/Networking/ResponseDecoders/ModelExecuteResponse.swift +++ /dev/null @@ -1,41 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation - -/// Decodes the response when running a model making a API call to `MODELS_RUN_URL -internal struct ModelExecuteResponse: Codable { - let completed: Bool? - let data: String? - let requestId: String? - - var pollingURL: URL? { - URL(string: self.data ?? "") - } - - enum CodingKeys: String, CodingKey { - case completed - case data - case requestId - } -} diff --git a/Sources/aiXplainKit/Networking/ResponseDecoders/ModelOutput.swift b/Sources/aiXplainKit/Networking/ResponseDecoders/ModelOutput.swift deleted file mode 100644 index 2a6713b..0000000 --- a/Sources/aiXplainKit/Networking/ResponseDecoders/ModelOutput.swift +++ /dev/null @@ -1,74 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation - -/// A struct that represents the output provided by a model when calling `Model.run()`. -/// -/// This struct conforms to the `Codable` protocol, which allows it to be encoded and decoded from external representations such as JSON or property lists. -/// -/// - Parameters: -/// - `output`: The main output string returned by the model. -/// - `usedCredits`: The number of credits used for running the model. -/// - `runtime`: The time it took to run the model, measured in seconds. -/// -public struct ModelOutput: Codable { - - /// The main output string returned by the model. - public let output: String - - /// The standard output from the model execution, if any - public let stdout: String? - - /// The standard error from the model execution, if any - public let stderr: String? - - /// The number of credits used for running the model. - public let usedCredits: Float - - /// The time it took to run the model, measured in seconds. - public let runtime: TimeInterval - - private enum CodingKeys: String, CodingKey { - case output = "data" - case stdout - case stderr - case usedCredits - case runtime = "runTime" - } - - // MARK: - Codable - - /// Creates a new `ModelOutput` instance by decoding from the given decoder. - /// - /// - Parameter decoder: The decoder to read data from. - /// - Throws: `DecodingError` if there are any issues during decoding. - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - output = try container.decode(String.self, forKey: .output) - stdout = try container.decodeIfPresent(String.self, forKey: .stdout) - stderr = try container.decodeIfPresent(String.self, forKey: .stderr) - usedCredits = try container.decode(Float.self, forKey: .usedCredits) - runtime = try container.decode(Double.self, forKey: .runtime) - } -} diff --git a/Sources/aiXplainKit/Networking/ResponseDecoders/PipelineExecuteResponse.swift b/Sources/aiXplainKit/Networking/ResponseDecoders/PipelineExecuteResponse.swift deleted file mode 100644 index 061fa48..0000000 --- a/Sources/aiXplainKit/Networking/ResponseDecoders/PipelineExecuteResponse.swift +++ /dev/null @@ -1,30 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation - -struct PipelineExecuteResponse: Codable { - let url: URL? - let status: String - let batchMode: Bool -} diff --git a/Sources/aiXplainKit/Networking/ResponseDecoders/PipelineOutput.swift b/Sources/aiXplainKit/Networking/ResponseDecoders/PipelineOutput.swift deleted file mode 100644 index 327729b..0000000 --- a/Sources/aiXplainKit/Networking/ResponseDecoders/PipelineOutput.swift +++ /dev/null @@ -1,65 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation - -/// Represents the output of an AI pipeline execution. -/// -/// This struct encapsulates the raw data returned from the pipeline execution, along with additional metadata such as the number of credits used and the elapsed time for the execution. -public struct PipelineOutput { - /// The raw data returned from the pipeline execution. - /// The format of this data depends on the pipeline configuration and needs to be decoded by the user. - public let rawData: Data - - /// The number of credits used for the pipeline execution. - public let creditsUsed: Float - - /// The elapsed time for the pipeline execution. - public let elapsedTime: TimeInterval - - /// Initializes a new instance of `PipelineOutput` by parsing the raw data received from the pipeline execution. - /// - /// - Parameter rawData: The raw data received from the pipeline execution. - public init(from rawData: Data) { - - // Primary Information - guard let json = try? JSONSerialization.jsonObject(with: rawData, options: []) as? [String: Any] else { - self.creditsUsed = 0.0 - self.elapsedTime = 0.0 - self.rawData = rawData - return - } - - // Secondary Information - guard let secondaryJson = json["data"] as? Data else { - self.creditsUsed = (json["used_credits"] as? Float) ?? 0.0 - self.elapsedTime = (json["elapsed_time"] as? TimeInterval) ?? 0.0 - self.rawData = rawData - return - } - - self.rawData = secondaryJson - self.creditsUsed = (json["used_credits"] as? Float) ?? 0.0 - self.elapsedTime = (json["elapsed_time"] as? TimeInterval) ?? 0.0 - } -} diff --git a/Sources/aiXplainKit/Provider/Agent/AgentProvider+BuildAgents.swift b/Sources/aiXplainKit/Provider/Agent/AgentProvider+BuildAgents.swift deleted file mode 100644 index 2d36f69..0000000 --- a/Sources/aiXplainKit/Provider/Agent/AgentProvider+BuildAgents.swift +++ /dev/null @@ -1,93 +0,0 @@ -// -// File.swift -// aiXplainKit -// -// Created by Joao Maia on 06/01/25. -// - -import Foundation - -extension AgentProvider { - /// Creates a new agent with the specified configuration. - /// - /// This method creates an agent on the aiXplain platform with the provided parameters. - /// The agent will be initialized in a "draft" status and can later be deployed using - /// the `deploy()` method. - /// - /// - Parameters: - /// - name: The name of the agent - /// - description: A description of the agent's role and capabilities - /// - llmId: The aiXplain ID of the large language model to be used. Defaults to GPT-4 mini. - /// - tools: Array of tools that the agent can use - /// - supplier: The owner/supplier of the agent. Defaults to empty string. - /// - version: Version identifier for the agent. Defaults to empty string. - /// - /// - Returns: A newly created `Agent` instance - /// - /// - Throws: - /// - `AgentsError.missingBackendURL` if the backend URL is not configured - /// - `AgentsError.invalidURL` if the constructed URL is invalid - /// - Networking errors if the request fails - /// - Decoding errors if the response cannot be parsed - /// - /// # Example - /// ```swift - /// let tools = [ - /// CreateAgentTool(name: "Calculator", description: "Performs calculations"), - /// CreateAgentTool(name: "Translator", description: "Translates text") - /// ] - /// - /// do { - /// let agent = try await agentProvider.create( - /// name: "Math Assistant", - /// description: "Helps with mathematical problems", - /// tools: tools - /// ) - /// print("Created agent with ID: \(agent.id)") - /// } catch { - /// print("Failed to create agent: \(error)") - /// } - /// ``` - public func create( - name: String, - description: String, - instructions: String? = nil, - llmId: String = "6646261c6eb563165658bbb1", - tools: [CreateAgentTool], - supplier: String = "", - version: String = "" - ) async throws -> Agent { - let headers = try networking.buildHeader() - - guard let baseURL = APIKeyManager.shared.BACKEND_URL else { - throw AgentsError.missingBackendURL - } - - let endpoint = Networking.Endpoint.agents(agentIdentifier: "") - guard let url = URL(string: baseURL.absoluteString + endpoint.path) else { - throw AgentsError.invalidURL(url: baseURL.absoluteString + endpoint.path) - } - - let agent = Agent( - id: "", - name: name, - status: "draft", - teamId: 0, - description: description, - llmId: llmId, - createdAt: .now, - updatedAt: .now, - role: instructions ?? "" - ) - agent.assets = tools.map { $0.convertToTool() } - - let encodedAgent = try JSONEncoder().encode(agent) - let response = try await networking.post(url: url, headers: headers, body: encodedAgent) - if let httpUrlResponse = response.1 as? HTTPURLResponse, - !(200...299).contains(httpUrlResponse.statusCode) { - throw NetworkingError.invalidStatusCode(statusCode: httpUrlResponse.statusCode) - } - - return try JSONDecoder().decode(Agent.self, from: response.0) - } -} diff --git a/Sources/aiXplainKit/Provider/Agent/AgentProvider.swift b/Sources/aiXplainKit/Provider/Agent/AgentProvider.swift deleted file mode 100644 index e24ab85..0000000 --- a/Sources/aiXplainKit/Provider/Agent/AgentProvider.swift +++ /dev/null @@ -1,205 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation -import OSLog - -/// A class responsible for fetching agent information from the backend. -/// -/// The `AgentProvider` class is used to interact with the backend services, allowing you to fetch agent details -/// using the agent's unique identifier. It uses the `Networking` class to handle API requests and responses. -public final class AgentProvider { - - // MARK: - Properties - - /// A logger instance for recording events and debugging information. - private let logger = Logger(subsystem: "AiXplainKit", category: "AgentProvider") - - /// The networking service used to make API calls. - var networking = Networking() - - // MARK: - Initializers - - /// Initializes a new instance of `AgentProvider`. - public init() { - self.networking = Networking() - } - - /// Internal initializer for testing or dependency injection purposes. - /// - /// - Parameter networking: A pre-configured `Networking` instance. - internal init(networking: Networking) { - self.networking = networking - } - - // MARK: - Methods - - /// Fetches the details of an agent with the provided ID from the backend. - /// - /// This method sends a request to the backend to retrieve the details of an agent identified by its unique ID. - /// It decodes the response into an `Agent` object and logs relevant events during the process. - /// - /// - Parameter agentID: The unique identifier of the agent to fetch. - /// - Returns: An `Agent` object containing the details of the requested agent. - /// - Throws: - /// - `AgentsError.missingBackendURL` if the backend URL is not available. - /// - `AgentsError.invalidURL` if the constructed URL is invalid. - /// - `NetworkingError.invalidStatusCode` if the response status code is not 200. - /// - `DecodingError` if the response cannot be decoded into an `Agent` object. - /// - /// # Example - /// ```swift - /// do { - /// let agentProvider = AgentProvider() - /// let agent = try await agentProvider.get("agent-12345") - /// print("Fetched agent: \(agent.name)") - /// } catch { - /// print("Failed to fetch agent: \(error)") - /// } - /// ``` - public func get(_ agentID: String) async throws -> Agent { - let headers: [String: String] = try networking.buildHeader() - - guard let url = APIKeyManager.shared.BACKEND_URL else { - throw AgentsError.missingBackendURL - } - - let endpoint = Networking.Endpoint.agents(agentIdentifier: agentID) - guard let url = URL(string: url.absoluteString + endpoint.path) else { - throw AgentsError.invalidURL(url: url.absoluteString + endpoint.path) - } - - let response = try await networking.get(url: url, headers: headers) - - if let httpUrlResponse = response.1 as? HTTPURLResponse, - httpUrlResponse.statusCode != 200 { - throw NetworkingError.invalidStatusCode(statusCode: httpUrlResponse.statusCode) - } - - do { - logger.debug("\(String(data: response.0, encoding: .utf8)!)") - let fetchedAgent = try JSONDecoder().decode(Agent.self, from: response.0) - if fetchedAgent.id.count <= 1 { - fetchedAgent.id = agentID - } - - logger.info("\(fetchedAgent.name) fetched") - return fetchedAgent - } catch { - logger.error("\(String(describing: error))") - throw error - } - } - - - /// Retrieves a list of all available agents. - /// - /// This method fetches all agents accessible to the authenticated user from the aiXplain platform. - /// - /// - Returns: An array of `Agent` objects representing the available agents. - /// - /// - Throws: - /// - `AgentsError.missingBackendURL` if the backend URL is not available. - /// - `AgentsError.invalidURL` if the constructed URL is invalid. - /// - `NetworkingError.invalidStatusCode` if the response status code is not 200. - /// - `DecodingError` if the response cannot be decoded into an array of `Agent` objects. - /// - /// # Example - /// ```swift - /// do { - /// let agentProvider = AgentProvider() - /// let agents = try await agentProvider.list() - /// print("Found \(agents.count) agents") - /// } catch { - /// print("Failed to fetch agents: \(error)") - /// } - /// ``` - public func list() async throws -> [Agent] { - let headers: [String: String] = try networking.buildHeader() - - guard let url = APIKeyManager.shared.BACKEND_URL else { - throw AgentsError.missingBackendURL - } - - let endpoint = Networking.Endpoint.paginateAgents - guard let url = URL(string: url.absoluteString + endpoint.path) else { - throw AgentsError.invalidURL(url: url.absoluteString + endpoint.path) - } - - let response = try await networking.get(url: url, headers: headers) - - if let httpUrlResponse = response.1 as? HTTPURLResponse, - httpUrlResponse.statusCode != 200 { - throw NetworkingError.invalidStatusCode(statusCode: httpUrlResponse.statusCode) - } - - do { - struct agentID:Codable{ - var id:String - } - - logger.debug("\(String(data: response.0, encoding: .utf8)!)") - let fetchedAgentID:[agentID] = try JSONDecoder().decode([agentID].self, from: response.0) - var fetchedAgents:[Agent] = [] - - for agentID in fetchedAgentID{ - if let agent = try? await get(agentID.id){ - fetchedAgents.append(agent) - } - } - - logger.info("\(fetchedAgents.count) fetched") - return fetchedAgents - } catch { - logger.error("\(String(describing: error))") - throw error - } - } - - - public func list(_ query: ModelQuery) async throws -> [Agent] { - let headers: [String: String] = try networking.buildHeader() - - guard let url = APIKeyManager.shared.BACKEND_URL else { - throw ModelError.missingBackendURL - } - - let endpoint = Networking.Endpoint.paginateAgents - guard let url = URL(string: url.absoluteString + endpoint.path) else { - throw ModelError.invalidURL(url: url.absoluteString + endpoint.path) - } - - let body = try query.buildQuery() - let response = try await networking.post(url: url, headers: headers, body: body) - - if let httpUrlResponse = response.1 as? HTTPURLResponse, - httpUrlResponse.statusCode != 201 { - throw NetworkingError.invalidStatusCode(statusCode: httpUrlResponse.statusCode) - } - -// if let stringedResponse = String(data: response.0, encoding: .utf8) { -// return parseModelQueryResponse(stringedResponse) ?? [] -// } - return [] - } -} diff --git a/Sources/aiXplainKit/Provider/Indexing/IndexProvider.swift b/Sources/aiXplainKit/Provider/Indexing/IndexProvider.swift deleted file mode 100644 index 0cd1b7a..0000000 --- a/Sources/aiXplainKit/Provider/Indexing/IndexProvider.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// File.swift -// aiXplainKit -// -// Created by Joao Maia on 12/05/25. -// - -import Foundation -public class IndexProvider { - public init(){} - - //TODO: Add Docs - public func create(name:String,description:String,embedding embeddingModel:EmbeddingModel = EmbeddingModel.OPENAI_ADA002, engine:AiXplainEngine = .AIR) async throws -> IndexModel{ - - let engine = try await engine.getModel() - - let requestPayload: [String: ModelInput] = [ - "data": name, - "description": description, - "model": embeddingModel.modelId - ] - - - do{ - let response = try await engine.run(requestPayload) - - do{ - let indexModel = try await ModelProvider().get(response.output) - - guard let index = IndexModel(from: indexModel) else { - throw IndexErrors.failedToCreateIndex(reason: String(describing: "Failed to initalize index from model \(indexModel.id)")) - } - return index - - }catch{ - throw IndexErrors.failedToCreateIndex(reason: String(describing: error)) - } - - - }catch{ - - throw IndexErrors.failedToCreateIndex(reason: String(describing: error)) - } - - - } - - - public func get(_ id:String) async throws -> IndexModel?{ - let indexModel = try await ModelProvider().get(id) - if indexModel.function?.id != "search" { - throw IndexErrors.failedToCreateIndex(reason: "The provided ID does not correspond to an index model.") - } - - return IndexModel(from: indexModel) - } - - -} - -public enum IndexErrors:Error{ - case failedToCreateIndex(reason:String) - - var localizedDescription: String { - switch self { - case .failedToCreateIndex(reason: let reason): - return "Failed to create index. Reason: \(reason)" - } - } -} diff --git a/Sources/aiXplainKit/Provider/Model/ModelProvider+Utility.swift b/Sources/aiXplainKit/Provider/Model/ModelProvider+Utility.swift deleted file mode 100644 index 9b3cfbb..0000000 --- a/Sources/aiXplainKit/Provider/Model/ModelProvider+Utility.swift +++ /dev/null @@ -1,96 +0,0 @@ -// -// File.swift -// aiXplainKit -// -// Created by Joao Maia on 02/01/25. -// - -import Foundation - -extension ModelProvider { - /// Creates a new utility model with code provided as a string - /// - Parameters: - /// - name: The name of the utility model - /// - code: The code implementation as a string - /// - inputs: Array of input parameters that the utility model accepts - /// - description: Description of what the utility model does - /// - outputExample: Optional example of the model's output format - /// - Returns: The created UtilityModel instance - /// - Throws: ModelError if creation fails - public func createUtilityModel( - name: String, - code: String, - inputs: [UtilityModelInput], - description: String, - outputExample: String = "" - ) async throws -> UtilityModel { - let headers = try networking.buildHeader() - - guard let baseURL = APIKeyManager.shared.BACKEND_URL else { - throw ModelError.missingBackendURL - } - - let endpoint = Networking.Endpoint.utilities - guard let url = URL(string: baseURL.absoluteString + endpoint.path) else { - throw ModelError.invalidURL(url: baseURL.absoluteString + endpoint.path) - } - - let utilityModel = UtilityModel( - id: "", - name: name, - code: code, - description: description, - inputs: inputs, - outputExamples: outputExample - ) - - let encodedModel = try JSONEncoder().encode(utilityModel) - let (data, response) = try await networking.post(url: url, headers: headers, body: encodedModel) - - guard let httpResponse = response as? HTTPURLResponse else { - throw ModelError.noResponse(endpoint: url.absoluteString) - } - - if (200..<300).contains(httpResponse.statusCode) { - let responseData = try JSONDecoder().decode([String: String].self, from: data) - guard let modelId = responseData["id"] else { - throw ModelError.missingModelUtilityID - } - utilityModel.id = modelId - return utilityModel - } else { - let errorMessage = "Utility Model Creation: Failed to create utility model. Status Code: \(httpResponse.statusCode). Error: \(String(data: data, encoding: .utf8) ?? "")" - throw ModelError.modelUtilityCreationError(error: errorMessage) - } - } - - /// Creates a new utility model with code loaded from a file URL - /// - Parameters: - /// - name: The name of the utility model - /// - code: URL pointing to the file containing the code implementation - /// - inputs: Array of input parameters that the utility model accepts - /// - description: Description of what the utility model does - /// - outputExample: Optional example of the model's output format - /// - Returns: The created UtilityModel instance - /// - Throws: ModelError if creation fails or if code file cannot be read - public func createUtilityModel( - name: String, - code: URL, - inputs: [UtilityModelInput] = [], - description: String, - outputExample: String = "" - ) async throws -> UtilityModel { - let codeData = try Data(contentsOf: code) - guard let codeString = String(data: codeData, encoding: .utf8) else { - throw ModelError.invalidURL(url: code.absoluteString) - } - - return try await createUtilityModel( - name: name, - code: codeString, - inputs: inputs, - description: description, - outputExample: outputExample - ) - } -} diff --git a/Sources/aiXplainKit/Provider/Model/ModelProvider.swift b/Sources/aiXplainKit/Provider/Model/ModelProvider.swift deleted file mode 100644 index ddbf178..0000000 --- a/Sources/aiXplainKit/Provider/Model/ModelProvider.swift +++ /dev/null @@ -1,213 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation -import OSLog - -/// A class responsible for fetching model information from the backend. -/// -/// The `ModelProvider` class interacts with the backend services to retrieve information -/// about specific models or lists of models using queries. -public final class ModelProvider { - - // MARK: - Properties - - /// A logger instance for recording events and debugging information. - private let logger = Logger(subsystem: "AiXplinaKit", category: "ModelProvider") - - /// The networking service used to make API calls. - var networking = Networking() - - // MARK: - Initializers - - /// Initializes a new instance of `ModelProvider`. - public init() { - self.networking = Networking() - } - - /// Internal initializer for testing or dependency injection purposes. - /// - /// - Parameter networking: A pre-configured `Networking` instance. - internal init(networking: Networking) { - self.networking = networking - } - - // MARK: - Methods - - /// Fetches the details of a model with the specified ID. - /// - /// This method sends a request to the backend to retrieve the details of a specific model - /// identified by its unique ID. The response is decoded into a `Model` object. - /// - /// - Parameter modelID: The unique identifier of the model to fetch. - /// - Returns: A `Model` object containing the details of the fetched model. - /// - Throws: - /// - `ModelError.missingBackendURL` if the backend URL is not configured. - /// - `ModelError.invalidURL` if the constructed URL is invalid. - /// - `NetworkingError.invalidStatusCode` if the response status code is not 200. - /// - `DecodingError` if the response cannot be decoded into a `Model` object. - /// - /// # Example - /// ```swift - /// let modelProvider = ModelProvider() - /// do { - /// let model = try await modelProvider.get("model-12345") - /// print("Fetched model: \(model.name)") - /// } catch { - /// print("Failed to fetch model: \(error)") - /// } - /// ``` - public func get(_ modelID: String) async throws -> Model { - let headers: [String: String] = try networking.buildHeader() - - guard let url = APIKeyManager.shared.BACKEND_URL else { - throw ModelError.missingBackendURL - } - - let endpoint = Networking.Endpoint.model(modelIdentifier: modelID) - guard let url = URL(string: url.absoluteString + endpoint.path) else { - throw ModelError.invalidURL(url: url.absoluteString + endpoint.path) - } - - let response = try await networking.get(url: url, headers: headers) - - if let httpUrlResponse = response.1 as? HTTPURLResponse, - httpUrlResponse.statusCode != 200 { - throw NetworkingError.invalidStatusCode(statusCode: httpUrlResponse.statusCode) - } - - do { - logger.debug("\(String(data: response.0, encoding: .utf8)!)") - let fetchedModel = try JSONDecoder().decode(Model.self, from: response.0) - if fetchedModel.id.count <= 1 { - fetchedModel.id = modelID - } - - logger.info("\(fetchedModel.name) fetched") - return fetchedModel - } catch { - logger.error("\(String(describing: error))") - throw error - } - } - - /// Lists models based on the specified query. - /// - /// This method sends a paginated request to the backend to retrieve a list of models - /// matching the provided query parameters. The response is parsed into an array of `Model` objects. - /// - /// - Parameter query: A `ModelQuery` object defining the search parameters. - /// - Returns: An array of `Model` objects matching the query. - /// - Throws: - /// - `ModelError.missingBackendURL` if the backend URL is not configured. - /// - `ModelError.invalidURL` if the constructed URL is invalid. - /// - `NetworkingError.invalidStatusCode` if the response status code is not 201. - /// - `PipelineError.inputEncodingError` if the query cannot be serialized into JSON. - /// - /// # Example - /// ```swift - /// let query = ModelQuery(query: "AI models", pageNumber: 1, pageSize: 20, functions: ["text-analysis"]) - /// let modelProvider = ModelProvider() - /// do { - /// let models = try await modelProvider.list(query) - /// print("Fetched models: \(models.count)") - /// } catch { - /// print("Failed to fetch models: \(error)") - /// } - /// ``` - public func list(_ query: ModelQuery) async throws -> [Model] { - let headers: [String: String] = try networking.buildHeader() - - guard let url = APIKeyManager.shared.BACKEND_URL else { - throw ModelError.missingBackendURL - } - - let endpoint = Networking.Endpoint.paginateModels - guard let url = URL(string: url.absoluteString + endpoint.path) else { - throw ModelError.invalidURL(url: url.absoluteString + endpoint.path) - } - - let body = try query.buildQuery() - let response = try await networking.post(url: url, headers: headers, body: body) - - if let httpUrlResponse = response.1 as? HTTPURLResponse, - httpUrlResponse.statusCode != 201 { - throw NetworkingError.invalidStatusCode(statusCode: httpUrlResponse.statusCode) - } - - if let stringedResponse = String(data: response.0, encoding: .utf8) { - return parseModelQueryResponse(stringedResponse) ?? [] - } - return [] - } - - - public func listFunctions() async throws -> FunctionListResponse { - let headers: [String: String] = try networking.buildHeader() - - guard let url = APIKeyManager.shared.BACKEND_URL else { - throw ModelError.missingBackendURL - } - - let endpoint = Networking.Endpoint.functions - guard let url = URL(string: url.absoluteString + endpoint.path) else { - throw ModelError.invalidURL(url: url.absoluteString + endpoint.path) - } - - let response = try await networking.get(url: url,headers: headers) - - if let httpUrlResponse = response.1 as? HTTPURLResponse, - httpUrlResponse.statusCode != 200 { - throw NetworkingError.invalidStatusCode(statusCode: httpUrlResponse.statusCode) - } - - let functionListResponse = try! JSONDecoder().decode(FunctionListResponse.self, from: response.0) - - return functionListResponse - } - - /// Parses a JSON string response into an array of `Model` objects. - /// - /// This method extracts the "items" array from the JSON response and decodes it - /// into an array of `Model` objects. - /// - /// - Parameter jsonData: A JSON string containing the response from the backend. - /// - Returns: An optional array of `Model` objects if parsing succeeds, or `nil` if parsing fails. - private func parseModelQueryResponse(_ jsonData: String) -> [Model]? { - guard let data = jsonData.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], - let itemsData = json["items"] as? [[String: Any]] else { - return nil - } - - let items = itemsData.compactMap { itemDict -> Model? in - guard let itemData = try? JSONSerialization.data(withJSONObject: itemDict, options: []) else { - return nil - } - - return try? JSONDecoder().decode(Model.self, from: itemData) - } - - return items - } -} diff --git a/Sources/aiXplainKit/Provider/PipelineProvider.swift b/Sources/aiXplainKit/Provider/PipelineProvider.swift deleted file mode 100644 index ea600e4..0000000 --- a/Sources/aiXplainKit/Provider/PipelineProvider.swift +++ /dev/null @@ -1,92 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation -import OSLog - - -/** - The `PipelineProvider` class is responsible for fetching `Pipeline` objects from the AiXplain backend. - - This class handles the network request to retrieve pipeline data and parses the response into a `Pipeline` object. - - - Important: You must have a valid API key and backend URL configured in `APIKeyManager` to use this class. - - */ -public final class PipelineProvider { - private let logger = Logger(subsystem: "AiXplain", category: "PipelineProvider") - - var networking = Networking() - - public init() { - self.networking = Networking() - } - - internal init(networking: Networking) { - self.networking = networking - } - - /** - Fetches a `Pipeline` object from the AiXplain backend. - - This method sends a GET request to the backend URL with the provided `pipelineID` and parses the response into a `Pipeline` object. - - - Parameter pipelineID: The unique identifier of the pipeline to fetch. - - Throws: `PipelineError.missingBackendURL` if the backend URL is missing. - `ModelError.invalidURL` if the backend URL is invalid. - `NetworkingError.invalidStatusCode` if the server returns an unexpected status code. - An error of type `Error` for any other error that may occur during the request or parsing process. - - Returns: A `Pipeline` object containing the data for the requested pipeline. - */ - public func get(_ pipelineID: String) async throws -> Pipeline { - - let headers: [String: String] = try networking.buildHeader() - - guard let url = APIKeyManager.shared.BACKEND_URL else { - throw PipelineError.missingBackendURL - } - - let endpoint = Networking.Endpoint.pipelines(pipelineIdentifier: pipelineID) - guard let url = URL(string: url.absoluteString + endpoint.path) else { - throw ModelError.invalidURL(url: url.absoluteString + endpoint.path) - } - - let response = try await networking.get(url: url, headers: headers) - - if let httpUrlResponse = response.1 as? HTTPURLResponse, - httpUrlResponse.statusCode != 200 { - throw NetworkingError.invalidStatusCode(statusCode: httpUrlResponse.statusCode) - } - - do { - let fetchedPipeline = try JSONDecoder().decode(Pipeline.self, from: response.0) - fetchedPipeline.id = pipelineID - return fetchedPipeline - } catch { - logger.error("\(error.localizedDescription)") - throw error - } - - } - -} diff --git a/Sources/aiXplainKit/Provider/TeamAgent/TeamAgentProvider+BuildTeamAgent.swift b/Sources/aiXplainKit/Provider/TeamAgent/TeamAgentProvider+BuildTeamAgent.swift deleted file mode 100644 index 918878d..0000000 --- a/Sources/aiXplainKit/Provider/TeamAgent/TeamAgentProvider+BuildTeamAgent.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// File.swift -// aiXplainKit -// -// Created by Joao Maia on 24/01/25. -// - -import Foundation -public extension TeamAgentProvider{ - public func create(_ name:String,agents: [Agent], usingLLMID llmID:String = "669a63646eb56306647e1091" ,description:String = "",useMentalistAndSpector:Bool = true) async throws -> TeamAgent{ - - if agents.isEmpty{ - throw(AgentsError.teamOfAgentsHasNoAgents) - } - - let headers = try networking.buildHeader() - - guard let baseURL = APIKeyManager.shared.BACKEND_URL else { - throw AgentsError.missingBackendURL - } - - let endpoint = Networking.Endpoint.agentCommunities(agentIdentifier: "") - guard let url = URL(string: baseURL.absoluteString + endpoint.path) else { - throw AgentsError.invalidURL(url: baseURL.absoluteString + endpoint.path) - } - - - - let team = TeamAgent(id: "", name: name, - agents: agents.map({$0.id}), - description: description, - llmID: llmID, - supplier: nil, - version: nil, - useMentalistAndInspector: useMentalistAndSpector) - - let response = try await networking.post(url: url, headers: headers, body: team.encode()) - - - if let httpResponse = response.1 as? HTTPURLResponse { - if !(200...299).contains(httpResponse.statusCode) { - if let errorData = try? JSONSerialization.jsonObject(with: response.0) as? [String: Any], - let message = errorData["message"] as? String { - var errorMessage = message - - switch message { - case "err.name_already_exists": - errorMessage = "TeamAgent name already exists." - case "err.asset_is_not_available": - errorMessage = "Some tools are not available." - default: - break - } - - throw NetworkingError.invalidStatusCode(statusCode: httpResponse.statusCode) - - /*(statusCode: httpResponse.statusCode, message: "TeamAgent Onboarding Error: \(errorMessage)")TODO: Inform this */ - } else { - throw NetworkingError.invalidStatusCode(statusCode: httpResponse.statusCode) - } - } - } - - let teamAgentResponse = try JSONDecoder().decode(TeamAgent.self, from: response.0) - - return teamAgentResponse - } - -} diff --git a/Sources/aiXplainKit/Provider/TeamAgent/TeamAgentProvider.swift b/Sources/aiXplainKit/Provider/TeamAgent/TeamAgentProvider.swift deleted file mode 100644 index 2fd4a11..0000000 --- a/Sources/aiXplainKit/Provider/TeamAgent/TeamAgentProvider.swift +++ /dev/null @@ -1,109 +0,0 @@ -import Foundation -import OSLog - -/// A provider class that handles API operations related to team agents. -/// -/// This class provides functionality to fetch and list team agents from the aiXplain platform. -public final class TeamAgentProvider { - - // MARK: - Properties - - /// A logger instance for recording events and debugging information. - private let logger = Logger(subsystem: "AiXplainKit", category: "AgentProvider") - - /// The networking service used to make API calls. - var networking = Networking() - - // MARK: - Initializers - - /// Initializes a new instance of `TeamAgentProvider`. - public init() { - self.networking = Networking() - } - - /// Internal initializer for testing or dependency injection purposes. - /// - /// - Parameter networking: A pre-configured `Networking` instance. - internal init(networking: Networking) { - self.networking = networking - } - - /// Retrieves a specific team agent by its ID. - /// - /// - Parameter teamAgentID: The unique identifier of the team agent to retrieve. - /// - Returns: A ``TeamAgent`` instance containing the retrieved team agent's information. - /// - Throws: Various errors that might occur during the retrieval process: - /// - ``AgentsError/missingBackendURL``: If the backend URL is not configured - /// - ``AgentsError/invalidURL``: If the constructed URL is invalid - /// - ``NetworkingError/invalidStatusCode``: If the server responds with a non-200 status code - /// - `DecodingError`: If there's an error decoding the server response - public func get(_ teamAgentID:String) async throws -> TeamAgent { - - let headers: [String: String] = try networking.buildHeader() - - guard let url = APIKeyManager.shared.BACKEND_URL else { - throw AgentsError.missingBackendURL - } - - let endpoint = Networking.Endpoint.agentCommunities(agentIdentifier: teamAgentID) - guard let url = URL(string: url.absoluteString + endpoint.path) else { - throw AgentsError.invalidURL(url: url.absoluteString + endpoint.path) - } - - let response = try await networking.get(url: url, headers: headers) - - if let httpUrlResponse = response.1 as? HTTPURLResponse, - httpUrlResponse.statusCode != 200 { - throw NetworkingError.invalidStatusCode(statusCode: httpUrlResponse.statusCode) - } - - do { - logger.debug("\(String(data: response.0, encoding: .utf8)!)") - let fetchedTeamAgent = try JSONDecoder().decode(TeamAgent.self, from: response.0) - logger.info("\(fetchedTeamAgent.name) fetched") - return fetchedTeamAgent - } catch { - logger.error("\(String(describing: error))") - throw error - } - } - - /// Retrieves a list of all available team agents. - /// - /// - Returns: An array of ``TeamAgent`` instances representing all available team agents. - /// - Throws: Various errors that might occur during the retrieval process: - /// - ``AgentsError/missingBackendURL``: If the backend URL is not configured - /// - ``AgentsError/invalidURL``: If the constructed URL is invalid - /// - ``NetworkingError/invalidStatusCode``: If the server responds with a non-200 status code - /// - `DecodingError`: If there's an error decoding the server response - public func list() async throws -> [TeamAgent] { - let headers: [String: String] = try networking.buildHeader() - - guard let url = APIKeyManager.shared.BACKEND_URL else { - throw AgentsError.missingBackendURL - } - - let endpoint = Networking.Endpoint.paginateTeamAgents - guard let url = URL(string: url.absoluteString + endpoint.path) else { - throw AgentsError.invalidURL(url: url.absoluteString + endpoint.path) - } - - let response = try await networking.get(url: url, headers: headers) - - if let httpUrlResponse = response.1 as? HTTPURLResponse, - httpUrlResponse.statusCode != 200 { - throw NetworkingError.invalidStatusCode(statusCode: httpUrlResponse.statusCode) - } - - do { - logger.debug("\(String(data: response.0, encoding: .utf8)!)") - let fetchedTeamAgents:[TeamAgent] = try JSONDecoder().decode([TeamAgent].self, from: response.0) - - logger.info("\(fetchedTeamAgents.count) fetched") - return fetchedTeamAgents - } catch { - logger.error("\(String(describing: error))") - throw error - } - } -} diff --git a/Sources/aiXplainKit/Resources/AgentToolConvertible.swift b/Sources/aiXplainKit/Resources/AgentToolConvertible.swift new file mode 100644 index 0000000..10c20e7 --- /dev/null +++ b/Sources/aiXplainKit/Resources/AgentToolConvertible.swift @@ -0,0 +1,9 @@ +import Foundation + +/// Any resource that can be serialized as an agent tool. +/// +/// Mirrors Python v2 `ToolableMixin` ABC from `mixins.py`. +/// Conforming types: `Model` (RFC-0007), `Tool` (RFC-0008). +public protocol AgentToolConvertible { + func asAgentTool() -> AgentToolDict +} diff --git a/Sources/aiXplainKit/Resources/AgentToolDict.swift b/Sources/aiXplainKit/Resources/AgentToolDict.swift new file mode 100644 index 0000000..21f74d4 --- /dev/null +++ b/Sources/aiXplainKit/Resources/AgentToolDict.swift @@ -0,0 +1,42 @@ +import Foundation + +/// Serialization format for agent tools. +/// +/// Mirrors Python v2 `ToolDict` TypedDict from `mixins.py`. +/// Used by `Agent.buildSavePayload()` to serialize tools into the agent creation/update payload. +public struct AgentToolDict: Codable, Sendable { + public var id: String + public var name: String + public var description: String + public var supplier: String + public var parameters: [[String: AnyCodable]]? + public var function: String + public var type: ToolType + public var version: String + public var assetId: String + public var actions: [String]? + + public init( + id: String, + name: String, + description: String, + supplier: String, + parameters: [[String: AnyCodable]]? = nil, + function: String, + type: ToolType, + version: String, + assetId: String, + actions: [String]? = nil + ) { + self.id = id + self.name = name + self.description = description + self.supplier = supplier + self.parameters = parameters + self.function = function + self.type = type + self.version = version + self.assetId = assetId + self.actions = actions + } +} diff --git a/Sources/aiXplainKit/Resources/BaseResource.swift b/Sources/aiXplainKit/Resources/BaseResource.swift new file mode 100644 index 0000000..c296430 --- /dev/null +++ b/Sources/aiXplainKit/Resources/BaseResource.swift @@ -0,0 +1,259 @@ +import Foundation +import OSLog + +/// Base class for all aiXplain resources. +/// +/// Mirrors Python v2 `BaseResource` from `resource.py`. Provides: +/// - Shared fields: `id`, `name`, `description` +/// - Context reference to `Aixplain` instance +/// - Modification tracking via `_savedState` diffing +/// - `save()` (create or update), `clone()` +/// +/// All domain resources (`Agent`, `Model`, `Tool`, `Index`) inherit from this. +open class BaseResource: @unchecked Sendable { + private static let logger = Logger(subsystem: "aiXplainKit", category: "BaseResource") + + /// Subclasses override to define the API path (e.g., `"v2/agents"`). + open class var resourcePath: String { "" } + + // MARK: - Core fields + + public var id: String? + public var name: String? + public var description: String? + + /// Strong reference to the Aixplain context (resolved question: strong, not weak). + public var context: Aixplain? + + // MARK: - State tracking + + private var _savedState: [String: String]? + private var _deleted: Bool = false + + public var isModified: Bool { + guard let saved = _savedState else { return true } + return serializableSnapshot() != saved + } + + public var isDeleted: Bool { _deleted } + + /// URL-encoded resource ID for use in API paths. + /// URL-safe ID for use in API paths. Encodes all special characters including `/`. + public var encodedId: String { + guard let id else { return "" } + var allowed = CharacterSet.urlPathAllowed + allowed.remove("/") + return id.addingPercentEncoding(withAllowedCharacters: allowed) ?? id + } + + // MARK: - Init + + public required init(id: String? = nil, name: String? = nil, description: String? = nil, context: Aixplain? = nil) { + self.id = id + self.name = name + self.description = description + self.context = context + } + + // MARK: - State management + + /// Snapshot current state for modification tracking. + func serializableSnapshot() -> [String: String] { + var snap: [String: String] = [:] + if let id { snap["id"] = id } + if let name { snap["name"] = name } + if let description { snap["description"] = description } + return snap + } + + /// Mark the current state as "saved" (no modifications since). + public func updateSavedState() { + _savedState = serializableSnapshot() + } + + /// Mark the resource as deleted. + public func markAsDeleted() { + _deleted = true + id = nil + } + + // MARK: - Validation + + /// Ensure the resource is in a valid state for operations. + public func ensureValidState() throws { + let typeName = String(describing: Swift.type(of: self)) + if isDeleted { + throw AixplainError.validation(ValidationError("\(typeName) has been deleted and cannot be used.")) + } + guard id != nil else { + throw AixplainError.validation(ValidationError("\(typeName) has not been saved yet. Call .save() first.")) + } + } + + /// Ensure the context is available. + public func ensureContext() throws -> Aixplain { + guard let ctx = context else { + throw AixplainError.resource(ResourceError("Context is required for resource operations.")) + } + return ctx + } + + // MARK: - Save + + /// Build the payload for save operations. Subclasses override for custom serialization. + open func buildSavePayload() throws -> [String: Any] { + var payload: [String: Any] = [:] + if let id { payload["id"] = id } + if let name { payload["name"] = name } + if let description { payload["description"] = description } + return payload + } + + /// Save the resource: POST (create) if no ID, PUT (update) if ID exists. + /// Mirrors Python v2 `BaseResource.save()`. + @discardableResult + open func save() async throws -> Self { + let ctx = try ensureContext() + let payload = try buildSavePayload() + + if let existingId = id { + let path = "\(Self.resourcePath)/\(encodedId)" + _ = try await ctx.client.post(path, json: payload) + } else { + let result = try await ctx.client.post(Self.resourcePath, json: payload) + if let newId = result["id"] as? String { + self.id = newId + } + updateFromResponse(result) + } + + updateSavedState() + return self + } + + /// Update local fields from a server response. Subclasses override for custom fields. + open func updateFromResponse(_ response: [String: Any]) { + if let id = response["id"] as? String { self.id = id } + if let name = response["name"] as? String { self.name = name } + if let desc = response["description"] as? String { self.description = desc } + } + + // MARK: - Clone + + /// Create a deep copy with `id = nil`. Mirrors Python v2 `BaseResource.clone()`. + open func clone(name: String? = nil) -> Self { + let cloned = Self.init( + id: nil, + name: name ?? self.name, + description: self.description, + context: self.context + ) + return cloned + } + + // MARK: - Delete + + /// Delete this resource. Mirrors Python v2 `DeleteResourceMixin.delete()`. + open func delete() async throws { + try ensureValidState() + let ctx = try ensureContext() + let path = "\(Self.resourcePath)/\(encodedId)" + _ = try await ctx.client.requestRaw(method: .delete, path: path) + markAsDeleted() + } + + // MARK: - Get + + /// Fetch a single resource by ID. Mirrors Python v2 `GetResourceMixin.get()`. + class func performGet(_ id: String, context: Aixplain, type: T.Type) async throws -> T { + var allowed = CharacterSet.urlPathAllowed + allowed.remove("/") + let encodedId = id.addingPercentEncoding(withAllowedCharacters: allowed) ?? id + let path = "\(T.resourcePath)/\(encodedId)" + let dict = try await context.client.get(path) + let instance = try T.from(dict: dict, context: context) + instance.updateSavedState() + return instance + } + + /// Deserialize from a dictionary. Subclasses must override. + open class func from(dict: [String: Any], context: Aixplain) throws -> Self { + let instance = Self.init( + id: dict["id"] as? String, + name: dict["name"] as? String, + description: dict["description"] as? String, + context: context + ) + return instance + } + + // MARK: - Search + + /// Search/list resources with pagination. Mirrors Python v2 `SearchResourceMixin.search()`. + class func performSearch( + filters: [String: Any], + context: Aixplain, + type: T.Type, + paginatePath: String = "paginate", + itemsKey: String = "results" + ) async throws -> Page { + let path = paginatePath.isEmpty ? T.resourcePath : "\(T.resourcePath)/\(paginatePath)" + let response = try await context.client.post(path, json: filters) + let items = response[itemsKey] as? [[String: Any]] ?? [] + let total = response["total"] as? Int ?? items.count + let pageTotal = response["pageTotal"] as? Int ?? 1 + let pageNumber = filters["pageNumber"] as? Int ?? 0 + + let results = try items.map { dict -> T in + let instance = try T.from(dict: dict, context: context) + instance.updateSavedState() + return instance + } + + return Page(results: results, pageNumber: pageNumber, pageTotal: pageTotal, total: total) + } + + // MARK: - Run (polling) + + /// Poll a URL for completion. Mirrors Python v2 `RunnableResourceMixin.poll()`. + public func poll(_ pollURL: String) async throws -> RunResult { + let ctx = try ensureContext() + let response = try await ctx.client.get(pollURL) + let result = RunResult.from(response) + if result.status == ResponseStatus.failed.rawValue { + throw APIError.fromFailedOperation(response) + } + return result + } + + /// Poll until completion with exponential backoff. Mirrors Python v2 `sync_poll()`. + public func syncPoll( + _ pollURL: String, + timeout: TimeInterval = 300, + waitTime: TimeInterval = 0.5 + ) async throws -> RunResult { + let startTime = Date() + var currentWait = max(waitTime, 0.2) + + while Date().timeIntervalSince(startTime) < timeout { + let result = try await poll(pollURL) + if result.completed { + return result + } + try await Task.sleep(nanoseconds: UInt64(currentWait * 1_000_000_000)) + currentWait = min(currentWait * 1.1, 60) + } + + throw AixplainError.timeout(TimeoutError( + "Operation timed out after \(Int(timeout)) seconds", + pollingURL: pollURL, + timeout: timeout + )) + } + + // MARK: - Required init for clone/from + + public required convenience init() { + self.init(id: nil, name: nil, description: nil, context: nil) + } +} diff --git a/Sources/aiXplainKit/Resources/Page.swift b/Sources/aiXplainKit/Resources/Page.swift new file mode 100644 index 0000000..b592e39 --- /dev/null +++ b/Sources/aiXplainKit/Resources/Page.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Generic pagination container. +/// +/// Mirrors Python v2 `Page(Generic[ResourceT])` from `resource.py`. +public struct Page: @unchecked Sendable { + public let results: [T] + public let pageNumber: Int + public let pageTotal: Int + public let total: Int + + public init(results: [T], pageNumber: Int, pageTotal: Int, total: Int) { + self.results = results + self.pageNumber = pageNumber + self.pageTotal = pageTotal + self.total = total + } + + public var isEmpty: Bool { results.isEmpty } + public var count: Int { results.count } +} diff --git a/Sources/aiXplainKit/Resources/RunResult.swift b/Sources/aiXplainKit/Resources/RunResult.swift new file mode 100644 index 0000000..b0af6a3 --- /dev/null +++ b/Sources/aiXplainKit/Resources/RunResult.swift @@ -0,0 +1,46 @@ +import Foundation + +/// Base result for all run/poll operations. +/// +/// Mirrors Python v2 `Result` dataclass from `resource.py`. +/// Subclassed or extended by `AgentRunResult`, `ModelResult`, etc. +public struct RunResult: Sendable { + public let status: String + public let completed: Bool + public let errorMessage: String? + public let url: String? + public let supplierError: String? + public let data: AnyCodable? + public let rawData: [String: Any]? + + public init( + status: String, + completed: Bool, + errorMessage: String? = nil, + url: String? = nil, + supplierError: String? = nil, + data: AnyCodable? = nil, + rawData: [String: Any]? = nil + ) { + self.status = status + self.completed = completed + self.errorMessage = errorMessage + self.url = url + self.supplierError = supplierError + self.data = data + self.rawData = rawData + } + + /// Parse from a polling response dictionary. + public static func from(_ dict: [String: Any]) -> RunResult { + RunResult( + status: dict["status"] as? String ?? "IN_PROGRESS", + completed: dict["completed"] as? Bool ?? false, + errorMessage: dict["errorMessage"] as? String, + url: dict["url"] as? String, + supplierError: dict["supplierError"] as? String, + data: (dict["data"]).map { AnyCodable($0) }, + rawData: dict + ) + } +} diff --git a/Sources/aiXplainKit/Tools/Action.swift b/Sources/aiXplainKit/Tools/Action.swift new file mode 100644 index 0000000..6f079e1 --- /dev/null +++ b/Sources/aiXplainKit/Tools/Action.swift @@ -0,0 +1,92 @@ +import Foundation + +/// Action metadata from an integration or tool. +/// +/// Mirrors Python v2 `Action` dataclass from `integration.py`. +public struct Action: @unchecked Sendable { + public let name: String? + public let description: String? + public let displayName: String? + public let slug: String? + public let inputs: [ActionInput]? + + public init( + name: String? = nil, + description: String? = nil, + displayName: String? = nil, + slug: String? = nil, + inputs: [ActionInput]? = nil + ) { + self.name = name + self.description = description + self.displayName = displayName + self.slug = slug + self.inputs = inputs + } + + /// Parse from API response dictionary. + public static func from(_ dict: [String: Any]) -> Action { + var inputs: [ActionInput]? = nil + if let inputList = dict["inputs"] as? [[String: Any]] { + inputs = inputList.map { ActionInput.from($0) } + } + return Action( + name: dict["name"] as? String, + description: dict["description"] as? String, + displayName: dict["displayName"] as? String, + slug: dict["slug"] as? String, + inputs: inputs + ) + } +} + +/// Input parameter definition for an action. +/// +/// Mirrors Python v2 `Input` dataclass from `integration.py`. +public struct ActionInput: @unchecked Sendable { + public let name: String + public var code: String? + public var datatype: String + public var allowMulti: Bool + public var supportsVariables: Bool + public var defaultValue: [Any]? + public var required: Bool + public var fixed: Bool + public var inputDescription: String + + public init( + name: String, + code: String? = nil, + datatype: String = "string", + allowMulti: Bool = false, + supportsVariables: Bool = false, + defaultValue: [Any]? = nil, + required: Bool = false, + fixed: Bool = false, + inputDescription: String = "" + ) { + self.name = name + self.code = code + self.datatype = datatype + self.allowMulti = allowMulti + self.supportsVariables = supportsVariables + self.defaultValue = defaultValue + self.required = required + self.fixed = fixed + self.inputDescription = inputDescription + } + + public static func from(_ dict: [String: Any]) -> ActionInput { + ActionInput( + name: dict["name"] as? String ?? "", + code: dict["code"] as? String, + datatype: dict["datatype"] as? String ?? "string", + allowMulti: dict["allowMulti"] as? Bool ?? false, + supportsVariables: dict["supportsVariables"] as? Bool ?? false, + defaultValue: dict["defaultValue"] as? [Any], + required: dict["required"] as? Bool ?? false, + fixed: dict["fixed"] as? Bool ?? false, + inputDescription: dict["description"] as? String ?? "" + ) + } +} diff --git a/Sources/aiXplainKit/Tools/Integration.swift b/Sources/aiXplainKit/Tools/Integration.swift new file mode 100644 index 0000000..e11c6f8 --- /dev/null +++ b/Sources/aiXplainKit/Tools/Integration.swift @@ -0,0 +1,93 @@ +import Foundation +import OSLog + +/// Integration resource for connecting external services. +/// +/// Mirrors Python v2 `Integration(Model, ActionMixin)` from `integration.py`. +/// Integrations create Tools via `connect()`. +public final class Integration: Model { + private static let integrationLogger = Logger(subsystem: "aiXplainKit", category: "Integration") + public override class var resourcePath: String { "v2/integrations" } + + public var actionsAvailable: Bool? + + // MARK: - Init + + public required init(id: String? = nil, name: String? = nil, description: String? = nil, context: Aixplain? = nil) { + super.init(id: id, name: name, description: description, context: context) + } + + public required convenience init() { + self.init(id: nil, name: nil, description: nil, context: nil) + } + + // MARK: - Get + + public class func getIntegration(_ id: String, context: Aixplain) async throws -> Integration { + try await performGet(id, context: context, type: Integration.self) + } + + // MARK: - Connect + + /// Connect the integration, creating a Tool. + /// Mirrors Python v2 `integration.connect()`: + /// response = self.run(**kwargs) + /// tool_id = response.data.id + /// return self.context.Tool.get(tool_id) + public func connect( + name: String? = nil, + description: String? = nil, + config: [String: Any]? = nil + ) async throws -> Tool { + try ensureValidState() + let ctx = try ensureContext() + + var payload: [String: Any] = [:] + if let name { payload["name"] = name } + if let description { payload["description"] = description } + if let config { + var data = config + if let code = data.removeValue(forKey: "code") { + data["code"] = code + } + payload["data"] = data + } + + let runURL = try buildRunURL() + let response = try await ctx.client.post(runURL, json: payload) + + let toolId: String + if let dataDict = response["data"] as? [String: Any], let id = dataDict["id"] as? String { + toolId = id + } else if let dataStr = response["data"] as? String { + toolId = dataStr + } else { + throw AixplainError.resource(ResourceError("Integration connect did not return a tool ID")) + } + + return try await Tool.getTool(toolId, context: ctx) + } + + // MARK: - Actions + + public func listActions() async throws -> [Action] { + guard actionsAvailable == true else { return [] } + let runURL = try buildRunURL() + let ctx = try ensureContext() + let response = try await ctx.client.post(runURL, json: [ + "action": "LIST_ACTIONS", + "data": [String: Any]() + ] as [String: Any]) + + guard let dataList = response["data"] as? [[String: Any]] else { return [] } + return dataList.compactMap { Action.from($0) } + } + + // MARK: - Deserialization + + public override class func from(dict: [String: Any], context: Aixplain) throws -> Self { + let instance = try super.from(dict: dict, context: context) + instance.actionsAvailable = dict["actionsAvailable"] as? Bool + return instance + } +} diff --git a/Sources/aiXplainKit/Tools/Tool.swift b/Sources/aiXplainKit/Tools/Tool.swift new file mode 100644 index 0000000..70d9e01 --- /dev/null +++ b/Sources/aiXplainKit/Tools/Tool.swift @@ -0,0 +1,136 @@ +import Foundation +import OSLog + +/// Tool resource -- subclass of Model (matches Python v2 `class Tool(Model, ...)`). +/// +/// Tools are the primary mechanism for extending agent capabilities. +/// They can be created from integrations, run with action-based execution, +/// and serialized for agent tool lists via `asAgentTool()`. +public final class Tool: Model { + private static let toolLogger = Logger(subsystem: "aiXplainKit", category: "Tool") + public override class var resourcePath: String { "v2/tools" } + public static let defaultIntegrationId = "686432941223092cb4294d3f" + + // MARK: - Tool-specific fields + + public var assetId: String? + public var allowedActions: [String] = [] + public var actionsAvailable: Bool? + public var code: String? + + // MARK: - Init + + public required init(id: String? = nil, name: String? = nil, description: String? = nil, context: Aixplain? = nil) { + super.init(id: id, name: name, description: description, context: context) + } + + public required convenience init() { + self.init(id: nil, name: nil, description: nil, context: nil) + } + + // MARK: - Get + + public class func getTool(_ id: String, context: Aixplain) async throws -> Tool { + try await performGet(id, context: context, type: Tool.self) + } + + // MARK: - Search + + public class func searchTools( + query: String? = nil, + pageNumber: Int = 0, + pageSize: Int = 20, + context: Aixplain + ) async throws -> Page { + var filters: [String: Any] = [ + "pageNumber": pageNumber, + "pageSize": pageSize, + "sort": [[:] as [String: Any]] + ] + if let q = query { filters["q"] = q } + return try await performSearch(filters: filters, context: context, type: Tool.self) + } + + // MARK: - Run (action-based) + + /// Run the tool with an action. Falls back to single allowed action. + /// Mirrors Python v2 `Tool.run()`. + public func runTool(action: String? = nil, data: Any? = nil) async throws -> ModelResult { + try ensureValidState() + + var resolvedAction = action + if resolvedAction == nil { + if allowedActions.count == 1 { + resolvedAction = allowedActions.first + } else { + let available = try await listActions() + if available.count == 1 { + resolvedAction = available.first?.name + } + } + } + + guard let finalAction = resolvedAction else { + throw AixplainError.validation(ValidationError("No action provided and tool has multiple actions")) + } + + var payload: [String: Any] = ["action": finalAction] + if let data { payload["data"] = data } + + return try await run(parameters: payload) + } + + // MARK: - Actions + + /// List available actions for this tool. + /// Mirrors Python v2 `ActionMixin.list_actions()`. + public func listActions() async throws -> [Action] { + guard actionsAvailable == true else { return [] } + let runURL = try buildRunURL() + let ctx = try ensureContext() + let response = try await ctx.client.post(runURL, json: [ + "action": "LIST_ACTIONS", + "data": [String: Any]() + ] as [String: Any]) + + guard let dataList = response["data"] as? [[String: Any]] else { return [] } + return dataList.compactMap { Action.from($0) } + } + + /// List inputs for specified actions. + /// Mirrors Python v2 `ActionMixin.list_inputs()`. + public func listInputs(_ actionNames: [String]) async throws -> [Action] { + let runURL = try buildRunURL() + let ctx = try ensureContext() + let response = try await ctx.client.post(runURL, json: [ + "action": "LIST_INPUTS", + "data": ["actions": actionNames] + ] as [String: Any]) + + guard let dataList = response["data"] as? [[String: Any]] else { return [] } + return dataList.compactMap { Action.from($0) } + } + + // MARK: - AgentToolConvertible (override) + + /// Override to include `actions` list for agent serialization. + public override func asAgentTool() -> AgentToolDict { + var dict = super.asAgentTool() + dict.type = .tool + if !allowedActions.isEmpty { + dict.actions = allowedActions + } + return dict + } + + // MARK: - Deserialization + + public override class func from(dict: [String: Any], context: Aixplain) throws -> Self { + let instance = try super.from(dict: dict, context: context) + instance.assetId = dict["assetId"] as? String + instance.allowedActions = dict["allowedActions"] as? [String] ?? [] + instance.actionsAvailable = dict["actionsAvailable"] as? Bool + instance.code = dict["code"] as? String + return instance + } +} diff --git a/Sources/aiXplainKit/aiXplainKit.docc/Essential/DiscoverEssential.md b/Sources/aiXplainKit/aiXplainKit.docc/Essential/DiscoverEssential.md deleted file mode 100644 index 9a4bab3..0000000 --- a/Sources/aiXplainKit/aiXplainKit.docc/Essential/DiscoverEssential.md +++ /dev/null @@ -1,20 +0,0 @@ -# Models Essential -Learn how to use aiXplain's ever-expanding catalog of 35,000+ ready-to-use AI models that can be used for various tasks like Translation, Speech Recognition, Diacritization, Sentiment Analysis, and much more. - -## Overview - -The catalog of all available models on aiXplain can be accessed and browsed [here](https://platform.aixplain.com/discovery/models). Details of each model can be found by clicking on the model card. Model ID can be found on the URL or below the model name. - -Once the Model ID of the desired model is available, it can be used to create a `Model` object from the `ModelFactory`. - -```swift -from aixplain.factories import ModelFactory -let model = ModelProvider().get("") -``` - -### Run -The aixplain SDK allows you to run machine learning models. - -```python -let output = model.run("This is a sample text") # You can use a URL or a file path on your local machine -``` diff --git a/Sources/aiXplainKit/aiXplainKit.docc/Essential/PipelineEssential.md b/Sources/aiXplainKit/aiXplainKit.docc/Essential/PipelineEssential.md deleted file mode 100644 index bae5558..0000000 --- a/Sources/aiXplainKit/aiXplainKit.docc/Essential/PipelineEssential.md +++ /dev/null @@ -1,33 +0,0 @@ -# Pipelines Essentials -Learn how to use Pipelines - - -## Overview - - [Design](https://aixplain.com/platform/studio/) is aiXplain’s no-code AI pipeline builder tool that accelerates AI development by providing a seamless experience to build complex AI systems and deploy them within minutes. You can visit our platform and design your own custom pipeline [here](https://platform.aixplain.com/studio). - -#### Explore -The catalog of all your pipelines on aiXplain can be accessed and browsed [here](https://platform.aixplain.com/dashboard/pipelines). Details of the pipeline can be found by clicking on the pipeline card. Pipeline ID can be found from the URL or below the pipeline name (similar to models). - -Once the Pipeline ID of the desired pipeline is available, it can be used to create a `Pipeline` object from the `PipelineProvider`. -```swift -import AiXplainKit -pipeline = PipelineProvider().get("") -``` - -### Run -The AiXplainKit allows you to run pipelines asynchronously. - -```swift -let result = try await pipeline.run("This is a sample text") -``` - - -For multi-input pipelines, you can specify as input a dictionary where the keys are the label names of the input node and values are their corresponding content: - -```swift -let result = try await pipeline.run({ - "Input 1": "This is a sample text to input node 1.", - "Input 2": "This is a sample text to input node 2." -}) -``` diff --git a/Sources/aiXplainKit/aiXplainKit.docc/Essential/TeamAPIKeyGuide.md b/Sources/aiXplainKit/aiXplainKit.docc/Essential/TeamAPIKeyGuide.md deleted file mode 100644 index 7bd6d8a..0000000 --- a/Sources/aiXplainKit/aiXplainKit.docc/Essential/TeamAPIKeyGuide.md +++ /dev/null @@ -1,38 +0,0 @@ -# Team API Key Guide - -Learn how to create and use API keys. - -## How to get your keys - - [Sign up](https://platform.aixplain.com/register) or [login](https://platform.aixplain.com/login) for an account on aiXplain. Then from the Dashboard, navigate to the [Integrations](https://platform.aixplain.com/account/integrations). - -![Dashboard photo](NavigateAPIKey) - -### Creating a New API Key -On the **Integrations** page, you can find the **Create a team access key** button on the top right corner. You can create a new key by clicking that button, then specifiying a label and an (optional) expiry date. - -### Manage API Keys -On the **Integrations** page, you can view all the existing Team API keys. You can also delete keys on this page. - -### Setting the keys - -An example of how to use the `APIKeyManager` to retrieve and set API keys. - -To set the API keys using Xcode environment variables, follow these steps: - -1. In Xcode, select your project in the Project Navigator. -2. Select your target, then click the "Info" tab. -3. Under the "Configurations" section, click the "+" button in the bottom-left corner. -4. In the newly added row, set the "Name" to the desired API key name (e.g., "TEAM_API_KEY") and the "Value" to your API key. -5. Repeat step 4 for each API key you need to set. - -With the environment variables set, the `APIKeyManager` will automatically load and use the API keys from the corresponding environment variables. - -You can also set the API keys directly in code if needed: - -```swift -AiXplainKit.shared.keyManager.TEAM_API_KEY = "" -``` - -### Classes -- ``APIKeyManager`` diff --git a/Sources/aiXplainKit/aiXplainKit.docc/Resources/NavigateAPIKey.png b/Sources/aiXplainKit/aiXplainKit.docc/Resources/NavigateAPIKey.png deleted file mode 100644 index ec5a034b09ccd5b410daaf4d980d4477616b6a7d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 99855 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%0DO#kys=xdH3Pt_&J2WSO`O_(7 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](). +// Initialize with your API key +let aix = try Aixplain(apiKey: "your-team-api-key") -Once you get the API key, you'll need to add this API key as an environment variable on your system. +// Run a model +let model = try await Model.get("model-id", context: aix) +let result = try await model.run(text: "Translate this to French") -```swift -AiXplainKit.shared.keyManager.TEAM_API_KEY = "" +// Run an agent +let agent = try await Agent.get("agent-id", context: aix) +let agentResult = try await agent.run("What can you help me with?") +print(agentResult.data?.output ?? "") ``` -Alternatively, you can set the API key as an environment variable in Xcode. This approach keeps your API key separate from your code, which can be beneficial for security and portability. Check on how to do this on the ``APIKeyManager`` - ## Topics -### Essential -- -- -- +### Entry Point +- ``Aixplain`` + +### Authentication +- ``Credential`` +- ``AuthenticationScheme`` + +### Client +- ``AixplainClient`` +- ``ClientConfiguration`` -### Tutorial -- +### Resources +- ``Agent`` +- ``Model`` +- ``Tool`` +- ``Integration`` +- ``Index`` +### Errors +- ``AixplainError`` +- ``APIError`` diff --git a/Sources/aiXplainKit/aiXplainKit.swift b/Sources/aiXplainKit/aiXplainKit.swift deleted file mode 100644 index 29f8b8f..0000000 --- a/Sources/aiXplainKit/aiXplainKit.swift +++ /dev/null @@ -1,30 +0,0 @@ -/* - AiXplainKit Library. - --- - - aiXplain SDK enables Swift programmers to add AI functions - to their software. - - Copyright 2024 The aiXplain SDK authors - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - AUTHOR: João Pedro Maia - */ - -import Foundation - -public final class AiXplainKit { - public let keyManager = APIKeyManager.shared - public static let shared = AiXplainKit() - -} diff --git a/Tests/aiXplainKitTests/E2E/AgentE2ETests.swift b/Tests/aiXplainKitTests/E2E/AgentE2ETests.swift new file mode 100644 index 0000000..1245034 --- /dev/null +++ b/Tests/aiXplainKitTests/E2E/AgentE2ETests.swift @@ -0,0 +1,94 @@ +import XCTest +@testable import aiXplainKit + +/// End-to-end tests for Agent against the real aiXplain API. +final class AgentE2ETests: XCTestCase { + + private var aix: Aixplain! + + override func setUp() async throws { + guard ProcessInfo.processInfo.environment["TEAM_API_KEY"] != nil else { + throw XCTSkip("TEAM_API_KEY not set -- skipping E2E tests") + } + let backendURL = ProcessInfo.processInfo.environment["BACKEND_URL"] + .flatMap { URL(string: $0) } + let modelURL = ProcessInfo.processInfo.environment["MODELS_RUN_URL"] + .flatMap { URL(string: $0) } + aix = try Aixplain(backendURL: backendURL, modelURL: modelURL) + } + + // MARK: - Search + + func test_agent_search() async throws { + let page = try await Agent.search(pageSize: 5, context: aix) + XCTAssertGreaterThanOrEqual(page.total, 0) + } + + // MARK: - Get + + func test_agent_get_byId() async throws { + let page = try await Agent.search(pageSize: 1, context: aix) + guard let firstAgent = page.results.first, let agentId = firstAgent.id else { + throw XCTSkip("No agents found to test get()") + } + + let fetched = try await Agent.get(agentId, context: aix) + XCTAssertEqual(fetched.id, agentId) + XCTAssertNotNil(fetched.name) + XCTAssertFalse(fetched.isModified) + XCTAssertNotNil(fetched.context) + } + + // MARK: - Run + + func test_agent_run_simpleQuery() async throws { + let page = try await Agent.search(pageSize: 1, context: aix) + guard let agent = page.results.first, agent.id != nil else { + throw XCTSkip("No agents found to test run()") + } + + let fetched = try await Agent.get(agent.id!, context: aix) + guard fetched.status == .onboarded else { + throw XCTSkip("Agent is not onboarded (status: \(fetched.status))") + } + + let result = try await fetched.run("Say hello in one word") + XCTAssertEqual(result.status, "SUCCESS") + XCTAssertTrue(result.completed) + XCTAssertNotNil(result.data?.output) + XCTAssertFalse(result.data!.output!.isEmpty, "Agent should return non-empty output") + } + + // MARK: - Session + + func test_agent_generateSessionId() async throws { + let page = try await Agent.search(pageSize: 1, context: aix) + guard let agent = page.results.first, let agentId = agent.id else { + throw XCTSkip("No agents found") + } + + let fetched = try await Agent.get(agentId, context: aix) + let sessionId = try await fetched.generateSessionId() + XCTAssertTrue(sessionId.contains(agentId), "Session ID should contain agent ID") + XCTAssertTrue(sessionId.contains("_"), "Session ID should have timestamp separator") + } + + // MARK: - Cross-RFC: Agent with Model as tool + + func test_agent_model_tool_serialization() async throws { + let modelPage = try await Model.search(pageSize: 1, context: aix) + guard let model = modelPage.results.first else { + throw XCTSkip("No models found") + } + + let agent = Agent(name: "Test Agent with Tool", instructions: "Use the tool", context: aix) + agent.tools = [model] + + let payload = try agent.buildSavePayload() + let toolsList = payload["tools"] as? [[String: Any]] + XCTAssertNotNil(toolsList) + XCTAssertEqual(toolsList?.count, 1) + XCTAssertEqual(toolsList?.first?["type"] as? String, "model") + XCTAssertEqual(toolsList?.first?["id"] as? String, model.id) + } +} diff --git a/Tests/aiXplainKitTests/E2E/ClientE2ETests.swift b/Tests/aiXplainKitTests/E2E/ClientE2ETests.swift new file mode 100644 index 0000000..87a38f2 --- /dev/null +++ b/Tests/aiXplainKitTests/E2E/ClientE2ETests.swift @@ -0,0 +1,81 @@ +import XCTest +@testable import aiXplainKit + +/// End-to-end tests that hit the real aiXplain API. +/// Requires TEAM_API_KEY environment variable to be set. +final class ClientE2ETests: XCTestCase { + + private var aix: Aixplain! + + override func setUp() async throws { + guard ProcessInfo.processInfo.environment["TEAM_API_KEY"] != nil else { + throw XCTSkip("TEAM_API_KEY not set -- skipping E2E tests") + } + let backendURL = ProcessInfo.processInfo.environment["BACKEND_URL"] + .flatMap { URL(string: $0) } + let modelURL = ProcessInfo.processInfo.environment["MODELS_RUN_URL"] + .flatMap { URL(string: $0) } + aix = try Aixplain(backendURL: backendURL, modelURL: modelURL) + } + + // MARK: - Credential resolution E2E + + func test_credentialResolvesFromEnvironment() throws { + let cred = try Credential.resolve() + let headers = cred.authHeaders() + XCTAssertNotNil(headers["x-api-key"], "TEAM_API_KEY should produce x-api-key header") + } + + // MARK: - AixplainClient.get E2E + + func test_client_get_agentsList() async throws { + let response = try await aix.client.requestRaw(method: .get, path: "sdk/agents") + XCTAssertTrue(response.isSuccess, "GET /sdk/agents should return 2xx, got \(response.statusCode)") + XCTAssertFalse(response.data.isEmpty) + } + + func test_client_get_invalidEndpoint_throws() async { + do { + _ = try await aix.client.get("v2/nonexistent-endpoint-xyz") + XCTFail("Should have thrown for invalid endpoint") + } catch let error as AixplainError { + if case .api(let apiErr) = error { + XCTAssertTrue(apiErr.statusCode >= 400) + } + } catch { + // Network or other errors are also acceptable + } + } + + // MARK: - Retry behavior E2E (indirect: confirm non-retryable errors throw immediately) + + func test_client_nonRetryableError_throwsImmediately() async { + do { + _ = try await aix.client.requestRaw(method: .delete, path: "v2/agents/nonexistent-id-xyz") + XCTFail("DELETE nonexistent should throw") + } catch { + // 404 or 405 is expected and should NOT be retried (DELETE is not retryable) + } + } + + // MARK: - URL resolution E2E + + func test_client_absoluteURL_passesThrough() async throws { + let url = aix.client.configuration.backendURL.absoluteString + "/sdk/agents" + let response = try await aix.client.requestRaw(method: .get, path: url) + XCTAssertTrue(response.isSuccess) + } + + // MARK: - Response parsing E2E + + func test_client_post_modelsSearch() async throws { + let body: [String: Any] = [ + "pageSize": 1, + "pageNumber": 0, + "sort": [[:]] + ] + let result = try await aix.client.post("v2/models/paginate", json: body) + XCTAssertNotNil(result["results"], "Model search should return 'results' key") + XCTAssertNotNil(result["total"], "Model search should return 'total' key") + } +} diff --git a/Tests/aiXplainKitTests/E2E/IndexE2ETests.swift b/Tests/aiXplainKitTests/E2E/IndexE2ETests.swift new file mode 100644 index 0000000..fcfa2a6 --- /dev/null +++ b/Tests/aiXplainKitTests/E2E/IndexE2ETests.swift @@ -0,0 +1,95 @@ +import XCTest +@testable import aiXplainKit + +/// End-to-end tests for Index against the real aiXplain API. +final class IndexE2ETests: XCTestCase { + + private var aix: Aixplain! + + override func setUp() async throws { + guard ProcessInfo.processInfo.environment["TEAM_API_KEY"] != nil else { + throw XCTSkip("TEAM_API_KEY not set -- skipping E2E tests") + } + let backendURL = ProcessInfo.processInfo.environment["BACKEND_URL"] + .flatMap { URL(string: $0) } + let modelURL = ProcessInfo.processInfo.environment["MODELS_RUN_URL"] + .flatMap { URL(string: $0) } + aix = try Aixplain(backendURL: backendURL, modelURL: modelURL) + } + + // MARK: - Index.get with known model + + func test_index_get_model() async throws { + // Fetch a model that backs an index (function="search") + // Use the AIR engine model ID as a known model + let engineId = IndexEngine.air.id + do { + let model = try await Model.get(engineId, context: aix) + XCTAssertNotNil(model.id) + XCTAssertNotNil(model.name) + } catch { + throw XCTSkip("AIR engine model not available: \(error)") + } + } + + // MARK: - Cross-RFC: full stack test + + func test_fullStack_credential_client_model_agent() async throws { + // 1. Credential resolved from env (RFC-0001) + let cred = try Credential.resolve() + XCTAssertNotNil(cred.authHeaders()["x-api-key"]) + + // 2. Client makes real request (RFC-0002) + let response = try await aix.client.requestRaw(method: .get, path: "sdk/agents") + XCTAssertTrue(response.isSuccess) + + // 3. Model search works (RFC-0007) + let modelPage = try await Model.search(pageSize: 1, context: aix) + XCTAssertGreaterThan(modelPage.total, 0) + + // 4. Tool search works (RFC-0008) + let toolPage = try await Tool.searchTools(pageSize: 1, context: aix) + XCTAssertGreaterThanOrEqual(toolPage.total, 0) + + // 5. Agent search works (RFC-0003) + let agentPage = try await Agent.search(pageSize: 1, context: aix) + XCTAssertGreaterThanOrEqual(agentPage.total, 0) + + // 6. Model → AgentToolDict serialization (RFC-0004 → 0007 → 0003) + if let model = modelPage.results.first { + let agentTool = model.asAgentTool() + XCTAssertEqual(agentTool.type, .model) + let encoded = try JSONEncoder().encode(agentTool) + XCTAssertFalse(encoded.isEmpty) + } + } + + // MARK: - Record serialization + + func test_record_serialization_forIndex() throws { + let record = Record(text: "Swift is a programming language") + let dict = record.toDictionary() + XCTAssertEqual(dict["dataType"] as? String, "text") + XCTAssertEqual(dict["data"] as? String, "Swift is a programming language") + + let data = try JSONEncoder().encode(record) + let decoded = try JSONDecoder().decode(Record.self, from: data) + XCTAssertEqual(decoded.value, record.value) + } + + // MARK: - Filter builder + + func test_filterBuilder_producesValidDicts() { + let filters = IndexFilter.builder() + .where("language", .equals("en")) + .where("score", .greaterThan("0.5")) + .build() + + XCTAssertEqual(filters.count, 2) + let dicts = filters.map { $0.toDict() } + XCTAssertEqual(dicts[0]["field"], "language") + XCTAssertEqual(dicts[0]["operator"], "==") + XCTAssertEqual(dicts[1]["field"], "score") + XCTAssertEqual(dicts[1]["operator"], ">") + } +} diff --git a/Tests/aiXplainKitTests/E2E/ModelE2ETests.swift b/Tests/aiXplainKitTests/E2E/ModelE2ETests.swift new file mode 100644 index 0000000..8959286 --- /dev/null +++ b/Tests/aiXplainKitTests/E2E/ModelE2ETests.swift @@ -0,0 +1,79 @@ +import XCTest +@testable import aiXplainKit + +/// End-to-end tests for Model that hit the real aiXplain API. +final class ModelE2ETests: XCTestCase { + + private var aix: Aixplain! + + override func setUp() async throws { + guard ProcessInfo.processInfo.environment["TEAM_API_KEY"] != nil else { + throw XCTSkip("TEAM_API_KEY not set -- skipping E2E tests") + } + let backendURL = ProcessInfo.processInfo.environment["BACKEND_URL"] + .flatMap { URL(string: $0) } + let modelURL = ProcessInfo.processInfo.environment["MODELS_RUN_URL"] + .flatMap { URL(string: $0) } + aix = try Aixplain(backendURL: backendURL, modelURL: modelURL) + } + + // MARK: - Search + + func test_model_search_returnsResults() async throws { + let page = try await Model.search(pageSize: 5, context: aix) + XCTAssertGreaterThan(page.total, 0, "There should be models in the platform") + XCTAssertFalse(page.isEmpty) + XCTAssertLessThanOrEqual(page.count, 5) + + let firstModel = page.results.first! + XCTAssertNotNil(firstModel.id) + XCTAssertNotNil(firstModel.name) + } + + // MARK: - Get + + func test_model_get_byId() async throws { + let page = try await Model.search(pageSize: 1, context: aix) + guard let firstModel = page.results.first, let modelId = firstModel.id else { + throw XCTSkip("No models found to test get()") + } + + let fetched = try await Model.get(modelId, context: aix) + XCTAssertEqual(fetched.id, modelId) + XCTAssertNotNil(fetched.name) + XCTAssertFalse(fetched.isModified, "Freshly fetched model should not be modified") + } + + // MARK: - Run + + func test_model_run_textGeneration() async throws { + // GPT-4o Mini (known model ID from Python v2 DEFAULT_LLM) + let modelId = "669a63646eb56306647e1091" + + let model: Model + do { + model = try await Model.get(modelId, context: aix) + } catch { + throw XCTSkip("Default LLM model not available: \(error)") + } + + let result = try await model.run(text: "Say hello in one word") + XCTAssertEqual(result.status, "SUCCESS") + XCTAssertTrue(result.completed) + XCTAssertNotNil(result.data, "Model should return output data") + } + + // MARK: - asAgentTool + + func test_model_asAgentTool_realModel() async throws { + let page = try await Model.search(pageSize: 1, context: aix) + guard let model = page.results.first else { + throw XCTSkip("No models found") + } + + let tool = model.asAgentTool() + XCTAssertEqual(tool.id, model.id) + XCTAssertEqual(tool.type, .model) + XCTAssertFalse(tool.name.isEmpty) + } +} diff --git a/Tests/aiXplainKitTests/E2E/ToolE2ETests.swift b/Tests/aiXplainKitTests/E2E/ToolE2ETests.swift new file mode 100644 index 0000000..eaf43d2 --- /dev/null +++ b/Tests/aiXplainKitTests/E2E/ToolE2ETests.swift @@ -0,0 +1,70 @@ +import XCTest +@testable import aiXplainKit + +/// End-to-end tests for Tool and Integration against the real API. +final class ToolE2ETests: XCTestCase { + + private var aix: Aixplain! + + override func setUp() async throws { + guard ProcessInfo.processInfo.environment["TEAM_API_KEY"] != nil else { + throw XCTSkip("TEAM_API_KEY not set -- skipping E2E tests") + } + let backendURL = ProcessInfo.processInfo.environment["BACKEND_URL"] + .flatMap { URL(string: $0) } + let modelURL = ProcessInfo.processInfo.environment["MODELS_RUN_URL"] + .flatMap { URL(string: $0) } + aix = try Aixplain(backendURL: backendURL, modelURL: modelURL) + } + + // MARK: - Tool Search + + func test_tool_search_returnsResults() async throws { + let page = try await Tool.searchTools(pageSize: 5, context: aix) + XCTAssertGreaterThanOrEqual(page.total, 0, "Tool search should return a total count") + } + + // MARK: - Tool Get + + func test_tool_get_byId() async throws { + let page = try await Tool.searchTools(pageSize: 1, context: aix) + guard let firstTool = page.results.first, let toolId = firstTool.id else { + throw XCTSkip("No tools found to test get()") + } + + let fetched = try await Tool.getTool(toolId, context: aix) + XCTAssertEqual(fetched.id, toolId) + XCTAssertNotNil(fetched.name) + XCTAssertFalse(fetched.isModified) + } + + // MARK: - Tool asAgentTool + + func test_tool_asAgentTool_realTool() async throws { + let page = try await Tool.searchTools(pageSize: 1, context: aix) + guard let tool = page.results.first else { + throw XCTSkip("No tools found") + } + + let agentTool = tool.asAgentTool() + XCTAssertEqual(agentTool.id, tool.id) + XCTAssertEqual(agentTool.type, .tool) + } + + // MARK: - Model as tool (cross-RFC: Model → AgentToolDict for agent use) + + func test_model_asAgentTool_forAgentUse() async throws { + let page = try await Model.search(pageSize: 1, context: aix) + guard let model = page.results.first else { + throw XCTSkip("No models found") + } + + let tool = model.asAgentTool() + XCTAssertEqual(tool.type, .model) + XCTAssertEqual(tool.id, model.id) + + let encoded = try JSONEncoder().encode(tool) + let decoded = try JSONDecoder().decode(AgentToolDict.self, from: encoded) + XCTAssertEqual(decoded.id, model.id) + } +} diff --git a/Tests/aiXplainKitTests/Functional/Modules/Agents/AgentBuildingTests.swift b/Tests/aiXplainKitTests/Functional/Modules/Agents/AgentBuildingTests.swift deleted file mode 100644 index d2aea88..0000000 --- a/Tests/aiXplainKitTests/Functional/Modules/Agents/AgentBuildingTests.swift +++ /dev/null @@ -1,140 +0,0 @@ -// -// AgentBuildingTests.swift -// aiXplainKit -// -// Created by Joao Maia on 10/01/25. -// - -import XCTest -@testable import aiXplainKit - -final class AgentBuildingTests: XCTestCase { - - // MARK: - Properties - - private let defaultAgentName = "Swift Functional Test Agent" - private let defaultAgentDescription = "This agent has been created for functional testing purposes" - - - // MARK: - Tests - - func testBuildAgent() async throws { - // Given - let tools = createDefaultTools() - - // When - let agent = try await AgentProvider().create( - name: defaultAgentName, - description: defaultAgentDescription, - tools: tools - ) - - // Then - validateAgent(agent, expectedToolCount: 4) - } - - func testBuildAgentWithAssetsAndModelsAndTools() async throws { - // Given - let utilityModel = try await createRandomNumberUtilityModel() - let textToSpeechTool = try await ModelProvider().get("6171efa5159531495cadefbc") - let tools = try await createComplexTools(utilityModel: utilityModel, tool: textToSpeechTool) - - // When - let agent = try await AgentProvider().create( - name: defaultAgentName, - description: defaultAgentDescription, - tools: tools - ) - - // Then - validateAgent(agent, expectedToolCount: 4) - } - - func testUpdateAgent() async throws { - // Given - let initialTools: [CreateAgentTool] = [ - .asset(id: "65c51c556eb563350f6e1bb1", description: "Allows the agent to perform web searches to find up-to-date information on any topic. Use this tool to access the most recent and relevant online content.") - ] - - // When - let agent = try await AgentProvider().create( - name: defaultAgentName, - description: defaultAgentDescription, - tools: initialTools - ) - - // Then - validateAgent(agent, expectedToolCount: 1) - - // When updating - try await agent.appendTools([ - .asset(id: "6633fd59821ee31dd914e232", description: "Allows the agent to perform web searches to find up-to-date information on any topic. Use this tool to access the most recent and relevant online content.") - ]) - - // Then after update - XCTAssertEqual(agent.assets.count, 2, "Agent should have 2 tools after appending") - } - - func testUpdateAndDeleteAgent() async throws { - // Given - let initialTools: [CreateAgentTool] = [ - .asset(id: "65c51c556eb563350f6e1bb1", description: "Allows the agent to perform web searches to find up-to-date information on any topic. Use this tool to access the most recent and relevant online content.") - ] - - // When - let agent = try await AgentProvider().create( - name: defaultAgentName, - description: defaultAgentDescription, - tools: initialTools - ) - - // Then - validateAgent(agent, expectedToolCount: 1) - - // When deleting - try await agent.delete() - } - - // MARK: - Helper Methods - - private func createDefaultTools() -> [CreateAgentTool] { - [ - .asset(id: "65c51c556eb563350f6e1bb1", description: "Allows the agent to perform web searches to find up-to-date information on any topic. Use this tool to access the most recent and relevant online content."), - .asset(id: "6633fd59821ee31dd914e232", description: "Allows the agent to perform web searches to find up-to-date information on any topic. Use this tool to access the most recent and relevant online content."), - .asset(id: "64aee5824d34b1221e70ac07", description: "Generates high-quality images from detailed text prompts. Use this tool to create visual representations or artistic renderings based on user input."), - .asset(id: "6171efa5159531495cadefbc", description: "Converts text to spoken audio in natural-sounding voices. Useful for generating audible output or creating voice responses for interactive applications") - ] - } - - private func createRandomNumberUtilityModel() async throws -> UtilityModel { - let code = """ - def main(number): - import random - return random.randint(1, number) - """ - - return try await ModelProvider().createUtilityModel( - name: "Random Number Generator", - code: code, - inputs: [.number(name: "number", description: "max random integer to be used")], - description: "generate random numbers" - ) - } - - private func createComplexTools(utilityModel: UtilityModel, tool: Model) async throws -> [CreateAgentTool] { - [ - .asset(id: "65c51c556eb563350f6e1bb1", description: "Allows the agent to perform web searches to find up-to-date information on any topic. Use this tool to access the most recent and relevant online content."), - .model(try await ModelProvider().get("6633fd59821ee31dd914e232"), description: "Allows the agent to perform web searches to find up-to-date information on any topic. Use this tool to access the most recent and relevant online content."), - .utility(utilityModel, description: "generation of random numbers"), - .tool(tool, description: "Text to speech") - ] - } - - private func validateAgent(_ agent: Agent, expectedToolCount: Int) { - XCTAssertFalse(agent.id.isEmpty, "Agent ID should not be empty") - XCTAssertEqual(agent.name, defaultAgentName, "Agent name should match the provided name") - XCTAssertEqual(agent.status, "draft", "Initial agent status should be draft") - XCTAssertEqual(agent.description, defaultAgentDescription, "Agent description should match the provided description") - XCTAssertEqual(agent.assets.count, expectedToolCount, "Agent should have \(expectedToolCount) tools") - } -} diff --git a/Tests/aiXplainKitTests/Functional/Modules/Agents/AgentOutputTests.swift b/Tests/aiXplainKitTests/Functional/Modules/Agents/AgentOutputTests.swift deleted file mode 100644 index cf305e5..0000000 --- a/Tests/aiXplainKitTests/Functional/Modules/Agents/AgentOutputTests.swift +++ /dev/null @@ -1,540 +0,0 @@ -// -// AgentOutputTests.swift -// aiXplainKitTests -// -// Created by João Pedro on 26/03/2024. -// - -import XCTest -@testable import aiXplainKit - -final class AgentOutputTests: XCTestCase { - - func testDecodeBasicAgentOutput() throws { - let json = """ - { - "completed": true, - "status": "SUCCESS", - "data": { - "input": "Hello World", - "output": "Greetings!", - "session_id": "123456", - "intermediate_steps": [] - } - } - """ - - let agentOutput = try AgentOutput(json) - - XCTAssertTrue(agentOutput.completed) - XCTAssertEqual(agentOutput.status, "SUCCESS") - XCTAssertEqual(agentOutput.data.input, "Hello World") - XCTAssertEqual(agentOutput.data.output, "Greetings!") - XCTAssertEqual(agentOutput.data.sessionID, "123456") - XCTAssertTrue(agentOutput.data.intermediateSteps.isEmpty) - } - - func testDecodeAgentOutputWithIntermediateSteps() throws { - let json = """ - { - "completed": true, - "status": "SUCCESS", - "data": { - "input": "What's the weather?", - "output": "It's sunny", - "session_id": "789", - "intermediate_steps": [ - { - "agent": "weather-bot", - "input": "Check weather", - "output": "Sunny conditions", - "tool_steps": [ - { - "tool": "weather-api", - "input": "get-current", - "output": "sunny", - "runTime": null, - "usedCredits": null - } - ], - "thought": "I should check the weather", - "runTime": 1.5, - "usedCredits": 0.1 - } - ] - } - } - """ - - let agentOutput = try AgentOutput(json) - - XCTAssertTrue(agentOutput.completed) - XCTAssertEqual(agentOutput.status, "SUCCESS") - XCTAssertEqual(agentOutput.data.input, "What's the weather?") - XCTAssertEqual(agentOutput.data.output, "It's sunny") - - let step = try XCTUnwrap(agentOutput.data.intermediateSteps.first) - XCTAssertEqual(step.agent, "weather-bot") - XCTAssertEqual(step.input, "Check weather") - XCTAssertEqual(step.output, "Sunny conditions") - XCTAssertEqual(step.thought, "I should check the weather") - XCTAssertEqual(step.runTime, 1.5) - XCTAssertEqual(step.usedCredits, 0.1) - - let toolStep = try XCTUnwrap(step.toolSteps?.first) - XCTAssertEqual(toolStep.tool, "weather-api") - XCTAssertEqual(toolStep.input, "get-current") - XCTAssertEqual(toolStep.output, "sunny") - } - - func testDecodeInvalidJSON() { - let invalidJSON = """ - { - "completed": true, - "status": "SUCCESS" - "data": { // Missing comma - "input": "Hello" - } - } - """ - - XCTAssertThrowsError(try AgentOutput(invalidJSON)) { error in - XCTAssertTrue(error is DecodingError || error is NSError) - } - } - - func testMissingRequiredFields() { - let incompleteJSON = """ - { - "completed": true, - "data": { - "input": "Hello", - "output": "Hi", - "session_id": "123", - "intermediate_steps": [] - } - } - """ - - XCTAssertThrowsError(try AgentOutput(incompleteJSON)) { error in - guard case .keyNotFound? = error as? DecodingError else { - XCTFail("Expected keyNotFound error") - return - } - } - } - - func testEncodingAndDecoding() throws { - // Create an AgentOutput instance - let originalOutput = AgentOutput( - completed: true, - status: "SUCCESS", - data: DataClass( - input: "Test input", - output: "Test output", - sessionID: "test-123", - intermediateSteps: [ - IntermediateStep( - agent: "test-agent", - input: "step input", - output: "step output", - toolSteps: nil, - thought: "thinking", - runTime: 1.0, - usedCredits: 0.5 - ) - ] - ) - ) - - // Encode to JSON data - let encodedData = try originalOutput.jsonData() - - // Decode back to AgentOutput - let decodedOutput = try AgentOutput(data: encodedData) - - // Verify the decoded output matches the original - XCTAssertEqual(decodedOutput.completed, originalOutput.completed) - XCTAssertEqual(decodedOutput.status, originalOutput.status) - XCTAssertEqual(decodedOutput.data.input, originalOutput.data.input) - XCTAssertEqual(decodedOutput.data.output, originalOutput.data.output) - XCTAssertEqual(decodedOutput.data.sessionID, originalOutput.data.sessionID) - XCTAssertEqual(decodedOutput.data.intermediateSteps.count, originalOutput.data.intermediateSteps.count) - } - - func testDecodeAgentOutputWithNullValues() throws { - let json = """ - { - "completed": true, - "status": "SUCCESS", - "data": { - "input": "Test input", - "output": "Test output", - "session_id": "123", - "intermediate_steps": [ - { - "agent": "test-agent", - "input": "step input", - "output": "step output", - "tool_steps": null, - "thought": null, - "runTime": 1.5, - "usedCredits": 0.1 - } - ] - } - } - """ - - let agentOutput = try AgentOutput(json) - let step = try XCTUnwrap(agentOutput.data.intermediateSteps.first) - - XCTAssertNil(step.toolSteps) - XCTAssertNil(step.thought) - XCTAssertEqual(step.runTime, 1.5) - XCTAssertEqual(step.usedCredits, 0.1) - } - - func testDecodeAgentOutputWithEmptyStrings() throws { - let json = """ - { - "completed": true, - "status": "SUCCESS", - "data": { - "input": "", - "output": "", - "session_id": "", - "intermediate_steps": [ - { - "agent": "", - "input": "", - "output": "", - "tool_steps": [], - "thought": "", - "runTime": 0.0, - "usedCredits": 0.0 - } - ] - } - } - """ - - let agentOutput = try AgentOutput(json) - - XCTAssertEqual(agentOutput.data.input, "") - XCTAssertEqual(agentOutput.data.output, "") - XCTAssertEqual(agentOutput.data.sessionID, "") - - let step = try XCTUnwrap(agentOutput.data.intermediateSteps.first) - XCTAssertEqual(step.agent, "") - XCTAssertEqual(step.input, "") - XCTAssertEqual(step.output, "") - XCTAssertEqual(step.thought, "") - } - - func testDecodeAgentOutputWithMultipleToolSteps() throws { - let json = """ - { - "completed": true, - "status": "SUCCESS", - "data": { - "input": "Complex query", - "output": "Final result", - "session_id": "789", - "intermediate_steps": [ - { - "agent": "multi-tool-agent", - "input": "Process query", - "output": "Processed result", - "tool_steps": [ - { - "tool": "tool1", - "input": "input1", - "output": "output1", - "runTime": null, - "usedCredits": null - }, - { - "tool": "tool2", - "input": "input2", - "output": "output2", - "runTime": null, - "usedCredits": null - } - ], - "thought": "Processing with multiple tools", - "runTime": 2.5, - "usedCredits": 0.2 - } - ] - } - } - """ - - let agentOutput = try AgentOutput(json) - let step = try XCTUnwrap(agentOutput.data.intermediateSteps.first) - let toolSteps = try XCTUnwrap(step.toolSteps) - - XCTAssertEqual(toolSteps.count, 2) - XCTAssertEqual(toolSteps[0].tool, "tool1") - XCTAssertEqual(toolSteps[0].input, "input1") - XCTAssertEqual(toolSteps[0].output, "output1") - XCTAssertEqual(toolSteps[1].tool, "tool2") - XCTAssertEqual(toolSteps[1].input, "input2") - XCTAssertEqual(toolSteps[1].output, "output2") - } - - func testDecodeAgentOutputWithMultipleIntermediateSteps() throws { - let json = """ - { - "completed": true, - "status": "SUCCESS", - "data": { - "input": "Multi-step query", - "output": "Final output", - "session_id": "abc123", - "intermediate_steps": [ - { - "agent": "agent1", - "input": "step1 input", - "output": "step1 output", - "tool_steps": null, - "thought": "first thought", - "runTime": 1.0, - "usedCredits": 0.1 - }, - { - "agent": "agent2", - "input": "step2 input", - "output": "step2 output", - "tool_steps": null, - "thought": "second thought", - "runTime": 1.5, - "usedCredits": 0.15 - } - ] - } - } - """ - - let agentOutput = try AgentOutput(json) - - XCTAssertEqual(agentOutput.data.intermediateSteps.count, 2) - XCTAssertEqual(agentOutput.data.intermediateSteps[0].agent, "agent1") - XCTAssertEqual(agentOutput.data.intermediateSteps[0].thought, "first thought") - XCTAssertEqual(agentOutput.data.intermediateSteps[1].agent, "agent2") - XCTAssertEqual(agentOutput.data.intermediateSteps[1].thought, "second thought") - } - - func testJSONNullBehavior() { - let jsonNull1 = JSONNull() - let jsonNull2 = JSONNull() - - XCTAssertEqual(jsonNull1, jsonNull2) - XCTAssertEqual(jsonNull1.hashValue, 0) - - var hasher = Hasher() - jsonNull1.hash(into: &hasher) - // No assertion needed for hash(into:) as it's a no-op function - } - - func testJSONDecodingErrors() throws { - // Test invalid JSON format - let malformedJSON = "{ this is not valid json }" - XCTAssertThrowsError(try AgentOutput(malformedJSON)) { error in - XCTAssertTrue(error is DecodingError || error is NSError) - } - - // Test wrong type for boolean field - let invalidBooleanJSON = """ - { - "completed": "true", - "status": "SUCCESS", - "data": { - "input": "test", - "output": "test", - "session_id": "123", - "intermediate_steps": [] - } - } - """ - XCTAssertThrowsError(try AgentOutput(invalidBooleanJSON)) { error in - XCTAssertTrue(error is DecodingError) - } - - // Test wrong type for numeric field - let invalidNumericJSON = """ - { - "completed": true, - "status": "SUCCESS", - "data": { - "input": "test", - "output": "test", - "session_id": "123", - "intermediate_steps": [{ - "agent": "test", - "input": "test", - "output": "test", - "runTime": "not a number", - "usedCredits": 0.1 - }] - } - } - """ - XCTAssertThrowsError(try AgentOutput(invalidNumericJSON)) { error in - XCTAssertTrue(error is DecodingError) - } - } - - func testWithFunctions() throws { - // Test AgentOutput.with() - let originalOutput = AgentOutput( - completed: false, - status: "PENDING", - data: DataClass( - input: "original", - output: "original", - sessionID: "123", - intermediateSteps: [] - ) - ) - - let modifiedOutput = originalOutput.with( - completed: true, - status: "SUCCESS" - ) - - XCTAssertTrue(modifiedOutput.completed) - XCTAssertEqual(modifiedOutput.status, "SUCCESS") - - // Test DataClass.with() - let originalData = DataClass( - input: "original", - output: "original", - sessionID: "123", - intermediateSteps: [] - ) - - let modifiedData = originalData.with( - input: "modified", - output: "modified", - sessionID: "456", - intermediateSteps: [ - IntermediateStep( - agent: "test", - input: "test", - output: "test", - toolSteps: nil, - thought: nil, - runTime: 1.0, - usedCredits: 0.1 - ) - ] - ) - - XCTAssertEqual(modifiedData.input, "modified") - XCTAssertEqual(modifiedData.output, "modified") - XCTAssertEqual(modifiedData.sessionID, "456") - XCTAssertEqual(modifiedData.intermediateSteps.count, 1) - - // Test IntermediateStep.with() - let originalStep = IntermediateStep( - agent: "original", - input: "original", - output: "original", - toolSteps: nil, - thought: nil, - runTime: 1.0, - usedCredits: 0.1 - ) - - let modifiedStep = originalStep.with( - agent: "modified", - input: "modified", - output: "modified", - toolSteps: [ - ToolStep( - tool: "test", - input: "test", - output: "test", - runTime: nil, - usedCredits: nil - ) - ], - thought: "modified thought", - runTime: 2.0, - usedCredits: 0.2 - ) - - XCTAssertEqual(modifiedStep.agent, "modified") - XCTAssertEqual(modifiedStep.input, "modified") - XCTAssertEqual(modifiedStep.output, "modified") - XCTAssertEqual(modifiedStep.toolSteps?.count, 1) - XCTAssertEqual(modifiedStep.thought, "modified thought") - XCTAssertEqual(modifiedStep.runTime, 2.0) - XCTAssertEqual(modifiedStep.usedCredits, 0.2) - - // Test ToolStep.with() - let originalToolStep = ToolStep( - tool: "original", - input: "original", - output: "original", - runTime: nil, - usedCredits: nil - ) - - let modifiedToolStep = originalToolStep.with( - tool: "modified", - input: "modified", - output: "modified" - ) - - XCTAssertEqual(modifiedToolStep.tool, "modified") - XCTAssertEqual(modifiedToolStep.input, "modified") - XCTAssertEqual(modifiedToolStep.output, "modified") - XCTAssertNil(modifiedToolStep.runTime) - XCTAssertNil(modifiedToolStep.usedCredits) - } - - func testJSONEncodingAndStringConversion() throws { - let agentOutput = AgentOutput( - completed: true, - status: "SUCCESS", - data: DataClass( - input: "test", - output: "test", - sessionID: "123", - intermediateSteps: [] - ) - ) - - // Test jsonData() function - let jsonData = try agentOutput.jsonData() - XCTAssertNoThrow(try AgentOutput(data: jsonData)) - - // Test jsonString() function - let jsonString = try agentOutput.jsonString() - XCTAssertNotNil(jsonString) - if let jsonString = jsonString { - XCTAssertNoThrow(try AgentOutput(jsonString)) - } - - // Test DataClass JSON conversion - let dataClass = agentOutput.data - XCTAssertNoThrow(try dataClass.jsonData()) - XCTAssertNotNil(try dataClass.jsonString()) - - // Test IntermediateStep JSON conversion - let step = IntermediateStep( - agent: "test", - input: "test", - output: "test", - toolSteps: nil, - thought: nil, - runTime: 1.0, - usedCredits: 0.1 - ) - XCTAssertNoThrow(try step.jsonData()) - XCTAssertNotNil(try step.jsonString()) - } -} diff --git a/Tests/aiXplainKitTests/Functional/Modules/Agents/AgentsFunctionTests.swift b/Tests/aiXplainKitTests/Functional/Modules/Agents/AgentsFunctionTests.swift deleted file mode 100644 index 9589230..0000000 --- a/Tests/aiXplainKitTests/Functional/Modules/Agents/AgentsFunctionTests.swift +++ /dev/null @@ -1,215 +0,0 @@ -// -// File.swift -// aiXplainKit -// -// Created by Joao Maia on 22/11/24. -// - -import Foundation -import Foundation -import XCTest -@testable import aiXplainKit - -// IMPORTANT: THESE TESTS WILL COST CREDITS FROM YOUR ACCOUNT - -final class AgentFunctionalTests: XCTestCase { - - private var agent: Agent! - - override func setUp() async throws { - try await super.setUp() - // Use a shared agent instance for most tests - agent = try await AgentProvider().get("67851fd27fbcb5fa9a62b53f") - } - - func testAgentRun() async throws { - let response = try await agent.run("Brazil population in 2020") - XCTAssertEqual(response.completed, true) - XCTAssert(response.status == "SUCCESS") - XCTAssertFalse(response.data.input.isEmpty) - } - - func testAgentRunWithParameters() async throws { - let parameters = AgentRunParameters( - pollingWaitTimeInSeconds: 1.0, maxPollingRetries: 5 - ) - - let response = try await agent.run("What is Swift?", parameters: parameters) - XCTAssertEqual(response.completed, true) - XCTAssert(response.status == "SUCCESS") - } - - func testAgentRunWithSessionID() async throws { - let sessionID = UUID().uuidString - let response = try await agent.run("Hello", sessionID: sessionID) - XCTAssertEqual(response.completed, true) - XCTAssert(response.status == "SUCCESS") - } - - func testAgentQueryRun() async throws { - // Test single placeholder replacement - var response = try await agent.run(query: "{{city}} population in 2020", - content: ["city": "Rio de Janeiro"]) - - XCTAssertEqual(response.completed, true) - XCTAssert(response.status == "SUCCESS") - - // Test appending content when no placeholder exists - response = try await agent.run(query: "population in 2020", - content: ["city": "Rome"]) - XCTAssertEqual(response.completed, true) - XCTAssertEqual(response.data.input, "population in 2020 Rome") - XCTAssert(response.status == "SUCCESS") - - // Test multiple placeholders - response = try await agent.run( - query: "Compare {{city1}} and {{city2}} populations", - content: [ - "city1": "Tokyo", - "city2": "London" - ] - ) - XCTAssertEqual(response.completed, true) - XCTAssert(response.status == "SUCCESS") - } - - func testAgentURLQueryRun() async throws { - let imageURL = "https://i.pinimg.com/736x/44/b6/71/44b671446b75d00c686625eb89fc6326.jpg" - let agent = try await AgentProvider().get("673e0fda8b9e53f626c8fe1d") - - let response = try await agent.run( - query: "What is the history of the text in the figure: {{image}}", - content: ["image": imageURL] - ) - - XCTAssertEqual(response.completed, true) - XCTAssertEqual(response.data.input, "What is the history of the text in the figure: \(imageURL)") - XCTAssert(response.status == "SUCCESS") - } - - func testAgentDataRun() async throws { - let dataDictionary = ["query": "Hello World"] - let jsonData = try JSONSerialization.data(withJSONObject: dataDictionary) - - let response = try await agent.run(jsonData) - - XCTAssertEqual(response.completed, true) - XCTAssertEqual(response.data.input, "Hello World") - XCTAssert(response.status == "SUCCESS") - } - - func testInvalidAgentID() async throws { - do { - _ = try await AgentProvider().get("invalid_id") - XCTFail("Expected error for invalid agent ID") - } catch { - XCTAssertTrue(error is NetworkingError) - } - } - - func testMaxContentItems() async throws { - do { - _ = try await agent.run( - query: "Test {{1}} {{2}} {{3}} {{4}}", - content: [ - "1": "One", - "2": "Two", - "3": "Three", - "4": "Four" // Should trigger assertion - ] - ) - XCTFail("Expected assertion failure for too many content items") - } catch { - // Assertion should prevent this from being reached - } - } - - func testEmptyQuery() async throws { - do { - _ = try await agent.run("") - XCTFail("Expected assertion failure for empty query") - } catch { - // Assertion should prevent this from being reached - } - } - - - func testAgentRunWithLargeInput() async throws { - // Test with a large string input - let largeString = String(repeating: "a", count: 1000000) - do { - let response = try await agent.run(largeString) - XCTAssertEqual(response.completed, true) - XCTAssert(response.status == "SUCCESS") - } catch { - XCTFail("Failed to handle large input: \(error)") - } - } - - func testAgentRunWithSpecialCharacters() async throws { - let specialChars = "!@#$%^&*()_+{}[]|\\:;\"'<>,.?/~`" - let response = try await agent.run( - query: "Process this: {{text}}", - content: ["text": specialChars] - ) - XCTAssertEqual(response.completed, true) - XCTAssert(response.status == "SUCCESS") - } - - func testAgentRunWithUnicodeCharacters() async throws { - let unicodeString = "Hello 世界! 👋 🌍" - let response = try await agent.run(unicodeString) - XCTAssertEqual(response.completed, true) - XCTAssert(response.status == "SUCCESS") - } - - func testAgentRunWithMissingPlaceholder() async throws { - let response = try await agent.run( - query: "Test {{missing}}", - content: ["different": "value"] - ) - XCTAssertEqual(response.completed, true) - XCTAssert(response.status == "SUCCESS") - XCTAssertEqual(response.data.input, "Test {{missing}} value") - } - - func testAgentRunWithEmptyContent() async throws { - let response = try await agent.run( - query: "Test {{placeholder}}", - content: [:] - ) - XCTAssertEqual(response.completed, true) - XCTAssert(response.status == "SUCCESS") - XCTAssertEqual(response.data.input, "Test {{placeholder}}") - } - - func testAgentRunWithMultipleURLs() async throws { - let urls = [ - "https://example.com/image1.jpg", - "https://example.com/image2.jpg" - ] - - let response = try await agent.run( - query: "Compare these images: {{url1}} and {{url2}}", - content: [ - "url1": urls[0], - "url2": urls[1] - ] - ) - XCTAssertEqual(response.completed, true) - XCTAssert(response.status == "SUCCESS") - } - - func testAgentRunWithTimeout() async throws { - let parameters = AgentRunParameters( - pollingWaitTimeInSeconds: 0.1, maxPollingRetries: 1 - ) - - do { - _ = try await agent.run("This should timeout", parameters: parameters) - XCTFail("Expected timeout error") - } catch { - XCTAssertTrue(error is ModelError) - } - } -} diff --git a/Tests/aiXplainKitTests/Functional/Modules/Model/ModelFunctionalTests.swift b/Tests/aiXplainKitTests/Functional/Modules/Model/ModelFunctionalTests.swift deleted file mode 100644 index e2174be..0000000 --- a/Tests/aiXplainKitTests/Functional/Modules/Model/ModelFunctionalTests.swift +++ /dev/null @@ -1,119 +0,0 @@ -// -// ModelFunctionTests.swift -// -// -// Created by Joao Pedro Monteiro Maia on 26/03/24. -// - -import XCTest -@testable import aiXplainKit - -final class ModelFunctionalTests: XCTestCase { - - let llamaModelIdentifier = "6622cf096eb563537126b1a1" - - var testRunningCosts: Float = 0 - - override func setUp() async throws { -// AiXplainKit.shared.logLevel = .error - AiXplainKit.shared.keyManager.reload() - } - - override func tearDown() { - print("Cost until now for running the functional tests: \(testRunningCosts)") - } - - // MARK: Text-to-Text - - func testRunTextToTextOnPrem() async throws { - let llamaModel = try await ModelProvider().get(llamaModelIdentifier) - let modelOutput = try! await llamaModel.run("Hello World") - XCTAssertTrue(modelOutput.output.count > 0) - XCTAssertTrue(modelOutput.usedCredits > 0 || modelOutput.usedCredits < 1) - XCTAssertTrue(modelOutput.runtime < 60 * 5) - testRunningCosts += modelOutput.usedCredits - } - - func testRunPerformanceTextToTextOnPrem() async throws { - let llamaModel = try await ModelProvider().get(llamaModelIdentifier) - self.measure { - let exp = expectation(description: "Finished") - Task { - let modelOutput = try! await llamaModel.run("Hello World") - testRunningCosts += modelOutput.usedCredits - exp.fulfill() - } - wait(for: [exp], timeout: 200.0) - } - } - - func test_model_description() async { - let llamaModel = try! await ModelProvider().get(llamaModelIdentifier) - - XCTAssertTrue(llamaModel.id == "6622cf096eb563537126b1a1") - XCTAssertEqual(llamaModel.name, "Llama 3 8B") - XCTAssertEqual(llamaModel.supplier.id, 6839) - XCTAssertEqual(llamaModel.supplier.name, "Groq") - XCTAssertEqual(llamaModel.supplier.code, "groq") - XCTAssertEqual(llamaModel.version, "llama3-8b-8192") - XCTAssertEqual(llamaModel.pricing.price, 7.5e-07) - XCTAssertEqual(llamaModel.pricing.unitType, "TOKEN") - XCTAssertNil(llamaModel.pricing.unitScale) - - var description = "Model:\n ID: 6622cf096eb563537126b1a1\n Name: Llama 3 8B\n Description: Creates coherent and contextually relevant textual content based on prompts or certain parameters. Useful for chatbots, content creation, and data augmentation.\n Hosted By: Groq\n Developed By: Meta\n Version: llama3-8b-8192\n Pricing: Pricing(price: 7.5e-07, unitType: Optional(\"TOKEN\"), unitScale: nil)\n" - - XCTAssertTrue(llamaModel.description == description) - } - - // MARK: Audio-to-Text - - func testRunAudioToTextOnPrem() async throws { - let speechModel = try await ModelProvider().get("61716a9ed4e2751804b8097a") - - let marshallPlanURL: URL = URL(string: "https://www.americanrhetoric.com/mp3clips/newmoviespeeches/moviespeechthereturnofthekingbenediction.mp3")! - // Audio file from: Lord of The Rings: The Return of the King, https://www.americanrhetoric.com/MovieSpeeches/moviespeechreturnoftheking.html - - - // From Local URL - await self.withTempFile(from: marshallPlanURL) { localURL in - let modelOutput = try! await speechModel.run(localURL) - - XCTAssert(modelOutput.output == "This day does not belong to one man, but to all let us together rebuild this world that we may share in the days of peace.") - XCTAssert(modelOutput.usedCredits < 0.1) - self.testRunningCosts += modelOutput.usedCredits - - } - } - - // MARK: Image-to-text - - func testRunImageToTextOnPrem() async throws { - let blipModel = try await ModelProvider().get("60ddef7d8d38c51c5885d1e9") - - let nasaURL: URL = URL(string: "https://upload.wikimedia.org/wikipedia/commons/4/4e/Surveyor_3-Apollo_12.jpg")! - // Image file from: NASA, Alan L. Bean, Public domain, via Wikimedia Commons - - let modelOutput = try! await blipModel.run(nasaURL) - - let expectedOutputList = "Aircraft, Airplane, Landing, Vehicle, Baby, Outer Space, Nature, Face, Head, Portrait, Worker, Night, Moon, Motorcycle, Beach, Coast, Sea, Shoreline, Hat, People, Hardhat, Building, Shelter, Airfield, Space Station, Ammunition, Bomb, Antenna, Radio Telescope, Cannon, Photographer, Spaceship, Desert, Sand, Bird, Flying, Camping".components(separatedBy: ", ") - - let modelOutputSet = Set(modelOutput.output.components(separatedBy: ", ")) - - let intersection = modelOutputSet.intersection(expectedOutputList) - - let matchPercentage = Double(intersection.count) / Double(expectedOutputList.count) * 100 - - // check if at least 50% match the list - XCTAssertTrue(matchPercentage >= 50) - } - - // TODO: Image-To-Image - // TODO: Text-To-Audio - - //TODO: Model Provider to get a list - func testModelList() async throws { - let modelList = try await ModelProvider().list(.init(functions: [])) - XCTAssertTrue(modelList.count > 0) - } - -} diff --git a/Tests/aiXplainKitTests/Functional/Modules/Model/ModelUtilityFunctionTests.swift b/Tests/aiXplainKitTests/Functional/Modules/Model/ModelUtilityFunctionTests.swift deleted file mode 100644 index 8ecb9a2..0000000 --- a/Tests/aiXplainKitTests/Functional/Modules/Model/ModelUtilityFunctionTests.swift +++ /dev/null @@ -1,111 +0,0 @@ -// -// File.swift -// aiXplainKit -// -// Created by Joao Maia on 10/01/25. -// - -import XCTest -@testable import aiXplainKit - -final class ModelUtilityFunctionTests: XCTestCase { - // MARK: - Setup - - func testCreateTool() async throws { - let code = """ - def main(number,text): - if number > 0: - return text - return number + 10 - """ - - let utility = try await ModelProvider().createUtilityModel( - name: "Functional test", - code: code, - inputs: [ - .number(name: "number", description: "number to be used in the test"), - .text(name: "text", description: "text to be used in the test") - ], - description: "Test Utility" - ) - - // Test positive number case - should return text - let response1 = try await utility.run(["number": "10", "text": "Hello World"]) - XCTAssertEqual(response1.output as? String, "Hello World") - - // Test zero case - should return number + 10 - let response2 = try await utility.run(["number": "0", "text": "Hello World"]) - XCTAssertEqual(Int(response2.output), 10) - - // Test negative number case - should return number + 10 - let response3 = try await utility.run(["number": "-5", "text": "Hello World"]) - XCTAssertEqual(Int(response3.output), 5) - } - - func testUpdateTool() async throws { - - let code = """ - def main(number,text): - if number > 0: - return text - return number + 10 - """ - - let utility = try await ModelProvider().createUtilityModel( - name: "Functional test", - code: code, - inputs: [ - .number(name: "number", description: "number to be used"), - .text(name: "text", description: "text to be used") - ], - description: "Test Utility" - ) - - // Test positive number case - should return text - let response1 = try await utility.run(["number": "10", "text": "Hello World"]) - XCTAssertEqual(response1.output as? String, "Hello World") - - utility.code = """ - def main(text): - return text - """ - - utility.inputs = [ - .init(name: "text", description: "text to be used") - ] - - try await utility.update() - - let response2 = try await utility.run(["text": "Hello World"]) - XCTAssertEqual(response2.output, "Hello World") - } - - func testDeleteTool() async throws { - let code = """ - def main(number,text): - if number > 0: - return text - return number + 10 - """ - - let utility = try await ModelProvider().createUtilityModel( - name: "Functional test", - code: code, - inputs: [ - .number(name: "number", description: "number to be used"), - .text(name: "text", description: "text to be used") - ], - description: "Test Utility" - ) - - try await utility.delete() - - do { - _ = try await ModelProvider().get(utility.id) - XCTFail("Expected error to be thrown") - } catch { - // Expected error - } - } - -} diff --git a/Tests/aiXplainKitTests/Functional/Modules/PipelineFunctionalTests.swift b/Tests/aiXplainKitTests/Functional/Modules/PipelineFunctionalTests.swift deleted file mode 100644 index d59032a..0000000 --- a/Tests/aiXplainKitTests/Functional/Modules/PipelineFunctionalTests.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// File.swift -// -// -// Created by Joao Pedro Monteiro Maia on 27/03/24. -// - -import Foundation -import XCTest -@testable import aiXplainKit - -final class PipelineFunctionalTests: XCTestCase { - - let textToTextPipelineID: String = "676416f4f84918acb4ab7001" - let multipleTextToTextPipelineID: String = "" - let imageToTextPipelineID: String = "" - let audioToTextPipelienID: String = "" - let multipleInputPipelineID: String = "67641849485ec8ef69777288" - - var testRunningCosts: Float = 0 - - override func setUp() async throws { -// AiXplainKit.shared.logLevel = .error - AiXplainKit.shared.keyManager.reload() - } - - override func tearDown() { - print("Cost until now for running the functional tests: \(testRunningCosts)") - } - - func testTextToTextPipeline() async { - let pipeline = try! await PipelineProvider().get(textToTextPipelineID) - - XCTAssert(pipeline.id == textToTextPipelineID) - XCTAssert(pipeline.inputNodes.count == 1) - XCTAssert(pipeline.inputNodes.first?.dataType.first == "text") - XCTAssert(pipeline.outputNodes.count == 1) - - let pipelineOutput = try! await pipeline.run("The primary duty of an exception handler is to get the error out of the lap of the programmer and into the surprised face of the user.") - - XCTAssertEqual(pipelineOutput.creditsUsed, 0) - - if let json = try? JSONSerialization.jsonObject(with: pipelineOutput.rawData, options: []) as? [String: Any] { - - if let dataArray = json["data"] as? [[String: Any]], - let dataObject = dataArray.first, - let segments = dataObject["segments"] as? [[String: Any]], - let segment = segments.first, - let details = segment["details"] as? [String: Any], - let rawData = details["rawData"] as? [String: Any], - let data = rawData["data"] as? [String: Any], - let translations = data["translations"] as? [[String: String]], - let translation = translations.first { - - if let translatedText = translation["translatedText"] { - XCTAssertEqual(translatedText, "La tâche principale d'un gestionnaire d'exceptions est d'extraire l'erreur du programmeur et de la faire apparaître sur le visage surpris de l'utilisateur.") - } - } - } - } - - func testmultipleInputPipeline() async { - let pipeline = try! await PipelineProvider().get(multipleInputPipelineID) - - let marshallPlanURL: URL = URL(string: "https://upload.wikimedia.org/wikipedia/commons/7/75/Marshall_Plan_Speech.wav")! - // Audio file from: George C. Marshall, Public domain, via Wikimedia Commons - - let input: [String: PipelineInput] = ["Voice": marshallPlanURL, "Text": "In the week before their departure to Arrakis, when all the final scurrying about had reached a nearly unbearable frenzy, an old crone came to visit the mother of the boy, Paul."] - - let pipelineOutput = try! await pipeline.run(input) - XCTAssertNotNil(pipelineOutput.rawData) - - await self.withTempFile(from: marshallPlanURL) { localURL in - let input: [String: PipelineInput] = ["Voice": localURL, "Text": "In the week before their departure to Arrakis, when all the final scurrying about had reached a nearly unbearable frenzy, an old crone came to visit the mother of the boy, Paul."] - - let pipelineOutput = try! await pipeline.run(input) - XCTAssertNotNil(pipelineOutput.rawData) - } - - } -} diff --git a/Tests/aiXplainKitTests/Functional/Provider/AgentsProviderFunctionalTests.swift b/Tests/aiXplainKitTests/Functional/Provider/AgentsProviderFunctionalTests.swift deleted file mode 100644 index a2928aa..0000000 --- a/Tests/aiXplainKitTests/Functional/Provider/AgentsProviderFunctionalTests.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// AgentsProviderFunctionalTests.swift -// aiXplainKit -// -// Created by Joao Maia on 22/11/24. -// - - -import XCTest -import aiXplainKit - - -final class AgentsProviderFunctionalTests: XCTestCase { - - func testLoadAgent() async throws{ - let agent = try await AgentProvider().get("673deab41fafaeceee8829c8") - XCTAssertEqual(agent.id, "673deab41fafaeceee8829c8") - XCTAssertEqual(agent.name, "Flight v8 Swift SDK") - XCTAssertEqual(agent.status, "draft") - XCTAssertEqual(agent.teamId, 1) - XCTAssertEqual(agent.description, "This is an agent about Flights") - XCTAssertEqual(agent.llmId, "6646261c6eb563165658bbb1") - XCTAssertEqual(agent.createdAt.timeIntervalSinceReferenceDate, 753803828.51) - XCTAssertEqual(agent.updatedAt.timeIntervalSinceReferenceDate, 753803828.51) - } - - func testLoadTeamOfAgents() async throws{ - let agent = try await AgentProvider().get("673e0fda8b9e53f626c8fe1d") - XCTAssertEqual(agent.id, "673e0fda8b9e53f626c8fe1d") - XCTAssertEqual(agent.name, "Team of Agents for Text Audio and Image Processing test in SwiftSDK") - XCTAssertEqual(agent.status, "draft") - XCTAssertEqual(agent.teamId, 1) - XCTAssertEqual(agent.description, "") - XCTAssertEqual(agent.llmId, "6646261c6eb563165658bbb1") - XCTAssertEqual(agent.createdAt.timeIntervalSinceReferenceDate, 753813338.1800001) - XCTAssertEqual(agent.updatedAt.timeIntervalSinceReferenceDate, 753813338.1800001) - } - -} diff --git a/Tests/aiXplainKitTests/Functional/Provider/ModelProviderFunctionalTests.swift b/Tests/aiXplainKitTests/Functional/Provider/ModelProviderFunctionalTests.swift deleted file mode 100644 index 7a37dd4..0000000 --- a/Tests/aiXplainKitTests/Functional/Provider/ModelProviderFunctionalTests.swift +++ /dev/null @@ -1,47 +0,0 @@ -// ModelProviderFunctionalTests.swift -// Created by Joao Pedro Monteiro Maia on 15/03/24. - -import XCTest -import aiXplainKit - -// IMPORTANT: THESE TESTS WILL COST CREDITS FROM YOUR ACCOUNT - -final class ModelProviderFunctionalTests: XCTestCase { - - let llamaModelIdentifier = "6543cb991f695e72028e9428" - let msftEnglishArabicModelIdentifier = "60ddefca8d38c51c58860131" - let aiXplainEnglishSpeechRecognitionModelIdentifier = "621cf3fa6442ef511d2830af" - let aiXplainArabicSpeechSynthesisModelIdentifier = "6171ef97159531495cadef56" - - var testRunningCosts: Float = 0 - - override func setUp() async throws { -// AiXplainKit.shared.logLevel = .error - } - - override func tearDown() { - print("Cost until now for running the functional tests: \(testRunningCosts)") - } - - // MARK: Text-to-Text - - func testGetTextToTextOnPrem() async throws { - let llamaModel = try await ModelProvider().get(llamaModelIdentifier) - XCTAssertEqual(llamaModel.id, llamaModelIdentifier) - XCTAssertEqual(llamaModel.name, "Llama 2 7B") - XCTAssertEqual(llamaModel.version, "llama-2-7b-hf") - XCTAssertEqual(llamaModel.pricing.price, 5e-06) - } - - func testPerformanceTextToTextOnPrem() async throws { - self.measure { - let exp = expectation(description: "Finished") - Task { - _ = try await ModelProvider().get(llamaModelIdentifier) - exp.fulfill() - } - wait(for: [exp], timeout: 200.0) - } - } - -} diff --git a/Tests/aiXplainKitTests/Unit/Agents/AgentTests.swift b/Tests/aiXplainKitTests/Unit/Agents/AgentTests.swift new file mode 100644 index 0000000..c723b99 --- /dev/null +++ b/Tests/aiXplainKitTests/Unit/Agents/AgentTests.swift @@ -0,0 +1,193 @@ +import XCTest +@testable import aiXplainKit + +final class AgentTests: XCTestCase { + + func test_agent_resourcePath() { + XCTAssertEqual(Agent.resourcePath, "v2/agents") + } + + func test_agent_from_dict() throws { + let aix = try Aixplain(apiKey: "test-key") + let dict: [String: Any] = [ + "id": "agent-123", + "name": "My Agent", + "description": "A helpful agent", + "status": "onboarded", + "instructions": "You are a helpful assistant", + "teamId": 42, + "model": ["id": "llm-abc"], + "outputFormat": "text", + "maxIterations": 10, + "maxTokens": 4096 + ] + + let agent = try Agent.from(dict: dict, context: aix) + XCTAssertEqual(agent.id, "agent-123") + XCTAssertEqual(agent.name, "My Agent") + XCTAssertEqual(agent.status, .onboarded) + XCTAssertEqual(agent.instructions, "You are a helpful assistant") + XCTAssertEqual(agent.llmId, "llm-abc") + XCTAssertEqual(agent.outputFormat, .text) + XCTAssertEqual(agent.maxIterations, 10) + XCTAssertEqual(agent.maxTokens, 4096) + XCTAssertEqual(agent.teamId, 42) + } + + func test_agent_isTeamAgent() { + let agent = Agent(name: "Solo") + XCTAssertFalse(agent.isTeamAgent) + + let subAgent = Agent(id: "sub1", name: "Sub") + let teamAgent = Agent(name: "Team") + teamAgent.subagents = [subAgent] + XCTAssertTrue(teamAgent.isTeamAgent) + } + + func test_teamAgent_typealias() { + let agent: TeamAgent = Agent(name: "Team via alias") + XCTAssertTrue(agent is Agent) + } + + func test_agent_buildSavePayload() throws { + let agent = Agent(name: "Test", instructions: "Be helpful", llmId: "llm-1") + let payload = try agent.buildSavePayload() + + XCTAssertEqual(payload["name"] as? String, "Test") + XCTAssertEqual(payload["instructions"] as? String, "Be helpful") + XCTAssertEqual((payload["model"] as? [String: Any])?["id"] as? String, "llm-1") + XCTAssertEqual(payload["status"] as? String, "draft") + XCTAssertNotNil(payload["tools"]) + } + + func test_agent_buildRunPayload() throws { + let agent = Agent(id: "agent-1", name: "Test") + agent.outputFormat = .json + + let payload = try agent.buildRunPayload(query: "Hello", sessionId: "sess-1") + XCTAssertEqual(payload["id"] as? String, "agent-1") + XCTAssertEqual(payload["sessionId"] as? String, "sess-1") + XCTAssertTrue(payload["runResponseGeneration"] as? Bool ?? false) + + let execParams = payload["executionParams"] as? [String: Any] + XCTAssertEqual(execParams?["outputFormat"] as? String, "json") + } + + func test_agent_clone() throws { + let aix = try Aixplain(apiKey: "test-key") + let agent = Agent(id: "original", name: "Original", context: aix) + agent.instructions = "Original instructions" + agent.llmId = "llm-custom" + + let cloned = agent.clone(name: "Cloned") + XCTAssertNil(cloned.id) + XCTAssertEqual(cloned.name, "Cloned") + XCTAssertEqual(cloned.instructions, "Original instructions") + XCTAssertEqual(cloned.llmId, "llm-custom") + XCTAssertEqual(cloned.status, .draft) + XCTAssertNotNil(cloned.context) + } +} + +final class ConversationMessageTests: XCTestCase { + + func test_validateHistory_valid() throws { + let history = [ + ConversationMessage(role: .user, content: "Hello"), + ConversationMessage(role: .assistant, content: "Hi there!") + ] + XCTAssertNoThrow(try ConversationMessage.validateHistory(history)) + } + + func test_validateHistory_emptyContent_throws() { + let history = [ + ConversationMessage(role: .user, content: "") + ] + XCTAssertThrowsError(try ConversationMessage.validateHistory(history)) + } + + func test_validateHistory_emptyList_passes() throws { + XCTAssertNoThrow(try ConversationMessage.validateHistory([])) + } + + func test_message_codable() throws { + let msg = ConversationMessage(role: .user, content: "test") + let data = try JSONEncoder().encode(msg) + let decoded = try JSONDecoder().decode(ConversationMessage.self, from: data) + XCTAssertEqual(decoded.role, .user) + XCTAssertEqual(decoded.content, "test") + } +} + +final class AgentRunResultTests: XCTestCase { + + func test_from_completed() { + let dict: [String: Any] = [ + "status": "SUCCESS", + "completed": true, + "data": [ + "input": "Hello", + "output": "Hi there!", + "steps": [] as [Any], + "sessionId": "sess-abc" + ] as [String: Any], + "sessionId": "sess-abc", + "usedCredits": 0.002, + "runTime": 2.5, + "requestId": "req-xyz" + ] + let result = AgentRunResult.from(dict) + XCTAssertEqual(result.status, "SUCCESS") + XCTAssertTrue(result.completed) + XCTAssertEqual(result.data?.output, "Hi there!") + XCTAssertEqual(result.data?.sessionId, "sess-abc") + XCTAssertEqual(result.sessionId, "sess-abc") + XCTAssertEqual(result.usedCredits, 0.002) + XCTAssertEqual(result.runTime, 2.5) + XCTAssertEqual(result.requestId, "req-xyz") + } + + func test_from_inProgress() { + let dict: [String: Any] = [ + "status": "IN_PROGRESS", + "completed": false, + "data": "https://polling-url.com/123" + ] + let result = AgentRunResult.from(dict) + XCTAssertFalse(result.completed) + XCTAssertEqual(result.url, "https://polling-url.com/123") + } +} + +final class AgentTaskTests: XCTestCase { + + func test_task_codable() throws { + let task = AgentTask( + name: "research", + instructions: "Find information", + expectedOutput: "A summary", + dependencies: ["planning"] + ) + let data = try JSONEncoder().encode(task) + let decoded = try JSONDecoder().decode(AgentTask.self, from: data) + XCTAssertEqual(decoded.name, "research") + XCTAssertEqual(decoded.instructions, "Find information") + XCTAssertEqual(decoded.dependencies, ["planning"]) + } + + func test_task_codingKeys_maps_description() throws { + let json = #"{"name": "task1", "description": "Do something", "expectedOutput": "result", "dependencies": []}"# + let data = json.data(using: .utf8)! + let task = try JSONDecoder().decode(AgentTask.self, from: data) + XCTAssertEqual(task.instructions, "Do something") + } +} + +final class OutputFormatTests: XCTestCase { + + func test_allFormats() { + XCTAssertEqual(OutputFormat.markdown.rawValue, "markdown") + XCTAssertEqual(OutputFormat.text.rawValue, "text") + XCTAssertEqual(OutputFormat.json.rawValue, "json") + } +} diff --git a/Tests/aiXplainKitTests/Unit/Auth/CredentialTests.swift b/Tests/aiXplainKitTests/Unit/Auth/CredentialTests.swift new file mode 100644 index 0000000..74af615 --- /dev/null +++ b/Tests/aiXplainKitTests/Unit/Auth/CredentialTests.swift @@ -0,0 +1,136 @@ +import XCTest +@testable import aiXplainKit + +final class CredentialTests: XCTestCase { + + // MARK: - Credential.resolve (explicit key) + + func test_resolve_explicitKey_returnsTeamKey() throws { + let cred = try Credential.resolve(apiKey: "abc123") + XCTAssertEqual(cred.scheme, .teamKey("abc123")) + } + + func test_resolve_explicitKey_ignoresEnvironment() throws { + let env = ["TEAM_API_KEY": "env-key", "AIXPLAIN_API_KEY": "aix-key"] + let cred = try Credential.resolve(apiKey: "explicit", environment: env) + XCTAssertEqual(cred.scheme, .teamKey("explicit")) + } + + // MARK: - Credential.resolve (environment) + + func test_resolve_teamKeyFromEnv() throws { + let env = ["TEAM_API_KEY": "team-env-key"] + let cred = try Credential.resolve(environment: env) + XCTAssertEqual(cred.scheme, .teamKey("team-env-key")) + } + + func test_resolve_aixplainKeyFromEnv() throws { + let env = ["AIXPLAIN_API_KEY": "aix-env-key"] + let cred = try Credential.resolve(environment: env) + XCTAssertEqual(cred.scheme, .aixplainKey("aix-env-key")) + } + + func test_resolve_teamKeyTakesPrecedenceOverAixplainKey() throws { + let env = ["TEAM_API_KEY": "team", "AIXPLAIN_API_KEY": "aix"] + let cred = try Credential.resolve(environment: env) + XCTAssertEqual(cred.scheme, .teamKey("team")) + } + + func test_resolve_noKey_throwsNoCredentialFound() { + XCTAssertThrowsError(try Credential.resolve(environment: [:])) { error in + XCTAssertEqual(error as? AuthError, .noCredentialFound) + } + } + + func test_resolve_emptyExplicitKey_fallsToEnv() throws { + let env = ["TEAM_API_KEY": "fallback"] + let cred = try Credential.resolve(apiKey: "", environment: env) + XCTAssertEqual(cred.scheme, .teamKey("fallback")) + } + + func test_resolve_emptyEnvKeys_throwsNoCredentialFound() { + let env = ["TEAM_API_KEY": "", "AIXPLAIN_API_KEY": ""] + XCTAssertThrowsError(try Credential.resolve(environment: env)) { error in + XCTAssertEqual(error as? AuthError, .noCredentialFound) + } + } + + // MARK: - Credential init validation + + func test_init_emptyKey_throws() { + XCTAssertThrowsError(try Credential(scheme: .teamKey(""))) { error in + XCTAssertEqual(error as? AuthError, .emptyKey) + } + } + + func test_init_whitespaceOnlyKey_throws() { + XCTAssertThrowsError(try Credential(scheme: .teamKey(" "))) { error in + XCTAssertEqual(error as? AuthError, .emptyKey) + } + } + + func test_init_validKey_succeeds() throws { + let cred = try Credential(scheme: .teamKey("valid-key")) + XCTAssertEqual(cred.scheme, .teamKey("valid-key")) + } + + // MARK: - authHeaders + + func test_teamKey_producesCorrectHeaders() throws { + let cred = try Credential(scheme: .teamKey("my-team-key")) + let headers = cred.authHeaders() + XCTAssertEqual(headers["x-api-key"], "my-team-key") + XCTAssertNil(headers["x-aixplain-key"]) + XCTAssertEqual(headers["Content-Type"], "application/json") + } + + func test_aixplainKey_producesCorrectHeaders() throws { + let cred = try Credential(scheme: .aixplainKey("my-aix-key")) + let headers = cred.authHeaders() + XCTAssertEqual(headers["x-aixplain-key"], "my-aix-key") + XCTAssertNil(headers["x-api-key"]) + XCTAssertEqual(headers["Content-Type"], "application/json") + } + + func test_headers_alwaysIncludeContentType() throws { + let cred = try Credential(scheme: .teamKey("key")) + let headers = cred.authHeaders() + XCTAssertEqual(headers["Content-Type"], "application/json") + } + + // MARK: - Codable + + func test_credential_roundTrips_via_codable() throws { + let original = try Credential(scheme: .teamKey("codable-test")) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(Credential.self, from: data) + XCTAssertEqual(original, decoded) + } + + func test_credential_aixplainKey_roundTrips_via_codable() throws { + let original = try Credential(scheme: .aixplainKey("aix-codable")) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(Credential.self, from: data) + XCTAssertEqual(original, decoded) + } + + // MARK: - Equatable + + func test_sameCredentials_areEqual() throws { + let a = try Credential(scheme: .teamKey("same")) + let b = try Credential(scheme: .teamKey("same")) + XCTAssertEqual(a, b) + } + + func test_differentCredentials_areNotEqual() throws { + let a = try Credential(scheme: .teamKey("one")) + let b = try Credential(scheme: .teamKey("two")) + XCTAssertNotEqual(a, b) + } + + func test_differentSchemes_areNotEqual() throws { + let a = try Credential(scheme: .teamKey("key")) + let b = try Credential(scheme: .aixplainKey("key")) + XCTAssertNotEqual(a, b) + } +} diff --git a/Tests/aiXplainKitTests/Unit/Client/ClientTests.swift b/Tests/aiXplainKitTests/Unit/Client/ClientTests.swift new file mode 100644 index 0000000..fd54742 --- /dev/null +++ b/Tests/aiXplainKitTests/Unit/Client/ClientTests.swift @@ -0,0 +1,188 @@ +import XCTest +@testable import aiXplainKit + +final class ClientConfigurationTests: XCTestCase { + + func test_defaultConfiguration_hasCorrectURLs() { + let config = ClientConfiguration.default + XCTAssertEqual(config.backendURL.absoluteString, "https://platform-api.aixplain.com") + XCTAssertEqual(config.modelsRunURL.absoluteString, "https://models.aixplain.com/api/v2/execute") + } + + func test_defaultConfiguration_hasCorrectTimeout() { + let config = ClientConfiguration.default + XCTAssertEqual(config.timeoutInterval, 30) + } + + func test_defaultConfiguration_hasCorrectRetryPolicy() { + let config = ClientConfiguration.default + XCTAssertEqual(config.retryPolicy.maxRetries, 5) + XCTAssertEqual(config.retryPolicy.backoffFactor, 0.1) + XCTAssertEqual(config.retryPolicy.retryableStatusCodes, [500, 502, 503, 504]) + } + + func test_customConfiguration() { + let config = ClientConfiguration( + backendURL: URL(string: "https://custom.api.com")!, + modelsRunURL: URL(string: "https://custom.models.com/v2")!, + timeoutInterval: 60, + retryPolicy: RetryPolicy(maxRetries: 3), + userAgent: "TestAgent/1.0" + ) + XCTAssertEqual(config.backendURL.absoluteString, "https://custom.api.com") + XCTAssertEqual(config.timeoutInterval, 60) + XCTAssertEqual(config.retryPolicy.maxRetries, 3) + XCTAssertEqual(config.userAgent, "TestAgent/1.0") + } +} + +final class RetryPolicyTests: XCTestCase { + + func test_defaultPolicy() { + let policy = RetryPolicy.default + XCTAssertEqual(policy.maxRetries, 5) + XCTAssertEqual(policy.backoffFactor, 0.1) + XCTAssertEqual(policy.retryableStatusCodes, [500, 502, 503, 504]) + } + + func test_delay_exponentialBackoff() { + let policy = RetryPolicy(backoffFactor: 0.1) + XCTAssertEqual(policy.delay(for: 0), 0.1, accuracy: 0.001) + XCTAssertEqual(policy.delay(for: 1), 0.2, accuracy: 0.001) + XCTAssertEqual(policy.delay(for: 2), 0.4, accuracy: 0.001) + XCTAssertEqual(policy.delay(for: 3), 0.8, accuracy: 0.001) + } + + func test_delay_customBackoff() { + let policy = RetryPolicy(backoffFactor: 1.0) + XCTAssertEqual(policy.delay(for: 0), 1.0, accuracy: 0.001) + XCTAssertEqual(policy.delay(for: 1), 2.0, accuracy: 0.001) + XCTAssertEqual(policy.delay(for: 2), 4.0, accuracy: 0.001) + } +} + +final class HTTPMethodTests: XCTestCase { + + func test_retryable_getAndPost() { + XCTAssertTrue(HTTPMethod.get.isRetryable) + XCTAssertTrue(HTTPMethod.post.isRetryable) + } + + func test_notRetryable_putAndDelete() { + XCTAssertFalse(HTTPMethod.put.isRetryable) + XCTAssertFalse(HTTPMethod.delete.isRetryable) + } + + func test_rawValues() { + XCTAssertEqual(HTTPMethod.get.rawValue, "GET") + XCTAssertEqual(HTTPMethod.post.rawValue, "POST") + XCTAssertEqual(HTTPMethod.put.rawValue, "PUT") + XCTAssertEqual(HTTPMethod.delete.rawValue, "DELETE") + } +} + +final class ResponseTests: XCTestCase { + + private func makeResponse(json: String, statusCode: Int) -> Response { + let data = json.data(using: .utf8)! + let url = URL(string: "https://test.com")! + let httpResponse = HTTPURLResponse(url: url, statusCode: statusCode, httpVersion: nil, headerFields: nil)! + return Response(data: data, httpResponse: httpResponse) + } + + func test_isSuccess_200() { + let response = makeResponse(json: "{}", statusCode: 200) + XCTAssertTrue(response.isSuccess) + } + + func test_isSuccess_201() { + let response = makeResponse(json: "{}", statusCode: 201) + XCTAssertTrue(response.isSuccess) + } + + func test_isNotSuccess_404() { + let response = makeResponse(json: "{}", statusCode: 404) + XCTAssertFalse(response.isSuccess) + } + + func test_isNotSuccess_500() { + let response = makeResponse(json: "{}", statusCode: 500) + XCTAssertFalse(response.isSuccess) + } + + func test_json_parsesObject() throws { + let response = makeResponse(json: #"{"key": "value"}"#, statusCode: 200) + let dict = try response.json() + XCTAssertEqual(dict["key"] as? String, "value") + } + + func test_json_throwsOnArray() { + let response = makeResponse(json: "[1,2,3]", statusCode: 200) + XCTAssertThrowsError(try response.json()) + } + + func test_decode_success() throws { + struct TestModel: Decodable { let name: String } + let response = makeResponse(json: #"{"name": "test"}"#, statusCode: 200) + let model = try response.decode(TestModel.self) + XCTAssertEqual(model.name, "test") + } + + func test_decode_failure() { + struct TestModel: Decodable { let name: String } + let response = makeResponse(json: #"{"wrong": "field"}"#, statusCode: 200) + XCTAssertThrowsError(try response.decode(TestModel.self)) + } +} + +final class AixplainInitTests: XCTestCase { + + func test_init_withExplicitKey() throws { + let aix = try Aixplain(apiKey: "test-key-12345") + XCTAssertEqual(aix.apiKey, "test-key-12345") + } + + func test_init_defaultURLs() throws { + let aix = try Aixplain(apiKey: "test-key") + XCTAssertTrue(aix.backendURL.absoluteString.contains("platform-api.aixplain.com")) + XCTAssertTrue(aix.modelURL.absoluteString.contains("api/v2/execute")) + } + + func test_init_customURLs() throws { + let aix = try Aixplain( + apiKey: "test-key", + backendURL: URL(string: "https://custom.api.com"), + modelURL: URL(string: "https://custom.models.com/v2") + ) + XCTAssertTrue(aix.backendURL.absoluteString.contains("custom.api.com")) + XCTAssertTrue(aix.modelURL.absoluteString.contains("custom.models.com")) + } + + func test_init_noKey_usesEnvironment() throws { + // When TEAM_API_KEY is set in env, init succeeds without explicit key. + // When no env key, it throws. Both are valid. + if ProcessInfo.processInfo.environment["TEAM_API_KEY"] != nil + || ProcessInfo.processInfo.environment["AIXPLAIN_API_KEY"] != nil { + let aix = try Aixplain() + XCTAssertFalse(aix.apiKey.isEmpty) + } else { + XCTAssertThrowsError(try Aixplain()) + } + } +} + +final class AixplainClientTests: XCTestCase { + + func test_client_createdWithCredential() throws { + let cred = try Credential(scheme: .teamKey("test-key")) + let client = AixplainClient(credential: cred) + XCTAssertEqual(client.credential, cred) + } + + func test_client_customConfiguration() throws { + let cred = try Credential(scheme: .teamKey("test-key")) + let config = ClientConfiguration(timeoutInterval: 60) + let client = AixplainClient(credential: cred, configuration: config) + XCTAssertEqual(client.configuration.timeoutInterval, 60) + } +} diff --git a/Tests/aiXplainKitTests/Unit/Errors/ErrorTests.swift b/Tests/aiXplainKitTests/Unit/Errors/ErrorTests.swift new file mode 100644 index 0000000..624e2b5 --- /dev/null +++ b/Tests/aiXplainKitTests/Unit/Errors/ErrorTests.swift @@ -0,0 +1,181 @@ +import XCTest +@testable import aiXplainKit + +final class ErrorTests: XCTestCase { + + // MARK: - AixplainError pattern matching + + func test_authError_patternMatches() { + let error: AixplainError = .auth(.noCredentialFound) + if case .auth(let authErr) = error { + XCTAssertEqual(authErr, .noCredentialFound) + } else { + XCTFail("Expected .auth case") + } + } + + func test_apiError_patternMatches() { + let error: AixplainError = .api(APIError(message: "Not found", statusCode: 404)) + if case .api(let apiErr) = error { + XCTAssertEqual(apiErr.statusCode, 404) + XCTAssertEqual(apiErr.message, "Not found") + } else { + XCTFail("Expected .api case") + } + } + + func test_validationError_patternMatches() { + let error: AixplainError = .validation(ValidationError("bad input")) + if case .validation(let valErr) = error { + XCTAssertEqual(valErr.message, "bad input") + } else { + XCTFail("Expected .validation case") + } + } + + func test_timeoutError_patternMatches() { + let error: AixplainError = .timeout(TimeoutError("timed out", pollingURL: "https://poll.url", timeout: 300)) + if case .timeout(let toErr) = error { + XCTAssertEqual(toErr.message, "timed out") + XCTAssertEqual(toErr.pollingURL, "https://poll.url") + XCTAssertEqual(toErr.timeout, 300) + } else { + XCTFail("Expected .timeout case") + } + } + + func test_fileUploadError_patternMatches() { + let error: AixplainError = .fileUpload(FileUploadError("too large", fileName: "big.mp4")) + if case .fileUpload(let fuErr) = error { + XCTAssertEqual(fuErr.message, "too large") + XCTAssertEqual(fuErr.fileName, "big.mp4") + } else { + XCTFail("Expected .fileUpload case") + } + } + + func test_resourceError_patternMatches() { + let error: AixplainError = .resource(ResourceError("context missing")) + if case .resource(let resErr) = error { + XCTAssertEqual(resErr.message, "context missing") + } else { + XCTFail("Expected .resource case") + } + } + + // MARK: - userMessage + + func test_userMessage_returnsReadableText() { + let error: AixplainError = .api(APIError(message: "developer detail", error: "User-friendly error")) + XCTAssertEqual(error.userMessage, "User-friendly error") + } + + func test_userMessage_fallsBackToMessage() { + let error: AixplainError = .api(APIError(message: "fallback msg", error: nil)) + XCTAssertEqual(error.userMessage, "fallback msg") + } + + func test_userMessage_auth() { + let error: AixplainError = .auth(.emptyKey) + XCTAssertEqual(error.userMessage, "API key must not be empty.") + } + + // MARK: - APIError factories + + func test_fromFailedOperation_extractsSupplierError() { + let response: [String: Any] = [ + "status": "FAILED", + "supplierError": "Model capacity exceeded", + "statusCode": 500 + ] + let error = APIError.fromFailedOperation(response) + if case .api(let apiErr) = error { + XCTAssertTrue(apiErr.message.contains("Model capacity exceeded")) + XCTAssertEqual(apiErr.statusCode, 500) + XCTAssertEqual(apiErr.error, "Model capacity exceeded") + } else { + XCTFail("Expected .api case") + } + } + + func test_fromFailedOperation_fallbackChain() { + let response: [String: Any] = [ + "error_message": "secondary error", + "statusCode": 422 + ] + let error = APIError.fromFailedOperation(response) + if case .api(let apiErr) = error { + XCTAssertTrue(apiErr.message.contains("secondary error")) + } else { + XCTFail("Expected .api case") + } + } + + func test_fromFailedOperation_ultimateFallback() { + let response: [String: Any] = ["status": "FAILED"] + let error = APIError.fromFailedOperation(response) + if case .api(let apiErr) = error { + XCTAssertTrue(apiErr.message.contains("Operation failed")) + } else { + XCTFail("Expected .api case") + } + } + + func test_fromHTTPResponse_parsesJSON() { + let json = """ + {"message": "Agent not found", "statusCode": 404, "error": "Not Found"} + """.data(using: .utf8)! + + let error = APIError.fromHTTPResponse(data: json, statusCode: 404) + if case .api(let apiErr) = error { + XCTAssertEqual(apiErr.message, "Agent not found") + XCTAssertEqual(apiErr.statusCode, 404) + XCTAssertEqual(apiErr.error, "Not Found") + } else { + XCTFail("Expected .api case") + } + } + + func test_fromHTTPResponse_handlesNonJSON() { + let body = "Internal Server Error".data(using: .utf8)! + let error = APIError.fromHTTPResponse(data: body, statusCode: 500) + if case .api(let apiErr) = error { + XCTAssertEqual(apiErr.statusCode, 500) + XCTAssertEqual(apiErr.message, "Internal Server Error") + } else { + XCTFail("Expected .api case") + } + } + + func test_fromHTTPResponse_extractsRequestId() { + let json = """ + {"message": "error", "requestId": "req-abc-123"} + """.data(using: .utf8)! + + let error = APIError.fromHTTPResponse(data: json, statusCode: 400) + if case .api(let apiErr) = error { + XCTAssertEqual(apiErr.requestId, "req-abc-123") + } else { + XCTFail("Expected .api case") + } + } + + // MARK: - APIError properties + + func test_apiError_requestId() { + let err = APIError(message: "test", requestId: "rid-42") + XCTAssertEqual(err.requestId, "rid-42") + } + + func test_apiError_responseData() { + let data: [String: Any] = ["key": "value"] + let err = APIError(message: "test", responseData: data) + XCTAssertEqual(err.responseData?["key"] as? String, "value") + } + + func test_apiError_description() { + let err = APIError(message: "test error", statusCode: 500, requestId: "rid") + XCTAssertTrue(err.description.contains("500")) + XCTAssertTrue(err.description.contains("rid")) + } +} diff --git a/Tests/aiXplainKitTests/Unit/Index/IndexTests.swift b/Tests/aiXplainKitTests/Unit/Index/IndexTests.swift new file mode 100644 index 0000000..764c79e --- /dev/null +++ b/Tests/aiXplainKitTests/Unit/Index/IndexTests.swift @@ -0,0 +1,154 @@ +import XCTest +@testable import aiXplainKit + +final class RecordTests: XCTestCase { + + func test_textRecord() { + let record = Record(text: "Hello world", attributes: ["lang": "en"], id: "r1") + XCTAssertEqual(record.id, "r1") + XCTAssertEqual(record.dataType, .text) + XCTAssertEqual(record.value, "Hello world") + XCTAssertEqual(record.attributes["lang"], "en") + XCTAssertNil(record.uri) + } + + func test_imageRecord() { + let url = URL(string: "https://example.com/image.png")! + let record = Record(imageURL: url, id: "r2") + XCTAssertEqual(record.dataType, .image) + XCTAssertEqual(record.uri, "https://example.com/image.png") + XCTAssertEqual(record.value, "") + } + + func test_record_codable_roundTrip() throws { + let original = Record(text: "Test content", attributes: ["key": "value"], id: "r3") + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(Record.self, from: data) + XCTAssertEqual(decoded.id, "r3") + XCTAssertEqual(decoded.value, "Test content") + XCTAssertEqual(decoded.dataType, .text) + XCTAssertEqual(decoded.attributes["key"], "value") + } + + func test_record_toDictionary() { + let record = Record(text: "Hello", attributes: ["a": "b"], id: "r4") + let dict = record.toDictionary() + XCTAssertEqual(dict["data"] as? String, "Hello") + XCTAssertEqual(dict["dataType"] as? String, "text") + XCTAssertEqual(dict["document_id"] as? String, "r4") + } +} + +final class IndexFilterTests: XCTestCase { + + func test_filter_toDict() { + let filter = IndexFilter(fieldName: "author", operation: .equals("Woolf")) + let dict = filter.toDict() + XCTAssertEqual(dict["field"], "author") + XCTAssertEqual(dict["value"], "Woolf") + XCTAssertEqual(dict["operator"], "==") + } + + func test_filter_subscript() { + let filter = IndexFilter["year", .greaterThan("1920")] + XCTAssertEqual(filter.fieldName, "year") + XCTAssertEqual(filter.operation.operatorString, ">") + } + + func test_filter_allOperators() { + let ops: [(FieldOperator, String)] = [ + (.equals("v"), "=="), + (.notEquals("v"), "!="), + (.contains("v"), "in"), + (.notContains("v"), "not in"), + (.greaterThan("v"), ">"), + (.lessThan("v"), "<"), + (.greaterThanOrEquals("v"), ">="), + (.lessThanOrEquals("v"), "<="), + ] + for (op, expected) in ops { + XCTAssertEqual(op.operatorString, expected) + } + } + + func test_filter_builder() { + let filters = IndexFilter.builder() + .where("author", .equals("Woolf")) + .where("year", .greaterThan("1920")) + .build() + XCTAssertEqual(filters.count, 2) + XCTAssertEqual(filters[0].fieldName, "author") + XCTAssertEqual(filters[1].fieldName, "year") + } +} + +final class EmbeddingModelTests: XCTestCase { + + func test_predefinedModels_haveIds() { + XCTAssertFalse(EmbeddingModel.openaiAda002.id.isEmpty) + XCTAssertFalse(EmbeddingModel.bgeM3.id.isEmpty) + XCTAssertFalse(EmbeddingModel.snowflakeArcticEmbedMLong.id.isEmpty) + } + + func test_customModel() { + let custom = EmbeddingModel.custom(id: "my-custom-model") + XCTAssertEqual(custom.id, "my-custom-model") + } + + func test_equality() { + XCTAssertEqual(EmbeddingModel.openaiAda002, EmbeddingModel.openaiAda002) + XCTAssertNotEqual(EmbeddingModel.openaiAda002, EmbeddingModel.bgeM3) + } +} + +final class IndexEngineTests: XCTestCase { + + func test_air_hasId() { + XCTAssertFalse(IndexEngine.air.id.isEmpty) + } + + func test_custom() { + XCTAssertEqual(IndexEngine.custom(id: "abc").id, "abc") + } +} + +final class SearchHitTests: XCTestCase { + + func test_from_dict() { + let dict: [String: Any] = [ + "document_id": "doc1", + "score": 0.95, + "data": "Hello world", + "attributes": ["lang": "en"] + ] + let hit = SearchHit.from(dict) + XCTAssertNotNil(hit) + XCTAssertEqual(hit?.documentId, "doc1") + XCTAssertEqual(hit?.score, 0.95) + XCTAssertEqual(hit?.data, "Hello world") + } + + func test_from_dict_missingId_returnsNil() { + let dict: [String: Any] = ["score": 0.5] + XCTAssertNil(SearchHit.from(dict)) + } +} + +final class IndexUnitTests: XCTestCase { + + func test_index_init() { + let index = Index(id: "idx-1", name: "Test Index") + XCTAssertEqual(index.id, "idx-1") + XCTAssertEqual(index.name, "Test Index") + } + + func test_index_noContext_throws() async { + let index = Index(id: "idx-1") + do { + _ = try await index.count() + XCTFail("Should throw without context") + } catch { + // Expected + } + } +} diff --git a/Tests/aiXplainKitTests/Unit/MockServices/APIKeyManager+ReloadState.swift b/Tests/aiXplainKitTests/Unit/MockServices/APIKeyManager+ReloadState.swift deleted file mode 100644 index b51ce05..0000000 --- a/Tests/aiXplainKitTests/Unit/MockServices/APIKeyManager+ReloadState.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// File.swift -// -// -// Created by Joao Pedro Monteiro Maia on 27/03/24. -// - -import Foundation -import aiXplainKit - -internal extension APIKeyManager { - func reload() { - self.BACKEND_URL = URL(string: "https://platform-api.aixplain.com") - self.MODELS_RUN_URL = URL(string: "https://models.aixplain.com/api/v1/execute/") - self.TEAM_API_KEY = ProcessInfo.processInfo.environment["TEAM_API_KEY"] - self.AIXPLAIN_API_KEY = ProcessInfo.processInfo.environment["AIXPLAIN_API_KEY"] - self.PIPELINE_API_KEY = ProcessInfo.processInfo.environment["PIPELINE_API_KEY"] - self.MODEL_API_KEY = ProcessInfo.processInfo.environment["MODEL_API_KEY"] - self.HF_TOKEN = ProcessInfo.processInfo.environment["HF_TOKEN"] - - } -} diff --git a/Tests/aiXplainKitTests/Unit/MockServices/MockNetworking.swift b/Tests/aiXplainKitTests/Unit/MockServices/MockNetworking.swift deleted file mode 100644 index a044dca..0000000 --- a/Tests/aiXplainKitTests/Unit/MockServices/MockNetworking.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// MockNetworking.swift -// -// -// Created by Joao Pedro Monteiro Maia on 08/03/24. -// - -import Foundation -@testable import aiXplainKit - -final class MockNetworking: Networking { - - var urlPatterns: [NSRegularExpression: (Data, URLResponse)] = [:] - - override init() { - super.init() - - addPattern("^https://platform-api\\.aixplain\\.com/sdk/models/.*$", - data: ModelProvider_get_MockResponse!, - response: HTTPURLResponse(url: URL(string: "https://platform-api.aixplain.com/sdk/models/example")!, statusCode: 200, httpVersion: nil, headerFields: nil)!) - - addPattern("^https://models\\.aixplain\\.com/api/v1/execute/.*$", - data: model_CreateExecution_MockResponse!, - response: HTTPURLResponse(url: URL(string: "https://models.aixplain.com/api/v1/execute/")!, statusCode: 201, httpVersion: nil, headerFields: nil)!) - - addPattern("^https://models\\.aixplain\\.com/api/v1/data/8d548248-c4b8-4051-b036-ccb0417f9cf1$", - data: model_Polling_MockResponse!, - response: HTTPURLResponse(url: URL(string: "https://models.aixplain.com/api/v1/data/8d548248-c4b8-4051-b036-ccb0417f9cf1$")!, statusCode: 200, httpVersion: nil, headerFields: nil)!) - - } - - public func addPattern(_ pattern: String, data: Data, response: URLResponse) { - guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { - return - } - urlPatterns[regex] = (data, response) - } - - override func get(url: URL, headers: [String: String]) async throws -> (Data, URLResponse) { - if let matchingPattern = urlPatterns.first(where: { regex in - !regex.key.matches(in: url.absoluteString, options: [], range: NSRange(url.absoluteString.startIndex..., in: url.absoluteString)).isEmpty - }) { - return matchingPattern.value - } - return (Data(), URLResponse()) - } - - override func post(url: URL, headers: [String: String], body: Data?) async throws -> (Data, URLResponse) { - if let matchingPattern = urlPatterns.first(where: { regex in - !regex.key.matches(in: url.absoluteString, options: [], range: NSRange(url.absoluteString.startIndex..., in: url.absoluteString)).isEmpty - }) { - return matchingPattern.value - } - return (Data(), URLResponse()) - } - -} diff --git a/Tests/aiXplainKitTests/Unit/MockServices/MockResponses.swift b/Tests/aiXplainKitTests/Unit/MockServices/MockResponses.swift deleted file mode 100644 index 5e8edc8..0000000 --- a/Tests/aiXplainKitTests/Unit/MockServices/MockResponses.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// File.swift -// -// -// Created by Joao Pedro Monteiro Maia on 08/03/24. -// - -import Foundation - -/// ClassName_Method_MockResponse - -var ModelProvider_get_MockResponse = "{\"id\":\"640b517694bf816d35a59125\",\"name\":\"Chat GPT 3.5\",\"serviceName\":\"OpenAI InstructGPT\",\"status\":\"onboarded\",\"hostedBy\":\"OpenAI\",\"developedBy\":\"OpenAI\",\"description\":\"Creates coherent and contextually relevant textual content based on prompts or certain parameters. Useful for chatbots, content creation, and data augmentation.\",\"subscriptions\":[\"PRO\"],\"supplier\":{\"id\":1777,\"name\":\"OpenAI\",\"code\":\"openai\"},\"function\":{\"id\":\"text-generation\",\"name\":\"Text Generation\"},\"pricing\":{\"price\":0.000002,\"unitType\":\"TOKEN\",\"unitTypeScale\":null},\"version\":{\"name\":\"3.5\",\"id\":\"gpt-3.5-turbo\"},\"subscription\":{\"id\":\"6650ad2df641d71a695fdf82\",\"apiKey\":\"YWl4cGxhaW5fYzI5MWQ2ZTc5ODIxYjdiY2E2NmExMTk4ZmQ2MTA5NDdiYWZjZWQ0ZmZiMWFhYTdmZTkxMjNjNmNhNGY1N2U1MjQzMTc4ZWM4NTc4Mzg2YjkwYjRkYzdlNGIxN2Y0Y2U0MmU5YTg0ZDg1NmU2MzEwZmE2YTVhYTA4MzkxY2Y1ZTk=\"},\"functionType\":\"ai\",\"type\":\"regular\",\"createdAt\":\"2024-11-19T14:17:12.424Z\",\"updatedAt\":\"2024-12-17T08:16:18.806Z\",\"params\":[{\"name\":\"text\",\"required\":true,\"isFixed\":false,\"values\":[],\"defaultValues\":[],\"availableOptions\":[],\"dataType\":\"text\",\"dataSubType\":\"json\",\"multipleValues\":true},{\"name\":\"template\",\"required\":false,\"isFixed\":true,\"values\":[{\"value\":\"[{\\\"role\\\": \\\"user\\\", \\\"content\\\": \\\"\\\"}]\",\"label\":\"Template\"}],\"defaultValues\":[],\"availableOptions\":[],\"dataType\":\"text\",\"dataSubType\":\"text\",\"multipleValues\":false},{\"name\":\"prompt\",\"required\":false,\"isFixed\":false,\"values\":[{\"value\":\" \",\"label\":\"Prompt\"}],\"defaultValues\":[],\"availableOptions\":[],\"dataType\":\"text\",\"dataSubType\":\"text\",\"multipleValues\":false},{\"name\":\"context\",\"required\":false,\"isFixed\":false,\"values\":[{\"value\":\" \",\"label\":\"Context\"}],\"defaultValues\":[],\"availableOptions\":[],\"dataType\":\"text\",\"dataSubType\":\"text\",\"multipleValues\":false},{\"name\":\"language\",\"required\":true,\"isFixed\":true,\"values\":[{\"value\":\"en\",\"label\":\"English\"}],\"defaultValues\":[],\"availableOptions\":[],\"dataType\":\"label\",\"dataSubType\":\"label\",\"multipleValues\":false},{\"name\":\"script\",\"required\":false,\"isFixed\":true,\"values\":[],\"defaultValues\":[],\"availableOptions\":[],\"dataType\":\"label\",\"dataSubType\":\"label\",\"multipleValues\":false},{\"name\":\"temperature\",\"required\":false,\"isFixed\":false,\"values\":[],\"defaultValues\":[{\"value\":\"0\",\"label\":\"0\"}],\"availableOptions\":[],\"dataType\":\"text\",\"dataSubType\":\"number\",\"multipleValues\":false},{\"name\":\"max_tokens\",\"required\":false,\"isFixed\":false,\"values\":[],\"defaultValues\":[{\"value\":\"200\",\"label\":\"200\"}],\"availableOptions\":[],\"dataType\":\"text\",\"dataSubType\":\"number\",\"multipleValues\":false},{\"name\":\"history\",\"required\":false,\"isFixed\":false,\"values\":[{\"value\":\" \",\"label\":\"default\"}],\"defaultValues\":[{\"value\":\"\",\"label\":\"default\"}],\"availableOptions\":[],\"dataType\":\"text\",\"dataSubType\":\"text\",\"multipleValues\":false}]}".data(using: .utf8) - -var model_CreateExecution_MockResponse = "{\"completed\":false,\"data\":\"https://models.aixplain.com/api/v1/data/8d548248-c4b8-4051-b036-ccb0417f9cf1\",\"requestId\":\"8d548248-c4b8-4051-b036-ccb0417f9cf1\"}".data(using: .utf8) - -var model_Polling_MockResponse = "{\"completed\":true,\"data\":\"Olá! Como posso ajudar você hoje?\",\"usedCredits\":0.000037999999999999995,\"runTime\":0.645,\"rawData\":{\"id\":\"chatcmpl-91wbcwddVCEPqgvY2iWGivhMppW2u\",\"object\":\"chat.completion\",\"created\":1710250636,\"model\":\"gpt-3.5-turbo-0125\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"Olá! Como posso ajudar você hoje?\"},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":8,\"completion_tokens\":11,\"total_tokens\":19},\"system_fingerprint\":\"fp_4f0b692a78\"}}".data(using: .utf8) diff --git a/Tests/aiXplainKitTests/Unit/Models/ModelTests.swift b/Tests/aiXplainKitTests/Unit/Models/ModelTests.swift new file mode 100644 index 0000000..a7f26da --- /dev/null +++ b/Tests/aiXplainKitTests/Unit/Models/ModelTests.swift @@ -0,0 +1,108 @@ +import XCTest +@testable import aiXplainKit + +final class ModelTests: XCTestCase { + + func test_model_resourcePath() { + XCTAssertEqual(Model.resourcePath, "v2/models") + } + + func test_model_from_dict() throws { + let aix = try Aixplain(apiKey: "test-key") + let dict: [String: Any] = [ + "id": "model-123", + "name": "GPT-4o Mini", + "description": "A small GPT model", + "status": "onboarded", + "host": "openai", + "developer": "OpenAI", + "functionType": "ai", + "type": "model", + "supportsStreaming": true, + "connectionType": ["synchronous", "asynchronous"], + "vendor": ["id": 42, "name": "OpenAI", "code": "openai"], + "function": ["id": "TEXT_GENERATION"], + "version": ["name": "1.0", "id": "v1"], + "pricing": ["price": 0.001, "unitType": "token"] + ] + + let model = try Model.from(dict: dict, context: aix) + XCTAssertEqual(model.id, "model-123") + XCTAssertEqual(model.name, "GPT-4o Mini") + XCTAssertEqual(model.status, .onboarded) + XCTAssertEqual(model.host, "openai") + XCTAssertEqual(model.vendor?.code, "openai") + XCTAssertEqual(model.function, .textGeneration) + XCTAssertEqual(model.version?.id, "v1") + XCTAssertEqual(model.supportsStreaming, true) + XCTAssertFalse(model.isSyncOnly) + XCTAssertTrue(model.isAsyncCapable) + } + + func test_model_syncOnly() throws { + let model = Model(id: "1") + model.connectionType = ["synchronous"] + XCTAssertTrue(model.isSyncOnly) + XCTAssertFalse(model.isAsyncCapable) + } + + func test_model_asyncCapable() throws { + let model = Model(id: "1") + model.connectionType = ["asynchronous"] + XCTAssertFalse(model.isSyncOnly) + XCTAssertTrue(model.isAsyncCapable) + } + + func test_model_noConnectionType_defaultsAsync() { + let model = Model(id: "1") + XCTAssertFalse(model.isSyncOnly) + XCTAssertTrue(model.isAsyncCapable) + } + + func test_model_asAgentTool() throws { + let aix = try Aixplain(apiKey: "test-key") + let dict: [String: Any] = [ + "id": "model-abc", + "name": "Test Model", + "description": "A test model", + "vendor": ["code": "openai"], + "function": ["id": "TEXT_GENERATION"], + "version": ["id": "v2"] + ] + let model = try Model.from(dict: dict, context: aix) + let tool = model.asAgentTool() + + XCTAssertEqual(tool.id, "model-abc") + XCTAssertEqual(tool.name, "Test Model") + XCTAssertEqual(tool.type, .model) + XCTAssertEqual(tool.supplier, "openai") + XCTAssertEqual(tool.function, "TEXT_GENERATION") + XCTAssertEqual(tool.assetId, "model-abc") + XCTAssertNil(tool.actions) + } + + func test_modelResult_from_dict() { + let dict: [String: Any] = [ + "status": "SUCCESS", + "completed": true, + "data": "Hello, world!", + "runTime": 1.5, + "usedCredits": 0.001, + "sessionId": "sess-123", + "requestId": "req-456", + "usage": [ + "prompt_tokens": 10, + "completion_tokens": 20, + "total_tokens": 30 + ] + ] + let result = ModelResult.from(dict) + XCTAssertEqual(result.status, "SUCCESS") + XCTAssertTrue(result.completed) + XCTAssertEqual(result.runTime, 1.5) + XCTAssertEqual(result.usedCredits, 0.001) + XCTAssertEqual(result.sessionId, "sess-123") + XCTAssertEqual(result.requestId, "req-456") + XCTAssertEqual(result.usage?.totalTokens, 30) + } +} diff --git a/Tests/aiXplainKitTests/Unit/Modules/ModelTests.swift b/Tests/aiXplainKitTests/Unit/Modules/ModelTests.swift deleted file mode 100644 index c2e83ae..0000000 --- a/Tests/aiXplainKitTests/Unit/Modules/ModelTests.swift +++ /dev/null @@ -1,117 +0,0 @@ -// -// ModelTestCase.swift -// -// -// Created by Joao Pedro Monteiro Maia on 15/03/24. -// - -import XCTest -@testable import aiXplainKit - -final class ModelTests: XCTestCase { - - let testedModel = Model( - id: "testId", - name: "Test Model", - description: "This is a test model for mocking purposes.", - supplier: Supplier(id: 123, name: "Test Supplier", code: "123"), - version: "1.0.0", - license: nil, - privacy: nil, - pricing: Pricing(price: 1, unitType: nil), - hostedBy: "TestHost", - developedBy: "TestDeveloper", - networking: MockNetworking() - ) - - override func tearDown() { - testedModel.networking = MockNetworking() - AiXplainKit.shared.keyManager.TEAM_API_KEY = nil - - AiXplainKit.shared.keyManager.BACKEND_URL = URL(string: "https://platform-api.aixplain.com") - - AiXplainKit.shared.keyManager.MODELS_RUN_URL = URL(string: "https://models.aixplain.com/api/v1/execute/") - } - - func test_modelRun_Run_sucess() async { - AiXplainKit.shared.keyManager.TEAM_API_KEY = "123" - let modelOutput = try! await testedModel.run("Hello World") - - XCTAssertEqual(modelOutput.output, "Olá! Como posso ajudar você hoje?") - XCTAssertEqual(modelOutput.usedCredits, 3.8e-05) - XCTAssertEqual(modelOutput.runtime, 0.645) - } - - func test_modelRun_Run_MissingModelRunError() async { - AiXplainKit.shared.keyManager.TEAM_API_KEY = "123" - AiXplainKit.shared.keyManager.MODELS_RUN_URL = nil - do { - _ = try await testedModel.run("-") - } catch { - XCTAssertTrue(error as! ModelError == ModelError.missingModelRunURL) - } - } - - func test_modelRun_Run_MissingAPIKeyError() async { - do { - _ = try await testedModel.run("-") - } catch { - XCTAssertTrue(error as! ModelError == ModelError.missingAPIKey) - } - } - - func test_modelRun_Run_InvalidStatusCodeError() async throws { - guard let networking = testedModel.networking as? MockNetworking else { - throw fatalError() - } - - AiXplainKit.shared.keyManager.TEAM_API_KEY = "123" - - networking.addPattern("^https://models\\.aixplain\\.com/api/v1/execute/.*$", - data: model_CreateExecution_MockResponse!, - response: HTTPURLResponse(url: URL(string: "https://models.aixplain.com/api/v1/execute/")!, statusCode: 400, httpVersion: nil, headerFields: nil)!) - - do { - _ = try await testedModel.run("-") - } catch { - XCTAssertTrue(error as! NetworkingError == NetworkingError.invalidStatusCode(statusCode: 400)) - } - } - - func test_model_init() { - let testedInitModel = Model( - id: "testId", - name: "Test Model", - description: "This is a test model for mocking purposes.", - supplier: Supplier(id: 123, name: "Test Supplier", code: "123"), - version: "1.0.0", - license: nil, - privacy: nil, - pricing: Pricing(price: 1, unitType: nil), - hostedBy: "TestHost", - developedBy: "TestDeveloper", - networking: MockNetworking() - ) - - XCTAssertEqual(testedInitModel.id, "testId") - XCTAssertEqual(testedInitModel.name, "Test Model") - XCTAssertEqual(testedInitModel.supplier.id, 123) - XCTAssertEqual(testedInitModel.supplier.name, "Test Supplier") - XCTAssertEqual(testedInitModel.supplier.code, "123") - XCTAssertEqual(testedInitModel.version, "1.0.0") - XCTAssertEqual(testedInitModel.pricing.price, 1) - XCTAssertNil(testedInitModel.pricing.unitType) - } - - func test_modelRun_MissingModelRunUrlError() async { - APIKeyManager.shared.TEAM_API_KEY = "" - APIKeyManager.shared.MODELS_RUN_URL = nil - - do { - _ = try await testedModel.run("-") - } catch { - XCTAssertTrue(error as! ModelError == ModelError.missingModelRunURL) - } - } - -} diff --git a/Tests/aiXplainKitTests/Unit/Networking/NetworkingTests.swift b/Tests/aiXplainKitTests/Unit/Networking/NetworkingTests.swift deleted file mode 100644 index a3380a9..0000000 --- a/Tests/aiXplainKitTests/Unit/Networking/NetworkingTests.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// NetworkingTests.swift -// -// -// Created by Joao Pedro Monteiro Maia on 08/03/24. -// - -import XCTest -@testable import aiXplainKit - -final class NetworkingTests: XCTestCase { - - override func tearDownWithError() throws { - AiXplainKit.shared.keyManager.clear() - } - - func test_buildHeaders_correctly() { - AiXplainKit.shared.keyManager.TEAM_API_KEY = "-" - let network = Networking() - - let headers = try? network.buildHeader() - - XCTAssertEqual(headers, ["Authorization": "Token -", "Content-Type": "application/json"]) - - AiXplainKit.shared.keyManager.TEAM_API_KEY = nil - AiXplainKit.shared.keyManager.TEAM_API_KEY = "-" - - XCTAssertEqual(headers, ["Authorization": "Token -", "Content-Type": "application/json"]) - - } - - func test_buildHeaders_MissingKeysError() { - let network = Networking() - do { - _ = try network.buildHeader() - } catch { - XCTAssertTrue(error as! ModelError == ModelError.missingAPIKey) - } - } - - func test_buildHeaders_MissingBackendError() { - AiXplainKit.shared.keyManager.BACKEND_URL = nil - let network = Networking() - - do { - _ = try network.buildUrl(for: .functionEndpoint) - } catch { - XCTAssertTrue(error as! ModelError == ModelError.missingBackendURL) - } - - do { - _ = try network.buildUrl(for: .model(modelIdentifier: "123")) - } catch { - XCTAssertTrue(error as! ModelError == ModelError.missingBackendURL) - } - - } - -} diff --git a/Tests/aiXplainKitTests/Unit/Provider/ModelProviderTests.swift b/Tests/aiXplainKitTests/Unit/Provider/ModelProviderTests.swift deleted file mode 100644 index cc6f011..0000000 --- a/Tests/aiXplainKitTests/Unit/Provider/ModelProviderTests.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// ModelProviderTests.swift -// -// -// Created by Joao Pedro Monteiro Maia on 08/03/24. -// - -import XCTest -@testable import aiXplainKit - -/// Test_method_description_expectation -final class ModelProviderTests: XCTestCase { - - let networking = MockNetworking() - - override func tearDownWithError() throws { - AiXplainKit.shared.keyManager.clear() - } - - //TODO: Fix error - func test_get_fetchesAndDecodesModel_whenSuccessfulResponse() async throws { - AiXplainKit.shared.keyManager.TEAM_API_KEY = "-" - - let mockNetworking = MockNetworking() - let modelProvider = ModelProvider(networking: mockNetworking) - - let fetchedModel = try await modelProvider.get("640b517694bf816d35a59125") - XCTAssertEqual(fetchedModel.id, "640b517694bf816d35a59125") - } - - func test_get_missingKEY_ThrowError() async throws { - let mockNetworking = MockNetworking() - let modelProvider = ModelProvider(networking: mockNetworking) - - do { - _ = try await modelProvider.get("-") - } catch { - XCTAssertTrue(error as! ModelError == ModelError.missingAPIKey) - } - - } - - func test_get_noBackendURL_ThrowError() async throws { - AiXplainKit.shared.keyManager.TEAM_API_KEY = "-" - AiXplainKit.shared.keyManager.BACKEND_URL = nil - - let mockNetworking = MockNetworking() - let modelProvider = ModelProvider(networking: mockNetworking) - - do { - _ = try await modelProvider.get("-") - } catch { - XCTAssertTrue(error as! ModelError == ModelError.missingBackendURL) - } - - } - -} diff --git a/Tests/aiXplainKitTests/Unit/Resources/ResourceTests.swift b/Tests/aiXplainKitTests/Unit/Resources/ResourceTests.swift new file mode 100644 index 0000000..0b919f0 --- /dev/null +++ b/Tests/aiXplainKitTests/Unit/Resources/ResourceTests.swift @@ -0,0 +1,269 @@ +import XCTest +@testable import aiXplainKit + +final class PageTests: XCTestCase { + + func test_page_properties() { + let page = Page(results: ["a", "b", "c"], pageNumber: 0, pageTotal: 2, total: 5) + XCTAssertEqual(page.count, 3) + XCTAssertEqual(page.total, 5) + XCTAssertEqual(page.pageNumber, 0) + XCTAssertEqual(page.pageTotal, 2) + XCTAssertFalse(page.isEmpty) + } + + func test_page_empty() { + let page = Page(results: [], pageNumber: 0, pageTotal: 0, total: 0) + XCTAssertTrue(page.isEmpty) + XCTAssertEqual(page.count, 0) + } +} + +final class RunResultTests: XCTestCase { + + func test_from_dict_completed() { + let dict: [String: Any] = [ + "status": "SUCCESS", + "completed": true, + "data": ["output": "hello"] + ] + let result = RunResult.from(dict) + XCTAssertEqual(result.status, "SUCCESS") + XCTAssertTrue(result.completed) + XCTAssertNotNil(result.data) + } + + func test_from_dict_inProgress() { + let dict: [String: Any] = [ + "status": "IN_PROGRESS", + "completed": false, + "url": "https://poll.url/123" + ] + let result = RunResult.from(dict) + XCTAssertEqual(result.status, "IN_PROGRESS") + XCTAssertFalse(result.completed) + XCTAssertEqual(result.url, "https://poll.url/123") + } + + func test_from_dict_withErrors() { + let dict: [String: Any] = [ + "status": "FAILED", + "completed": true, + "errorMessage": "Something went wrong", + "supplierError": "Model overloaded" + ] + let result = RunResult.from(dict) + XCTAssertEqual(result.errorMessage, "Something went wrong") + XCTAssertEqual(result.supplierError, "Model overloaded") + } +} + +final class AgentToolDictTests: XCTestCase { + + func test_codable_roundTrip() throws { + let tool = AgentToolDict( + id: "t1", + name: "Test Tool", + description: "A test", + supplier: "aixplain", + function: "text-generation", + type: .model, + version: "1.0", + assetId: "t1" + ) + + let data = try JSONEncoder().encode(tool) + let decoded = try JSONDecoder().decode(AgentToolDict.self, from: data) + XCTAssertEqual(decoded.id, "t1") + XCTAssertEqual(decoded.name, "Test Tool") + XCTAssertEqual(decoded.type, .model) + XCTAssertNil(decoded.actions) + } + + func test_codable_withActions() throws { + var tool = AgentToolDict( + id: "t2", + name: "Slack Tool", + description: "Slack integration", + supplier: "aixplain", + function: "utilities", + type: .tool, + version: "1.0", + assetId: "t2", + actions: ["send_message", "upload_file"] + ) + + let data = try JSONEncoder().encode(tool) + let decoded = try JSONDecoder().decode(AgentToolDict.self, from: data) + XCTAssertEqual(decoded.actions, ["send_message", "upload_file"]) + } +} + +final class AnyCodableTests: XCTestCase { + + func test_string() throws { + let value = AnyCodable("hello") + let data = try JSONEncoder().encode(value) + let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) + XCTAssertEqual(decoded.value as? String, "hello") + } + + func test_int() throws { + let value = AnyCodable(42) + let data = try JSONEncoder().encode(value) + let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) + XCTAssertEqual(decoded.value as? Int, 42) + } + + func test_double() throws { + let value = AnyCodable(3.14) + let data = try JSONEncoder().encode(value) + let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) + XCTAssertEqual(decoded.value as? Double, 3.14) + } + + func test_bool() throws { + let value = AnyCodable(true) + let data = try JSONEncoder().encode(value) + let decoded = try JSONDecoder().decode(AnyCodable.self, from: data) + XCTAssertEqual(decoded.value as? Bool, true) + } + + func test_null() throws { + let json = "null".data(using: .utf8)! + let decoded = try JSONDecoder().decode(AnyCodable.self, from: json) + XCTAssertTrue(decoded.value is NSNull) + } + + func test_array() throws { + let json = "[1, 2, 3]".data(using: .utf8)! + let decoded = try JSONDecoder().decode(AnyCodable.self, from: json) + let arr = decoded.value as? [Any] + XCTAssertEqual(arr?.count, 3) + } + + func test_dict() throws { + let json = #"{"key": "value"}"#.data(using: .utf8)! + let decoded = try JSONDecoder().decode(AnyCodable.self, from: json) + let dict = decoded.value as? [String: Any] + XCTAssertEqual(dict?["key"] as? String, "value") + } +} + +final class BaseResourceTests: XCTestCase { + + func test_isModified_newResource() { + let resource = BaseResource(name: "test") + XCTAssertTrue(resource.isModified) + } + + func test_isModified_afterSaveState() { + let resource = BaseResource(id: "1", name: "test") + resource.updateSavedState() + XCTAssertFalse(resource.isModified) + } + + func test_isModified_afterMutation() { + let resource = BaseResource(id: "1", name: "test") + resource.updateSavedState() + resource.name = "changed" + XCTAssertTrue(resource.isModified) + } + + func test_markAsDeleted() { + let resource = BaseResource(id: "1", name: "test") + resource.markAsDeleted() + XCTAssertTrue(resource.isDeleted) + XCTAssertNil(resource.id) + } + + func test_ensureValidState_deletedThrows() { + let resource = BaseResource(id: "1") + resource.markAsDeleted() + XCTAssertThrowsError(try resource.ensureValidState()) + } + + func test_ensureValidState_noIdThrows() { + let resource = BaseResource() + XCTAssertThrowsError(try resource.ensureValidState()) + } + + func test_ensureValidState_validPasses() { + let resource = BaseResource(id: "123") + XCTAssertNoThrow(try resource.ensureValidState()) + } + + func test_ensureContext_missingThrows() { + let resource = BaseResource(id: "1") + XCTAssertThrowsError(try resource.ensureContext()) + } + + func test_ensureContext_presentPasses() throws { + let aix = try Aixplain(apiKey: "test-key") + let resource = BaseResource(id: "1", context: aix) + XCTAssertNoThrow(try resource.ensureContext()) + } + + func test_buildSavePayload() throws { + let resource = BaseResource(id: "1", name: "Test", description: "Desc") + let payload = try resource.buildSavePayload() + XCTAssertEqual(payload["id"] as? String, "1") + XCTAssertEqual(payload["name"] as? String, "Test") + XCTAssertEqual(payload["description"] as? String, "Desc") + } + + func test_clone_resetsId() throws { + let aix = try Aixplain(apiKey: "test-key") + let resource = BaseResource(id: "original", name: "Original", context: aix) + let cloned = resource.clone(name: "Cloned") + XCTAssertNil(cloned.id) + XCTAssertEqual(cloned.name, "Cloned") + XCTAssertNotNil(cloned.context) + } + + func test_encodedId() { + let resource = BaseResource(id: "abc/def") + XCTAssertEqual(resource.encodedId, "abc%2Fdef") + } +} + +final class AssetStatusTests: XCTestCase { + + func test_decode_draft() throws { + let json = #""draft""#.data(using: .utf8)! + let status = try JSONDecoder().decode(AssetStatus.self, from: json) + XCTAssertEqual(status, .draft) + } + + func test_decode_onboarded() throws { + let json = #""onboarded""#.data(using: .utf8)! + let status = try JSONDecoder().decode(AssetStatus.self, from: json) + XCTAssertEqual(status, .onboarded) + } + + func test_decode_inProgress() throws { + let json = #""in_progress""#.data(using: .utf8)! + let status = try JSONDecoder().decode(AssetStatus.self, from: json) + XCTAssertEqual(status, .inProgress) + } +} + +final class ResponseStatusTests: XCTestCase { + + func test_values() { + XCTAssertEqual(ResponseStatus.inProgress.rawValue, "IN_PROGRESS") + XCTAssertEqual(ResponseStatus.success.rawValue, "SUCCESS") + XCTAssertEqual(ResponseStatus.failed.rawValue, "FAILED") + } +} + +final class ToolTypeTests: XCTestCase { + + func test_allCases() throws { + for type in [ToolType.model, .pipeline, .utility, .tool] { + let data = try JSONEncoder().encode(type) + let decoded = try JSONDecoder().decode(ToolType.self, from: data) + XCTAssertEqual(decoded, type) + } + } +} diff --git a/Tests/aiXplainKitTests/Unit/Tools/ToolTests.swift b/Tests/aiXplainKitTests/Unit/Tools/ToolTests.swift new file mode 100644 index 0000000..1f967a9 --- /dev/null +++ b/Tests/aiXplainKitTests/Unit/Tools/ToolTests.swift @@ -0,0 +1,120 @@ +import XCTest +@testable import aiXplainKit + +final class ToolTests: XCTestCase { + + func test_tool_resourcePath() { + XCTAssertEqual(Tool.resourcePath, "v2/tools") + } + + func test_tool_isSubclassOfModel() { + let tool = Tool(id: "t1", name: "Test Tool") + XCTAssertTrue(tool is Model) + } + + func test_tool_from_dict() throws { + let aix = try Aixplain(apiKey: "test-key") + let dict: [String: Any] = [ + "id": "tool-123", + "name": "Slack Tool", + "description": "Send messages to Slack", + "allowedActions": ["send_message", "upload_file"], + "actionsAvailable": true, + "vendor": ["code": "aixplain"], + "function": ["id": "utilities"], + "version": ["id": "1.0"] + ] + + let tool = try Tool.from(dict: dict, context: aix) + XCTAssertEqual(tool.id, "tool-123") + XCTAssertEqual(tool.name, "Slack Tool") + XCTAssertEqual(tool.allowedActions, ["send_message", "upload_file"]) + XCTAssertEqual(tool.actionsAvailable, true) + } + + func test_tool_asAgentTool_includesActions() throws { + let aix = try Aixplain(apiKey: "test-key") + let dict: [String: Any] = [ + "id": "tool-abc", + "name": "My Tool", + "allowedActions": ["action1", "action2"], + "vendor": ["code": "aixplain"], + "function": ["id": "utilities"], + "version": ["id": "v1"] + ] + let tool = try Tool.from(dict: dict, context: aix) + let agentTool = tool.asAgentTool() + + XCTAssertEqual(agentTool.type, .tool) + XCTAssertEqual(agentTool.actions, ["action1", "action2"]) + XCTAssertEqual(agentTool.id, "tool-abc") + } + + func test_tool_asAgentTool_noActions() throws { + let tool = Tool(id: "t1", name: "Simple") + let agentTool = tool.asAgentTool() + XCTAssertEqual(agentTool.type, .tool) + XCTAssertNil(agentTool.actions) + } +} + +final class IntegrationTests: XCTestCase { + + func test_integration_resourcePath() { + XCTAssertEqual(Integration.resourcePath, "v2/integrations") + } + + func test_integration_isSubclassOfModel() { + let integration = Integration(id: "i1") + XCTAssertTrue(integration is Model) + } + + func test_integration_from_dict() throws { + let aix = try Aixplain(apiKey: "test-key") + let dict: [String: Any] = [ + "id": "int-123", + "name": "Slack Integration", + "actionsAvailable": true + ] + let integration = try Integration.from(dict: dict, context: aix) + XCTAssertEqual(integration.id, "int-123") + XCTAssertEqual(integration.actionsAvailable, true) + } +} + +final class ActionTests: XCTestCase { + + func test_action_from_dict() { + let dict: [String: Any] = [ + "name": "send_message", + "description": "Send a message", + "slug": "send_msg", + "inputs": [ + ["name": "channel", "datatype": "string", "required": true], + ["name": "text", "datatype": "string", "required": true] + ] + ] + let action = Action.from(dict) + XCTAssertEqual(action.name, "send_message") + XCTAssertEqual(action.slug, "send_msg") + XCTAssertEqual(action.inputs?.count, 2) + XCTAssertEqual(action.inputs?.first?.name, "channel") + XCTAssertEqual(action.inputs?.first?.required, true) + } + + func test_actionInput_from_dict() { + let dict: [String: Any] = [ + "name": "temperature", + "code": "temp", + "datatype": "number", + "required": false, + "fixed": false, + "description": "Sampling temperature" + ] + let input = ActionInput.from(dict) + XCTAssertEqual(input.name, "temperature") + XCTAssertEqual(input.code, "temp") + XCTAssertEqual(input.datatype, "number") + XCTAssertFalse(input.required) + } +} diff --git a/Tests/aiXplainKitTests/XCTestCase+TempFile.swift b/Tests/aiXplainKitTests/XCTestCase+TempFile.swift deleted file mode 100644 index 457e33f..0000000 --- a/Tests/aiXplainKitTests/XCTestCase+TempFile.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// File.swift -// -// -// Created by Joao Pedro Monteiro Maia on 26/03/24. -// - -import Foundation -import XCTest - -extension XCTestCase { - func withTempFile(from url: URL, _ completion: @escaping (URL) async -> Void) async { - let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) - let temporaryFileURL = temporaryDirectoryURL.appendingPathComponent(url.lastPathComponent) - - do { - let data = try await downloadData(from: url) - try data.write(to: temporaryFileURL) - - await completion(temporaryFileURL) - - do { - try FileManager.default.removeItem(at: temporaryFileURL) - } catch { - XCTFail("Error removing file: \(error.localizedDescription)") - } - - } catch { - XCTFail("Error downloading file: \(error.localizedDescription)") - } - } - - private func downloadData(from url: URL) async throws -> Data { - let (data, _) = try await URLSession.shared.data(from: url) - return data - } -} diff --git a/docs/rfcs/README.md b/docs/rfcs/README.md new file mode 100644 index 0000000..6c947f6 --- /dev/null +++ b/docs/rfcs/README.md @@ -0,0 +1,97 @@ +# aiXplainKit Swift SDK v2 -- RFC Index + +This directory contains the Request for Comments (RFC) series for rebuilding aiXplainKit as a v2 SDK, aligned with the [aiXplain Python SDK v2](https://github.com/aixplain/aiXplain/tree/main/aixplain/v2). + +**Clean-slate approach**: the existing v1 SDK code is deleted entirely. There is no backward compatibility requirement. The v2 SDK is built from scratch following the Python v2 architecture. + +## Execution Order + +RFCs are dependency-ordered. Each RFC should be implemented only after its dependencies are complete. + +| # | RFC | Priority | Depends on | Status | +|------|---------------------------------------------------------------------|----------|--------------------------|--------| +| 0001 | [Auth and Credentials](RFC-0001-auth-and-credentials.md) | P0 | -- | Done | +| 0005 | [Error Model and Contract Tests](RFC-0005-error-model-and-contract-tests.md) | P0 | RFC-0001 | Done | +| 0002 | [Client Configuration and Transport](RFC-0002-client-configuration-and-transport.md) | P0 | RFC-0001, 0005 | Done | +| 0004 | [Resources and Tools Schema](RFC-0004-resources-tools-schema-alignment.md) | P0 | RFC-0002 | Done | +| 0007 | [Models v2 API](RFC-0007-models-v2-api.md) | P0 | RFC-0002, 0004, 0005 | Done | +| 0008 | [Tools and Integrations v2 API](RFC-0008-tools-and-integrations-v2-api.md) | **P0** | RFC-0002, 0004, 0005, 0007 | Done | +| 0003 | [Agents v2 API and Lifecycle](RFC-0003-agents-v2-api-and-lifecycle.md) | **P0** | RFC-0001, 0002, 0004, 0005, 0008 | Done | +| 0009 | [Index and Search v2 API](RFC-0009-index-and-search-v2-api.md) | P1 | RFC-0002, 0004, 0005, 0007 | Done | +| 0006 | [Clean-Slate Implementation Plan](RFC-0006-clean-slate-implementation-plan.md) | P2 | All above | Done | + +## Dependency Graph + +``` +RFC-0001 (Auth) + ├──▶ RFC-0005 (Errors) ◀── error types used by everything + └──▶ RFC-0002 (Client) ◀── consumes Auth + Errors + └──▶ RFC-0004 (Resources) ◀── shared protocols/types hub + ├──▶ RFC-0007 (Models) + │ ├──▶ RFC-0008 (Tools) ◀── critical for agents + │ └──▶ RFC-0009 (Index) + │ + └──▶ RFC-0003 (Agents) ◀── consumes ALL of the above + │ + └──▶ RFC-0006 (Implementation Plan) +``` + +Key: RFC-0004 is the **interface hub** -- it defines `BaseResource`, `Page`, +`AgentToolDict`, `AssetStatus`, `AIFunction`, `Supplier`, `ResponseStatus`, and +`AnyCodable` that every other RFC consumes. + +## Implementation Phases + +### Phase 1: Foundation (RFCs 0001, 0005) +Auth types and Error hierarchy. Every other RFC consumes these. + +### Phase 2: Client (RFC 0002) +Unified HTTP client and `Aixplain` entry point. Consumes Auth + Errors. + +### Phase 3: Shared Protocols (RFC 0004) +Resource protocols, shared enums, and `AgentToolDict`. This is the **interface hub** that all domain RFCs build on. + +### Phase 4: Models + Tools (RFCs 0007, 0008) +Model and Tool resources. Tools depend on Models. Both produce `AgentToolConvertible` conformances consumed by Agents. + +### Phase 5: Agents (RFC 0003) +The primary product surface. Consumes everything: Auth, Client, Resource protocols, Errors, Models (as LLM reference), and Tools (for agent capabilities). + +### Phase 6: Index (RFC 0009) +Index and Search builds on Models. Important for RAG workflows. + +### Phase 7: Cleanup (RFC 0006) +Final directory structure, DocC documentation, and verification. + +## Deep Dive Status + +Each RFC has been enriched with direct references to the Python v2 source code, showing: +- Exact Python v2 code snippets that the Swift implementation should mirror +- Line-by-line mapping of Python v2 patterns to proposed Swift API designs +- Contract test fixtures derived from the Python v2 response structures + +## RFC Template + +Each RFC follows this structure: + +| Section | Purpose | +|-----------------|---------| +| **Status** | Draft / Accepted / Implemented | +| **Context** | What exists today and the Python v2 reference | +| **Decision** | The chosen approach | +| **API Shape** | Proposed Swift API with code examples | +| **Implementation** | Files to delete and files to create | +| **Testing** | Required test coverage | +| **Out of Scope** | What this RFC explicitly does not cover | +| **Resolved Questions** | Decisions made (previously Open Questions) | + +## How to Use + +1. Read the RFCs in order by phase (Phase 1 → Phase 5). +2. All Open Questions have been resolved -- decisions are documented in each RFC's "Resolved Questions" section. +3. Implement each RFC on a feature branch; update the Status column here when done. +4. RFC-0006 defines the full file deletion list and target directory structure. + +## Reference + +- [aiXplain Python SDK v2 source](https://github.com/aixplain/aiXplain/tree/main/aixplain/v2) diff --git a/docs/rfcs/RFC-0001-auth-and-credentials.md b/docs/rfcs/RFC-0001-auth-and-credentials.md new file mode 100644 index 0000000..6f58eeb --- /dev/null +++ b/docs/rfcs/RFC-0001-auth-and-credentials.md @@ -0,0 +1,230 @@ +# RFC-0001: Auth and Credentials + +| Field | Value | +|--------------|------------------------------------------| +| Status | Implemented | +| Authors | | +| Created | 2026-03-06 | +| Depends on | -- | +| Depended by | RFC-0002, RFC-0003 | +| Priority | P0 -- Foundation | + +## Context + +The current Swift SDK manages authentication through a singleton `APIKeyManager` that loads keys from `ProcessInfo` environment variables and exposes mutable properties (`TEAM_API_KEY`, `AIXPLAIN_API_KEY`, `PIPELINE_API_KEY`, `MODEL_API_KEY`, `HF_TOKEN`). Header construction lives in `Networking+Metadata.buildHeader()`, which silently prefers `TEAM_API_KEY` over `AIXPLAIN_API_KEY` when both are set. + +### How Python v2 handles auth + +The Python SDK v2 enforces **mutual exclusivity** at the client level (`client.py`): + +```python +# From client.py -- AixplainClient.__init__ +if not (self.aixplain_api_key or self.team_api_key): + raise ValueError("Either `aixplain_api_key` or `team_api_key` should be set") + +if self.aixplain_api_key and self.team_api_key: + raise ValueError("Either `aixplain_api_key` or `team_api_key` should be set") + +headers = {"Content-Type": "application/json"} +if self.aixplain_api_key: + headers["x-aixplain-key"] = self.aixplain_api_key +if self.team_api_key: + headers["x-api-key"] = self.team_api_key +``` + +The top-level `Aixplain` entry point (`core.py`) resolves the key once: + +```python +# From core.py -- Aixplain.__init__ +self.api_key = api_key or os.getenv("TEAM_API_KEY") or "" +assert self.api_key, ( + "API key is required. You should either pass it as an argument or " + "set the TEAM_API_KEY environment variable." +) +``` + +Key observations from Python v2: +1. Exactly one key type is accepted -- never both. +2. `TEAM_API_KEY` is the default environment variable (not `AIXPLAIN_API_KEY`). +3. The header name differs by key type: `x-aixplain-key` vs `x-api-key`. +4. Validation happens at init time with a clear error message. +5. The key is set once on the session headers and reused for all requests. + +### Problems in the current Swift SDK + +1. **Global mutable singleton** -- any part of the code can mutate `APIKeyManager.shared` at any time, making behavior unpredictable in concurrent contexts and impossible to scope per-client. +2. **Silent override** -- `buildHeader()` overwrites headers when both key types exist; there is no error or warning. Python v2 explicitly rejects this. +3. **No validation** -- keys are used as-is with no format or emptiness check. Python v2 asserts non-empty at init. +4. **Mixed concerns** -- `APIKeyManager` also holds `BACKEND_URL` and `MODELS_RUN_URL`, coupling auth with endpoint configuration. Python v2 separates these: URLs go on `Aixplain`, keys go on `AixplainClient`. +5. **No key-type enum** -- the distinction between aiXplain key and team key is implicit in property names, not in a typed contract. +6. **Team key uses wrong header** -- Swift sends `Authorization: Token `, Python v2 sends `x-api-key: `. The Swift SDK must align to the v2 header contract. +7. **Unused keys** -- `PIPELINE_API_KEY` and `MODEL_API_KEY` are never referenced outside `APIKeyManager`; they are dead code. + +## Decision + +Introduce a `Credential` value type that enforces mutual exclusivity and maps to the correct header name, matching the Python v2 contract exactly. + +## API Shape + +```swift +/// How the SDK authenticates against the aiXplain platform. +/// Matches Python v2: exactly one key type per client instance. +public enum AuthenticationScheme: Sendable, Codable { + /// Platform-scoped key sent as `x-aixplain-key`. + /// Python v2: `headers["x-aixplain-key"] = self.aixplain_api_key` + case aixplainKey(String) + + /// Team-scoped key sent as `x-api-key`. + /// Python v2: `headers["x-api-key"] = self.team_api_key` + case teamKey(String) +} + +/// Resolved, validated credential ready for use in requests. +/// Immutable once created -- matches Python v2 pattern where +/// headers are set once on the session during __init__. +public struct Credential: Sendable, Equatable, Codable { + public let scheme: AuthenticationScheme + + /// Validates and creates a credential. + /// Throws `AuthError.emptyKey` if the key string is empty. + /// Mirrors Python v2: `assert self.api_key` in core.py. + public init(scheme: AuthenticationScheme) throws { + switch scheme { + case .aixplainKey(let key), .teamKey(let key): + guard !key.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw AuthError.emptyKey + } + } + self.scheme = scheme + } + + /// Builds the authentication header pair for this credential. + /// Returns (headerField, headerValue) matching the Python v2 contract. + public func authHeaders() -> [String: String] { + var headers = ["Content-Type": "application/json"] + switch scheme { + case .aixplainKey(let key): + headers["x-aixplain-key"] = key + case .teamKey(let key): + headers["x-api-key"] = key + } + return headers + } + + /// Resolves a credential from explicit value or environment. + /// Resolution order matches Python v2 core.py: + /// 1. Explicit `apiKey` parameter + /// 2. `TEAM_API_KEY` environment variable + /// 3. Throw error + public static func resolve( + apiKey: String? = nil, + environment: [String: String] = ProcessInfo.processInfo.environment + ) throws -> Credential { + if let key = apiKey, !key.isEmpty { + return try Credential(scheme: .teamKey(key)) + } + if let envKey = environment["TEAM_API_KEY"], !envKey.isEmpty { + return try Credential(scheme: .teamKey(envKey)) + } + if let envKey = environment["AIXPLAIN_API_KEY"], !envKey.isEmpty { + return try Credential(scheme: .aixplainKey(envKey)) + } + throw AuthError.noCredentialFound + } +} + +/// Authentication errors. +public enum AuthError: Error, Sendable, LocalizedError { + case noCredentialFound + case emptyKey + case bothKeysProvided + + public var errorDescription: String? { + switch self { + case .noCredentialFound: + return "API key is required. Pass it as an argument or set the TEAM_API_KEY environment variable." + case .emptyKey: + return "API key must not be empty." + case .bothKeysProvided: + return "Either `aixplainKey` or `teamKey` should be set, not both." + } + } +} +``` + +### Header mapping (Python v2 parity) + +| Key Type | Python v2 Header | Current Swift Header | v2 Swift Header | +|-------------|---------------------|------------------------------|---------------------| +| aiXplain | `x-aixplain-key` | `x-aixplain-key` | `x-aixplain-key` | +| Team | `x-api-key` | `Authorization: Token ` | `x-api-key` | + +The team key header changes from `Authorization: Token` to `x-api-key` to match the Python v2 contract. + +### Resolution order (mirrors `core.py`) + +``` +┌─────────────────────────────┐ +│ Explicit apiKey parameter? │──yes──▶ Credential(.teamKey(key)) +└──────────┬──────────────────┘ + │ no + ▼ +┌─────────────────────────────┐ +│ TEAM_API_KEY env var? │──yes──▶ Credential(.teamKey(key)) +└──────────┬──────────────────┘ + │ no + ▼ +┌─────────────────────────────┐ +│ AIXPLAIN_API_KEY env var? │──yes──▶ Credential(.aixplainKey(key)) +└──────────┬──────────────────┘ + │ no + ▼ + throw AuthError.noCredentialFound +``` + +## Shared Contracts + +| Type | Produced here | Consumed by | +|------|---------------|-------------| +| `AuthenticationScheme` | `Auth/AuthenticationScheme.swift` | RFC-0002 (`AixplainClient` init) | +| `Credential` | `Auth/Credential.swift` | RFC-0002 (`AixplainClient.credential`, `Aixplain` init) | +| `AuthError` | `Errors/AuthError.swift` | RFC-0002 (credential resolution failure), RFC-0005 (`AixplainError.auth` case) | + +## Implementation + +Clean-slate: delete all v1 auth code and replace with the new types. + +### Files to delete + +- `Sources/aiXplainKit/Manager/APIKeyManager.swift` +- `Sources/aiXplainKit/Networking/Networking+Metadata.swift` + +### Files to create + +| File | Content | +|------|---------| +| `Sources/aiXplainKit/Auth/AuthenticationScheme.swift` | `AuthenticationScheme` enum | +| `Sources/aiXplainKit/Auth/Credential.swift` | `Credential` struct with `resolve()` and `authHeaders()` | +| `Sources/aiXplainKit/Errors/AuthError.swift` | `AuthError` enum | + +## Testing + +- Unit: `Credential.resolve(apiKey: "abc")` returns `.teamKey("abc")`. +- Unit: `Credential.resolve()` with `TEAM_API_KEY` in env returns `.teamKey(...)`. +- Unit: `Credential.resolve()` with `AIXPLAIN_API_KEY` in env returns `.aixplainKey(...)`. +- Unit: `Credential.resolve()` with no key throws `AuthError.noCredentialFound`. +- Unit: `Credential(scheme: .teamKey(""))` throws `AuthError.emptyKey`. +- Unit: `.teamKey` produces header `["x-api-key": key]`. +- Unit: `.aixplainKey` produces header `["x-aixplain-key": key]`. +- Unit: `authHeaders()` always includes `Content-Type: application/json`. + +## Out of Scope + +- OAuth / token refresh flows (not supported by platform today; Python v2 `enums.py` has `AuthenticationScheme` for integrations, not SDK auth). +- Per-request credential override (deferred to RFC-0002 transport layer). +- API Key CRUD management (`api_key.py` resource) -- will be covered in a future RFC when needed. + +## Resolved Questions + +1. **`Credential` conforms to `Codable`** -- yes, to support persisted configurations (e.g., saved in UserDefaults or config files). +2. **No `CredentialProvider` protocol** -- credential resolution is handled by `Credential.resolve()` only. No dynamic sources like Keychain. diff --git a/docs/rfcs/RFC-0002-client-configuration-and-transport.md b/docs/rfcs/RFC-0002-client-configuration-and-transport.md new file mode 100644 index 0000000..e380b3f --- /dev/null +++ b/docs/rfcs/RFC-0002-client-configuration-and-transport.md @@ -0,0 +1,406 @@ +# RFC-0002: Client Configuration and Transport + +| Field | Value | +|--------------|------------------------------------------| +| Status | Implemented | +| Authors | | +| Created | 2026-03-06 | +| Depends on | RFC-0001, RFC-0005 (errors) | +| Depended by | RFC-0003, RFC-0004, RFC-0007, RFC-0008, RFC-0009 | +| Priority | P0 -- Foundation | + +## Context + +The current Swift SDK has a `Networking` class that provides raw HTTP methods (`get`, `post`, `put`, `delete`) with retry logic, and an `Endpoint` enum that builds URL paths. Each provider (`AgentProvider`, `ModelProvider`, etc.) creates its own `Networking` instance, manually builds headers via `buildHeader()`, and resolves base URLs through `APIKeyManager.shared`. + +### How Python v2 structures client and entry point + +**`AixplainClient` (`client.py`)** is the single HTTP client: + +```python +class AixplainClient: + def __init__(self, base_url, aixplain_api_key=None, team_api_key=None, + retry_total=5, retry_backoff_factor=0.1, + retry_status_forcelist=[500, 502, 503, 504]): + self.base_url = base_url + self.session = create_retry_session(...) + self.session.headers.update(headers) + + def request_raw(self, method, path, **kwargs) -> requests.Response + def request(self, method, path, **kwargs) -> dict # auto .json() + def get(self, path, **kwargs) -> dict + def post(self, path, **kwargs) -> dict + def request_stream(self, method, path, **kwargs) -> Response # SSE +``` + +Key design decisions in Python v2: +- `request_raw` returns raw response; `request` auto-parses `.json()`. +- URL resolution: if `path` starts with `http://` or `https://`, use it directly (for polling URLs). Otherwise `urljoin(self.base_url, path)`. +- Error handling: non-OK responses are parsed into `APIError` with `status_code`, `response_data`, and `error` fields. +- Retry: uses `requests.adapters.Retry` with exponential backoff on `[500, 502, 503, 504]` for both GET and POST. +- Streaming: `request_stream` sets `stream=True` on the session request for SSE support. + +**`Aixplain` (`core.py`)** is the entry point: + +```python +class Aixplain: + BACKEND_URL = "https://platform-api.aixplain.com" + MODELS_RUN_URL = "https://models.aixplain.com/api/v2/execute" + # PIPELINES_RUN_URL also exists but not used in Swift v2 + + def __init__(self, api_key=None, backend_url=None, pipeline_url=None, model_url=None): + self.api_key = api_key or os.getenv("TEAM_API_KEY") or "" + self.backend_url = backend_url or os.getenv("BACKEND_URL") or self.BACKEND_URL + self.init_client() + self.init_resources() + + def init_client(self): + self.client = AixplainClient(base_url=self.backend_url, team_api_key=self.api_key) + + def init_resources(self): + # Dynamically creates bound subclasses so each resource has context + self.Model = type("Model", (Model,), {"context": self}) + self.Agent = type("Agent", (Agent,), {"context": self}) + # ... Tool, Utility, Integration, Resource, Inspector, Debugger, APIKey +``` + +Key design decisions in Python v2 entry point: +- **One client, shared context** -- a single `AixplainClient` instance is created and injected into all resource types. +- **Resource binding** -- uses Python metaprogramming (`type()`) to create subclasses with `context` set as a class attribute. Each resource accesses `self.context.client` for HTTP calls. +- **Enums as attributes** -- `Function`, `Supplier`, `Language`, `License`, etc. are exposed on `Aixplain` for convenience (e.g., `aix.Function.TRANSLATION`). +- **Multiple URLs** -- `backend_url`, `model_url`, `pipeline_url` are separate configuration points, each with env var fallback. + +### Problems in the current Swift SDK + +1. **No unified client** -- each provider independently constructs networking, headers, and URLs. +2. **Endpoint fragmentation** -- `BACKEND_URL` and `MODELS_RUN_URL` live on `APIKeyManager`, while endpoint paths live on `Networking.Endpoint`. +3. **No dependency injection** -- providers use `APIKeyManager.shared` directly. +4. **Basic retry strategy** -- fixed delay, no exponential backoff, no status-code-aware retry list. +5. **No streaming support** -- Python v2 has `request_stream()` for SSE; Swift has none. +6. **No context injection** -- Python v2 binds resources to a context; Swift resources are unconnected. +7. **URL construction is manual** -- each provider manually concatenates `url.absoluteString + endpoint.path`. + +## Decision + +Introduce `AixplainClient` and `Aixplain` entry point that mirror the Python v2 architecture, adapted to Swift idioms (protocols, async/await, Sendable). + +## API Shape + +### AixplainClient (mirrors `client.py`) + +```swift +/// Central HTTP client for the aiXplain platform. +/// Mirrors Python v2 `AixplainClient`: single session, shared headers, retry logic. +public final class AixplainClient: @unchecked Sendable { + public let credential: Credential + public let configuration: ClientConfiguration + private let session: URLSession + + public init(credential: Credential, configuration: ClientConfiguration = .default) { + self.credential = credential + self.configuration = configuration + let sessionConfig = URLSessionConfiguration.default + sessionConfig.timeoutIntervalForRequest = configuration.timeoutInterval + sessionConfig.httpAdditionalHeaders = credential.authHeaders() + self.session = URLSession(configuration: sessionConfig) + } + + /// Mirrors Python `request_raw` -- returns raw response. + public func requestRaw( + method: HTTPMethod, + path: String, + body: Data? = nil, + additionalHeaders: [String: String] = [:] + ) async throws -> Response + + /// Mirrors Python `request` -- auto-decodes JSON dict. + /// If path starts with "http://" or "https://", uses it directly (for polling URLs). + /// Otherwise joins with `configuration.backendURL`. + public func request( + method: HTTPMethod, + path: String, + body: Data? = nil, + additionalHeaders: [String: String] = [:] + ) async throws -> [String: Any] + + /// Convenience: GET request. Mirrors Python `get()`. + public func get(_ path: String) async throws -> [String: Any] + + /// Convenience: POST request. Mirrors Python `post()`. + public func post(_ path: String, json: Encodable) async throws -> [String: Any] + + /// Streaming request for SSE. Mirrors Python `request_stream()`. + public func requestStream( + method: HTTPMethod, + path: String, + body: Data? = nil + ) -> AsyncThrowingStream +} + +public enum HTTPMethod: String, Sendable { + case get = "GET" + case post = "POST" + case put = "PUT" + case delete = "DELETE" +} +``` + +### URL resolution (mirrors Python v2 `request_raw`) + +```swift +/// Mirrors Python v2: if path starts with http, use directly; else urljoin with base. +private func resolveURL(_ path: String) throws -> URL { + if path.hasPrefix("http://") || path.hasPrefix("https://") { + guard let url = URL(string: path) else { throw ClientError.invalidURL(path) } + return url + } + guard let url = URL(string: path, relativeTo: configuration.backendURL) else { + throw ClientError.invalidURL(path) + } + return url +} +``` + +### Retry strategy (mirrors Python v2 `create_retry_session`) + +```swift +/// Retry configuration matching Python v2 defaults. +public struct RetryPolicy: Sendable { + public var maxRetries: Int // Python default: 5 + public var backoffFactor: Double // Python default: 0.1 + public var retryableStatusCodes: Set // Python default: [500, 502, 503, 504] + + public static let `default` = RetryPolicy( + maxRetries: 5, + backoffFactor: 0.1, + retryableStatusCodes: [500, 502, 503, 504] + ) +} +``` + +Retry logic: on retryable status codes, wait `backoffFactor * 2^attempt` seconds, retry up to `maxRetries` times. Only GET and POST are retried (matching Python v2 `allowed_methods=frozenset({"GET", "POST"})`). + +### Error handling (mirrors Python v2 `request_raw` error path) + +```swift +// Mirrors Python v2: +// if not response.ok: +// error_obj = response.json() +// raise APIError(error_obj.get("message", ...), status_code=..., response_data=...) +private func handleErrorResponse(_ response: Response, url: URL, method: HTTPMethod) throws -> Never { + if let errorDict = try? JSONSerialization.jsonObject(with: response.data) as? [String: Any] { + throw AixplainError.api(APIError( + message: errorDict["message"] as? String + ?? errorDict["error"] as? String + ?? HTTPURLResponse.localizedString(forStatusCode: response.statusCode), + statusCode: errorDict["statusCode"] as? Int ?? response.statusCode, + responseData: errorDict, + error: errorDict["error"] as? String + )) + } + throw AixplainError.api(APIError( + message: String(data: response.data, encoding: .utf8) ?? "Unknown error", + statusCode: response.statusCode, + responseData: nil, + error: nil + )) +} +``` + +### ClientConfiguration (mirrors `Aixplain` class-level URLs in `core.py`) + +```swift +/// Configurable transport settings. +/// Default URLs match Python v2 `Aixplain` class attributes in core.py. +public struct ClientConfiguration: Sendable { + public var backendURL: URL + public var modelsRunURL: URL + public var timeoutInterval: TimeInterval + public var retryPolicy: RetryPolicy + public var userAgent: String + + public static let `default` = ClientConfiguration( + backendURL: URL(string: "https://platform-api.aixplain.com")!, + modelsRunURL: URL(string: "https://models.aixplain.com/api/v2/execute")!, + timeoutInterval: 30, + retryPolicy: .default, + userAgent: "aiXplainKit-Swift/2.0" + ) +} +``` + +### Response wrapper + +```swift +/// Unified response from the client. +public struct Response: Sendable { + public let data: Data + public let httpResponse: HTTPURLResponse + public var statusCode: Int { httpResponse.statusCode } + public var isSuccess: Bool { (200..<300).contains(statusCode) } + + public func decode(_ type: T.Type, using decoder: JSONDecoder = .init()) throws -> T { + try decoder.decode(type, from: data) + } + + public func json() throws -> [String: Any] { + guard let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw ClientError.invalidJSON + } + return dict + } +} +``` + +### Aixplain entry point (mirrors `core.py`) + +```swift +/// Main entry point for the aiXplain Swift SDK v2. +/// Mirrors Python v2 `Aixplain` class in core.py. +/// +/// Usage: +/// let aix = try Aixplain(apiKey: "your-team-key") +/// let agent = try await aix.Agent.get("agent-id") +/// let result = try await agent.run("Hello!") +public final class Aixplain: @unchecked Sendable { + public let client: AixplainClient + + /// Resource types bound to this context (mirrors Python v2 init_resources). + /// Each resource type holds a reference to this Aixplain instance. + public let Agent: AgentResourceAccessor + public let Model: ModelResourceAccessor + // ... Tool, Utility, Integration, etc. + + // Enum conveniences (mirrors Python v2 `aix.Function.TRANSLATION`) + public typealias Function = AIFunction + public typealias Supplier = Supplier + + /// Convenience init matching Python v2: + /// `Aixplain(api_key=None, backend_url=None, ...)` + public init( + apiKey: String? = nil, + backendURL: URL? = nil, + modelURL: URL? = nil + ) throws { + let credential = try Credential.resolve(apiKey: apiKey) + var config = ClientConfiguration.default + if let url = backendURL { config.backendURL = url } + if let url = modelURL { config.modelsRunURL = url } + + self.client = AixplainClient(credential: credential, configuration: config) + + // Bind resource accessors to this context + self.Agent = AgentResourceAccessor(context: self) + self.Model = ModelResourceAccessor(context: self) + } +} + +/// Mirrors Python v2 pattern where resources access `self.context.client`. +/// In Swift, each resource accessor holds a reference to the Aixplain context. +public struct AgentResourceAccessor { + let context: Aixplain + + public func get(_ id: String) async throws -> Agent { ... } + public func search(...) async throws -> Page { ... } + // ... create, list +} +``` + +### RESOURCE_PATH convention (mirrors Python v2) + +Python v2 resources define their API path as a class-level constant: + +```python +class Agent(BaseResource, ...): + RESOURCE_PATH = "v2/agents" + +class Model(BaseResource, ...): + RESOURCE_PATH = "sdk/models" + +class Tool(Model, ...): + RESOURCE_PATH = "v2/tools" +``` + +Swift v2 will follow the same pattern: + +```swift +protocol ResourcePathProviding { + static var resourcePath: String { get } +} + +extension Agent: ResourcePathProviding { + static let resourcePath = "v2/agents" +} +``` + +## Shared Contracts + +### Consumes from other RFCs + +| Type | From RFC | How it's used | +|------|----------|---------------| +| `Credential` | RFC-0001 | Stored on `AixplainClient`; provides auth headers for every request | +| `AuthError` | RFC-0001 | Thrown by `Credential.resolve()` during `Aixplain` init | +| `AixplainError` | RFC-0005 | Thrown by `handleErrorResponse()` on non-2xx HTTP responses | +| `APIError` | RFC-0005 | Wrapped in `AixplainError.api()` for HTTP failures | + +### Produces for other RFCs + +| Type | Consumed by | How it's used | +|------|-------------|---------------| +| `AixplainClient` | RFC-0003 (Agent), RFC-0007 (Model), RFC-0008 (Tool), RFC-0009 (Index) | All resources call `context.client.get/post/request` for HTTP | +| `ClientConfiguration` | All resource RFCs | Provides `backendURL`, `modelsRunURL` for URL resolution | +| `Response` | All resource RFCs | Returned by client methods; decoded into resource-specific types | +| `Aixplain` | All resource RFCs | Entry point; holds `client` and resource accessors; set as `context` on resources | +| `HTTPMethod` | All resource RFCs | Used by `Runnable.buildRunPayload`, `Deletable.delete`, etc. | + +## Implementation + +Clean-slate: delete all v1 networking and provider code, replace with new client and entry point. + +### Files to delete + +- `Sources/aiXplainKit/Networking/Networking.swift` +- `Sources/aiXplainKit/Networking/Networking+Endpoint.swift` +- `Sources/aiXplainKit/Networking/Networking+Metadata.swift` +- `Sources/aiXplainKit/aiXplainKit.swift` (the `AiXplainKit.shared` singleton) +- All providers under `Sources/aiXplainKit/Provider/` + +### Files to create + +| File | Content | +|------|---------| +| `Sources/aiXplainKit/Client/AixplainClient.swift` | Core client with request/get/post/stream | +| `Sources/aiXplainKit/Client/ClientConfiguration.swift` | Configuration + RetryPolicy | +| `Sources/aiXplainKit/Client/Response.swift` | Response wrapper with decode/json | +| `Sources/aiXplainKit/Client/HTTPMethod.swift` | HTTP method enum | +| `Sources/aiXplainKit/Client/ClientError.swift` | Client-level errors | +| `Sources/aiXplainKit/Aixplain.swift` | Top-level entry point | + +## Testing + +- Unit: `AixplainClient` attaches correct auth headers from `Credential`. +- Unit: URL resolution -- relative paths join with `backendURL`, absolute URLs pass through. +- Unit: retry logic -- mock transport returning 500s retried up to `maxRetries`, then throws. +- Unit: retry logic -- only GET and POST are retried (matching Python v2). +- Unit: exponential backoff timing -- `backoffFactor * 2^attempt`. +- Unit: non-retryable status codes (400, 401, 403, 404) throw immediately. +- Unit: error response parsing -- JSON body with `message`/`error` fields mapped to `APIError`. +- Unit: `Response.decode()` success and failure paths. +- Unit: `Aixplain` init resolves credential and creates client. +- Unit: `Aixplain` init with explicit URLs overrides defaults. +- Integration: end-to-end GET to a health endpoint through `Aixplain`. + +## Out of Scope + +- WebSocket transport (no current platform need). +- Request interceptors / middleware chain (may revisit post-v2). +- Certificate pinning (platform uses standard TLS). +- Multipart upload support (handled by dedicated file upload utilities). + +## Resolved Questions + +1. **`AixplainClient` uses `URLSession` directly** -- simplest approach. Testability via `URLProtocol` stubbing. +2. **No environment variable overrides for URLs** -- only explicit parameters on `Aixplain.init()`. Credentials still resolve from env via `Credential.resolve()`. +3. **Switch to `/api/v2/execute`** -- confirmed. Default `modelsRunURL` is `https://models.aixplain.com/api/v2/execute`. +4. **`Aixplain` exposes enum conveniences** -- e.g., `aix.Function.translation`, `aix.Supplier.openai`, mirroring Python v2 `aix.Function.TRANSLATION`. diff --git a/docs/rfcs/RFC-0003-agents-v2-api-and-lifecycle.md b/docs/rfcs/RFC-0003-agents-v2-api-and-lifecycle.md new file mode 100644 index 0000000..fd8ccae --- /dev/null +++ b/docs/rfcs/RFC-0003-agents-v2-api-and-lifecycle.md @@ -0,0 +1,687 @@ +# RFC-0003: Agents v2 API and Lifecycle + +| Field | Value | +|--------------|------------------------------------------| +| Status | Implemented | +| Authors | | +| Created | 2026-03-06 | +| Depends on | RFC-0001, RFC-0002, RFC-0004, RFC-0005, RFC-0008 | +| Depended by | -- | +| Priority | **P0 -- Highest-priority domain RFC** | + +> This is the most important domain RFC in the v2 sequence. Agents are the primary product surface and the first resource type to be fully migrated after the Auth and Client foundations are in place. + +## Context + +### Current Swift SDK (what exists) + +The agent surface is split across multiple files and types: + +- **`Agent`** (`Agents.swift`) -- `Codable` class with `id`, `name`, `status`, `teamId`, `llmId`, `role`, `assets`. Owns its own `Networking` instance. Has two `run()` variants and private `polling()`. +- **`AgentProvider`** (`AgentProvider.swift`) -- separate class for `get()`, `list()`, `create()`. +- **`Agent+CRUD`** (`Agents+CRUD.swift`) -- `deploy()`, `appendTools()`, `update()`, `delete()` as instance methods. +- **`TeamAgent`** -- parallel hierarchy with its own provider. +- **Tools** -- `Tool` struct, `CreateAgentTool` enum, `AgentUsableTool` protocol. + +### How Python v2 structures Agents (`agent.py` + `resource.py`) + +The Python v2 `Agent` class is the most complex resource in the SDK. Understanding its architecture is critical. + +**Inheritance chain:** + +```python +@dataclass(repr=False) +class Agent( + BaseResource, # save(), clone(), _action() + SearchResourceMixin[BaseSearchParams, "Agent"], # search() with pagination + GetResourceMixin[BaseGetParams, "Agent"], # get() class method + DeleteResourceMixin[BaseDeleteParams, "Agent"], # delete() instance method + RunnableResourceMixin[AgentRunParams, AgentRunResult], # run(), run_async(), poll() +): + RESOURCE_PATH = "v2/agents" + RESPONSE_CLASS = AgentRunResult +``` + +**Key fields (from `agent.py`):** + +```python +instructions: Optional[str] = None +status: AssetStatus = AssetStatus.DRAFT +team_id: Optional[int] = field(metadata=config(field_name="teamId")) +llm: Union[str, "Model"] = field(default=DEFAULT_LLM) +tools: Optional[List[Dict[str, Any]]] = field(default_factory=list) +tasks: Optional[List[Task]] = field(default_factory=list) +subagents: Optional[List[Union[str, "Agent"]]] = field(field_name="agents") +output_format: Optional[Union[str, OutputFormat]] = field(field_name="outputFormat") +expected_output: Optional[Union[str, dict, BaseModel]] = field(field_name="expectedOutput") +inspector_id, supervisor_id, planner_id # agent orchestration +max_iterations: Optional[int] = 5 +max_tokens: Optional[int] = 2048 +``` + +**AgentRunParams (TypedDict):** + +```python +class AgentRunParams(BaseRunParams): + sessionId: NotRequired[Optional[Text]] + query: NotRequired[Optional[Union[Dict, Text]]] + variables: NotRequired[Optional[Dict[str, Any]]] # {{var}} substitution + allowHistoryAndSessionId: NotRequired[Optional[bool]] + tasks: NotRequired[Optional[List[Any]]] + prompt: NotRequired[Optional[Text]] + history: NotRequired[Optional[List[ConversationMessage]]] + executionParams: NotRequired[Optional[Dict[str, Any]]] + criteria: NotRequired[Optional[Text]] + evolve: NotRequired[Optional[Text]] + inspectors: NotRequired[Optional[List[Dict]]] + runResponseGeneration: NotRequired[Optional[bool]] + progress_format: NotRequired[Optional[Text]] # "status" or "logs" + progress_verbosity: NotRequired[Optional[int]] # 1, 2, or 3 + progress_truncate: NotRequired[Optional[bool]] +``` + +**AgentRunResult:** + +```python +@dataclass +class AgentRunResult(Result): + data: Optional[Union[AgentResponseData, Text]] = None + session_id: Optional[Text] = field(field_name="sessionId") + request_id: Optional[Text] = field(field_name="requestId") + used_credits: float = field(field_name="usedCredits") + run_time: float = field(field_name="runTime") + _context: Optional[Any] = None # for debug() method + + def debug(self, prompt=None, execution_id=None) -> DebugResult: + # convenience method to debug this response +``` + +**Lifecycle hooks (from `resource.py` `with_hooks` decorator + agent overrides):** + +```python +def before_run(self, **kwargs): + # 1. Validate all dependencies are saved + self._validate_run_dependencies() + # 2. Auto-save draft agents + if self.status in [AssetStatus.DRAFT, None] and self.is_modified: + self.save(as_draft=True) + # 3. Initialize progress tracker if progress_format is provided + +def after_run(self, result, **kwargs): + # 1. Finish progress tracking + # 2. Set result._context for debug() support + +def before_save(self, **kwargs): + as_draft = kwargs.pop("as_draft", False) + self.status = AssetStatus.DRAFT if as_draft else AssetStatus.ONBOARDED + self._validate_expected_output() + +def build_save_payload(self, **kwargs): + payload = self.to_dict() + # Convert tools via ToolableMixin.as_tool() + # Convert {{var}} to {var} for backend + # Set payload["model"] = {"id": self.llm} + # Resolve subagent IDs + +def build_run_payload(self, **kwargs): + # Build executionParams with defaults (outputFormat, maxTokens, maxIterations, maxTime) + # Handle BaseModel expectedOutput conversion + # Process variables for {{placeholder}} substitution + # Build final payload with id, executionParams, runResponseGeneration, query +``` + +**Session management:** + +```python +def generate_session_id(self, history=None) -> str: + # Format: "{agent_id}_{timestamp}" + # If history provided, validate and initialize via run_async + session_id = f"{self.id}_{timestamp}" + if history: + validate_history(history) + self.run_async(query="/", sessionId=session_id, history=history, ...) + return session_id +``` + +**Task dependencies:** + +```python +@dataclass +class Task: + name: str + instructions: Optional[str] = field(field_name="description") + expected_output: Optional[str] = field(field_name="expectedOutput") + dependencies: List[Union[str, "Task"]] = field(default_factory=list) + + def __post_init__(self): + # Resolve Task references to name strings + self.dependencies = [d if isinstance(d, str) else d.name for d in self.dependencies] +``` + +**Conversation history:** + +```python +class ConversationMessage(TypedDict): + role: Literal["user", "assistant"] + content: str + +def validate_history(history): + # Must be list of dicts with "role" and "content" + # role must be "user" or "assistant" + # content must be string +``` + +### Problems in the current Swift SDK + +1. **Split responsibilities** -- CRUD on `Agent`, fetching on `AgentProvider`, creation on `AgentProvider+BuildAgents`. +2. **Each agent owns networking** -- `Agent` creates `Networking()` in `init(from:)`. +3. **No session model** -- `sessionID` is a pass-through string with no lifecycle. +4. **No conversation history** -- Python v2 has `ConversationMessage` and `validate_history()`. +5. **No output format** -- Python v2 has `OutputFormat` (markdown, text, json). +6. **No progress tracking** -- Python v2 has `AgentProgressTracker`. +7. **No tasks/dependencies** -- Python v2 supports `Task` objects. +8. **No hook system** -- Python v2 has `before_run`, `after_run`, `before_save` hooks via `@with_hooks`. +9. **No auto-save** -- Python v2 auto-saves draft agents before run. +10. **No modification tracking** -- Python v2 tracks `is_modified` via saved state diffing. +11. **No variables/placeholder substitution** -- Python v2 supports `{{var}}` in instructions. +12. **No expected output / structured output** -- Python v2 supports Pydantic BaseModel schemas. +13. **TeamAgent is separate** -- Python v2 models team agents as agents with `subagents` field. +14. **Tool type typo** -- `ToolType.pipiline`. +15. **`list(ModelQuery)` returns `[]`** -- unfinished. + +## Decision + +Unify the agent surface into a single `Agent` resource type that uses the v2 `AixplainClient` (RFC-0002), following the Python v2 mixin pattern adapted to Swift protocols. + +## API Shape + +### Agent (mirrors Python v2 `Agent` dataclass) + +```swift +/// Agent resource. +/// Mirrors Python v2: `class Agent(BaseResource, SearchResourceMixin, GetResourceMixin, +/// DeleteResourceMixin, RunnableResourceMixin)`. +public final class Agent: @unchecked Sendable { + /// Python v2: `RESOURCE_PATH = "v2/agents"` + public static let resourcePath = "v2/agents" + + // Core fields (mirrors Python v2 agent.py fields) + public var id: String? + public var name: String? + public var description: String? + public var instructions: String? + public var status: AssetStatus = .draft + public var teamId: Int? + public var llmId: String = Agent.defaultLLM + + // Tools and subagents + public var tools: [Any] = [] // Tool, Model, or dict + public var subagents: [Any] = [] // Agent or String IDs + public var tasks: [AgentTask] = [] + + // Output control + public var outputFormat: OutputFormat = .text + public var expectedOutput: ExpectedOutput? = nil + public var maxIterations: Int = 5 + public var maxTokens: Int = 2048 + + // Metadata (read-only from API) + public private(set) var createdAt: Date? + public private(set) var updatedAt: Date? + + // Inspector/supervisor IDs + public var inspectorId: String? + public var supervisorId: String? + public var plannerId: String? + + // Strong context reference (mirrors Python v2 `context` class attribute) + // Decision: strong ref, not weak -- agent always needs its context. + var context: Aixplain? + + // Modification tracking (mirrors Python v2 `_saved_state` + `is_modified`) + private var savedState: [String: Any]? + + public var isModified: Bool { /* diff current vs savedState */ } + + static let defaultLLM = "669a63646eb56306647e1091" +} + +public enum AssetStatus: String, Codable, Sendable { + case draft, hidden, scheduled, onboarding, onboarded + case pending, failed, training, rejected + case enabling, deleting, disabled, deleted + case inProgress = "in_progress" + case completed, canceling, canceled + case deprecatedDraft = "deprecated_draft" +} + +public enum OutputFormat: String, Codable, Sendable { + case markdown, text, json +} +``` + +### CRUD (mirrors Python v2 mixins) + +```swift +// Mirrors Python v2 GetResourceMixin.get() +extension Agent { + /// Get agent by ID. + /// Python v2: `Agent.get(id)` → `context.client.get(f"{RESOURCE_PATH}/{id}")` + public static func get(_ id: String, context: Aixplain) async throws -> Agent +} + +// Mirrors Python v2 SearchResourceMixin.search() +extension Agent { + /// Search/list agents with pagination. + /// Python v2: `Agent.search(**kwargs)` → POST to `{RESOURCE_PATH}/paginate` + public static func search( + query: String? = nil, + pageNumber: Int = 0, + pageSize: Int = 20, + context: Aixplain + ) async throws -> Page +} + +// Mirrors Python v2 BaseResource.save() +extension Agent { + /// Save the agent. Creates if no ID, updates if ID exists. + /// Python v2: `agent.save(as_draft=True)` → POST/PUT to RESOURCE_PATH + public func save(asDraft: Bool = false) async throws -> Agent + + /// Clone the agent (deep copy with id=nil). + /// Python v2: `agent.clone(name="new")` + public func clone(name: String? = nil) -> Agent +} + +// Mirrors Python v2 DeleteResourceMixin.delete() +extension Agent { + /// Delete this agent. + /// Python v2: `agent.delete()` → DELETE to `{RESOURCE_PATH}/{id}` + public func delete() async throws +} +``` + +### Run / Execution (mirrors Python v2 `RunnableResourceMixin`) + +```swift +/// Run parameters matching Python v2 `AgentRunParams`. +public struct AgentRunParams: Sendable { + public var query: QueryInput? + public var sessionId: String? + public var variables: [String: Any]? + public var history: [ConversationMessage]? + public var tasks: [AgentTask]? + public var prompt: String? + public var executionParams: ExecutionParams? + public var runResponseGeneration: Bool = true + + // Progress tracking (mirrors Python v2) + public var progressFormat: ProgressFormat? + public var progressVerbosity: Int = 1 + public var progressTruncate: Bool = true + + public var timeout: TimeInterval = 300 + public var waitTime: TimeInterval = 0.5 +} + +public enum QueryInput: Sendable { + case text(String) + case structured([String: Any]) +} + +public struct ExecutionParams: Codable, Sendable { + public var outputFormat: OutputFormat = .text + public var maxTokens: Int = 2048 + public var maxIterations: Int = 5 + public var maxTime: Int = 300 + public var expectedOutput: ExpectedOutput? +} + +/// Conversation message matching Python v2 `ConversationMessage`. +public struct ConversationMessage: Codable, Sendable { + public let role: MessageRole + public let content: String +} + +public enum MessageRole: String, Codable, Sendable { + case user, assistant +} + +/// Task with dependencies matching Python v2 `Task`. +public struct AgentTask: Codable, Sendable { + public let name: String + public let instructions: String? + public let expectedOutput: String? + public var dependencies: [String] = [] +} + +/// Run result matching Python v2 `AgentRunResult`. +public struct AgentRunResult: Sendable { + public let status: String + public let completed: Bool + public let data: AgentResponseData? + public let sessionId: String? + public let requestId: String? + public let usedCredits: Double + public let runTime: Double + public let errorMessage: String? + public let supplierError: String? + + // For polling + public let url: String? +} + +public struct AgentResponseData: Codable, Sendable { + public let input: String? + public let output: String? + public let steps: [[String: Any]]? + public let sessionId: String? + public let executionStats: [String: Any]? +} + +public enum ProgressFormat: String, Sendable { + case status // single line + case logs // timeline +} + +/// Progress tracking via delegate/closure pattern (not AsyncStream). +public protocol AgentProgressDelegate: AnyObject { + func agent(_ agent: Agent, didUpdateProgress step: AgentProgressStep) + func agent(_ agent: Agent, didCompleteWithResult result: AgentRunResult) +} + +public struct AgentProgressStep: Sendable { + public let status: String + public let message: String? + public let stepIndex: Int? + public let totalSteps: Int? +} + +/// TeamAgent is just an Agent with subagents. +/// Kept as typealias for discoverability. +public typealias TeamAgent = Agent +``` + +### Run methods (mirrors Python v2 `RunnableResourceMixin.run()` / `run_async()`) + +```swift +extension Agent { + /// Run synchronously with automatic polling. + /// Mirrors Python v2: `agent.run(query="Hello")` which internally calls + /// `run_async()` then `sync_poll()`. + /// Uses `self.context.client` for HTTP -- no need to pass client. + public func run(_ query: String, sessionId: String? = nil) async throws -> AgentRunResult + + /// Run with full params. + public func run(_ params: AgentRunParams) async throws -> AgentRunResult + + /// Run asynchronously -- returns immediately with polling URL. + /// Mirrors Python v2: `agent.run_async(query="Hello")` + public func runAsync(_ params: AgentRunParams) async throws -> AgentRunResult + + /// Poll a URL for completion. + /// Mirrors Python v2: `agent.poll(poll_url)` + public func poll(_ url: String) async throws -> AgentRunResult + + /// Poll until completion with exponential backoff. + /// Mirrors Python v2: `agent.sync_poll(url, timeout=300, wait_time=0.5)` + public func syncPoll( + _ url: String, + timeout: TimeInterval = 300, + waitTime: TimeInterval = 0.5 + ) async throws -> AgentRunResult + + /// Generate a unique session ID. + /// Mirrors Python v2: `agent.generate_session_id(history=None)` + /// Format: "{agent_id}_{timestamp}" + public func generateSessionId(history: [ConversationMessage]? = nil) async throws -> String +} +``` + +### Hook system (mirrors Python v2 `@with_hooks` + before/after methods) + +```swift +extension Agent { + /// Called before run. Mirrors Python v2 `before_run`: + /// 1. Validate dependencies are saved + /// 2. Auto-save draft agents if modified + /// 3. Initialize progress tracker + func beforeRun(_ params: AgentRunParams) async throws + + /// Called after run. Mirrors Python v2 `after_run`: + /// 1. Finish progress tracking + /// 2. Set context on result for debug support + func afterRun(_ result: AgentRunResult) -> AgentRunResult + + /// Called before save. Mirrors Python v2 `before_save`: + /// 1. Set status to draft or onboarded + /// 2. Validate expected output + func beforeSave(asDraft: Bool) throws + + /// Build the save payload. Mirrors Python v2 `build_save_payload`: + /// 1. Serialize to dict + /// 2. Convert tools via as_tool() + /// 3. Convert {{var}} to {var} + /// 4. Set model.id + /// 5. Resolve subagent IDs + func buildSavePayload() throws -> [String: Any] + + /// Build the run payload. Mirrors Python v2 `build_run_payload`: + /// 1. Build executionParams with defaults + /// 2. Process variables + /// 3. Build final payload + func buildRunPayload(_ params: AgentRunParams) throws -> [String: Any] +} +``` + +### Save payload construction (mirrors Python v2 `build_save_payload`) + +The Python v2 save payload has specific transformations: + +``` +payload = self.to_dict() + +# 1. Convert tools via ToolableMixin +for tool in self.tools: + if isinstance(tool, ToolableMixin): + converted_assets.append(tool.as_tool()) + +# 2. Template variables: {{var}} → {var} +payload["instructions"] = re.sub(r"\{\{(\w+)\}\}", r"{\1}", payload["instructions"]) + +# 3. LLM reference +payload["model"] = {"id": self.llm} + +# 4. Subagent IDs +for agent in self._original_subagents: + converted_agents.append({"id": agent.id, "inspectors": []}) +``` + +### Conversation history validation (mirrors Python v2 `validate_history`) + +```swift +/// Validates conversation history for agent sessions. +/// Mirrors Python v2 `validate_history()` exactly. +public static func validateHistory(_ history: [ConversationMessage]) throws { + for (index, message) in history.enumerated() { + guard !message.content.isEmpty else { + throw ValidationError("'content' at index \(index) must not be empty.") + } + // role is already constrained by MessageRole enum + } +} +``` + +### TeamAgent (mirrors Python v2 `subagents` field) + +In Python v2, team agents are just agents with `subagents` populated: + +```python +subagents: Optional[List[Union[str, "Agent"]]] = field(field_name="agents") +``` + +Swift v2 should follow the same pattern -- `Agent` has an optional `subagents` field. The v1 `TeamAgent` class is deleted. + +```swift +// An agent with subagents is a team agent. +// Python v2 does not have a separate TeamAgent class. +extension Agent { + public var isTeamAgent: Bool { !subagents.isEmpty } +} +``` + +## Lifecycle Flow + +``` +┌────────────┐ ┌────────────┐ +│ Agent(...) │──save(draft)──▶│ draft │ +└────────────┘ └─────┬──────┘ + │ + save()/clone() + │ + ┌───────────┼──────────┐ + │ │ │ + save(onboard) delete() save(draft) + │ │ │ + ▼ ▼ ▼ + ┌──────────┐ ┌────────┐ ┌────────┐ + │onboarded │ │deleted │ │ draft │ + └────┬─────┘ └────────┘ └────────┘ + │ + run() / runAsync() + │ + ┌───────────┼──────────┐ + │ │ + [auto-save if poll() + draft+modified] │ + │ ▼ + ▼ ┌──────────────┐ + AgentRunResult │sync_poll loop│ + │ on_poll() │ + └──────┬───────┘ + │ + ▼ + AgentRunResult +``` + +## Shared Contracts + +### Consumes from other RFCs + +| Type | From RFC | How it's used | +|------|----------|---------------| +| `Credential` | RFC-0001 | Via `context.client` for all HTTP calls | +| `AixplainClient` | RFC-0002 | Via `context.client` -- `get()`, `post()`, `request()` for CRUD and run | +| `Aixplain` | RFC-0002 | Stored as `weak var context`; provides client and configuration | +| `ClientConfiguration` | RFC-0002 | `context.client.configuration.backendURL` for URL resolution | +| `BaseResource` | RFC-0004 | Agent conforms to `BaseResource` protocol for `save()`/`clone()` | +| `Gettable` | RFC-0004 | Agent conforms for `Agent.get()` | +| `Searchable` | RFC-0004 | Agent conforms for `Agent.search()` returning `Page` | +| `Deletable` | RFC-0004 | Agent conforms for `agent.delete()` | +| `Runnable` | RFC-0004 | Agent conforms for `run()`/`runAsync()`/`poll()`/`syncPoll()` | +| `Page` | RFC-0004 | Return type of `Agent.search()` | +| `AgentToolConvertible` | RFC-0004 | Used in `buildSavePayload()` to serialize tools via `asAgentTool()` | +| `AgentToolDict` | RFC-0004 | The serialized form of tools in the agent save payload | +| `AssetStatus` | RFC-0004 | Agent status field (draft, onboarded, deleted, etc.) | +| `ResponseStatus` | RFC-0004 | Used in polling to check for SUCCESS/FAILED | +| `AixplainError` | RFC-0005 | Thrown by `poll()` on failure, `syncPoll()` on timeout | +| `APIError` | RFC-0005 | Thrown via `APIError.fromFailedOperation()` when polling returns FAILED | +| `TimeoutError` | RFC-0005 | Thrown by `syncPoll()` when timeout exceeded | +| `ValidationError` | RFC-0005 | Thrown by `validateHistory()` and `beforeRun()` | +| `Tool` (as `AgentToolConvertible`) | RFC-0008 | Tools in `agent.tools` are serialized via `tool.asAgentTool()` | +| `Model` (as `AgentToolConvertible`) | RFC-0007 | Models can be used as agent tools via `model.asAgentTool()` | + +### Produces for other RFCs + +| Type | Consumed by | How it's used | +|------|-------------|---------------| +| `Agent` | RFC-0008 (subagents) | Agent instances can be subagents of other agents | +| `AgentRunResult` | RFC-0005 (contract tests) | Decoded from polling responses; validated by contract fixtures | +| `ConversationMessage` | -- | Self-contained; used in agent sessions | +| `AgentTask` | -- | Self-contained; used in agent task workflows | +| `OutputFormat` | -- | Self-contained; used in execution params | + +### Key interaction: Agent → Tool serialization flow + +``` +Agent.save() + └── buildSavePayload() + └── for tool in self.tools: + └── if tool conforms to AgentToolConvertible (RFC-0004): + └── tool.asAgentTool() → AgentToolDict (RFC-0004) + └── Model.asAgentTool() (RFC-0007) + └── Tool.asAgentTool() (RFC-0008) with actions list +``` + +## Implementation + +Clean-slate: delete all v1 agent code and build from scratch following the Python v2 architecture. + +### Files to delete + +- `Sources/aiXplainKit/Modules/Agents/Agents.swift` +- `Sources/aiXplainKit/Modules/Agents/Agents+CRUD.swift` +- `Sources/aiXplainKit/Modules/Agents/Input/` (entire directory) +- `Sources/aiXplainKit/Modules/Agents/Tools/` (entire directory) +- `Sources/aiXplainKit/Modules/TeamAgents/` (entire directory) +- `Sources/aiXplainKit/Modules/Parameters/Agent/` +- `Sources/aiXplainKit/Provider/Agent/` (entire directory) +- `Sources/aiXplainKit/Provider/TeamAgent/` (entire directory) +- `Sources/aiXplainKit/Networking/ResponseDecoders/AgentOutput.swift` +- `Sources/aiXplainKit/Networking/ResponseDecoders/AgentExecuteResponse.swift` + +### Files to create + +| File | Content | +|------|---------| +| `Sources/aiXplainKit/Modules/Agents/Agent.swift` | `Agent` class with all fields, save/clone/delete/run | +| `Sources/aiXplainKit/Modules/Agents/AgentRunParams.swift` | Run parameters | +| `Sources/aiXplainKit/Modules/Agents/AgentRunResult.swift` | Run result with session metadata | +| `Sources/aiXplainKit/Modules/Agents/ConversationMessage.swift` | History model + validation | +| `Sources/aiXplainKit/Modules/Agents/AgentTask.swift` | Task with dependencies | +| `Sources/aiXplainKit/Modules/Agents/OutputFormat.swift` | Output format enum | +| `Sources/aiXplainKit/Modules/Agents/AssetStatus.swift` | Shared status enum | + +## Testing + +- Unit: `Agent.get()` dispatches GET to `v2/agents/{id}`. +- Unit: `Agent.search()` dispatches POST to `v2/agents/paginate` with filters. +- Unit: `Agent.save()` dispatches POST (create) or PUT (update) based on `id`. +- Unit: `Agent.save(asDraft: true)` sets status to `.draft`. +- Unit: `Agent.save(asDraft: false)` sets status to `.onboarded`. +- Unit: `beforeRun` auto-saves draft agents that are modified. +- Unit: `beforeRun` validates all tool/subagent dependencies are saved. +- Unit: `buildSavePayload` converts tools via `asAgentTool()`. +- Unit: `buildSavePayload` converts `{{var}}` to `{var}` in instructions. +- Unit: `buildSavePayload` sets `model.id` from `llmId`. +- Unit: `buildRunPayload` builds `executionParams` with defaults. +- Unit: `buildRunPayload` processes `variables` into query dict. +- Unit: `run()` calls `runAsync()` then `syncPoll()`. +- Unit: `syncPoll` loops with exponential backoff until `completed == true`. +- Unit: `syncPoll` throws `TimeoutError` after timeout. +- Unit: `poll()` raises `APIError` on `status == "FAILED"`. +- Unit: `generateSessionId` returns `"{id}_{timestamp}"` format. +- Unit: `generateSessionId` with history validates and initializes session. +- Unit: `validateHistory` rejects non-dict, missing role/content, invalid roles. +- Unit: `AgentRunResult` decodes from fixture JSON with all fields. +- Unit: `AgentTask` resolves Task references to name strings in dependencies. +- Unit: `clone()` produces copy with `id = nil`. +- Integration: create → save(draft) → save(onboard) → run → delete lifecycle. + +## Out of Scope + +- Inspector / Debugger integration (separate RFC candidate; Python v2 has `inspector.py`, `meta_agents.py`). +- Agent progress tracker implementation (will use the hook system; `agent_progress.py` is complex). +- Custom LLM provider integration (provider-level concern). +- Code interpreter support (Python v2 has `CodeInterpreterModel` enum). +- Integration / Action system (Python v2 `integration.py` -- separate RFC). +- `result.debug()` method (requires Debugger meta-agent; separate RFC). + +## Resolved Questions + +1. **`Agent` holds a strong reference to `Aixplain` context** -- matches Python v2 `context` class attribute. No need to pass `using: client` on every call. `var context: Aixplain` (strong, not weak). +2. **Fully unify Agent and TeamAgent** -- no separate class. Keep a `public typealias TeamAgent = Agent` for discoverability. An agent with non-empty `subagents` is a team agent. +3. **`as_tool()` via protocol conformance** -- `AgentToolConvertible` protocol from RFC-0004. +4. **`Codable` with custom `CodingKeys`** -- use Swift's native serialization. Map API field names (e.g., `teamId`, `createdAt`) via `CodingKeys`. +5. **Auto-save draft agents in `beforeRun` is acceptable** -- matches Python v2 behavior. Implicit save for convenience. +6. **Progress tracking uses delegate/closure pattern** -- `AgentProgressDelegate` protocol with `didUpdateProgress(_:)`. Not `AsyncStream`. diff --git a/docs/rfcs/RFC-0004-resources-tools-schema-alignment.md b/docs/rfcs/RFC-0004-resources-tools-schema-alignment.md new file mode 100644 index 0000000..edab084 --- /dev/null +++ b/docs/rfcs/RFC-0004-resources-tools-schema-alignment.md @@ -0,0 +1,510 @@ +# RFC-0004: Resources and Tools Schema Alignment + +| Field | Value | +|--------------|------------------------------------------| +| Status | Implemented | +| Authors | | +| Created | 2026-03-06 | +| Depends on | RFC-0002 | +| Depended by | RFC-0003, RFC-0006, RFC-0007, RFC-0008, RFC-0009 | +| Priority | P0 -- Shared foundation | + +## Context + +### Current Swift SDK + +The Swift SDK has overlapping concepts for assets: + +- **`Tool`** -- struct with `id`, `type` (`.model` or `.pipiline` [sic]), `function`, `supplier`, `description`, `version`. +- **`CreateAgentTool`** -- enum: `.model(Model)`, `.pipeline(Pipeline)`, `.asset(assetID, functionString)`, `.utility(UtilityModel)`, `.tool(toolID, functionString)`. +- **`AgentUsableTool`** -- protocol that `Model`, `Pipeline`, `UtilityModel` conform to. +- **`Asset`** -- base type with `id`, `name`, `description`, `supplier`, `version`, `privacy`, `pricing`, `license`. +- **`Model`** -- extends `Asset` with execution capabilities. +- **`UtilityModel`** -- subclass of `Model` for custom code functions. +- File naming typos: `Dictonary+AgentInputable.swift`, `Suplier.swift`. + +### How Python v2 structures resources and tools + +**`BaseResource` (`resource.py`)** is the foundation for all resources: + +```python +@dataclass +class BaseResource: + context: Any # Aixplain instance (excluded from serialization) + RESOURCE_PATH: str # e.g., "v2/agents", "sdk/models" + _saved_state: Optional[dict] = None + + id: Optional[str] = None + name: Optional[str] = None + description: Optional[str] = None + path: Optional[str] = None # e.g., "openai/whisper-large/groq" + + @property + def is_modified(self) -> bool # diff current vs _saved_state + @property + def is_deleted(self) -> bool + + def save(self, **kwargs) -> BaseResource # create or update + def clone(self, **kwargs) -> BaseResource # deep copy with id=None + def _action(self, method, action_paths) -> Response + def build_save_payload(self, **kwargs) -> dict + def _create(self, resource_path, payload) + def _update(self, resource_path, payload) +``` + +**Mixins** provide CRUD capabilities: + +```python +class SearchResourceMixin(Generic[SearchParamsT, ResourceT]): + PAGINATE_PATH = "paginate" # POST {RESOURCE_PATH}/paginate + PAGINATE_METHOD = "post" + PAGINATE_ITEMS_KEY = "results" + PAGINATE_DEFAULT_PAGE_SIZE = 20 + + @classmethod + def search(cls, **kwargs) -> Page[ResourceT] + +class GetResourceMixin(Generic[GetParamsT, ResourceT]): + @classmethod + def get(cls, id, host=None, **kwargs) -> ResourceT + +class DeleteResourceMixin(Generic[DeleteParamsT, DeleteResultT]): + def delete(self, **kwargs) -> DeleteResultT + +class RunnableResourceMixin(Generic[RunParamsT, ResultT]): + RUN_ACTION_PATH = "run" + RESPONSE_CLASS = Result + + def run(self, **kwargs) -> ResultT # sync: run_async + sync_poll + def run_async(self, **kwargs) -> ResultT # just POST, return polling URL + def poll(self, poll_url) -> ResultT # single poll + def sync_poll(self, poll_url, **kwargs) -> ResultT # loop until complete + def on_poll(self, response, **kwargs) # hook for progress updates +``` + +**`ToolableMixin` (`mixins.py`)** defines the `as_tool()` interface: + +```python +class ToolableMixin(ABC): + @abstractmethod + def as_tool(self) -> ToolDict: + # Returns: id, name, description, supplier, parameters, + # function, type, version, assetId + +class ToolDict(TypedDict): + id: str + name: str + description: str + supplier: str + parameters: Optional[List[ParameterDefinition]] + function: Literal["utilities", "text-generation", ...] + type: Literal["model", "pipeline", "utility", "tool"] + version: str + assetId: str +``` + +**`Model` (`model.py`)** is a `BaseResource` + `SearchResourceMixin` + `GetResourceMixin` + `RunnableResourceMixin` + `ToolableMixin`: + +```python +@dataclass +class Model(BaseResource, SearchResourceMixin, GetResourceMixin, + RunnableResourceMixin, ToolableMixin): + RESOURCE_PATH = "sdk/models" + + function: Optional[str] = None + supplier: Optional[str] = None + version: Optional[str] = None + # ... pricing, license, privacy, etc. + + def as_tool(self) -> ToolDict: + return { + "id": self.id, + "name": self.name, + "description": self.description, + "supplier": str(self.supplier_code), + "parameters": self.get_parameters(), + "function": str(self.function), + "type": "model", + "version": str(self.version), + "assetId": self.id, + } + + def run(self, *args, **kwargs) -> ModelResult: + # Merges with dynamic attributes (InputsProxy) + # Validates parameters + # Dispatches via RunnableResourceMixin +``` + +**`Tool` (`tool.py`)** extends `Model` with integration/action support: + +```python +@dataclass +class Tool(Model, DeleteResourceMixin, ActionMixin): + RESOURCE_PATH = "v2/tools" + RESPONSE_CLASS = ToolResult + DEFAULT_INTEGRATION_ID = "686432941223092cb4294d3f" + + asset_id: Optional[str] = None + integration: Optional[Union[Integration, str]] = None + config: Optional[dict] = None + code: Optional[str] = None + allowed_actions: Optional[List[str]] = [] + + def _create(self, resource_path, payload): + # Creates via integration.connect(**payload) instead of standard POST + self._ensure_integration(required=True) + connection = self.integration.connect(**payload) + self.id = connection.id + + def run(self, *args, **kwargs) -> ToolResult: + # Requires action parameter + # Falls back to single allowed action if only one +``` + +**`Page` (`resource.py`)** is a generic pagination container: + +```python +class Page(Generic[ResourceT]): + results: List[ResourceT] + page_number: int + page_total: int + total: int +``` + +**`Result` (`resource.py`)** is the base run result: + +```python +@dataclass +class Result: + status: str + completed: bool + error_message: Optional[str] + url: Optional[str] + result: Optional[Any] + supplier_error: Optional[str] + data: Optional[Any] + _raw_data: Optional[dict] +``` + +### Problems in the current Swift SDK + +1. **No `BaseResource`** -- no shared protocol for CRUD, state tracking, or modification detection. +2. **No mixin pattern** -- each resource type implements its own fetch/save/delete logic. +3. **Vocabulary mismatch** -- `Tool`, `CreateAgentTool`, `AgentUsableTool`, `Asset` are related but named inconsistently. +4. **`ToolType` typo** -- `.pipiline` instead of `.pipeline`. +5. **No `as_tool()` pattern** -- Python v2 lets any `ToolableMixin` become a tool; Swift uses `CreateAgentTool` enum indirection. +6. **No `Page` type** -- list operations return raw arrays with no pagination metadata. +7. **No `Result` base type** -- each resource has its own output type with no shared structure. +8. **No modification tracking** -- no `is_modified`, `_saved_state`, or `clone()`. +9. **No `RESOURCE_PATH`** -- endpoints are defined in `Networking.Endpoint` instead of on the resource. +10. **File naming typos** -- `Suplier.swift`, `Dictonary+AgentInputable.swift`. + +## Decision + +Introduce a Swift protocol-based equivalent of Python v2's `BaseResource` + mixin system, adapted to Swift's type system (protocols instead of multiple inheritance). + +## API Shape + +### BaseResource class (mirrors Python v2 `BaseResource`) + +```swift +/// Mirrors Python v2 `BaseResource`. +/// Base class -- all resources inherit from this. +/// Provides stored properties for state tracking that protocols can't. +public class BaseResource: @unchecked Sendable, Identifiable { + public class var resourcePath: String { "" } + + public var id: String? + public var name: String? + public var description: String? + public var context: Aixplain? + + // State tracking (mirrors Python v2 _saved_state) + private var savedState: [String: Any]? + private var _deleted: Bool = false + + public var isModified: Bool { /* diff current vs savedState */ } + public var isDeleted: Bool { _deleted } + + public func save() async throws -> Self { ... } + public func clone(name: String? = nil) -> Self { ... } + public func buildSavePayload() throws -> [String: Any] { ... } + + func updateSavedState() { ... } + func markAsDeleted() { ... } +} +``` + +### CRUD protocols (mirror Python v2 mixins) + +```swift +/// Mirrors Python v2 `GetResourceMixin`. +public protocol Gettable: BaseResource { + static func get(_ id: String, context: Aixplain) async throws -> Self +} + +/// Mirrors Python v2 `SearchResourceMixin`. +public protocol Searchable: BaseResource { + associatedtype SearchParams + static var paginatePath: String { get } // default: "paginate" + static var paginateMethod: String { get } // default: "post" + static var paginateItemsKey: String { get } // default: "results" + + static func search(_ params: SearchParams, context: Aixplain) async throws -> Page +} + +/// Mirrors Python v2 `DeleteResourceMixin`. +public protocol Deletable: BaseResource { + func delete() async throws +} + +/// Mirrors Python v2 `RunnableResourceMixin`. +public protocol Runnable: BaseResource { + associatedtype RunParams + associatedtype RunResult + + static var runActionPath: String { get } // default: "run" + + func run(_ params: RunParams) async throws -> RunResult + func runAsync(_ params: RunParams) async throws -> RunResult + func poll(_ url: String) async throws -> RunResult + func syncPoll(_ url: String, timeout: TimeInterval, waitTime: TimeInterval) async throws -> RunResult + func onPoll(_ response: RunResult, params: RunParams) + func buildRunPayload(_ params: RunParams) throws -> [String: Any] +} +``` + +### ToolableMixin (mirrors Python v2 `ToolableMixin`) + +```swift +/// Mirrors Python v2 `ToolableMixin`. +/// Any resource conforming to this can be used as an agent tool. +public protocol AgentToolConvertible { + func asAgentTool() -> AgentToolDict +} + +/// Mirrors Python v2 `ToolDict`. +/// Used by RFC-0003 (Agents save payload), RFC-0007 (Model.asAgentTool), +/// RFC-0008 (Tool.asAgentTool with actions). +public struct AgentToolDict: Codable, Sendable { + public var id: String + public var name: String + public var description: String + public var supplier: String + public var parameters: [AnyCodable]? + public var function: String + public var type: ToolType + public var version: String + public var assetId: String + + /// Tool-specific: which actions the agent is allowed to invoke. + /// Set by RFC-0008 Tool.asAgentTool() when allowedActions is non-empty. + public var actions: [String]? +} + +/// Mirrors Python v2 ToolDict["type"] literal. +public enum ToolType: String, Codable, Sendable { + case model + case pipeline + case utility + case tool +} +``` + +### Page (mirrors Python v2 `Page`) + +```swift +/// Generic pagination container. +/// Mirrors Python v2 `Page(Generic[ResourceT])`. +public struct Page: Sendable { + public let results: [T] + public let pageNumber: Int + public let pageTotal: Int + public let total: Int +} +``` + +### Result (mirrors Python v2 `Result`) + +```swift +/// Base result for all run operations. +/// Mirrors Python v2 `Result` dataclass. +public struct RunResult: Codable, Sendable { + public let status: String + public let completed: Bool + public let errorMessage: String? + public let url: String? + public let result: AnyCodable? + public let supplierError: String? + public let data: AnyCodable? +} +``` + +### Shared types (owned by this RFC, consumed by others) + +This RFC owns types that are used across multiple other RFCs. Ownership is defined here to avoid duplication. + +```swift +/// Type-erased Codable wrapper for heterogeneous JSON values. +/// Used by: RFC-0004 (RunResult.data), RFC-0007 (ModelResult), RFC-0008 (Tool config). +public struct AnyCodable: Codable, Sendable { ... } + +/// AI functions supported by the platform. +/// Used by: RFC-0007 (Model.function), RFC-0008 (Tool.function). +public enum AIFunction: String, Codable, Sendable { + case search, translation, textGeneration, classification + case speechRecognition, imageClassification, objectDetection + case utilities + // ... +} + +/// AI model suppliers. +/// Used by: RFC-0007 (Model.vendor), RFC-0008 (Tool). +public enum Supplier: String, Codable, Sendable { + case openai, anthropic, google, meta, huggingface, cohere, aixplain + // ... +} + +/// Response status for polling operations. +/// Used by: RFC-0003 (AgentRunResult), RFC-0007 (StreamChunk, ModelResult). +public enum ResponseStatus: String, Codable, Sendable { + case inProgress = "IN_PROGRESS" + case success = "SUCCESS" + case failed = "FAILED" +} + +/// Asset status values shared across all resources. +/// Used by: RFC-0003 (Agent), RFC-0007 (Model), RFC-0008 (Tool). +public enum AssetStatus: String, Codable, Sendable { + case draft, hidden, scheduled, onboarding, onboarded + case pending, failed, training, rejected + case enabling, deleting, disabled, deleted + case inProgress = "in_progress" + case completed, canceling, canceled +} +``` + +### Model conformance (mirrors Python v2 `Model` class) + +```swift +extension Model: Gettable, Searchable, Runnable, AgentToolConvertible { + static let resourcePath = "sdk/models" + static let runActionPath = "run" + + public func asAgentTool() -> AgentToolDict { + AgentToolDict( + id: id ?? "", + name: name ?? "", + description: description ?? "", + supplier: supplier?.rawValue ?? "", + parameters: getParameters(), + function: function?.rawValue ?? "", + type: .model, + version: version ?? "", + assetId: id ?? "" + ) + } +} +``` + +### Tool resource (mirrors Python v2 `Tool` class) + +```swift +/// Mirrors Python v2 `Tool(Model, DeleteResourceMixin, ActionMixin)`. +public final class Tool: @unchecked Sendable { + public static let resourcePath = "v2/tools" + static let defaultIntegrationId = "686432941223092cb4294d3f" + + // Inherits model fields + tool-specific: + public var assetId: String? + public var integration: IntegrationRef? // String ID or Integration object + public var config: [String: Any]? + public var code: String? + public var allowedActions: [String] = [] + + // Tool creation goes through integration.connect() + // instead of standard POST (mirrors Python v2 _create) +} + +extension Tool: Gettable, Searchable, Deletable, Runnable, AgentToolConvertible { + public func asAgentTool() -> AgentToolDict { + var dict = super.asAgentTool() + if !allowedActions.isEmpty { + dict.actions = allowedActions + } + return dict + } +} +``` + +## Implementation + +Clean-slate: delete all v1 asset/tool types and build the unified resource system from scratch. + +### Files to delete + +- `Sources/aiXplainKit/Modules/Asset/` (entire directory, including `Suplier.swift`) +- `Sources/aiXplainKit/Modules/Agents/Tools/` (entire directory) + +### Files to create + +| File | Content | +|------|---------| +| `Sources/aiXplainKit/Resources/BaseResource.swift` | Protocol definition | +| `Sources/aiXplainKit/Resources/Protocols/Gettable.swift` | Get mixin | +| `Sources/aiXplainKit/Resources/Protocols/Searchable.swift` | Search/paginate mixin | +| `Sources/aiXplainKit/Resources/Protocols/Deletable.swift` | Delete mixin | +| `Sources/aiXplainKit/Resources/Protocols/Runnable.swift` | Run mixin | +| `Sources/aiXplainKit/Resources/AgentToolConvertible.swift` | Tool conversion protocol | +| `Sources/aiXplainKit/Resources/AgentToolDict.swift` | Tool dict struct | +| `Sources/aiXplainKit/Resources/Page.swift` | Pagination container | +| `Sources/aiXplainKit/Resources/RunResult.swift` | Base run result | +| `Sources/aiXplainKit/Enums/AnyCodable.swift` | Type-erased Codable wrapper | +| `Sources/aiXplainKit/Enums/AIFunction.swift` | AI function enum | +| `Sources/aiXplainKit/Enums/Supplier.swift` | Supplier enum | +| `Sources/aiXplainKit/Enums/AssetStatus.swift` | Asset status enum | +| `Sources/aiXplainKit/Enums/ResponseStatus.swift` | Response status enum | + +## Shared Contracts + +This RFC is the **interface hub** for the entire SDK. Every other RFC consumes types defined here. + +| Type | Defined in | Consumed by | +|------|-----------|-------------| +| `BaseResource` | `Resources/BaseResource.swift` | RFC-0003 (Agent), RFC-0007 (Model), RFC-0008 (Tool), RFC-0009 (Index) | +| `Gettable` | `Resources/Protocols/Gettable.swift` | RFC-0003, RFC-0007, RFC-0008, RFC-0009 | +| `Searchable` | `Resources/Protocols/Searchable.swift` | RFC-0003, RFC-0007, RFC-0008 | +| `Deletable` | `Resources/Protocols/Deletable.swift` | RFC-0003, RFC-0007, RFC-0008 | +| `Runnable` | `Resources/Protocols/Runnable.swift` | RFC-0003, RFC-0007, RFC-0008 | +| `AgentToolConvertible` | `Resources/AgentToolConvertible.swift` | RFC-0007 (Model), RFC-0008 (Tool) | +| `AgentToolDict` | `Resources/AgentToolDict.swift` | RFC-0003 (save payload), RFC-0007, RFC-0008 | +| `Page` | `Resources/Page.swift` | RFC-0003, RFC-0007, RFC-0008, RFC-0009 | +| `RunResult` | `Resources/RunResult.swift` | RFC-0007 (ModelResult extends), RFC-0003 (AgentRunResult extends) | +| `AnyCodable` | `Enums/AnyCodable.swift` | RFC-0003, RFC-0007, RFC-0008, RFC-0009 | +| `AssetStatus` | `Enums/AssetStatus.swift` | RFC-0003, RFC-0007, RFC-0008 | +| `AIFunction` | `Enums/AIFunction.swift` | RFC-0007, RFC-0008 | +| `Supplier` | `Enums/Supplier.swift` | RFC-0007, RFC-0008 | +| `ResponseStatus` | `Enums/ResponseStatus.swift` | RFC-0003, RFC-0007 | +| `ToolType` | `Resources/AgentToolDict.swift` | RFC-0007, RFC-0008 | + +## Testing + +- Unit: `Model.asAgentTool()` produces correct `AgentToolDict` with all fields. +- Unit: `Page` correctly holds results, pageNumber, pageTotal, total. +- Unit: `RunResult` decoding from fixture JSON with all field variants. +- Unit: `BaseResource.isModified` detects changes after property mutation. +- Unit: `BaseResource.clone()` produces copy with `id = nil`. +- Unit: `ToolType` decoding handles all valid values (`model`, `pipeline`, `utility`, `tool`). + +## Out of Scope + +- Tool actions and integration resolution (`ActionMixin`, `Integration.connect()`) -- separate RFC. +- Dynamic parameter inputs (`InputsProxy` equivalent) -- deferred. +- Index/search resource alignment -- deferred. +- File/Resource upload (`file.py`) -- deferred. + +## Resolved Questions + +1. **`BaseResource` is a base class** (not a protocol) -- provides `_saved_state`, `isModified`, `isDeleted`, `save()`/`clone()` with stored properties. All resources (`Agent`, `Model`, `Tool`, `Index`) inherit from it. +2. **Protocol extensions with default implementations** -- `Gettable`, `Searchable`, `Deletable`, `Runnable` provide default behavior via extensions. Resources override only what differs (e.g., `buildRunPayload`, `buildSavePayload`). Lowest maintenance burden. +3. **Simple `AnyCodable` wrapper** -- no third-party dependency. A lightweight struct wrapping `Any` with `Codable` conformance via `JSONSerialization`. diff --git a/docs/rfcs/RFC-0005-error-model-and-contract-tests.md b/docs/rfcs/RFC-0005-error-model-and-contract-tests.md new file mode 100644 index 0000000..8ddfd6b --- /dev/null +++ b/docs/rfcs/RFC-0005-error-model-and-contract-tests.md @@ -0,0 +1,463 @@ +# RFC-0005: Error Model and Contract Tests + +| Field | Value | +|--------------|------------------------------------------| +| Status | Implemented | +| Authors | | +| Created | 2026-03-06 | +| Depends on | RFC-0001 (AuthError) | +| Depended by | RFC-0002, RFC-0003, RFC-0006, RFC-0007, RFC-0008, RFC-0009 | +| Priority | P1 -- Post-Agents | + +## Context + +### Current Swift SDK errors + +The Swift SDK defines error enums per module with massive overlap: + +- **`ModelError`** (16 cases) -- `missingAPIKey`, `missingBackendURL`, `missingModelRunURL`, `invalidURL`, `failToDecodeRunResponse`, `pollingTimeoutOnModelResponse`, `failToDecodeModelOutputDuringPollingPhase`, `supplierError`, `failToGenerateAFilePayload`, `typeNotRecognizedWhileCreatingACombinedInput`, `inputEncodingError`, `noResponse`, `missingModelUtilityID`, `modelUtilityCreationError`, `failToCallModelExecuteFromUtility`, `unableToUpdateModelUtility`. +- **`AgentsError`** -- mirrors most of `ModelError` plus `invalidInput`, `errorOnDelete`, `errorOnUpdate`, `teamOfAgentsHasNoAgents`. +- **`PipelineError`** -- mirrors most of `ModelError` (9 shared cases). +- **`NetworkingError`** (4 cases) -- `invalidHttpResponse`, `invalidStatusCode`, `invalidURL`, `maxRetryReached`. +- **`FileError`** (4 cases) -- `fileSizeExceedsLimit`, `payloadGenerationFailed`, `couldNotGetTheS3PreSignedURL`, `bucketNameNotFound`. +- **`IndexErrors`** (1 case) -- `failedToCreateIndex(reason:)`. + +### How Python v2 structures errors (`exceptions.py`) + +Python v2 has a clean, flat hierarchy with exactly 5 error types: + +```python +class AixplainV2Error(Exception): + """Base exception for all v2 errors.""" + def __init__(self, message, details=None): + self.message = message # Can be str or List[str] + self.details = details or {} + +class ResourceError(AixplainV2Error): + """Raised when resource operations fail.""" + pass + +class APIError(AixplainV2Error): + """Raised when API calls fail.""" + def __init__(self, message, status_code=0, response_data=None, error=None): + self.status_code = status_code + self.response_data = response_data or {} + self.error = error or message + +class ValidationError(AixplainV2Error): + """Raised when validation fails.""" + pass + +class TimeoutError(AixplainV2Error): + """Raised when operations timeout.""" + pass + +class FileUploadError(AixplainV2Error): + """Raised when file upload operations fail.""" + pass +``` + +**Error factory (`exceptions.py`):** + +```python +def create_operation_failed_error(response: dict) -> APIError: + """Create an operation failed error from API response.""" + error_msg = ( + response.get("supplierError") + or response.get("supplier_error") + or response.get("error_message") + or response.get("error") + or "Operation failed" + ) + return APIError( + f"Operation failed: {error_msg}", + status_code=response.get("statusCode", 0), + response_data=response, + error=error_msg, + ) +``` + +**How errors are raised in Python v2:** + +In `client.py` (HTTP errors): +```python +if not response.ok: + error_obj = response.json() + raise APIError( + error_obj.get("message", error_obj.get("error", response.text)), + status_code=error_obj.get("statusCode", response.status_code), + response_data=error_obj, + error=error_obj.get("error", response.text), + ) +``` + +In `resource.py` (polling errors): +```python +# sync_poll timeout +raise TimeoutError(f"Operation timed out after {timeout} seconds") + +# poll failure +if status == "FAILED": + raise create_operation_failed_error(response) + +# Resource state validation +raise ValidationError(f"{resource_name} has been deleted and cannot be used") +raise ValidationError(f"{resource_name} has not been saved yet. Call .save() first") + +# Context missing +raise ResourceError("Context is required for resource operations") +``` + +### Problems in the current Swift SDK + +1. **16+ duplicated cases** -- `missingAPIKey`, `missingBackendURL`, `invalidURL`, etc. repeated across 3 error types. +2. **No shared base** -- error handling must catch module-specific errors even for the same root cause. +3. **Missing context** -- `invalidStatusCode(statusCode: Int)` carries no URL, method, or response body. +4. **No error factory** -- Python v2 has `create_operation_failed_error(response)` that uniformly handles supplier errors. +5. **No contract tests** -- mock responses exist but there's no systematic validation of API response shapes. + +## Decision + +Introduce a unified `AixplainError` hierarchy aligned with the Python v2's 5-type system, plus a contract test framework. + +## API Shape + +### Error hierarchy (mirrors Python v2 `exceptions.py`) + +```swift +/// Root error for all aiXplain SDK v2 operations. +/// Mirrors Python v2 `AixplainV2Error`. +public enum AixplainError: Error, Sendable, LocalizedError { + case auth(AuthError) + case api(APIError) + case validation(ValidationError) + case timeout(TimeoutError) + case fileUpload(FileUploadError) + case resource(ResourceError) + + /// User-facing message for display in UI. + /// Distinct from `localizedDescription` which is developer-facing. + public var userMessage: String { + switch self { + case .auth(let e): return e.errorDescription ?? "Authentication failed" + case .api(let e): return e.userMessage + case .validation(let e): return e.message + case .timeout(let e): return e.message + case .fileUpload(let e): return e.message + case .resource(let e): return e.message + } + } +} + +/// Mirrors Python v2 `APIError`. +/// Carries full HTTP context: status code, URL, response body, requestId. +public struct APIError: Error, Sendable { + public let message: String + public let statusCode: Int + public let responseData: [String: Any]? + public let error: String? + public let requestId: String? // For correlation with platform logs + + public init( + message: String, + statusCode: Int = 0, + responseData: [String: Any]? = nil, + error: String? = nil, + requestId: String? = nil + ) { + self.message = message + self.statusCode = statusCode + self.responseData = responseData + self.error = error ?? message + self.requestId = requestId + } + + /// User-facing message distinct from developer-facing localizedDescription. + public var userMessage: String { + if let err = error, !err.isEmpty { return err } + return message + } +} + +/// AuthError is defined in RFC-0001 and re-exported here. +/// See RFC-0001 for the full definition. +/// case noCredentialFound, emptyKey, bothKeysProvided + +/// Mirrors Python v2 `ValidationError`. +public struct ValidationError: Error, Sendable { + public let message: String + + public init(_ message: String) { + self.message = message + } +} + +/// Mirrors Python v2 `TimeoutError`. +public struct TimeoutError: Error, Sendable { + public let message: String + public let pollingURL: String? + public let timeout: TimeInterval? + + public init(_ message: String, pollingURL: String? = nil, timeout: TimeInterval? = nil) { + self.message = message + self.pollingURL = pollingURL + self.timeout = timeout + } +} + +/// Mirrors Python v2 `FileUploadError`. +public struct FileUploadError: Error, Sendable { + public let message: String + public let fileName: String? +} + +/// Mirrors Python v2 `ResourceError`. +public struct ResourceError: Error, Sendable { + public let message: String + + public init(_ message: String) { + self.message = message + } +} +``` + +### Error factory (mirrors Python v2 `create_operation_failed_error`) + +```swift +/// Mirrors Python v2 `create_operation_failed_error(response)`. +/// Used when polling returns status == "FAILED". +extension APIError { + public static func fromFailedOperation(_ response: [String: Any]) -> AixplainError { + let errorMsg = (response["supplierError"] as? String) + ?? (response["supplier_error"] as? String) + ?? (response["error_message"] as? String) + ?? (response["error"] as? String) + ?? "Operation failed" + + return .api(APIError( + message: "Operation failed: \(errorMsg)", + statusCode: response["statusCode"] as? Int ?? 0, + responseData: response, + error: errorMsg + )) + } +} +``` + +### HTTP error construction (mirrors `client.py` error handling) + +```swift +/// Mirrors Python v2 client.py error handling in request_raw(). +extension APIError { + public static func fromHTTPResponse( + data: Data, + statusCode: Int, + url: URL, + method: String + ) -> AixplainError { + if let errorObj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + return .api(APIError( + message: errorObj["message"] as? String + ?? errorObj["error"] as? String + ?? "Request failed", + statusCode: errorObj["statusCode"] as? Int ?? statusCode, + responseData: errorObj, + error: errorObj["error"] as? String + )) + } + return .api(APIError( + message: String(data: data, encoding: .utf8) ?? "Request failed", + statusCode: statusCode, + responseData: nil, + error: nil + )) + } +} +``` + +### Mapping from old errors to new + +| Old Swift Error | Python v2 Equivalent | New Swift v2 | +|-----------------|----------------------|------------| +| `ModelError.missingAPIKey` | `assert self.api_key` | `AixplainError.auth(.noCredentialFound)` | +| `ModelError.missingBackendURL` | N/A (config validation) | `AixplainError.validation("Backend URL not configured")` | +| `ModelError.invalidURL(url:)` | N/A (URL construction) | `AixplainError.validation("Invalid URL: ...")` | +| `ModelError.failToDecodeRunResponse` | `APIError(...)` | `AixplainError.resource("Failed to decode run response")` | +| `ModelError.pollingTimeoutOnModelResponse` | `TimeoutError(...)` | `AixplainError.timeout(TimeoutError(...))` | +| `ModelError.supplierError(error:)` | `create_operation_failed_error()` | `APIError.fromFailedOperation(response)` | +| `AgentsError.invalidInput(error:)` | `ValueError(...)` | `AixplainError.validation(ValidationError(...))` | +| `AgentsError.errorOnDelete` | `ResourceError(...)` | `AixplainError.resource(ResourceError(...))` | +| `NetworkingError.invalidStatusCode` | `APIError(status_code=...)` | `AixplainError.api(APIError(statusCode:...))` | +| `NetworkingError.maxRetryReached` | `TimeoutError(...)` | `AixplainError.timeout(TimeoutError("Max retries"))` | +| `FileError.fileSizeExceedsLimit` | `FileUploadError(...)` | `AixplainError.fileUpload(FileUploadError(...))` | + +### Contract test pattern + +```swift +/// Contract fixtures namespace for API response validation. +enum ContractFixtures { + /// Known-good agent GET response from the API. + static let agentGetResponse: Data = """ + { + "id": "abc123", + "name": "Test Agent", + "status": "onboarded", + "teamId": 42, + "llmId": "669a63646eb56306647e1091", + "createdAt": "2026-01-01T00:00:00.000Z", + "updatedAt": "2026-01-01T00:00:00.000Z", + "tools": [], + "instructions": "You are a helpful assistant." + } + """.data(using: .utf8)! + + /// Known-good agent run result from polling. + static let agentRunResult: Data = """ + { + "status": "SUCCESS", + "completed": true, + "data": { + "input": "Hello", + "output": "Hi there!", + "steps": [], + "sessionId": "abc123_20260101120000" + }, + "sessionId": "abc123_20260101120000", + "usedCredits": 0.001, + "runTime": 1.5 + } + """.data(using: .utf8)! + + /// Known-good error response. + static let errorResponse: Data = """ + { + "message": "Agent not found", + "statusCode": 404, + "error": "Not Found" + } + """.data(using: .utf8)! + + /// Known-good supplier error during polling. + static let supplierErrorResponse: Data = """ + { + "status": "FAILED", + "completed": true, + "supplierError": "Model capacity exceeded", + "errorMessage": "Supplier returned error" + } + """.data(using: .utf8)! +} + +/// Contract tests validate that known API response shapes decode correctly. +final class AgentContractTests: XCTestCase { + func test_agent_get_response_decodes() throws { + let agent = try JSONDecoder().decode(Agent.self, from: ContractFixtures.agentGetResponse) + XCTAssertEqual(agent.id, "abc123") + XCTAssertEqual(agent.name, "Test Agent") + XCTAssertEqual(agent.status, .onboarded) + } + + func test_agent_run_result_decodes() throws { + let result = try JSONDecoder().decode(AgentRunResult.self, from: ContractFixtures.agentRunResult) + XCTAssertTrue(result.completed) + XCTAssertEqual(result.sessionId, "abc123_20260101120000") + XCTAssertEqual(result.usedCredits, 0.001) + } + + func test_error_response_maps_to_api_error() throws { + let json = try JSONSerialization.jsonObject(with: ContractFixtures.errorResponse) as! [String: Any] + let error = APIError.fromHTTPResponse( + data: ContractFixtures.errorResponse, + statusCode: 404, + url: URL(string: "https://api.example.com")!, + method: "GET" + ) + if case .api(let apiError) = error { + XCTAssertEqual(apiError.statusCode, 404) + XCTAssertEqual(apiError.message, "Agent not found") + } + } + + func test_supplier_error_maps_to_api_error() throws { + let json = try JSONSerialization.jsonObject(with: ContractFixtures.supplierErrorResponse) as! [String: Any] + let error = APIError.fromFailedOperation(json) + if case .api(let apiError) = error { + XCTAssertTrue(apiError.message.contains("Model capacity exceeded")) + } + } +} +``` + +## Shared Contracts + +### Consumes from other RFCs + +| Type | From RFC | How it's used | +|------|----------|---------------| +| `AuthError` | RFC-0001 | Wrapped as `AixplainError.auth(AuthError)` | + +### Produces for other RFCs + +| Type | Consumed by | How it's used | +|------|-------------|---------------| +| `AixplainError` | RFC-0002 (client error handling), RFC-0003 (agent run/poll), RFC-0007 (model run), RFC-0008 (tool run), RFC-0009 (index ops) | Root error type for all SDK operations | +| `APIError` | RFC-0002 (`handleErrorResponse`), RFC-0003 (`poll` failure), RFC-0007/0008 (run failures) | HTTP-level errors with status code and response body | +| `ValidationError` | RFC-0003 (`validateHistory`, `beforeRun`), RFC-0007 (`_validate_params`), RFC-0008 (action input validation) | Client-side validation before requests | +| `TimeoutError` | RFC-0003 (`syncPoll` timeout), RFC-0007 (model polling timeout) | Polling exceeded timeout budget | +| `ResourceError` | RFC-0003 (context missing), RFC-0004 (state validation) | Resource-level operation failures | +| `FileUploadError` | RFC-0009 (image record upload) | File upload failures | +| `APIError.fromFailedOperation()` | RFC-0003, RFC-0007 | Factory for supplier error responses during polling | +| `APIError.fromHTTPResponse()` | RFC-0002 | Factory for non-2xx HTTP responses | + +## Implementation + +Clean-slate: delete all v1 error types and build the unified error hierarchy from scratch. + +### Files to delete + +- `Sources/aiXplainKit/Errors/Agents+error.swift` +- `Sources/aiXplainKit/Errors/Model+Error.swift` +- `Sources/aiXplainKit/Errors/Pipeline+Error.swift` +- `Sources/aiXplainKit/Errors/Networking+Error.swift` +- `Sources/aiXplainKit/Errors/File+Error.swift` + +### Files to create + +| File | Content | +|------|---------| +| `Sources/aiXplainKit/Errors/AixplainError.swift` | Unified error enum | +| `Sources/aiXplainKit/Errors/APIError.swift` | HTTP error struct + factories | +| `Sources/aiXplainKit/Errors/ValidationError.swift` | Validation error | +| `Sources/aiXplainKit/Errors/TimeoutError.swift` | Timeout error | +| `Sources/aiXplainKit/Errors/FileUploadError.swift` | File upload error | +| `Sources/aiXplainKit/Errors/ResourceError.swift` | Resource error | +| `Tests/aiXplainKitTests/Contract/ContractFixtures.swift` | JSON fixtures | +| `Tests/aiXplainKitTests/Contract/AgentContractTests.swift` | Agent contract tests | +| `Tests/aiXplainKitTests/Contract/ErrorContractTests.swift` | Error mapping tests | + +## Testing + +- Unit: every `AixplainError` case can be constructed and pattern-matched. +- Unit: `APIError.fromHTTPResponse` correctly parses JSON error bodies. +- Unit: `APIError.fromHTTPResponse` handles non-JSON response bodies. +- Unit: `APIError.fromFailedOperation` extracts `supplierError` first, then fallbacks. +- Unit: `ValidationError` stores message string. +- Unit: `TimeoutError` stores polling URL and timeout value. +- Contract: `Agent` decoding from fixture JSON. +- Contract: `AgentRunResult` decoding from fixture JSON. +- Contract: error response → `APIError` mapping. +- Contract: supplier error response → `APIError` mapping. + +## Out of Scope + +- Error reporting / telemetry integration. +- Localized error descriptions beyond `LocalizedError` conformance. +- Rate-limiting (429) retry-after logic. +- Error recovery strategies. + +## Resolved Questions + +1. **`AixplainError` is an enum** -- exhaustive pattern matching over extensibility. All error cases are known at compile time. +2. **Contract test fixtures are auto-generated** from live API recordings. A test helper records real API responses and saves them as fixture JSON files. +3. **Errors carry `requestId`** -- `APIError` includes `requestId: String?` for correlation with platform logs. +4. **`AixplainError` provides `userMessage`** -- computed property returning a user-facing message distinct from `localizedDescription`. diff --git a/docs/rfcs/RFC-0006-clean-slate-implementation-plan.md b/docs/rfcs/RFC-0006-clean-slate-implementation-plan.md new file mode 100644 index 0000000..f98267c --- /dev/null +++ b/docs/rfcs/RFC-0006-clean-slate-implementation-plan.md @@ -0,0 +1,297 @@ +# RFC-0006: Clean-Slate Implementation Plan + +| Field | Value | +|--------------|------------------------------------------| +| Status | Implemented | +| Authors | | +| Created | 2026-03-06 | +| Depends on | RFC-0004, RFC-0005 | +| Depended by | -- | +| Priority | P2 -- Final phase | + +## Context + +The existing v1 Swift SDK is being replaced entirely. There is no backward compatibility requirement -- the v1 code is non-functional and will be deleted. This RFC defines the implementation order, directory structure, and what gets deleted vs created. + +## Decision + +Erase the entire `Sources/aiXplainKit/` directory (except DocC docs) and rebuild from scratch following the Python v2 architecture defined in RFCs 0001-0005. + +## v1 Code Deletion + +### Delete entirely + +``` +Sources/aiXplainKit/ +├── aiXplainKit.swift # DELETE (AiXplainKit.shared singleton) +├── Manager/ +│ └── APIKeyManager.swift # DELETE +├── Networking/ +│ ├── Networking.swift # DELETE +│ ├── Networking+Endpoint.swift # DELETE +│ ├── Networking+Metadata.swift # DELETE +│ └── ResponseDecoders/ # DELETE (entire directory) +├── Errors/ +│ ├── Agents+error.swift # DELETE +│ ├── File+Error.swift # DELETE +│ ├── Model+Error.swift # DELETE +│ ├── Networking+Error.swift # DELETE +│ └── Pipeline+Error.swift # DELETE +├── Modules/ +│ ├── Asset/ # DELETE (entire directory) +│ ├── Agents/ # DELETE (entire directory) +│ ├── Model/ # DELETE (entire directory) +│ ├── Pipeline/ # DELETE (no pipelines in v2) +│ ├── TeamAgents/ # DELETE (unified into Agent) +│ ├── Parameters/ # DELETE (entire directory) +│ └── Index/ # DELETE (entire directory) +├── Provider/ # DELETE (entire directory) +├── Extensions/ # DELETE (entire directory) +└── Manager/FileManager/ # DELETE (entire directory) +``` + +### Keep + +``` +Sources/aiXplainKit/ +└── aiXplainKit.docc/ # KEEP (DocC documentation bundle) +``` + +### Delete tests + +``` +Tests/aiXplainKitTests/ # DELETE (entire directory -- rewrite) +``` + +## v2 Directory Structure + +``` +Sources/aiXplainKit/ +├── Aixplain.swift # Entry point (RFC-0002) +├── Auth/ +│ ├── AuthenticationScheme.swift # RFC-0001 +│ └── Credential.swift # RFC-0001 +├── Client/ +│ ├── AixplainClient.swift # RFC-0002 +│ ├── ClientConfiguration.swift # RFC-0002 +│ ├── RetryPolicy.swift # RFC-0002 +│ ├── Response.swift # RFC-0002 +│ └── HTTPMethod.swift # RFC-0002 +├── Resources/ +│ ├── BaseResource.swift # RFC-0004 +│ ├── Page.swift # RFC-0004 +│ ├── RunResult.swift # RFC-0004 +│ ├── AgentToolConvertible.swift # RFC-0004 +│ ├── AgentToolDict.swift # RFC-0004 +│ └── Protocols/ +│ ├── Gettable.swift # RFC-0004 +│ ├── Searchable.swift # RFC-0004 +│ ├── Deletable.swift # RFC-0004 +│ └── Runnable.swift # RFC-0004 +├── Agents/ +│ ├── Agent.swift # RFC-0003 +│ ├── AgentRunParams.swift # RFC-0003 +│ ├── AgentRunResult.swift # RFC-0003 +│ ├── AgentTask.swift # RFC-0003 +│ ├── ConversationMessage.swift # RFC-0003 +│ └── OutputFormat.swift # RFC-0003 +├── Models/ +│ ├── Model.swift # RFC-0007 +│ ├── ModelResult.swift # RFC-0007 +│ ├── ModelSearchParams.swift # RFC-0007 +│ ├── ModelRunParams.swift # RFC-0007 +│ ├── InputsProxy.swift # RFC-0007 +│ ├── StreamChunk.swift # RFC-0007 +│ ├── ModelTypes.swift # RFC-0007 +│ └── Utility.swift # RFC-0007 +├── Tools/ +│ ├── Tool.swift # RFC-0008 +│ ├── ToolSearchParams.swift # RFC-0008 +│ ├── Integration.swift # RFC-0008 +│ ├── ActionCapable.swift # RFC-0008 +│ ├── Action.swift # RFC-0008 +│ ├── ActionsProxy.swift # RFC-0008 +│ └── ActionInputsProxy.swift # RFC-0008 +├── Index/ +│ ├── Index.swift # RFC-0009 +│ ├── Record.swift # RFC-0009 (adapted from v1) +│ ├── IndexFilter.swift # RFC-0009 (adapted from v1) +│ ├── EmbeddingModel.swift # RFC-0009 (adapted from v1) +│ ├── IndexEngine.swift # RFC-0009 +│ └── IndexSearchResult.swift # RFC-0009 +├── Enums/ +│ ├── AssetStatus.swift # RFC-0003/0004 +│ ├── ToolType.swift # RFC-0004 +│ ├── AIFunction.swift # RFC-0007 +│ └── Supplier.swift # RFC-0004 +├── Errors/ +│ ├── AixplainError.swift # RFC-0005 +│ ├── APIError.swift # RFC-0005 +│ ├── AuthError.swift # RFC-0001 +│ ├── ValidationError.swift # RFC-0005 +│ ├── TimeoutError.swift # RFC-0005 +│ ├── FileUploadError.swift # RFC-0005 +│ └── ResourceError.swift # RFC-0005 +└── aiXplainKit.docc/ # KEEP from v1 + ├── aiXplainKit.md # Update + └── Essential/ + └── GettingStarted.md # Rewrite for v2 API + +Tests/aiXplainKitTests/ +├── Unit/ +│ ├── Auth/ +│ │ └── CredentialTests.swift +│ ├── Client/ +│ │ ├── AixplainClientTests.swift +│ │ └── RetryPolicyTests.swift +│ ├── Agents/ +│ │ ├── AgentTests.swift +│ │ ├── AgentRunTests.swift +│ │ ├── ConversationMessageTests.swift +│ │ └── AgentTaskTests.swift +│ ├── Models/ +│ │ ├── ModelTests.swift +│ │ ├── InputsProxyTests.swift +│ │ └── StreamingTests.swift +│ ├── Tools/ +│ │ ├── ToolTests.swift +│ │ ├── IntegrationTests.swift +│ │ └── ActionsProxyTests.swift +│ ├── Index/ +│ │ ├── IndexTests.swift +│ │ ├── RecordTests.swift +│ │ └── IndexFilterTests.swift +│ └── Errors/ +│ └── ErrorMappingTests.swift +├── Contract/ +│ ├── ContractFixtures.swift +│ ├── AgentContractTests.swift +│ ├── ModelContractTests.swift +│ ├── ToolContractTests.swift +│ └── ErrorContractTests.swift +└── Helpers/ + └── MockHTTPTransport.swift +``` + +## Implementation Order + +Each step builds on the previous. After each step, the code should compile and tests should pass. + +### Step 1: Foundation (RFC-0001 + RFC-0005 errors) + +Create `Auth/`, `Errors/` directories with: +- `AuthenticationScheme`, `Credential`, `AuthError` +- `AixplainError`, `APIError`, `ValidationError`, `TimeoutError`, `FileUploadError`, `ResourceError` + +Tests: credential resolution, header generation, error construction. + +### Step 2: Client (RFC-0002) + +Create `Client/` directory and `Aixplain.swift` with: +- `AixplainClient`, `ClientConfiguration`, `RetryPolicy`, `Response`, `HTTPMethod` +- `Aixplain` entry point with resource accessor stubs + +Tests: URL resolution, retry logic, error parsing, credential attachment. + +### Step 3: Resource Protocols (RFC-0004) + +Create `Resources/` directory with: +- `BaseResource` protocol, `Gettable`, `Searchable`, `Deletable`, `Runnable` +- `Page`, `RunResult`, `AgentToolConvertible`, `AgentToolDict` + +Tests: protocol default implementations, Page construction, RunResult decoding. + +### Step 4: Models (RFC-0007) + +Create `Models/` directory with: +- `Model` class conforming to resource protocols +- `ModelResult`, `ModelSearchParams`, `ModelRunParams`, `InputsProxy`, `StreamChunk` +- Sync/async routing based on `connectionType` +- `Utility` resource for custom code functions + +Tests: model CRUD, run routing, InputsProxy, streaming SSE, contract fixtures. + +### Step 5: Tools and Integrations (RFC-0008) + +Create `Tools/` directory with: +- `Tool` class with CRUD, run (action-based), `as_tool()` +- `Integration` with `connect()` to create tools +- `ActionCapable` protocol, `Action`, `ActionsProxy`, `ActionInputsProxy` + +Tests: tool lifecycle, action listing, integration connect, agent tool serialization. + +### Step 6: Agents (RFC-0003) + +Create `Agents/` directory with: +- `Agent` class conforming to resource protocols +- `AgentRunParams`, `AgentRunResult`, `ConversationMessage`, `AgentTask`, `OutputFormat` +- Save/clone/delete, run/runAsync/poll/syncPoll, generateSessionId +- Tool and subagent management + +Tests: full agent lifecycle, payload construction, hook behavior, contract fixtures. + +### Step 7: Index (RFC-0009) + +Create `Index/` directory with: +- `Index` class with create/search/upsert/getDocument/count +- Adapted `Record`, `IndexFilter`, `EmbeddingModel` from v1 +- `IndexEngine`, `IndexSearchResult` + +Tests: index lifecycle, text/image search, record CRUD, filter construction. + +### Step 8: Enums + DocC (cleanup) + +Create `Enums/` directory and update DocC: +- `AssetStatus`, `ToolType`, `AIFunction`, `Supplier` +- Rewrite `aiXplainKit.md` and getting-started guide for v2 API + +## Minimum Viable v2 + +The minimum to ship is Steps 1-6 (Auth + Client + Resource Protocols + Models + Tools + Agents). This gives users: + +```swift +let aix = try Aixplain(apiKey: "your-key") + +// Get and run a model +let model = try await aix.Model.get("model-id") +let modelResult = try await model.run(text: "Translate this to French") + +// Stream a model response +for try await chunk in model.runStream(text: "Explain quantum computing") { + print(chunk.data, terminator: "") +} + +// Create a tool from a model for agent use +let tool = model.asAgentTool() + +// Get and run an agent with tools +let agent = try await aix.Agent.get("agent-id") +let result = try await agent.run("Hello, what can you do?") +print(result.data?.output) + +// Create a new agent with tools +let newAgent = Agent(name: "My Agent", instructions: "You are helpful", tools: [tool]) +try await newAgent.save() + +// Session with history +let sessionId = try await agent.generateSessionId(history: [ + ConversationMessage(role: .user, content: "Hi"), + ConversationMessage(role: .assistant, content: "Hello!") +]) +let result2 = try await agent.run("Follow up question", sessionId: sessionId) + +// Create and search an index +let index = try await Index.create(name: "Knowledge", description: "My docs", context: aix) +try await index.upsert([Record(text: "Swift is a programming language")]) +let hits = try await index.search("What is Swift?") +``` + +## Swift Version + +Target Swift 5.9+ for `Sendable` support. Swift 6 strict concurrency can be adopted later via compiler flags. + +## Resolved Questions + +1. **DocC rewrite is a separate pass** -- not part of this plan. DocC will be updated after all RFCs are implemented. +2. **No Pipelines** -- pipelines are not part of the v2 SDK. They are removed and not reimplemented. +3. **Package name remains `aiXplainKit`** -- no rename. diff --git a/docs/rfcs/RFC-0007-models-v2-api.md b/docs/rfcs/RFC-0007-models-v2-api.md new file mode 100644 index 0000000..e1a4981 --- /dev/null +++ b/docs/rfcs/RFC-0007-models-v2-api.md @@ -0,0 +1,461 @@ +# RFC-0007: Models v2 API + +| Field | Value | +|--------------|------------------------------------------| +| Status | Implemented | +| Authors | | +| Created | 2026-03-06 | +| Depends on | RFC-0002, RFC-0004, RFC-0005 | +| Depended by | RFC-0003, RFC-0008, RFC-0009 | +| Priority | P0 -- Core resource | + +## Context + +### How Python v2 structures Models (`model.py`) + +`Model` is the most feature-rich resource after `Agent`: + +```python +@dataclass(repr=False) +class Model( + BaseResource, + SearchResourceMixin[ModelSearchParams, "Model"], + GetResourceMixin[BaseGetParams, "Model"], + RunnableResourceMixin[ModelRunParams, ModelResult], + ToolableMixin, # as_tool() for agent usage +): + RESOURCE_PATH = "v2/models" + RESPONSE_CLASS = ModelResult +``` + +Key capabilities: +- **Get/Search** via mixins with rich filters (functions, vendors, languages, host, developer, path). +- **Run** with sync/async routing based on `connection_type`. +- **Streaming** via `run_stream()` returning a `ModelResponseStreamer` (SSE parsing). +- **InputsProxy** for dynamic parameter access (`model.inputs.temperature = 0.7`). +- **Parameter validation** against the model's declared parameter schema. +- **`as_tool()`** to serialize the model for agent tool usage. +- **`Utility`** subclass for custom Python code functions. + +**Model fields (from `model.py`):** + +```python +service_name, status, host, developer, vendor (VendorInfo) +function (Function enum), pricing (Pricing), version (Version) +function_type, type, created_at, updated_at +supports_streaming, supports_byoc +connection_type # ["synchronous"], ["asynchronous"], or both +attributes (List[Attribute]), params (List[Parameter]) +``` + +**ModelResult:** + +```python +class ModelResult(Result): + details: Optional[List[Detail]] # message, role, finish_reason + run_time: Optional[float] + used_credits: Optional[float] + usage: Optional[Usage] # prompt_tokens, completion_tokens, total_tokens +``` + +**Streaming:** + +```python +class ModelResponseStreamer(Iterator[StreamChunk]): + # Parses SSE: "data: {json}" lines + # Yields StreamChunk(status, data) for each token + # Handles [DONE] marker + # Context manager support for cleanup + +class StreamChunk: + status: ResponseStatus + data: str +``` + +**InputsProxy:** + +```python +class InputsProxy: + # Dict-like + dot notation access to model parameters + # model.inputs.temperature = 0.7 + # model.inputs['temperature'] = 0.7 + # Type validation against Parameter.data_type + # Reset to backend defaults +``` + +**Sync vs Async routing:** + +```python +def run(self, **kwargs): + if self.is_sync_only: + return self._run_sync_v2(**effective_params) # V2 direct + else: + return super().run(**effective_params) # run_async + poll + +def run_async(self, **kwargs): + if self.is_sync_only: + return self._run_async_v1(**effective_params) # V1 fallback + else: + return super().run_async(**effective_params) # V2 endpoint +``` + +**ModelSearchParams:** + +```python +class ModelSearchParams(BaseSearchParams): + functions, vendors, source_languages, target_languages + is_finetunable, saved, status, q, host, developer, path +``` + +**Utility (`utility.py`):** + +```python +class Utility(BaseResource, SearchResourceMixin, GetResourceMixin, + DeleteResourceMixin, RunnableResourceMixin): + RESOURCE_PATH = "sdk/utilities" + code: str = "" + inputs: List[str] = [] + utility_id: str = "custom_python_code" + + def __post_init__(self): + # Auto-parses code via parse_code_decorated() + # Validates description length > 10 + # Auto-saves on creation +``` + +### Current Swift SDK + +- `Model` is a `Codable` class with `run()` and `polling()` that owns its own `Networking`. +- `UtilityModel` is a subclass with `update()`, `delete()`, `deploy()`. +- `ModelProvider` handles `get()`, `list()`, `listFunctions()`. +- `ModelInput` protocol with conformances on `String`, `URL`, `Data`, `Dictionary`. + +## Decision + +Replace `Model`, `UtilityModel`, and `ModelProvider` with a v2 `Model` resource following the Python v2 mixin architecture, including streaming and InputsProxy. + +## API Shape + +### Model + +```swift +public final class Model: @unchecked Sendable { + public override class var resourcePath: String { "v2/models" } + + // Core fields + public var id: String? + public var name: String? + public var description: String? + public var serviceName: String? + public var status: AssetStatus? + public var host: String? + public var developer: String? + public var vendor: VendorInfo? + public var function: AIFunction? + public var pricing: ModelPricing? + public var version: ModelVersion? + public var functionType: String? + public var type: String? = "model" + + // Timestamps + public private(set) var createdAt: String? + public private(set) var updatedAt: String? + + // Capabilities + public var supportsStreaming: Bool? + public var supportsBYOC: Bool? + public var connectionType: [String]? + + // Parameters + public var attributes: [ModelAttribute]? + public var params: [ModelParameter]? + + // Dynamic parameter proxy (mirrors Python v2 InputsProxy) + public lazy var inputs: InputsProxy = InputsProxy(model: self) + + // Context + weak var context: Aixplain? + + // Computed properties for sync/async routing + public var isSyncOnly: Bool { + guard let ct = connectionType else { return false } + return ct.contains("synchronous") && !ct.contains("asynchronous") + } + + public var isAsyncCapable: Bool { + guard let ct = connectionType else { return true } + return ct.contains("asynchronous") + } +} +``` + +### Supporting types + +```swift +public struct VendorInfo: Codable, Sendable { + public let id: String? + public let name: String? + public let code: String? +} + +public struct ModelPricing: Codable, Sendable { + public let price: Double? + public let unitType: String? + public let unitTypeScale: String? +} + +public struct ModelVersion: Codable, Sendable { + public let name: String? + public let id: String? +} + +public struct ModelAttribute: Codable, Sendable { + public let name: String + public let code: String? + public let value: AnyCodable? +} + +public struct ModelParameter: Codable, Sendable { + public let name: String + public var required: Bool = false + public var multipleValues: Bool = false + public var isFixed: Bool = false + public var dataType: String? + public var dataSubType: String? + public var values: [AnyCodable] = [] + public var defaultValues: [AnyCodable] = [] + public var availableOptions: [AnyCodable] = [] +} +``` + +### ModelResult + +```swift +public struct ModelResult: Sendable { + public let status: String + public let completed: Bool + public let data: AnyCodable? + public let url: String? + public let errorMessage: String? + public let supplierError: String? + public let details: [ModelDetail]? + public let runTime: Double? + public let usedCredits: Double? + public let usage: TokenUsage? +} + +public struct TokenUsage: Codable, Sendable { + public let promptTokens: Int + public let completionTokens: Int + public let totalTokens: Int +} + +public struct ModelDetail: Codable, Sendable { + public let index: Int + public let message: ModelMessage + public let finishReason: String? +} + +public struct ModelMessage: Codable, Sendable { + public let role: String + public let content: String +} +``` + +### Streaming + +```swift +/// Mirrors Python v2 `ModelResponseStreamer`. +/// Parses SSE lines from the response stream. +public struct StreamChunk: Sendable { + public let status: ResponseStatus + public let data: String +} + +extension Model { + /// Stream model responses as an AsyncSequence. + /// Mirrors Python v2 `model.run_stream(text="...")`. + public func runStream(_ params: ModelRunParams) -> AsyncThrowingStream +} +``` + +### InputsProxy + +```swift +/// Mirrors Python v2 `InputsProxy`. +/// Uses `@dynamicMemberLookup` for dot-notation access: `model.inputs.temperature = 0.7` +@dynamicMemberLookup +public class InputsProxy { + private weak var model: Model? + private var values: [String: Any] = [:] + + public subscript(key: String) -> Any? { + get { values[key] } + set { values[key] = newValue } + } + + /// Dot-notation access: `model.inputs.temperature` + public subscript(dynamicMember member: String) -> Any? { + get { values[member] } + set { values[member] = newValue } + } + + public func getAll() -> [String: Any] + public func reset() + public func resetParameter(_ name: String) +} +``` + +### Search + +```swift +public struct ModelSearchParams { + public var query: String? + public var functions: [String]? + public var vendors: [String]? + public var sourceLanguages: [String]? + public var targetLanguages: [String]? + public var isFinetunable: Bool? + public var host: String? + public var developer: String? + public var path: String? + public var pageNumber: Int = 0 + public var pageSize: Int = 20 +} + +extension Model { + public static func get(_ id: String, context: Aixplain) async throws -> Model + public static func search(_ params: ModelSearchParams, context: Aixplain) async throws -> Page +} +``` + +### Run + +```swift +extension Model { + /// Run the model. Routes sync vs async based on connectionType. + public func run(_ params: ModelRunParams) async throws -> ModelResult + + /// Run async -- returns polling URL. + public func runAsync(_ params: ModelRunParams) async throws -> ModelResult + + /// as_tool() for agent usage. + public func asAgentTool() -> AgentToolDict +} +``` + +### Utility + +```swift +/// Mirrors Python v2 `Utility`. +public final class Utility: @unchecked Sendable { + public static let resourcePath = "sdk/utilities" + + public var id: String? + public var name: String? + public var description: String? + public var code: String = "" + public var inputs: [String] = [] + + weak var context: Aixplain? +} + +extension Utility { + public static func get(_ id: String, context: Aixplain) async throws -> Utility + public static func search(_ params: UtilitySearchParams, context: Aixplain) async throws -> Page + public func save() async throws -> Utility + public func delete() async throws + public func run(_ data: String) async throws -> RunResult +} +``` + +## Shared Contracts + +### Consumes from other RFCs + +| Type | From RFC | How it's used | +|------|----------|---------------| +| `AixplainClient` | RFC-0002 | Via `context.client` for all HTTP calls | +| `Aixplain` | RFC-0002 | `context` reference; also provides `context.model_url` for run URL | +| `ClientConfiguration` | RFC-0002 | `modelsRunURL` used in `buildRunURL()` | +| `BaseResource` | RFC-0004 | Model conforms for save/clone/modification tracking | +| `Gettable` | RFC-0004 | Model conforms for `Model.get()` | +| `Searchable` | RFC-0004 | Model conforms for `Model.search()` returning `Page` | +| `Runnable` | RFC-0004 | Model conforms for `run()`/`runAsync()`/`poll()`/`syncPoll()` | +| `AgentToolConvertible` | RFC-0004 | Model conforms for `model.asAgentTool()` | +| `AgentToolDict` | RFC-0004 | Return type of `asAgentTool()` | +| `Page` | RFC-0004 | Return type of `Model.search()` | +| `RunResult` | RFC-0004 | `ModelResult` extends `RunResult` with model-specific fields | +| `AssetStatus` | RFC-0004 | Model status field | +| `AIFunction` | RFC-0004 | Model function field | +| `Supplier` | RFC-0004 | Model vendor code | +| `ResponseStatus` | RFC-0004 | Used in `StreamChunk.status` | +| `AnyCodable` | RFC-0004 | Used in `ModelResult.data`, `ModelParameter.values` | +| `AixplainError` | RFC-0005 | Thrown on HTTP failures, validation errors | +| `ValidationError` | RFC-0005 | Thrown by parameter validation | +| `TimeoutError` | RFC-0005 | Thrown by `syncPoll()` | + +### Produces for other RFCs + +| Type | Consumed by | How it's used | +|------|-------------|---------------| +| `Model` | RFC-0003 (`agent.llmId` references a model), RFC-0008 (`Tool` extends `Model`), RFC-0009 (`Index` wraps a model) | Core AI model resource | +| `Model.asAgentTool()` | RFC-0003 (agent save payload) | Serializes model for agent tool list | +| `VendorInfo` | RFC-0008 (Tool inherits vendor info) | Supplier metadata | +| `ModelParameter` | RFC-0008 (Tool parameter validation) | Parameter schema | +| `InputsProxy` | RFC-0008 could reuse pattern | Dynamic parameter access pattern | +| `StreamChunk` | -- | Self-contained streaming type | +| `Utility` | -- | Self-contained custom code resource | + +## Implementation + +### Files to delete + +- `Sources/aiXplainKit/Modules/Model/Model.swift` +- `Sources/aiXplainKit/Modules/Model/Utility.swift` +- `Sources/aiXplainKit/Modules/Model/Input/` (entire directory) +- `Sources/aiXplainKit/Modules/Model/Query/` +- `Sources/aiXplainKit/Provider/Model/` (entire directory) +- `Sources/aiXplainKit/Networking/ResponseDecoders/ModelExecuteResponse.swift` +- `Sources/aiXplainKit/Networking/ResponseDecoders/ModelOutput.swift` +- `Sources/aiXplainKit/Modules/Parameters/Model/` + +### Files to create + +| File | Content | +|------|---------| +| `Sources/aiXplainKit/Models/Model.swift` | Model class with get/search/run/runStream/asAgentTool | +| `Sources/aiXplainKit/Models/ModelResult.swift` | ModelResult, TokenUsage, ModelDetail | +| `Sources/aiXplainKit/Models/ModelSearchParams.swift` | Search parameters | +| `Sources/aiXplainKit/Models/ModelRunParams.swift` | Run parameters | +| `Sources/aiXplainKit/Models/InputsProxy.swift` | Dynamic parameter proxy | +| `Sources/aiXplainKit/Models/StreamChunk.swift` | SSE stream chunk | +| `Sources/aiXplainKit/Models/ModelTypes.swift` | VendorInfo, ModelPricing, ModelVersion, etc. | +| `Sources/aiXplainKit/Models/Utility.swift` | Utility resource | + +## Testing + +- Unit: `Model.get()` dispatches GET to `v2/models/{id}`. +- Unit: `Model.search()` builds correct filter payload with functions, vendors, languages. +- Unit: `Model.run()` routes to sync path when `isSyncOnly`. +- Unit: `Model.run()` routes to async+poll when `isAsyncCapable`. +- Unit: `InputsProxy` subscript get/set. +- Unit: `InputsProxy` type validation against `ModelParameter.dataType`. +- Unit: `Model.asAgentTool()` produces correct `AgentToolDict`. +- Unit: `ModelResult` decoding with `details`, `usage`, `runTime`, `usedCredits`. +- Unit: streaming SSE parsing -- `data: {json}`, `data: [DONE]`, blank line separators. +- Unit: `Utility` auto-save on creation. +- Contract: model GET response fixture. +- Contract: model run result fixture. + +## Out of Scope + +- Pipeline resource (separate RFC candidate). +- Fine-tuning API. +- BYOC (Bring Your Own Compute) configuration. + +## Resolved Questions + +1. **`InputsProxy` uses `@dynamicMemberLookup`** -- enables `model.inputs.temperature = 0.7` syntax. +2. **Streaming uses `AsyncThrowingStream`** -- native Swift concurrency primitive. Returned by `model.runStream()`. +3. **Resource path is `"v2/models"`** -- matches Python v2. The old `"sdk/models"` path is dropped. diff --git a/docs/rfcs/RFC-0008-tools-and-integrations-v2-api.md b/docs/rfcs/RFC-0008-tools-and-integrations-v2-api.md new file mode 100644 index 0000000..979ecc9 --- /dev/null +++ b/docs/rfcs/RFC-0008-tools-and-integrations-v2-api.md @@ -0,0 +1,440 @@ +# RFC-0008: Tools and Integrations v2 API + +| Field | Value | +|--------------|------------------------------------------| +| Status | Implemented | +| Authors | | +| Created | 2026-03-06 | +| Depends on | RFC-0002, RFC-0004, RFC-0005, RFC-0007 | +| Depended by | RFC-0003 (agent operations depend on tools) | +| Priority | **P0 -- Critical for Agents** | + +> Tools are the primary mechanism for extending agent capabilities. Without tools, agents cannot interact with external services, run models, or execute custom code. This RFC is tightly coupled with RFC-0003 (Agents). + +## Context + +### How Python v2 structures Tools (`tool.py`) + +`Tool` extends `Model` and adds integration/action capabilities: + +```python +@dataclass(repr=False) +class Tool(Model, DeleteResourceMixin, ActionMixin): + RESOURCE_PATH = "v2/tools" + RESPONSE_CLASS = ToolResult + DEFAULT_INTEGRATION_ID = "686432941223092cb4294d3f" # Script integration + + asset_id: Optional[str] + integration: Optional[Union[Integration, str]] # Integration object or ID + config: Optional[dict] + code: Optional[str] + allowed_actions: Optional[List[str]] = [] +``` + +Key capabilities: +- **Creation via Integration** -- `Tool._create()` calls `integration.connect()` instead of standard POST. The integration creates the tool and returns its ID. +- **Actions** -- tools expose named actions via `ActionMixin.list_actions()` and `list_inputs()`. +- **`allowed_actions`** -- restricts which actions the agent can use from this tool. +- **`as_tool()`** -- serializes for agent creation, including `actions` list. +- **Action-based validation** -- `_validate_params()` uses `ActionInputsProxy` instead of model parameter validation. +- **Run requires action** -- `Tool.run()` requires an `action` parameter (falls back to single allowed action). + +### How Python v2 structures Integrations (`integration.py`) + +`Integration` extends `Model` with `ActionMixin`: + +```python +class Integration(Model, ActionMixin): + RESOURCE_PATH = "v2/integrations" + RESPONSE_CLASS = IntegrationResult + AuthenticationScheme = AuthenticationScheme + + def connect(self, **kwargs) -> Tool: + response = self.run(**kwargs) + tool_id = response.data.id + return self.context.Tool.get(tool_id) +``` + +**ActionMixin** provides the action infrastructure: + +```python +class ActionMixin: + actions_available: Optional[bool] + + def list_actions(self) -> List[Action] + # POST to run_url with action="LIST_ACTIONS" + + def list_inputs(self, *actions) -> List[Action] + # POST to run_url with action="LIST_INPUTS" + + @cached_property + def actions(self) -> ActionsProxy + # tool.actions['SLACK_SEND_MESSAGE'].channel = '#general' + + def set_inputs(self, inputs_dict) + # Bulk-set action inputs +``` + +**Action data model:** + +```python +@dataclass +class Action: + name, description, displayName, slug + available_versions, version, toolkit + input_parameters, output_parameters + scopes, tags, no_auth, deprecated + inputs: Optional[List[Input]] + +@dataclass +class Input: + name, code, value, availableOptions + datatype, allowMulti, supportsVariables + defaultValue, required, fixed, description +``` + +**ActionInputsProxy** provides parameter access per action: + +```python +class ActionInputsProxy: + # Lazy-fetches action inputs from backend + # Dict-like + dot notation access + # Type validation against Input.datatype + # Reset to defaults +``` + +**ActionsProxy** provides action access on the tool: + +```python +class ActionsProxy: + # tool.actions['ACTION_NAME'] returns ActionInputsProxy + # Case-insensitive action name resolution + # Caching of action proxies +``` + +### How tools are used with agents + +When saving an agent, tools are serialized via `ToolableMixin.as_tool()`: + +```python +# From agent.py build_save_payload +for tool in self.tools: + if isinstance(tool, ToolableMixin): + converted_assets.append(tool.as_tool()) +``` + +`Tool.as_tool()` extends `Model.as_tool()` by adding `actions`: + +```python +def as_tool(self) -> dict: + tool_dict = super().as_tool() + if self.allowed_actions: + tool_dict["actions"] = self.allowed_actions + return tool_dict +``` + +### Current Swift SDK + +- `Tool` is a simple struct (id, type, function, supplier, description, version). +- `CreateAgentTool` enum wraps models/pipelines/utilities for agent creation. +- `AgentUsableTool` protocol with `convertToTool()` method. +- No concept of integrations, actions, or action inputs. + +## Decision + +Build a full `Tool` resource and `Integration` resource following the Python v2 architecture, including the `ActionMixin` pattern. + +## API Shape + +### Tool + +```swift +/// Tool is a subclass of Model (matches Python v2 `class Tool(Model, ...)`). +/// Inherits all Model fields and capabilities. +public final class Tool: Model { + public override class var resourcePath: String { "v2/tools" } + static let defaultIntegrationId = "686432941223092cb4294d3f" + + // Tool-specific fields (Model fields inherited) + public var assetId: String? + public var integration: IntegrationRef? // String ID or Integration + public var config: [String: Any]? + public var code: String? + public var allowedActions: [String] = [] + public var actionsAvailable: Bool? + + // Action access (mirrors Python v2 ActionsProxy) + public lazy var actions: ActionsProxy = ActionsProxy(container: self) + + weak var context: Aixplain? +} + +/// Reference to an integration -- either an ID string or a resolved object. +public enum IntegrationRef: Sendable { + case id(String) + case resolved(Integration) +} +``` + +### Tool CRUD + +```swift +extension Tool { + /// Get a tool by ID. + public static func get(_ id: String, context: Aixplain) async throws -> Tool + + /// Search tools. + public static func search(_ params: ToolSearchParams, context: Aixplain) async throws -> Page + + /// Delete this tool. + public func delete() async throws + + /// Save/create this tool. + /// Creation goes through integration.connect() -- mirrors Python v2 _create. + public func save() async throws -> Tool +} +``` + +### Tool Run + +```swift +extension Tool { + /// Run the tool. Requires an `action` parameter. + /// Falls back to single allowed action if only one exists. + /// Mirrors Python v2 `Tool.run()`. + public func run(action: String? = nil, data: Any? = nil) async throws -> RunResult +} +``` + +### Tool as_tool() + +```swift +extension Tool: AgentToolConvertible { + public func asAgentTool() -> AgentToolDict { + var dict = AgentToolDict( + id: id ?? "", + name: name ?? "", + description: description ?? "", + supplier: vendor?.code ?? "aixplain", + parameters: getParameters(), + function: function?.rawValue ?? "", + type: .tool, + version: version?.id ?? "", + assetId: id ?? "" + ) + if !allowedActions.isEmpty { + dict.actions = allowedActions + } + return dict + } +} +``` + +### Integration + +```swift +public final class Integration: @unchecked Sendable { + public static let resourcePath = "v2/integrations" + + public var id: String? + public var name: String? + public var description: String? + public var actionsAvailable: Bool? + + // Action access + public lazy var actions: ActionsProxy = ActionsProxy(container: self) + + weak var context: Aixplain? + + /// Connect the integration, creating a Tool. + /// Mirrors Python v2 `integration.connect()`. + public func connect(name: String? = nil, description: String? = nil, + config: [String: Any]? = nil) async throws -> Tool +} + +extension Integration { + public static func get(_ id: String, context: Aixplain) async throws -> Integration +} +``` + +### ActionMixin (Swift protocol) + +```swift +/// Mirrors Python v2 `ActionMixin`. +public protocol ActionCapable: AnyObject { + var actionsAvailable: Bool? { get } + var context: Aixplain? { get } + func buildRunURL() throws -> String + + /// List available actions. + func listActions() async throws -> [Action] + + /// List inputs for specified actions. + func listInputs(_ actionNames: String...) async throws -> [Action] +} +``` + +### Action data model + +```swift +/// Mirrors Python v2 `Action` dataclass. +public struct Action: Codable, Sendable { + public let name: String? + public let description: String? + public let displayName: String? + public let slug: String? + public let inputs: [ActionInput]? +} + +/// Mirrors Python v2 `Input` dataclass. +public struct ActionInput: Codable, Sendable { + public let name: String + public var code: String? + public var datatype: String = "string" + public var allowMulti: Bool = false + public var supportsVariables: Bool = false + public var defaultValue: [AnyCodable]? + public var required: Bool = false + public var fixed: Bool = false + public var description: String = "" +} +``` + +### ActionsProxy + +```swift +/// Mirrors Python v2 `ActionsProxy`. +/// Uses `@dynamicMemberLookup` for `tool.actions.slackSendMessage` syntax. +@dynamicMemberLookup +public class ActionsProxy { + private weak var container: (any ActionCapable)? + private var cache: [String: ActionInputsProxy] = [:] + + public subscript(actionName: String) -> ActionInputsProxy { + get async throws { ... } + } + + /// Dot-notation: `tool.actions.slackSendMessage` + public subscript(dynamicMember member: String) -> ActionInputsProxy { + get async throws { try await self[member] } + } + + public func availableActions() async throws -> [String] +} + +/// Mirrors Python v2 `ActionInputsProxy`. +/// Actor for thread-safe concurrent access to action input values. +@dynamicMemberLookup +public actor ActionInputsProxy { + public subscript(inputCode: String) -> Any? { get set } + + /// Dot-notation: `proxy.channel = "#general"` + public subscript(dynamicMember member: String) -> Any? { + get { self[member] } + set { self[member] = newValue } + } + + public func validate(_ data: [String: Any]) -> [String] + public func reset() +} +``` + +## Shared Contracts + +### Consumes from other RFCs + +| Type | From RFC | How it's used | +|------|----------|---------------| +| `AixplainClient` | RFC-0002 | Via `context.client` for HTTP calls (CRUD, run, list_actions) | +| `Aixplain` | RFC-0002 | `context` reference; `context.Tool.get()` in `Integration.connect()` | +| `BaseResource` | RFC-0004 | Tool conforms for save/clone | +| `Gettable` | RFC-0004 | Tool/Integration conform for `.get()` | +| `Searchable` | RFC-0004 | Tool conforms for `.search()` | +| `Deletable` | RFC-0004 | Tool conforms for `.delete()` | +| `Runnable` | RFC-0004 | Tool/Integration conform for `.run()` | +| `AgentToolConvertible` | RFC-0004 | Tool conforms; `asAgentTool()` returns `AgentToolDict` | +| `AgentToolDict` | RFC-0004 | Return type of `asAgentTool()`; includes `actions` field | +| `Page` | RFC-0004 | Return type of `Tool.search()` | +| `AssetStatus` | RFC-0004 | Tool status field | +| `AIFunction` | RFC-0004 | Tool function field | +| `AnyCodable` | RFC-0004 | Tool config, action input values | +| `AixplainError` | RFC-0005 | Thrown on HTTP failures | +| `ValidationError` | RFC-0005 | Thrown by action input validation | +| `Model` | RFC-0007 | Tool extends Model conceptually; shares `VendorInfo`, `ModelVersion` | +| `VendorInfo` | RFC-0007 | Tool vendor metadata | + +### Produces for other RFCs + +| Type | Consumed by | How it's used | +|------|-------------|---------------| +| `Tool` | RFC-0003 (agent tools list) | Agents hold tools; serialized via `asAgentTool()` during save | +| `Tool.asAgentTool()` | RFC-0003 (save payload) | Returns `AgentToolDict` with `actions` for allowed actions | +| `Integration` | -- | Used to create Tools via `connect()` | +| `ActionCapable` | -- | Protocol for action-capable resources | +| `Action` | -- | Action metadata; could be used by agent progress tracking | +| `ActionsProxy` | -- | Action parameter access on tools | + +### Key interaction: Tool → Agent flow + +``` +Agent.save() + └── buildSavePayload() + └── for tool in self.tools: + └── tool.asAgentTool() → AgentToolDict + ├── Model: {id, name, type:"model", parameters, ...} + └── Tool: {id, name, type:"tool", actions:["ACTION1","ACTION2"], ...} +``` + +### Key interaction: Integration → Tool creation flow + +``` +Integration.connect(name: "My Slack", config: {...}) + └── integration.run(**kwargs) // POST to v2/integrations/{id}/run + └── response.data.id // new tool ID + └── context.Tool.get(toolId) // fetch created Tool +``` + +## Implementation + +### Files to delete + +- `Sources/aiXplainKit/Modules/Agents/Tools/` (entire directory -- Tool, CreateAgentTool, AgentUsable conformances) + +### Files to create + +| File | Content | +|------|---------| +| `Sources/aiXplainKit/Tools/Tool.swift` | Tool resource with CRUD, run, as_tool | +| `Sources/aiXplainKit/Tools/ToolSearchParams.swift` | Search parameters | +| `Sources/aiXplainKit/Tools/Integration.swift` | Integration resource with connect() | +| `Sources/aiXplainKit/Tools/ActionCapable.swift` | ActionMixin protocol | +| `Sources/aiXplainKit/Tools/Action.swift` | Action + ActionInput data models | +| `Sources/aiXplainKit/Tools/ActionsProxy.swift` | ActionsProxy for action access | +| `Sources/aiXplainKit/Tools/ActionInputsProxy.swift` | Per-action parameter proxy | + +## Testing + +- Unit: `Tool.get()` dispatches GET to `v2/tools/{id}`. +- Unit: `Tool.save()` resolves integration and calls `connect()`. +- Unit: `Tool.run()` requires action parameter; falls back to single allowed action. +- Unit: `Tool.asAgentTool()` includes `actions` list when `allowedActions` is set. +- Unit: `Integration.connect()` calls `run()` and returns `Tool.get(responseId)`. +- Unit: `listActions()` posts `LIST_ACTIONS` and parses `Action` list. +- Unit: `listInputs()` posts `LIST_INPUTS` and parses `Action` with inputs. +- Unit: `ActionsProxy` caches action proxies; case-insensitive lookup. +- Unit: `ActionInputsProxy` validates input types against `ActionInput.datatype`. +- Contract: tool GET response fixture. +- Contract: integration connect response fixture. + +## Out of Scope + +- OAuth flow for integrations (handled by platform). +- Custom integration creation (only connecting existing integrations). +- Action execution monitoring / progress tracking. + +## Resolved Questions + +1. **`Tool` is a subclass of `Model`** -- matches Python v2 `class Tool(Model, ...)`. Inherits model fields and `run()`/`asAgentTool()`. +2. **`ActionsProxy` uses `@dynamicMemberLookup`** -- enables `tool.actions.slackSendMessage.channel = "#general"` syntax. +3. **Lazy integration resolution uses async** -- `_ensureIntegration()` is `async throws`, resolves integration ID to `Integration` object on first access. +4. **`ActionInputsProxy` is an actor** -- thread-safe concurrent access to action input values. diff --git a/docs/rfcs/RFC-0009-index-and-search-v2-api.md b/docs/rfcs/RFC-0009-index-and-search-v2-api.md new file mode 100644 index 0000000..58943e6 --- /dev/null +++ b/docs/rfcs/RFC-0009-index-and-search-v2-api.md @@ -0,0 +1,330 @@ +# RFC-0009: Index and Search v2 API + +| Field | Value | +|--------------|------------------------------------------| +| Status | Implemented | +| Authors | | +| Created | 2026-03-06 | +| Depends on | RFC-0002, RFC-0004, RFC-0005, RFC-0007 | +| Depended by | -- | +| Priority | P1 -- After core resources | + +## Context + +### Current Swift SDK (well-developed) + +The Swift SDK's Index module is one of the more complete areas. It includes: + +- **`IndexModel`** -- subclass of `Model` with `search()` (text and image), `upsert()`, `get(documentID:)`, `count()`, and a subscript accessor. +- **`Record`** -- value type for index items with text/image variants, attributes, and Codable conformance. +- **`IndexFilter`** -- predicate type with `IndexFieldOperator` (equals, contains, greaterThan, etc.). +- **`EmbeddingModel`** -- enum of supported embedding models (Snowflake, OpenAI Ada, JINA, BGE-M3, etc.). +- **`AiXplainEngine`** -- enum for index engines (AIR or custom). +- **`IndexProvider`** -- creates and fetches indexes by ID. +- **`IndexSearchOutput`** -- response decoder for search results. + +### Python v2 gaps + +The Python v2 SDK does **not** have a dedicated Index module. Indexing is handled through generic model/tool execution (the index is a model with `function="search"`). There is no `index.py` file in the Python v2 directory. + +This means the Swift SDK's Index module is **more advanced** than the Python v2 in this area. The v2 rewrite should preserve and improve the existing functionality while adapting it to the new architecture. + +### What works well in the current Swift implementation + +1. **`Record`** -- clean value type with text/image variants, metadata attributes, S3 upload for images. +2. **`IndexFilter`** -- expressive filter system with subscript shorthand. +3. **`EmbeddingModel`** -- convenient enum with model IDs. +4. **Subscript access** -- `index[documentID]` for single record fetch. +5. **Image search** -- supports image-to-image search with automatic upload. + +### What needs to change for v2 + +1. **`IndexModel` subclasses `Model`** -- should use v2 `Model` as base or be standalone. +2. **`IndexProvider` uses `ModelProvider`** -- should use v2 `AixplainClient` directly. +3. **Custom networking** -- `runSearch()` and `pollingSearch()` duplicate the polling logic that now lives in `Runnable` protocol. +4. **`AiXplainEngine`** uses `ModelProvider` -- should use v2 model access. +5. **No pagination** -- `search()` returns all results in one call. +6. **Error types** -- uses `IndexErrors` and `ModelError` instead of unified `AixplainError`. + +## Decision + +Rebuild the Index module on top of v2 architecture (RFC-0002 client, RFC-0004 resource protocols, RFC-0007 model), preserving the well-designed `Record`, `IndexFilter`, and `EmbeddingModel` types. + +## API Shape + +### Index + +```swift +/// An index resource backed by a search model on the aiXplain platform. +/// Functionally equivalent to a Model with function="search". +public final class Index: @unchecked Sendable { + public static let resourcePath = "sdk/models" + + public var id: String? + public var name: String? + public var description: String? + + // The underlying model ID + public var modelId: String? + + weak var context: Aixplain? +} +``` + +### Index CRUD + +```swift +extension Index { + /// Get an existing index by ID. + public static func get(_ id: String, context: Aixplain) async throws -> Index + + /// Create a new index. + /// Mirrors current `IndexProvider.create()` -- uses an engine model to create the index. + public static func create( + name: String, + description: String, + embedding: EmbeddingModel = .openaiAda002, + engine: IndexEngine = .air, + context: Aixplain + ) async throws -> Index +} +``` + +### Index operations + +```swift +extension Index { + /// Text search. + public func search( + _ query: String, + topK: Int = 10, + filters: [IndexFilter] = [] + ) async throws -> IndexSearchResult + + /// Image search. + public func search( + image: URL, + topK: Int = 10, + filters: [IndexFilter] = [] + ) async throws -> IndexSearchResult + + /// Upsert documents into the index. + @discardableResult + public func upsert(_ records: [Record]) async throws -> Bool + + /// Get a single document by ID. + public func getDocument(_ id: String) async throws -> Record? + + /// Count documents in the index. + public func count() async throws -> Int + + /// Subscript access (mirrors current Swift SDK). + public subscript(id: String) -> Record? { + get async throws + } +} +``` + +### Record (preserve from v1 -- well-designed) + +```swift +/// A single item in the index. Preserved from v1 with minor cleanup. +public struct Record: Codable, Identifiable, Sendable { + public enum DataType: String, Codable, Sendable { + case text + case image + } + + public let id: String + public let dataType: DataType + public let value: String + public let attributes: [String: String] + public let uri: URL? + + /// Create a text record. + public init(text: String, attributes: [String: String] = [:], id: String = UUID().uuidString) + + /// Create an image record (uploads to S3 if local file). + public init(image: URL, attributes: [String: String] = [:], id: String = UUID().uuidString) async throws +} +``` + +### IndexFilter (preserve from v1 -- well-designed) + +```swift +/// A filter for constraining index search queries. Preserved from v1. +public struct IndexFilter: Sendable { + public let fieldName: String + public let operation: FieldOperator + + /// Subscript shorthand: `IndexFilter["author", .equals("Woolf")]` + public static subscript(fieldName: String, operation: FieldOperator) -> IndexFilter + + public func toDict() -> [String: String] +} + +public enum FieldOperator: Sendable { + case equals(String) + case notEquals(String) + case contains(String) + case notContains(String) + case greaterThan(String) + case lessThan(String) + case greaterThanOrEquals(String) + case lessThanOrEquals(String) +} + +/// Builder pattern for chaining filters. +/// Usage: +/// let filters = IndexFilter.builder() +/// .where("author", .equals("Woolf")) +/// .where("year", .greaterThan("1920")) +/// .build() +public class IndexFilterBuilder { + private var filters: [IndexFilter] = [] + + public func `where`(_ field: String, _ op: FieldOperator) -> IndexFilterBuilder { + filters.append(IndexFilter(fieldName: field, operation: op)) + return self + } + + public func build() -> [IndexFilter] { filters } +} + +extension IndexFilter { + public static func builder() -> IndexFilterBuilder { IndexFilterBuilder() } +} +``` + +### EmbeddingModel (preserve and extend) + +```swift +/// Supported embedding models. Preserved from v1. +public enum EmbeddingModel: CaseIterable, Identifiable, Sendable { + case snowflakeArcticEmbedMLong + case openaiAda002 + case snowflakeArcticEmbedLV20 + case jinaClipV2Multimodal + case multilingualE5Large + case bgeM3 + case aixplainLegalEmbeddings + case custom(id: String) + + public var id: String { ... } +} +``` + +### IndexEngine (renamed from AiXplainEngine) + +```swift +/// Index engine backend. Renamed from `AiXplainEngine` for clarity. +public enum IndexEngine: Sendable { + case air + case custom(id: String) + + public var id: String { ... } +} +``` + +### IndexSearchResult + +```swift +public struct IndexSearchResult: Codable, Sendable { + public let results: [SearchHit] + public let totalCount: Int? +} + +public struct SearchHit: Codable, Sendable { + public let documentId: String + public let score: Double + public let data: String + public let attributes: [String: String] +} +``` + +## Shared Contracts + +### Consumes from other RFCs + +| Type | From RFC | How it's used | +|------|----------|---------------| +| `AixplainClient` | RFC-0002 | Via `context.client` for HTTP calls (search, upsert, get, count) | +| `Aixplain` | RFC-0002 | `context` reference; provides `context.model_url` for index operations | +| `ClientConfiguration` | RFC-0002 | `modelsRunURL` for index run URL (indexes are models) | +| `Page` | RFC-0004 | Could be used for paginated search results in future | +| `AnyCodable` | RFC-0004 | Search result data fields | +| `AixplainError` | RFC-0005 | Thrown on HTTP failures | +| `FileUploadError` | RFC-0005 | Thrown by `Record.init(image:)` on upload failure | +| `Model` | RFC-0007 | `Index.create()` uses an engine Model to create the index; index IS a model with function=search | + +### Produces for other RFCs + +| Type | Consumed by | How it's used | +|------|-------------|---------------| +| `Index` | RFC-0003 (agents can use indexes as tools) | Index could be exposed as an agent tool for RAG | +| `Record` | -- | Self-contained index item type | +| `IndexFilter` | -- | Self-contained filter type | +| `EmbeddingModel` | -- | Self-contained embedding model catalog | + +### Key interaction: Index creation flow + +``` +Index.create(name: "KB", embedding: .openaiAda002, engine: .air, context: aix) + └── engine = Model.get(IndexEngine.air.id) // RFC-0007: get the AIR engine model + └── engine.run(data: name, model: embeddingId) // RFC-0007: run engine to create index + └── response.output = indexModelId + └── Model.get(indexModelId) // RFC-0007: fetch the created index model + └── Index(from: model) // Wrap as Index +``` + +## Implementation + +### Files to delete + +- `Sources/aiXplainKit/Modules/Index/IndexModel.swift` +- `Sources/aiXplainKit/Modules/Index/IndexerModel.swift` +- `Sources/aiXplainKit/Provider/Indexing/IndexProvider.swift` +- `Sources/aiXplainKit/Networking/ResponseDecoders/IndexSearchOutput.swift` + +### Files to preserve (adapted) + +- `Sources/aiXplainKit/Modules/Index/Record.swift` → move to `Sources/aiXplainKit/Index/Record.swift` +- `Sources/aiXplainKit/Modules/Index/IndexFilter.swift` → move to `Sources/aiXplainKit/Index/IndexFilter.swift` +- `Sources/aiXplainKit/Modules/Index/EmbeddingModel.swift` → move to `Sources/aiXplainKit/Index/EmbeddingModel.swift` + +### Files to create + +| File | Content | +|------|---------| +| `Sources/aiXplainKit/Index/Index.swift` | Index class with CRUD and operations | +| `Sources/aiXplainKit/Index/IndexEngine.swift` | IndexEngine enum (renamed from AiXplainEngine) | +| `Sources/aiXplainKit/Index/IndexSearchResult.swift` | Search result types | + +## Testing + +- Unit: `Index.create()` calls engine model with correct payload. +- Unit: `Index.search()` (text) builds correct payload with action="search". +- Unit: `Index.search()` (image) uploads to S3 first, then searches. +- Unit: `Index.upsert()` sends records in correct format. +- Unit: `Index.getDocument()` dispatches action="get_document". +- Unit: `Index.count()` dispatches action="count". +- Unit: `Record` text/image initialization. +- Unit: `Record` Codable round-trip. +- Unit: `IndexFilter` subscript shorthand and `toDict()`. +- Unit: `IndexSearchResult` decoding from fixture JSON. +- Contract: search response fixture. +- Integration: create index → upsert records → search → verify results. + +## Out of Scope + +- Real-time index updates / webhooks. +- Index deletion (not supported by platform currently). +- Batch operations beyond upsert. +- Index metrics / analytics. + +## Resolved Questions + +1. **`Index` is a standalone type that wraps a `Model`** -- not a subclass. Holds a `modelId` reference and delegates execution to the underlying model via `context.client`. +2. **Keep `Record.init(image:)` async initializer** -- automatic S3 upload on init is convenient and matches v1 behavior. +3. **`EmbeddingModel` uses Swift `camelCase` convention** -- e.g., `.snowflakeArcticEmbedMLong`, `.openaiAda002`, `.bgeM3`. +4. **`IndexFilter` adopts a builder pattern** for chaining filters.