diff --git a/examples/graphics/CMakeLists.txt b/examples/graphics/CMakeLists.txt index 8d08221f7..c604ef24e 100644 --- a/examples/graphics/CMakeLists.txt +++ b/examples/graphics/CMakeLists.txt @@ -78,6 +78,7 @@ yup_standalone_app ( yup::yup_audio_gui yup::yup_audio_processors yup::yup_audio_formats + yup::yup_ai pffft_library opus_library flac_library diff --git a/examples/graphics/source/examples/AI.h b/examples/graphics/source/examples/AI.h new file mode 100644 index 000000000..f7f552dc4 --- /dev/null +++ b/examples/graphics/source/examples/AI.h @@ -0,0 +1,330 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +#include +#include +#include + +//============================================================================== + +class AiDemo : public yup::Component +{ +public: + AiDemo() + : Component ("AiDemo") + { + auto theme = yup::ApplicationTheme::getGlobalTheme(); + titleFont = theme->getDefaultFont(); + + titleLabel.setText ("Ollama Tools", yup::dontSendNotification); + titleLabel.setFont (titleFont); + addAndMakeVisible (titleLabel); + + modelLabel.setText ("Model", yup::dontSendNotification); + addAndMakeVisible (modelLabel); + + modelEditor.setText ("gemma4", yup::dontSendNotification); + modelEditor.setMultiLine (false); + addAndMakeVisible (modelEditor); + + baseUrlLabel.setText ("Base URL", yup::dontSendNotification); + addAndMakeVisible (baseUrlLabel); + + baseUrlEditor.setText ("http://localhost:11434/v1", yup::dontSendNotification); + baseUrlEditor.setMultiLine (false); + addAndMakeVisible (baseUrlEditor); + + promptLabel.setText ("Prompt", yup::dontSendNotification); + addAndMakeVisible (promptLabel); + + promptEditor.setText ("Change this component background to dark green, then say what you changed.", yup::dontSendNotification); + promptEditor.setMultiLine (true); + addAndMakeVisible (promptEditor); + + askButton.setButtonText ("Ask"); + askButton.onClick = [this] + { + askModel(); + }; + addAndMakeVisible (askButton); + + toolsToggle.setButtonText ("Tools"); + toolsToggle.setToggleState (true, yup::dontSendNotification); + addAndMakeVisible (toolsToggle); + + statusLabel.setText ("Ollama can call set_background_color for this page.", yup::dontSendNotification); + addAndMakeVisible (statusLabel); + + responseLabel.setText ("Response", yup::dontSendNotification); + addAndMakeVisible (responseLabel); + + responseEditor.setMultiLine (true); + responseEditor.setReadOnly (true); + responseEditor.setText ("", yup::dontSendNotification); + addAndMakeVisible (responseEditor); + } + + ~AiDemo() override + { + if (requestThread != nullptr) + requestThread->stopThread (-1); + } + + void resized() override + { + auto area = getLocalBounds().reduced (20); + + titleLabel.setBounds (area.removeFromTop (40)); + area.removeFromTop (10); + + auto settings = area.removeFromTop (58); + auto columnWidth = (settings.getWidth() - 12) / 2; + + auto modelColumn = settings.removeFromLeft (columnWidth); + settings.removeFromLeft (12); + auto baseUrlColumn = settings; + + modelLabel.setBounds (modelColumn.removeFromTop (22)); + modelEditor.setBounds (modelColumn.removeFromTop (30)); + + baseUrlLabel.setBounds (baseUrlColumn.removeFromTop (22)); + baseUrlEditor.setBounds (baseUrlColumn.removeFromTop (30)); + + area.removeFromTop (14); + + promptLabel.setBounds (area.removeFromTop (22)); + promptEditor.setBounds (area.removeFromTop (120)); + + area.removeFromTop (12); + + auto actionRow = area.removeFromTop (34); + askButton.setBounds (actionRow.removeFromLeft (96)); + actionRow.removeFromLeft (12); + toolsToggle.setBounds (actionRow.removeFromLeft (86)); + actionRow.removeFromLeft (12); + statusLabel.setBounds (actionRow); + + area.removeFromTop (18); + + responseLabel.setBounds (area.removeFromTop (22)); + responseEditor.setBounds (area); + } + + void paint (yup::Graphics& g) override + { + g.setFillColor (backgroundColor.value_or (findColor (yup::DocumentWindow::Style::backgroundColorId).value_or (yup::Colors::dimgray))); + g.fillAll(); + + g.setStrokeColor (yup::Colors::darkgray); + g.setStrokeWidth (1.0f); + g.strokeLine (20.0f, 66.0f, getWidth() - 20.0f, 66.0f); + } + +private: + class OllamaRequestThread final : public yup::Thread + { + public: + OllamaRequestThread (AiDemo& ownerToUse, yup::String modelToUse, yup::String baseUrlToUse, yup::String promptToUse, bool useToolsToUse) + : Thread ("OllamaRequest") + , owner (ownerToUse) + , model (std::move (modelToUse)) + , baseUrl (std::move (baseUrlToUse)) + , prompt (std::move (promptToUse)) + , useTools (useToolsToUse) + , ownerReference (&ownerToUse) + { + } + + void run() override + { + yup::LLMClient::Options options; + options.model = model; + options.baseUrl = baseUrl; + options.timeoutMs = 120000; + options.maxRetries = 0; + + yup::LLMHttpClient client (std::move (options)); + + yup::LLMClient::Request request; + request.messages.push_back (yup::LLMMessage::user (prompt)); + request.temperature = 0.2f; + + yup::LLMToolRegistry toolRegistry; + if (useTools) + { + request.systemPrompt = "You are a concise assistant inside a YUP example app. " + "If the user asks to change the page background, call set_background_color with a CSS color name, #RRGGBB value, rgb(...), or hsl(...). " + "After a tool result, briefly tell the user what changed."; + + owner.registerTools (toolRegistry, ownerReference); + request.tools = toolRegistry.getAllTools(); + request.toolChoice = "auto"; + } + + auto response = client.runToolLoop (request, toolRegistry); + + yup::String responseText; + if (response.failed() && response.errorMessage.has_value()) + responseText = "Ollama error: " + *response.errorMessage; + else if (! response.choices.empty()) + responseText = response.choices.front().message.content.trim(); + + if (responseText.isEmpty()) + responseText = useTools ? "No response was returned. Check that Ollama is running, the model is pulled, the base URL is reachable, and the model supports tool calls." + : "No response was returned. Check that Ollama is running, the model is pulled, and the base URL is reachable."; + + if (threadShouldExit()) + return; + + auto ownerPtr = std::addressof (owner); + auto weakOwner = ownerReference; + + yup::MessageManager::callAsync ([ownerPtr, weakOwner, responseText] + { + if (weakOwner.get() == nullptr) + return; + + ownerPtr->handleResponse (responseText); + }); + } + + private: + AiDemo& owner; + yup::String model; + yup::String baseUrl; + yup::String prompt; + bool useTools; + yup::WeakReference ownerReference; + }; + + void askModel() + { + if (requestThread != nullptr && requestThread->isThreadRunning()) + { + statusLabel.setText ("A request is already running.", yup::dontSendNotification); + return; + } + + requestThread.reset(); + + const auto model = modelEditor.getText().trim(); + const auto baseUrl = baseUrlEditor.getText().trim(); + const auto prompt = promptEditor.getText().trim(); + const auto useTools = toolsToggle.getToggleState(); + + if (model.isEmpty() || baseUrl.isEmpty() || prompt.isEmpty()) + { + statusLabel.setText ("Model, base URL, and prompt are required.", yup::dontSendNotification); + return; + } + + askButton.setEnabled (false); + statusLabel.setText ("Waiting for Ollama...", yup::dontSendNotification); + responseEditor.setText ("", yup::dontSendNotification); + + requestThread = std::make_unique (*this, model, baseUrl, prompt, useTools); + if (! requestThread->startThread (yup::Thread::Priority::background)) + { + requestThread.reset(); + responseEditor.setText ("", yup::dontSendNotification); + statusLabel.setText ("Unable to start background request thread.", yup::dontSendNotification); + askButton.setEnabled (true); + } + } + + void handleResponse (const yup::String& responseText) + { + responseEditor.setText (responseText, yup::dontSendNotification); + statusLabel.setText ("Complete", yup::dontSendNotification); + askButton.setEnabled (true); + } + + void registerTools (yup::LLMToolRegistry& registry, yup::WeakReference ownerReference) + { + yup::LLMTool colorTool; + colorTool.name = "set_background_color"; + colorTool.description = "Changes the visible background color of the current YUP example component."; + + yup::LLMTool::Parameter colorParameter; + colorParameter.name = "color"; + colorParameter.type = "string"; + colorParameter.description = "CSS color name, #RRGGBB, rgb(...), rgba(...), hsl(...), or hsla(...) value."; + colorParameter.required = true; + colorTool.parameters.push_back (std::move (colorParameter)); + + auto ownerPtr = this; + + colorTool.setHandler ([ownerPtr, ownerReference] (const yup::var& arguments) + { + const auto colorText = arguments["color"].toString().trim(); + const auto colorValue = colorText.startsWithChar ('#') || colorText.startsWithIgnoreCase ("rgb") || colorText.startsWithIgnoreCase ("hsl") + ? colorText + : colorText.removeCharacters (" "); + const auto color = yup::Color::fromString (colorValue); + + yup::MessageManager::callAsync ([ownerPtr, ownerReference, color] + { + if (ownerReference.get() == nullptr) + return; + + ownerPtr->setBackgroundColor (color); + }); + + auto result = yup::var (std::make_unique()); + if (auto* object = result.getDynamicObject()) + { + object->setProperty ("success", true); + object->setProperty ("color", colorValue); + object->setProperty ("message", "Background color updated."); + } + + return result; + }); + + registry.registerTool (std::move (colorTool)); + } + + void setBackgroundColor (yup::Color color) + { + backgroundColor = color; + repaint(); + } + + yup::Label titleLabel { "titleLabel" }; + yup::Label modelLabel { "modelLabel" }; + yup::Label baseUrlLabel { "baseUrlLabel" }; + yup::Label promptLabel { "promptLabel" }; + yup::Label statusLabel { "statusLabel" }; + yup::Label responseLabel { "responseLabel" }; + + yup::TextEditor modelEditor { "modelEditor" }; + yup::TextEditor baseUrlEditor { "baseUrlEditor" }; + yup::TextEditor promptEditor { "promptEditor" }; + yup::TextEditor responseEditor { "responseEditor" }; + yup::TextButton askButton { "askButton" }; + yup::ToggleButton toolsToggle { "toolsToggle" }; + + yup::Font titleFont; + std::optional backgroundColor; + std::unique_ptr requestThread; +}; diff --git a/examples/graphics/source/main.cpp b/examples/graphics/source/main.cpp index 339185015..ebf15661d 100644 --- a/examples/graphics/source/main.cpp +++ b/examples/graphics/source/main.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #if YUP_MODULE_AVAILABLE_yup_python #include #endif @@ -37,6 +38,7 @@ #endif #include "examples/Artboard.h" +#include "examples/AI.h" #include "examples/Audio.h" #include "examples/AudioFileDemo.h" #include "examples/ColorLab.h" @@ -140,6 +142,7 @@ class CustomWindow int counter = 0; + registerDemo ("AI", counter++); registerDemo ("Audio", counter++); registerDemo ("Audio File", counter++); registerDemo ("Color Lab", counter++); diff --git a/modules/yup_ai/embedding/yup_EmbeddingModel.cpp b/modules/yup_ai/embedding/yup_EmbeddingModel.cpp new file mode 100644 index 000000000..efe8cc4bd --- /dev/null +++ b/modules/yup_ai/embedding/yup_EmbeddingModel.cpp @@ -0,0 +1,160 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ +namespace +{ +var makeEmbeddingObject() +{ + return var (std::make_unique()); +} + +void setEmbeddingProperty (var& object, const Identifier& name, const var& value) +{ + if (auto* dynamicObject = object.getDynamicObject()) + dynamicObject->setProperty (name, value); +} + +String makeEmbeddingEndpointUrl (const String& baseUrl, const String& path) +{ + return baseUrl.endsWithChar ('/') ? baseUrl.dropLastCharacters (1) + path + : baseUrl + path; +} + +String makeEmbeddingHeaders (const String& apiKey) +{ + String headers = "Content-Type: application/json\r\nAccept: application/json\r\n"; + + if (apiKey.isNotEmpty()) + headers += "Authorization: Bearer " + apiKey + "\r\n"; + + return headers; +} +} // namespace + +struct EmbeddingModel::Pimpl +{ + explicit Pimpl (Options optionsToUse) + : options (std::move (optionsToUse)) + { + } + + std::vector embedBatch (const std::vector& texts) + { + auto request = makeEmbeddingObject(); + + if (options.model.isNotEmpty()) + setEmbeddingProperty (request, "model", options.model); + + var input; + for (const auto& text : texts) + input.append (text); + + setEmbeddingProperty (request, "input", input); + + int statusCode = 0; + auto url = URL (makeEmbeddingEndpointUrl (options.baseUrl, "/embeddings")) + .withPOSTData (JSON::toString (request, true)); + auto streamOptions = URL::InputStreamOptions (URL::ParameterHandling::inPostData) + .withExtraHeaders (makeEmbeddingHeaders (options.apiKey)) + .withConnectionTimeoutMs (options.timeoutMs) + .withStatusCode (&statusCode) + .withHttpRequestCmd ("POST"); + + auto stream = url.createInputStream (streamOptions); + if (stream == nullptr || statusCode < 200 || statusCode >= 300) + return {}; + + return parseEmbeddings (JSON::parse (stream->readEntireStreamAsString())); + } + + static std::vector parseEmbeddings (const var& json) + { + std::vector result; + + if (auto* data = json["data"].getArray()) + { + for (const auto& item : *data) + { + Embedding embedding; + embedding.index = static_cast (item["index"]); + + if (auto* values = item["embedding"].getArray()) + { + embedding.values.reserve (static_cast (values->size())); + + for (const auto& value : *values) + embedding.values.push_back (static_cast (value)); + } + + result.push_back (std::move (embedding)); + } + } + + return result; + } + + Options options; +}; + +EmbeddingModel::EmbeddingModel (Options options) + : pimpl (std::make_unique (std::move (options))) +{ +} + +EmbeddingModel::~EmbeddingModel() = default; + +EmbeddingModel::Embedding EmbeddingModel::embed (const String& text) +{ + auto results = embedBatch ({ text }); + return results.empty() ? Embedding {} : results.front(); +} + +std::vector EmbeddingModel::embedBatch (const std::vector& texts) +{ + return pimpl->embedBatch (texts); +} + +float EmbeddingModel::cosineSimilarity (const Embedding& a, const Embedding& b) +{ + const auto count = std::min (a.values.size(), b.values.size()); + if (count == 0) + return 0.0f; + + double dot = 0.0; + double magnitudeA = 0.0; + double magnitudeB = 0.0; + + for (size_t i = 0; i < count; ++i) + { + dot += static_cast (a.values[i]) * static_cast (b.values[i]); + magnitudeA += static_cast (a.values[i]) * static_cast (a.values[i]); + magnitudeB += static_cast (b.values[i]) * static_cast (b.values[i]); + } + + if (magnitudeA <= 0.0 || magnitudeB <= 0.0) + return 0.0f; + + return static_cast (dot / (std::sqrt (magnitudeA) * std::sqrt (magnitudeB))); +} + +} // namespace yup diff --git a/modules/yup_ai/embedding/yup_EmbeddingModel.h b/modules/yup_ai/embedding/yup_EmbeddingModel.h new file mode 100644 index 000000000..94f4bfb6d --- /dev/null +++ b/modules/yup_ai/embedding/yup_EmbeddingModel.h @@ -0,0 +1,67 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== +/** OpenAI-compatible HTTP embedding model. + + @tags{AI} +*/ +class YUP_API EmbeddingModel +{ +public: + struct Options + { + String model; + String baseUrl = "http://localhost:11434/v1"; + String apiKey; + int timeoutMs = 60000; + }; + + struct Embedding + { + std::vector values; + int index = 0; + + /** Returns the number of embedding dimensions. */ + int dimensions() const noexcept { return static_cast (values.size()); } + }; + + explicit EmbeddingModel (Options options); + ~EmbeddingModel(); + + /** Embeds one text input. */ + Embedding embed (const String& text); + + /** Embeds a batch of text inputs. */ + std::vector embedBatch (const std::vector& texts); + + /** Returns cosine similarity in the range [-1, 1] for non-zero vectors. */ + static float cosineSimilarity (const Embedding& a, const Embedding& b); + +private: + struct Pimpl; + std::unique_ptr pimpl; +}; + +} // namespace yup diff --git a/modules/yup_ai/llm/yup_LLMClient.cpp b/modules/yup_ai/llm/yup_LLMClient.cpp new file mode 100644 index 000000000..4a5f9c093 --- /dev/null +++ b/modules/yup_ai/llm/yup_LLMClient.cpp @@ -0,0 +1,169 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ +namespace +{ +var makeLLMClientObject() +{ + return var (std::make_unique()); +} + +void setLLMClientProperty (var& object, const Identifier& name, const var& value) +{ + if (auto* dynamicObject = object.getDynamicObject()) + dynamicObject->setProperty (name, value); +} + +var toolChoiceToVar (const String& toolChoice) +{ + if (toolChoice == "auto" || toolChoice == "none" || toolChoice == "required") + return toolChoice; + + auto functionObject = makeLLMClientObject(); + setLLMClientProperty (functionObject, "name", toolChoice); + + auto object = makeLLMClientObject(); + setLLMClientProperty (object, "type", "function"); + setLLMClientProperty (object, "function", functionObject); + + return object; +} +} // namespace + +LLMClient::LLMClient (Options optionsToUse) + : options (std::move (optionsToUse)) +{ +} + +LLMClient::~LLMClient() = default; + +LLMResponse LLMClient::chat (const String& userMessage) +{ + Request request; + request.messages.push_back (LLMMessage::user (userMessage)); + return complete (request); +} + +LLMResponse LLMClient::chatWithTools (const String& userMessage, const LLMToolRegistry& tools) +{ + Request request; + request.messages.push_back (LLMMessage::user (userMessage)); + request.tools = tools.getAllTools(); + request.toolChoice = "auto"; + return complete (request); +} + +LLMResponse LLMClient::runToolLoop (const Request& request, LLMToolRegistry& tools) +{ + constexpr int maxToolIterations = 8; + + Request current = request; + if (current.tools.empty()) + current.tools = tools.getAllTools(); + + auto response = complete (current); + + for (int iteration = 0; iteration < maxToolIterations && response.hasToolCalls(); ++iteration) + { + for (const auto& choice : response.choices) + current.messages.push_back (choice.message); + + for (const auto& toolCall : response.getToolCalls()) + { + auto result = tools.dispatchToolCall (toolCall.name, toolCall.arguments); + current.messages.push_back (LLMMessage::toolResult (toolCall.id, JSON::toString (result, true))); + } + + response = complete (current); + } + + return response; +} + +String LLMClient::buildChatCompletionBody (const Request& request, bool stream) const +{ + auto object = makeLLMClientObject(); + + if (options.model.isNotEmpty()) + setLLMClientProperty (object, "model", options.model); + + std::vector messages; + messages.reserve (request.messages.size() + (request.systemPrompt.has_value() ? 1u : 0u)); + + if (request.systemPrompt.has_value()) + messages.push_back (LLMMessage::system (*request.systemPrompt)); + + messages.insert (messages.end(), request.messages.begin(), request.messages.end()); + + setLLMClientProperty (object, "messages", messagesToVar (messages)); + setLLMClientProperty (object, "stream", stream); + + if (! request.tools.empty()) + setLLMClientProperty (object, "tools", toolsToVar (request.tools)); + + if (request.toolChoice.has_value()) + setLLMClientProperty (object, "tool_choice", toolChoiceToVar (*request.toolChoice)); + + if (request.temperature.has_value()) + setLLMClientProperty (object, "temperature", static_cast (*request.temperature)); + + if (request.topP.has_value()) + setLLMClientProperty (object, "top_p", static_cast (*request.topP)); + + if (request.maxTokens.has_value()) + setLLMClientProperty (object, "max_tokens", *request.maxTokens); + + if (request.stopSequences.has_value()) + { + var stop; + + for (const auto& stopSequence : *request.stopSequences) + stop.append (stopSequence); + + setLLMClientProperty (object, "stop", stop); + } + + return JSON::toString (object, true); +} + +var LLMClient::messagesToVar (const std::vector& messages) const +{ + var result; + + for (const auto& message : messages) + result.append (message.toVar()); + + return result; +} + +var LLMClient::toolsToVar (const std::vector& tools) const +{ + var result; + + for (const auto& tool : tools) + result.append (tool.toJsonSchema()); + + return result; +} + +} // namespace yup diff --git a/modules/yup_ai/llm/yup_LLMClient.h b/modules/yup_ai/llm/yup_LLMClient.h new file mode 100644 index 000000000..f2a306d07 --- /dev/null +++ b/modules/yup_ai/llm/yup_LLMClient.h @@ -0,0 +1,86 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== +/** Abstract base class for chat-completion backends. + + @tags{AI} +*/ +class YUP_API LLMClient +{ +public: + struct Request + { + std::vector messages; + std::optional systemPrompt; + std::vector tools; + std::optional toolChoice; + + std::optional temperature; + std::optional topP; + std::optional maxTokens; + std::optional> stopSequences; + }; + + struct Options + { + String model; + String baseUrl = "http://localhost:11434/v1"; + String apiKey; + int timeoutMs = 120000; + int maxRetries = 2; + }; + + explicit LLMClient (Options options); + virtual ~LLMClient(); + + /** Performs a non-streaming completion request. */ + virtual LLMResponse complete (const Request& request) = 0; + + using ChunkCallback = std::function; + + /** Performs a streaming completion request and invokes onChunk for deltas. */ + virtual bool completeStreaming (const Request& request, ChunkCallback onChunk) = 0; + + /** Convenience helper for a single user message. */ + LLMResponse chat (const String& userMessage); + + /** Convenience helper for a single user message with all registered tools. */ + LLMResponse chatWithTools (const String& userMessage, const LLMToolRegistry& tools); + + /** Repeatedly completes and dispatches tool calls until the model stops requesting tools. */ + LLMResponse runToolLoop (const Request& request, LLMToolRegistry& tools); + + /** Returns immutable client options. */ + const Options& getOptions() const noexcept { return options; } + +protected: + Options options; + + String buildChatCompletionBody (const Request& request, bool stream) const; + var messagesToVar (const std::vector& messages) const; + var toolsToVar (const std::vector& tools) const; +}; + +} // namespace yup diff --git a/modules/yup_ai/llm/yup_LLMHttpClient.cpp b/modules/yup_ai/llm/yup_LLMHttpClient.cpp new file mode 100644 index 000000000..6ec8bc553 --- /dev/null +++ b/modules/yup_ai/llm/yup_LLMHttpClient.cpp @@ -0,0 +1,180 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ +namespace +{ +String makeAiEndpointUrl (const String& baseUrl, const String& path) +{ + return baseUrl.endsWithChar ('/') ? baseUrl.dropLastCharacters (1) + path + : baseUrl + path; +} + +String makeAiHeaders (const String& apiKey) +{ + String headers = "Content-Type: application/json\r\nAccept: application/json\r\n"; + + if (apiKey.isNotEmpty()) + headers += "Authorization: Bearer " + apiKey + "\r\n"; + + return headers; +} + +bool shouldRetryAiStatus (int statusCode) +{ + return statusCode == 0 || statusCode == 408 || statusCode == 429 || statusCode >= 500; +} + +LLMResponse makeHttpErrorResponse (int statusCode, const String& body) +{ + if (body.isNotEmpty()) + { + auto parsed = JSON::parse (body); + + if (! parsed.isVoid()) + { + auto response = LLMResponse::fromOpenAiJson (parsed); + + if (response.failed()) + return response; + } + } + + if (statusCode > 0) + return LLMResponse::fromError ("AI HTTP request failed with status " + String (statusCode)); + + return LLMResponse::fromError ("AI HTTP request failed"); +} +} // namespace + +struct LLMHttpClient::Pimpl +{ + explicit Pimpl (LLMHttpClient& ownerToUse) + : owner (ownerToUse) + { + } + + LLMResponse complete (const Request& request) + { + const auto body = owner.buildChatCompletionBody (request, false); + const auto endpoint = makeAiEndpointUrl (owner.options.baseUrl, "/chat/completions"); + + for (int attempt = 0; attempt <= owner.options.maxRetries; ++attempt) + { + int statusCode = 0; + auto url = URL (endpoint).withPOSTData (body); + auto options = URL::InputStreamOptions (URL::ParameterHandling::inPostData) + .withExtraHeaders (makeAiHeaders (owner.options.apiKey)) + .withConnectionTimeoutMs (owner.options.timeoutMs) + .withStatusCode (&statusCode) + .withHttpRequestCmd ("POST"); + + auto stream = url.createInputStream (options); + const auto responseBody = stream != nullptr ? stream->readEntireStreamAsString() : String(); + + if (stream != nullptr && statusCode >= 200 && statusCode < 300) + return LLMResponse::fromOpenAiJson (JSON::parse (responseBody)); + + if (! shouldRetryAiStatus (statusCode) || attempt == owner.options.maxRetries) + return makeHttpErrorResponse (statusCode, responseBody); + } + + return LLMResponse::fromError ("AI HTTP request failed after retries"); + } + + bool completeStreaming (const Request& request, ChunkCallback onChunk) + { + if (! onChunk) + return false; + + const auto body = owner.buildChatCompletionBody (request, true); + const auto endpoint = makeAiEndpointUrl (owner.options.baseUrl, "/chat/completions"); + + for (int attempt = 0; attempt <= owner.options.maxRetries; ++attempt) + { + int statusCode = 0; + auto url = URL (endpoint).withPOSTData (body); + auto options = URL::InputStreamOptions (URL::ParameterHandling::inPostData) + .withExtraHeaders (makeAiHeaders (owner.options.apiKey)) + .withConnectionTimeoutMs (owner.options.timeoutMs) + .withStatusCode (&statusCode) + .withHttpRequestCmd ("POST"); + + auto stream = url.createInputStream (options); + + if (stream != nullptr && statusCode >= 200 && statusCode < 300) + { + LLMResponse accumulatedResponse; + + while (! stream->isExhausted()) + { + auto line = stream->readNextLine().trim(); + + if (! line.startsWith ("data:")) + continue; + + auto payload = line.substring (5).trim(); + if (payload == "[DONE]") + return true; + + auto parsed = JSON::parse (payload); + auto chunk = LLMResponse::fromStreamChunk (parsed); + + accumulatedResponse.appendStreamChunk (chunk); + onChunk (accumulatedResponse); + + if (chunk.failed()) + return false; + } + + return true; + } + + if (! shouldRetryAiStatus (statusCode) || attempt == owner.options.maxRetries) + break; + } + + return false; + } + + LLMHttpClient& owner; +}; + +LLMHttpClient::LLMHttpClient (Options options) + : LLMClient (std::move (options)) + , pimpl (std::make_unique (*this)) +{ +} + +LLMHttpClient::~LLMHttpClient() = default; + +LLMResponse LLMHttpClient::complete (const Request& request) +{ + return pimpl->complete (request); +} + +bool LLMHttpClient::completeStreaming (const Request& request, ChunkCallback onChunk) +{ + return pimpl->completeStreaming (request, std::move (onChunk)); +} + +} // namespace yup diff --git a/modules/yup_ai/llm/yup_LLMHttpClient.h b/modules/yup_ai/llm/yup_LLMHttpClient.h new file mode 100644 index 000000000..3073b2ebe --- /dev/null +++ b/modules/yup_ai/llm/yup_LLMHttpClient.h @@ -0,0 +1,44 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== +/** OpenAI-compatible HTTP chat completion client. + + @tags{AI} +*/ +class YUP_API LLMHttpClient : public LLMClient +{ +public: + explicit LLMHttpClient (Options options); + ~LLMHttpClient() override; + + LLMResponse complete (const Request& request) override; + bool completeStreaming (const Request& request, ChunkCallback onChunk) override; + +private: + struct Pimpl; + std::unique_ptr pimpl; +}; + +} // namespace yup diff --git a/modules/yup_ai/llm/yup_LLMMessage.cpp b/modules/yup_ai/llm/yup_LLMMessage.cpp new file mode 100644 index 000000000..157f28d40 --- /dev/null +++ b/modules/yup_ai/llm/yup_LLMMessage.cpp @@ -0,0 +1,223 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ +namespace +{ +var makeLLMMessageObject() +{ + return var (std::make_unique()); +} + +void setLLMMessageProperty (var& object, const Identifier& name, const var& value) +{ + if (auto* dynamicObject = object.getDynamicObject()) + dynamicObject->setProperty (name, value); +} + +String argumentsToApiString (const var& arguments) +{ + if (arguments.isObject() || arguments.isArray()) + return JSON::toString (arguments, true); + + return arguments.toString(); +} + +var parseArguments (const var& arguments) +{ + if (! arguments.isString()) + return arguments; + + auto parsed = JSON::parse (arguments.toString()); + return parsed.isVoid() ? arguments : parsed; +} +} // namespace + +var LLMToolCall::toVar() const +{ + auto functionObject = makeLLMMessageObject(); + setLLMMessageProperty (functionObject, "name", name); + setLLMMessageProperty (functionObject, "arguments", argumentsToApiString (arguments)); + + auto object = makeLLMMessageObject(); + setLLMMessageProperty (object, "id", id); + setLLMMessageProperty (object, "type", "function"); + setLLMMessageProperty (object, "function", functionObject); + + return object; +} + +std::optional LLMToolCall::fromVar (const var& value) +{ + if (! value.isObject()) + return std::nullopt; + + LLMToolCall result; + result.index = static_cast (value["index"]); + result.id = value["id"].toString(); + + if (auto* functionObject = value["function"].getDynamicObject()) + { + result.name = functionObject->getProperty ("name").toString(); + result.arguments = parseArguments (functionObject->getProperty ("arguments")); + } + else + { + result.name = value["name"].toString(); + result.arguments = parseArguments (value["arguments"]); + } + + const auto hasArguments = ! result.arguments.isVoid() + && ! result.arguments.isUndefined() + && result.arguments.toString().isNotEmpty(); + + if (result.name.isEmpty() && result.id.isEmpty() && ! hasArguments) + return std::nullopt; + + return result; +} + +LLMMessage LLMMessage::system (const String& content) +{ + LLMMessage result; + result.role = Role::system; + result.content = content; + return result; +} + +LLMMessage LLMMessage::user (const String& content) +{ + LLMMessage result; + result.role = Role::user; + result.content = content; + return result; +} + +LLMMessage LLMMessage::assistant (const String& content) +{ + LLMMessage result; + result.role = Role::assistant; + result.content = content; + return result; +} + +LLMMessage LLMMessage::toolResult (const String& toolCallId, const String& content) +{ + LLMMessage result; + result.role = Role::tool; + result.toolCallId = toolCallId; + result.content = content; + return result; +} + +var LLMMessage::toVar() const +{ + auto object = makeLLMMessageObject(); + setLLMMessageProperty (object, "role", roleToString (role)); + setLLMMessageProperty (object, "content", content); + + if (name.isNotEmpty()) + setLLMMessageProperty (object, "name", name); + + if (toolCallId.has_value()) + setLLMMessageProperty (object, "tool_call_id", *toolCallId); + + if (toolCalls.has_value()) + { + var calls; + + for (const auto& toolCall : *toolCalls) + calls.append (toolCall.toVar()); + + setLLMMessageProperty (object, "tool_calls", calls); + } + + return object; +} + +std::optional LLMMessage::fromVar (const var& value) +{ + if (! value.isObject()) + return std::nullopt; + + auto role = roleFromString (value["role"].toString()); + if (! role.has_value()) + return std::nullopt; + + LLMMessage result; + result.role = *role; + result.content = value["content"].toString(); + result.name = value["name"].toString(); + + if (value.hasProperty ("tool_call_id")) + result.toolCallId = value["tool_call_id"].toString(); + + if (auto* calls = value["tool_calls"].getArray()) + { + std::vector parsedCalls; + + for (const auto& call : *calls) + if (auto parsed = LLMToolCall::fromVar (call)) + parsedCalls.push_back (*parsed); + + result.toolCalls = std::move (parsedCalls); + } + + return result; +} + +String LLMMessage::roleToString (Role role) +{ + switch (role) + { + case Role::system: + return "system"; + case Role::user: + return "user"; + case Role::assistant: + return "assistant"; + case Role::tool: + return "tool"; + } + + jassertfalse; + return "user"; +} + +std::optional LLMMessage::roleFromString (const String& role) +{ + if (role == "system") + return Role::system; + + if (role == "user") + return Role::user; + + if (role == "assistant") + return Role::assistant; + + if (role == "tool") + return Role::tool; + + return std::nullopt; +} + +} // namespace yup diff --git a/modules/yup_ai/llm/yup_LLMMessage.h b/modules/yup_ai/llm/yup_LLMMessage.h new file mode 100644 index 000000000..40ac68f20 --- /dev/null +++ b/modules/yup_ai/llm/yup_LLMMessage.h @@ -0,0 +1,99 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== +/** Describes a function call requested by a chat model. + + Tool calls use the OpenAI-compatible shape where the model supplies an id, a + function name, and a JSON-compatible arguments value. String arguments returned + by remote APIs are parsed into var objects when possible. + + @tags{AI} +*/ +struct YUP_API LLMToolCall +{ + int index = 0; + String id; + String name; + var arguments; + + /** Converts this tool call to an OpenAI-compatible JSON var object. */ + var toVar() const; + + /** Attempts to parse an OpenAI-compatible JSON var object into a tool call. */ + static std::optional fromVar (const var& value); +}; + +//============================================================================== +/** A chat message for OpenAI-compatible completion APIs. + + The message can represent system, user, assistant, or tool-result content. + Assistant messages may carry tool calls, while tool messages use toolCallId + to correlate results with the requested call. + + @tags{AI} +*/ +class YUP_API LLMMessage +{ +public: + enum class Role + { + system, + user, + assistant, + tool + }; + + Role role = Role::user; + String content; + String name; + std::optional> toolCalls; + std::optional toolCallId; + + /** Creates a system message. */ + static LLMMessage system (const String& content); + + /** Creates a user message. */ + static LLMMessage user (const String& content); + + /** Creates an assistant message. */ + static LLMMessage assistant (const String& content); + + /** Creates a tool-result message correlated with a tool call id. */ + static LLMMessage toolResult (const String& toolCallId, const String& content); + + /** Converts this message to an OpenAI ChatML-compatible JSON var object. */ + var toVar() const; + + /** Attempts to parse an OpenAI ChatML-compatible JSON var object into a message. */ + static std::optional fromVar (const var& value); + + /** Converts a role enum to its API string representation. */ + static String roleToString (Role role); + + /** Parses an API role string. */ + static std::optional roleFromString (const String& role); +}; + +} // namespace yup diff --git a/modules/yup_ai/llm/yup_LLMResponse.cpp b/modules/yup_ai/llm/yup_LLMResponse.cpp new file mode 100644 index 000000000..32abc5d70 --- /dev/null +++ b/modules/yup_ai/llm/yup_LLMResponse.cpp @@ -0,0 +1,246 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ +namespace +{ +String getOpenAiErrorMessage (const var& json) +{ + if (json["error"].isString()) + return json["error"].toString(); + + if (json["error"].isObject()) + { + auto message = json["error"]["message"].toString(); + if (message.isNotEmpty()) + return message; + } + + return {}; +} + +var parseStreamArguments (const String& arguments) +{ + auto parsed = JSON::parse (arguments); + return parsed.isVoid() ? var (arguments) : parsed; +} + +String argumentsToStreamText (const var& arguments) +{ + if (arguments.isObject() || arguments.isArray()) + return JSON::toString (arguments, true); + + return arguments.toString(); +} + +LLMResponse::Choice& findOrAppendChoice (std::vector& choices, const LLMResponse::Choice& chunkChoice) +{ + for (auto& choice : choices) + if (choice.index == chunkChoice.index) + return choice; + + choices.push_back ({}); + auto& choice = choices.back(); + choice.index = chunkChoice.index; + choice.message.role = chunkChoice.message.role; + return choice; +} +} // namespace + +bool LLMResponse::hasToolCalls() const noexcept +{ + for (const auto& choice : choices) + if (choice.message.toolCalls.has_value() && ! choice.message.toolCalls->empty()) + return true; + + return false; +} + +bool LLMResponse::failed() const noexcept +{ + return errorMessage.has_value(); +} + +std::vector LLMResponse::getToolCalls() const +{ + std::vector result; + + for (const auto& choice : choices) + if (choice.message.toolCalls.has_value()) + result.insert (result.end(), choice.message.toolCalls->begin(), choice.message.toolCalls->end()); + + return result; +} + +void LLMResponse::appendStreamChunk (const LLMResponse& chunk) +{ + if (chunk.errorMessage.has_value()) + { + errorMessage = chunk.errorMessage; + return; + } + + if (model.isEmpty()) + model = chunk.model; + + for (const auto& chunkChoice : chunk.choices) + { + auto& choice = findOrAppendChoice (choices, chunkChoice); + + if (choice.message.role == LLMMessage::Role::assistant) + choice.message.role = chunkChoice.message.role; + + choice.message.content += chunkChoice.message.content; + + if (chunkChoice.finishReason.has_value()) + choice.finishReason = chunkChoice.finishReason; + + if (! chunkChoice.message.toolCalls.has_value()) + continue; + + if (! choice.message.toolCalls.has_value()) + choice.message.toolCalls = std::vector(); + + for (const auto& chunkToolCall : *chunkChoice.message.toolCalls) + { + const auto toolIndex = chunkToolCall.index; + if (toolIndex < 0) + continue; + + if (toolIndex >= static_cast (choice.message.toolCalls->size())) + choice.message.toolCalls->resize (static_cast (toolIndex + 1)); + + auto& toolCall = (*choice.message.toolCalls)[static_cast (toolIndex)]; + toolCall.index = toolIndex; + + if (chunkToolCall.id.isNotEmpty()) + toolCall.id = chunkToolCall.id; + + if (chunkToolCall.name.isNotEmpty()) + toolCall.name = chunkToolCall.name; + + const auto mergedArguments = argumentsToStreamText (toolCall.arguments) + argumentsToStreamText (chunkToolCall.arguments); + if (mergedArguments.isNotEmpty()) + toolCall.arguments = parseStreamArguments (mergedArguments); + } + } +} + +LLMResponse LLMResponse::fromError (const String& message) +{ + LLMResponse response; + response.errorMessage = message.isEmpty() ? String ("Unknown AI response error") : message; + return response; +} + +LLMResponse LLMResponse::fromOpenAiJson (const var& json) +{ + LLMResponse response; + + if (json.isVoid()) + return fromError ("Unable to parse chat completion response JSON"); + + if (auto error = getOpenAiErrorMessage (json); error.isNotEmpty()) + return fromError (error); + + response.model = json["model"].toString(); + + if (auto* choicesArray = json["choices"].getArray()) + { + for (const auto& choiceVar : *choicesArray) + { + Choice choice; + choice.index = static_cast (choiceVar["index"]); + + if (auto message = LLMMessage::fromVar (choiceVar["message"])) + choice.message = *message; + + if (choiceVar.hasProperty ("finish_reason") && ! choiceVar["finish_reason"].isVoid()) + choice.finishReason = choiceVar["finish_reason"].toString(); + + response.choices.push_back (std::move (choice)); + } + } + + if (json["usage"].isObject()) + { + Usage usage; + usage.promptTokens = static_cast (json["usage"]["prompt_tokens"]); + usage.completionTokens = static_cast (json["usage"]["completion_tokens"]); + usage.totalTokens = static_cast (json["usage"]["total_tokens"]); + response.usage = usage; + } + + return response; +} + +LLMResponse LLMResponse::fromStreamChunk (const var& json) +{ + LLMResponse response; + + if (json.isVoid()) + return fromError ("Unable to parse chat completion stream JSON"); + + if (auto error = getOpenAiErrorMessage (json); error.isNotEmpty()) + return fromError (error); + + response.model = json["model"].toString(); + + if (auto* choicesArray = json["choices"].getArray()) + { + for (const auto& choiceVar : *choicesArray) + { + Choice choice; + choice.index = static_cast (choiceVar["index"]); + choice.message.role = LLMMessage::Role::assistant; + + const auto& delta = choiceVar["delta"]; + if (delta.isObject()) + { + if (auto role = LLMMessage::roleFromString (delta["role"].toString())) + choice.message.role = *role; + + choice.message.content = delta["content"].toString(); + + if (auto* toolCallsArray = delta["tool_calls"].getArray()) + { + std::vector toolCalls; + + for (const auto& callVar : *toolCallsArray) + if (auto toolCall = LLMToolCall::fromVar (callVar)) + toolCalls.push_back (*toolCall); + + choice.message.toolCalls = std::move (toolCalls); + } + } + + if (choiceVar.hasProperty ("finish_reason") && ! choiceVar["finish_reason"].isVoid()) + choice.finishReason = choiceVar["finish_reason"].toString(); + + response.choices.push_back (std::move (choice)); + } + } + + return response; +} + +} // namespace yup diff --git a/modules/yup_ai/llm/yup_LLMResponse.h b/modules/yup_ai/llm/yup_LLMResponse.h new file mode 100644 index 000000000..341239413 --- /dev/null +++ b/modules/yup_ai/llm/yup_LLMResponse.h @@ -0,0 +1,74 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== +/** Parsed chat completion response. + + @tags{AI} +*/ +class YUP_API LLMResponse +{ +public: + struct Choice + { + int index = 0; + LLMMessage message; + std::optional finishReason; + }; + + struct Usage + { + int promptTokens = 0; + int completionTokens = 0; + int totalTokens = 0; + }; + + std::vector choices; + std::optional usage; + String model; + std::optional errorMessage; + + /** Returns true if any choice contains assistant tool calls. */ + bool hasToolCalls() const noexcept; + + /** Returns true if this response represents an API, transport, or parse error. */ + bool failed() const noexcept; + + /** Returns all tool calls from all choices. */ + std::vector getToolCalls() const; + + /** Appends a streaming response chunk to this response, concatenating content and tool-call arguments by choice index. */ + void appendStreamChunk (const LLMResponse& chunk); + + /** Creates an error response with a diagnostic message. */ + static LLMResponse fromError (const String& message); + + /** Parses a non-streaming OpenAI-compatible chat completion response. */ + static LLMResponse fromOpenAiJson (const var& json); + + /** Parses a streaming OpenAI-compatible chat completion delta chunk. */ + static LLMResponse fromStreamChunk (const var& json); +}; + +} // namespace yup diff --git a/modules/yup_ai/llm/yup_LLMTool.cpp b/modules/yup_ai/llm/yup_LLMTool.cpp new file mode 100644 index 000000000..30b5865bb --- /dev/null +++ b/modules/yup_ai/llm/yup_LLMTool.cpp @@ -0,0 +1,127 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ +namespace +{ +var makeLLMToolObject() +{ + return var (std::make_unique()); +} + +void setLLMToolProperty (var& object, const Identifier& name, const var& value) +{ + if (auto* dynamicObject = object.getDynamicObject()) + dynamicObject->setProperty (name, value); +} + +var makeErrorObject (const String& message) +{ + auto object = makeLLMToolObject(); + setLLMToolProperty (object, "error", true); + setLLMToolProperty (object, "message", message); + return object; +} + +var parameterToSchema (const LLMTool::Parameter& parameter) +{ + auto schema = makeLLMToolObject(); + setLLMToolProperty (schema, "type", parameter.type); + + if (parameter.description.isNotEmpty()) + setLLMToolProperty (schema, "description", parameter.description); + + if (parameter.enumValues.has_value()) + setLLMToolProperty (schema, "enum", *parameter.enumValues); + + if (parameter.defaultValue.has_value()) + setLLMToolProperty (schema, "default", *parameter.defaultValue); + + if (parameter.properties.has_value()) + { + auto properties = makeLLMToolObject(); + var required; + + for (const auto& child : *parameter.properties) + { + setLLMToolProperty (properties, child.name, parameterToSchema (child)); + + if (child.required) + required.append (child.name); + } + + setLLMToolProperty (schema, "properties", properties); + + if (required.size() > 0) + setLLMToolProperty (schema, "required", required); + } + + return schema; +} +} // namespace + +var LLMTool::toJsonSchema() const +{ + auto properties = makeLLMToolObject(); + var required; + + for (const auto& parameter : parameters) + { + setLLMToolProperty (properties, parameter.name, parameterToSchema (parameter)); + + if (parameter.required) + required.append (parameter.name); + } + + auto parameterSchema = makeLLMToolObject(); + setLLMToolProperty (parameterSchema, "type", "object"); + setLLMToolProperty (parameterSchema, "properties", properties); + + if (required.size() > 0) + setLLMToolProperty (parameterSchema, "required", required); + + auto functionObject = makeLLMToolObject(); + setLLMToolProperty (functionObject, "name", name); + setLLMToolProperty (functionObject, "description", description); + setLLMToolProperty (functionObject, "parameters", parameterSchema); + + auto toolObject = makeLLMToolObject(); + setLLMToolProperty (toolObject, "type", "function"); + setLLMToolProperty (toolObject, "function", functionObject); + + return toolObject; +} + +var LLMTool::execute (const var& arguments) const +{ + if (! handler) + return makeErrorObject ("No handler registered for tool '" + name + "'"); + + return handler (arguments); +} + +void LLMTool::setHandler (Handler newHandler) +{ + handler = std::move (newHandler); +} + +} // namespace yup diff --git a/modules/yup_ai/llm/yup_LLMTool.h b/modules/yup_ai/llm/yup_LLMTool.h new file mode 100644 index 000000000..dcd7a90a6 --- /dev/null +++ b/modules/yup_ai/llm/yup_LLMTool.h @@ -0,0 +1,71 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== +/** Describes an LLM-callable function and its JSON Schema parameter model. + + Tools are serialised to the OpenAI function-calling format. The handler is a + local callable that receives JSON-compatible arguments and returns a + JSON-compatible result. + + @tags{AI} +*/ +class YUP_API LLMTool +{ +public: + /** A JSON Schema parameter description. */ + struct Parameter + { + String name; + String type; + String description; + bool required = false; + std::optional enumValues; + std::optional defaultValue; + std::optional> properties; + }; + + using Handler = std::function; + + String name; + String description; + std::vector parameters; + + /** Converts this tool to the OpenAI function-calling schema. */ + var toJsonSchema() const; + + /** Executes the registered handler. + + If no handler is installed, the returned value is an error object. + */ + var execute (const var& arguments) const; + + /** Installs or replaces the handler for this tool. */ + void setHandler (Handler newHandler); + +private: + Handler handler; +}; + +} // namespace yup diff --git a/modules/yup_ai/llm/yup_LLMToolRegistry.cpp b/modules/yup_ai/llm/yup_LLMToolRegistry.cpp new file mode 100644 index 000000000..5ba015cb4 --- /dev/null +++ b/modules/yup_ai/llm/yup_LLMToolRegistry.cpp @@ -0,0 +1,120 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ +namespace +{ +var makeToolRegistryObject() +{ + return var (std::make_unique()); +} + +void setToolRegistryProperty (var& object, const Identifier& name, const var& value) +{ + if (auto* dynamicObject = object.getDynamicObject()) + dynamicObject->setProperty (name, value); +} + +var makeToolRegistryErrorObject (const String& message) +{ + auto object = makeToolRegistryObject(); + setToolRegistryProperty (object, "error", true); + setToolRegistryProperty (object, "message", message); + return object; +} +} // namespace + +void LLMToolRegistry::registerTool (LLMTool tool) +{ + const ScopedLock lock (mutex); + tools[tool.name] = std::move (tool); + lookupCache.reset(); +} + +void LLMToolRegistry::unregisterTool (const String& name) +{ + const ScopedLock lock (mutex); + tools.erase (name); + lookupCache.reset(); +} + +bool LLMToolRegistry::contains (const String& name) const noexcept +{ + const ScopedLock lock (mutex); + return tools.find (name) != tools.end(); +} + +const LLMTool* LLMToolRegistry::findTool (const String& name) const noexcept +{ + const ScopedLock lock (mutex); + + if (auto iter = tools.find (name); iter != tools.end()) + { + lookupCache = iter->second; + return std::addressof (*lookupCache); + } + + lookupCache.reset(); + return nullptr; +} + +std::vector LLMToolRegistry::getAllTools() const +{ + const ScopedLock lock (mutex); + + std::vector result; + result.reserve (tools.size()); + + for (const auto& entry : tools) + result.push_back (entry.second); + + return result; +} + +var LLMToolRegistry::toToolsArray() const +{ + var result; + + for (const auto& tool : getAllTools()) + result.append (tool.toJsonSchema()); + + return result; +} + +var LLMToolRegistry::dispatchToolCall (const String& name, const var& arguments) const +{ + std::optional tool; + + { + const ScopedLock lock (mutex); + + if (auto iter = tools.find (name); iter != tools.end()) + tool = iter->second; + } + + if (! tool.has_value()) + return makeToolRegistryErrorObject ("Unknown tool '" + name + "'"); + + return tool->execute (arguments); +} + +} // namespace yup diff --git a/modules/yup_ai/llm/yup_LLMToolRegistry.h b/modules/yup_ai/llm/yup_LLMToolRegistry.h new file mode 100644 index 000000000..73f6e6cdc --- /dev/null +++ b/modules/yup_ai/llm/yup_LLMToolRegistry.h @@ -0,0 +1,64 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== +/** Thread-safe storage and dispatch for LLM tools. + + @tags{AI} +*/ +class YUP_API LLMToolRegistry +{ +public: + /** Adds or replaces a tool by name. */ + void registerTool (LLMTool tool); + + /** Removes a tool by name if it exists. */ + void unregisterTool (const String& name); + + /** Returns true if a tool with this name exists. */ + bool contains (const String& name) const noexcept; + + /** Returns a pointer to a copied cache entry for immediate read-only use. + + Prefer getAllTools() or dispatchToolCall() for thread-safe ownership + across longer lifetimes. + */ + const LLMTool* findTool (const String& name) const noexcept; + + /** Returns a snapshot of all registered tools. */ + std::vector getAllTools() const; + + /** Converts all registered tools to the OpenAI tools array. */ + var toToolsArray() const; + + /** Dispatches a tool call by name. Missing tools return an error object. */ + var dispatchToolCall (const String& name, const var& arguments) const; + +private: + mutable CriticalSection mutex; + mutable std::optional lookupCache; + std::unordered_map tools; +}; + +} // namespace yup diff --git a/modules/yup_ai/mcp/yup_MCPClient.cpp b/modules/yup_ai/mcp/yup_MCPClient.cpp new file mode 100644 index 000000000..8b0985b92 --- /dev/null +++ b/modules/yup_ai/mcp/yup_MCPClient.cpp @@ -0,0 +1,325 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ +namespace +{ +var makeMCPClientObject() +{ + return var (std::make_unique()); +} + +void setMCPClientProperty (var& object, const Identifier& name, const var& value) +{ + if (auto* dynamicObject = object.getDynamicObject()) + dynamicObject->setProperty (name, value); +} + +var makeRequestParamsWithNameAndArguments (const String& toolName, const var& arguments) +{ + auto params = makeMCPClientObject(); + setMCPClientProperty (params, "name", toolName); + setMCPClientProperty (params, "arguments", arguments); + return params; +} + +var makeResourceReadParams (const String& uri) +{ + auto params = makeMCPClientObject(); + setMCPClientProperty (params, "uri", uri); + return params; +} + +var makeInitializeParams (const MCPCapabilities& capabilities) +{ + auto params = makeMCPClientObject(); + setMCPClientProperty (params, "protocolVersion", "2024-11-05"); + setMCPClientProperty (params, "capabilities", capabilities.toVar()); + + auto clientInfo = makeMCPClientObject(); + setMCPClientProperty (clientInfo, "name", "YUP"); + setMCPClientProperty (clientInfo, "version", "1.0.0"); + setMCPClientProperty (params, "clientInfo", clientInfo); + + return params; +} + +var unwrapToolCallResult (const var& result) +{ + if (auto* content = result["content"].getArray()) + { + if (content->isEmpty()) + return {}; + + const auto& firstContent = content->getReference (0); + + if (firstContent["type"].toString() == "text") + return firstContent["text"]; + + if (! firstContent["json"].isVoid()) + return firstContent["json"]; + } + + return result; +} + +ResultValue unwrapResourceReadResult (const var& result) +{ + if (auto* contents = result["contents"].getArray()) + { + if (contents->isEmpty()) + return makeResultValueFail ("MCP resource response did not contain content"); + + const auto& firstContent = contents->getReference (0); + if (firstContent.hasProperty ("text")) + return makeResultValueOk (firstContent["text"].toString()); + + if (firstContent.hasProperty ("blob")) + return makeResultValueOk (firstContent["blob"].toString()); + } + + if (result.isString()) + return makeResultValueOk (result.toString()); + + return makeResultValueFail ("MCP resource response did not contain readable text"); +} + +bool schemaMarksParameterRequired (const var& schema, const String& parameterName) +{ + if (auto* required = schema["required"].getArray()) + for (const auto& requiredName : *required) + if (parameterName == requiredName.toString()) + return true; + + return false; +} + +std::optional> schemaPropertiesToParameters (const var& schema); + +LLMTool::Parameter schemaPropertyToParameter (const Identifier& name, const var& schema, bool required) +{ + LLMTool::Parameter parameter; + parameter.name = name.toString(); + parameter.type = schema["type"].toString(); + parameter.description = schema["description"].toString(); + parameter.required = required; + + if (schema.hasProperty ("enum")) + parameter.enumValues = schema["enum"]; + + if (schema.hasProperty ("default")) + parameter.defaultValue = schema["default"]; + + if (auto nestedProperties = schemaPropertiesToParameters (schema); nestedProperties.has_value()) + parameter.properties = std::move (*nestedProperties); + else if (auto nestedItems = schemaPropertiesToParameters (schema["items"]); nestedItems.has_value()) + parameter.properties = std::move (*nestedItems); + + return parameter; +} + +std::optional> schemaPropertiesToParameters (const var& schema) +{ + auto* properties = schema["properties"].getDynamicObject(); + if (properties == nullptr) + return std::nullopt; + + std::vector parameters; + + for (const auto& property : properties->getProperties()) + { + const auto propertyName = property.name.toString(); + parameters.push_back (schemaPropertyToParameter (property.name, + property.value, + schemaMarksParameterRequired (schema, propertyName))); + } + + return parameters; +} +} // namespace + +struct MCPClient::Pimpl +{ + explicit Pimpl (std::unique_ptr transportToUse) + : transport (std::move (transportToUse)) + { + } + + ResultValue sendRequest (const String& method, std::optional params) + { + if (transport == nullptr) + return makeResultValueFail ("MCP client has no transport"); + + if (! transport->isConnected()) + { + if (auto startResult = transport->start(); startResult.failed()) + return makeResultValueFail (startResult.getErrorMessage()); + } + + JsonRpcRequest request; + request.id = static_cast (nextRequestId++); + request.method = method; + request.params = std::move (params); + + if (auto sendResult = transport->sendMessage (request.toVar()); sendResult.failed()) + return makeResultValueFail (sendResult.getErrorMessage()); + + for (;;) + { + auto received = transport->receiveMessage(); + if (received.failed()) + return makeResultValueFail (received.getErrorMessage()); + + auto response = JsonRpcResponse::fromVar (received.getReference()); + if (! response.has_value()) + continue; + + if (response->id.equals (*request.id)) + return makeResultValueOk (std::move (*response)); + } + } + + Result sendNotification (const String& method, std::optional params) + { + if (transport == nullptr) + return Result::fail ("MCP client has no transport"); + + JsonRpcRequest notification; + notification.method = method; + notification.params = std::move (params); + + return transport->sendMessage (notification.toVar()); + } + + std::unique_ptr transport; + int64 nextRequestId = 1; +}; + +MCPClient::MCPClient (std::unique_ptr transport) + : pimpl (std::make_unique (std::move (transport))) +{ +} + +MCPClient::~MCPClient() = default; + +Result MCPClient::initialize (MCPCapabilities clientCapabilities) +{ + auto response = pimpl->sendRequest ("initialize", makeInitializeParams (clientCapabilities)); + if (response.failed()) + return Result::fail (response.getErrorMessage()); + + if (response.getReference().isError()) + return Result::fail (response.getReference().error->message); + + return pimpl->sendNotification ("notifications/initialized", std::nullopt); +} + +std::vector MCPClient::listTools() +{ + std::vector result; + + auto response = pimpl->sendRequest ("tools/list", std::nullopt); + if (response.failed() || response.getReference().isError() || ! response.getReference().result.has_value()) + return result; + + if (auto* tools = (*response.getReference().result)["tools"].getArray()) + for (const auto& toolVar : *tools) + if (auto tool = MCPToolDefinition::fromVar (toolVar)) + result.push_back (std::move (*tool)); + + return result; +} + +ResultValue MCPClient::callTool (const String& toolName, const var& arguments) +{ + auto response = pimpl->sendRequest ("tools/call", makeRequestParamsWithNameAndArguments (toolName, arguments)); + if (response.failed()) + return makeResultValueFail (response.getErrorMessage()); + + if (response.getReference().isError()) + return makeResultValueFail (response.getReference().error->message); + + if (! response.getReference().result.has_value()) + return makeResultValueFail ("MCP tool call response did not contain a result"); + + return makeResultValueOk (unwrapToolCallResult (*response.getReference().result)); +} + +std::vector MCPClient::listResources() +{ + std::vector result; + + auto response = pimpl->sendRequest ("resources/list", std::nullopt); + if (response.failed() || response.getReference().isError() || ! response.getReference().result.has_value()) + return result; + + if (auto* resources = (*response.getReference().result)["resources"].getArray()) + for (const auto& resourceVar : *resources) + if (auto resource = MCPResourceDefinition::fromVar (resourceVar)) + result.push_back (std::move (*resource)); + + return result; +} + +ResultValue MCPClient::readResource (const String& uri) +{ + auto response = pimpl->sendRequest ("resources/read", makeResourceReadParams (uri)); + if (response.failed()) + return makeResultValueFail (response.getErrorMessage()); + + if (response.getReference().isError()) + return makeResultValueFail (response.getReference().error->message); + + if (! response.getReference().result.has_value()) + return makeResultValueFail ("MCP resource response did not contain a result"); + + return unwrapResourceReadResult (*response.getReference().result); +} + +void MCPClient::registerToolsWith (LLMToolRegistry& registry) +{ + for (auto toolDefinition : listTools()) + { + LLMTool tool; + tool.name = toolDefinition.name; + tool.description = toolDefinition.description; + + if (auto parameters = schemaPropertiesToParameters (toolDefinition.inputSchema)) + tool.parameters = std::move (*parameters); + + tool.setHandler ([this, toolName = tool.name] (const var& arguments) + { + auto callResult = callTool (toolName, arguments); + return callResult.wasOk() ? callResult.getValue() + : var (callResult.getErrorMessage()); + }); + + registry.registerTool (std::move (tool)); + } +} + +MCPTransport* MCPClient::getTransport() noexcept +{ + return pimpl->transport.get(); +} + +} // namespace yup diff --git a/modules/yup_ai/mcp/yup_MCPClient.h b/modules/yup_ai/mcp/yup_MCPClient.h new file mode 100644 index 000000000..2b8b709a0 --- /dev/null +++ b/modules/yup_ai/mcp/yup_MCPClient.h @@ -0,0 +1,66 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== +/** Synchronous MCP client over an `MCPTransport`. + + The client performs JSON-RPC request/response correlation and exposes common + MCP methods for initialization, tool discovery, tool calls, and resources. + + @tags{AI} +*/ +class YUP_API MCPClient +{ +public: + /** Connects this client to an MCP server using the supplied transport. */ + explicit MCPClient (std::unique_ptr transport); + ~MCPClient(); + + /** Performs the MCP `initialize` handshake and sends the initialized notification. */ + Result initialize (MCPCapabilities clientCapabilities = {}); + + /** Requests the server's available tools. */ + std::vector listTools(); + + /** Calls a server tool with JSON-compatible arguments. */ + ResultValue callTool (const String& toolName, const var& arguments); + + /** Requests the server's available resources. */ + std::vector listResources(); + + /** Reads a resource by URI, returning text content when available. */ + ResultValue readResource (const String& uri); + + /** Imports remote MCP tools into an LLM tool registry. */ + void registerToolsWith (LLMToolRegistry& registry); + + /** Returns the underlying transport. */ + MCPTransport* getTransport() noexcept; + +private: + struct Pimpl; + std::unique_ptr pimpl; +}; + +} // namespace yup diff --git a/modules/yup_ai/mcp/yup_MCPServer.cpp b/modules/yup_ai/mcp/yup_MCPServer.cpp new file mode 100644 index 000000000..b5de71a19 --- /dev/null +++ b/modules/yup_ai/mcp/yup_MCPServer.cpp @@ -0,0 +1,357 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ +namespace +{ +var makeMCPServerObject() +{ + return var (std::make_unique()); +} + +void setMCPServerProperty (var& object, const Identifier& name, const var& value) +{ + if (auto* dynamicObject = object.getDynamicObject()) + dynamicObject->setProperty (name, value); +} + +JsonRpcResponse makeMCPErrorResponse (const var& id, int code, const String& message) +{ + JsonRpcResponse response; + response.id = id; + response.error = JsonRpcError { code, message, std::nullopt }; + return response; +} + +var makeTextContent (const String& text) +{ + auto content = makeMCPServerObject(); + setMCPServerProperty (content, "type", "text"); + setMCPServerProperty (content, "text", text); + return content; +} + +var makeJsonContent (const var& value) +{ + auto content = makeMCPServerObject(); + setMCPServerProperty (content, "type", "json"); + setMCPServerProperty (content, "json", value); + return content; +} + +var makeToolCallResult (const var& value) +{ + var content; + + if (value.isString()) + content.append (makeTextContent (value.toString())); + else + content.append (makeJsonContent (value)); + + auto result = makeMCPServerObject(); + setMCPServerProperty (result, "content", content); + return result; +} + +MCPToolDefinition toolDefinitionFromLLMTool (const LLMTool& tool) +{ + auto schema = tool.toJsonSchema(); + + MCPToolDefinition definition; + definition.name = tool.name; + definition.description = tool.description; + definition.inputSchema = schema["function"]["parameters"]; + return definition; +} +} // namespace + +struct MCPServer::Pimpl +{ + struct ResourceEntry + { + MCPResourceDefinition definition; + std::function reader; + }; + + explicit Pimpl (Options optionsToUse) + : options (std::move (optionsToUse)) + { + } + + void registerTool (MCPToolDefinition definition, LLMTool tool) + { + { + const ScopedLock lock (mutex); + toolDefinitions[definition.name] = std::move (definition); + options.capabilities.supportsTools = true; + } + + toolRegistry.registerTool (std::move (tool)); + } + + void sendResponse (const JsonRpcResponse& response) + { + auto* currentTransport = transport.get(); + if (currentTransport != nullptr) + currentTransport->sendMessage (response.toVar()); + } + + var makeInitializeResult() const + { + auto result = makeMCPServerObject(); + setMCPServerProperty (result, "protocolVersion", "2024-11-05"); + setMCPServerProperty (result, "capabilities", options.capabilities.toVar()); + + auto serverInfo = makeMCPServerObject(); + setMCPServerProperty (serverInfo, "name", options.serverName); + setMCPServerProperty (serverInfo, "version", options.serverVersion); + setMCPServerProperty (result, "serverInfo", serverInfo); + + return result; + } + + var makeToolsListResult() const + { + var tools; + + { + const ScopedLock lock (mutex); + for (const auto& entry : toolDefinitions) + tools.append (entry.second.toVar()); + } + + auto result = makeMCPServerObject(); + setMCPServerProperty (result, "tools", tools); + return result; + } + + var callTool (const var& params) const + { + const auto toolName = params["name"].toString(); + if (toolName.isEmpty()) + return makeToolCallResult (var ("Missing MCP tool name")); + + return makeToolCallResult (toolRegistry.dispatchToolCall (toolName, params["arguments"])); + } + + var makeResourcesListResult() const + { + var resources; + + { + const ScopedLock lock (mutex); + for (const auto& entry : resourcesByUri) + resources.append (entry.second.definition.toVar()); + } + + auto result = makeMCPServerObject(); + setMCPServerProperty (result, "resources", resources); + return result; + } + + ResultValue readResource (const var& params) const + { + const auto uri = params["uri"].toString(); + + ResourceEntry entry; + { + const ScopedLock lock (mutex); + auto iter = resourcesByUri.find (uri); + if (iter == resourcesByUri.end()) + return makeResultValueFail ("Unknown MCP resource '" + uri + "'"); + + entry = iter->second; + } + + auto content = makeMCPServerObject(); + setMCPServerProperty (content, "uri", entry.definition.uri); + setMCPServerProperty (content, "mimeType", entry.definition.mimeType); + setMCPServerProperty (content, "text", entry.reader ? entry.reader() : String()); + + var contents; + contents.append (content); + + auto result = makeMCPServerObject(); + setMCPServerProperty (result, "contents", contents); + return makeResultValueOk (result); + } + + std::optional handleRequest (const JsonRpcRequest& request) + { + if (request.isNotification()) + return std::nullopt; + + JsonRpcResponse response; + response.id = *request.id; + + if (request.method == "initialize") + response.result = makeInitializeResult(); + else if (request.method == "tools/list") + response.result = makeToolsListResult(); + else if (request.method == "tools/call") + response.result = callTool (request.params.value_or (var())); + else if (request.method == "resources/list") + response.result = makeResourcesListResult(); + else if (request.method == "resources/read") + { + auto result = readResource (request.params.value_or (var())); + if (result.failed()) + return makeMCPErrorResponse (*request.id, MCPErrorCodes::invalidParams, result.getErrorMessage()); + + response.result = result.getValue(); + } + else + { + response.error = JsonRpcError { MCPErrorCodes::methodNotFound, "Unknown MCP method '" + request.method + "'", std::nullopt }; + } + + return response; + } + + void handleMessage (const var& message) + { + auto request = JsonRpcRequest::fromVar (message); + if (! request.has_value()) + { + sendResponse (makeMCPErrorResponse (message["id"], MCPErrorCodes::invalidRequest, "Invalid JSON-RPC request")); + return; + } + + if (auto response = handleRequest (*request)) + sendResponse (*response); + } + + Options options; + LLMToolRegistry toolRegistry; + mutable CriticalSection mutex; + std::unordered_map toolDefinitions; + std::unordered_map resourcesByUri; + std::unique_ptr transport; + bool running = false; +}; + +MCPServer::MCPServer() + : MCPServer (Options {}) +{ +} + +MCPServer::MCPServer (Options options) + : pimpl (std::make_unique (std::move (options))) +{ +} + +MCPServer::~MCPServer() +{ + stop(); +} + +void MCPServer::registerTool (MCPToolDefinition tool, LLMTool::Handler handler) +{ + LLMTool llmTool; + llmTool.name = tool.name; + llmTool.description = tool.description; + llmTool.setHandler (std::move (handler)); + + pimpl->registerTool (std::move (tool), std::move (llmTool)); +} + +void MCPServer::registerTool (LLMTool tool) +{ + auto definition = toolDefinitionFromLLMTool (tool); + pimpl->registerTool (std::move (definition), std::move (tool)); +} + +void MCPServer::unregisterTool (const String& name) +{ + { + const ScopedLock lock (pimpl->mutex); + pimpl->toolDefinitions.erase (name); + } + + pimpl->toolRegistry.unregisterTool (name); +} + +void MCPServer::registerResource (MCPResourceDefinition resource, std::function reader) +{ + const ScopedLock lock (pimpl->mutex); + const auto uri = resource.uri; + pimpl->resourcesByUri[uri] = Pimpl::ResourceEntry { std::move (resource), std::move (reader) }; + pimpl->options.capabilities.supportsResources = true; +} + +void MCPServer::unregisterResource (const String& uri) +{ + const ScopedLock lock (pimpl->mutex); + pimpl->resourcesByUri.erase (uri); +} + +Result MCPServer::start (std::unique_ptr transport) +{ + if (transport == nullptr) + return Result::fail ("Cannot start MCP server without a transport"); + + stop(); + + pimpl->transport = std::move (transport); + pimpl->transport->setMessageHandler ([this] (const var& message) + { + pimpl->handleMessage (message); + }); + + auto result = pimpl->transport->start(); + if (result.failed()) + { + pimpl->transport.reset(); + return result; + } + + pimpl->running = true; + return Result::ok(); +} + +void MCPServer::stop() +{ + if (pimpl->transport != nullptr) + { + pimpl->transport->stop(); + pimpl->transport.reset(); + } + + pimpl->running = false; +} + +bool MCPServer::isRunning() const noexcept +{ + return pimpl->running; +} + +Result MCPServer::startStdio() +{ + return Result::fail ("MCP stdio transport is not implemented yet"); +} + +Result MCPServer::startHttp (int) +{ + return Result::fail ("MCP HTTP transport is not implemented yet"); +} + +} // namespace yup diff --git a/modules/yup_ai/mcp/yup_MCPServer.h b/modules/yup_ai/mcp/yup_MCPServer.h new file mode 100644 index 000000000..99ec85ea1 --- /dev/null +++ b/modules/yup_ai/mcp/yup_MCPServer.h @@ -0,0 +1,82 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== +/** MCP server that exposes local YUP tools and resources over an `MCPTransport`. + + The server handles JSON-RPC messages for initialization, `tools/list`, + `tools/call`, `resources/list`, and `resources/read`. + + @tags{AI} +*/ +class YUP_API MCPServer +{ +public: + struct Options + { + String serverName = "YUP Application"; + String serverVersion = "1.0.0"; + MCPCapabilities capabilities = {}; + }; + + MCPServer(); + explicit MCPServer (Options options); + ~MCPServer(); + + /** Registers a tool definition and handler. */ + void registerTool (MCPToolDefinition tool, LLMTool::Handler handler); + + /** Registers an LLM tool, deriving the MCP tool definition from its JSON Schema. */ + void registerTool (LLMTool tool); + + /** Removes a tool by name. */ + void unregisterTool (const String& name); + + /** Registers a readable MCP resource. */ + void registerResource (MCPResourceDefinition resource, std::function reader); + + /** Removes a resource by URI. */ + void unregisterResource (const String& uri); + + /** Starts serving messages on the supplied transport. */ + Result start (std::unique_ptr transport); + + /** Stops serving and releases the transport. */ + void stop(); + + /** Returns true while a transport is active. */ + bool isRunning() const noexcept; + + /** Convenience placeholder for future stdio transport support. */ + Result startStdio(); + + /** Convenience placeholder for future HTTP transport support. */ + Result startHttp (int port); + +private: + struct Pimpl; + std::unique_ptr pimpl; +}; + +} // namespace yup diff --git a/modules/yup_ai/mcp/yup_MCPTransport.h b/modules/yup_ai/mcp/yup_MCPTransport.h new file mode 100644 index 000000000..6fd5d7d41 --- /dev/null +++ b/modules/yup_ai/mcp/yup_MCPTransport.h @@ -0,0 +1,59 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== +/** Abstract transport for JSON-RPC messages used by MCP. + + Implementations may use stdio, HTTP/SSE, sockets, or in-process queues. The + payload is always a JSON-compatible `var` object. + + @tags{AI} +*/ +class YUP_API MCPTransport +{ +public: + using MessageHandler = std::function; + + virtual ~MCPTransport() = default; + + /** Sends one JSON-RPC message. */ + virtual Result sendMessage (const var& message) = 0; + + /** Receives the next JSON-RPC message, blocking until timeout when supported. */ + virtual ResultValue receiveMessage (int timeoutMs = -1) = 0; + + /** Installs a callback for asynchronous incoming messages. */ + virtual void setMessageHandler (MessageHandler handler) = 0; + + /** Starts the transport. */ + virtual Result start() = 0; + + /** Stops the transport and releases any underlying connection. */ + virtual void stop() = 0; + + /** Returns true while the transport can send and receive messages. */ + virtual bool isConnected() const noexcept = 0; +}; + +} // namespace yup diff --git a/modules/yup_ai/mcp/yup_MCPTypes.cpp b/modules/yup_ai/mcp/yup_MCPTypes.cpp new file mode 100644 index 000000000..be54312e2 --- /dev/null +++ b/modules/yup_ai/mcp/yup_MCPTypes.cpp @@ -0,0 +1,244 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ +namespace +{ +var makeMCPObject() +{ + return var (std::make_unique()); +} + +void setMCPProperty (var& object, const Identifier& name, const var& value) +{ + if (auto* dynamicObject = object.getDynamicObject()) + dynamicObject->setProperty (name, value); +} + +bool hasPresentProperty (const var& object, const Identifier& name) +{ + return object.hasProperty (name) && ! object[name].isUndefined(); +} +} // namespace + +var JsonRpcError::toVar() const +{ + auto object = makeMCPObject(); + setMCPProperty (object, "code", code); + setMCPProperty (object, "message", message); + + if (data.has_value()) + setMCPProperty (object, "data", *data); + + return object; +} + +std::optional JsonRpcError::fromVar (const var& value) +{ + if (! value.isObject()) + return std::nullopt; + + JsonRpcError result; + result.code = static_cast (value["code"]); + result.message = value["message"].toString(); + + if (hasPresentProperty (value, "data")) + result.data = value["data"]; + + return result; +} + +var JsonRpcRequest::toVar() const +{ + auto object = makeMCPObject(); + setMCPProperty (object, "jsonrpc", jsonrpc); + setMCPProperty (object, "method", method); + + if (id.has_value()) + setMCPProperty (object, "id", *id); + + if (params.has_value()) + setMCPProperty (object, "params", *params); + + return object; +} + +std::optional JsonRpcRequest::fromVar (const var& value) +{ + if (! value.isObject()) + return std::nullopt; + + const auto version = value["jsonrpc"].toString(); + const auto method = value["method"].toString(); + if (version != "2.0" || method.isEmpty() || value.hasProperty ("result") || value.hasProperty ("error")) + return std::nullopt; + + JsonRpcRequest result; + result.jsonrpc = version; + result.method = method; + + if (hasPresentProperty (value, "id")) + result.id = value["id"]; + + if (hasPresentProperty (value, "params")) + result.params = value["params"]; + + return result; +} + +var JsonRpcResponse::toVar() const +{ + auto object = makeMCPObject(); + setMCPProperty (object, "jsonrpc", jsonrpc); + setMCPProperty (object, "id", id); + + if (error.has_value()) + setMCPProperty (object, "error", error->toVar()); + else + setMCPProperty (object, "result", result.value_or (var())); + + return object; +} + +std::optional JsonRpcResponse::fromVar (const var& value) +{ + if (! value.isObject()) + return std::nullopt; + + const auto version = value["jsonrpc"].toString(); + if (version != "2.0" || value.hasProperty ("method") || ! value.hasProperty ("id")) + return std::nullopt; + + JsonRpcResponse response; + response.jsonrpc = version; + response.id = value["id"]; + + if (hasPresentProperty (value, "error")) + { + auto parsedError = JsonRpcError::fromVar (value["error"]); + if (! parsedError.has_value()) + return std::nullopt; + + response.error = std::move (*parsedError); + } + else if (hasPresentProperty (value, "result")) + { + response.result = value["result"]; + } + else + { + return std::nullopt; + } + + return response; +} + +var MCPCapabilities::toVar() const +{ + auto object = makeMCPObject(); + + if (supportsTools) + setMCPProperty (object, "tools", makeMCPObject()); + + if (supportsResources) + setMCPProperty (object, "resources", makeMCPObject()); + + if (supportsPrompts) + setMCPProperty (object, "prompts", makeMCPObject()); + + if (supportsLogging) + setMCPProperty (object, "logging", makeMCPObject()); + + return object; +} + +MCPCapabilities MCPCapabilities::fromVar (const var& value) +{ + MCPCapabilities capabilities; + + if (! value.isObject()) + return capabilities; + + capabilities.supportsTools = hasPresentProperty (value, "tools"); + capabilities.supportsResources = hasPresentProperty (value, "resources"); + capabilities.supportsPrompts = hasPresentProperty (value, "prompts"); + capabilities.supportsLogging = hasPresentProperty (value, "logging"); + + return capabilities; +} + +var MCPToolDefinition::toVar() const +{ + auto object = makeMCPObject(); + setMCPProperty (object, "name", name); + setMCPProperty (object, "description", description); + setMCPProperty (object, "inputSchema", inputSchema); + return object; +} + +std::optional MCPToolDefinition::fromVar (const var& value) +{ + if (! value.isObject()) + return std::nullopt; + + MCPToolDefinition result; + result.name = value["name"].toString(); + result.description = value["description"].toString(); + result.inputSchema = value["inputSchema"]; + + if (result.name.isEmpty()) + return std::nullopt; + + return result; +} + +var MCPResourceDefinition::toVar() const +{ + auto object = makeMCPObject(); + setMCPProperty (object, "uri", uri); + setMCPProperty (object, "name", name); + setMCPProperty (object, "description", description); + setMCPProperty (object, "mimeType", mimeType); + return object; +} + +std::optional MCPResourceDefinition::fromVar (const var& value) +{ + if (! value.isObject()) + return std::nullopt; + + MCPResourceDefinition result; + result.uri = value["uri"].toString(); + result.name = value["name"].toString(); + result.description = value["description"].toString(); + result.mimeType = value["mimeType"].toString(); + + if (result.mimeType.isEmpty()) + result.mimeType = "application/json"; + + if (result.uri.isEmpty() || result.name.isEmpty()) + return std::nullopt; + + return result; +} + +} // namespace yup diff --git a/modules/yup_ai/mcp/yup_MCPTypes.h b/modules/yup_ai/mcp/yup_MCPTypes.h new file mode 100644 index 000000000..1c7af67f6 --- /dev/null +++ b/modules/yup_ai/mcp/yup_MCPTypes.h @@ -0,0 +1,159 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +namespace yup +{ + +//============================================================================== +/** JSON-RPC 2.0 and MCP error codes. + + @tags{AI} +*/ +namespace MCPErrorCodes +{ +constexpr int parseError = -32700; +constexpr int invalidRequest = -32600; +constexpr int methodNotFound = -32601; +constexpr int invalidParams = -32602; +constexpr int internalError = -32603; +} // namespace MCPErrorCodes + +//============================================================================== +/** JSON-RPC 2.0 error object. + + @tags{AI} +*/ +struct YUP_API JsonRpcError +{ + int code = MCPErrorCodes::internalError; + String message; + std::optional data; + + /** Serialises this error to `{ "code", "message", "data" }`. */ + var toVar() const; + + /** Parses a JSON-RPC error object. */ + static std::optional fromVar (const var& value); +}; + +//============================================================================== +/** JSON-RPC 2.0 request or notification envelope. + + Requests have an id. Notifications omit it. + + @tags{AI} +*/ +struct YUP_API JsonRpcRequest +{ + String jsonrpc = "2.0"; + std::optional id; + String method; + std::optional params; + + /** Returns true if this message omits an id and therefore expects no response. */ + bool isNotification() const noexcept { return ! id.has_value(); } + + /** Serialises this request to a JSON-compatible object. */ + var toVar() const; + + /** Parses a JSON-RPC request or notification. */ + static std::optional fromVar (const var& value); +}; + +//============================================================================== +/** JSON-RPC 2.0 response envelope. + + @tags{AI} +*/ +struct YUP_API JsonRpcResponse +{ + String jsonrpc = "2.0"; + var id; + std::optional result; + std::optional error; + + /** Returns true if this response contains an error object. */ + bool isError() const noexcept { return error.has_value(); } + + /** Serialises this response to a JSON-compatible object. */ + var toVar() const; + + /** Parses a JSON-RPC response. */ + static std::optional fromVar (const var& value); +}; + +//============================================================================== +/** MCP client or server capability flags. + + @tags{AI} +*/ +struct YUP_API MCPCapabilities +{ + bool supportsTools = false; + bool supportsResources = false; + bool supportsPrompts = false; + bool supportsLogging = false; + + /** Serialises this capability set to an MCP capabilities object. */ + var toVar() const; + + /** Parses an MCP capabilities object. */ + static MCPCapabilities fromVar (const var& value); +}; + +//============================================================================== +/** MCP tool definition returned by `tools/list`. + + @tags{AI} +*/ +struct YUP_API MCPToolDefinition +{ + String name; + String description; + var inputSchema; + + /** Serialises this tool definition to MCP's `tools/list` shape. */ + var toVar() const; + + /** Parses an MCP tool definition. */ + static std::optional fromVar (const var& value); +}; + +//============================================================================== +/** MCP resource definition returned by `resources/list`. + + @tags{AI} +*/ +struct YUP_API MCPResourceDefinition +{ + String uri; + String name; + String description; + String mimeType = "application/json"; + + /** Serialises this resource definition to MCP's `resources/list` shape. */ + var toVar() const; + + /** Parses an MCP resource definition. */ + static std::optional fromVar (const var& value); +}; + +} // namespace yup diff --git a/modules/yup_ai/yup_ai.cpp b/modules/yup_ai/yup_ai.cpp new file mode 100644 index 000000000..4c78b7081 --- /dev/null +++ b/modules/yup_ai/yup_ai.cpp @@ -0,0 +1,43 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#ifdef YUP_AI_H_INCLUDED +/* When you add this cpp file to your project, you mustn't include it in a file where you've + already included any other headers - just put it inside a file on its own, possibly with your config + flags preceding it, but don't include anything else. That also includes avoiding any automatic prefix + header files that the compiler may be using. +*/ +#error "Incorrect use of YUP cpp file" +#endif + +#include "yup_ai.h" + +//============================================================================== +#include "llm/yup_LLMMessage.cpp" +#include "llm/yup_LLMTool.cpp" +#include "llm/yup_LLMToolRegistry.cpp" +#include "llm/yup_LLMResponse.cpp" +#include "llm/yup_LLMClient.cpp" +#include "llm/yup_LLMHttpClient.cpp" +#include "embedding/yup_EmbeddingModel.cpp" +#include "mcp/yup_MCPTypes.cpp" +#include "mcp/yup_MCPClient.cpp" +#include "mcp/yup_MCPServer.cpp" diff --git a/modules/yup_ai/yup_ai.h b/modules/yup_ai/yup_ai.h new file mode 100644 index 000000000..01f9fdf80 --- /dev/null +++ b/modules/yup_ai/yup_ai.h @@ -0,0 +1,67 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +/* + ============================================================================== + + BEGIN_YUP_MODULE_DECLARATION + + ID: yup_ai + vendor: yup + version: 1.0.0 + name: YUP AI + description: LLM client and AI integration classes. + website: https://github.com/kunitoki/yup + license: ISC + + dependencies: yup_core yup_events + + END_YUP_MODULE_DECLARATION + + ============================================================================== +*/ + +#pragma once +#define YUP_AI_H_INCLUDED + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +//============================================================================== +#include "llm/yup_LLMMessage.h" +#include "llm/yup_LLMTool.h" +#include "llm/yup_LLMToolRegistry.h" +#include "llm/yup_LLMResponse.h" +#include "llm/yup_LLMClient.h" +#include "llm/yup_LLMHttpClient.h" +#include "embedding/yup_EmbeddingModel.h" +#include "mcp/yup_MCPTypes.h" +#include "mcp/yup_MCPTransport.h" +#include "mcp/yup_MCPClient.h" +#include "mcp/yup_MCPServer.h" diff --git a/modules/yup_python/bindings/yup_YupAi_bindings.cpp b/modules/yup_python/bindings/yup_YupAi_bindings.cpp new file mode 100644 index 000000000..34c130c68 --- /dev/null +++ b/modules/yup_python/bindings/yup_YupAi_bindings.cpp @@ -0,0 +1,510 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include "yup_YupAi_bindings.h" + +#define YUP_PYTHON_INCLUDE_PYBIND11_FUNCTIONAL +#define YUP_PYTHON_INCLUDE_PYBIND11_STL +#include "../utilities/yup_PyBind11Includes.h" + +namespace yup::Bindings +{ + +namespace py = pybind11; +using namespace py::literals; + +namespace +{ +class PyLLMClient : public LLMClient +{ +public: + using LLMClient::LLMClient; + + LLMResponse complete (const Request& request) override + { + PYBIND11_OVERRIDE_PURE (LLMResponse, LLMClient, complete, request); + } + + bool completeStreaming (const Request& request, ChunkCallback onChunk) override + { + PYBIND11_OVERRIDE_PURE (bool, LLMClient, completeStreaming, request, onChunk); + } +}; + +class PyMCPTransport : public MCPTransport +{ +public: + Result sendMessage (const var& message) override + { + py::gil_scoped_acquire gil; + auto override = py::get_override (this, "sendMessage"); + + if (! override) + py::pybind11_fail ("Tried to call pure virtual function \"MCPTransport.sendMessage\""); + + auto result = override (message); + + if (py::isinstance (result)) + return result.cast(); + + if (result.is_none() || result.cast()) + return Result::ok(); + + return Result::fail ("Python MCPTransport.sendMessage returned false"); + } + + ResultValue receiveMessage (int timeoutMs = -1) override + { + py::gil_scoped_acquire gil; + auto override = py::get_override (this, "receiveMessage"); + + if (! override) + py::pybind11_fail ("Tried to call pure virtual function \"MCPTransport.receiveMessage\""); + + auto result = override (timeoutMs); + if (result.is_none()) + return makeResultValueFail ("Python MCPTransport.receiveMessage returned None"); + + return makeResultValueOk (result.cast()); + } + + void setMessageHandler (MessageHandler handler) override + { + py::gil_scoped_acquire gil; + auto override = py::get_override (this, "setMessageHandler"); + + if (! override) + py::pybind11_fail ("Tried to call pure virtual function \"MCPTransport.setMessageHandler\""); + + override (std::move (handler)); + } + + Result start() override + { + py::gil_scoped_acquire gil; + auto override = py::get_override (this, "start"); + + if (! override) + py::pybind11_fail ("Tried to call pure virtual function \"MCPTransport.start\""); + + auto result = override(); + + if (py::isinstance (result)) + return result.cast(); + + if (result.is_none() || result.cast()) + return Result::ok(); + + return Result::fail ("Python MCPTransport.start returned false"); + } + + void stop() override + { + py::gil_scoped_acquire gil; + auto override = py::get_override (this, "stop"); + + if (! override) + py::pybind11_fail ("Tried to call pure virtual function \"MCPTransport.stop\""); + + override(); + } + + bool isConnected() const noexcept override + { + py::gil_scoped_acquire gil; + auto override = py::get_override (this, "isConnected"); + + if (! override) + return false; + + try + { + return override().cast(); + } + catch (...) + { + return false; + } + } +}; + +String messageRepr (const LLMMessage& message) +{ + return "LLMMessage(role='" + LLMMessage::roleToString (message.role) + "', content='" + message.content + "')"; +} + +py::object optionalJsonRpcRequestToPython (const std::optional& value) +{ + return value.has_value() ? py::cast (*value) : py::none(); +} + +py::object optionalJsonRpcResponseToPython (const std::optional& value) +{ + return value.has_value() ? py::cast (*value) : py::none(); +} + +py::object optionalJsonRpcErrorToPython (const std::optional& value) +{ + return value.has_value() ? py::cast (*value) : py::none(); +} + +py::object optionalMCPToolDefinitionToPython (const std::optional& value) +{ + return value.has_value() ? py::cast (*value) : py::none(); +} + +py::object optionalMCPResourceDefinitionToPython (const std::optional& value) +{ + return value.has_value() ? py::cast (*value) : py::none(); +} +} // namespace + +void registerYupAiBindings (py::module_& m) +{ + auto ai = m.def_submodule ("ai"); + + ai.attr ("MCP_PARSE_ERROR") = MCPErrorCodes::parseError; + ai.attr ("MCP_INVALID_REQUEST") = MCPErrorCodes::invalidRequest; + ai.attr ("MCP_METHOD_NOT_FOUND") = MCPErrorCodes::methodNotFound; + ai.attr ("MCP_INVALID_PARAMS") = MCPErrorCodes::invalidParams; + ai.attr ("MCP_INTERNAL_ERROR") = MCPErrorCodes::internalError; + + py::enum_ (ai, "LLMMessageRole") + .value ("system", LLMMessage::Role::system) + .value ("user", LLMMessage::Role::user) + .value ("assistant", LLMMessage::Role::assistant) + .value ("tool", LLMMessage::Role::tool) + .export_values(); + + py::class_ (ai, "LLMToolCall") + .def (py::init<>()) + .def_readwrite ("index", &LLMToolCall::index) + .def_readwrite ("id", &LLMToolCall::id) + .def_readwrite ("name", &LLMToolCall::name) + .def_readwrite ("arguments", &LLMToolCall::arguments) + .def ("toVar", &LLMToolCall::toVar) + .def_static ("fromVar", [] (const var& value) -> py::object + { + if (auto result = LLMToolCall::fromVar (value)) + return py::cast (*result); + + return py::none(); + }); + + py::class_ (ai, "LLMMessage") + .def (py::init<>()) + .def_readwrite ("role", &LLMMessage::role) + .def_readwrite ("content", &LLMMessage::content) + .def_readwrite ("name", &LLMMessage::name) + .def_readwrite ("toolCalls", &LLMMessage::toolCalls) + .def_readwrite ("toolCallId", &LLMMessage::toolCallId) + .def_static ("system", &LLMMessage::system) + .def_static ("user", &LLMMessage::user) + .def_static ("assistant", &LLMMessage::assistant) + .def_static ("toolResult", &LLMMessage::toolResult) + .def ("toVar", &LLMMessage::toVar) + .def_static ("fromVar", [] (const var& value) -> py::object + { + if (auto result = LLMMessage::fromVar (value)) + return py::cast (*result); + + return py::none(); + }).def ("__repr__", [] (const LLMMessage& message) + { + return messageRepr (message); + }); + + py::class_ (ai, "LLMToolParameter") + .def (py::init<>()) + .def_readwrite ("name", &LLMTool::Parameter::name) + .def_readwrite ("type", &LLMTool::Parameter::type) + .def_readwrite ("description", &LLMTool::Parameter::description) + .def_readwrite ("required", &LLMTool::Parameter::required) + .def_readwrite ("enumValues", &LLMTool::Parameter::enumValues) + .def_readwrite ("defaultValue", &LLMTool::Parameter::defaultValue) + .def_readwrite ("properties", &LLMTool::Parameter::properties); + + py::class_ (ai, "LLMTool") + .def (py::init<>()) + .def_readwrite ("name", &LLMTool::name) + .def_readwrite ("description", &LLMTool::description) + .def_readwrite ("parameters", &LLMTool::parameters) + .def ("toJsonSchema", &LLMTool::toJsonSchema) + .def ("execute", &LLMTool::execute) + .def ("setHandler", [] (LLMTool& self, py::function function) + { + self.setHandler ([function = std::move (function)] (const var& arguments) -> var + { + py::gil_scoped_acquire gil; + return function (arguments).cast(); + }); + }); + + py::class_ (ai, "LLMToolRegistry") + .def (py::init<>()) + .def ("registerTool", &LLMToolRegistry::registerTool) + .def ("unregisterTool", &LLMToolRegistry::unregisterTool) + .def ("contains", &LLMToolRegistry::contains) + .def ("getAllTools", &LLMToolRegistry::getAllTools) + .def ("toToolsArray", &LLMToolRegistry::toToolsArray) + .def ("dispatchToolCall", &LLMToolRegistry::dispatchToolCall) + .def ("register", [] (LLMToolRegistry& self, const String& name, const String& description, py::function function) + { + LLMTool tool; + tool.name = name; + tool.description = description; + tool.setHandler ([function = std::move (function)] (const var& arguments) -> var + { + py::gil_scoped_acquire gil; + return function (arguments).cast(); + }); + + self.registerTool (std::move (tool)); + }); + + py::class_ (ai, "LLMResponseChoice") + .def (py::init<>()) + .def_readwrite ("index", &LLMResponse::Choice::index) + .def_readwrite ("message", &LLMResponse::Choice::message) + .def_readwrite ("finishReason", &LLMResponse::Choice::finishReason); + + py::class_ (ai, "LLMResponseUsage") + .def (py::init<>()) + .def_readwrite ("promptTokens", &LLMResponse::Usage::promptTokens) + .def_readwrite ("completionTokens", &LLMResponse::Usage::completionTokens) + .def_readwrite ("totalTokens", &LLMResponse::Usage::totalTokens); + + py::class_ (ai, "LLMResponse") + .def (py::init<>()) + .def_readwrite ("choices", &LLMResponse::choices) + .def_readwrite ("usage", &LLMResponse::usage) + .def_readwrite ("model", &LLMResponse::model) + .def_readwrite ("errorMessage", &LLMResponse::errorMessage) + .def ("hasToolCalls", &LLMResponse::hasToolCalls) + .def ("failed", &LLMResponse::failed) + .def ("getToolCalls", &LLMResponse::getToolCalls) + .def_static ("fromError", &LLMResponse::fromError) + .def_static ("fromOpenAiJson", &LLMResponse::fromOpenAiJson) + .def_static ("fromStreamChunk", &LLMResponse::fromStreamChunk); + + py::class_ (ai, "LLMRequest") + .def (py::init<>()) + .def_readwrite ("messages", &LLMClient::Request::messages) + .def_readwrite ("systemPrompt", &LLMClient::Request::systemPrompt) + .def_readwrite ("tools", &LLMClient::Request::tools) + .def_readwrite ("toolChoice", &LLMClient::Request::toolChoice) + .def_readwrite ("temperature", &LLMClient::Request::temperature) + .def_readwrite ("topP", &LLMClient::Request::topP) + .def_readwrite ("maxTokens", &LLMClient::Request::maxTokens) + .def_readwrite ("stopSequences", &LLMClient::Request::stopSequences); + + py::class_ (ai, "LLMOptions") + .def (py::init<>()) + .def_readwrite ("model", &LLMClient::Options::model) + .def_readwrite ("baseUrl", &LLMClient::Options::baseUrl) + .def_readwrite ("apiKey", &LLMClient::Options::apiKey) + .def_readwrite ("timeoutMs", &LLMClient::Options::timeoutMs) + .def_readwrite ("maxRetries", &LLMClient::Options::maxRetries); + + py::class_ (ai, "LLMClient") + .def (py::init()) + .def ("complete", &LLMClient::complete) + .def ("completeStreaming", &LLMClient::completeStreaming) + .def ("chat", &LLMClient::chat) + .def ("chatWithTools", &LLMClient::chatWithTools) + .def ("runToolLoop", &LLMClient::runToolLoop) + .def ("getOptions", &LLMClient::getOptions, py::return_value_policy::reference_internal); + + py::class_ (ai, "LLMHttpClient") + .def (py::init()); + + py::class_ (ai, "EmbeddingOptions") + .def (py::init<>()) + .def_readwrite ("model", &EmbeddingModel::Options::model) + .def_readwrite ("baseUrl", &EmbeddingModel::Options::baseUrl) + .def_readwrite ("apiKey", &EmbeddingModel::Options::apiKey) + .def_readwrite ("timeoutMs", &EmbeddingModel::Options::timeoutMs); + + py::class_ (ai, "Embedding") + .def (py::init<>()) + .def_readwrite ("values", &EmbeddingModel::Embedding::values) + .def_readwrite ("index", &EmbeddingModel::Embedding::index) + .def ("dimensions", &EmbeddingModel::Embedding::dimensions); + + py::class_ (ai, "EmbeddingModel") + .def (py::init()) + .def ("embed", &EmbeddingModel::embed) + .def ("embedBatch", &EmbeddingModel::embedBatch) + .def_static ("cosineSimilarity", &EmbeddingModel::cosineSimilarity); + + py::class_ (ai, "JsonRpcError") + .def (py::init<>()) + .def_readwrite ("code", &JsonRpcError::code) + .def_readwrite ("message", &JsonRpcError::message) + .def_readwrite ("data", &JsonRpcError::data) + .def ("toVar", &JsonRpcError::toVar) + .def_static ("fromVar", [] (const var& value) + { + return optionalJsonRpcErrorToPython (JsonRpcError::fromVar (value)); + }); + + py::class_ (ai, "JsonRpcRequest") + .def (py::init<>()) + .def_readwrite ("jsonrpc", &JsonRpcRequest::jsonrpc) + .def_readwrite ("id", &JsonRpcRequest::id) + .def_readwrite ("method", &JsonRpcRequest::method) + .def_readwrite ("params", &JsonRpcRequest::params) + .def ("isNotification", &JsonRpcRequest::isNotification) + .def ("toVar", &JsonRpcRequest::toVar) + .def_static ("fromVar", [] (const var& value) + { + return optionalJsonRpcRequestToPython (JsonRpcRequest::fromVar (value)); + }); + + py::class_ (ai, "JsonRpcResponse") + .def (py::init<>()) + .def_readwrite ("jsonrpc", &JsonRpcResponse::jsonrpc) + .def_readwrite ("id", &JsonRpcResponse::id) + .def_readwrite ("result", &JsonRpcResponse::result) + .def_readwrite ("error", &JsonRpcResponse::error) + .def ("isError", &JsonRpcResponse::isError) + .def ("toVar", &JsonRpcResponse::toVar) + .def_static ("fromVar", [] (const var& value) + { + return optionalJsonRpcResponseToPython (JsonRpcResponse::fromVar (value)); + }); + + py::class_ (ai, "MCPCapabilities") + .def (py::init<>()) + .def_readwrite ("supportsTools", &MCPCapabilities::supportsTools) + .def_readwrite ("supportsResources", &MCPCapabilities::supportsResources) + .def_readwrite ("supportsPrompts", &MCPCapabilities::supportsPrompts) + .def_readwrite ("supportsLogging", &MCPCapabilities::supportsLogging) + .def ("toVar", &MCPCapabilities::toVar) + .def_static ("fromVar", &MCPCapabilities::fromVar); + + py::class_ (ai, "MCPToolDefinition") + .def (py::init<>()) + .def_readwrite ("name", &MCPToolDefinition::name) + .def_readwrite ("description", &MCPToolDefinition::description) + .def_readwrite ("inputSchema", &MCPToolDefinition::inputSchema) + .def ("toVar", &MCPToolDefinition::toVar) + .def_static ("fromVar", [] (const var& value) + { + return optionalMCPToolDefinitionToPython (MCPToolDefinition::fromVar (value)); + }); + + py::class_ (ai, "MCPResourceDefinition") + .def (py::init<>()) + .def_readwrite ("uri", &MCPResourceDefinition::uri) + .def_readwrite ("name", &MCPResourceDefinition::name) + .def_readwrite ("description", &MCPResourceDefinition::description) + .def_readwrite ("mimeType", &MCPResourceDefinition::mimeType) + .def ("toVar", &MCPResourceDefinition::toVar) + .def_static ("fromVar", [] (const var& value) + { + return optionalMCPResourceDefinitionToPython (MCPResourceDefinition::fromVar (value)); + }); + + py::class_ (ai, "MCPTransport") + .def (py::init<>()) + .def ("sendMessage", &MCPTransport::sendMessage) + .def ("receiveMessage", [] (MCPTransport& self, int timeoutMs) + { + auto result = self.receiveMessage (timeoutMs); + if (result.failed()) + py::pybind11_fail (result.getErrorMessage().toRawUTF8()); + + return result.getValue(); + }, + "timeoutMs"_a = -1) + .def ("setMessageHandler", &MCPTransport::setMessageHandler) + .def ("start", &MCPTransport::start) + .def ("stop", &MCPTransport::stop) + .def ("isConnected", &MCPTransport::isConnected); + + py::class_ (ai, "MCPClient") + .def (py::init>(), "transport"_a) + .def ("initialize", &MCPClient::initialize, "clientCapabilities"_a = MCPCapabilities {}) + .def ("listTools", &MCPClient::listTools) + .def ("callTool", [] (MCPClient& self, const String& toolName, const var& arguments) + { + auto result = self.callTool (toolName, arguments); + if (result.failed()) + py::pybind11_fail (result.getErrorMessage().toRawUTF8()); + + return result.getValue(); + }, + "toolName"_a, + "arguments"_a) + .def ("listResources", &MCPClient::listResources) + .def ("readResource", [] (MCPClient& self, const String& uri) + { + auto result = self.readResource (uri); + if (result.failed()) + py::pybind11_fail (result.getErrorMessage().toRawUTF8()); + + return result.getValue(); + }, + "uri"_a) + .def ("registerToolsWith", &MCPClient::registerToolsWith) + .def ("getTransport", &MCPClient::getTransport, py::return_value_policy::reference_internal); + + py::class_ (ai, "MCPServerOptions") + .def (py::init<>()) + .def_readwrite ("serverName", &MCPServer::Options::serverName) + .def_readwrite ("serverVersion", &MCPServer::Options::serverVersion) + .def_readwrite ("capabilities", &MCPServer::Options::capabilities); + + py::class_ (ai, "MCPServer") + .def (py::init<>()) + .def (py::init()) + .def ("registerTool", [] (MCPServer& self, MCPToolDefinition tool, py::function function) + { + self.registerTool (std::move (tool), [function = std::move (function)] (const var& arguments) -> var + { + py::gil_scoped_acquire gil; + return function (arguments).cast(); + }); + }, + "tool"_a, + "function"_a) + .def ("registerLLMTool", static_cast (&MCPServer::registerTool), "tool"_a) + .def ("unregisterTool", &MCPServer::unregisterTool) + .def ("registerResource", [] (MCPServer& self, MCPResourceDefinition resource, py::function function) + { + self.registerResource (std::move (resource), [function = std::move (function)]() -> String + { + py::gil_scoped_acquire gil; + return py::str (function()).cast(); + }); + }, + "resource"_a, + "function"_a) + .def ("unregisterResource", &MCPServer::unregisterResource) + .def ("start", &MCPServer::start, "transport"_a) + .def ("stop", &MCPServer::stop) + .def ("isRunning", &MCPServer::isRunning) + .def ("startStdio", &MCPServer::startStdio) + .def ("startHttp", &MCPServer::startHttp, "port"_a); +} + +} // namespace yup::Bindings diff --git a/modules/yup_python/bindings/yup_YupAi_bindings.h b/modules/yup_python/bindings/yup_YupAi_bindings.h new file mode 100644 index 000000000..f8e811897 --- /dev/null +++ b/modules/yup_python/bindings/yup_YupAi_bindings.h @@ -0,0 +1,37 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#pragma once + +#if ! YUP_MODULE_AVAILABLE_yup_ai +#error This binding file requires adding the yup_ai module in the project +#else +#include +#endif + +#include "yup_YupCore_bindings.h" + +namespace yup::Bindings +{ + +void registerYupAiBindings (pybind11::module_& m); + +} // namespace yup::Bindings diff --git a/modules/yup_python/modules/yup_YupMain_module.cpp b/modules/yup_python/modules/yup_YupMain_module.cpp index 65e6749b7..528b0ffeb 100644 --- a/modules/yup_python/modules/yup_YupMain_module.cpp +++ b/modules/yup_python/modules/yup_YupMain_module.cpp @@ -40,6 +40,10 @@ #include "../bindings/yup_YupGui_bindings.h" #endif +#if YUP_MODULE_AVAILABLE_yup_ai +#include "../bindings/yup_YupAi_bindings.h" +#endif + #if YUP_MODULE_AVAILABLE_yup_audio_basics #include "../bindings/yup_YupAudioBasics_bindings.h" #endif @@ -92,6 +96,10 @@ PYBIND11_MODULE (YUP_PYTHON_MODULE_NAME, m) yup::Bindings::registerYupGuiBindings (m); #endif +#if YUP_MODULE_AVAILABLE_yup_ai + yup::Bindings::registerYupAiBindings (m); +#endif + #if YUP_MODULE_AVAILABLE_yup_audio_basics yup::Bindings::registerYupAudioBasicsBindings (m); #endif diff --git a/modules/yup_python/yup_python_ai.cpp b/modules/yup_python/yup_python_ai.cpp new file mode 100644 index 000000000..4cc6021f8 --- /dev/null +++ b/modules/yup_python/yup_python_ai.cpp @@ -0,0 +1,24 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#if YUP_MODULE_AVAILABLE_yup_ai +#include "bindings/yup_YupAi_bindings.cpp" +#endif diff --git a/modules/yup_python/yup_python_audio_basics.cpp b/modules/yup_python/yup_python_audio_basics.cpp index c0e53394d..facfdc336 100644 --- a/modules/yup_python/yup_python_audio_basics.cpp +++ b/modules/yup_python/yup_python_audio_basics.cpp @@ -19,4 +19,6 @@ ============================================================================== */ +#if YUP_MODULE_AVAILABLE_yup_audio_basics #include "bindings/yup_YupAudioBasics_bindings.cpp" +#endif diff --git a/modules/yup_python/yup_python_data_model.cpp b/modules/yup_python/yup_python_data_model.cpp index 0e59f13ab..fb3d4ff34 100644 --- a/modules/yup_python/yup_python_data_model.cpp +++ b/modules/yup_python/yup_python_data_model.cpp @@ -19,4 +19,6 @@ ============================================================================== */ +#if YUP_MODULE_AVAILABLE_yup_data_model #include "bindings/yup_YupDataModel_bindings.cpp" +#endif diff --git a/modules/yup_python/yup_python_events.cpp b/modules/yup_python/yup_python_events.cpp index d1e8688a6..e65bf9e24 100644 --- a/modules/yup_python/yup_python_events.cpp +++ b/modules/yup_python/yup_python_events.cpp @@ -19,4 +19,6 @@ ============================================================================== */ +#if YUP_MODULE_AVAILABLE_yup_events #include "bindings/yup_YupEvents_bindings.cpp" +#endif diff --git a/modules/yup_python/yup_python_graphics.cpp b/modules/yup_python/yup_python_graphics.cpp index edfc1957f..b4a914f74 100644 --- a/modules/yup_python/yup_python_graphics.cpp +++ b/modules/yup_python/yup_python_graphics.cpp @@ -19,4 +19,6 @@ ============================================================================== */ +#if YUP_MODULE_AVAILABLE_yup_graphics #include "bindings/yup_YupGraphics_bindings.cpp" +#endif diff --git a/modules/yup_python/yup_python_gui.cpp b/modules/yup_python/yup_python_gui.cpp index a13553aee..577569d05 100644 --- a/modules/yup_python/yup_python_gui.cpp +++ b/modules/yup_python/yup_python_gui.cpp @@ -19,4 +19,6 @@ ============================================================================== */ +#if YUP_MODULE_AVAILABLE_yup_gui #include "bindings/yup_YupGui_bindings.cpp" +#endif diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt index 24c92d6c2..c3a27b73b 100644 --- a/python/CMakeLists.txt +++ b/python/CMakeLists.txt @@ -58,7 +58,8 @@ yup_standalone_app ( yup::yup_events yup::yup_graphics yup::yup_gui - yup::yup_python) + yup::yup_python + yup::yup_ai) set_target_properties (${target_name} PROPERTIES CXX_EXTENSIONS OFF diff --git a/python/tests/test_yup_ai/__init__.py b/python/tests/test_yup_ai/__init__.py new file mode 100644 index 000000000..bb21a990a --- /dev/null +++ b/python/tests/test_yup_ai/__init__.py @@ -0,0 +1,2 @@ +import yup + diff --git a/python/tests/test_yup_ai/test_MCPServer.py b/python/tests/test_yup_ai/test_MCPServer.py new file mode 100644 index 000000000..a7ec03a51 --- /dev/null +++ b/python/tests/test_yup_ai/test_MCPServer.py @@ -0,0 +1,41 @@ +import yup + +#================================================================================================== + +def test_server_options_are_exposed(): + options = yup.ai.MCPServerOptions() + options.serverName = "Python MCP Server" + options.serverVersion = "2.1" + options.capabilities.supportsTools = True + + server = yup.ai.MCPServer(options) + + assert not server.isRunning() + +#================================================================================================== + +def test_server_accepts_python_tool_and_resource_callbacks(): + server = yup.ai.MCPServer() + + tool = yup.ai.MCPToolDefinition() + tool.name = "echo" + tool.description = "Echoes text." + tool.inputSchema = { + "type": "object", + "properties": { + "value": { "type": "string" }, + }, + "required": [ "value" ], + } + + server.registerTool(tool, lambda arguments: { "echoed": arguments["value"] }) + + resource = yup.ai.MCPResourceDefinition() + resource.uri = "yup://python/status" + resource.name = "Status" + resource.description = "Python status." + + server.registerResource(resource, lambda: '{"ok":true}') + + assert not server.isRunning() + diff --git a/python/tests/test_yup_ai/test_MCPTransport.py b/python/tests/test_yup_ai/test_MCPTransport.py new file mode 100644 index 000000000..3b245dfa2 --- /dev/null +++ b/python/tests/test_yup_ai/test_MCPTransport.py @@ -0,0 +1,69 @@ +import yup + +#================================================================================================== + +class QueueTransport(yup.ai.MCPTransport): + def __init__(self): + super().__init__() + self.connected = False + self.sent_messages = [] + self.queued_messages = [] + self.handler = None + + def sendMessage(self, message): + self.sent_messages.append(message) + return True + + def receiveMessage(self, timeoutMs=-1): + if not self.queued_messages: + return None + + return self.queued_messages.pop(0) + + def setMessageHandler(self, handler): + self.handler = handler + + def start(self): + self.connected = True + return True + + def stop(self): + self.connected = False + + def isConnected(self): + return self.connected + +#================================================================================================== + +def test_python_transport_subclass_sends_and_receives_messages(): + transport = QueueTransport() + + assert transport.start() + assert transport.isConnected() + + assert transport.sendMessage({ "jsonrpc": "2.0", "method": "ping" }) + assert transport.sent_messages[0]["method"] == "ping" + + transport.queued_messages.append({ + "jsonrpc": "2.0", + "id": 1, + "result": { + "ok": True, + }, + }) + + assert transport.receiveMessage()["result"]["ok"] + + transport.stop() + assert not transport.isConnected() + +#================================================================================================== + +def test_python_transport_message_handler_can_be_called(): + transport = QueueTransport() + received = [] + + transport.setMessageHandler(lambda message: received.append(message)) + transport.handler({ "jsonrpc": "2.0", "method": "notifications/test" }) + + assert received[0]["method"] == "notifications/test" diff --git a/python/tests/test_yup_ai/test_MCPTypes.py b/python/tests/test_yup_ai/test_MCPTypes.py new file mode 100644 index 000000000..ddbb35be8 --- /dev/null +++ b/python/tests/test_yup_ai/test_MCPTypes.py @@ -0,0 +1,111 @@ +import yup + +#================================================================================================== + +def test_json_rpc_request_round_trip(): + request = yup.ai.JsonRpcRequest() + request.id = 42 + request.method = "tools/call" + request.params = { + "name": "echo", + "arguments": { + "value": "hello", + }, + } + + parsed = yup.ai.JsonRpcRequest.fromVar(request.toVar()) + + assert parsed is not None + assert not parsed.isNotification() + assert parsed.jsonrpc == "2.0" + assert parsed.id == 42 + assert parsed.method == "tools/call" + assert parsed.params["name"] == "echo" + assert parsed.params["arguments"]["value"] == "hello" + +#================================================================================================== + +def test_json_rpc_notification_has_no_id(): + parsed = yup.ai.JsonRpcRequest.fromVar({ + "jsonrpc": "2.0", + "method": "notifications/initialized", + }) + + assert parsed is not None + assert parsed.isNotification() + assert parsed.method == "notifications/initialized" + +#================================================================================================== + +def test_json_rpc_error_response_round_trip(): + error = yup.ai.JsonRpcError() + error.code = yup.ai.MCP_METHOD_NOT_FOUND + error.message = "Missing method" + error.data = { "method": "missing" } + + response = yup.ai.JsonRpcResponse() + response.id = "abc" + response.error = error + + parsed = yup.ai.JsonRpcResponse.fromVar(response.toVar()) + + assert parsed is not None + assert parsed.isError() + assert parsed.id == "abc" + assert parsed.error.code == yup.ai.MCP_METHOD_NOT_FOUND + assert parsed.error.message == "Missing method" + assert parsed.error.data["method"] == "missing" + +#================================================================================================== + +def test_capabilities_round_trip(): + capabilities = yup.ai.MCPCapabilities() + capabilities.supportsTools = True + capabilities.supportsResources = True + + parsed = yup.ai.MCPCapabilities.fromVar(capabilities.toVar()) + + assert parsed.supportsTools + assert parsed.supportsResources + assert not parsed.supportsPrompts + assert not parsed.supportsLogging + +#================================================================================================== + +def test_tool_definition_round_trip(): + tool = yup.ai.MCPToolDefinition() + tool.name = "set_gain" + tool.description = "Sets gain." + tool.inputSchema = { + "type": "object", + "properties": { + "gainDb": { + "type": "number", + "description": "Gain in decibels.", + }, + }, + "required": [ "gainDb" ], + } + + parsed = yup.ai.MCPToolDefinition.fromVar(tool.toVar()) + + assert parsed is not None + assert parsed.name == "set_gain" + assert parsed.inputSchema["properties"]["gainDb"]["type"] == "number" + assert parsed.inputSchema["required"][0] == "gainDb" + +#================================================================================================== + +def test_resource_definition_round_trip_defaults_mime_type(): + resource = yup.ai.MCPResourceDefinition() + resource.uri = "yup://test/status" + resource.name = "Status" + resource.description = "Current status." + + parsed = yup.ai.MCPResourceDefinition.fromVar(resource.toVar()) + + assert parsed is not None + assert parsed.uri == "yup://test/status" + assert parsed.name == "Status" + assert parsed.mimeType == "application/json" + diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 2faad6c3b..68abe4c9c 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -60,6 +60,7 @@ set (target_modules yup_audio_formats yup_audio_processors yup_audio_graph + yup_ai yup_dsp yup_events yup_data_model diff --git a/tests/data/ai/.gitkeep b/tests/data/ai/.gitkeep new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/tests/data/ai/.gitkeep @@ -0,0 +1 @@ + diff --git a/tests/yup_ai.cpp b/tests/yup_ai.cpp new file mode 100644 index 000000000..2eccd7c84 --- /dev/null +++ b/tests/yup_ai.cpp @@ -0,0 +1,23 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include "yup_ai/yup_LLMTypes.cpp" +#include "yup_ai/yup_MCPTypes.cpp" diff --git a/tests/yup_ai/yup_LLMTypes.cpp b/tests/yup_ai/yup_LLMTypes.cpp new file mode 100644 index 000000000..cb56bae8b --- /dev/null +++ b/tests/yup_ai/yup_LLMTypes.cpp @@ -0,0 +1,374 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +#include + +using namespace yup; + +namespace +{ +class TestLLMClient final : public LLMClient +{ +public: + TestLLMClient() + : LLMClient ({}) + { + } + + LLMResponse complete (const Request&) override + { + LLMResponse response; + LLMResponse::Choice choice; + choice.message = LLMMessage::assistant ("done"); + response.choices.push_back (choice); + return response; + } + + bool completeStreaming (const Request&, ChunkCallback) override + { + return false; + } + + String buildBody (const Request& request, bool stream) const + { + return buildChatCompletionBody (request, stream); + } +}; +} // namespace + +TEST (YupAiLLMMessage, SerializesAndParsesChatMessage) +{ + auto message = LLMMessage::user ("hello"); + message.name = "tester"; + + auto parsed = LLMMessage::fromVar (message.toVar()); + + ASSERT_TRUE (parsed.has_value()); + EXPECT_EQ (LLMMessage::Role::user, parsed->role); + EXPECT_EQ ("hello", parsed->content); + EXPECT_EQ ("tester", parsed->name); +} + +TEST (YupAiLLMMessage, SerializesToolCalls) +{ + LLMToolCall toolCall; + toolCall.id = "call_1"; + toolCall.name = "lookup"; + toolCall.arguments = JSON::parse (R"({"query":"abc"})"); + + auto message = LLMMessage::assistant (""); + message.toolCalls = std::vector { toolCall }; + + auto parsed = LLMMessage::fromVar (message.toVar()); + + ASSERT_TRUE (parsed.has_value()); + ASSERT_TRUE (parsed->toolCalls.has_value()); + ASSERT_EQ (1u, parsed->toolCalls->size()); + EXPECT_EQ ("lookup", parsed->toolCalls->front().name); + EXPECT_EQ ("abc", parsed->toolCalls->front().arguments["query"].toString()); +} + +TEST (YupAiLLMTool, GeneratesOpenAiSchema) +{ + LLMTool tool; + tool.name = "weather"; + tool.description = "Gets weather"; + tool.parameters.push_back ({ "city", "string", "City name", true }); + tool.parameters.push_back ({ "units", "string", "Units", false, JSON::parse (R"(["metric","imperial"])") }); + + auto schema = tool.toJsonSchema(); + + EXPECT_EQ ("function", schema["type"].toString()); + EXPECT_EQ ("weather", schema["function"]["name"].toString()); + EXPECT_EQ ("string", schema["function"]["parameters"]["properties"]["city"]["type"].toString()); + ASSERT_TRUE (schema["function"]["parameters"]["required"].isArray()); + EXPECT_EQ ("city", schema["function"]["parameters"]["required"][0].toString()); +} + +TEST (YupAiLLMTool, DispatchesHandlerAndReportsMissingHandler) +{ + LLMTool tool; + tool.name = "echo"; + tool.setHandler ([] (const var& arguments) + { + return arguments["value"]; + }); + + EXPECT_EQ ("ok", tool.execute (JSON::parse (R"({"value":"ok"})")).toString()); + + LLMTool missing; + missing.name = "missing"; + EXPECT_TRUE (static_cast (missing.execute (var())["error"])); +} + +TEST (YupAiLLMToolRegistry, RegistersFindsAndDispatchesTools) +{ + LLMToolRegistry registry; + + LLMTool tool; + tool.name = "double"; + tool.description = "Doubles a number"; + tool.setHandler ([] (const var& arguments) + { + return static_cast (arguments["value"]) * 2; + }); + + registry.registerTool (std::move (tool)); + + EXPECT_TRUE (registry.contains ("double")); + ASSERT_NE (nullptr, registry.findTool ("double")); + EXPECT_EQ (42, static_cast (registry.dispatchToolCall ("double", JSON::parse (R"({"value":21})")))); + EXPECT_TRUE (registry.toToolsArray().isArray()); +} + +TEST (YupAiLLMToolRegistry, HandlesConcurrentRegistration) +{ + LLMToolRegistry registry; + + auto registerRange = [®istry] (int start) + { + for (int i = 0; i < 16; ++i) + { + LLMTool tool; + tool.name = "tool_" + String (start + i); + registry.registerTool (std::move (tool)); + } + }; + + std::thread first (registerRange, 0); + std::thread second (registerRange, 100); + first.join(); + second.join(); + + EXPECT_EQ (32u, registry.getAllTools().size()); +} + +TEST (YupAiLLMResponse, ParsesOpenAiResponse) +{ + auto json = JSON::parse (R"({ + "model": "test-model", + "choices": [ + { + "index": 0, + "message": { "role": "assistant", "content": "hello" }, + "finish_reason": "stop" + } + ], + "usage": { "prompt_tokens": 2, "completion_tokens": 3, "total_tokens": 5 } + })"); + + auto response = LLMResponse::fromOpenAiJson (json); + + ASSERT_EQ (1u, response.choices.size()); + EXPECT_EQ ("test-model", response.model); + EXPECT_EQ ("hello", response.choices.front().message.content); + ASSERT_TRUE (response.usage.has_value()); + EXPECT_EQ (5, response.usage->totalTokens); +} + +TEST (YupAiLLMResponse, ExtractsToolCalls) +{ + auto json = JSON::parse (R"({ + "model": "test-model", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "set_background_color", + "arguments": "{\"color\":\"darkgreen\"}" + } + } + ] + }, + "finish_reason": "tool_calls" + } + ] + })"); + + auto response = LLMResponse::fromOpenAiJson (json); + + EXPECT_TRUE (response.hasToolCalls()); + + auto toolCalls = response.getToolCalls(); + ASSERT_EQ (1u, toolCalls.size()); + EXPECT_EQ ("call_1", toolCalls.front().id); + EXPECT_EQ ("set_background_color", toolCalls.front().name); + EXPECT_EQ ("darkgreen", toolCalls.front().arguments["color"].toString()); +} + +TEST (YupAiLLMResponse, ParsesOpenAiError) +{ + auto json = JSON::parse (R"({ + "error": { + "message": "model not found", + "type": "invalid_request_error" + } + })"); + + auto response = LLMResponse::fromOpenAiJson (json); + + EXPECT_TRUE (response.failed()); + ASSERT_TRUE (response.errorMessage.has_value()); + EXPECT_EQ ("model not found", *response.errorMessage); +} + +TEST (YupAiLLMResponse, ParsesStreamingChunk) +{ + auto chunk = JSON::parse (R"({ + "model": "test-model", + "choices": [ + { + "index": 0, + "delta": { "role": "assistant", "content": "hel" }, + "finish_reason": null + } + ] + })"); + + auto response = LLMResponse::fromStreamChunk (chunk); + + ASSERT_EQ (1u, response.choices.size()); + EXPECT_EQ (LLMMessage::Role::assistant, response.choices.front().message.role); + EXPECT_EQ ("hel", response.choices.front().message.content); +} + +TEST (YupAiLLMResponse, AccumulatesStreamingToolCallArguments) +{ + auto first = LLMResponse::fromStreamChunk (JSON::parse (R"({ + "model": "test-model", + "choices": [ + { + "index": 0, + "delta": { + "role": "assistant", + "tool_calls": [ + { + "index": 0, + "id": "call_1", + "type": "function", + "function": { + "name": "set_background_color", + "arguments": "{\"color\"" + } + } + ] + }, + "finish_reason": null + } + ] + })")); + + auto second = LLMResponse::fromStreamChunk (JSON::parse (R"({ + "model": "test-model", + "choices": [ + { + "index": 0, + "delta": { + "tool_calls": [ + { + "index": 0, + "function": { + "arguments": ":\"darkgreen\"}" + } + } + ] + }, + "finish_reason": "tool_calls" + } + ] + })")); + + LLMResponse accumulated; + accumulated.appendStreamChunk (first); + accumulated.appendStreamChunk (second); + + ASSERT_TRUE (accumulated.hasToolCalls()); + + auto toolCalls = accumulated.getToolCalls(); + ASSERT_EQ (1u, toolCalls.size()); + EXPECT_EQ ("call_1", toolCalls.front().id); + EXPECT_EQ ("set_background_color", toolCalls.front().name); + EXPECT_EQ ("darkgreen", toolCalls.front().arguments["color"].toString()); +} + +TEST (YupAiLLMClient, BuildsChatCompletionBody) +{ + TestLLMClient client; + + LLMClient::Request request; + request.systemPrompt = "be brief"; + request.messages.push_back (LLMMessage::user ("hello")); + request.temperature = 0.25f; + request.maxTokens = 32; + + auto body = JSON::parse (client.buildBody (request, true)); + + EXPECT_TRUE (static_cast (body["stream"])); + EXPECT_EQ ("system", body["messages"][0]["role"].toString()); + EXPECT_EQ ("user", body["messages"][1]["role"].toString()); + EXPECT_EQ (32, static_cast (body["max_tokens"])); +} + +TEST (YupAiLLMClient, SerializesSpecificToolChoice) +{ + TestLLMClient client; + + LLMClient::Request request; + request.messages.push_back (LLMMessage::user ("change the color")); + request.toolChoice = "set_background_color"; + + LLMTool tool; + tool.name = "set_background_color"; + tool.description = "Changes the component background color."; + tool.parameters.push_back ({ "color", "string", "CSS color value", true }); + request.tools.push_back (std::move (tool)); + + auto body = JSON::parse (client.buildBody (request, false)); + + EXPECT_EQ ("function", body["tool_choice"]["type"].toString()); + EXPECT_EQ ("set_background_color", body["tool_choice"]["function"]["name"].toString()); +} + +TEST (YupAiEmbeddingModel, ComputesCosineSimilarity) +{ + EmbeddingModel::Embedding a; + a.values = { 1.0f, 0.0f }; + + EmbeddingModel::Embedding b; + b.values = { 0.0f, 1.0f }; + + EmbeddingModel::Embedding c; + c.values = { 2.0f, 0.0f }; + + EXPECT_FLOAT_EQ (0.0f, EmbeddingModel::cosineSimilarity (a, b)); + EXPECT_FLOAT_EQ (1.0f, EmbeddingModel::cosineSimilarity (a, c)); +} diff --git a/tests/yup_ai/yup_MCPTypes.cpp b/tests/yup_ai/yup_MCPTypes.cpp new file mode 100644 index 000000000..d0228daf9 --- /dev/null +++ b/tests/yup_ai/yup_MCPTypes.cpp @@ -0,0 +1,751 @@ +/* + ============================================================================== + + This file is part of the YUP library. + Copyright (c) 2026 - kunitoki@gmail.com + + YUP is an open source library subject to open-source licensing. + + The code included in this file is provided under the terms of the ISC license + http://www.isc.org/downloads/software-support-policy/isc-license. Permission + to use, copy, modify, and/or distribute this software for any purpose with or + without fee is hereby granted provided that the above copyright notice and + this permission notice appear in all copies. + + YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER + EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE + DISCLAIMED. + + ============================================================================== +*/ + +#include + +#include + +using namespace yup; + +namespace +{ +var makeTestObject() +{ + return var (std::make_unique()); +} + +void setTestProperty (var& object, const Identifier& name, const var& value) +{ + if (auto* dynamicObject = object.getDynamicObject()) + dynamicObject->setProperty (name, value); +} + +var makeTextToolResult (const String& text) +{ + auto textContent = makeTestObject(); + setTestProperty (textContent, "type", "text"); + setTestProperty (textContent, "text", text); + + var content; + content.append (textContent); + + auto result = makeTestObject(); + setTestProperty (result, "content", content); + return result; +} + +var makeResourceReadResult (const String& uri, const String& text) +{ + auto resourceContent = makeTestObject(); + setTestProperty (resourceContent, "uri", uri); + setTestProperty (resourceContent, "mimeType", "application/json"); + setTestProperty (resourceContent, "text", text); + + var contents; + contents.append (resourceContent); + + auto result = makeTestObject(); + setTestProperty (result, "contents", contents); + return result; +} + +class MockMCPTransport final : public MCPTransport +{ +public: + Result sendMessage (const var& message) override + { + sentMessages.push_back (message); + + auto request = JsonRpcRequest::fromVar (message); + if (! request.has_value() || request->isNotification()) + return Result::ok(); + + JsonRpcResponse response; + response.id = *request->id; + + if (request->method == "initialize") + { + auto result = makeTestObject(); + setTestProperty (result, "protocolVersion", "2024-11-05"); + setTestProperty (result, "capabilities", MCPCapabilities { true, true }.toVar()); + response.result = result; + } + else if (request->method == "tools/list") + { + MCPToolDefinition tool; + tool.name = "echo"; + tool.description = "Echoes text."; + tool.inputSchema = JSON::parse (R"({ + "type": "object", + "properties": { + "value": { "type": "string", "description": "Text to echo." } + }, + "required": [ "value" ] + })"); + + var tools; + tools.append (tool.toVar()); + + auto result = makeTestObject(); + setTestProperty (result, "tools", tools); + response.result = result; + } + else if (request->method == "tools/call") + { + response.result = makeTextToolResult ((*request->params)["arguments"]["value"].toString()); + } + else if (request->method == "resources/list") + { + MCPResourceDefinition resource; + resource.uri = "yup://test/status"; + resource.name = "Status"; + resource.description = "Test status."; + + var resources; + resources.append (resource.toVar()); + + auto result = makeTestObject(); + setTestProperty (result, "resources", resources); + response.result = result; + } + else if (request->method == "resources/read") + { + response.result = makeResourceReadResult ((*request->params)["uri"].toString(), R"({"ok":true})"); + } + else + { + response.error = JsonRpcError { MCPErrorCodes::methodNotFound, "Method not found", std::nullopt }; + } + + queuedMessages.push_back (response.toVar()); + return Result::ok(); + } + + ResultValue receiveMessage (int) override + { + if (queuedMessages.empty()) + return makeResultValueFail ("No queued MCP messages"); + + auto message = queuedMessages.front(); + queuedMessages.erase (queuedMessages.begin()); + return makeResultValueOk (std::move (message)); + } + + void setMessageHandler (MessageHandler handler) override + { + messageHandler = std::move (handler); + } + + Result start() override + { + connected = true; + return Result::ok(); + } + + void stop() override + { + connected = false; + } + + bool isConnected() const noexcept override + { + return connected; + } + + bool connected = false; + std::vector sentMessages; + std::vector queuedMessages; + MessageHandler messageHandler; +}; + +class ServerCaptureTransport final : public MCPTransport +{ +public: + Result sendMessage (const var& message) override + { + sentMessages.push_back (message); + return Result::ok(); + } + + ResultValue receiveMessage (int) override + { + return makeResultValueFail ("ServerCaptureTransport does not support receiveMessage"); + } + + void setMessageHandler (MessageHandler handler) override + { + messageHandler = std::move (handler); + } + + Result start() override + { + connected = true; + return Result::ok(); + } + + void stop() override + { + connected = false; + } + + bool isConnected() const noexcept override + { + return connected; + } + + void deliver (const var& message) + { + if (messageHandler) + messageHandler (message); + } + + bool connected = false; + std::vector sentMessages; + MessageHandler messageHandler; +}; + +class LinkedMCPTransport final : public MCPTransport +{ +public: + Result sendMessage (const var& message) override + { + if (peer == nullptr) + return Result::fail ("LinkedMCPTransport has no peer"); + + if (peer->messageHandler) + peer->messageHandler (message); + else + peer->queuedMessages.push_back (message); + + return Result::ok(); + } + + ResultValue receiveMessage (int) override + { + if (queuedMessages.empty()) + return makeResultValueFail ("No queued linked MCP messages"); + + auto message = queuedMessages.front(); + queuedMessages.erase (queuedMessages.begin()); + return makeResultValueOk (std::move (message)); + } + + void setMessageHandler (MessageHandler handler) override + { + messageHandler = std::move (handler); + } + + Result start() override + { + connected = true; + return Result::ok(); + } + + void stop() override + { + connected = false; + } + + bool isConnected() const noexcept override + { + return connected; + } + + LinkedMCPTransport* peer = nullptr; + bool connected = false; + std::vector queuedMessages; + MessageHandler messageHandler; +}; + +JsonRpcRequest makeTestRequest (int id, const String& method, std::optional params = std::nullopt) +{ + JsonRpcRequest request; + request.id = id; + request.method = method; + request.params = std::move (params); + return request; +} + +MCPToolDefinition makeEchoToolDefinition() +{ + MCPToolDefinition tool; + tool.name = "echo"; + tool.description = "Echoes text."; + tool.inputSchema = JSON::parse (R"({ + "type": "object", + "properties": { + "value": { "type": "string", "description": "Text to echo." } + }, + "required": [ "value" ] + })"); + + return tool; +} +} // namespace + +TEST (YupAiMCPTypes, SerializesAndParsesJsonRpcRequest) +{ + JsonRpcRequest request; + request.id = 7; + request.method = "tools/call"; + request.params = JSON::parse (R"({"name":"echo","arguments":{"value":"hello"}})"); + + auto parsed = JsonRpcRequest::fromVar (request.toVar()); + + ASSERT_TRUE (parsed.has_value()); + EXPECT_FALSE (parsed->isNotification()); + EXPECT_EQ ("2.0", parsed->jsonrpc); + EXPECT_EQ (7, static_cast (*parsed->id)); + EXPECT_EQ ("tools/call", parsed->method); + ASSERT_TRUE (parsed->params.has_value()); + EXPECT_EQ ("echo", (*parsed->params)["name"].toString()); + EXPECT_EQ ("hello", (*parsed->params)["arguments"]["value"].toString()); +} + +TEST (YupAiMCPTypes, ParsesNotificationWithoutId) +{ + auto parsed = JsonRpcRequest::fromVar (JSON::parse (R"({ + "jsonrpc": "2.0", + "method": "notifications/initialized" + })")); + + ASSERT_TRUE (parsed.has_value()); + EXPECT_TRUE (parsed->isNotification()); + EXPECT_EQ ("notifications/initialized", parsed->method); +} + +TEST (YupAiMCPTypes, SerializesAndParsesJsonRpcErrorResponse) +{ + JsonRpcResponse response; + response.id = "abc"; + response.error = JsonRpcError { + MCPErrorCodes::methodNotFound, + "No such method", + JSON::parse (R"({"method":"missing"})") + }; + + auto parsed = JsonRpcResponse::fromVar (response.toVar()); + + ASSERT_TRUE (parsed.has_value()); + EXPECT_TRUE (parsed->isError()); + EXPECT_EQ ("abc", parsed->id.toString()); + ASSERT_TRUE (parsed->error.has_value()); + EXPECT_EQ (MCPErrorCodes::methodNotFound, parsed->error->code); + EXPECT_EQ ("No such method", parsed->error->message); + ASSERT_TRUE (parsed->error->data.has_value()); + EXPECT_EQ ("missing", (*parsed->error->data)["method"].toString()); +} + +TEST (YupAiMCPTypes, SerializesAndParsesCapabilities) +{ + MCPCapabilities capabilities; + capabilities.supportsTools = true; + capabilities.supportsResources = true; + + auto parsed = MCPCapabilities::fromVar (capabilities.toVar()); + + EXPECT_TRUE (parsed.supportsTools); + EXPECT_TRUE (parsed.supportsResources); + EXPECT_FALSE (parsed.supportsPrompts); + EXPECT_FALSE (parsed.supportsLogging); +} + +TEST (YupAiMCPTypes, SerializesAndParsesToolDefinition) +{ + MCPToolDefinition tool; + tool.name = "set_background_color"; + tool.description = "Changes the component background color."; + tool.inputSchema = JSON::parse (R"({ + "type": "object", + "properties": { + "color": { "type": "string" } + }, + "required": [ "color" ] + })"); + + auto parsed = MCPToolDefinition::fromVar (tool.toVar()); + + ASSERT_TRUE (parsed.has_value()); + EXPECT_EQ ("set_background_color", parsed->name); + EXPECT_EQ ("string", parsed->inputSchema["properties"]["color"]["type"].toString()); + EXPECT_EQ ("color", parsed->inputSchema["required"][0].toString()); +} + +TEST (YupAiMCPTypes, SerializesAndParsesResourceDefinition) +{ + MCPResourceDefinition resource; + resource.uri = "yup://graph/main/nodes"; + resource.name = "Current Graph"; + resource.description = "Current audio graph nodes."; + + auto parsed = MCPResourceDefinition::fromVar (resource.toVar()); + + ASSERT_TRUE (parsed.has_value()); + EXPECT_EQ ("yup://graph/main/nodes", parsed->uri); + EXPECT_EQ ("Current Graph", parsed->name); + EXPECT_EQ ("application/json", parsed->mimeType); +} + +TEST (YupAiMCPClient, InitializesAndSendsInitializedNotification) +{ + auto transport = std::make_unique(); + auto* transportPtr = transport.get(); + MCPClient client (std::move (transport)); + + EXPECT_TRUE (client.initialize().wasOk()); + + ASSERT_EQ (2u, transportPtr->sentMessages.size()); + EXPECT_EQ ("initialize", transportPtr->sentMessages[0]["method"].toString()); + EXPECT_EQ ("notifications/initialized", transportPtr->sentMessages[1]["method"].toString()); +} + +TEST (YupAiMCPClient, ListsAndCallsTools) +{ + auto transport = std::make_unique(); + MCPClient client (std::move (transport)); + + auto tools = client.listTools(); + ASSERT_EQ (1u, tools.size()); + EXPECT_EQ ("echo", tools.front().name); + EXPECT_EQ ("string", tools.front().inputSchema["properties"]["value"]["type"].toString()); + + auto result = client.callTool ("echo", JSON::parse (R"({"value":"hello"})")); + ASSERT_TRUE (result.wasOk()); + EXPECT_EQ ("hello", result.getValue().toString()); +} + +TEST (YupAiMCPClient, ListsAndReadsResources) +{ + auto transport = std::make_unique(); + MCPClient client (std::move (transport)); + + auto resources = client.listResources(); + ASSERT_EQ (1u, resources.size()); + EXPECT_EQ ("yup://test/status", resources.front().uri); + + auto result = client.readResource ("yup://test/status"); + ASSERT_TRUE (result.wasOk()); + EXPECT_EQ (R"({"ok":true})", result.getValue()); +} + +TEST (YupAiMCPClient, RegistersRemoteToolsWithLLMRegistry) +{ + auto transport = std::make_unique(); + MCPClient client (std::move (transport)); + LLMToolRegistry registry; + + client.registerToolsWith (registry); + + ASSERT_TRUE (registry.contains ("echo")); + const auto* tool = registry.findTool ("echo"); + ASSERT_NE (nullptr, tool); + ASSERT_EQ (1u, tool->parameters.size()); + EXPECT_EQ ("value", tool->parameters.front().name); + EXPECT_TRUE (tool->parameters.front().required); + + auto result = registry.dispatchToolCall ("echo", JSON::parse (R"({"value":"from registry"})")); + EXPECT_EQ ("from registry", result.toString()); +} + +TEST (YupAiMCPServer, StartsAndHandlesInitialize) +{ + MCPServer::Options options; + options.serverName = "Test Server"; + options.serverVersion = "2.0"; + + MCPServer server (options); + auto transport = std::make_unique(); + auto* transportPtr = transport.get(); + + EXPECT_TRUE (server.start (std::move (transport)).wasOk()); + EXPECT_TRUE (server.isRunning()); + + transportPtr->deliver (makeTestRequest (1, "initialize").toVar()); + + ASSERT_EQ (1u, transportPtr->sentMessages.size()); + auto response = JsonRpcResponse::fromVar (transportPtr->sentMessages.front()); + ASSERT_TRUE (response.has_value()); + ASSERT_TRUE (response->result.has_value()); + EXPECT_EQ ("Test Server", (*response->result)["serverInfo"]["name"].toString()); + EXPECT_EQ ("2.0", (*response->result)["serverInfo"]["version"].toString()); +} + +TEST (YupAiMCPServer, ListsAndCallsRegisteredTool) +{ + MCPServer server; + server.registerTool (makeEchoToolDefinition(), [] (const var& arguments) + { + return arguments["value"]; + }); + + auto transport = std::make_unique(); + auto* transportPtr = transport.get(); + ASSERT_TRUE (server.start (std::move (transport)).wasOk()); + + transportPtr->deliver (makeTestRequest (1, "tools/list").toVar()); + + ASSERT_EQ (1u, transportPtr->sentMessages.size()); + auto listResponse = JsonRpcResponse::fromVar (transportPtr->sentMessages.back()); + ASSERT_TRUE (listResponse.has_value()); + ASSERT_TRUE (listResponse->result.has_value()); + EXPECT_EQ ("echo", (*listResponse->result)["tools"][0]["name"].toString()); + EXPECT_EQ ("string", (*listResponse->result)["tools"][0]["inputSchema"]["properties"]["value"]["type"].toString()); + + transportPtr->deliver (makeTestRequest (2, "tools/call", JSON::parse (R"({ + "name": "echo", + "arguments": { "value": "hello server" } + })")) + .toVar()); + + ASSERT_EQ (2u, transportPtr->sentMessages.size()); + auto callResponse = JsonRpcResponse::fromVar (transportPtr->sentMessages.back()); + ASSERT_TRUE (callResponse.has_value()); + ASSERT_TRUE (callResponse->result.has_value()); + EXPECT_EQ ("hello server", (*callResponse->result)["content"][0]["text"].toString()); +} + +TEST (YupAiMCPServer, RegistersLLMToolAndDerivesSchema) +{ + MCPServer server; + + LLMTool tool; + tool.name = "set_gain"; + tool.description = "Sets gain."; + tool.parameters.push_back ({ "gainDb", "number", "Gain in decibels.", true }); + tool.setHandler ([] (const var& arguments) + { + auto result = makeTestObject(); + setTestProperty (result, "gainDb", arguments["gainDb"]); + return result; + }); + + server.registerTool (std::move (tool)); + + auto transport = std::make_unique(); + auto* transportPtr = transport.get(); + ASSERT_TRUE (server.start (std::move (transport)).wasOk()); + + transportPtr->deliver (makeTestRequest (1, "tools/list").toVar()); + + ASSERT_EQ (1u, transportPtr->sentMessages.size()); + auto response = JsonRpcResponse::fromVar (transportPtr->sentMessages.back()); + ASSERT_TRUE (response.has_value()); + ASSERT_TRUE (response->result.has_value()); + EXPECT_EQ ("set_gain", (*response->result)["tools"][0]["name"].toString()); + EXPECT_EQ ("gainDb", (*response->result)["tools"][0]["inputSchema"]["required"][0].toString()); +} + +TEST (YupAiMCPServer, ListsAndReadsRegisteredResource) +{ + MCPServer server; + + MCPResourceDefinition resource; + resource.uri = "yup://test/status"; + resource.name = "Status"; + resource.description = "Test status."; + server.registerResource (resource, [] + { + return String (R"({"running":true})"); + }); + + auto transport = std::make_unique(); + auto* transportPtr = transport.get(); + ASSERT_TRUE (server.start (std::move (transport)).wasOk()); + + transportPtr->deliver (makeTestRequest (1, "resources/list").toVar()); + + ASSERT_EQ (1u, transportPtr->sentMessages.size()); + auto listResponse = JsonRpcResponse::fromVar (transportPtr->sentMessages.back()); + ASSERT_TRUE (listResponse.has_value()); + ASSERT_TRUE (listResponse->result.has_value()); + EXPECT_EQ ("yup://test/status", (*listResponse->result)["resources"][0]["uri"].toString()); + + transportPtr->deliver (makeTestRequest (2, "resources/read", JSON::parse (R"({ + "uri": "yup://test/status" + })")) + .toVar()); + + ASSERT_EQ (2u, transportPtr->sentMessages.size()); + auto readResponse = JsonRpcResponse::fromVar (transportPtr->sentMessages.back()); + ASSERT_TRUE (readResponse.has_value()); + ASSERT_TRUE (readResponse->result.has_value()); + EXPECT_EQ (R"({"running":true})", (*readResponse->result)["contents"][0]["text"].toString()); +} + +TEST (YupAiMCPServer, ReturnsErrorsForUnknownMethodsAndResources) +{ + MCPServer server; + auto transport = std::make_unique(); + auto* transportPtr = transport.get(); + ASSERT_TRUE (server.start (std::move (transport)).wasOk()); + + transportPtr->deliver (makeTestRequest (1, "unknown/method").toVar()); + + ASSERT_EQ (1u, transportPtr->sentMessages.size()); + auto methodResponse = JsonRpcResponse::fromVar (transportPtr->sentMessages.back()); + ASSERT_TRUE (methodResponse.has_value()); + ASSERT_TRUE (methodResponse->error.has_value()); + EXPECT_EQ (MCPErrorCodes::methodNotFound, methodResponse->error->code); + + transportPtr->deliver (makeTestRequest (2, "resources/read", JSON::parse (R"({ + "uri": "yup://missing" + })")) + .toVar()); + + ASSERT_EQ (2u, transportPtr->sentMessages.size()); + auto resourceResponse = JsonRpcResponse::fromVar (transportPtr->sentMessages.back()); + ASSERT_TRUE (resourceResponse.has_value()); + ASSERT_TRUE (resourceResponse->error.has_value()); + EXPECT_EQ (MCPErrorCodes::invalidParams, resourceResponse->error->code); +} + +TEST (YupAiMCPServer, IgnoresNotifications) +{ + MCPServer server; + auto transport = std::make_unique(); + auto* transportPtr = transport.get(); + ASSERT_TRUE (server.start (std::move (transport)).wasOk()); + + JsonRpcRequest notification; + notification.method = "notifications/initialized"; + transportPtr->deliver (notification.toVar()); + + EXPECT_TRUE (transportPtr->sentMessages.empty()); +} + +TEST (YupAiMCPServer, UnregistersToolsAndResources) +{ + MCPServer server; + server.registerTool (makeEchoToolDefinition(), [] (const var& arguments) + { + return arguments["value"]; + }); + + MCPResourceDefinition resource; + resource.uri = "yup://test/status"; + resource.name = "Status"; + resource.description = "Test status."; + server.registerResource (resource, [] + { + return String ("ok"); + }); + + server.unregisterTool ("echo"); + server.unregisterResource ("yup://test/status"); + + auto transport = std::make_unique(); + auto* transportPtr = transport.get(); + ASSERT_TRUE (server.start (std::move (transport)).wasOk()); + + transportPtr->deliver (makeTestRequest (1, "tools/list").toVar()); + auto toolsResponse = JsonRpcResponse::fromVar (transportPtr->sentMessages.back()); + ASSERT_TRUE (toolsResponse.has_value()); + ASSERT_TRUE (toolsResponse->result.has_value()); + EXPECT_EQ (0, (*toolsResponse->result)["tools"].size()); + + transportPtr->deliver (makeTestRequest (2, "resources/list").toVar()); + auto resourcesResponse = JsonRpcResponse::fromVar (transportPtr->sentMessages.back()); + ASSERT_TRUE (resourcesResponse.has_value()); + ASSERT_TRUE (resourcesResponse->result.has_value()); + EXPECT_EQ (0, (*resourcesResponse->result)["resources"].size()); + + transportPtr->deliver (makeTestRequest (3, "resources/read", JSON::parse (R"({ + "uri": "yup://test/status" + })")) + .toVar()); + auto readResponse = JsonRpcResponse::fromVar (transportPtr->sentMessages.back()); + ASSERT_TRUE (readResponse.has_value()); + ASSERT_TRUE (readResponse->error.has_value()); + EXPECT_EQ (MCPErrorCodes::invalidParams, readResponse->error->code); +} + +TEST (YupAiMCPServer, ReturnsInvalidRequestForMalformedJsonRpc) +{ + MCPServer server; + auto transport = std::make_unique(); + auto* transportPtr = transport.get(); + ASSERT_TRUE (server.start (std::move (transport)).wasOk()); + + transportPtr->deliver (JSON::parse (R"({ + "jsonrpc": "2.0", + "id": 4, + "result": { "unexpected": true } + })")); + + ASSERT_EQ (1u, transportPtr->sentMessages.size()); + auto response = JsonRpcResponse::fromVar (transportPtr->sentMessages.back()); + ASSERT_TRUE (response.has_value()); + ASSERT_TRUE (response->error.has_value()); + EXPECT_EQ (4, static_cast (response->id)); + EXPECT_EQ (MCPErrorCodes::invalidRequest, response->error->code); +} + +TEST (YupAiMCPIntegration, ClientAndServerCommunicateOverLinkedTransports) +{ + auto clientTransport = std::make_unique(); + auto serverTransport = std::make_unique(); + auto* clientTransportPtr = clientTransport.get(); + auto* serverTransportPtr = serverTransport.get(); + + clientTransportPtr->peer = serverTransportPtr; + serverTransportPtr->peer = clientTransportPtr; + + MCPServer::Options options; + options.serverName = "Linked Test Server"; + MCPServer server (options); + + server.registerTool (makeEchoToolDefinition(), [] (const var& arguments) + { + auto result = makeTestObject(); + setTestProperty (result, "echoed", arguments["value"]); + return result; + }); + + MCPResourceDefinition resource; + resource.uri = "yup://linked/status"; + resource.name = "Linked Status"; + resource.description = "Linked transport status."; + server.registerResource (resource, [] + { + return String ("linked-ok"); + }); + + ASSERT_TRUE (server.start (std::move (serverTransport)).wasOk()); + + MCPClient client (std::move (clientTransport)); + ASSERT_TRUE (client.initialize().wasOk()); + + auto tools = client.listTools(); + ASSERT_EQ (1u, tools.size()); + EXPECT_EQ ("echo", tools.front().name); + + auto toolResult = client.callTool ("echo", JSON::parse (R"({"value":"round trip"})")); + ASSERT_TRUE (toolResult.wasOk()); + EXPECT_EQ ("round trip", toolResult.getValue()["echoed"].toString()); + + auto resources = client.listResources(); + ASSERT_EQ (1u, resources.size()); + EXPECT_EQ ("yup://linked/status", resources.front().uri); + + auto resourceResult = client.readResource ("yup://linked/status"); + ASSERT_TRUE (resourceResult.wasOk()); + EXPECT_EQ ("linked-ok", resourceResult.getValue()); +}