From 1b2a8def09c42d821a3122210fcf7652d78257b2 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Thu, 21 May 2026 09:44:48 +0200 Subject: [PATCH 1/7] Initial AI module --- examples/graphics/CMakeLists.txt | 1 + examples/graphics/source/examples/AI.h | 313 ++++++++++++++++++ examples/graphics/source/main.cpp | 3 + .../yup_ai/embedding/yup_EmbeddingModel.cpp | 160 +++++++++ modules/yup_ai/embedding/yup_EmbeddingModel.h | 67 ++++ modules/yup_ai/llm/yup_LLMClient.cpp | 154 +++++++++ modules/yup_ai/llm/yup_LLMClient.h | 86 +++++ modules/yup_ai/llm/yup_LLMHttpClient.cpp | 151 +++++++++ modules/yup_ai/llm/yup_LLMHttpClient.h | 44 +++ modules/yup_ai/llm/yup_LLMMessage.cpp | 218 ++++++++++++ modules/yup_ai/llm/yup_LLMMessage.h | 98 ++++++ modules/yup_ai/llm/yup_LLMResponse.cpp | 122 +++++++ modules/yup_ai/llm/yup_LLMResponse.h | 64 ++++ modules/yup_ai/llm/yup_LLMTool.cpp | 127 +++++++ modules/yup_ai/llm/yup_LLMTool.h | 71 ++++ modules/yup_ai/llm/yup_LLMToolRegistry.cpp | 120 +++++++ modules/yup_ai/llm/yup_LLMToolRegistry.h | 64 ++++ modules/yup_ai/yup_ai.cpp | 40 +++ modules/yup_ai/yup_ai.h | 63 ++++ .../bindings/yup_YupAi_bindings.cpp | 227 +++++++++++++ .../yup_python/bindings/yup_YupAi_bindings.h | 33 ++ .../yup_python/modules/yup_YupMain_module.cpp | 8 + modules/yup_python/yup_python_ai.cpp | 22 ++ tests/CMakeLists.txt | 1 + tests/data/ai/.gitkeep | 1 + tests/yup_ai.cpp | 22 ++ tests/yup_ai/yup_LLMTypes.cpp | 242 ++++++++++++++ 27 files changed, 2522 insertions(+) create mode 100644 examples/graphics/source/examples/AI.h create mode 100644 modules/yup_ai/embedding/yup_EmbeddingModel.cpp create mode 100644 modules/yup_ai/embedding/yup_EmbeddingModel.h create mode 100644 modules/yup_ai/llm/yup_LLMClient.cpp create mode 100644 modules/yup_ai/llm/yup_LLMClient.h create mode 100644 modules/yup_ai/llm/yup_LLMHttpClient.cpp create mode 100644 modules/yup_ai/llm/yup_LLMHttpClient.h create mode 100644 modules/yup_ai/llm/yup_LLMMessage.cpp create mode 100644 modules/yup_ai/llm/yup_LLMMessage.h create mode 100644 modules/yup_ai/llm/yup_LLMResponse.cpp create mode 100644 modules/yup_ai/llm/yup_LLMResponse.h create mode 100644 modules/yup_ai/llm/yup_LLMTool.cpp create mode 100644 modules/yup_ai/llm/yup_LLMTool.h create mode 100644 modules/yup_ai/llm/yup_LLMToolRegistry.cpp create mode 100644 modules/yup_ai/llm/yup_LLMToolRegistry.h create mode 100644 modules/yup_ai/yup_ai.cpp create mode 100644 modules/yup_ai/yup_ai.h create mode 100644 modules/yup_python/bindings/yup_YupAi_bindings.cpp create mode 100644 modules/yup_python/bindings/yup_YupAi_bindings.h create mode 100644 modules/yup_python/yup_python_ai.cpp create mode 100644 tests/data/ai/.gitkeep create mode 100644 tests/yup_ai.cpp create mode 100644 tests/yup_ai/yup_LLMTypes.cpp diff --git a/examples/graphics/CMakeLists.txt b/examples/graphics/CMakeLists.txt index ff70a6a15..5b793e2dd 100644 --- a/examples/graphics/CMakeLists.txt +++ b/examples/graphics/CMakeLists.txt @@ -76,6 +76,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..51a718bd2 --- /dev/null +++ b/examples/graphics/source/examples/AI.h @@ -0,0 +1,313 @@ +/* + ============================================================================== + + 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 ("llama3.2", 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); + + 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); + 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) + : Thread ("OllamaRequest") + , owner (ownerToUse) + , model (std::move (modelToUse)) + , baseUrl (std::move (baseUrlToUse)) + , prompt (std::move (promptToUse)) + , 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.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."; + request.messages.push_back (yup::LLMMessage::user (prompt)); + request.temperature = 0.2f; + + yup::LLMToolRegistry toolRegistry; + owner.registerTools (toolRegistry, ownerReference); + request.tools = toolRegistry.getAllTools(); + request.toolChoice = "auto"; + + auto response = client.runToolLoop (request, toolRegistry); + + yup::String responseText; + if (! response.choices.empty()) + responseText = response.choices.front().message.content.trim(); + + if (responseText.isEmpty()) + responseText = "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; + 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(); + + 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); + 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::Font titleFont; + std::optional backgroundColor; + std::unique_ptr requestThread; +}; diff --git a/examples/graphics/source/main.cpp b/examples/graphics/source/main.cpp index a943ca89a..48c8bdfa4 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" @@ -139,6 +141,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..b0db33b7a --- /dev/null +++ b/modules/yup_ai/llm/yup_LLMClient.cpp @@ -0,0 +1,154 @@ +/* + ============================================================================== + + 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); +} +} // 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", *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..d552c5164 --- /dev/null +++ b/modules/yup_ai/llm/yup_LLMHttpClient.cpp @@ -0,0 +1,151 @@ +/* + ============================================================================== + + 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; +} +} // 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); + + if (stream != nullptr && statusCode >= 200 && statusCode < 300) + return LLMResponse::fromOpenAiJson (JSON::parse (stream->readEntireStreamAsString())); + + if (! shouldRetryAiStatus (statusCode) || attempt == owner.options.maxRetries) + break; + } + + return {}; + } + + 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) + { + 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); + if (! parsed.isVoid()) + onChunk (LLMResponse::fromStreamChunk (parsed)); + } + + 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..af7510509 --- /dev/null +++ b/modules/yup_ai/llm/yup_LLMMessage.cpp @@ -0,0 +1,218 @@ +/* + ============================================================================== + + 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.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"]); + } + + if (result.name.isEmpty()) + 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..f9e48e0fd --- /dev/null +++ b/modules/yup_ai/llm/yup_LLMMessage.h @@ -0,0 +1,98 @@ +/* + ============================================================================== + + 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 +{ + 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..7d92ab031 --- /dev/null +++ b/modules/yup_ai/llm/yup_LLMResponse.cpp @@ -0,0 +1,122 @@ +/* + ============================================================================== + + 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 +{ + +bool LLMResponse::hasToolCalls() const noexcept +{ + for (const auto& choice : choices) + if (choice.message.toolCalls.has_value() && ! choice.message.toolCalls->empty()) + return true; + + return false; +} + +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; +} + +LLMResponse LLMResponse::fromOpenAiJson (const var& json) +{ + LLMResponse response; + 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; + 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..d9e9f0627 --- /dev/null +++ b/modules/yup_ai/llm/yup_LLMResponse.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 +{ + +//============================================================================== +/** 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; + + /** Returns true if any choice contains assistant tool calls. */ + bool hasToolCalls() const noexcept; + + /** Returns all tool calls from all choices. */ + std::vector getToolCalls() const; + + /** 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/yup_ai.cpp b/modules/yup_ai/yup_ai.cpp new file mode 100644 index 000000000..9a78066dd --- /dev/null +++ b/modules/yup_ai/yup_ai.cpp @@ -0,0 +1,40 @@ +/* + ============================================================================== + + 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" diff --git a/modules/yup_ai/yup_ai.h b/modules/yup_ai/yup_ai.h new file mode 100644 index 000000000..74475e9d5 --- /dev/null +++ b/modules/yup_ai/yup_ai.h @@ -0,0 +1,63 @@ +/* + ============================================================================== + + 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" 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..fa61cd5f2 --- /dev/null +++ b/modules/yup_python/bindings/yup_YupAi_bindings.cpp @@ -0,0 +1,227 @@ +/* + ============================================================================== + + 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); + } +}; + +String messageRepr (const LLMMessage& message) +{ + return "LLMMessage(role='" + LLMMessage::roleToString (message.role) + "', content='" + message.content + "')"; +} +} // namespace + +void registerYupAiBindings (py::module_& m) +{ + auto ai = m.def_submodule ("ai"); + + 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 ("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 ("hasToolCalls", &LLMResponse::hasToolCalls) + .def ("getToolCalls", &LLMResponse::getToolCalls) + .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); +} + +} // 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..ae2375f93 --- /dev/null +++ b/modules/yup_python/bindings/yup_YupAi_bindings.h @@ -0,0 +1,33 @@ +/* + ============================================================================== + + 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 "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..530fe47a6 100644 --- a/modules/yup_python/modules/yup_YupMain_module.cpp +++ b/modules/yup_python/modules/yup_YupMain_module.cpp @@ -24,6 +24,10 @@ #include "../bindings/yup_YupCore_bindings.h" +#if YUP_MODULE_AVAILABLE_yup_ai +#include "../bindings/yup_YupAi_bindings.h" +#endif + #if YUP_MODULE_AVAILABLE_yup_events #include "../bindings/yup_YupEvents_bindings.h" #endif @@ -105,4 +109,8 @@ PYBIND11_MODULE (YUP_PYTHON_MODULE_NAME, m) yup::Bindings::registerYupAudioProcessorsBindings (m); #endif */ + +#if YUP_MODULE_AVAILABLE_yup_ai + yup::Bindings::registerYupAiBindings (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..cf728cf8f --- /dev/null +++ b/modules/yup_python/yup_python_ai.cpp @@ -0,0 +1,22 @@ +/* + ============================================================================== + + 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 "bindings/yup_YupAi_bindings.cpp" 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..a3f16665e --- /dev/null +++ b/tests/yup_ai.cpp @@ -0,0 +1,22 @@ +/* + ============================================================================== + + 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" diff --git a/tests/yup_ai/yup_LLMTypes.cpp b/tests/yup_ai/yup_LLMTypes.cpp new file mode 100644 index 000000000..41d068f6f --- /dev/null +++ b/tests/yup_ai/yup_LLMTypes.cpp @@ -0,0 +1,242 @@ +/* + ============================================================================== + + 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, 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 (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 (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)); +} From 5e8b401700fcb6ea2d225a695c0734eeb6932a42 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Thu, 21 May 2026 10:36:59 +0200 Subject: [PATCH 2/7] More error handling --- modules/yup_ai/llm/yup_LLMClient.cpp | 17 ++++- modules/yup_ai/llm/yup_LLMHttpClient.cpp | 35 +++++++-- modules/yup_ai/llm/yup_LLMResponse.cpp | 43 +++++++++++ modules/yup_ai/llm/yup_LLMResponse.h | 7 ++ .../bindings/yup_YupAi_bindings.cpp | 3 + tests/yup_ai/yup_LLMTypes.cpp | 73 +++++++++++++++++++ 6 files changed, 172 insertions(+), 6 deletions(-) diff --git a/modules/yup_ai/llm/yup_LLMClient.cpp b/modules/yup_ai/llm/yup_LLMClient.cpp index b0db33b7a..4a5f9c093 100644 --- a/modules/yup_ai/llm/yup_LLMClient.cpp +++ b/modules/yup_ai/llm/yup_LLMClient.cpp @@ -33,6 +33,21 @@ 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) @@ -107,7 +122,7 @@ String LLMClient::buildChatCompletionBody (const Request& request, bool stream) setLLMClientProperty (object, "tools", toolsToVar (request.tools)); if (request.toolChoice.has_value()) - setLLMClientProperty (object, "tool_choice", *request.toolChoice); + setLLMClientProperty (object, "tool_choice", toolChoiceToVar (*request.toolChoice)); if (request.temperature.has_value()) setLLMClientProperty (object, "temperature", static_cast (*request.temperature)); diff --git a/modules/yup_ai/llm/yup_LLMHttpClient.cpp b/modules/yup_ai/llm/yup_LLMHttpClient.cpp index d552c5164..bcc6be083 100644 --- a/modules/yup_ai/llm/yup_LLMHttpClient.cpp +++ b/modules/yup_ai/llm/yup_LLMHttpClient.cpp @@ -43,6 +43,27 @@ 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 @@ -68,15 +89,16 @@ struct LLMHttpClient::Pimpl .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 (stream->readEntireStreamAsString())); + return LLMResponse::fromOpenAiJson (JSON::parse (responseBody)); if (! shouldRetryAiStatus (statusCode) || attempt == owner.options.maxRetries) - break; + return makeHttpErrorResponse (statusCode, responseBody); } - return {}; + return LLMResponse::fromError ("AI HTTP request failed after retries"); } bool completeStreaming (const Request& request, ChunkCallback onChunk) @@ -113,8 +135,11 @@ struct LLMHttpClient::Pimpl return true; auto parsed = JSON::parse (payload); - if (! parsed.isVoid()) - onChunk (LLMResponse::fromStreamChunk (parsed)); + auto chunk = LLMResponse::fromStreamChunk (parsed); + onChunk (chunk); + + if (chunk.failed()) + return false; } return true; diff --git a/modules/yup_ai/llm/yup_LLMResponse.cpp b/modules/yup_ai/llm/yup_LLMResponse.cpp index 7d92ab031..4413aa7ef 100644 --- a/modules/yup_ai/llm/yup_LLMResponse.cpp +++ b/modules/yup_ai/llm/yup_LLMResponse.cpp @@ -21,6 +21,23 @@ 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 {}; +} +} // namespace bool LLMResponse::hasToolCalls() const noexcept { @@ -31,6 +48,11 @@ bool LLMResponse::hasToolCalls() const noexcept return false; } +bool LLMResponse::failed() const noexcept +{ + return errorMessage.has_value(); +} + std::vector LLMResponse::getToolCalls() const { std::vector result; @@ -42,9 +64,23 @@ std::vector LLMResponse::getToolCalls() const return result; } +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()) @@ -79,6 +115,13 @@ LLMResponse LLMResponse::fromOpenAiJson (const var& json) 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()) diff --git a/modules/yup_ai/llm/yup_LLMResponse.h b/modules/yup_ai/llm/yup_LLMResponse.h index d9e9f0627..76755d540 100644 --- a/modules/yup_ai/llm/yup_LLMResponse.h +++ b/modules/yup_ai/llm/yup_LLMResponse.h @@ -47,13 +47,20 @@ class YUP_API LLMResponse 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; + /** 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); diff --git a/modules/yup_python/bindings/yup_YupAi_bindings.cpp b/modules/yup_python/bindings/yup_YupAi_bindings.cpp index fa61cd5f2..b98562e8f 100644 --- a/modules/yup_python/bindings/yup_YupAi_bindings.cpp +++ b/modules/yup_python/bindings/yup_YupAi_bindings.cpp @@ -168,8 +168,11 @@ void registerYupAiBindings (py::module_& m) .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); diff --git a/tests/yup_ai/yup_LLMTypes.cpp b/tests/yup_ai/yup_LLMTypes.cpp index 41d068f6f..6b4dc370f 100644 --- a/tests/yup_ai/yup_LLMTypes.cpp +++ b/tests/yup_ai/yup_LLMTypes.cpp @@ -188,6 +188,59 @@ TEST (YupAiLLMResponse, ParsesOpenAiResponse) 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"({ @@ -226,6 +279,26 @@ TEST (YupAiLLMClient, BuildsChatCompletionBody) 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; From d47d35267d81faedf6da7ec17c1d545f6f1754ef Mon Sep 17 00:00:00 2001 From: kunitoki Date: Thu, 21 May 2026 12:15:38 +0200 Subject: [PATCH 3/7] More MCP goodness --- examples/graphics/source/examples/AI.h | 39 +- modules/yup_ai/llm/yup_LLMHttpClient.cpp | 6 +- modules/yup_ai/llm/yup_LLMMessage.cpp | 7 +- modules/yup_ai/llm/yup_LLMMessage.h | 1 + modules/yup_ai/llm/yup_LLMResponse.cpp | 81 +++ modules/yup_ai/llm/yup_LLMResponse.h | 3 + modules/yup_ai/mcp/yup_MCPClient.cpp | 325 +++++++++ modules/yup_ai/mcp/yup_MCPClient.h | 66 ++ modules/yup_ai/mcp/yup_MCPServer.cpp | 356 +++++++++ modules/yup_ai/mcp/yup_MCPServer.h | 82 +++ modules/yup_ai/mcp/yup_MCPTransport.h | 59 ++ modules/yup_ai/mcp/yup_MCPTypes.cpp | 244 +++++++ modules/yup_ai/mcp/yup_MCPTypes.h | 159 ++++ modules/yup_ai/yup_ai.cpp | 3 + modules/yup_ai/yup_ai.h | 4 + .../bindings/yup_YupAi_bindings.cpp | 1 + tests/yup_ai.cpp | 1 + tests/yup_ai/yup_LLMTypes.cpp | 59 ++ tests/yup_ai/yup_MCPTypes.cpp | 684 ++++++++++++++++++ 19 files changed, 2167 insertions(+), 13 deletions(-) create mode 100644 modules/yup_ai/mcp/yup_MCPClient.cpp create mode 100644 modules/yup_ai/mcp/yup_MCPClient.h create mode 100644 modules/yup_ai/mcp/yup_MCPServer.cpp create mode 100644 modules/yup_ai/mcp/yup_MCPServer.h create mode 100644 modules/yup_ai/mcp/yup_MCPTransport.h create mode 100644 modules/yup_ai/mcp/yup_MCPTypes.cpp create mode 100644 modules/yup_ai/mcp/yup_MCPTypes.h create mode 100644 tests/yup_ai/yup_MCPTypes.cpp diff --git a/examples/graphics/source/examples/AI.h b/examples/graphics/source/examples/AI.h index 51a718bd2..f7f552dc4 100644 --- a/examples/graphics/source/examples/AI.h +++ b/examples/graphics/source/examples/AI.h @@ -43,7 +43,7 @@ class AiDemo : public yup::Component modelLabel.setText ("Model", yup::dontSendNotification); addAndMakeVisible (modelLabel); - modelEditor.setText ("llama3.2", yup::dontSendNotification); + modelEditor.setText ("gemma4", yup::dontSendNotification); modelEditor.setMultiLine (false); addAndMakeVisible (modelEditor); @@ -68,6 +68,10 @@ class AiDemo : public yup::Component }; 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); @@ -116,6 +120,8 @@ class AiDemo : public yup::Component 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); @@ -138,12 +144,13 @@ class AiDemo : public yup::Component class OllamaRequestThread final : public yup::Thread { public: - OllamaRequestThread (AiDemo& ownerToUse, yup::String modelToUse, yup::String baseUrlToUse, yup::String promptToUse) + 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) { } @@ -159,25 +166,32 @@ class AiDemo : public yup::Component yup::LLMHttpClient client (std::move (options)); yup::LLMClient::Request request; - 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."; request.messages.push_back (yup::LLMMessage::user (prompt)); request.temperature = 0.2f; yup::LLMToolRegistry toolRegistry; - owner.registerTools (toolRegistry, ownerReference); - request.tools = toolRegistry.getAllTools(); - request.toolChoice = "auto"; + 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.choices.empty()) + 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 = "No response was returned. Check that Ollama is running, the model is pulled, and the base URL is reachable."; + 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; @@ -199,6 +213,7 @@ class AiDemo : public yup::Component yup::String model; yup::String baseUrl; yup::String prompt; + bool useTools; yup::WeakReference ownerReference; }; @@ -215,6 +230,7 @@ class AiDemo : public yup::Component 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()) { @@ -226,7 +242,7 @@ class AiDemo : public yup::Component statusLabel.setText ("Waiting for Ollama...", yup::dontSendNotification); responseEditor.setText ("", yup::dontSendNotification); - requestThread = std::make_unique (*this, model, baseUrl, prompt); + requestThread = std::make_unique (*this, model, baseUrl, prompt, useTools); if (! requestThread->startThread (yup::Thread::Priority::background)) { requestThread.reset(); @@ -306,6 +322,7 @@ class AiDemo : public yup::Component yup::TextEditor promptEditor { "promptEditor" }; yup::TextEditor responseEditor { "responseEditor" }; yup::TextButton askButton { "askButton" }; + yup::ToggleButton toolsToggle { "toolsToggle" }; yup::Font titleFont; std::optional backgroundColor; diff --git a/modules/yup_ai/llm/yup_LLMHttpClient.cpp b/modules/yup_ai/llm/yup_LLMHttpClient.cpp index bcc6be083..6ec8bc553 100644 --- a/modules/yup_ai/llm/yup_LLMHttpClient.cpp +++ b/modules/yup_ai/llm/yup_LLMHttpClient.cpp @@ -123,6 +123,8 @@ struct LLMHttpClient::Pimpl if (stream != nullptr && statusCode >= 200 && statusCode < 300) { + LLMResponse accumulatedResponse; + while (! stream->isExhausted()) { auto line = stream->readNextLine().trim(); @@ -136,7 +138,9 @@ struct LLMHttpClient::Pimpl auto parsed = JSON::parse (payload); auto chunk = LLMResponse::fromStreamChunk (parsed); - onChunk (chunk); + + accumulatedResponse.appendStreamChunk (chunk); + onChunk (accumulatedResponse); if (chunk.failed()) return false; diff --git a/modules/yup_ai/llm/yup_LLMMessage.cpp b/modules/yup_ai/llm/yup_LLMMessage.cpp index af7510509..157f28d40 100644 --- a/modules/yup_ai/llm/yup_LLMMessage.cpp +++ b/modules/yup_ai/llm/yup_LLMMessage.cpp @@ -72,6 +72,7 @@ std::optional LLMToolCall::fromVar (const var& value) return std::nullopt; LLMToolCall result; + result.index = static_cast (value["index"]); result.id = value["id"].toString(); if (auto* functionObject = value["function"].getDynamicObject()) @@ -85,7 +86,11 @@ std::optional LLMToolCall::fromVar (const var& value) result.arguments = parseArguments (value["arguments"]); } - if (result.name.isEmpty()) + 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; diff --git a/modules/yup_ai/llm/yup_LLMMessage.h b/modules/yup_ai/llm/yup_LLMMessage.h index f9e48e0fd..40ac68f20 100644 --- a/modules/yup_ai/llm/yup_LLMMessage.h +++ b/modules/yup_ai/llm/yup_LLMMessage.h @@ -33,6 +33,7 @@ namespace yup */ struct YUP_API LLMToolCall { + int index = 0; String id; String name; var arguments; diff --git a/modules/yup_ai/llm/yup_LLMResponse.cpp b/modules/yup_ai/llm/yup_LLMResponse.cpp index 4413aa7ef..32abc5d70 100644 --- a/modules/yup_ai/llm/yup_LLMResponse.cpp +++ b/modules/yup_ai/llm/yup_LLMResponse.cpp @@ -37,6 +37,33 @@ String getOpenAiErrorMessage (const var& json) 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 @@ -64,6 +91,60 @@ std::vector LLMResponse::getToolCalls() const 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; diff --git a/modules/yup_ai/llm/yup_LLMResponse.h b/modules/yup_ai/llm/yup_LLMResponse.h index 76755d540..341239413 100644 --- a/modules/yup_ai/llm/yup_LLMResponse.h +++ b/modules/yup_ai/llm/yup_LLMResponse.h @@ -58,6 +58,9 @@ class YUP_API LLMResponse /** 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); 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..89361dc51 --- /dev/null +++ b/modules/yup_ai/mcp/yup_MCPServer.cpp @@ -0,0 +1,356 @@ +/* + ============================================================================== + + 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); + pimpl->resourcesByUri[resource.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 index 9a78066dd..4c78b7081 100644 --- a/modules/yup_ai/yup_ai.cpp +++ b/modules/yup_ai/yup_ai.cpp @@ -38,3 +38,6 @@ #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 index 74475e9d5..01f9fdf80 100644 --- a/modules/yup_ai/yup_ai.h +++ b/modules/yup_ai/yup_ai.h @@ -61,3 +61,7 @@ #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 index b98562e8f..93823334a 100644 --- a/modules/yup_python/bindings/yup_YupAi_bindings.cpp +++ b/modules/yup_python/bindings/yup_YupAi_bindings.cpp @@ -68,6 +68,7 @@ void registerYupAiBindings (py::module_& m) 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) diff --git a/tests/yup_ai.cpp b/tests/yup_ai.cpp index a3f16665e..2eccd7c84 100644 --- a/tests/yup_ai.cpp +++ b/tests/yup_ai.cpp @@ -20,3 +20,4 @@ */ #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 index 6b4dc370f..cb56bae8b 100644 --- a/tests/yup_ai/yup_LLMTypes.cpp +++ b/tests/yup_ai/yup_LLMTypes.cpp @@ -261,6 +261,65 @@ TEST (YupAiLLMResponse, ParsesStreamingChunk) 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; diff --git a/tests/yup_ai/yup_MCPTypes.cpp b/tests/yup_ai/yup_MCPTypes.cpp new file mode 100644 index 000000000..d0085aa0e --- /dev/null +++ b/tests/yup_ai/yup_MCPTypes.cpp @@ -0,0 +1,684 @@ +/* + ============================================================================== + + 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 (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()); +} From 2f99e8620e45d2ec569b082c5f2fe851f27e5d9c Mon Sep 17 00:00:00 2001 From: kunitoki Date: Thu, 21 May 2026 12:32:52 +0200 Subject: [PATCH 4/7] More mcp stuff --- modules/yup_ai/mcp/yup_MCPServer.cpp | 3 +- .../bindings/yup_YupAi_bindings.cpp | 279 ++++++++++++++++++ python/tests/test_yup_ai/__init__.py | 2 + python/tests/test_yup_ai/test_MCPServer.py | 41 +++ python/tests/test_yup_ai/test_MCPTransport.py | 69 +++++ python/tests/test_yup_ai/test_MCPTypes.py | 111 +++++++ tests/yup_ai/yup_MCPTypes.cpp | 67 +++++ 7 files changed, 571 insertions(+), 1 deletion(-) create mode 100644 python/tests/test_yup_ai/__init__.py create mode 100644 python/tests/test_yup_ai/test_MCPServer.py create mode 100644 python/tests/test_yup_ai/test_MCPTransport.py create mode 100644 python/tests/test_yup_ai/test_MCPTypes.py diff --git a/modules/yup_ai/mcp/yup_MCPServer.cpp b/modules/yup_ai/mcp/yup_MCPServer.cpp index 89361dc51..b5de71a19 100644 --- a/modules/yup_ai/mcp/yup_MCPServer.cpp +++ b/modules/yup_ai/mcp/yup_MCPServer.cpp @@ -293,7 +293,8 @@ void MCPServer::unregisterTool (const String& name) void MCPServer::registerResource (MCPResourceDefinition resource, std::function reader) { const ScopedLock lock (pimpl->mutex); - pimpl->resourcesByUri[resource.uri] = Pimpl::ResourceEntry { std::move (resource), std::move (reader) }; + const auto uri = resource.uri; + pimpl->resourcesByUri[uri] = Pimpl::ResourceEntry { std::move (resource), std::move (reader) }; pimpl->options.capabilities.supportsResources = true; } diff --git a/modules/yup_python/bindings/yup_YupAi_bindings.cpp b/modules/yup_python/bindings/yup_YupAi_bindings.cpp index 93823334a..34c130c68 100644 --- a/modules/yup_python/bindings/yup_YupAi_bindings.cpp +++ b/modules/yup_python/bindings/yup_YupAi_bindings.cpp @@ -49,16 +49,144 @@ class PyLLMClient : public LLMClient } }; +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) @@ -226,6 +354,157 @@ void registerYupAiBindings (py::module_& m) .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/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/yup_ai/yup_MCPTypes.cpp b/tests/yup_ai/yup_MCPTypes.cpp index d0085aa0e..d0228daf9 100644 --- a/tests/yup_ai/yup_MCPTypes.cpp +++ b/tests/yup_ai/yup_MCPTypes.cpp @@ -631,6 +631,73 @@ TEST (YupAiMCPServer, IgnoresNotifications) 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(); From 5fe327adefdc78bfd25cd3b9d9ca51cc90b3bbf9 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 22 May 2026 08:04:31 +0200 Subject: [PATCH 5/7] Fix python dependencies --- modules/yup_python/bindings/yup_YupAi_bindings.h | 4 ++++ .../yup_python/modules/yup_YupMain_module.cpp | 16 ++++++++-------- modules/yup_python/yup_python_ai.cpp | 2 ++ modules/yup_python/yup_python_audio_basics.cpp | 2 ++ modules/yup_python/yup_python_data_model.cpp | 2 ++ modules/yup_python/yup_python_events.cpp | 2 ++ modules/yup_python/yup_python_graphics.cpp | 2 ++ modules/yup_python/yup_python_gui.cpp | 2 ++ python/CMakeLists.txt | 3 ++- 9 files changed, 26 insertions(+), 9 deletions(-) diff --git a/modules/yup_python/bindings/yup_YupAi_bindings.h b/modules/yup_python/bindings/yup_YupAi_bindings.h index ae2375f93..f8e811897 100644 --- a/modules/yup_python/bindings/yup_YupAi_bindings.h +++ b/modules/yup_python/bindings/yup_YupAi_bindings.h @@ -21,7 +21,11 @@ #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" diff --git a/modules/yup_python/modules/yup_YupMain_module.cpp b/modules/yup_python/modules/yup_YupMain_module.cpp index 530fe47a6..528b0ffeb 100644 --- a/modules/yup_python/modules/yup_YupMain_module.cpp +++ b/modules/yup_python/modules/yup_YupMain_module.cpp @@ -24,10 +24,6 @@ #include "../bindings/yup_YupCore_bindings.h" -#if YUP_MODULE_AVAILABLE_yup_ai -#include "../bindings/yup_YupAi_bindings.h" -#endif - #if YUP_MODULE_AVAILABLE_yup_events #include "../bindings/yup_YupEvents_bindings.h" #endif @@ -44,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 @@ -96,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 @@ -109,8 +113,4 @@ PYBIND11_MODULE (YUP_PYTHON_MODULE_NAME, m) yup::Bindings::registerYupAudioProcessorsBindings (m); #endif */ - -#if YUP_MODULE_AVAILABLE_yup_ai - yup::Bindings::registerYupAiBindings (m); -#endif } diff --git a/modules/yup_python/yup_python_ai.cpp b/modules/yup_python/yup_python_ai.cpp index cf728cf8f..4cc6021f8 100644 --- a/modules/yup_python/yup_python_ai.cpp +++ b/modules/yup_python/yup_python_ai.cpp @@ -19,4 +19,6 @@ ============================================================================== */ +#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 From 03a270791db32cf344cd46743f38f3a50e957549 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Fri, 22 May 2026 15:30:43 +0200 Subject: [PATCH 6/7] Fix WASM builds --- modules/yup_core/native/yup_Network_wasm.cpp | 2 +- modules/yup_core/yup_core.cpp | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/yup_core/native/yup_Network_wasm.cpp b/modules/yup_core/native/yup_Network_wasm.cpp index e828a3a4e..2e7e40a2a 100644 --- a/modules/yup_core/native/yup_Network_wasm.cpp +++ b/modules/yup_core/native/yup_Network_wasm.cpp @@ -37,7 +37,7 @@ bool YUP_CALLTYPE Process::openEmailWithAttachments (const String& /* targetEmai } //============================================================================== -#if YUP_EMSCRIPTEN && ! YUP_USE_CURL +#if YUP_EMSCRIPTEN class WebInputStream::Pimpl { public: diff --git a/modules/yup_core/yup_core.cpp b/modules/yup_core/yup_core.cpp index 57fd7f003..ce08a0c2d 100644 --- a/modules/yup_core/yup_core.cpp +++ b/modules/yup_core/yup_core.cpp @@ -326,9 +326,10 @@ extern char** environ; #if ! YUP_WASM #include "threads/yup_ChildProcess.cpp" +#endif + #include "network/yup_WebInputStream.cpp" #include "streams/yup_URLInputSource.cpp" -#endif //============================================================================== #include From f1e1c68bd838b4f326c48d1d1f0fa096652212b7 Mon Sep 17 00:00:00 2001 From: kunitoki Date: Mon, 25 May 2026 23:31:25 +0200 Subject: [PATCH 7/7] Add support for client providers --- examples/graphics/source/examples/AI.h | 392 ++++- modules/yup_ai/llm/yup_LLMClient.cpp | 52 +- modules/yup_ai/llm/yup_LLMClient.h | 27 +- modules/yup_ai/llm/yup_LLMClientFactory.cpp | 96 ++ modules/yup_ai/llm/yup_LLMClientFactory.h | 79 + modules/yup_ai/llm/yup_LLMHttpClient.cpp | 105 +- modules/yup_ai/llm/yup_LLMHttpClient.h | 59 +- modules/yup_ai/llm/yup_LLMSchema.h | 154 ++ .../providers/yup_LLMAnthropicClient.cpp | 185 +++ .../yup_ai/providers/yup_LLMAnthropicClient.h | 56 + .../yup_ai/providers/yup_LLMGeminiClient.cpp | 407 +++++ .../yup_ai/providers/yup_LLMGeminiClient.h | 61 + .../providers/yup_LLMOpenAIChatClient.cpp | 80 + .../providers/yup_LLMOpenAIChatClient.h | 60 + .../yup_LLMOpenAIResponsesClient.cpp | 240 +++ .../providers/yup_LLMOpenAIResponsesClient.h | 62 + modules/yup_ai/yup_ai.cpp | 14 + modules/yup_ai/yup_ai.h | 15 + .../bindings/yup_YupAi_bindings.cpp | 96 +- tests/yup_ai/yup_LLMProviders.cpp | 1321 +++++++++++++++++ tests/yup_ai/yup_LLMTypes.cpp | 2 +- 21 files changed, 3423 insertions(+), 140 deletions(-) create mode 100644 modules/yup_ai/llm/yup_LLMClientFactory.cpp create mode 100644 modules/yup_ai/llm/yup_LLMClientFactory.h create mode 100644 modules/yup_ai/llm/yup_LLMSchema.h create mode 100644 modules/yup_ai/providers/yup_LLMAnthropicClient.cpp create mode 100644 modules/yup_ai/providers/yup_LLMAnthropicClient.h create mode 100644 modules/yup_ai/providers/yup_LLMGeminiClient.cpp create mode 100644 modules/yup_ai/providers/yup_LLMGeminiClient.h create mode 100644 modules/yup_ai/providers/yup_LLMOpenAIChatClient.cpp create mode 100644 modules/yup_ai/providers/yup_LLMOpenAIChatClient.h create mode 100644 modules/yup_ai/providers/yup_LLMOpenAIResponsesClient.cpp create mode 100644 modules/yup_ai/providers/yup_LLMOpenAIResponsesClient.h create mode 100644 tests/yup_ai/yup_LLMProviders.cpp diff --git a/examples/graphics/source/examples/AI.h b/examples/graphics/source/examples/AI.h index f7f552dc4..97bdd50d8 100644 --- a/examples/graphics/source/examples/AI.h +++ b/examples/graphics/source/examples/AI.h @@ -36,31 +36,88 @@ class AiDemo : public yup::Component auto theme = yup::ApplicationTheme::getGlobalTheme(); titleFont = theme->getDefaultFont(); - titleLabel.setText ("Ollama Tools", yup::dontSendNotification); + //====================================================================== + // Title + titleLabel.setText ("AI Providers", yup::dontSendNotification); titleLabel.setFont (titleFont); addAndMakeVisible (titleLabel); + //====================================================================== + // Provider selector buttons + providerOpenAIChatButton.setButtonText ("OpenAI Chat"); + providerOpenAIChatButton.onClick = [this] + { + selectProvider (SelectedProvider::OpenAIChat); + }; + addAndMakeVisible (providerOpenAIChatButton); + + providerOpenAIResponsesButton.setButtonText ("OpenAI Responses"); + providerOpenAIResponsesButton.onClick = [this] + { + selectProvider (SelectedProvider::OpenAIResponses); + }; + addAndMakeVisible (providerOpenAIResponsesButton); + + providerAnthropicButton.setButtonText ("Anthropic"); + providerAnthropicButton.onClick = [this] + { + selectProvider (SelectedProvider::Anthropic); + }; + addAndMakeVisible (providerAnthropicButton); + + providerGeminiButton.setButtonText ("Gemini"); + providerGeminiButton.onClick = [this] + { + selectProvider (SelectedProvider::Gemini); + }; + addAndMakeVisible (providerGeminiButton); + + //====================================================================== + // Model modelLabel.setText ("Model", yup::dontSendNotification); addAndMakeVisible (modelLabel); - modelEditor.setText ("gemma4", yup::dontSendNotification); modelEditor.setMultiLine (false); addAndMakeVisible (modelEditor); + //====================================================================== + // Base URL baseUrlLabel.setText ("Base URL", yup::dontSendNotification); addAndMakeVisible (baseUrlLabel); - baseUrlEditor.setText ("http://localhost:11434/v1", yup::dontSendNotification); baseUrlEditor.setMultiLine (false); addAndMakeVisible (baseUrlEditor); + //====================================================================== + // API Key + apiKeyLabel.setText ("API Key", yup::dontSendNotification); + addAndMakeVisible (apiKeyLabel); + + apiKeyEditor.setMultiLine (false); + apiKeyEditor.setText ("", yup::dontSendNotification); + addAndMakeVisible (apiKeyEditor); + + //====================================================================== + // Reasoning effort (for OpenAI Responses / Gemini 2.5 — leave empty to disable) + reasoningLabel.setText ("Reasoning (low/med/high)", yup::dontSendNotification); + addAndMakeVisible (reasoningLabel); + + reasoningEditor.setMultiLine (false); + reasoningEditor.setText ("", yup::dontSendNotification); + addAndMakeVisible (reasoningEditor); + + //====================================================================== + // Prompt 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); + promptEditor.setText ("Change this component background to dark green, then say what you changed.", + yup::dontSendNotification); addAndMakeVisible (promptEditor); + //====================================================================== + // Action row askButton.setButtonText ("Ask"); askButton.onClick = [this] { @@ -68,13 +125,16 @@ class AiDemo : public yup::Component }; addAndMakeVisible (askButton); + // Tools are supported by OpenAI Chat and Gemini providers. toolsToggle.setButtonText ("Tools"); toolsToggle.setToggleState (true, yup::dontSendNotification); addAndMakeVisible (toolsToggle); - statusLabel.setText ("Ollama can call set_background_color for this page.", yup::dontSendNotification); + statusLabel.setText ("Select a provider and ask a question.", yup::dontSendNotification); addAndMakeVisible (statusLabel); + //====================================================================== + // Response responseLabel.setText ("Response", yup::dontSendNotification); addAndMakeVisible (responseLabel); @@ -82,6 +142,9 @@ class AiDemo : public yup::Component responseEditor.setReadOnly (true); responseEditor.setText ("", yup::dontSendNotification); addAndMakeVisible (responseEditor); + + // Apply defaults for the initial provider. + selectProvider (SelectedProvider::OpenAIChat); } ~AiDemo() override @@ -94,61 +157,167 @@ class AiDemo : public yup::Component { auto area = getLocalBounds().reduced (20); + // Title titleLabel.setBounds (area.removeFromTop (40)); + area.removeFromTop (8); + + // Provider selector — four equal-width buttons. + { + auto row = area.removeFromTop (30); + const int w = row.getWidth() / 4; + providerOpenAIChatButton.setBounds (row.removeFromLeft (w)); + providerOpenAIResponsesButton.setBounds (row.removeFromLeft (w)); + providerAnthropicButton.setBounds (row.removeFromLeft (w)); + providerGeminiButton.setBounds (row); + } area.removeFromTop (10); - auto settings = area.removeFromTop (58); - auto columnWidth = (settings.getWidth() - 12) / 2; + constexpr int columnGap = 12; + constexpr int labelH = 20; + constexpr int editorH = 28; + constexpr int rowH = labelH + 4 + editorH; - auto modelColumn = settings.removeFromLeft (columnWidth); - settings.removeFromLeft (12); - auto baseUrlColumn = settings; + // Row 1: Model (left) | Base URL (right) + { + auto row = area.removeFromTop (rowH); + auto left = row.removeFromLeft ((row.getWidth() - columnGap) / 2); + row.removeFromLeft (columnGap); - modelLabel.setBounds (modelColumn.removeFromTop (22)); - modelEditor.setBounds (modelColumn.removeFromTop (30)); + modelLabel.setBounds (left.removeFromTop (labelH)); + left.removeFromTop (4); + modelEditor.setBounds (left); - baseUrlLabel.setBounds (baseUrlColumn.removeFromTop (22)); - baseUrlEditor.setBounds (baseUrlColumn.removeFromTop (30)); + baseUrlLabel.setBounds (row.removeFromTop (labelH)); + row.removeFromTop (4); + baseUrlEditor.setBounds (row); + } + area.removeFromTop (8); - area.removeFromTop (14); + // Row 2: API Key (left) | Reasoning effort (right) + { + auto row = area.removeFromTop (rowH); + auto left = row.removeFromLeft ((row.getWidth() - columnGap) / 2); + row.removeFromLeft (columnGap); - promptLabel.setBounds (area.removeFromTop (22)); - promptEditor.setBounds (area.removeFromTop (120)); + apiKeyLabel.setBounds (left.removeFromTop (labelH)); + left.removeFromTop (4); + apiKeyEditor.setBounds (left); - area.removeFromTop (12); + reasoningLabel.setBounds (row.removeFromTop (labelH)); + row.removeFromTop (4); + reasoningEditor.setBounds (row); + } + area.removeFromTop (14); - auto actionRow = area.removeFromTop (34); - askButton.setBounds (actionRow.removeFromLeft (96)); - actionRow.removeFromLeft (12); - toolsToggle.setBounds (actionRow.removeFromLeft (86)); - actionRow.removeFromLeft (12); - statusLabel.setBounds (actionRow); + // Prompt + promptLabel.setBounds (area.removeFromTop (labelH)); + area.removeFromTop (4); + promptEditor.setBounds (area.removeFromTop (90)); + area.removeFromTop (12); - area.removeFromTop (18); + // Action row + { + auto row = area.removeFromTop (30); + askButton.setBounds (row.removeFromLeft (80)); + row.removeFromLeft (10); + toolsToggle.setBounds (row.removeFromLeft (80)); + row.removeFromLeft (10); + statusLabel.setBounds (row); + } + area.removeFromTop (14); - responseLabel.setBounds (area.removeFromTop (22)); + // Response + responseLabel.setBounds (area.removeFromTop (labelH)); + area.removeFromTop (4); 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.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); + g.strokeLine (20.0f, 56.0f, getWidth() - 20.0f, 56.0f); // below title + g.strokeLine (20.0f, 96.0f, getWidth() - 20.0f, 96.0f); // below provider row } private: - class OllamaRequestThread final : public yup::Thread + //========================================================================== + enum class SelectedProvider + { + OpenAIChat, + OpenAIResponses, + Anthropic, + Gemini + }; + SelectedProvider currentProvider = SelectedProvider::OpenAIChat; + + //========================================================================== + // Provider selection — updates button labels, defaults, and enabled states. + void selectProvider (SelectedProvider p) + { + currentProvider = p; + + // Use a bullet marker on the active button text. + providerOpenAIChatButton.setButtonText (p == SelectedProvider::OpenAIChat ? "• OpenAI Chat" : "OpenAI Chat"); + providerOpenAIResponsesButton.setButtonText (p == SelectedProvider::OpenAIResponses ? "• OpenAI Responses" : "OpenAI Responses"); + providerAnthropicButton.setButtonText (p == SelectedProvider::Anthropic ? "• Anthropic" : "Anthropic"); + providerGeminiButton.setButtonText (p == SelectedProvider::Gemini ? "• Gemini" : "Gemini"); + + // Apply per-provider defaults (model + base URL). + switch (p) + { + case SelectedProvider::OpenAIChat: + modelEditor.setText ("gemma4", yup::dontSendNotification); + baseUrlEditor.setText ("http://localhost:11434/v1", yup::dontSendNotification); + statusLabel.setText ("OpenAI Chat / Ollama - supports tools, streaming, and structured output.", yup::dontSendNotification); + break; + + case SelectedProvider::OpenAIResponses: + modelEditor.setText ("gpt-4.1", yup::dontSendNotification); + baseUrlEditor.setText ("https://api.openai.com/v1", yup::dontSendNotification); + statusLabel.setText ("OpenAI Responses API - supports reasoning effort and structured output.", yup::dontSendNotification); + break; + + case SelectedProvider::Anthropic: + modelEditor.setText ("claude-opus-4-5", yup::dontSendNotification); + baseUrlEditor.setText ("https://api.anthropic.com/v1", yup::dontSendNotification); + statusLabel.setText ("Anthropic Claude - requires an API key. Prompt cached automatically.", yup::dontSendNotification); + break; + + case SelectedProvider::Gemini: + modelEditor.setText ("gemini-2.5-flash", yup::dontSendNotification); + baseUrlEditor.setText ("https://generativelanguage.googleapis.com", yup::dontSendNotification); + statusLabel.setText ("Google Gemini - supports tools and thinking budget via Reasoning field.", yup::dontSendNotification); + break; + } + + // Tools are supported by OpenAI Chat and Gemini. + const bool supportsTools = (p == SelectedProvider::OpenAIChat || p == SelectedProvider::Gemini); + toolsToggle.setEnabled (supportsTools); + if (! supportsTools) + toolsToggle.setToggleState (false, yup::dontSendNotification); + + // Reasoning is meaningful for OpenAI Responses and Gemini. + reasoningEditor.setEnabled (p == SelectedProvider::OpenAIResponses || p == SelectedProvider::Gemini); + if (p == SelectedProvider::OpenAIChat || p == SelectedProvider::Anthropic) + reasoningEditor.setText ("", yup::dontSendNotification); + } + + //========================================================================== + class AiRequestThread final : public yup::Thread { public: - OllamaRequestThread (AiDemo& ownerToUse, yup::String modelToUse, yup::String baseUrlToUse, yup::String promptToUse, bool useToolsToUse) - : Thread ("OllamaRequest") + AiRequestThread (AiDemo& ownerToUse, + yup::LLMClient::Options optionsToUse, + yup::String promptToUse, + bool useToolsToUse) + : Thread ("AiRequest") , owner (ownerToUse) - , model (std::move (modelToUse)) - , baseUrl (std::move (baseUrlToUse)) + , clientOptions (std::move (optionsToUse)) , prompt (std::move (promptToUse)) , useTools (useToolsToUse) , ownerReference (&ownerToUse) @@ -157,13 +326,12 @@ class AiDemo : public yup::Component 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)); + auto client = yup::LLMClientFactory::create (clientOptions); + if (client == nullptr) + { + reportResult ("Error: unknown provider."); + return; + } yup::LLMClient::Request request; request.messages.push_back (yup::LLMMessage::user (prompt)); @@ -172,51 +340,57 @@ class AiDemo : public yup::Component 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."; + 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); + auto response = client->runToolLoop (request, toolRegistry); yup::String responseText; if (response.failed() && response.errorMessage.has_value()) - responseText = "Ollama error: " + *response.errorMessage; + responseText = "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."; + responseText = "No response returned. Check your connection, model name, and API key."; + reportResult (responseText); + } + + private: + void reportResult (const yup::String& result) + { if (threadShouldExit()) return; auto ownerPtr = std::addressof (owner); auto weakOwner = ownerReference; - yup::MessageManager::callAsync ([ownerPtr, weakOwner, responseText] + yup::MessageManager::callAsync ([ownerPtr, weakOwner, result] { if (weakOwner.get() == nullptr) return; - ownerPtr->handleResponse (responseText); + ownerPtr->handleResponse (result); }); } - private: AiDemo& owner; - yup::String model; - yup::String baseUrl; + yup::LLMClient::Options clientOptions; yup::String prompt; bool useTools; yup::WeakReference ownerReference; }; + //========================================================================== void askModel() { if (requestThread != nullptr && requestThread->isThreadRunning()) @@ -229,8 +403,10 @@ class AiDemo : public yup::Component const auto model = modelEditor.getText().trim(); const auto baseUrl = baseUrlEditor.getText().trim(); + const auto apiKey = apiKeyEditor.getText().trim(); + const auto reasoning = reasoningEditor.getText().trim(); const auto prompt = promptEditor.getText().trim(); - const auto useTools = toolsToggle.getToggleState(); + const auto useTools = toolsToggle.getToggleState() && toolsToggle.isEnabled(); if (model.isEmpty() || baseUrl.isEmpty() || prompt.isEmpty()) { @@ -238,16 +414,44 @@ class AiDemo : public yup::Component return; } + yup::LLMClient::Options options; + options.model = model; + options.baseUrl = baseUrl; + options.apiKey = apiKey; + options.timeoutMs = 120000; + options.maxRetries = 0; + options.reasoningEffort = reasoning; + + switch (currentProvider) + { + case SelectedProvider::OpenAIChat: + options.provider = yup::LLMClient::Provider::OpenAIChat; + break; + + case SelectedProvider::OpenAIResponses: + options.provider = yup::LLMClient::Provider::OpenAIResponses; + options.noTemperature = true; // Responses API does not accept temperature + break; + + case SelectedProvider::Anthropic: + options.provider = yup::LLMClient::Provider::Anthropic; + break; + + case SelectedProvider::Gemini: + options.provider = yup::LLMClient::Provider::Gemini; + break; + } + askButton.setEnabled (false); - statusLabel.setText ("Waiting for Ollama...", yup::dontSendNotification); + statusLabel.setText ("Waiting for response...", yup::dontSendNotification); responseEditor.setText ("", yup::dontSendNotification); - requestThread = std::make_unique (*this, model, baseUrl, prompt, useTools); + requestThread = std::make_unique (*this, std::move (options), 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); + statusLabel.setText ("Unable to start request thread.", yup::dontSendNotification); askButton.setEnabled (true); } } @@ -255,29 +459,36 @@ class AiDemo : public yup::Component void handleResponse (const yup::String& responseText) { responseEditor.setText (responseText, yup::dontSendNotification); - statusLabel.setText ("Complete", yup::dontSendNotification); + statusLabel.setText ("Complete.", yup::dontSendNotification); askButton.setEnabled (true); + + // Re-enable tools toggle for providers that support tools. + toolsToggle.setEnabled (currentProvider == SelectedProvider::OpenAIChat + || currentProvider == SelectedProvider::Gemini); } - void registerTools (yup::LLMToolRegistry& registry, yup::WeakReference ownerReference) + 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)); + yup::LLMTool::Parameter colorParam; + colorParam.name = "color"; + colorParam.type = "string"; + colorParam.description = "CSS color name, #RRGGBB, rgb(...), rgba(...), hsl(...), or hsla(...) value."; + colorParam.required = true; + colorTool.parameters.push_back (std::move (colorParam)); - auto ownerPtr = this; + 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") + const auto colorValue = colorText.startsWithChar ('#') + || colorText.startsWithIgnoreCase ("rgb") + || colorText.startsWithIgnoreCase ("hsl") ? colorText : colorText.removeCharacters (" "); const auto color = yup::Color::fromString (colorValue); @@ -291,11 +502,11 @@ class AiDemo : public yup::Component }); auto result = yup::var (std::make_unique()); - if (auto* object = result.getDynamicObject()) + if (auto* obj = result.getDynamicObject()) { - object->setProperty ("success", true); - object->setProperty ("color", colorValue); - object->setProperty ("message", "Background color updated."); + obj->setProperty ("success", true); + obj->setProperty ("color", colorValue); + obj->setProperty ("message", yup::String ("Background color updated.")); } return result; @@ -310,21 +521,44 @@ class AiDemo : public yup::Component repaint(); } + //========================================================================== + // Title 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::Font titleFont; + + // Provider selector + yup::TextButton providerOpenAIChatButton { "providerOpenAIChatButton" }; + yup::TextButton providerOpenAIResponsesButton { "providerOpenAIResponsesButton" }; + yup::TextButton providerAnthropicButton { "providerAnthropicButton" }; + yup::TextButton providerGeminiButton { "providerGeminiButton" }; + // Settings fields + yup::Label modelLabel { "modelLabel" }; yup::TextEditor modelEditor { "modelEditor" }; + + yup::Label baseUrlLabel { "baseUrlLabel" }; yup::TextEditor baseUrlEditor { "baseUrlEditor" }; + + yup::Label apiKeyLabel { "apiKeyLabel" }; + yup::TextEditor apiKeyEditor { "apiKeyEditor" }; + + yup::Label reasoningLabel { "reasoningLabel" }; + yup::TextEditor reasoningEditor { "reasoningEditor" }; + + // Prompt + yup::Label promptLabel { "promptLabel" }; yup::TextEditor promptEditor { "promptEditor" }; - yup::TextEditor responseEditor { "responseEditor" }; + + // Action row yup::TextButton askButton { "askButton" }; yup::ToggleButton toolsToggle { "toolsToggle" }; + yup::Label statusLabel { "statusLabel" }; - yup::Font titleFont; + // Response + yup::Label responseLabel { "responseLabel" }; + yup::TextEditor responseEditor { "responseEditor" }; + + // State std::optional backgroundColor; - std::unique_ptr requestThread; + std::unique_ptr requestThread; }; diff --git a/modules/yup_ai/llm/yup_LLMClient.cpp b/modules/yup_ai/llm/yup_LLMClient.cpp index 4a5f9c093..645ed764f 100644 --- a/modules/yup_ai/llm/yup_LLMClient.cpp +++ b/modules/yup_ai/llm/yup_LLMClient.cpp @@ -91,7 +91,10 @@ LLMResponse LLMClient::runToolLoop (const Request& request, LLMToolRegistry& too 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))); + + auto toolResultMsg = LLMMessage::toolResult (toolCall.id, JSON::toString (result, true)); + toolResultMsg.name = toolCall.name; // preserved for providers that need name + id separately (e.g. Gemini) + current.messages.push_back (std::move (toolResultMsg)); } response = complete (current); @@ -124,14 +127,19 @@ String LLMClient::buildChatCompletionBody (const Request& request, bool stream) 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 (! options.noTemperature) + { + 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); + // Per-request maxTokens overrides options.maxTokens; use max_completion_tokens for OpenAI-compatible APIs. + const int effectiveMaxTokens = request.maxTokens.value_or (options.maxTokens); + if (effectiveMaxTokens > 0) + setLLMClientProperty (object, "max_completion_tokens", effectiveMaxTokens); if (request.stopSequences.has_value()) { @@ -143,6 +151,40 @@ String LLMClient::buildChatCompletionBody (const Request& request, bool stream) setLLMClientProperty (object, "stop", stop); } + // Reasoning effort for o-series / GPT-5 models. + if (options.reasoningEffort.isNotEmpty()) + setLLMClientProperty (object, "reasoning_effort", options.reasoningEffort); + + // GBNF grammar for llama-server constrained decoding (per-request overrides config). + const auto& effectiveGrammar = request.grammar.isNotEmpty() ? request.grammar : options.grammar; + if (effectiveGrammar.isNotEmpty()) + setLLMClientProperty (object, "grammar", effectiveGrammar); + + // Prompt caching — bucket by application identity, retain for 24h. + if (options.userAgent.isNotEmpty()) + { + setLLMClientProperty (object, "prompt_cache_key", options.userAgent); + setLLMClientProperty (object, "prompt_cache_retention", String ("24h")); + } + + // Structured output via JSON Schema (built with LLMSchema helpers). + if (! request.schema.isVoid()) + { + auto schemaWrapper = makeLLMClientObject(); + setLLMClientProperty (schemaWrapper, "name", String ("response")); + setLLMClientProperty (schemaWrapper, "strict", true); + setLLMClientProperty (schemaWrapper, "schema", request.schema); + + auto responseFormat = makeLLMClientObject(); + setLLMClientProperty (responseFormat, "type", String ("json_schema")); + setLLMClientProperty (responseFormat, "json_schema", schemaWrapper); + + setLLMClientProperty (object, "response_format", responseFormat); + } + + // OpenRouter — application identification headers are injected at HTTP level, + // but some frontends read X-Title from the body; we skip that here. + return JSON::toString (object, true); } diff --git a/modules/yup_ai/llm/yup_LLMClient.h b/modules/yup_ai/llm/yup_LLMClient.h index f2a306d07..930b83047 100644 --- a/modules/yup_ai/llm/yup_LLMClient.h +++ b/modules/yup_ai/llm/yup_LLMClient.h @@ -30,26 +30,49 @@ namespace yup class YUP_API LLMClient { public: + /** The LLM provider type, used by LLMClientFactory to create the correct client. */ + enum class Provider + { + OpenAIChat, ///< OpenAI Chat Completions — also works with DeepSeek, OpenRouter, Ollama, llama-server. + OpenAIResponses, ///< OpenAI Responses API (GPT-5+). + Anthropic, ///< Anthropic Messages API — Claude models. + Gemini ///< Google Gemini generateContent API. + }; + struct Request { std::vector messages; std::optional systemPrompt; std::vector tools; - std::optional toolChoice; + std::optional toolChoice; ///< "auto", "none", "required", or a specific function name. std::optional temperature; std::optional topP; - std::optional maxTokens; + std::optional maxTokens; ///< Per-request override; falls back to Options::maxTokens. std::optional> stopSequences; + + var schema; ///< Optional JSON Schema for structured output (built with LLMSchema). + String grammar; ///< Optional per-request GBNF (llama-server) or Lark (OpenAI Responses) grammar. + String grammarToolName; ///< Tool name for grammar-constrained output (OpenAI Responses API only). + String grammarToolDescription; ///< Tool description for grammar output; defaults to system prompt if empty. }; struct Options { + Provider provider = Provider::OpenAIChat; ///< LLM backend provider — used by LLMClientFactory. + String model; String baseUrl = "http://localhost:11434/v1"; String apiKey; int timeoutMs = 120000; int maxRetries = 2; + int maxTokens = 0; ///< Default max output tokens (0 = provider default); per-request value overrides. + + String reasoningEffort; ///< "none", "low", "medium", "high" — for OpenAI o-series and Gemini 2.5 models. + String grammar; ///< Default GBNF grammar for llama-server constrained decoding (per-request overrides). + bool noTemperature = false; ///< Set true for models that reject the temperature parameter (e.g. GPT-5 series). + String userAgent; ///< Application identifier used for User-Agent header and prompt cache key. + String appUrl; ///< Application URL sent as HTTP-Referer on OpenRouter requests. }; explicit LLMClient (Options options); diff --git a/modules/yup_ai/llm/yup_LLMClientFactory.cpp b/modules/yup_ai/llm/yup_LLMClientFactory.cpp new file mode 100644 index 000000000..c5da032bb --- /dev/null +++ b/modules/yup_ai/llm/yup_LLMClientFactory.cpp @@ -0,0 +1,96 @@ +/* + ============================================================================== + + 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 +{ + +std::unique_ptr LLMClientFactory::create (LLMClient::Options options) +{ + switch (options.provider) + { + case LLMClient::Provider::OpenAIChat: + return std::make_unique (std::move (options)); + + case LLMClient::Provider::OpenAIResponses: + return std::make_unique (std::move (options)); + + case LLMClient::Provider::Anthropic: + return std::make_unique (std::move (options)); + + case LLMClient::Provider::Gemini: + return std::make_unique (std::move (options)); + + default: + jassertfalse; // Unknown provider + return nullptr; + } +} + +//============================================================================== +std::unique_ptr LLMClientFactory::openAIChat (String model, + String baseUrl, + String apiKey) +{ + LLMClient::Options opts; + opts.provider = LLMClient::Provider::OpenAIChat; + opts.model = std::move (model); + opts.baseUrl = std::move (baseUrl); + opts.apiKey = std::move (apiKey); + return create (std::move (opts)); +} + +std::unique_ptr LLMClientFactory::openAIResponses (String model, + String apiKey, + String baseUrl) +{ + LLMClient::Options opts; + opts.provider = LLMClient::Provider::OpenAIResponses; + opts.model = std::move (model); + opts.apiKey = std::move (apiKey); + opts.baseUrl = std::move (baseUrl); + return create (std::move (opts)); +} + +std::unique_ptr LLMClientFactory::anthropic (String model, + String apiKey, + String baseUrl) +{ + LLMClient::Options opts; + opts.provider = LLMClient::Provider::Anthropic; + opts.model = std::move (model); + opts.apiKey = std::move (apiKey); + opts.baseUrl = std::move (baseUrl); + return create (std::move (opts)); +} + +std::unique_ptr LLMClientFactory::gemini (String model, + String apiKey, + String baseUrl) +{ + LLMClient::Options opts; + opts.provider = LLMClient::Provider::Gemini; + opts.model = std::move (model); + opts.apiKey = std::move (apiKey); + opts.baseUrl = std::move (baseUrl); + return create (std::move (opts)); +} + +} // namespace yup diff --git a/modules/yup_ai/llm/yup_LLMClientFactory.h b/modules/yup_ai/llm/yup_LLMClientFactory.h new file mode 100644 index 000000000..db2c26fd7 --- /dev/null +++ b/modules/yup_ai/llm/yup_LLMClientFactory.h @@ -0,0 +1,79 @@ +/* + ============================================================================== + + 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 +{ + +//============================================================================== +/** Factory that creates the correct LLMHttpClient subclass from an Options struct. + + Use LLMClientFactory::create() to instantiate an LLM client for any supported + provider. The Provider enum in LLMClient::Options selects the concrete class. + + @code + yup::LLMClient::Options opts; + opts.provider = yup::LLMClient::Provider::Anthropic; + opts.model = "claude-opus-4-5"; + opts.apiKey = "sk-ant-..."; + opts.baseUrl = "https://api.anthropic.com/v1"; + + auto client = yup::LLMClientFactory::create (opts); + auto response = client->chat ("Hello, Claude!"); + @endcode + + Convenience static methods are provided for the most common provider setups. + + @tags{AI} +*/ +class YUP_API LLMClientFactory +{ +public: + /** Creates an LLM client for the provider specified in @p options. + + @param options Full options struct. options.provider selects the concrete class. + @returns A heap-allocated concrete LLMHttpClient subclass, or nullptr if + the provider enum value is unrecognised. + */ + static std::unique_ptr create (LLMClient::Options options); + + //============================================================================== + /** Convenience factory — OpenAI Chat Completions (also Ollama, DeepSeek, OpenRouter, llama-server). */ + static std::unique_ptr openAIChat (String model, + String baseUrl = "http://localhost:11434/v1", + String apiKey = {}); + + /** Convenience factory — OpenAI Responses API (GPT-5+, reasoning models). */ + static std::unique_ptr openAIResponses (String model, + String apiKey, + String baseUrl = "https://api.openai.com/v1"); + + /** Convenience factory — Anthropic Messages API (Claude models). */ + static std::unique_ptr anthropic (String model, + String apiKey, + String baseUrl = "https://api.anthropic.com/v1"); + + /** Convenience factory — Google Gemini generateContent API. */ + static std::unique_ptr gemini (String model, + String apiKey, + String baseUrl = "https://generativelanguage.googleapis.com"); +}; + +} // namespace yup diff --git a/modules/yup_ai/llm/yup_LLMHttpClient.cpp b/modules/yup_ai/llm/yup_LLMHttpClient.cpp index 6ec8bc553..1947dbc87 100644 --- a/modules/yup_ai/llm/yup_LLMHttpClient.cpp +++ b/modules/yup_ai/llm/yup_LLMHttpClient.cpp @@ -23,47 +23,10 @@ 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 @@ -75,15 +38,16 @@ struct LLMHttpClient::Pimpl LLMResponse complete (const Request& request) { - const auto body = owner.buildChatCompletionBody (request, false); - const auto endpoint = makeAiEndpointUrl (owner.options.baseUrl, "/chat/completions"); + const auto body = owner.buildPayload (request); + const auto endpoint = owner.getEndpointUrl(); + const auto headers = owner.buildHeaders(); 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)) + .withExtraHeaders (headers) .withConnectionTimeoutMs (owner.options.timeoutMs) .withStatusCode (&statusCode) .withHttpRequestCmd ("POST"); @@ -92,29 +56,51 @@ struct LLMHttpClient::Pimpl const auto responseBody = stream != nullptr ? stream->readEntireStreamAsString() : String(); if (stream != nullptr && statusCode >= 200 && statusCode < 300) - return LLMResponse::fromOpenAiJson (JSON::parse (responseBody)); + return owner.parseResponse (JSON::parse (responseBody)); - if (! shouldRetryAiStatus (statusCode) || attempt == owner.options.maxRetries) - return makeHttpErrorResponse (statusCode, responseBody); + // Build a meaningful error from the body before deciding whether to retry. + LLMResponse errorResponse; + if (responseBody.isNotEmpty()) + { + auto parsed = JSON::parse (responseBody); + if (! parsed.isVoid()) + errorResponse = owner.parseResponse (parsed); + } + + if (errorResponse.failed()) + { + if (! shouldRetryAiStatus (statusCode) || attempt == owner.options.maxRetries) + return errorResponse; + } + else + { + const auto msg = statusCode > 0 + ? "AI HTTP request failed with status " + String (statusCode) + : "AI HTTP request failed"; + + if (! shouldRetryAiStatus (statusCode) || attempt == owner.options.maxRetries) + return LLMResponse::fromError (msg); + } } return LLMResponse::fromError ("AI HTTP request failed after retries"); } - bool completeStreaming (const Request& request, ChunkCallback onChunk) + bool completeStreaming (const Request& request, LLMHttpClient::ChunkCallback onChunk) { if (! onChunk) return false; - const auto body = owner.buildChatCompletionBody (request, true); - const auto endpoint = makeAiEndpointUrl (owner.options.baseUrl, "/chat/completions"); + const auto body = owner.buildStreamingPayload (request); + const auto endpoint = owner.getStreamingEndpointUrl(); + const auto headers = owner.buildHeaders(); 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)) + .withExtraHeaders (headers) .withConnectionTimeoutMs (owner.options.timeoutMs) .withStatusCode (&statusCode) .withHttpRequestCmd ("POST"); @@ -137,7 +123,7 @@ struct LLMHttpClient::Pimpl return true; auto parsed = JSON::parse (payload); - auto chunk = LLMResponse::fromStreamChunk (parsed); + auto chunk = owner.parseChunk (parsed); accumulatedResponse.appendStreamChunk (chunk); onChunk (accumulatedResponse); @@ -159,6 +145,7 @@ struct LLMHttpClient::Pimpl LLMHttpClient& owner; }; +//============================================================================== LLMHttpClient::LLMHttpClient (Options options) : LLMClient (std::move (options)) , pimpl (std::make_unique (*this)) @@ -177,4 +164,26 @@ bool LLMHttpClient::completeStreaming (const Request& request, ChunkCallback onC return pimpl->completeStreaming (request, std::move (onChunk)); } +//============================================================================== +String LLMHttpClient::makeProviderUrl (const String& baseUrl, const String& path) +{ + return baseUrl.endsWithChar ('/') ? baseUrl.dropLastCharacters (1) + path + : baseUrl + path; +} + +String LLMHttpClient::getStreamingEndpointUrl() const +{ + return getEndpointUrl(); +} + +String LLMHttpClient::buildStreamingPayload (const Request& request) const +{ + return buildPayload (request); +} + +LLMResponse LLMHttpClient::parseChunk (const var& /*json*/) const +{ + return LLMResponse {}; +} + } // namespace yup diff --git a/modules/yup_ai/llm/yup_LLMHttpClient.h b/modules/yup_ai/llm/yup_LLMHttpClient.h index 3073b2ebe..4915254cf 100644 --- a/modules/yup_ai/llm/yup_LLMHttpClient.h +++ b/modules/yup_ai/llm/yup_LLMHttpClient.h @@ -23,7 +23,25 @@ namespace yup { //============================================================================== -/** OpenAI-compatible HTTP chat completion client. +/** Abstract HTTP transport base for LLM provider clients. + + Provides the concrete HTTP POST + SSE streaming mechanics. Subclasses + supply the provider-specific pieces by overriding the pure virtual methods: + + - getEndpointUrl() — full URL for non-streaming requests + - buildHeaders() — raw header string (key: value\\r\\n…) + - buildPayload() — JSON request body (non-streaming) + - parseResponse() — parse full JSON response → LLMResponse + + Three more methods have working defaults and may be overridden when the + streaming request differs from the non-streaming one: + + - getStreamingEndpointUrl() → getEndpointUrl() + - buildStreamingPayload() → buildPayload(request) + - parseChunk() → LLMResponse{} (empty / no-op chunk) + + Use LLMClientFactory::create() to obtain the correct concrete subclass + for a given Provider enum value. @tags{AI} */ @@ -36,6 +54,45 @@ class YUP_API LLMHttpClient : public LLMClient LLMResponse complete (const Request& request) override; bool completeStreaming (const Request& request, ChunkCallback onChunk) override; +protected: + //============================================================================== + // Pure virtual — implement in every provider subclass. + + /** Returns the endpoint URL for non-streaming requests. */ + virtual String getEndpointUrl() const = 0; + + /** Returns the raw HTTP header string ("Key: Value\\r\\n" pairs). */ + virtual String buildHeaders() const = 0; + + /** Builds the JSON request body for a non-streaming request. */ + virtual String buildPayload (const Request& request) const = 0; + + /** Parses a complete JSON response into an LLMResponse. */ + virtual LLMResponse parseResponse (const var& json) const = 0; + + //============================================================================== + // Virtual with sensible defaults — override when streaming differs. + + /** Returns the endpoint URL for streaming requests. + Default: same as getEndpointUrl(). + */ + virtual String getStreamingEndpointUrl() const; + + /** Builds the JSON request body for a streaming request. + Default: buildPayload(request) — override to add stream flags or pick a + different endpoint body (e.g. OpenAI Chat adds "stream":true here). + */ + virtual String buildStreamingPayload (const Request& request) const; + + /** Parses a single SSE data-line JSON object into a delta LLMResponse. + Default: returns LLMResponse{} (empty chunk, safe no-op in accumulation). + */ + virtual LLMResponse parseChunk (const var& json) const; + + //============================================================================== + /** Normalises baseUrl + path, stripping a trailing slash from the base. */ + static String makeProviderUrl (const String& baseUrl, const String& path); + private: struct Pimpl; std::unique_ptr pimpl; diff --git a/modules/yup_ai/llm/yup_LLMSchema.h b/modules/yup_ai/llm/yup_LLMSchema.h new file mode 100644 index 000000000..52a2946d6 --- /dev/null +++ b/modules/yup_ai/llm/yup_LLMSchema.h @@ -0,0 +1,154 @@ +/* + ============================================================================== + + 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 +{ + +//============================================================================== +/** Fluent helpers for building JSON Schema objects used in LLMClient::Request::schema. + + Pass the result of these helpers to LLMClient::Request::schema to request + structured (JSON) output from the LLM. All major providers (OpenAI Chat, + OpenAI Responses, Anthropic, Gemini) accept a JSON Schema for their + respective structured-output mechanisms. + + @code + yup::LLMClient::Request request; + request.messages.push_back (yup::LLMMessage::user ("Extract the key facts.")); + request.schema = yup::LLMSchema::object ({ + { "title", yup::LLMSchema::string() }, + { "summary", yup::LLMSchema::string() }, + { "year", yup::LLMSchema::integer() }, + }); + auto response = client.complete (request); + @endcode + + @tags{AI} +*/ +class YUP_API LLMSchema +{ +public: + /** Returns a JSON Schema node of type "string". */ + static var string() + { + auto obj = makeObj(); + setProperty (obj, "type", String ("string")); + return obj; + } + + /** Returns a JSON Schema node of type "number" (floating-point). */ + static var number() + { + auto obj = makeObj(); + setProperty (obj, "type", String ("number")); + return obj; + } + + /** Returns a JSON Schema node of type "integer". */ + static var integer() + { + auto obj = makeObj(); + setProperty (obj, "type", String ("integer")); + return obj; + } + + /** Returns a JSON Schema node of type "boolean". */ + static var boolean() + { + auto obj = makeObj(); + setProperty (obj, "type", String ("boolean")); + return obj; + } + + /** Returns a JSON Schema array node whose items conform to @p itemSchema. */ + static var array (const var& itemSchema) + { + auto obj = makeObj(); + setProperty (obj, "type", String ("array")); + setProperty (obj, "items", itemSchema); + return obj; + } + + /** Returns a JSON Schema object node with the given named field schemas. + + All listed fields are marked as required and additionalProperties is + set to false, which is required for strict mode on OpenAI. + + @code + auto schema = yup::LLMSchema::object ({ + { "name", yup::LLMSchema::string() }, + { "score", yup::LLMSchema::number() }, + }); + @endcode + */ + static var object (std::initializer_list> fields) + { + auto properties = makeObj(); + var requiredArray; + + for (const auto& [name, fieldSchema] : fields) + { + setProperty (properties, name, fieldSchema); + requiredArray.append (name); + } + + auto obj = makeObj(); + setProperty (obj, "type", String ("object")); + setProperty (obj, "properties", properties); + setProperty (obj, "required", requiredArray); + setProperty (obj, "additionalProperties", false); + return obj; + } + + /** Returns a JSON Schema string node restricted to one of the given @p values. */ + static var oneOf (std::initializer_list values) + { + var enumArray; + + for (const auto& v : values) + enumArray.append (v); + + auto obj = makeObj(); + setProperty (obj, "type", String ("string")); + setProperty (obj, "enum", enumArray); + return obj; + } + + /** Serialises a schema node to a compact JSON string. */ + static String toJsonString (const var& schema) + { + return JSON::toString (schema, true); + } + +private: + static var makeObj() + { + return var (std::make_unique()); + } + + static void setProperty (var& object, const Identifier& name, const var& value) + { + if (auto* obj = object.getDynamicObject()) + obj->setProperty (name, value); + } +}; + +} // namespace yup diff --git a/modules/yup_ai/providers/yup_LLMAnthropicClient.cpp b/modules/yup_ai/providers/yup_LLMAnthropicClient.cpp new file mode 100644 index 000000000..7bc139944 --- /dev/null +++ b/modules/yup_ai/providers/yup_LLMAnthropicClient.cpp @@ -0,0 +1,185 @@ +/* + ============================================================================== + + 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 +{ + +LLMAnthropicClient::LLMAnthropicClient (Options options) + : LLMHttpClient (std::move (options)) +{ +} + +LLMAnthropicClient::~LLMAnthropicClient() = default; + +//============================================================================== +String LLMAnthropicClient::getEndpointUrl() const +{ + return makeProviderUrl (options.baseUrl, "/messages"); +} + +String LLMAnthropicClient::buildHeaders() const +{ + String headers = "Content-Type: application/json\r\nAccept: application/json\r\n"; + + if (options.apiKey.isNotEmpty()) + headers += "x-api-key: " + options.apiKey + "\r\n"; + + headers += "anthropic-version: 2023-06-01\r\n"; + + if (options.userAgent.isNotEmpty()) + headers += "User-Agent: " + options.userAgent + "\r\n"; + + return headers; +} + +String LLMAnthropicClient::buildPayload (const Request& request) const +{ + // Build user messages array (Anthropic excludes system prompt from messages[]). + var messagesArray; + + for (const auto& message : request.messages) + { + switch (message.role) + { + case LLMMessage::Role::user: + case LLMMessage::Role::assistant: + messagesArray.append (message.toVar()); + break; + + default: + break; // system messages go in the top-level "system" field + } + } + + auto payload = var (std::make_unique()); + auto* payloadObj = payload.getDynamicObject(); + + payloadObj->setProperty ("model", options.model); + + // Anthropic always requires max_tokens; default to 4096 if unset. + const int effectiveMaxTokens = request.maxTokens.value_or (options.maxTokens > 0 ? options.maxTokens : 4096); + payloadObj->setProperty ("max_tokens", effectiveMaxTokens); + + payloadObj->setProperty ("temperature", static_cast (request.temperature.value_or (0.1f))); + payloadObj->setProperty ("messages", messagesArray); + + // System prompt with ephemeral cache control (cached for the session lifetime). + const auto& systemText = request.systemPrompt.has_value() ? *request.systemPrompt : String(); + if (systemText.isNotEmpty()) + { + auto cacheControl = var (std::make_unique()); + cacheControl.getDynamicObject()->setProperty ("type", String ("ephemeral")); + + auto sysBlock = var (std::make_unique()); + sysBlock.getDynamicObject()->setProperty ("type", String ("text")); + sysBlock.getDynamicObject()->setProperty ("text", systemText); + sysBlock.getDynamicObject()->setProperty ("cache_control", cacheControl); + + var systemArray; + systemArray.append (sysBlock); + payloadObj->setProperty ("system", systemArray); + } + + // Application identification for usage tracking. + if (options.userAgent.isNotEmpty()) + { + auto metadata = var (std::make_unique()); + metadata.getDynamicObject()->setProperty ("user_id", options.userAgent); + payloadObj->setProperty ("metadata", metadata); + } + + // NOTE: Anthropic does not support an `effort` / `reasoning_effort` field in the + // Messages API — that is an OpenAI-ism. Extended thinking uses a separate + // `thinking` block on models that support it, which is not yet implemented here. + + return JSON::toString (payload, true); +} + +LLMResponse LLMAnthropicClient::parseResponse (const var& json) const +{ + if (json.isVoid()) + return LLMResponse::fromError ("Unable to parse Anthropic response JSON"); + + // Anthropic wraps errors in an "error" object with a "message" field. + if (json["error"].isObject()) + { + auto message = json["error"]["message"].toString(); + return LLMResponse::fromError (message.isNotEmpty() ? message : "Unknown Anthropic API error"); + } + + LLMResponse response; + response.model = json["model"].toString(); + + if (auto* contentArray = json["content"].getArray()) + { + if (! contentArray->isEmpty()) + { + const auto text = (*contentArray)[0]["text"].toString().trim(); + + LLMResponse::Choice choice; + choice.index = 0; + choice.message = LLMMessage::assistant (text); + + const auto stopReason = json["stop_reason"].toString(); + if (stopReason.isNotEmpty()) + choice.finishReason = stopReason; + + response.choices.push_back (std::move (choice)); + } + } + + // Usage: Anthropic uses input_tokens / output_tokens. + if (json["usage"].isObject()) + { + LLMResponse::Usage usage; + usage.promptTokens = static_cast (json["usage"]["input_tokens"]); + usage.completionTokens = static_cast (json["usage"]["output_tokens"]); + usage.totalTokens = usage.promptTokens + usage.completionTokens; + response.usage = usage; + } + + return response; +} + +LLMResponse LLMAnthropicClient::parseChunk (const var& json) const +{ + // Anthropic SSE format: + // data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"token"}} + // data: {"type":"message_delta","usage":{"output_tokens":42}} + // data: {"type":"message_stop"} + + const auto type = json["type"].toString(); + if (type != "content_block_delta") + return LLMResponse {}; // non-content events produce an empty (no-op) chunk + + const auto text = json["delta"]["text"].toString(); + + LLMResponse chunk; + LLMResponse::Choice choice; + choice.index = 0; + choice.message.role = LLMMessage::Role::assistant; + choice.message.content = text; + chunk.choices.push_back (std::move (choice)); + + return chunk; +} + +} // namespace yup diff --git a/modules/yup_ai/providers/yup_LLMAnthropicClient.h b/modules/yup_ai/providers/yup_LLMAnthropicClient.h new file mode 100644 index 000000000..ec6e14c02 --- /dev/null +++ b/modules/yup_ai/providers/yup_LLMAnthropicClient.h @@ -0,0 +1,56 @@ +/* + ============================================================================== + + 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 +{ + +//============================================================================== +/** Anthropic Messages API client — for Claude models. + + Connects to the Anthropic /v1/messages endpoint. Handles ephemeral prompt + caching on the system prompt and translates between the Anthropic JSON + format and the unified LLMResponse type. + + The default base URL is https://api.anthropic.com/v1. The API key is sent + via the x-api-key header (not Bearer). + + Streaming uses Anthropic's SSE format: + @code + data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"…"}} + @endcode + + @tags{AI} +*/ +class YUP_API LLMAnthropicClient : public LLMHttpClient +{ +public: + explicit LLMAnthropicClient (Options options); + ~LLMAnthropicClient() override; + +protected: + String getEndpointUrl() const override; + String buildHeaders() const override; + String buildPayload (const Request& request) const override; + LLMResponse parseResponse (const var& json) const override; + LLMResponse parseChunk (const var& json) const override; +}; + +} // namespace yup diff --git a/modules/yup_ai/providers/yup_LLMGeminiClient.cpp b/modules/yup_ai/providers/yup_LLMGeminiClient.cpp new file mode 100644 index 000000000..2b54b0029 --- /dev/null +++ b/modules/yup_ai/providers/yup_LLMGeminiClient.cpp @@ -0,0 +1,407 @@ +/* + ============================================================================== + + 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 +{ + +//============================================================================== +/** Builds a Gemini functionDeclaration object from an LLMTool. + + The Gemini REST API uses camelCase keys (functionDeclarations, toolConfig …). + The parameters block is reused from LLMTool::toJsonSchema() — the JSON Schema + shape is identical to what OpenAI uses. +*/ +var buildGeminiFunctionDeclaration (const LLMTool& tool) +{ + // toJsonSchema() returns { "type":"function", "function":{ "name","description","parameters" } } + const auto openAiSchema = tool.toJsonSchema(); + + auto funcDecl = var (std::make_unique()); + auto* obj = funcDecl.getDynamicObject(); + obj->setProperty ("name", tool.name); + + if (tool.description.isNotEmpty()) + obj->setProperty ("description", tool.description); + + // The parameters schema is the same format for both providers. + obj->setProperty ("parameters", openAiSchema["function"]["parameters"]); + + return funcDecl; +} + +//============================================================================== +/** Normalises a tool-result content string into a JSON object for + Gemini's functionResponse.response field. + + JSON objects and arrays are passed through directly; scalars and raw strings + are wrapped in { "result": value }. +*/ +var parseToolResultForGemini (const String& content) +{ + const auto parsed = JSON::parse (content); + + if (parsed.isObject() || parsed.isArray()) + return parsed; + + auto wrapper = var (std::make_unique()); + wrapper.getDynamicObject()->setProperty ("result", parsed.isVoid() ? var (content) : parsed); + return wrapper; +} + +//============================================================================== +/** Shared response-parsing logic used by both parseResponse and parseChunk. + + Iterates over candidates/parts. Parts with a "functionCall" key are + converted to LLMToolCall objects (including the call id for parallel-call + correlation). "text" parts are concatenated into the message content. + If any function calls are present they take priority and the choice carries + them in toolCalls (content is left empty). +*/ +LLMResponse geminiCandidatesToResponse (const var& json) +{ + if (json.isVoid()) + return LLMResponse::fromError ("Unable to parse Gemini response JSON"); + + if (json["error"].isObject()) + { + const auto message = json["error"]["message"].toString(); + return LLMResponse::fromError (message.isNotEmpty() ? message : "Unknown Gemini API error"); + } + + LLMResponse response; + + if (auto* candidates = json["candidates"].getArray()) + { + int choiceIndex = 0; + + for (const auto& candidate : *candidates) + { + auto* parts = candidate["content"]["parts"].getArray(); + if (parts == nullptr || parts->isEmpty()) + continue; + + LLMResponse::Choice choice; + choice.index = choiceIndex++; + + String textContent; + std::vector toolCalls; + + for (const auto& part : *parts) + { + if (part.hasProperty ("functionCall")) + { + const auto& fc = part["functionCall"]; + + LLMToolCall toolCall; + toolCall.index = static_cast (toolCalls.size()); + toolCall.name = fc["name"].toString(); + + // Gemini may include a call id for parallel function calling. + // Store it so runToolLoop can round-trip it via toolCallId; fall + // back to the function name when absent (sequential calling). + const auto callId = fc["id"].toString(); + toolCall.id = callId.isNotEmpty() ? callId : toolCall.name; + + toolCall.arguments = fc["args"].isVoid() ? var() : fc["args"]; + toolCalls.push_back (std::move (toolCall)); + } + else if (part.hasProperty ("text")) + { + textContent += part["text"].toString(); + } + } + + if (! toolCalls.empty()) + { + choice.message = LLMMessage::assistant (""); + choice.message.toolCalls = std::move (toolCalls); + } + else + { + choice.message = LLMMessage::assistant (textContent.trim()); + } + + const auto finishReason = candidate["finishReason"].toString(); + if (finishReason.isNotEmpty()) + choice.finishReason = finishReason; + + response.choices.push_back (std::move (choice)); + } + } + + return response; +} + +} // namespace + +//============================================================================== +LLMGeminiClient::LLMGeminiClient (Options options) + : LLMHttpClient (std::move (options)) +{ +} + +LLMGeminiClient::~LLMGeminiClient() = default; + +//============================================================================== +String LLMGeminiClient::getEndpointUrl() const +{ + return options.baseUrl + "/v1beta/models/" + options.model + ":generateContent"; +} + +String LLMGeminiClient::getStreamingEndpointUrl() const +{ + return options.baseUrl + "/v1beta/models/" + options.model + ":streamGenerateContent?alt=sse"; +} + +String LLMGeminiClient::buildHeaders() const +{ + String headers = "Content-Type: application/json\r\nAccept: application/json\r\n"; + headers += "x-goog-api-key: " + options.apiKey + "\r\n"; + + if (options.userAgent.isNotEmpty()) + headers += "User-Agent: " + options.userAgent + "\r\n"; + + return headers; +} + +String LLMGeminiClient::buildPayload (const Request& request) const +{ + // System instruction. + auto sysPart = var (std::make_unique()); + sysPart.getDynamicObject()->setProperty ("text", request.systemPrompt.value_or (String())); + + var sysPartsArray; + sysPartsArray.append (sysPart); + + auto sysInstruction = var (std::make_unique()); + sysInstruction.getDynamicObject()->setProperty ("parts", sysPartsArray); + + // Contents array. + // - system → skipped (goes in system_instruction above). + // - tool → "user" turn with a functionResponse part. + // message.toolCallId holds the Gemini call id (or function name + // as fallback). message.name holds the function name, set by + // the updated runToolLoop. + // - assistant with toolCalls → "model" turn with functionCall parts. + // - user / plain assistant → "user" / "model" turn with a text part. + var contentsArray; + + for (const auto& message : request.messages) + { + if (message.role == LLMMessage::Role::system) + continue; + + // Tool-result message → user turn with functionResponse. + if (message.role == LLMMessage::Role::tool) + { + // name is the function name (set by updated runToolLoop); + // fall back to toolCallId when absent for backward compatibility. + const auto callId = message.toolCallId.value_or (String()); + const auto functionName = message.name.isNotEmpty() ? message.name : callId; + + if (functionName.isEmpty()) + continue; + + auto functionResponse = var (std::make_unique()); + auto* frObj = functionResponse.getDynamicObject(); + + if (callId.isNotEmpty()) + frObj->setProperty ("id", callId); + + frObj->setProperty ("name", functionName); + frObj->setProperty ("response", parseToolResultForGemini (message.content)); + + auto part = var (std::make_unique()); + part.getDynamicObject()->setProperty ("functionResponse", functionResponse); + + var partsArray; + partsArray.append (part); + + auto contentObj = var (std::make_unique()); + contentObj.getDynamicObject()->setProperty ("role", String ("user")); + contentObj.getDynamicObject()->setProperty ("parts", partsArray); + contentsArray.append (contentObj); + continue; + } + + // Assistant message with pending tool calls → model turn with functionCall parts. + if (message.role == LLMMessage::Role::assistant + && message.toolCalls.has_value() + && ! message.toolCalls->empty()) + { + var partsArray; + + for (const auto& toolCall : *message.toolCalls) + { + auto functionCall = var (std::make_unique()); + auto* fcObj = functionCall.getDynamicObject(); + + if (toolCall.id.isNotEmpty() && toolCall.id != toolCall.name) + fcObj->setProperty ("id", toolCall.id); + + fcObj->setProperty ("name", toolCall.name); + fcObj->setProperty ("args", + toolCall.arguments.isVoid() + ? var (std::make_unique()) + : toolCall.arguments); + + auto part = var (std::make_unique()); + part.getDynamicObject()->setProperty ("functionCall", functionCall); + partsArray.append (part); + } + + auto contentObj = var (std::make_unique()); + contentObj.getDynamicObject()->setProperty ("role", String ("model")); + contentObj.getDynamicObject()->setProperty ("parts", partsArray); + contentsArray.append (contentObj); + continue; + } + + // Ordinary user / assistant text message. + auto textPart = var (std::make_unique()); + textPart.getDynamicObject()->setProperty ("text", message.content); + + var partsArray; + partsArray.append (textPart); + + const auto geminiRole = (message.role == LLMMessage::Role::assistant) + ? String ("model") + : String ("user"); + + auto contentObj = var (std::make_unique()); + contentObj.getDynamicObject()->setProperty ("role", geminiRole); + contentObj.getDynamicObject()->setProperty ("parts", partsArray); + contentsArray.append (contentObj); + } + + // Generation config. + auto genConfig = var (std::make_unique()); + genConfig.getDynamicObject()->setProperty ("temperature", + static_cast (request.temperature.value_or (0.1f))); + + const int effectiveMaxTokens = request.maxTokens.value_or (options.maxTokens); + if (effectiveMaxTokens > 0) + genConfig.getDynamicObject()->setProperty ("maxOutputTokens", effectiveMaxTokens); + + // Thinking config for Gemini 2.5 models. + if (options.reasoningEffort.isNotEmpty()) + { + int budget = 4096; + if (options.reasoningEffort == "low") + budget = 1024; + else if (options.reasoningEffort == "high") + budget = 16384; + + auto thinkingConfig = var (std::make_unique()); + thinkingConfig.getDynamicObject()->setProperty ("thinkingBudget", budget); + genConfig.getDynamicObject()->setProperty ("thinkingConfig", thinkingConfig); + } + + // Structured output via JSON Schema. + if (! request.schema.isVoid()) + { + genConfig.getDynamicObject()->setProperty ("responseMimeType", String ("application/json")); + genConfig.getDynamicObject()->setProperty ("responseSchema", request.schema); + } + + auto payload = var (std::make_unique()); + payload.getDynamicObject()->setProperty ("system_instruction", sysInstruction); + payload.getDynamicObject()->setProperty ("contents", contentsArray); + payload.getDynamicObject()->setProperty ("generationConfig", genConfig); + + // Function declarations and tool config. + // Gemini REST API uses camelCase for these composite keys. + if (! request.tools.empty()) + { + var functionDeclarations; + for (const auto& tool : request.tools) + functionDeclarations.append (buildGeminiFunctionDeclaration (tool)); + + auto toolsGroup = var (std::make_unique()); + toolsGroup.getDynamicObject()->setProperty ("functionDeclarations", functionDeclarations); + + var toolsArray; + toolsArray.append (toolsGroup); + payload.getDynamicObject()->setProperty ("tools", toolsArray); + + // Translate OpenAI-style toolChoice to Gemini functionCallingConfig mode. + auto funcCallingConfig = var (std::make_unique()); + + if (request.toolChoice.has_value()) + { + const auto& choice = *request.toolChoice; + + if (choice == "none") + { + funcCallingConfig.getDynamicObject()->setProperty ("mode", String ("NONE")); + } + else if (choice == "required") + { + funcCallingConfig.getDynamicObject()->setProperty ("mode", String ("ANY")); + } + else if (choice != "auto") + { + // Specific function name — force the model to call exactly that tool. + funcCallingConfig.getDynamicObject()->setProperty ("mode", String ("ANY")); + var allowedNames; + allowedNames.append (choice); + funcCallingConfig.getDynamicObject()->setProperty ("allowedFunctionNames", allowedNames); + } + else + { + funcCallingConfig.getDynamicObject()->setProperty ("mode", String ("AUTO")); + } + } + else + { + funcCallingConfig.getDynamicObject()->setProperty ("mode", String ("AUTO")); + } + + auto toolConfig = var (std::make_unique()); + toolConfig.getDynamicObject()->setProperty ("functionCallingConfig", funcCallingConfig); + payload.getDynamicObject()->setProperty ("toolConfig", toolConfig); + } + + return JSON::toString (payload, true); +} + +String LLMGeminiClient::buildStreamingPayload (const Request& request) const +{ + // Gemini streaming uses the same body as non-streaming; the streaming + // behaviour is selected by the :streamGenerateContent?alt=sse endpoint URL. + return buildPayload (request); +} + +LLMResponse LLMGeminiClient::parseResponse (const var& json) const +{ + return geminiCandidatesToResponse (json); +} + +LLMResponse LLMGeminiClient::parseChunk (const var& json) const +{ + // Gemini SSE chunks use the same candidates/parts structure as full responses, + // including functionCall parts for tool-calling chunks. + return geminiCandidatesToResponse (json); +} + +} // namespace yup diff --git a/modules/yup_ai/providers/yup_LLMGeminiClient.h b/modules/yup_ai/providers/yup_LLMGeminiClient.h new file mode 100644 index 000000000..72402c427 --- /dev/null +++ b/modules/yup_ai/providers/yup_LLMGeminiClient.h @@ -0,0 +1,61 @@ +/* + ============================================================================== + + 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 +{ + +//============================================================================== +/** Google Gemini generateContent API client. + + Connects to the Gemini /v1beta/models/{model}:generateContent endpoint. + The API key is sent via the x-goog-api-key header. The default base URL + is https://generativelanguage.googleapis.com. + + Structured output is requested via generationConfig.responseMimeType and + generationConfig.responseSchema. + + Thinking budget (reasoning) is controlled via Options::reasoningEffort: + - "low" → 1024 tokens + - "high" → 16384 tokens + - any other non-empty value → 4096 tokens (default) + + Streaming uses :streamGenerateContent?alt=sse with the same candidates/parts + JSON structure as the non-streaming response. + + @tags{AI} +*/ +class YUP_API LLMGeminiClient : public LLMHttpClient +{ +public: + explicit LLMGeminiClient (Options options); + ~LLMGeminiClient() override; + +protected: + String getEndpointUrl() const override; + String getStreamingEndpointUrl() const override; + String buildHeaders() const override; + String buildPayload (const Request& request) const override; + String buildStreamingPayload (const Request& request) const override; + LLMResponse parseResponse (const var& json) const override; + LLMResponse parseChunk (const var& json) const override; +}; + +} // namespace yup diff --git a/modules/yup_ai/providers/yup_LLMOpenAIChatClient.cpp b/modules/yup_ai/providers/yup_LLMOpenAIChatClient.cpp new file mode 100644 index 000000000..26a40808e --- /dev/null +++ b/modules/yup_ai/providers/yup_LLMOpenAIChatClient.cpp @@ -0,0 +1,80 @@ +/* + ============================================================================== + + 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 +{ + +LLMOpenAIChatClient::LLMOpenAIChatClient (Options options) + : LLMHttpClient (std::move (options)) +{ +} + +LLMOpenAIChatClient::~LLMOpenAIChatClient() = default; + +//============================================================================== +String LLMOpenAIChatClient::getEndpointUrl() const +{ + return makeProviderUrl (options.baseUrl, "/chat/completions"); +} + +String LLMOpenAIChatClient::buildHeaders() const +{ + String headers = "Content-Type: application/json\r\nAccept: application/json\r\n"; + + if (options.apiKey.isNotEmpty()) + headers += "Authorization: Bearer " + options.apiKey + "\r\n"; + + if (options.userAgent.isNotEmpty()) + headers += "User-Agent: " + options.userAgent + "\r\n"; + + // OpenRouter — application identification. + if (options.baseUrl.contains ("openrouter.ai")) + { + if (options.userAgent.isNotEmpty()) + headers += "X-Title: " + options.userAgent + "\r\n"; + if (options.appUrl.isNotEmpty()) + headers += "HTTP-Referer: " + options.appUrl + "\r\n"; + } + + return headers; +} + +String LLMOpenAIChatClient::buildPayload (const Request& request) const +{ + return buildChatCompletionBody (request, false); +} + +String LLMOpenAIChatClient::buildStreamingPayload (const Request& request) const +{ + return buildChatCompletionBody (request, true); +} + +LLMResponse LLMOpenAIChatClient::parseResponse (const var& json) const +{ + return LLMResponse::fromOpenAiJson (json); +} + +LLMResponse LLMOpenAIChatClient::parseChunk (const var& json) const +{ + return LLMResponse::fromStreamChunk (json); +} + +} // namespace yup diff --git a/modules/yup_ai/providers/yup_LLMOpenAIChatClient.h b/modules/yup_ai/providers/yup_LLMOpenAIChatClient.h new file mode 100644 index 000000000..17b144744 --- /dev/null +++ b/modules/yup_ai/providers/yup_LLMOpenAIChatClient.h @@ -0,0 +1,60 @@ +/* + ============================================================================== + + 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 Chat Completions API client. + + Compatible with any OpenAI-compatible endpoint including: + - OpenAI (https://api.openai.com/v1) + - DeepSeek (https://api.deepseek.com/v1) + - OpenRouter (https://openrouter.ai/api/v1) + - Ollama (http://localhost:11434/v1) — the default base URL + - llama-server and any other OpenAI-compatible local server + + Supports the full yup_ai feature set: multi-turn messages, LLMTool + definitions and the tool-calling loop, streaming, structured output via + LLMSchema (response_format.json_schema), GBNF grammar for llama-server, + prompt caching, and per-model reasoning effort. + + For OpenRouter requests, X-Title and HTTP-Referer headers are injected + automatically when Options::userAgent and Options::appUrl are set. + + @tags{AI} +*/ +class YUP_API LLMOpenAIChatClient : public LLMHttpClient +{ +public: + explicit LLMOpenAIChatClient (Options options); + ~LLMOpenAIChatClient() override; + +protected: + String getEndpointUrl() const override; + String buildHeaders() const override; + String buildPayload (const Request& request) const override; + String buildStreamingPayload (const Request& request) const override; + LLMResponse parseResponse (const var& json) const override; + LLMResponse parseChunk (const var& json) const override; +}; + +} // namespace yup diff --git a/modules/yup_ai/providers/yup_LLMOpenAIResponsesClient.cpp b/modules/yup_ai/providers/yup_LLMOpenAIResponsesClient.cpp new file mode 100644 index 000000000..4b84616e3 --- /dev/null +++ b/modules/yup_ai/providers/yup_LLMOpenAIResponsesClient.cpp @@ -0,0 +1,240 @@ +/* + ============================================================================== + + 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 +{ + +LLMOpenAIResponsesClient::LLMOpenAIResponsesClient (Options options) + : LLMHttpClient (std::move (options)) +{ +} + +LLMOpenAIResponsesClient::~LLMOpenAIResponsesClient() = default; + +//============================================================================== +String LLMOpenAIResponsesClient::getEndpointUrl() const +{ + return makeProviderUrl (options.baseUrl, "/responses"); +} + +String LLMOpenAIResponsesClient::buildHeaders() const +{ + String headers = "Content-Type: application/json\r\nAccept: application/json\r\n"; + + if (options.apiKey.isNotEmpty()) + headers += "Authorization: Bearer " + options.apiKey + "\r\n"; + + if (options.userAgent.isNotEmpty()) + headers += "User-Agent: " + options.userAgent + "\r\n"; + + return headers; +} + +String LLMOpenAIResponsesClient::buildPayload (const Request& request) const +{ + auto payload = var (std::make_unique()); + auto* obj = payload.getDynamicObject(); + + obj->setProperty ("model", options.model); + + // System prompt → "instructions" field in the Responses API. + const auto systemText = request.systemPrompt.value_or (String()); + if (systemText.isNotEmpty()) + obj->setProperty ("instructions", systemText); + + // Build input from messages. The Responses API accepts a string for single-turn + // and an array of message objects for multi-turn. + if (! request.messages.empty()) + { + // Use a formatted string representation of the conversation. + // For simple single-user-message use, just pass the content. + if (request.messages.size() == 1 && request.messages[0].role == LLMMessage::Role::user) + { + obj->setProperty ("input", request.messages[0].content); + } + else + { + // Multi-turn: convert to the Responses API messages array format. + var inputArray; + + for (const auto& message : request.messages) + { + auto msgObj = var (std::make_unique()); + const String role = (message.role == LLMMessage::Role::user) ? "user" : "assistant"; + msgObj.getDynamicObject()->setProperty ("role", role); + msgObj.getDynamicObject()->setProperty ("content", message.content); + inputArray.append (msgObj); + } + + obj->setProperty ("input", inputArray); + } + } + + if (! options.noTemperature) + { + if (request.temperature.has_value()) + obj->setProperty ("temperature", static_cast (*request.temperature)); + } + + // Reasoning effort. + if (options.reasoningEffort.isNotEmpty()) + { + auto reasoning = var (std::make_unique()); + reasoning.getDynamicObject()->setProperty ("effort", options.reasoningEffort); + obj->setProperty ("reasoning", reasoning); + } + + // Max output tokens. + const int effectiveMaxTokens = request.maxTokens.value_or (options.maxTokens); + if (effectiveMaxTokens > 0) + obj->setProperty ("max_output_tokens", effectiveMaxTokens); + + // Prompt caching. + if (options.userAgent.isNotEmpty()) + { + obj->setProperty ("prompt_cache_key", options.userAgent); + obj->setProperty ("prompt_cache_retention", String ("24h")); + } + + // CFG grammar-constrained output via custom tool (Lark syntax). + // Request grammar overrides config grammar for per-call flexibility. + const auto& effectiveGrammar = request.grammar.isNotEmpty() ? request.grammar : options.grammar; + + if (effectiveGrammar.isNotEmpty()) + { + const auto toolName = request.grammarToolName.isNotEmpty() + ? request.grammarToolName + : String ("grammar_tool"); + const auto toolDesc = request.grammarToolDescription.isNotEmpty() + ? request.grammarToolDescription + : systemText; + + auto format = var (std::make_unique()); + format.getDynamicObject()->setProperty ("type", String ("grammar")); + format.getDynamicObject()->setProperty ("syntax", String ("lark")); + format.getDynamicObject()->setProperty ("definition", effectiveGrammar); + + auto tool = var (std::make_unique()); + tool.getDynamicObject()->setProperty ("type", String ("custom")); + tool.getDynamicObject()->setProperty ("name", toolName); + tool.getDynamicObject()->setProperty ("description", toolDesc); + tool.getDynamicObject()->setProperty ("format", format); + + var tools; + tools.append (tool); + obj->setProperty ("tools", tools); + obj->setProperty ("parallel_tool_calls", false); + } + // Structured output via JSON Schema (flat format under text.format — different + // from the Chat Completions response_format shape). + else if (! request.schema.isVoid()) + { + auto format = var (std::make_unique()); + format.getDynamicObject()->setProperty ("type", String ("json_schema")); + format.getDynamicObject()->setProperty ("name", String ("response")); + format.getDynamicObject()->setProperty ("strict", true); + format.getDynamicObject()->setProperty ("schema", request.schema); + + auto text = var (std::make_unique()); + text.getDynamicObject()->setProperty ("format", format); + obj->setProperty ("text", text); + } + + return JSON::toString (payload, true); +} + +LLMResponse LLMOpenAIResponsesClient::parseResponse (const var& json) const +{ + if (json.isVoid()) + return LLMResponse::fromError ("Unable to parse OpenAI Responses response JSON"); + + if (json["error"].isObject()) + { + auto message = json["error"]["message"].toString(); + return LLMResponse::fromError (message.isNotEmpty() ? message : "Unknown OpenAI Responses API error"); + } + + if (auto* output = json["output"].getArray()) + { + for (const auto& item : *output) + { + const auto type = item["type"].toString(); + + // Grammar tool response — text in item["input"]. + if (type == "custom_tool_call") + { + const auto input = item["input"].toString().trim(); + if (input.isNotEmpty()) + { + LLMResponse response; + LLMResponse::Choice choice; + choice.index = 0; + choice.message = LLMMessage::assistant (input); + response.choices.push_back (std::move (choice)); + return response; + } + } + + // Standard text response — output[].content[].text. + if (type == "message") + { + if (auto* content = item["content"].getArray()) + { + for (const auto& c : *content) + { + if (c["type"].toString() == "output_text") + { + LLMResponse response; + LLMResponse::Choice choice; + choice.index = 0; + choice.message = LLMMessage::assistant (c["text"].toString().trim()); + response.choices.push_back (std::move (choice)); + return response; + } + } + } + } + } + } + + return LLMResponse::fromError ("No content found in OpenAI Responses output"); +} + +LLMResponse LLMOpenAIResponsesClient::parseChunk (const var& json) const +{ + // Responses API SSE: + // data: {"type":"response.output_text.delta","delta":"token","item_id":"…"} + // data: {"type":"response.completed",…} + const auto type = json["type"].toString(); + if (type != "response.output_text.delta") + return LLMResponse {}; // non-content events + + LLMResponse chunk; + LLMResponse::Choice choice; + choice.index = 0; + choice.message.role = LLMMessage::Role::assistant; + choice.message.content = json["delta"].toString(); + chunk.choices.push_back (std::move (choice)); + + return chunk; +} + +} // namespace yup diff --git a/modules/yup_ai/providers/yup_LLMOpenAIResponsesClient.h b/modules/yup_ai/providers/yup_LLMOpenAIResponsesClient.h new file mode 100644 index 000000000..e3fbef850 --- /dev/null +++ b/modules/yup_ai/providers/yup_LLMOpenAIResponsesClient.h @@ -0,0 +1,62 @@ +/* + ============================================================================== + + 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 Responses API client — for GPT-5 and newer reasoning models. + + Connects to the /v1/responses endpoint. This API uses a different JSON + structure from Chat Completions: system prompt → "instructions", user + messages → "input", and structured output via text.format rather than + response_format. + + Reasoning effort is controlled via Options::reasoningEffort, which maps to + the reasoning.effort field ("none", "low", "medium", "high"). + + CFG grammar-constrained output is supported via Request::grammar (Lark + syntax) using a custom tool. Request::grammarToolName and + Request::grammarToolDescription configure the tool's identity. + + Streaming SSE format: + @code + data: {"type":"response.output_text.delta","delta":"token","item_id":"…"} + data: {"type":"response.completed",…} + @endcode + + @tags{AI} +*/ +class YUP_API LLMOpenAIResponsesClient : public LLMHttpClient +{ +public: + explicit LLMOpenAIResponsesClient (Options options); + ~LLMOpenAIResponsesClient() override; + +protected: + String getEndpointUrl() const override; + String buildHeaders() const override; + String buildPayload (const Request& request) const override; + LLMResponse parseResponse (const var& json) const override; + LLMResponse parseChunk (const var& json) const override; +}; + +} // namespace yup diff --git a/modules/yup_ai/yup_ai.cpp b/modules/yup_ai/yup_ai.cpp index 4c78b7081..fc4de42c2 100644 --- a/modules/yup_ai/yup_ai.cpp +++ b/modules/yup_ai/yup_ai.cpp @@ -31,13 +31,27 @@ #include "yup_ai.h" //============================================================================== +// Core LLM. #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" + +// Embedding. #include "embedding/yup_EmbeddingModel.cpp" + +// MCP. #include "mcp/yup_MCPTypes.cpp" #include "mcp/yup_MCPClient.cpp" #include "mcp/yup_MCPServer.cpp" + +// Provider implementations. +#include "providers/yup_LLMOpenAIChatClient.cpp" +#include "providers/yup_LLMAnthropicClient.cpp" +#include "providers/yup_LLMGeminiClient.cpp" +#include "providers/yup_LLMOpenAIResponsesClient.cpp" + +// Factory. +#include "llm/yup_LLMClientFactory.cpp" diff --git a/modules/yup_ai/yup_ai.h b/modules/yup_ai/yup_ai.h index 01f9fdf80..69bc662a2 100644 --- a/modules/yup_ai/yup_ai.h +++ b/modules/yup_ai/yup_ai.h @@ -54,14 +54,29 @@ #include //============================================================================== +// Core LLM types and abstract client. #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 "llm/yup_LLMSchema.h" + +// Embedding. #include "embedding/yup_EmbeddingModel.h" + +// MCP (Model Context Protocol). #include "mcp/yup_MCPTypes.h" #include "mcp/yup_MCPTransport.h" #include "mcp/yup_MCPClient.h" #include "mcp/yup_MCPServer.h" + +// Provider implementations (include before the factory). +#include "providers/yup_LLMOpenAIChatClient.h" +#include "providers/yup_LLMAnthropicClient.h" +#include "providers/yup_LLMGeminiClient.h" +#include "providers/yup_LLMOpenAIResponsesClient.h" + +// Factory — depends on all provider types above. +#include "llm/yup_LLMClientFactory.h" diff --git a/modules/yup_python/bindings/yup_YupAi_bindings.cpp b/modules/yup_python/bindings/yup_YupAi_bindings.cpp index 34c130c68..ee0691011 100644 --- a/modules/yup_python/bindings/yup_YupAi_bindings.cpp +++ b/modules/yup_python/bindings/yup_YupAi_bindings.cpp @@ -187,6 +187,13 @@ void registerYupAiBindings (py::module_& m) ai.attr ("MCP_INVALID_PARAMS") = MCPErrorCodes::invalidParams; ai.attr ("MCP_INTERNAL_ERROR") = MCPErrorCodes::internalError; + py::enum_ (ai, "LLMProvider") + .value ("OpenAIChat", LLMClient::Provider::OpenAIChat) + .value ("OpenAIResponses", LLMClient::Provider::OpenAIResponses) + .value ("Anthropic", LLMClient::Provider::Anthropic) + .value ("Gemini", LLMClient::Provider::Gemini) + .export_values(); + py::enum_ (ai, "LLMMessageRole") .value ("system", LLMMessage::Role::system) .value ("user", LLMMessage::Role::user) @@ -301,6 +308,7 @@ void registerYupAiBindings (py::module_& m) .def ("hasToolCalls", &LLMResponse::hasToolCalls) .def ("failed", &LLMResponse::failed) .def ("getToolCalls", &LLMResponse::getToolCalls) + .def ("appendStreamChunk", &LLMResponse::appendStreamChunk, "chunk"_a) .def_static ("fromError", &LLMResponse::fromError) .def_static ("fromOpenAiJson", &LLMResponse::fromOpenAiJson) .def_static ("fromStreamChunk", &LLMResponse::fromStreamChunk); @@ -314,15 +322,26 @@ void registerYupAiBindings (py::module_& m) .def_readwrite ("temperature", &LLMClient::Request::temperature) .def_readwrite ("topP", &LLMClient::Request::topP) .def_readwrite ("maxTokens", &LLMClient::Request::maxTokens) - .def_readwrite ("stopSequences", &LLMClient::Request::stopSequences); + .def_readwrite ("stopSequences", &LLMClient::Request::stopSequences) + .def_readwrite ("schema", &LLMClient::Request::schema) + .def_readwrite ("grammar", &LLMClient::Request::grammar) + .def_readwrite ("grammarToolName", &LLMClient::Request::grammarToolName) + .def_readwrite ("grammarToolDescription", &LLMClient::Request::grammarToolDescription); py::class_ (ai, "LLMOptions") .def (py::init<>()) + .def_readwrite ("provider", &LLMClient::Options::provider) .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); + .def_readwrite ("maxRetries", &LLMClient::Options::maxRetries) + .def_readwrite ("maxTokens", &LLMClient::Options::maxTokens) + .def_readwrite ("reasoningEffort", &LLMClient::Options::reasoningEffort) + .def_readwrite ("grammar", &LLMClient::Options::grammar) + .def_readwrite ("noTemperature", &LLMClient::Options::noTemperature) + .def_readwrite ("userAgent", &LLMClient::Options::userAgent) + .def_readwrite ("appUrl", &LLMClient::Options::appUrl); py::class_ (ai, "LLMClient") .def (py::init()) @@ -333,8 +352,77 @@ void registerYupAiBindings (py::module_& m) .def ("runToolLoop", &LLMClient::runToolLoop) .def ("getOptions", &LLMClient::getOptions, py::return_value_policy::reference_internal); - py::class_ (ai, "LLMHttpClient") - .def (py::init()); + // LLMHttpClient is an abstract base — not directly constructible from Python. + // Use LLMClientFactory to create provider-specific clients. + py::class_ (ai, "LLMHttpClient"); + + py::class_ (ai, "LLMClientFactory") + .def_static ("create", &LLMClientFactory::create, "options"_a) + .def_static ("openAIChat", + &LLMClientFactory::openAIChat, + "model"_a, + "baseUrl"_a = String ("http://localhost:11434/v1"), + "apiKey"_a = String {}) + .def_static ("openAIResponses", + &LLMClientFactory::openAIResponses, + "model"_a, + "apiKey"_a, + "baseUrl"_a = String ("https://api.openai.com/v1")) + .def_static ("anthropic", + &LLMClientFactory::anthropic, + "model"_a, + "apiKey"_a, + "baseUrl"_a = String ("https://api.anthropic.com/v1")) + .def_static ("gemini", + &LLMClientFactory::gemini, + "model"_a, + "apiKey"_a, + "baseUrl"_a = String ("https://generativelanguage.googleapis.com")); + + py::class_ (ai, "LLMSchema") + .def_static ("string", &LLMSchema::string) + .def_static ("number", &LLMSchema::number) + .def_static ("integer", &LLMSchema::integer) + .def_static ("boolean", &LLMSchema::boolean) + .def_static ("array", &LLMSchema::array, "itemSchema"_a) + .def_static ("object", [] (const std::vector>& fields) + { + // Convert from Python list-of-tuples to the initializer_list-based helper. + // We replicate the helper logic to avoid the initializer_list limitation. + auto properties = var (std::make_unique()); + var requiredArray; + + for (const auto& [name, fieldSchema] : fields) + { + if (auto* obj = properties.getDynamicObject()) + obj->setProperty (name, fieldSchema); + requiredArray.append (name); + } + + auto result = var (std::make_unique()); + auto* obj = result.getDynamicObject(); + obj->setProperty ("type", String ("object")); + obj->setProperty ("properties", properties); + obj->setProperty ("required", requiredArray); + obj->setProperty ("additionalProperties", false); + return result; + }, + "fields"_a) + .def_static ("oneOf", [] (const std::vector& values) + { + var enumArray; + + for (const auto& v : values) + enumArray.append (v); + + auto result = var (std::make_unique()); + auto* obj = result.getDynamicObject(); + obj->setProperty ("type", String ("string")); + obj->setProperty ("enum", enumArray); + return result; + }, + "values"_a) + .def_static ("toJsonString", &LLMSchema::toJsonString, "schema"_a); py::class_ (ai, "EmbeddingOptions") .def (py::init<>()) diff --git a/tests/yup_ai/yup_LLMProviders.cpp b/tests/yup_ai/yup_LLMProviders.cpp new file mode 100644 index 000000000..27133b147 --- /dev/null +++ b/tests/yup_ai/yup_LLMProviders.cpp @@ -0,0 +1,1321 @@ +/* + ============================================================================== + + 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 +{ + +//============================================================================== +// Test wrappers that expose the protected virtual methods of each provider. + +class TestableChatClient final : public LLMOpenAIChatClient +{ +public: + explicit TestableChatClient (Options options) + : LLMOpenAIChatClient (std::move (options)) + { + } + + String endpointUrl() const { return getEndpointUrl(); } + + String streamingEndpointUrl() const { return getStreamingEndpointUrl(); } + + String headers() const { return buildHeaders(); } + + String payload (const Request& r) const { return buildPayload (r); } + + String streamingPayload (const Request& r) const { return buildStreamingPayload (r); } + + LLMResponse response (const var& j) const { return parseResponse (j); } + + LLMResponse chunk (const var& j) const { return parseChunk (j); } +}; + +class TestableAnthropicClient final : public LLMAnthropicClient +{ +public: + explicit TestableAnthropicClient (Options options) + : LLMAnthropicClient (std::move (options)) + { + } + + String endpointUrl() const { return getEndpointUrl(); } + + String headers() const { return buildHeaders(); } + + String payload (const Request& r) const { return buildPayload (r); } + + LLMResponse response (const var& j) const { return parseResponse (j); } + + LLMResponse chunk (const var& j) const { return parseChunk (j); } +}; + +class TestableGeminiClient final : public LLMGeminiClient +{ +public: + explicit TestableGeminiClient (Options options) + : LLMGeminiClient (std::move (options)) + { + } + + String endpointUrl() const { return getEndpointUrl(); } + + String streamingEndpointUrl() const { return getStreamingEndpointUrl(); } + + String headers() const { return buildHeaders(); } + + String payload (const Request& r) const { return buildPayload (r); } + + String streamingPayload (const Request& r) const { return buildStreamingPayload (r); } + + LLMResponse response (const var& j) const { return parseResponse (j); } + + LLMResponse chunk (const var& j) const { return parseChunk (j); } +}; + +class TestableResponsesClient final : public LLMOpenAIResponsesClient +{ +public: + explicit TestableResponsesClient (Options options) + : LLMOpenAIResponsesClient (std::move (options)) + { + } + + String endpointUrl() const { return getEndpointUrl(); } + + String headers() const { return buildHeaders(); } + + String payload (const Request& r) const { return buildPayload (r); } + + LLMResponse response (const var& j) const { return parseResponse (j); } + + LLMResponse chunk (const var& j) const { return parseChunk (j); } +}; + +//============================================================================== +LLMClient::Options makeOptions (LLMClient::Provider provider, + const String& model, + const String& baseUrl, + const String& apiKey = {}) +{ + LLMClient::Options opts; + opts.provider = provider; + opts.model = model; + opts.baseUrl = baseUrl; + opts.apiKey = apiKey; + return opts; +} + +} // namespace + +//============================================================================== +// LLMOpenAIChatClient + +TEST (YupAiOpenAIChatClient, GetEndpointUrl_AppendsChatCompletionsPath) +{ + TestableChatClient client (makeOptions (LLMClient::Provider::OpenAIChat, + "gpt-4o", + "https://api.openai.com/v1", + "sk-key")); + + EXPECT_EQ ("https://api.openai.com/v1/chat/completions", client.endpointUrl()); +} + +TEST (YupAiOpenAIChatClient, GetEndpointUrl_StripsTrailingSlashFromBaseUrl) +{ + TestableChatClient client (makeOptions (LLMClient::Provider::OpenAIChat, + "gpt-4o", + "https://api.openai.com/v1/", + "sk-key")); + + EXPECT_EQ ("https://api.openai.com/v1/chat/completions", client.endpointUrl()); +} + +TEST (YupAiOpenAIChatClient, GetStreamingEndpointUrl_SameAsNonStreaming) +{ + TestableChatClient client (makeOptions (LLMClient::Provider::OpenAIChat, + "gpt-4o", + "https://api.openai.com/v1", + "sk-key")); + + EXPECT_EQ (client.endpointUrl(), client.streamingEndpointUrl()); +} + +TEST (YupAiOpenAIChatClient, BuildHeaders_IncludesBearerToken) +{ + TestableChatClient client (makeOptions (LLMClient::Provider::OpenAIChat, + "gpt-4o", + "https://api.openai.com/v1", + "sk-test-key")); + + EXPECT_TRUE (client.headers().contains ("Authorization: Bearer sk-test-key")); +} + +TEST (YupAiOpenAIChatClient, BuildHeaders_OmitsBearerWhenApiKeyEmpty) +{ + TestableChatClient client (makeOptions (LLMClient::Provider::OpenAIChat, + "gemma4", + "http://localhost:11434/v1")); + + EXPECT_FALSE (client.headers().contains ("Authorization:")); +} + +TEST (YupAiOpenAIChatClient, BuildHeaders_OpenRouter_InjectsTitleAndReferer) +{ + LLMClient::Options opts = makeOptions (LLMClient::Provider::OpenAIChat, + "openai/gpt-4o", + "https://openrouter.ai/api/v1", + "or-key"); + opts.userAgent = "MyApp"; + opts.appUrl = "https://myapp.com"; + TestableChatClient client (opts); + + const auto h = client.headers(); + EXPECT_TRUE (h.contains ("X-Title: MyApp")); + EXPECT_TRUE (h.contains ("HTTP-Referer: https://myapp.com")); +} + +TEST (YupAiOpenAIChatClient, BuildPayload_ContainsModelName) +{ + TestableChatClient client (makeOptions (LLMClient::Provider::OpenAIChat, + "gpt-4o", + "https://api.openai.com/v1", + "sk-key")); + + LLMClient::Request request; + request.messages.push_back (LLMMessage::user ("hello")); + + const auto body = JSON::parse (client.payload (request)); + EXPECT_EQ ("gpt-4o", body["model"].toString()); +} + +TEST (YupAiOpenAIChatClient, BuildPayload_StreamFlagIsFalse) +{ + TestableChatClient client (makeOptions (LLMClient::Provider::OpenAIChat, + "gpt-4o", + "https://api.openai.com/v1", + "sk-key")); + + LLMClient::Request request; + request.messages.push_back (LLMMessage::user ("hello")); + + const auto body = JSON::parse (client.payload (request)); + EXPECT_FALSE (static_cast (body["stream"])); +} + +TEST (YupAiOpenAIChatClient, BuildStreamingPayload_StreamFlagIsTrue) +{ + TestableChatClient client (makeOptions (LLMClient::Provider::OpenAIChat, + "gpt-4o", + "https://api.openai.com/v1", + "sk-key")); + + LLMClient::Request request; + request.messages.push_back (LLMMessage::user ("hello")); + + const auto body = JSON::parse (client.streamingPayload (request)); + EXPECT_TRUE (static_cast (body["stream"])); +} + +TEST (YupAiOpenAIChatClient, BuildPayload_SetsMaxCompletionTokens) +{ + TestableChatClient client (makeOptions (LLMClient::Provider::OpenAIChat, + "gpt-4o", + "https://api.openai.com/v1", + "sk-key")); + + LLMClient::Request request; + request.messages.push_back (LLMMessage::user ("hello")); + request.maxTokens = 100; + + const auto body = JSON::parse (client.payload (request)); + EXPECT_EQ (100, static_cast (body["max_completion_tokens"])); +} + +TEST (YupAiOpenAIChatClient, BuildPayload_SystemPromptAppearsFirst) +{ + TestableChatClient client (makeOptions (LLMClient::Provider::OpenAIChat, + "gpt-4o", + "https://api.openai.com/v1", + "sk-key")); + + LLMClient::Request request; + request.systemPrompt = "be brief"; + request.messages.push_back (LLMMessage::user ("hello")); + + const auto body = JSON::parse (client.payload (request)); + ASSERT_TRUE (body["messages"].isArray()); + EXPECT_EQ ("system", body["messages"][0]["role"].toString()); + EXPECT_EQ ("user", body["messages"][1]["role"].toString()); +} + +TEST (YupAiOpenAIChatClient, ParseResponse_ExtractsChoiceContent) +{ + TestableChatClient client (makeOptions (LLMClient::Provider::OpenAIChat, + "gpt-4o", + "https://api.openai.com/v1", + "sk-key")); + + const auto json = JSON::parse (R"({ + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": { "role": "assistant", "content": "world" }, + "finish_reason": "stop" + } + ] + })"); + + const auto response = client.response (json); + ASSERT_EQ (1u, response.choices.size()); + EXPECT_EQ ("world", response.choices.front().message.content); +} + +TEST (YupAiOpenAIChatClient, ParseChunk_ExtractsDeltaContent) +{ + TestableChatClient client (makeOptions (LLMClient::Provider::OpenAIChat, + "gpt-4o", + "https://api.openai.com/v1", + "sk-key")); + + const auto json = JSON::parse (R"({ + "choices": [ + { + "index": 0, + "delta": { "role": "assistant", "content": "tok" }, + "finish_reason": null + } + ] + })"); + + const auto response = client.chunk (json); + ASSERT_EQ (1u, response.choices.size()); + EXPECT_EQ ("tok", response.choices.front().message.content); +} + +//============================================================================== +// LLMAnthropicClient + +TEST (YupAiAnthropicClient, GetEndpointUrl_AppendsMessagesPath) +{ + TestableAnthropicClient client (makeOptions (LLMClient::Provider::Anthropic, + "claude-opus-4-5", + "https://api.anthropic.com/v1", + "ant-key")); + + EXPECT_EQ ("https://api.anthropic.com/v1/messages", client.endpointUrl()); +} + +TEST (YupAiAnthropicClient, BuildHeaders_IncludesApiKey) +{ + TestableAnthropicClient client (makeOptions (LLMClient::Provider::Anthropic, + "claude-opus-4-5", + "https://api.anthropic.com/v1", + "ant-key")); + + EXPECT_TRUE (client.headers().contains ("x-api-key: ant-key")); +} + +TEST (YupAiAnthropicClient, BuildHeaders_IncludesAnthropicVersion) +{ + TestableAnthropicClient client (makeOptions (LLMClient::Provider::Anthropic, + "claude-opus-4-5", + "https://api.anthropic.com/v1", + "ant-key")); + + EXPECT_TRUE (client.headers().contains ("anthropic-version: 2023-06-01")); +} + +TEST (YupAiAnthropicClient, BuildHeaders_DoesNotUseBearerScheme) +{ + TestableAnthropicClient client (makeOptions (LLMClient::Provider::Anthropic, + "claude-opus-4-5", + "https://api.anthropic.com/v1", + "ant-key")); + + EXPECT_FALSE (client.headers().contains ("Authorization: Bearer")); +} + +TEST (YupAiAnthropicClient, BuildPayload_DefaultsMaxTokensTo4096) +{ + TestableAnthropicClient client (makeOptions (LLMClient::Provider::Anthropic, + "claude-opus-4-5", + "https://api.anthropic.com/v1", + "ant-key")); + + LLMClient::Request request; + request.messages.push_back (LLMMessage::user ("hello")); + + const auto body = JSON::parse (client.payload (request)); + EXPECT_EQ (4096, static_cast (body["max_tokens"])); +} + +TEST (YupAiAnthropicClient, BuildPayload_RespectsRequestMaxTokens) +{ + TestableAnthropicClient client (makeOptions (LLMClient::Provider::Anthropic, + "claude-opus-4-5", + "https://api.anthropic.com/v1", + "ant-key")); + + LLMClient::Request request; + request.messages.push_back (LLMMessage::user ("hello")); + request.maxTokens = 512; + + const auto body = JSON::parse (client.payload (request)); + EXPECT_EQ (512, static_cast (body["max_tokens"])); +} + +TEST (YupAiAnthropicClient, BuildPayload_SystemPromptGoesToSystemFieldWithCacheControl) +{ + TestableAnthropicClient client (makeOptions (LLMClient::Provider::Anthropic, + "claude-opus-4-5", + "https://api.anthropic.com/v1", + "ant-key")); + + LLMClient::Request request; + request.systemPrompt = "You are helpful."; + request.messages.push_back (LLMMessage::user ("hello")); + + const auto body = JSON::parse (client.payload (request)); + + ASSERT_TRUE (body["system"].isArray()); + EXPECT_EQ ("text", body["system"][0]["type"].toString()); + EXPECT_EQ ("You are helpful.", body["system"][0]["text"].toString()); + EXPECT_EQ ("ephemeral", body["system"][0]["cache_control"]["type"].toString()); +} + +TEST (YupAiAnthropicClient, BuildPayload_FiltersSystemMessagesFromMessagesArray) +{ + TestableAnthropicClient client (makeOptions (LLMClient::Provider::Anthropic, + "claude-opus-4-5", + "https://api.anthropic.com/v1", + "ant-key")); + + LLMClient::Request request; + request.messages.push_back (LLMMessage::system ("system directive")); + request.messages.push_back (LLMMessage::user ("question")); + request.messages.push_back (LLMMessage::assistant ("answer")); + + const auto body = JSON::parse (client.payload (request)); + auto* messages = body["messages"].getArray(); + + ASSERT_NE (nullptr, messages); + EXPECT_EQ (2u, messages->size()); + EXPECT_EQ ("user", (*messages)[0]["role"].toString()); + EXPECT_EQ ("assistant", (*messages)[1]["role"].toString()); +} + +TEST (YupAiAnthropicClient, ParseResponse_ExtractsContentArrayText) +{ + TestableAnthropicClient client (makeOptions (LLMClient::Provider::Anthropic, + "claude-opus-4-5", + "https://api.anthropic.com/v1", + "ant-key")); + + const auto json = JSON::parse (R"({ + "model": "claude-opus-4-5", + "content": [{ "type": "text", "text": " hello " }], + "stop_reason": "end_turn", + "usage": { "input_tokens": 5, "output_tokens": 3 } + })"); + + const auto response = client.response (json); + ASSERT_EQ (1u, response.choices.size()); + EXPECT_EQ ("hello", response.choices.front().message.content); + ASSERT_TRUE (response.usage.has_value()); + EXPECT_EQ (8, response.usage->totalTokens); +} + +TEST (YupAiAnthropicClient, ParseResponse_ReportsApiError) +{ + TestableAnthropicClient client (makeOptions (LLMClient::Provider::Anthropic, + "claude-opus-4-5", + "https://api.anthropic.com/v1", + "ant-key")); + + const auto json = JSON::parse (R"({ "error": { "message": "invalid api key" } })"); + + const auto response = client.response (json); + EXPECT_TRUE (response.failed()); + ASSERT_TRUE (response.errorMessage.has_value()); + EXPECT_EQ ("invalid api key", *response.errorMessage); +} + +TEST (YupAiAnthropicClient, ParseChunk_ExtractsContentBlockDeltaText) +{ + TestableAnthropicClient client (makeOptions (LLMClient::Provider::Anthropic, + "claude-opus-4-5", + "https://api.anthropic.com/v1", + "ant-key")); + + const auto json = JSON::parse (R"({ + "type": "content_block_delta", + "index": 0, + "delta": { "type": "text_delta", "text": "tok" } + })"); + + const auto response = client.chunk (json); + ASSERT_EQ (1u, response.choices.size()); + EXPECT_EQ ("tok", response.choices.front().message.content); + EXPECT_EQ (LLMMessage::Role::assistant, response.choices.front().message.role); +} + +TEST (YupAiAnthropicClient, ParseChunk_IgnoresNonContentBlockDeltaEvents) +{ + TestableAnthropicClient client (makeOptions (LLMClient::Provider::Anthropic, + "claude-opus-4-5", + "https://api.anthropic.com/v1", + "ant-key")); + + const auto json = JSON::parse (R"({ "type": "message_stop" })"); + + const auto response = client.chunk (json); + EXPECT_TRUE (response.choices.empty()); +} + +//============================================================================== +// LLMGeminiClient + +TEST (YupAiGeminiClient, GetEndpointUrl_IncludesModelNameAndGenerateContent) +{ + TestableGeminiClient client (makeOptions (LLMClient::Provider::Gemini, + "gemini-2.5-flash", + "https://generativelanguage.googleapis.com", + "gemini-key")); + + EXPECT_EQ ("https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent", + client.endpointUrl()); +} + +TEST (YupAiGeminiClient, GetStreamingEndpointUrl_UsesStreamGenerateContentWithSse) +{ + TestableGeminiClient client (makeOptions (LLMClient::Provider::Gemini, + "gemini-2.5-flash", + "https://generativelanguage.googleapis.com", + "gemini-key")); + + EXPECT_EQ ("https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + client.streamingEndpointUrl()); +} + +TEST (YupAiGeminiClient, BuildHeaders_IncludesGoogApiKey) +{ + TestableGeminiClient client (makeOptions (LLMClient::Provider::Gemini, + "gemini-2.5-flash", + "https://generativelanguage.googleapis.com", + "gemini-key")); + + EXPECT_TRUE (client.headers().contains ("x-goog-api-key: gemini-key")); +} + +TEST (YupAiGeminiClient, BuildPayload_MapsAssistantRoleToModel) +{ + TestableGeminiClient client (makeOptions (LLMClient::Provider::Gemini, + "gemini-2.5-flash", + "https://generativelanguage.googleapis.com", + "gemini-key")); + + LLMClient::Request request; + request.messages.push_back (LLMMessage::user ("hello")); + request.messages.push_back (LLMMessage::assistant ("hi there")); + + const auto body = JSON::parse (client.payload (request)); + auto* contents = body["contents"].getArray(); + + ASSERT_NE (nullptr, contents); + ASSERT_EQ (2u, contents->size()); + EXPECT_EQ ("user", (*contents)[0]["role"].toString()); + EXPECT_EQ ("model", (*contents)[1]["role"].toString()); +} + +TEST (YupAiGeminiClient, BuildPayload_FiltersSystemMessages) +{ + TestableGeminiClient client (makeOptions (LLMClient::Provider::Gemini, + "gemini-2.5-flash", + "https://generativelanguage.googleapis.com", + "gemini-key")); + + LLMClient::Request request; + request.messages.push_back (LLMMessage::system ("system text")); + request.messages.push_back (LLMMessage::user ("question")); + + const auto body = JSON::parse (client.payload (request)); + auto* contents = body["contents"].getArray(); + + ASSERT_NE (nullptr, contents); + EXPECT_EQ (1u, contents->size()); + EXPECT_EQ ("user", (*contents)[0]["role"].toString()); +} + +TEST (YupAiGeminiClient, BuildPayload_SetsSystemInstruction) +{ + TestableGeminiClient client (makeOptions (LLMClient::Provider::Gemini, + "gemini-2.5-flash", + "https://generativelanguage.googleapis.com", + "gemini-key")); + + LLMClient::Request request; + request.systemPrompt = "You are a helpful assistant."; + request.messages.push_back (LLMMessage::user ("hello")); + + const auto body = JSON::parse (client.payload (request)); + auto* parts = body["system_instruction"]["parts"].getArray(); + + ASSERT_NE (nullptr, parts); + ASSERT_FALSE (parts->isEmpty()); + EXPECT_EQ ("You are a helpful assistant.", (*parts)[0]["text"].toString()); +} + +TEST (YupAiGeminiClient, BuildPayload_ReasoningEffortLow_SetsThinkingBudget1024) +{ + LLMClient::Options opts = makeOptions (LLMClient::Provider::Gemini, + "gemini-2.5-flash", + "https://generativelanguage.googleapis.com", + "gemini-key"); + opts.reasoningEffort = "low"; + TestableGeminiClient client (opts); + + LLMClient::Request request; + request.messages.push_back (LLMMessage::user ("hello")); + + const auto body = JSON::parse (client.payload (request)); + EXPECT_EQ (1024, + static_cast (body["generationConfig"]["thinkingConfig"]["thinkingBudget"])); +} + +TEST (YupAiGeminiClient, BuildPayload_ReasoningEffortHigh_SetsThinkingBudget16384) +{ + LLMClient::Options opts = makeOptions (LLMClient::Provider::Gemini, + "gemini-2.5-flash", + "https://generativelanguage.googleapis.com", + "gemini-key"); + opts.reasoningEffort = "high"; + TestableGeminiClient client (opts); + + LLMClient::Request request; + request.messages.push_back (LLMMessage::user ("hello")); + + const auto body = JSON::parse (client.payload (request)); + EXPECT_EQ (16384, + static_cast (body["generationConfig"]["thinkingConfig"]["thinkingBudget"])); +} + +TEST (YupAiGeminiClient, BuildPayload_ReasoningEffortDefault_SetsThinkingBudget4096) +{ + LLMClient::Options opts = makeOptions (LLMClient::Provider::Gemini, + "gemini-2.5-flash", + "https://generativelanguage.googleapis.com", + "gemini-key"); + opts.reasoningEffort = "medium"; + TestableGeminiClient client (opts); + + LLMClient::Request request; + request.messages.push_back (LLMMessage::user ("hello")); + + const auto body = JSON::parse (client.payload (request)); + EXPECT_EQ (4096, + static_cast (body["generationConfig"]["thinkingConfig"]["thinkingBudget"])); +} + +TEST (YupAiGeminiClient, BuildStreamingPayload_MatchesNonStreamingPayload) +{ + TestableGeminiClient client (makeOptions (LLMClient::Provider::Gemini, + "gemini-2.5-flash", + "https://generativelanguage.googleapis.com", + "gemini-key")); + + LLMClient::Request request; + request.messages.push_back (LLMMessage::user ("hello")); + + EXPECT_EQ (client.payload (request), client.streamingPayload (request)); +} + +TEST (YupAiGeminiClient, ParseResponse_ExtractsCandidateText) +{ + TestableGeminiClient client (makeOptions (LLMClient::Provider::Gemini, + "gemini-2.5-flash", + "https://generativelanguage.googleapis.com", + "gemini-key")); + + const auto json = JSON::parse (R"({ + "candidates": [ + { + "content": { "parts": [{ "text": " response text " }] }, + "finishReason": "STOP" + } + ] + })"); + + const auto response = client.response (json); + ASSERT_EQ (1u, response.choices.size()); + EXPECT_EQ ("response text", response.choices.front().message.content); + ASSERT_TRUE (response.choices.front().finishReason.has_value()); + EXPECT_EQ ("STOP", *response.choices.front().finishReason); +} + +TEST (YupAiGeminiClient, ParseResponse_ReportsApiError) +{ + TestableGeminiClient client (makeOptions (LLMClient::Provider::Gemini, + "gemini-2.5-flash", + "https://generativelanguage.googleapis.com", + "gemini-key")); + + const auto json = JSON::parse (R"({ "error": { "message": "quota exceeded" } })"); + + const auto response = client.response (json); + EXPECT_TRUE (response.failed()); + ASSERT_TRUE (response.errorMessage.has_value()); + EXPECT_EQ ("quota exceeded", *response.errorMessage); +} + +TEST (YupAiGeminiClient, ParseChunk_UsesSameCandidatesStructure) +{ + TestableGeminiClient client (makeOptions (LLMClient::Provider::Gemini, + "gemini-2.5-flash", + "https://generativelanguage.googleapis.com", + "gemini-key")); + + const auto json = JSON::parse (R"({ + "candidates": [ + { "content": { "parts": [{ "text": "delta" }] } } + ] + })"); + + const auto response = client.chunk (json); + ASSERT_EQ (1u, response.choices.size()); + EXPECT_EQ ("delta", response.choices.front().message.content); +} + +TEST (YupAiGeminiClient, BuildPayload_ToolsUseFunctionDeclarationsKey) +{ + TestableGeminiClient client (makeOptions (LLMClient::Provider::Gemini, + "gemini-2.5-flash", + "https://generativelanguage.googleapis.com", + "gemini-key")); + + LLMTool tool; + tool.name = "get_weather"; + tool.description = "Returns current weather."; + tool.parameters.push_back ({ "city", "string", "City name", true }); + + LLMClient::Request request; + request.messages.push_back (LLMMessage::user ("what's the weather?")); + request.tools.push_back (std::move (tool)); + + const auto body = JSON::parse (client.payload (request)); + + ASSERT_TRUE (body["tools"].isArray()); + EXPECT_EQ (1, body["tools"].size()); + + // Key must be camelCase "functionDeclarations", not snake_case. + const auto& decls = body["tools"][0]["functionDeclarations"]; + ASSERT_TRUE (decls.isArray()); + ASSERT_EQ (1, decls.size()); + EXPECT_EQ ("get_weather", decls[0]["name"].toString()); + EXPECT_EQ ("string", decls[0]["parameters"]["properties"]["city"]["type"].toString()); +} + +TEST (YupAiGeminiClient, BuildPayload_ToolConfigDefaultsToAutoMode) +{ + TestableGeminiClient client (makeOptions (LLMClient::Provider::Gemini, + "gemini-2.5-flash", + "https://generativelanguage.googleapis.com", + "gemini-key")); + + LLMTool tool; + tool.name = "echo"; + + LLMClient::Request request; + request.messages.push_back (LLMMessage::user ("hi")); + request.tools.push_back (std::move (tool)); + + const auto body = JSON::parse (client.payload (request)); + + // Keys must be camelCase: toolConfig → functionCallingConfig → mode. + EXPECT_EQ ("AUTO", body["toolConfig"]["functionCallingConfig"]["mode"].toString()); +} + +TEST (YupAiGeminiClient, BuildPayload_ToolChoiceNone_SetsNoneMode) +{ + TestableGeminiClient client (makeOptions (LLMClient::Provider::Gemini, + "gemini-2.5-flash", + "https://generativelanguage.googleapis.com", + "gemini-key")); + + LLMTool tool; + tool.name = "echo"; + + LLMClient::Request request; + request.messages.push_back (LLMMessage::user ("hi")); + request.tools.push_back (std::move (tool)); + request.toolChoice = "none"; + + const auto body = JSON::parse (client.payload (request)); + EXPECT_EQ ("NONE", body["toolConfig"]["functionCallingConfig"]["mode"].toString()); +} + +TEST (YupAiGeminiClient, BuildPayload_ToolChoiceRequired_SetsAnyMode) +{ + TestableGeminiClient client (makeOptions (LLMClient::Provider::Gemini, + "gemini-2.5-flash", + "https://generativelanguage.googleapis.com", + "gemini-key")); + + LLMTool tool; + tool.name = "echo"; + + LLMClient::Request request; + request.messages.push_back (LLMMessage::user ("hi")); + request.tools.push_back (std::move (tool)); + request.toolChoice = "required"; + + const auto body = JSON::parse (client.payload (request)); + EXPECT_EQ ("ANY", body["toolConfig"]["functionCallingConfig"]["mode"].toString()); +} + +TEST (YupAiGeminiClient, BuildPayload_SpecificToolChoice_SetsAllowedFunctionNames) +{ + TestableGeminiClient client (makeOptions (LLMClient::Provider::Gemini, + "gemini-2.5-flash", + "https://generativelanguage.googleapis.com", + "gemini-key")); + + LLMTool tool; + tool.name = "get_weather"; + + LLMClient::Request request; + request.messages.push_back (LLMMessage::user ("weather?")); + request.tools.push_back (std::move (tool)); + request.toolChoice = "get_weather"; + + const auto body = JSON::parse (client.payload (request)); + + const auto& fcc = body["toolConfig"]["functionCallingConfig"]; + EXPECT_EQ ("ANY", fcc["mode"].toString()); + ASSERT_TRUE (fcc["allowedFunctionNames"].isArray()); + EXPECT_EQ ("get_weather", fcc["allowedFunctionNames"][0].toString()); +} + +TEST (YupAiGeminiClient, BuildPayload_ToolResultMessage_BecomesUserFunctionResponse) +{ + TestableGeminiClient client (makeOptions (LLMClient::Provider::Gemini, + "gemini-2.5-flash", + "https://generativelanguage.googleapis.com", + "gemini-key")); + + auto toolResult = LLMMessage::toolResult ("get_weather", R"({"result":"sunny"})"); + toolResult.name = "get_weather"; // set by updated runToolLoop + + LLMClient::Request request; + request.messages.push_back (LLMMessage::user ("weather?")); + request.messages.push_back (toolResult); + + const auto body = JSON::parse (client.payload (request)); + auto* contents = body["contents"].getArray(); + + ASSERT_NE (nullptr, contents); + ASSERT_EQ (2u, contents->size()); + + const auto& turn = (*contents)[1]; + EXPECT_EQ ("user", turn["role"].toString()); + + const auto& fr = turn["parts"][0]["functionResponse"]; + EXPECT_EQ ("get_weather", fr["name"].toString()); + EXPECT_EQ ("sunny", fr["response"]["result"].toString()); +} + +TEST (YupAiGeminiClient, BuildPayload_AssistantWithToolCalls_BecomesModelFunctionCallTurn) +{ + TestableGeminiClient client (makeOptions (LLMClient::Provider::Gemini, + "gemini-2.5-flash", + "https://generativelanguage.googleapis.com", + "gemini-key")); + + LLMToolCall toolCall; + toolCall.index = 0; + toolCall.id = "get_weather"; + toolCall.name = "get_weather"; + toolCall.arguments = JSON::parse (R"({"city":"London"})"); + + auto assistantMsg = LLMMessage::assistant (""); + assistantMsg.toolCalls = std::vector { toolCall }; + + LLMClient::Request request; + request.messages.push_back (LLMMessage::user ("weather?")); + request.messages.push_back (assistantMsg); + + const auto body = JSON::parse (client.payload (request)); + auto* contents = body["contents"].getArray(); + + ASSERT_NE (nullptr, contents); + ASSERT_EQ (2u, contents->size()); + + const auto& modelTurn = (*contents)[1]; + EXPECT_EQ ("model", modelTurn["role"].toString()); + + const auto& fc = modelTurn["parts"][0]["functionCall"]; + EXPECT_EQ ("get_weather", fc["name"].toString()); + EXPECT_EQ ("London", fc["args"]["city"].toString()); +} + +TEST (YupAiGeminiClient, ParseResponse_ExtractsFunctionCallPart) +{ + TestableGeminiClient client (makeOptions (LLMClient::Provider::Gemini, + "gemini-2.5-flash", + "https://generativelanguage.googleapis.com", + "gemini-key")); + + const auto json = JSON::parse (R"({ + "candidates": [{ + "content": { + "parts": [{ + "functionCall": { + "id": "call_abc", + "name": "get_weather", + "args": { "city": "Paris" } + } + }] + }, + "finishReason": "STOP" + }] + })"); + + const auto response = client.response (json); + ASSERT_EQ (1u, response.choices.size()); + ASSERT_TRUE (response.hasToolCalls()); + + const auto toolCalls = response.getToolCalls(); + ASSERT_EQ (1u, toolCalls.size()); + EXPECT_EQ ("get_weather", toolCalls.front().name); + EXPECT_EQ ("call_abc", toolCalls.front().id); + EXPECT_EQ ("Paris", toolCalls.front().arguments["city"].toString()); +} + +TEST (YupAiGeminiClient, ParseResponse_FunctionCallId_FallsBackToNameWhenAbsent) +{ + TestableGeminiClient client (makeOptions (LLMClient::Provider::Gemini, + "gemini-2.5-flash", + "https://generativelanguage.googleapis.com", + "gemini-key")); + + const auto json = JSON::parse (R"({ + "candidates": [{ + "content": { + "parts": [{ + "functionCall": { + "name": "echo", + "args": {} + } + }] + } + }] + })"); + + const auto response = client.response (json); + const auto toolCalls = response.getToolCalls(); + ASSERT_EQ (1u, toolCalls.size()); + // id should fall back to the function name when no "id" field is present + EXPECT_EQ ("echo", toolCalls.front().id); + EXPECT_EQ ("echo", toolCalls.front().name); +} + +//============================================================================== +// LLMOpenAIResponsesClient + +TEST (YupAiOpenAIResponsesClient, GetEndpointUrl_AppendsResponsesPath) +{ + TestableResponsesClient client (makeOptions (LLMClient::Provider::OpenAIResponses, + "gpt-4.1", + "https://api.openai.com/v1", + "sk-key")); + + EXPECT_EQ ("https://api.openai.com/v1/responses", client.endpointUrl()); +} + +TEST (YupAiOpenAIResponsesClient, BuildHeaders_IncludesBearerToken) +{ + TestableResponsesClient client (makeOptions (LLMClient::Provider::OpenAIResponses, + "gpt-4.1", + "https://api.openai.com/v1", + "sk-resp-key")); + + EXPECT_TRUE (client.headers().contains ("Authorization: Bearer sk-resp-key")); +} + +TEST (YupAiOpenAIResponsesClient, BuildPayload_SingleUserMessage_UsesStringInput) +{ + TestableResponsesClient client (makeOptions (LLMClient::Provider::OpenAIResponses, + "gpt-4.1", + "https://api.openai.com/v1", + "sk-key")); + + LLMClient::Request request; + request.messages.push_back (LLMMessage::user ("single message")); + + const auto body = JSON::parse (client.payload (request)); + EXPECT_EQ ("single message", body["input"].toString()); + EXPECT_FALSE (body["input"].isArray()); +} + +TEST (YupAiOpenAIResponsesClient, BuildPayload_MultiTurnMessages_UsesArrayInput) +{ + TestableResponsesClient client (makeOptions (LLMClient::Provider::OpenAIResponses, + "gpt-4.1", + "https://api.openai.com/v1", + "sk-key")); + + LLMClient::Request request; + request.messages.push_back (LLMMessage::user ("question")); + request.messages.push_back (LLMMessage::assistant ("answer")); + request.messages.push_back (LLMMessage::user ("follow-up")); + + const auto body = JSON::parse (client.payload (request)); + ASSERT_TRUE (body["input"].isArray()); + EXPECT_EQ (3, body["input"].size()); + EXPECT_EQ ("user", body["input"][0]["role"].toString()); + EXPECT_EQ ("assistant", body["input"][1]["role"].toString()); + EXPECT_EQ ("user", body["input"][2]["role"].toString()); +} + +TEST (YupAiOpenAIResponsesClient, BuildPayload_SetsSystemPromptAsInstructions) +{ + TestableResponsesClient client (makeOptions (LLMClient::Provider::OpenAIResponses, + "gpt-4.1", + "https://api.openai.com/v1", + "sk-key")); + + LLMClient::Request request; + request.systemPrompt = "Be concise."; + request.messages.push_back (LLMMessage::user ("hello")); + + const auto body = JSON::parse (client.payload (request)); + EXPECT_EQ ("Be concise.", body["instructions"].toString()); +} + +TEST (YupAiOpenAIResponsesClient, BuildPayload_SetsReasoningEffort) +{ + LLMClient::Options opts = makeOptions (LLMClient::Provider::OpenAIResponses, + "gpt-4.1", + "https://api.openai.com/v1", + "sk-key"); + opts.reasoningEffort = "high"; + TestableResponsesClient client (opts); + + LLMClient::Request request; + request.messages.push_back (LLMMessage::user ("solve this")); + + const auto body = JSON::parse (client.payload (request)); + EXPECT_EQ ("high", body["reasoning"]["effort"].toString()); +} + +TEST (YupAiOpenAIResponsesClient, BuildPayload_NoTemperatureFlag_OmitsTemperatureField) +{ + LLMClient::Options opts = makeOptions (LLMClient::Provider::OpenAIResponses, + "gpt-4.1", + "https://api.openai.com/v1", + "sk-key"); + opts.noTemperature = true; + TestableResponsesClient client (opts); + + LLMClient::Request request; + request.temperature = 0.5f; + request.messages.push_back (LLMMessage::user ("hello")); + + const auto body = JSON::parse (client.payload (request)); + EXPECT_TRUE (body["temperature"].isVoid()); +} + +TEST (YupAiOpenAIResponsesClient, ParseResponse_ExtractsOutputTextFromMessageItem) +{ + TestableResponsesClient client (makeOptions (LLMClient::Provider::OpenAIResponses, + "gpt-4.1", + "https://api.openai.com/v1", + "sk-key")); + + const auto json = JSON::parse (R"({ + "output": [ + { + "type": "message", + "content": [ + { "type": "output_text", "text": " result " } + ] + } + ] + })"); + + const auto response = client.response (json); + ASSERT_EQ (1u, response.choices.size()); + EXPECT_EQ ("result", response.choices.front().message.content); +} + +TEST (YupAiOpenAIResponsesClient, ParseResponse_ExtractsGrammarToolCallInput) +{ + TestableResponsesClient client (makeOptions (LLMClient::Provider::OpenAIResponses, + "gpt-4.1", + "https://api.openai.com/v1", + "sk-key")); + + const auto json = JSON::parse (R"({ + "output": [ + { "type": "custom_tool_call", "input": "constrained output" } + ] + })"); + + const auto response = client.response (json); + ASSERT_EQ (1u, response.choices.size()); + EXPECT_EQ ("constrained output", response.choices.front().message.content); +} + +TEST (YupAiOpenAIResponsesClient, ParseResponse_ReportsApiError) +{ + TestableResponsesClient client (makeOptions (LLMClient::Provider::OpenAIResponses, + "gpt-4.1", + "https://api.openai.com/v1", + "sk-key")); + + const auto json = JSON::parse (R"({ "error": { "message": "rate limit exceeded" } })"); + + const auto response = client.response (json); + EXPECT_TRUE (response.failed()); + ASSERT_TRUE (response.errorMessage.has_value()); + EXPECT_EQ ("rate limit exceeded", *response.errorMessage); +} + +TEST (YupAiOpenAIResponsesClient, ParseChunk_ExtractsOutputTextDelta) +{ + TestableResponsesClient client (makeOptions (LLMClient::Provider::OpenAIResponses, + "gpt-4.1", + "https://api.openai.com/v1", + "sk-key")); + + const auto json = JSON::parse (R"({ + "type": "response.output_text.delta", + "delta": "streamed token", + "item_id": "item_123" + })"); + + const auto response = client.chunk (json); + ASSERT_EQ (1u, response.choices.size()); + EXPECT_EQ ("streamed token", response.choices.front().message.content); + EXPECT_EQ (LLMMessage::Role::assistant, response.choices.front().message.role); +} + +TEST (YupAiOpenAIResponsesClient, ParseChunk_IgnoresNonDeltaEvents) +{ + TestableResponsesClient client (makeOptions (LLMClient::Provider::OpenAIResponses, + "gpt-4.1", + "https://api.openai.com/v1", + "sk-key")); + + const auto json = JSON::parse (R"({ "type": "response.completed" })"); + + const auto response = client.chunk (json); + EXPECT_TRUE (response.choices.empty()); +} + +//============================================================================== +// LLMClientFactory + +TEST (YupAiLLMClientFactory, Create_OpenAIChat_ReturnsNonNull) +{ + LLMClient::Options opts; + opts.provider = LLMClient::Provider::OpenAIChat; + opts.model = "gpt-4o"; + opts.baseUrl = "https://api.openai.com/v1"; + + auto client = LLMClientFactory::create (opts); + ASSERT_NE (nullptr, client); + EXPECT_EQ (LLMClient::Provider::OpenAIChat, client->getOptions().provider); +} + +TEST (YupAiLLMClientFactory, Create_Anthropic_ReturnsNonNull) +{ + LLMClient::Options opts; + opts.provider = LLMClient::Provider::Anthropic; + opts.model = "claude-opus-4-5"; + opts.apiKey = "ant-key"; + + auto client = LLMClientFactory::create (opts); + ASSERT_NE (nullptr, client); + EXPECT_EQ (LLMClient::Provider::Anthropic, client->getOptions().provider); +} + +TEST (YupAiLLMClientFactory, Create_Gemini_ReturnsNonNull) +{ + LLMClient::Options opts; + opts.provider = LLMClient::Provider::Gemini; + opts.model = "gemini-2.5-flash"; + opts.apiKey = "gemini-key"; + + auto client = LLMClientFactory::create (opts); + ASSERT_NE (nullptr, client); + EXPECT_EQ (LLMClient::Provider::Gemini, client->getOptions().provider); +} + +TEST (YupAiLLMClientFactory, Create_OpenAIResponses_ReturnsNonNull) +{ + LLMClient::Options opts; + opts.provider = LLMClient::Provider::OpenAIResponses; + opts.model = "gpt-4.1"; + opts.apiKey = "sk-key"; + + auto client = LLMClientFactory::create (opts); + ASSERT_NE (nullptr, client); + EXPECT_EQ (LLMClient::Provider::OpenAIResponses, client->getOptions().provider); +} + +TEST (YupAiLLMClientFactory, OpenAIChat_HelperSetsModelAndBaseUrl) +{ + auto client = LLMClientFactory::openAIChat ("gpt-4o", "https://api.openai.com/v1", "sk-key"); + + ASSERT_NE (nullptr, client); + EXPECT_EQ (LLMClient::Provider::OpenAIChat, client->getOptions().provider); + EXPECT_EQ ("gpt-4o", client->getOptions().model); + EXPECT_EQ ("https://api.openai.com/v1", client->getOptions().baseUrl); + EXPECT_EQ ("sk-key", client->getOptions().apiKey); +} + +TEST (YupAiLLMClientFactory, Anthropic_HelperUsesDefaultAnthropicBaseUrl) +{ + auto client = LLMClientFactory::anthropic ("claude-opus-4-5", "ant-key"); + + ASSERT_NE (nullptr, client); + EXPECT_EQ (LLMClient::Provider::Anthropic, client->getOptions().provider); + EXPECT_EQ ("https://api.anthropic.com/v1", client->getOptions().baseUrl); + EXPECT_EQ ("ant-key", client->getOptions().apiKey); +} + +TEST (YupAiLLMClientFactory, Gemini_HelperUsesDefaultGeminiBaseUrl) +{ + auto client = LLMClientFactory::gemini ("gemini-2.5-flash", "gemini-key"); + + ASSERT_NE (nullptr, client); + EXPECT_EQ (LLMClient::Provider::Gemini, client->getOptions().provider); + EXPECT_EQ ("https://generativelanguage.googleapis.com", client->getOptions().baseUrl); + EXPECT_EQ ("gemini-key", client->getOptions().apiKey); +} + +TEST (YupAiLLMClientFactory, OpenAIResponses_HelperUsesDefaultOpenAIBaseUrl) +{ + auto client = LLMClientFactory::openAIResponses ("gpt-4.1", "sk-key"); + + ASSERT_NE (nullptr, client); + EXPECT_EQ (LLMClient::Provider::OpenAIResponses, client->getOptions().provider); + EXPECT_EQ ("https://api.openai.com/v1", client->getOptions().baseUrl); + EXPECT_EQ ("sk-key", client->getOptions().apiKey); +} + +//============================================================================== +// LLMSchema + +TEST (YupAiLLMSchema, String_HasTypeString) +{ + const auto schema = LLMSchema::string(); + EXPECT_EQ ("string", schema["type"].toString()); +} + +TEST (YupAiLLMSchema, Number_HasTypeNumber) +{ + const auto schema = LLMSchema::number(); + EXPECT_EQ ("number", schema["type"].toString()); +} + +TEST (YupAiLLMSchema, Integer_HasTypeInteger) +{ + const auto schema = LLMSchema::integer(); + EXPECT_EQ ("integer", schema["type"].toString()); +} + +TEST (YupAiLLMSchema, Boolean_HasTypeBoolean) +{ + const auto schema = LLMSchema::boolean(); + EXPECT_EQ ("boolean", schema["type"].toString()); +} + +TEST (YupAiLLMSchema, Array_HasTypeArrayAndItems) +{ + const auto schema = LLMSchema::array (LLMSchema::string()); + EXPECT_EQ ("array", schema["type"].toString()); + EXPECT_EQ ("string", schema["items"]["type"].toString()); +} + +TEST (YupAiLLMSchema, Object_HasTypePropertiesAndRequiredArray) +{ + const auto schema = LLMSchema::object ({ + { "name", LLMSchema::string() }, + { "score", LLMSchema::number() }, + }); + + EXPECT_EQ ("object", schema["type"].toString()); + EXPECT_EQ ("string", schema["properties"]["name"]["type"].toString()); + EXPECT_EQ ("number", schema["properties"]["score"]["type"].toString()); + ASSERT_TRUE (schema["required"].isArray()); + EXPECT_EQ (2, schema["required"].size()); +} + +TEST (YupAiLLMSchema, Object_DisallowsAdditionalProperties) +{ + const auto schema = LLMSchema::object ({ + { "x", LLMSchema::integer() }, + }); + + EXPECT_FALSE (static_cast (schema["additionalProperties"])); +} + +TEST (YupAiLLMSchema, OneOf_HasTypeStringAndEnumValues) +{ + const auto schema = LLMSchema::oneOf ({ "red", "green", "blue" }); + + EXPECT_EQ ("string", schema["type"].toString()); + ASSERT_TRUE (schema["enum"].isArray()); + EXPECT_EQ (3, schema["enum"].size()); + EXPECT_EQ ("red", schema["enum"][0].toString()); + EXPECT_EQ ("green", schema["enum"][1].toString()); + EXPECT_EQ ("blue", schema["enum"][2].toString()); +} + +TEST (YupAiLLMSchema, ToJsonString_ProducesReparseableJson) +{ + const auto schema = LLMSchema::string(); + const auto jsonStr = LLMSchema::toJsonString (schema); + const auto reparsed = JSON::parse (jsonStr); + + EXPECT_EQ ("string", reparsed["type"].toString()); +} + +TEST (YupAiLLMSchema, NestedObject_WorksInsideArray) +{ + const auto itemSchema = LLMSchema::object ({ + { "id", LLMSchema::integer() }, + { "label", LLMSchema::string() }, + }); + const auto schema = LLMSchema::array (itemSchema); + + EXPECT_EQ ("array", schema["type"].toString()); + EXPECT_EQ ("object", schema["items"]["type"].toString()); + EXPECT_EQ ("integer", schema["items"]["properties"]["id"]["type"].toString()); +} diff --git a/tests/yup_ai/yup_LLMTypes.cpp b/tests/yup_ai/yup_LLMTypes.cpp index cb56bae8b..b9a1bf8c9 100644 --- a/tests/yup_ai/yup_LLMTypes.cpp +++ b/tests/yup_ai/yup_LLMTypes.cpp @@ -335,7 +335,7 @@ TEST (YupAiLLMClient, BuildsChatCompletionBody) 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"])); + EXPECT_EQ (32, static_cast (body["max_completion_tokens"])); } TEST (YupAiLLMClient, SerializesSpecificToolChoice)