diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2deff9b10..ad30c5d2d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -62,9 +62,47 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- New standalone `yup_shader_bundler` console tool (`cmake/tools/shader_bundler`): takes a `.vert` and `.frag` GLSL (v450 Vulkan dialect) pair on disk and produces a single `.ysl` bundle containing transpiled variants for all target languages (GLSL/ESSL/HLSL/MSL).
- New `yup_add_shader_bundle()` CMake helper (`cmake/yup_shader_bundler.cmake`): builds the `yup_shader_bundler` tool for the host once (cached in the global property `YUP_SHADER_BUNDLER_EXECUTABLE`), runs it at configure time to generate the `.ysl`, and embeds it into a linkable object library via `yup_add_embedded_binary_resources`. Works even when the outer build is cross-compiling, since the tool is built in its own host binary tree without forwarding the cross toolchain. Accepts an `OPTIONS` argument that forwards arbitrary extra flags verbatim to `yup_shader_bundler` (e.g. `--spirv-opt`, `--target-langs`, `-DNAME=VALUE`, `-I
`).
+### AI (`yup_ai`)
+
+- New `yup_ai` module (`modules/yup_ai`): LLM client and AI integration classes depending on `yup_core` and `yup_events`.
+
+#### LLM
+
+- `LLMClient` (`yup_LLMClient.h`): abstract base for chat-completion backends with `complete()` and `completeStreaming()` methods, tool-call loop support via `runToolLoop()`, and structured output via `LLMSchema` JSON Schema or GBNF grammars.
+- `LLMHttpClient` (`yup_LLMHttpClient.h`): HTTP transport for `LLMClient` with retry and timeout logic, handling streaming SSE and non-streaming JSON responses.
+- `LLMClientFactory` (`yup_LLMClientFactory.h`): creates the correct `LLMHttpClient` subclass from `LLMClient::Options::provider`, with convenience factories for each provider.
+- `LLMMessage` (`yup_LLMMessage.h`): chat message with four roles (system, user, assistant, tool), optional tool calls, and serialisation to/from OpenAI ChatML JSON.
+- `LLMResponse` (`yup_LLMResponse.h`): parsed completion response with choices, token usage, tool-call extraction, streaming chunk accumulation, and error handling.
+- `LLMTool` (`yup_LLMTool.h`): callable function descriptor with JSON Schema parameters and a local handler, serialised to OpenAI function-calling format.
+- `LLMToolRegistry` (`yup_LLMToolRegistry.h`): thread-safe registry for `LLMTool` instances with snapshot, lookup, dispatch, and tools-array serialisation.
+- `LLMSchema` (`yup_LLMSchema.h`): fluent builder for JSON Schema objects (`string`, `number`, `integer`, `boolean`, `array`, `object`, `oneOf`) used in structured-output requests across all providers.
+
+#### LLM Providers
+
+- `LLMOpenAIChatClient` (`yup_LLMOpenAIChatClient.h`): OpenAI Chat Completions API — also compatible with Ollama, DeepSeek, OpenRouter, and llama-server.
+- `LLMOpenAIResponsesClient` (`yup_LLMOpenAIResponsesClient.h`): OpenAI Responses API (GPT-5+, reasoning models).
+- `LLMAnthropicClient` (`yup_LLMAnthropicClient.h`): Anthropic Messages API (Claude models).
+- `LLMGeminiClient` (`yup_LLMGeminiClient.h`): Google Gemini generateContent API.
+
+#### Embeddings
+
+- `EmbeddingModel` (`yup_EmbeddingModel.h`): OpenAI-compatible HTTP embedding model with `embed()` / `embedBatch()` and `cosineSimilarity()` helper.
+
+#### MCP (Model Context Protocol)
+
+- `MCPTypes` (`yup_MCPTypes.h`): JSON-RPC 2.0 request/response/error types, MCP capability flags, tool and resource definitions with `toVar` / `fromVar` serialisation.
+- `MCPTransport` (`yup_MCPTransport.h`): abstract transport interface for JSON-RPC messages (stdio, HTTP/SSE, sockets, in-process).
+- `MCPClient` (`yup_MCPClient.h`): synchronous MCP client with `initialize()` handshake, `listTools()` / `callTool()`, `listResources()` / `readResource()`, and tool-import bridge `registerToolsWith()`.
+- `MCPServer` (`yup_MCPServer.h`): MCP server exposing local YUP tools and resources over a transport, with `registerTool()` / `registerResource()`, `start()` / `stop()`, and placeholder `startStdio()` / `startHttp()`.
+
+#### Python Bindings
+
+- Python bindings for `yup_ai` (`modules/yup_python/bindings/yup_YupAi_bindings.cpp`): exposes LLM client, provider, messages, tools, responses, MCP types, client, and server to Python via pybind11.
+
### Examples
- `SpinningCubeDemo` example (`examples/graphics`): rewritten to the new RHI shape — `GpuFrame` + `GpuCanvas::beginDraw` + `GpuRenderPass` for both the indexed cube draw and the separable two-pass blur (H+V sharing one `GpuFrame`), `isGpuAvailable()` capability probe, and live GLSL editing via `GpuPipeline::compileFromGlsl`. The default Lottie animation is now played back per-frame into an offscreen `GpuCanvas` (2D path) and sampled by the cube's fragment shader so the animation is texture-mapped onto every cube face.
+- `AIDemo` example (`examples/graphics/source/examples/AI.h`): interactive demo for all four LLM providers (OpenAI Chat, OpenAI Responses, Anthropic, Gemini) with model and API key configuration, system prompt editing, streaming and non-streaming completion, tool calling, MCP server integration, and embedded text generation.
### Build System
diff --git a/docs/ai/embedding.md b/docs/ai/embedding.md
new file mode 100644
index 000000000..929383d29
--- /dev/null
+++ b/docs/ai/embedding.md
@@ -0,0 +1,98 @@
+# Embeddings
+
+`EmbeddingModel` converts text to dense vector embeddings via any
+OpenAI-compatible embeddings endpoint. Use it for semantic search, clustering,
+recommendations, and retrieval-augmented generation (RAG).
+
+## Creating a model
+
+```cpp
+#include
+
+yup::EmbeddingModel::Options opts;
+opts.model = "text-embedding-3-small";
+opts.baseUrl = "https://api.openai.com/v1";
+opts.apiKey = "sk-...";
+
+yup::EmbeddingModel embeddingModel (opts);
+```
+
+For Ollama, point `baseUrl` to your local server:
+
+```cpp
+yup::EmbeddingModel::Options opts;
+opts.model = "nomic-embed-text";
+opts.baseUrl = "http://localhost:11434/v1";
+```
+
+## Embedding text
+
+### Single input
+
+```cpp
+auto embedding = embeddingModel.embed ("Machine learning is fascinating.");
+DBG ("Dimensions: " << embedding.dimensions()); // e.g. 1536
+
+// Access the vector
+for (float v : embedding.values)
+ process (v);
+```
+
+### Batch input
+
+```cpp
+auto embeddings = embeddingModel.embedBatch ({
+ "What is a neural network?",
+ "How does backpropagation work?",
+ "The weather is nice today."
+});
+
+for (auto& e : embeddings)
+ DBG ("Index " << e.index << ": " << e.dimensions() << " dimensions");
+```
+
+## Similarity
+
+Compute cosine similarity between two embeddings (range [-1, 1]):
+
+```cpp
+auto e1 = embeddingModel.embed ("artificial intelligence");
+auto e2 = embeddingModel.embed ("machine learning");
+auto e3 = embeddingModel.embed ("cooking recipes");
+
+float scoreAIvsML = yup::EmbeddingModel::cosineSimilarity (e1, e2); // ~0.85
+float scoreAIvsCooking = yup::EmbeddingModel::cosineSimilarity (e1, e3); // ~0.15
+```
+
+```{note}
+`cosineSimilarity()` returns `0.0f` for zero vectors and handles
+floating-point rounding at the [-1, 1] boundaries.
+```
+
+## Semantic search example
+
+```cpp
+// Build a knowledge base
+std::vector> knowledgeBase;
+
+auto addDocument = [&](const String& content)
+{
+ knowledgeBase.push_back ({ content, embeddingModel.embed (content) });
+};
+
+addDocument ("C++ is a statically typed, compiled language.");
+addDocument ("Python is dynamically typed and interpreted.");
+addDocument ("Rust guarantees memory safety without a garbage collector.");
+
+// Search
+auto query = embeddingModel.embed ("Which language is compiled?");
+std::sort (knowledgeBase.begin(), knowledgeBase.end(),
+ [&](const auto& a, const auto& b)
+ {
+ return yup::EmbeddingModel::cosineSimilarity (query, a.second)
+ > yup::EmbeddingModel::cosineSimilarity (query, b.second);
+ });
+
+// Top match: "C++ is a statically typed, compiled language."
+DBG (knowledgeBase.front().first);
+```
diff --git a/docs/ai/index.md b/docs/ai/index.md
new file mode 100644
index 000000000..c3812aa3b
--- /dev/null
+++ b/docs/ai/index.md
@@ -0,0 +1,78 @@
+# AI
+
+LLM chat-completion clients, text embeddings, and MCP (Model Context Protocol)
+server and client — all built on `yup_core` and `yup_events`.
+
+**Module:** `yup_ai`.
+
+## In this area
+
+- [LLM clients](llm.md) — chat completions, streaming, tools, structured output,
+ and provider setup (OpenAI, Anthropic, Gemini, Ollama, DeepSeek, llama-server).
+- [MCP](mcp.md) — Model Context Protocol server and client for tool/resource
+ bridging over JSON-RPC transports.
+- [Embeddings](embedding.md) — text embeddings and cosine similarity for
+ semantic search and retrieval.
+
+## Key building blocks
+
+The `yup_ai` module provides:
+
+- **`LLMClient`** — abstract base for every chat-completion provider with
+ `complete()`, `completeStreaming()`, `chat()`, and `runToolLoop()`.
+- **`LLMClientFactory`** — a single `create()` call that picks the correct
+ provider from `LLMClient::Options::provider`, plus convenience factories
+ (`openAIChat()`, `anthropic()`, `gemini()`, etc.).
+- **`LLMMessage` / `LLMResponse`** — message and response types compatible
+ with OpenAI's ChatML format.
+- **`LLMTool` / `LLMToolRegistry`** — define callable functions with JSON
+ Schema parameters and register/dispatch them thread-safely.
+- **`LLMSchema`** — fluent builder for JSON Schema objects used in
+ structured-output requests.
+- **`EmbeddingModel`** — embed text via any OpenAI-compatible embeddings
+ endpoint.
+- **`MCPClient` / `MCPServer`** — JSON-RPC 2.0 bridge for Model Context
+ Protocol: list and call tools, register resources.
+- **`MCPTransport`** — abstract transport; implementations for stdio, HTTP/SSE,
+ and in-process queues.
+
+## Quick start
+
+```cpp
+#include
+
+// Create an OpenAI Chat client
+auto client = yup::LLMClientFactory::openAIChat (
+ "gpt-5",
+ "https://api.openai.com/v1",
+ "sk-..."
+);
+
+// One-shot chat
+auto response = client->chat ("What is the capital of France?");
+if (! response.failed())
+ DBG (response.choices.front().message.content); // "Paris"
+
+// Structured output with JSON Schema
+yup::LLMClient::Request request;
+request.messages.push_back (yup::LLMMessage::user ("Extract the title and year."));
+request.schema = yup::LLMSchema::object ({
+ { "title", yup::LLMSchema::string() },
+ { "year", yup::LLMSchema::integer() },
+});
+auto structured = client->complete (request);
+```
+
+## Related areas
+
+- [Scripting](../scripting/index.md) — Python bindings are available for `yup_ai`
+ when both modules are linked.
+
+```{toctree}
+:hidden:
+:maxdepth: 2
+
+llm
+mcp
+embedding
+```
diff --git a/docs/ai/llm.md b/docs/ai/llm.md
new file mode 100644
index 000000000..3654218f6
--- /dev/null
+++ b/docs/ai/llm.md
@@ -0,0 +1,233 @@
+# LLM clients
+
+The `yup_ai` module provides a uniform chat-completion interface across OpenAI,
+Anthropic, Google Gemini, Ollama, DeepSeek, OpenRouter, and llama-server.
+
+All providers are created through `LLMClientFactory` and share the same
+`LLMClient` API — change the provider by changing one option.
+
+## Creating a client
+
+Use `LLMClientFactory::create()` with an `LLMClient::Options` struct, or one of
+the convenience factories:
+
+```cpp
+// OpenAI Chat Completions (also Ollama, DeepSeek, OpenRouter, llama-server)
+auto client = yup::LLMClientFactory::openAIChat (
+ "gpt-4o-mini",
+ "https://api.openai.com/v1",
+ "sk-..."
+);
+
+// Anthropic Messages API
+auto client = yup::LLMClientFactory::anthropic (
+ "claude-opus-4-5",
+ "sk-ant-...",
+ "https://api.anthropic.com/v1"
+);
+
+// Google Gemini
+auto client = yup::LLMClientFactory::gemini (
+ "gemini-2.5-flash",
+ "AIza...",
+ "https://generativelanguage.googleapis.com"
+);
+
+// OpenAI Responses API (GPT-5+, reasoning models)
+auto client = yup::LLMClientFactory::openAIResponses (
+ "gpt-5",
+ "sk-..."
+);
+
+// Full control with LLMClientFactory::create
+yup::LLMClient::Options opts;
+opts.provider = yup::LLMClient::Provider::OpenAIChat;
+opts.model = "llama3.2";
+opts.baseUrl = "http://localhost:11434/v1"; // Ollama
+opts.timeoutMs = 60000;
+auto client = yup::LLMClientFactory::create (opts);
+```
+
+```{note}
+When targeting a local server (Ollama, llama-server), set `baseUrl` to the
+server's `/v1` endpoint and omit `apiKey`.
+```
+
+## Provider options
+
+| Option | Description |
+|--------|-------------|
+| `provider` | Backend selector (`OpenAIChat`, `OpenAIResponses`, `Anthropic`, `Gemini`) |
+| `model` | Model name string |
+| `baseUrl` | API base URL |
+| `apiKey` | API key or bearer token |
+| `timeoutMs` | HTTP timeout in milliseconds (default 120000) |
+| `maxRetries` | Number of retries on transient failure (default 2) |
+| `maxTokens` | Default max output tokens (0 = provider default) |
+| `reasoningEffort` | `"none"`, `"low"`, `"medium"`, `"high"` — for o-series and Gemini 2.5 |
+| `grammar` | Default GBNF grammar for llama-server constrained decoding |
+| `noTemperature` | Set `true` for models that reject temperature (GPT-5 series) |
+| `userAgent` | Application identifier for User-Agent header and prompt cache key |
+
+## Chat completions
+
+### Non-streaming
+
+```cpp
+yup::LLMClient::Request request;
+request.messages.push_back (yup::LLMMessage::system ("You are a helpful assistant."));
+request.messages.push_back (yup::LLMMessage::user ("Tell me a joke."));
+request.temperature = 0.7f;
+request.maxTokens = 256;
+
+auto response = client->complete (request);
+
+if (response.failed())
+ DBG ("Error: " << *response.errorMessage);
+else
+ DBG (response.choices.front().message.content);
+```
+
+### Streaming
+
+```cpp
+yup::LLMClient::Request request;
+request.messages.push_back (yup::LLMMessage::user ("Write a haiku about C++."));
+
+client->completeStreaming (request, [](const yup::LLMResponse& chunk)
+{
+ if (! chunk.choices.empty())
+ std::cout << chunk.choices.front().message.content << std::flush;
+});
+```
+
+### Convenience helpers
+
+```cpp
+// Single user message
+auto response = client->chat ("Hello!");
+
+// Single user message with all tools from a registry
+auto response = client->chatWithTools ("What time is it?", toolRegistry);
+```
+
+## Messages
+
+`LLMMessage` represents one turn in the conversation with four roles:
+
+```cpp
+auto systemMsg = yup::LLMMessage::system ("You are a calculator.");
+auto userMsg = yup::LLMMessage::user ("What is 2 + 3?");
+auto assistantMsg = yup::LLMMessage::assistant ("The answer is 5.");
+auto toolMsg = yup::LLMMessage::toolResult ("call_123", "5");
+```
+
+Messages serialise to OpenAI ChatML format with `toVar()` and parse from it with
+`fromVar()`.
+
+## Tools (function calling)
+
+Define a callable function with `LLMTool`:
+
+```cpp
+yup::LLMTool tool;
+tool.name = "get_weather";
+tool.description = "Get current weather for a city";
+tool.parameters = {
+ { "city", "string", "City name", true },
+ { "country", "string", "Country code", false }
+};
+tool.setHandler ([](const yup::var& args) -> yup::var
+{
+ auto city = args["city"].toString();
+ return yup::DynamicObject::Ptr (new yup::DynamicObject ({
+ { "temperature", 22 },
+ { "condition", "sunny" }
+ }));
+});
+```
+
+Register tools in a thread-safe `LLMToolRegistry`:
+
+```cpp
+yup::LLMToolRegistry registry;
+registry.registerTool (std::move (tool));
+
+yup::LLMClient::Request request;
+request.messages.push_back (yup::LLMMessage::user ("What's the weather in Rome?"));
+request.tools = registry.getAllTools();
+
+auto response = client->complete (request);
+if (response.hasToolCalls())
+{
+ for (auto& tc : response.getToolCalls())
+ {
+ auto result = registry.dispatchToolCall (tc.name, tc.arguments);
+ request.messages.push_back (yup::LLMMessage::toolResult (tc.id, JSON::toString (result)));
+ }
+ // Continue conversation with tool results...
+}
+```
+
+### Automatic tool loop
+
+`runToolLoop()` automates the tool round-trip:
+
+```cpp
+yup::LLMClient::Request request;
+request.messages = { yup::LLMMessage::user ("What's the weather in Rome and Paris?") };
+
+auto finalResponse = client->runToolLoop (request, registry);
+// finalResponse contains the model's final answer after all tool calls
+```
+
+## Structured output
+
+Request JSON output with `LLMSchema`:
+
+```cpp
+yup::LLMClient::Request request;
+request.messages.push_back (yup::LLMMessage::user ("Extract key facts from this article..."));
+request.schema = yup::LLMSchema::object ({
+ { "title", yup::LLMSchema::string() },
+ { "summary", yup::LLMSchema::string() },
+ { "year", yup::LLMSchema::integer() },
+ { "categories", yup::LLMSchema::array (yup::LLMSchema::string()) },
+ { "sentiment", yup::LLMSchema::oneOf ({ "positive", "negative", "neutral" }) },
+});
+
+auto response = client->complete (request);
+// response is guaranteed to match the schema (on supporting providers)
+```
+
+```{note}
+Structured output is supported on all four providers: OpenAI Chat (response_format),
+OpenAI Responses (text.format), Anthropic (tool_use with JSON Schema), and
+Gemini (response_schema).
+```
+
+## Reasoning effort
+
+For reasoning models (OpenAI o-series, Gemini 2.5), set `reasoningEffort`:
+
+```cpp
+yup::LLMClient::Options opts;
+opts.provider = yup::LLMClient::Provider::OpenAIResponses;
+opts.model = "gpt-5";
+opts.apiKey = "sk-...";
+opts.reasoningEffort = "medium";
+
+auto client = yup::LLMClientFactory::create (opts);
+```
+
+## Constrained decoding
+
+For llama-server, set a GBNF grammar for token-level output constraints:
+
+```cpp
+yup::LLMClient::Options opts;
+opts.provider = yup::LLMClient::Provider::OpenAIChat;
+opts.model = "llama3.2";
+opts.baseUrl = "http://localhost:8080/v1";
+opts.grammar = R"(root ::= "yes" | "no")";
+```
diff --git a/docs/ai/mcp.md b/docs/ai/mcp.md
new file mode 100644
index 000000000..2ab5665e2
--- /dev/null
+++ b/docs/ai/mcp.md
@@ -0,0 +1,201 @@
+# MCP (Model Context Protocol)
+
+The `yup_ai` module includes a complete [Model Context
+Protocol](https://modelcontextprotocol.io/) implementation — JSON-RPC 2.0
+types, an abstract transport layer, and client/server classes for tool and
+resource bridging.
+
+## Architecture
+
+```
+┌──────────────┐ JSON-RPC 2.0 ┌──────────────┐
+│ MCPClient │◄──────────────────►│ MCPServer │
+└──────┬───────┘ over transport └──────┬───────┘
+ │ │
+ │ sendMessage() / receiveMessage() │
+ ▼ ▼
+┌──────────────┐ ┌──────────────┐
+│ MCPTransport │ │ MCPTransport │
+│ (stdio, │ │ (stdio, │
+│ HTTP/SSE, │ │ HTTP/SSE, │
+│ inproc) │ │ inproc) │
+└──────────────┘ └──────────────┘
+```
+
+## JSON-RPC 2.0 types
+
+The foundation is standard JSON-RPC 2.0:
+
+```cpp
+#include
+
+// Build a request
+yup::JsonRpcRequest req;
+req.id = yup::var (1);
+req.method = "tools/list";
+req.params = yup::var(); // no params
+
+auto json = req.toVar(); // → {"jsonrpc":"2.0","id":1,"method":"tools/list"}
+
+// Parse a response from JSON
+auto resp = yup::JsonRpcResponse::fromVar (responseJson);
+if (resp && ! resp->isError())
+ DBG (JSON::toString (resp->result.value()));
+```
+
+Notifications omit the `id` field and expect no response:
+
+```cpp
+yup::JsonRpcRequest notification;
+notification.method = "notifications/initialized";
+// notification.id remains std::nullopt
+assert (notification.isNotification());
+```
+
+## MCPTypes
+
+Protocol-level types used by both client and server:
+
+| Type | Description |
+|------|-------------|
+| `MCPCapabilities` | Capability flags: `supportsTools`, `supportsResources`, `supportsPrompts`, `supportsLogging` |
+| `MCPToolDefinition` | A tool's name, description, and `inputSchema` (JSON Schema) |
+| `MCPResourceDefinition` | A resource's URI, name, description, and MIME type |
+
+## MCPTransport
+
+`MCPTransport` is the abstract interface connecting client and server.
+Implementations handle the wire format (stdio, HTTP, in-process queues).
+
+```cpp
+class MCPTransport
+{
+public:
+ virtual Result sendMessage (const var& message) = 0;
+ virtual ResultValue receiveMessage (int timeoutMs = -1) = 0;
+ virtual void setMessageHandler (MessageHandler handler) = 0;
+ virtual Result start() = 0;
+ virtual void stop() = 0;
+ virtual bool isConnected() const noexcept = 0;
+};
+```
+
+```{note}
+The transport only moves JSON-compatible `var` objects. Serialisation to/from
+the wire format is the transport's responsibility.
+```
+
+## MCPClient
+
+A synchronous client that connects to an MCP server through a transport.
+It handles the `initialize` handshake and exposes common MCP methods:
+
+```cpp
+// Connect via stdio or in-process transport
+auto transport = std::make_unique ("my-server --mcp");
+yup::MCPClient client (std::move (transport));
+
+// Perform the MCP handshake
+auto result = client.initialize();
+if (result.failed())
+{
+ DBG ("MCP handshake failed: " << result.getErrorMessage());
+ return;
+}
+
+// List and call tools
+auto tools = client.listTools();
+for (auto& tool : tools)
+ DBG (tool.name << ": " << tool.description);
+
+auto answer = client.callTool ("get_weather", yup::DynamicObject::Ptr (
+ new yup::DynamicObject ({ { "city", "Rome" } })));
+if (answer.wasOk())
+ DBG (JSON::toString (*answer));
+
+// List and read resources
+auto resources = client.listResources();
+auto content = client.readResource ("file:///tmp/data.json");
+```
+
+### Bridge to LLM tools
+
+`registerToolsWith()` imports remote MCP tools into a local `LLMToolRegistry`:
+
+```cpp
+yup::LLMToolRegistry registry;
+client.registerToolsWith (registry);
+
+// Now use the registry with any LLMClient
+auto response = llmClient->chatWithTools ("What's the weather?", registry);
+```
+
+## MCPServer
+
+Expose local YUP tools and resources to MCP clients. Register handlers, then
+start serving on a transport:
+
+```cpp
+yup::MCPServer::Options opts;
+opts.serverName = "Weather Service";
+opts.serverVersion = "1.0.0";
+opts.capabilities = { .supportsTools = true, .supportsResources = true };
+
+yup::MCPServer server (opts);
+
+// Register an MCP tool from a definition + handler
+yup::MCPToolDefinition tool;
+tool.name = "get_weather";
+tool.description = "Get current weather for a city";
+tool.inputSchema = yup::LLMSchema::object ({
+ { "city", yup::LLMSchema::string() },
+});
+server.registerTool (tool, [](const yup::var& args) -> yup::var
+{
+ auto city = args["city"].toString();
+ // ... fetch weather ...
+ return yup::DynamicObject::Ptr (new yup::DynamicObject ({
+ { "temperature", 22 }
+ }));
+});
+
+// Or register an LLMTool directly (MCP definition derived from JSON Schema)
+yup::LLMTool myTool;
+myTool.name = "greet";
+myTool.description = "Return a greeting";
+myTool.parameters = { { "name", "string", "Person's name", true } };
+myTool.setHandler ([](const yup::var& args)
+{
+ return yup::var ("Hello, " + args["name"].toString() + "!");
+});
+server.registerTool (std::move (myTool));
+
+// Register a readable resource
+yup::MCPResourceDefinition resource;
+resource.uri = "config://app";
+resource.name = "Application Config";
+resource.mimeType = "application/json";
+server.registerResource (resource, []() -> yup::String
+{
+ return R"({"theme": "dark", "language": "en"})";
+});
+
+// Start serving
+auto transport = std::make_unique();
+server.start (std::move (transport));
+```
+
+## Lifecycle
+
+The MCP lifecycle follows three phases:
+
+1. **Initialization** — client sends `initialize` request with capabilities;
+ server responds with its own capabilities. Client sends `notifications/initialized`.
+2. **Operation** — normal message exchange: `tools/list`, `tools/call`,
+ `resources/list`, `resources/read`, etc.
+3. **Shutdown** — transport closes or `stop()` is called.
+
+```{note}
+`MCPClient::initialize()` performs the full handshake automatically. After a
+successful `initialize()`, the client is ready for operation.
+```
diff --git a/docs/modules.md b/docs/modules.md
index aa3956a46..4b8472a5e 100644
--- a/docs/modules.md
+++ b/docs/modules.md
@@ -96,6 +96,24 @@ flowchart LR
classDef ext fill:#f3f4f6,color:#374151,stroke:#9ca3af,stroke-dasharray:4 3;
```
+## AI
+
+LLM clients, embeddings, and MCP (Model Context Protocol). See the [AI](ai/index.md) area.
+
+### yup_ai
+
+Chat-completion clients for OpenAI, Anthropic, and Gemini; function-calling tools;
+text embeddings; and MCP client/server for tool/resource bridging — all over
+HTTP or custom transports.
+
+```mermaid
+flowchart LR
+ yup_ai:::self --> yup_core
+ yup_ai --> yup_events
+ classDef self fill:#6366f1,color:#fff,stroke:#4f46e5;
+ classDef ext fill:#f3f4f6,color:#374151,stroke:#9ca3af,stroke-dasharray:4 3;
+```
+
## Graphics
The rendering stack. See the [Graphics](graphics/index.md) area; bitmap image
@@ -339,20 +357,21 @@ Bindings for driving YUP from scripts. See the [Scripting](scripting/index.md) a
Python bindings for creating and driving YUP applications from scripts. Its only
hard dependency is `yup_core`; it additionally generates bindings for
-`yup_events`, `yup_data_model`, `yup_graphics`, `yup_gui`, `yup_audio_basics`,
-`yup_audio_devices`, and `yup_audio_processors` when those modules are present in
+`yup_events`, `yup_data_model`, `yup_graphics`, `yup_gui`, `yup_ai`,
+`yup_audio_basics`, `yup_audio_devices`, and `yup_audio_processors` when those modules are present in
the build.
```mermaid
flowchart LR
yup_python:::self --> yup_core
- yup_python -. optional .-> yup_events:::opt
- yup_python -. optional .-> yup_data_model:::opt
- yup_python -. optional .-> yup_graphics:::opt
- yup_python -. optional .-> yup_gui:::opt
+ yup_python -. optional .-> yup_ai:::opt
yup_python -. optional .-> yup_audio_basics:::opt
yup_python -. optional .-> yup_audio_devices:::opt
yup_python -. optional .-> yup_audio_processors:::opt
+ yup_python -. optional .-> yup_data_model:::opt
+ yup_python -. optional .-> yup_events:::opt
+ yup_python -. optional .-> yup_graphics:::opt
+ yup_python -. optional .-> yup_gui:::opt
classDef self fill:#6366f1,color:#fff,stroke:#4f46e5;
classDef ext fill:#f3f4f6,color:#374151,stroke:#9ca3af,stroke-dasharray:4 3;
classDef opt fill:#fff7ed,color:#9a3412,stroke:#fb923c,stroke-dasharray:2 2;
@@ -369,6 +388,8 @@ flowchart TD
events[yup_events] --> core
shading[yup_shading] --> core
python[yup_python] --> core
+ ai[yup_ai] --> core
+ ai --> events
graphics[yup_graphics] --> core
graphics --> simd
diff --git a/examples/graphics/CMakeLists.txt b/examples/graphics/CMakeLists.txt
index bb4bb3999..79c9124dc 100644
--- a/examples/graphics/CMakeLists.txt
+++ b/examples/graphics/CMakeLists.txt
@@ -99,6 +99,7 @@ yup_standalone_app (
yup::yup_audio_processors
yup::yup_audio_formats
yup::yup_shading
+ yup::yup_ai
bungee_library
pffft_library
opus_library
diff --git a/examples/graphics/source/examples/AI.h b/examples/graphics/source/examples/AI.h
new file mode 100644
index 000000000..3603a5e2b
--- /dev/null
+++ b/examples/graphics/source/examples/AI.h
@@ -0,0 +1,564 @@
+/*
+ ==============================================================================
+
+ 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();
+
+ //======================================================================
+ // 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.setMultiLine (false);
+ addAndMakeVisible (modelEditor);
+
+ //======================================================================
+ // Base URL
+ baseUrlLabel.setText ("Base URL", yup::dontSendNotification);
+ addAndMakeVisible (baseUrlLabel);
+
+ 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.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]
+ {
+ askModel();
+ };
+ addAndMakeVisible (askButton);
+
+ // Tools are supported by OpenAI Chat and Gemini providers.
+ toolsToggle.setButtonText ("Tools");
+ toolsToggle.setToggleState (true, yup::dontSendNotification);
+ addAndMakeVisible (toolsToggle);
+
+ statusLabel.setText ("Select a provider and ask a question.", yup::dontSendNotification);
+ addAndMakeVisible (statusLabel);
+
+ //======================================================================
+ // Response
+ responseLabel.setText ("Response", yup::dontSendNotification);
+ addAndMakeVisible (responseLabel);
+
+ responseEditor.setMultiLine (true);
+ responseEditor.setReadOnly (true);
+ responseEditor.setText ("", yup::dontSendNotification);
+ addAndMakeVisible (responseEditor);
+
+ // Apply defaults for the initial provider.
+ selectProvider (SelectedProvider::OpenAIChat);
+ }
+
+ ~AIDemo() override
+ {
+ if (requestThread != nullptr)
+ requestThread->stopThread (-1);
+ }
+
+ void resized() override
+ {
+ 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);
+
+ constexpr int columnGap = 12;
+ constexpr int labelH = 20;
+ constexpr int editorH = 28;
+ constexpr int rowH = labelH + 4 + editorH;
+
+ // 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 (left.removeFromTop (labelH));
+ left.removeFromTop (4);
+ modelEditor.setBounds (left);
+
+ baseUrlLabel.setBounds (row.removeFromTop (labelH));
+ row.removeFromTop (4);
+ baseUrlEditor.setBounds (row);
+ }
+ area.removeFromTop (8);
+
+ // Row 2: API Key (left) | Reasoning effort (right)
+ {
+ auto row = area.removeFromTop (rowH);
+ auto left = row.removeFromLeft ((row.getWidth() - columnGap) / 2);
+ row.removeFromLeft (columnGap);
+
+ apiKeyLabel.setBounds (left.removeFromTop (labelH));
+ left.removeFromTop (4);
+ apiKeyEditor.setBounds (left);
+
+ reasoningLabel.setBounds (row.removeFromTop (labelH));
+ row.removeFromTop (4);
+ reasoningEditor.setBounds (row);
+ }
+ area.removeFromTop (14);
+
+ // Prompt
+ promptLabel.setBounds (area.removeFromTop (labelH));
+ area.removeFromTop (4);
+ promptEditor.setBounds (area.removeFromTop (90));
+ area.removeFromTop (12);
+
+ // 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);
+
+ // 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.fillAll();
+
+ g.setStrokeColor (yup::Colors::darkgray);
+ g.setStrokeWidth (1.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:
+ //==========================================================================
+ 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:
+ AiRequestThread (AIDemo& ownerToUse,
+ yup::LLMClient::Options optionsToUse,
+ yup::String promptToUse,
+ bool useToolsToUse)
+ : Thread ("AiRequest")
+ , owner (ownerToUse)
+ , clientOptions (std::move (optionsToUse))
+ , prompt (std::move (promptToUse))
+ , useTools (useToolsToUse)
+ , ownerReference (&ownerToUse)
+ {
+ }
+
+ void run() override
+ {
+ 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));
+ request.temperature = 0.2f;
+
+ yup::LLMToolRegistry toolRegistry;
+ if (useTools)
+ {
+ request.systemPrompt =
+ "You are a concise assistant inside a YUP example app. "
+ "If the user asks to change the page background, call set_background_color "
+ "with a CSS color name, #RRGGBB value, rgb(...), or hsl(...). "
+ "After a tool result, briefly tell the user what changed.";
+
+ owner.registerTools (toolRegistry, ownerReference);
+ request.tools = toolRegistry.getAllTools();
+ request.toolChoice = "auto";
+ }
+
+ auto response = client->runToolLoop (request, toolRegistry);
+
+ yup::String responseText;
+ if (response.failed() && response.errorMessage.has_value())
+ responseText = "Error: " + *response.errorMessage;
+ else if (! response.choices.empty())
+ responseText = response.choices.front().message.content.trim();
+
+ if (responseText.isEmpty())
+ 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, result]
+ {
+ if (weakOwner.get() == nullptr)
+ return;
+
+ ownerPtr->handleResponse (result);
+ });
+ }
+
+ AIDemo& owner;
+ yup::LLMClient::Options clientOptions;
+ yup::String prompt;
+ bool useTools;
+ yup::WeakReference ownerReference;
+ };
+
+ //==========================================================================
+ void askModel()
+ {
+ if (requestThread != nullptr && requestThread->isThreadRunning())
+ {
+ statusLabel.setText ("A request is already running.", yup::dontSendNotification);
+ return;
+ }
+
+ requestThread.reset();
+
+ const auto model = modelEditor.getText().trim();
+ const auto baseUrl = baseUrlEditor.getText().trim();
+ const auto apiKey = apiKeyEditor.getText().trim();
+ const auto reasoning = reasoningEditor.getText().trim();
+ const auto prompt = promptEditor.getText().trim();
+ const auto useTools = toolsToggle.getToggleState() && toolsToggle.isEnabled();
+
+ if (model.isEmpty() || baseUrl.isEmpty() || prompt.isEmpty())
+ {
+ statusLabel.setText ("Model, base URL, and prompt are required.", yup::dontSendNotification);
+ 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 response...", yup::dontSendNotification);
+ responseEditor.setText ("", yup::dontSendNotification);
+
+ requestThread = std::make_unique (*this, std::move (options), prompt, useTools);
+
+ if (! requestThread->startThread (yup::Thread::Priority::background))
+ {
+ requestThread.reset();
+ statusLabel.setText ("Unable to start 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);
+
+ // 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)
+ {
+ yup::LLMTool colorTool;
+ colorTool.name = "set_background_color";
+ colorTool.description = "Changes the visible background color of the current YUP example component.";
+
+ 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;
+
+ 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* obj = result.getDynamicObject())
+ {
+ obj->setProperty ("success", true);
+ obj->setProperty ("color", colorValue);
+ obj->setProperty ("message", yup::String ("Background color updated."));
+ }
+
+ return result;
+ });
+
+ registry.registerTool (std::move (colorTool));
+ }
+
+ void setBackgroundColor (yup::Color color)
+ {
+ backgroundColor = color;
+ repaint();
+ }
+
+ //==========================================================================
+ // Title
+ yup::Label titleLabel { "titleLabel" };
+ 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" };
+
+ // Action row
+ yup::TextButton askButton { "askButton" };
+ yup::ToggleButton toolsToggle { "toolsToggle" };
+ yup::Label statusLabel { "statusLabel" };
+
+ // Response
+ yup::Label responseLabel { "responseLabel" };
+ yup::TextEditor responseEditor { "responseEditor" };
+
+ // State
+ std::optional backgroundColor;
+ std::unique_ptr requestThread;
+};
diff --git a/examples/graphics/source/main.cpp b/examples/graphics/source/main.cpp
index c5a0ad97e..50faf849f 100644
--- a/examples/graphics/source/main.cpp
+++ b/examples/graphics/source/main.cpp
@@ -26,6 +26,7 @@
#include
#include
#include
+#include
#if YUP_MODULE_AVAILABLE_yup_python
#include
#endif
@@ -62,6 +63,7 @@ inline yup::File getAssetPath (yup::StringRef subPath = {})
//==============================================================================
#include "examples/Artboard.h"
+#include "examples/AI.h"
#include "examples/Audio.h"
#include "examples/AudioFileDemo.h"
#include "examples/ClipboardDemo.h"
@@ -168,116 +170,38 @@ class CustomWindow
components.add (nullptr);
};
- addDemo ("Artboard", []
- {
- return std::make_unique();
- });
- addDemo ("Audio", []
- {
- return std::make_unique();
- });
- addDemo ("Audio File", []
- {
- return std::make_unique();
- });
- addDemo ("Clipboard", []
- {
- return std::make_unique();
- });
- addDemo ("Color Lab", []
- {
- return std::make_unique();
- });
- addDemo ("Component Effects", []
- {
- return std::make_unique();
- });
- addDemo ("Convolution Demo", []
- {
- return std::make_unique();
- });
- addDemo ("Crossover Demo", []
- {
- return std::make_unique();
- });
- addDemo ("File Chooser", []
- {
- return std::make_unique();
- });
- addDemo ("Filter Demo", []
- {
- return std::make_unique();
- });
- addDemo ("Images", []
- {
- return std::make_unique();
- });
- addDemo ("Layout Fonts", []
- {
- return std::make_unique();
- });
- addDemo ("Lottie", []
- {
- return std::make_unique();
- });
- addDemo ("Offscreen Render", []
- {
- return std::make_unique();
- });
- addDemo ("Opaque Demo", []
- {
- return std::make_unique();
- });
- addDemo ("Paint Profiler", []
- {
- return std::make_unique();
- });
- addDemo ("Paths", []
- {
- return std::make_unique();
- });
- addDemo ("Popup Menu", []
- {
- return std::make_unique();
- });
- addDemo ("ScrollBar", []
- {
- return std::make_unique();
- });
- addDemo ("Sliders", []
- {
- return std::make_unique();
- });
- addDemo ("FFT Analyzer", []
- {
- return std::make_unique();
- });
- addDemo ("Spinning Cube", []
- {
- return std::make_unique();
- });
- addDemo ("SVG", []
- {
- return std::make_unique();
- });
- addDemo ("Text Editor", []
- {
- return std::make_unique();
- });
- addDemo ("Variable Fonts", []
- {
- return std::make_unique();
- });
- addDemo ("Widgets", []
- {
- return std::make_unique();
- });
+ // clang-format off
+ addDemo ("AI", [] { return std::make_unique(); });
+ addDemo ("Artboard", [] { return std::make_unique(); });
+ addDemo ("Audio", [] { return std::make_unique(); });
+ addDemo ("Audio File", [] { return std::make_unique(); });
+ addDemo ("Clipboard", [] { return std::make_unique(); });
+ addDemo ("Color Lab", [] { return std::make_unique(); });
+ addDemo ("Component Effects", [] { return std::make_unique(); });
+ addDemo ("Convolution Demo", [] { return std::make_unique(); });
+ addDemo ("Crossover Demo", [] { return std::make_unique(); });
+ addDemo ("File Chooser", [] { return std::make_unique(); });
+ addDemo ("Filter Demo", [] { return std::make_unique(); });
+ addDemo ("Images", [] { return std::make_unique(); });
+ addDemo ("Layout Fonts", [] { return std::make_unique(); });
+ addDemo ("Lottie", [] { return std::make_unique(); });
+ addDemo ("Offscreen Render", [] { return std::make_unique(); });
+ addDemo ("Opaque Demo", [] { return std::make_unique(); });
+ addDemo ("Paint Profiler", [] { return std::make_unique(); });
+ addDemo ("Paths", [] { return std::make_unique(); });
+ addDemo ("Popup Menu", [] { return std::make_unique(); });
+ addDemo ("ScrollBar", [] { return std::make_unique(); });
+ addDemo ("Sliders", [] { return std::make_unique(); });
+ addDemo ("FFT Analyzer", [] { return std::make_unique(); });
+ addDemo ("Spinning Cube", [] { return std::make_unique(); });
+ addDemo ("SVG", [] { return std::make_unique(); });
+ addDemo ("Text Editor", [] { return std::make_unique(); });
+ addDemo ("Variable Fonts", [] { return std::make_unique(); });
+ addDemo ("Widgets", [] { return std::make_unique(); });
#if YUP_MODULE_AVAILABLE_yup_python
- addDemo ("Python", []
- {
- return std::make_unique();
- });
+ addDemo ("Python", [] { return std::make_unique(); });
#endif
+ // clang-format on
// Create the ListBox with the demo names
listModel = std::make_unique (demoNames);
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..645ed764f
--- /dev/null
+++ b/modules/yup_ai/llm/yup_LLMClient.cpp
@@ -0,0 +1,211 @@
+/*
+ ==============================================================================
+
+ This file is part of the YUP library.
+ Copyright (c) 2026 - kunitoki@gmail.com
+
+ YUP is an open source library subject to open-source licensing.
+
+ The code included in this file is provided under the terms of the ISC license
+ http://www.isc.org/downloads/software-support-policy/isc-license. Permission
+ to use, copy, modify, and/or distribute this software for any purpose with or
+ without fee is hereby granted provided that the above copyright notice and
+ this permission notice appear in all copies.
+
+ YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
+ EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
+ DISCLAIMED.
+
+ ==============================================================================
+*/
+
+namespace yup
+{
+namespace
+{
+var makeLLMClientObject()
+{
+ return var (std::make_unique());
+}
+
+void setLLMClientProperty (var& object, const Identifier& name, const var& value)
+{
+ if (auto* dynamicObject = object.getDynamicObject())
+ dynamicObject->setProperty (name, value);
+}
+
+var toolChoiceToVar (const String& toolChoice)
+{
+ if (toolChoice == "auto" || toolChoice == "none" || toolChoice == "required")
+ return toolChoice;
+
+ auto functionObject = makeLLMClientObject();
+ setLLMClientProperty (functionObject, "name", toolChoice);
+
+ auto object = makeLLMClientObject();
+ setLLMClientProperty (object, "type", "function");
+ setLLMClientProperty (object, "function", functionObject);
+
+ return object;
+}
+} // namespace
+
+LLMClient::LLMClient (Options optionsToUse)
+ : options (std::move (optionsToUse))
+{
+}
+
+LLMClient::~LLMClient() = default;
+
+LLMResponse LLMClient::chat (const String& userMessage)
+{
+ Request request;
+ request.messages.push_back (LLMMessage::user (userMessage));
+ return complete (request);
+}
+
+LLMResponse LLMClient::chatWithTools (const String& userMessage, const LLMToolRegistry& tools)
+{
+ Request request;
+ request.messages.push_back (LLMMessage::user (userMessage));
+ request.tools = tools.getAllTools();
+ request.toolChoice = "auto";
+ return complete (request);
+}
+
+LLMResponse LLMClient::runToolLoop (const Request& request, LLMToolRegistry& tools)
+{
+ constexpr int maxToolIterations = 8;
+
+ Request current = request;
+ if (current.tools.empty())
+ current.tools = tools.getAllTools();
+
+ auto response = complete (current);
+
+ for (int iteration = 0; iteration < maxToolIterations && response.hasToolCalls(); ++iteration)
+ {
+ for (const auto& choice : response.choices)
+ current.messages.push_back (choice.message);
+
+ for (const auto& toolCall : response.getToolCalls())
+ {
+ auto result = tools.dispatchToolCall (toolCall.name, toolCall.arguments);
+
+ 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);
+ }
+
+ return response;
+}
+
+String LLMClient::buildChatCompletionBody (const Request& request, bool stream) const
+{
+ auto object = makeLLMClientObject();
+
+ if (options.model.isNotEmpty())
+ setLLMClientProperty (object, "model", options.model);
+
+ std::vector messages;
+ messages.reserve (request.messages.size() + (request.systemPrompt.has_value() ? 1u : 0u));
+
+ if (request.systemPrompt.has_value())
+ messages.push_back (LLMMessage::system (*request.systemPrompt));
+
+ messages.insert (messages.end(), request.messages.begin(), request.messages.end());
+
+ setLLMClientProperty (object, "messages", messagesToVar (messages));
+ setLLMClientProperty (object, "stream", stream);
+
+ if (! request.tools.empty())
+ setLLMClientProperty (object, "tools", toolsToVar (request.tools));
+
+ if (request.toolChoice.has_value())
+ setLLMClientProperty (object, "tool_choice", toolChoiceToVar (*request.toolChoice));
+
+ if (! 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));
+
+ // 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())
+ {
+ var stop;
+
+ for (const auto& stopSequence : *request.stopSequences)
+ stop.append (stopSequence);
+
+ 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);
+}
+
+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..930b83047
--- /dev/null
+++ b/modules/yup_ai/llm/yup_LLMClient.h
@@ -0,0 +1,109 @@
+/*
+ ==============================================================================
+
+ 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:
+ /** 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; ///< "auto", "none", "required", or a specific function name.
+
+ std::optional temperature;
+ std::optional topP;
+ 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);
+ 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_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
new file mode 100644
index 000000000..1947dbc87
--- /dev/null
+++ b/modules/yup_ai/llm/yup_LLMHttpClient.cpp
@@ -0,0 +1,189 @@
+/*
+ ==============================================================================
+
+ 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
+{
+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.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 (headers)
+ .withConnectionTimeoutMs (owner.options.timeoutMs)
+ .withStatusCode (&statusCode)
+ .withHttpRequestCmd ("POST");
+
+ auto stream = url.createInputStream (options);
+ const auto responseBody = stream != nullptr ? stream->readEntireStreamAsString() : String();
+
+ if (stream != nullptr && statusCode >= 200 && statusCode < 300)
+ return owner.parseResponse (JSON::parse (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, LLMHttpClient::ChunkCallback onChunk)
+ {
+ if (! onChunk)
+ return false;
+
+ 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 (headers)
+ .withConnectionTimeoutMs (owner.options.timeoutMs)
+ .withStatusCode (&statusCode)
+ .withHttpRequestCmd ("POST");
+
+ auto stream = url.createInputStream (options);
+
+ if (stream != nullptr && statusCode >= 200 && statusCode < 300)
+ {
+ LLMResponse accumulatedResponse;
+
+ while (! stream->isExhausted())
+ {
+ auto line = stream->readNextLine().trim();
+
+ if (! line.startsWith ("data:"))
+ continue;
+
+ auto payload = line.substring (5).trim();
+ if (payload == "[DONE]")
+ return true;
+
+ auto parsed = JSON::parse (payload);
+ auto chunk = owner.parseChunk (parsed);
+
+ accumulatedResponse.appendStreamChunk (chunk);
+ onChunk (accumulatedResponse);
+
+ if (chunk.failed())
+ return false;
+ }
+
+ return true;
+ }
+
+ if (! shouldRetryAiStatus (statusCode) || attempt == owner.options.maxRetries)
+ break;
+ }
+
+ return false;
+ }
+
+ LLMHttpClient& owner;
+};
+
+//==============================================================================
+LLMHttpClient::LLMHttpClient (Options options)
+ : LLMClient (std::move (options))
+ , pimpl (std::make_unique (*this))
+{
+}
+
+LLMHttpClient::~LLMHttpClient() = default;
+
+LLMResponse LLMHttpClient::complete (const Request& request)
+{
+ return pimpl->complete (request);
+}
+
+bool LLMHttpClient::completeStreaming (const Request& request, ChunkCallback onChunk)
+{
+ return pimpl->completeStreaming (request, std::move (onChunk));
+}
+
+//==============================================================================
+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
new file mode 100644
index 000000000..4915254cf
--- /dev/null
+++ b/modules/yup_ai/llm/yup_LLMHttpClient.h
@@ -0,0 +1,101 @@
+/*
+ ==============================================================================
+
+ 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 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}
+*/
+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;
+
+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;
+};
+
+} // namespace yup
diff --git a/modules/yup_ai/llm/yup_LLMMessage.cpp b/modules/yup_ai/llm/yup_LLMMessage.cpp
new file mode 100644
index 000000000..157f28d40
--- /dev/null
+++ b/modules/yup_ai/llm/yup_LLMMessage.cpp
@@ -0,0 +1,223 @@
+/*
+ ==============================================================================
+
+ This file is part of the YUP library.
+ Copyright (c) 2026 - kunitoki@gmail.com
+
+ YUP is an open source library subject to open-source licensing.
+
+ The code included in this file is provided under the terms of the ISC license
+ http://www.isc.org/downloads/software-support-policy/isc-license. Permission
+ to use, copy, modify, and/or distribute this software for any purpose with or
+ without fee is hereby granted provided that the above copyright notice and
+ this permission notice appear in all copies.
+
+ YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
+ EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
+ DISCLAIMED.
+
+ ==============================================================================
+*/
+
+namespace yup
+{
+namespace
+{
+var makeLLMMessageObject()
+{
+ return var (std::make_unique());
+}
+
+void setLLMMessageProperty (var& object, const Identifier& name, const var& value)
+{
+ if (auto* dynamicObject = object.getDynamicObject())
+ dynamicObject->setProperty (name, value);
+}
+
+String argumentsToApiString (const var& arguments)
+{
+ if (arguments.isObject() || arguments.isArray())
+ return JSON::toString (arguments, true);
+
+ return arguments.toString();
+}
+
+var parseArguments (const var& arguments)
+{
+ if (! arguments.isString())
+ return arguments;
+
+ auto parsed = JSON::parse (arguments.toString());
+ return parsed.isVoid() ? arguments : parsed;
+}
+} // namespace
+
+var LLMToolCall::toVar() const
+{
+ auto functionObject = makeLLMMessageObject();
+ setLLMMessageProperty (functionObject, "name", name);
+ setLLMMessageProperty (functionObject, "arguments", argumentsToApiString (arguments));
+
+ auto object = makeLLMMessageObject();
+ setLLMMessageProperty (object, "id", id);
+ setLLMMessageProperty (object, "type", "function");
+ setLLMMessageProperty (object, "function", functionObject);
+
+ return object;
+}
+
+std::optional LLMToolCall::fromVar (const var& value)
+{
+ if (! value.isObject())
+ return std::nullopt;
+
+ LLMToolCall result;
+ result.index = static_cast (value["index"]);
+ result.id = value["id"].toString();
+
+ if (auto* functionObject = value["function"].getDynamicObject())
+ {
+ result.name = functionObject->getProperty ("name").toString();
+ result.arguments = parseArguments (functionObject->getProperty ("arguments"));
+ }
+ else
+ {
+ result.name = value["name"].toString();
+ result.arguments = parseArguments (value["arguments"]);
+ }
+
+ const auto hasArguments = ! result.arguments.isVoid()
+ && ! result.arguments.isUndefined()
+ && result.arguments.toString().isNotEmpty();
+
+ if (result.name.isEmpty() && result.id.isEmpty() && ! hasArguments)
+ return std::nullopt;
+
+ return result;
+}
+
+LLMMessage LLMMessage::system (const String& content)
+{
+ LLMMessage result;
+ result.role = Role::system;
+ result.content = content;
+ return result;
+}
+
+LLMMessage LLMMessage::user (const String& content)
+{
+ LLMMessage result;
+ result.role = Role::user;
+ result.content = content;
+ return result;
+}
+
+LLMMessage LLMMessage::assistant (const String& content)
+{
+ LLMMessage result;
+ result.role = Role::assistant;
+ result.content = content;
+ return result;
+}
+
+LLMMessage LLMMessage::toolResult (const String& toolCallId, const String& content)
+{
+ LLMMessage result;
+ result.role = Role::tool;
+ result.toolCallId = toolCallId;
+ result.content = content;
+ return result;
+}
+
+var LLMMessage::toVar() const
+{
+ auto object = makeLLMMessageObject();
+ setLLMMessageProperty (object, "role", roleToString (role));
+ setLLMMessageProperty (object, "content", content);
+
+ if (name.isNotEmpty())
+ setLLMMessageProperty (object, "name", name);
+
+ if (toolCallId.has_value())
+ setLLMMessageProperty (object, "tool_call_id", *toolCallId);
+
+ if (toolCalls.has_value())
+ {
+ var calls;
+
+ for (const auto& toolCall : *toolCalls)
+ calls.append (toolCall.toVar());
+
+ setLLMMessageProperty (object, "tool_calls", calls);
+ }
+
+ return object;
+}
+
+std::optional LLMMessage::fromVar (const var& value)
+{
+ if (! value.isObject())
+ return std::nullopt;
+
+ auto role = roleFromString (value["role"].toString());
+ if (! role.has_value())
+ return std::nullopt;
+
+ LLMMessage result;
+ result.role = *role;
+ result.content = value["content"].toString();
+ result.name = value["name"].toString();
+
+ if (value.hasProperty ("tool_call_id"))
+ result.toolCallId = value["tool_call_id"].toString();
+
+ if (auto* calls = value["tool_calls"].getArray())
+ {
+ std::vector parsedCalls;
+
+ for (const auto& call : *calls)
+ if (auto parsed = LLMToolCall::fromVar (call))
+ parsedCalls.push_back (*parsed);
+
+ result.toolCalls = std::move (parsedCalls);
+ }
+
+ return result;
+}
+
+String LLMMessage::roleToString (Role role)
+{
+ switch (role)
+ {
+ case Role::system:
+ return "system";
+ case Role::user:
+ return "user";
+ case Role::assistant:
+ return "assistant";
+ case Role::tool:
+ return "tool";
+ }
+
+ jassertfalse;
+ return "user";
+}
+
+std::optional LLMMessage::roleFromString (const String& role)
+{
+ if (role == "system")
+ return Role::system;
+
+ if (role == "user")
+ return Role::user;
+
+ if (role == "assistant")
+ return Role::assistant;
+
+ if (role == "tool")
+ return Role::tool;
+
+ return std::nullopt;
+}
+
+} // namespace yup
diff --git a/modules/yup_ai/llm/yup_LLMMessage.h b/modules/yup_ai/llm/yup_LLMMessage.h
new file mode 100644
index 000000000..40ac68f20
--- /dev/null
+++ b/modules/yup_ai/llm/yup_LLMMessage.h
@@ -0,0 +1,99 @@
+/*
+ ==============================================================================
+
+ This file is part of the YUP library.
+ Copyright (c) 2026 - kunitoki@gmail.com
+
+ YUP is an open source library subject to open-source licensing.
+
+ The code included in this file is provided under the terms of the ISC license
+ http://www.isc.org/downloads/software-support-policy/isc-license. Permission
+ to use, copy, modify, and/or distribute this software for any purpose with or
+ without fee is hereby granted provided that the above copyright notice and
+ this permission notice appear in all copies.
+
+ YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
+ EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
+ DISCLAIMED.
+
+ ==============================================================================
+*/
+
+namespace yup
+{
+
+//==============================================================================
+/** Describes a function call requested by a chat model.
+
+ Tool calls use the OpenAI-compatible shape where the model supplies an id, a
+ function name, and a JSON-compatible arguments value. String arguments returned
+ by remote APIs are parsed into var objects when possible.
+
+ @tags{AI}
+*/
+struct YUP_API LLMToolCall
+{
+ int index = 0;
+ String id;
+ String name;
+ var arguments;
+
+ /** Converts this tool call to an OpenAI-compatible JSON var object. */
+ var toVar() const;
+
+ /** Attempts to parse an OpenAI-compatible JSON var object into a tool call. */
+ static std::optional fromVar (const var& value);
+};
+
+//==============================================================================
+/** A chat message for OpenAI-compatible completion APIs.
+
+ The message can represent system, user, assistant, or tool-result content.
+ Assistant messages may carry tool calls, while tool messages use toolCallId
+ to correlate results with the requested call.
+
+ @tags{AI}
+*/
+class YUP_API LLMMessage
+{
+public:
+ enum class Role
+ {
+ system,
+ user,
+ assistant,
+ tool
+ };
+
+ Role role = Role::user;
+ String content;
+ String name;
+ std::optional> toolCalls;
+ std::optional toolCallId;
+
+ /** Creates a system message. */
+ static LLMMessage system (const String& content);
+
+ /** Creates a user message. */
+ static LLMMessage user (const String& content);
+
+ /** Creates an assistant message. */
+ static LLMMessage assistant (const String& content);
+
+ /** Creates a tool-result message correlated with a tool call id. */
+ static LLMMessage toolResult (const String& toolCallId, const String& content);
+
+ /** Converts this message to an OpenAI ChatML-compatible JSON var object. */
+ var toVar() const;
+
+ /** Attempts to parse an OpenAI ChatML-compatible JSON var object into a message. */
+ static std::optional fromVar (const var& value);
+
+ /** Converts a role enum to its API string representation. */
+ static String roleToString (Role role);
+
+ /** Parses an API role string. */
+ static std::optional roleFromString (const String& role);
+};
+
+} // namespace yup
diff --git a/modules/yup_ai/llm/yup_LLMResponse.cpp b/modules/yup_ai/llm/yup_LLMResponse.cpp
new file mode 100644
index 000000000..32abc5d70
--- /dev/null
+++ b/modules/yup_ai/llm/yup_LLMResponse.cpp
@@ -0,0 +1,246 @@
+/*
+ ==============================================================================
+
+ This file is part of the YUP library.
+ Copyright (c) 2026 - kunitoki@gmail.com
+
+ YUP is an open source library subject to open-source licensing.
+
+ The code included in this file is provided under the terms of the ISC license
+ http://www.isc.org/downloads/software-support-policy/isc-license. Permission
+ to use, copy, modify, and/or distribute this software for any purpose with or
+ without fee is hereby granted provided that the above copyright notice and
+ this permission notice appear in all copies.
+
+ YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
+ EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
+ DISCLAIMED.
+
+ ==============================================================================
+*/
+
+namespace yup
+{
+namespace
+{
+String getOpenAiErrorMessage (const var& json)
+{
+ if (json["error"].isString())
+ return json["error"].toString();
+
+ if (json["error"].isObject())
+ {
+ auto message = json["error"]["message"].toString();
+ if (message.isNotEmpty())
+ return message;
+ }
+
+ return {};
+}
+
+var parseStreamArguments (const String& arguments)
+{
+ auto parsed = JSON::parse (arguments);
+ return parsed.isVoid() ? var (arguments) : parsed;
+}
+
+String argumentsToStreamText (const var& arguments)
+{
+ if (arguments.isObject() || arguments.isArray())
+ return JSON::toString (arguments, true);
+
+ return arguments.toString();
+}
+
+LLMResponse::Choice& findOrAppendChoice (std::vector& choices, const LLMResponse::Choice& chunkChoice)
+{
+ for (auto& choice : choices)
+ if (choice.index == chunkChoice.index)
+ return choice;
+
+ choices.push_back ({});
+ auto& choice = choices.back();
+ choice.index = chunkChoice.index;
+ choice.message.role = chunkChoice.message.role;
+ return choice;
+}
+} // namespace
+
+bool LLMResponse::hasToolCalls() const noexcept
+{
+ for (const auto& choice : choices)
+ if (choice.message.toolCalls.has_value() && ! choice.message.toolCalls->empty())
+ return true;
+
+ return false;
+}
+
+bool LLMResponse::failed() const noexcept
+{
+ return errorMessage.has_value();
+}
+
+std::vector LLMResponse::getToolCalls() const
+{
+ std::vector result;
+
+ for (const auto& choice : choices)
+ if (choice.message.toolCalls.has_value())
+ result.insert (result.end(), choice.message.toolCalls->begin(), choice.message.toolCalls->end());
+
+ return result;
+}
+
+void LLMResponse::appendStreamChunk (const LLMResponse& chunk)
+{
+ if (chunk.errorMessage.has_value())
+ {
+ errorMessage = chunk.errorMessage;
+ return;
+ }
+
+ if (model.isEmpty())
+ model = chunk.model;
+
+ for (const auto& chunkChoice : chunk.choices)
+ {
+ auto& choice = findOrAppendChoice (choices, chunkChoice);
+
+ if (choice.message.role == LLMMessage::Role::assistant)
+ choice.message.role = chunkChoice.message.role;
+
+ choice.message.content += chunkChoice.message.content;
+
+ if (chunkChoice.finishReason.has_value())
+ choice.finishReason = chunkChoice.finishReason;
+
+ if (! chunkChoice.message.toolCalls.has_value())
+ continue;
+
+ if (! choice.message.toolCalls.has_value())
+ choice.message.toolCalls = std::vector();
+
+ for (const auto& chunkToolCall : *chunkChoice.message.toolCalls)
+ {
+ const auto toolIndex = chunkToolCall.index;
+ if (toolIndex < 0)
+ continue;
+
+ if (toolIndex >= static_cast (choice.message.toolCalls->size()))
+ choice.message.toolCalls->resize (static_cast (toolIndex + 1));
+
+ auto& toolCall = (*choice.message.toolCalls)[static_cast (toolIndex)];
+ toolCall.index = toolIndex;
+
+ if (chunkToolCall.id.isNotEmpty())
+ toolCall.id = chunkToolCall.id;
+
+ if (chunkToolCall.name.isNotEmpty())
+ toolCall.name = chunkToolCall.name;
+
+ const auto mergedArguments = argumentsToStreamText (toolCall.arguments) + argumentsToStreamText (chunkToolCall.arguments);
+ if (mergedArguments.isNotEmpty())
+ toolCall.arguments = parseStreamArguments (mergedArguments);
+ }
+ }
+}
+
+LLMResponse LLMResponse::fromError (const String& message)
+{
+ LLMResponse response;
+ response.errorMessage = message.isEmpty() ? String ("Unknown AI response error") : message;
+ return response;
+}
+
+LLMResponse LLMResponse::fromOpenAiJson (const var& json)
+{
+ LLMResponse response;
+
+ if (json.isVoid())
+ return fromError ("Unable to parse chat completion response JSON");
+
+ if (auto error = getOpenAiErrorMessage (json); error.isNotEmpty())
+ return fromError (error);
+
+ response.model = json["model"].toString();
+
+ if (auto* choicesArray = json["choices"].getArray())
+ {
+ for (const auto& choiceVar : *choicesArray)
+ {
+ Choice choice;
+ choice.index = static_cast (choiceVar["index"]);
+
+ if (auto message = LLMMessage::fromVar (choiceVar["message"]))
+ choice.message = *message;
+
+ if (choiceVar.hasProperty ("finish_reason") && ! choiceVar["finish_reason"].isVoid())
+ choice.finishReason = choiceVar["finish_reason"].toString();
+
+ response.choices.push_back (std::move (choice));
+ }
+ }
+
+ if (json["usage"].isObject())
+ {
+ Usage usage;
+ usage.promptTokens = static_cast (json["usage"]["prompt_tokens"]);
+ usage.completionTokens = static_cast (json["usage"]["completion_tokens"]);
+ usage.totalTokens = static_cast (json["usage"]["total_tokens"]);
+ response.usage = usage;
+ }
+
+ return response;
+}
+
+LLMResponse LLMResponse::fromStreamChunk (const var& json)
+{
+ LLMResponse response;
+
+ if (json.isVoid())
+ return fromError ("Unable to parse chat completion stream JSON");
+
+ if (auto error = getOpenAiErrorMessage (json); error.isNotEmpty())
+ return fromError (error);
+
+ response.model = json["model"].toString();
+
+ if (auto* choicesArray = json["choices"].getArray())
+ {
+ for (const auto& choiceVar : *choicesArray)
+ {
+ Choice choice;
+ choice.index = static_cast (choiceVar["index"]);
+ choice.message.role = LLMMessage::Role::assistant;
+
+ const auto& delta = choiceVar["delta"];
+ if (delta.isObject())
+ {
+ if (auto role = LLMMessage::roleFromString (delta["role"].toString()))
+ choice.message.role = *role;
+
+ choice.message.content = delta["content"].toString();
+
+ if (auto* toolCallsArray = delta["tool_calls"].getArray())
+ {
+ std::vector toolCalls;
+
+ for (const auto& callVar : *toolCallsArray)
+ if (auto toolCall = LLMToolCall::fromVar (callVar))
+ toolCalls.push_back (*toolCall);
+
+ choice.message.toolCalls = std::move (toolCalls);
+ }
+ }
+
+ if (choiceVar.hasProperty ("finish_reason") && ! choiceVar["finish_reason"].isVoid())
+ choice.finishReason = choiceVar["finish_reason"].toString();
+
+ response.choices.push_back (std::move (choice));
+ }
+ }
+
+ return response;
+}
+
+} // namespace yup
diff --git a/modules/yup_ai/llm/yup_LLMResponse.h b/modules/yup_ai/llm/yup_LLMResponse.h
new file mode 100644
index 000000000..341239413
--- /dev/null
+++ b/modules/yup_ai/llm/yup_LLMResponse.h
@@ -0,0 +1,74 @@
+/*
+ ==============================================================================
+
+ This file is part of the YUP library.
+ Copyright (c) 2026 - kunitoki@gmail.com
+
+ YUP is an open source library subject to open-source licensing.
+
+ The code included in this file is provided under the terms of the ISC license
+ http://www.isc.org/downloads/software-support-policy/isc-license. Permission
+ to use, copy, modify, and/or distribute this software for any purpose with or
+ without fee is hereby granted provided that the above copyright notice and
+ this permission notice appear in all copies.
+
+ YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
+ EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
+ DISCLAIMED.
+
+ ==============================================================================
+*/
+
+namespace yup
+{
+
+//==============================================================================
+/** Parsed chat completion response.
+
+ @tags{AI}
+*/
+class YUP_API LLMResponse
+{
+public:
+ struct Choice
+ {
+ int index = 0;
+ LLMMessage message;
+ std::optional finishReason;
+ };
+
+ struct Usage
+ {
+ int promptTokens = 0;
+ int completionTokens = 0;
+ int totalTokens = 0;
+ };
+
+ std::vector choices;
+ std::optional usage;
+ String model;
+ std::optional errorMessage;
+
+ /** Returns true if any choice contains assistant tool calls. */
+ bool hasToolCalls() const noexcept;
+
+ /** Returns true if this response represents an API, transport, or parse error. */
+ bool failed() const noexcept;
+
+ /** Returns all tool calls from all choices. */
+ std::vector getToolCalls() const;
+
+ /** Appends a streaming response chunk to this response, concatenating content and tool-call arguments by choice index. */
+ void appendStreamChunk (const LLMResponse& chunk);
+
+ /** Creates an error response with a diagnostic message. */
+ static LLMResponse fromError (const String& message);
+
+ /** Parses a non-streaming OpenAI-compatible chat completion response. */
+ static LLMResponse fromOpenAiJson (const var& json);
+
+ /** Parses a streaming OpenAI-compatible chat completion delta chunk. */
+ static LLMResponse fromStreamChunk (const var& json);
+};
+
+} // namespace yup
diff --git a/modules/yup_ai/llm/yup_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/llm/yup_LLMTool.cpp b/modules/yup_ai/llm/yup_LLMTool.cpp
new file mode 100644
index 000000000..30b5865bb
--- /dev/null
+++ b/modules/yup_ai/llm/yup_LLMTool.cpp
@@ -0,0 +1,127 @@
+/*
+ ==============================================================================
+
+ This file is part of the YUP library.
+ Copyright (c) 2026 - kunitoki@gmail.com
+
+ YUP is an open source library subject to open-source licensing.
+
+ The code included in this file is provided under the terms of the ISC license
+ http://www.isc.org/downloads/software-support-policy/isc-license. Permission
+ to use, copy, modify, and/or distribute this software for any purpose with or
+ without fee is hereby granted provided that the above copyright notice and
+ this permission notice appear in all copies.
+
+ YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
+ EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
+ DISCLAIMED.
+
+ ==============================================================================
+*/
+
+namespace yup
+{
+namespace
+{
+var makeLLMToolObject()
+{
+ return var (std::make_unique());
+}
+
+void setLLMToolProperty (var& object, const Identifier& name, const var& value)
+{
+ if (auto* dynamicObject = object.getDynamicObject())
+ dynamicObject->setProperty (name, value);
+}
+
+var makeErrorObject (const String& message)
+{
+ auto object = makeLLMToolObject();
+ setLLMToolProperty (object, "error", true);
+ setLLMToolProperty (object, "message", message);
+ return object;
+}
+
+var parameterToSchema (const LLMTool::Parameter& parameter)
+{
+ auto schema = makeLLMToolObject();
+ setLLMToolProperty (schema, "type", parameter.type);
+
+ if (parameter.description.isNotEmpty())
+ setLLMToolProperty (schema, "description", parameter.description);
+
+ if (parameter.enumValues.has_value())
+ setLLMToolProperty (schema, "enum", *parameter.enumValues);
+
+ if (parameter.defaultValue.has_value())
+ setLLMToolProperty (schema, "default", *parameter.defaultValue);
+
+ if (parameter.properties.has_value())
+ {
+ auto properties = makeLLMToolObject();
+ var required;
+
+ for (const auto& child : *parameter.properties)
+ {
+ setLLMToolProperty (properties, child.name, parameterToSchema (child));
+
+ if (child.required)
+ required.append (child.name);
+ }
+
+ setLLMToolProperty (schema, "properties", properties);
+
+ if (required.size() > 0)
+ setLLMToolProperty (schema, "required", required);
+ }
+
+ return schema;
+}
+} // namespace
+
+var LLMTool::toJsonSchema() const
+{
+ auto properties = makeLLMToolObject();
+ var required;
+
+ for (const auto& parameter : parameters)
+ {
+ setLLMToolProperty (properties, parameter.name, parameterToSchema (parameter));
+
+ if (parameter.required)
+ required.append (parameter.name);
+ }
+
+ auto parameterSchema = makeLLMToolObject();
+ setLLMToolProperty (parameterSchema, "type", "object");
+ setLLMToolProperty (parameterSchema, "properties", properties);
+
+ if (required.size() > 0)
+ setLLMToolProperty (parameterSchema, "required", required);
+
+ auto functionObject = makeLLMToolObject();
+ setLLMToolProperty (functionObject, "name", name);
+ setLLMToolProperty (functionObject, "description", description);
+ setLLMToolProperty (functionObject, "parameters", parameterSchema);
+
+ auto toolObject = makeLLMToolObject();
+ setLLMToolProperty (toolObject, "type", "function");
+ setLLMToolProperty (toolObject, "function", functionObject);
+
+ return toolObject;
+}
+
+var LLMTool::execute (const var& arguments) const
+{
+ if (! handler)
+ return makeErrorObject ("No handler registered for tool '" + name + "'");
+
+ return handler (arguments);
+}
+
+void LLMTool::setHandler (Handler newHandler)
+{
+ handler = std::move (newHandler);
+}
+
+} // namespace yup
diff --git a/modules/yup_ai/llm/yup_LLMTool.h b/modules/yup_ai/llm/yup_LLMTool.h
new file mode 100644
index 000000000..dcd7a90a6
--- /dev/null
+++ b/modules/yup_ai/llm/yup_LLMTool.h
@@ -0,0 +1,71 @@
+/*
+ ==============================================================================
+
+ This file is part of the YUP library.
+ Copyright (c) 2026 - kunitoki@gmail.com
+
+ YUP is an open source library subject to open-source licensing.
+
+ The code included in this file is provided under the terms of the ISC license
+ http://www.isc.org/downloads/software-support-policy/isc-license. Permission
+ to use, copy, modify, and/or distribute this software for any purpose with or
+ without fee is hereby granted provided that the above copyright notice and
+ this permission notice appear in all copies.
+
+ YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
+ EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
+ DISCLAIMED.
+
+ ==============================================================================
+*/
+
+namespace yup
+{
+
+//==============================================================================
+/** Describes an LLM-callable function and its JSON Schema parameter model.
+
+ Tools are serialised to the OpenAI function-calling format. The handler is a
+ local callable that receives JSON-compatible arguments and returns a
+ JSON-compatible result.
+
+ @tags{AI}
+*/
+class YUP_API LLMTool
+{
+public:
+ /** A JSON Schema parameter description. */
+ struct Parameter
+ {
+ String name;
+ String type;
+ String description;
+ bool required = false;
+ std::optional enumValues;
+ std::optional defaultValue;
+ std::optional> properties;
+ };
+
+ using Handler = std::function;
+
+ String name;
+ String description;
+ std::vector parameters;
+
+ /** Converts this tool to the OpenAI function-calling schema. */
+ var toJsonSchema() const;
+
+ /** Executes the registered handler.
+
+ If no handler is installed, the returned value is an error object.
+ */
+ var execute (const var& arguments) const;
+
+ /** Installs or replaces the handler for this tool. */
+ void setHandler (Handler newHandler);
+
+private:
+ Handler handler;
+};
+
+} // namespace yup
diff --git a/modules/yup_ai/llm/yup_LLMToolRegistry.cpp b/modules/yup_ai/llm/yup_LLMToolRegistry.cpp
new file mode 100644
index 000000000..5ba015cb4
--- /dev/null
+++ b/modules/yup_ai/llm/yup_LLMToolRegistry.cpp
@@ -0,0 +1,120 @@
+/*
+ ==============================================================================
+
+ This file is part of the YUP library.
+ Copyright (c) 2026 - kunitoki@gmail.com
+
+ YUP is an open source library subject to open-source licensing.
+
+ The code included in this file is provided under the terms of the ISC license
+ http://www.isc.org/downloads/software-support-policy/isc-license. Permission
+ to use, copy, modify, and/or distribute this software for any purpose with or
+ without fee is hereby granted provided that the above copyright notice and
+ this permission notice appear in all copies.
+
+ YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
+ EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
+ DISCLAIMED.
+
+ ==============================================================================
+*/
+
+namespace yup
+{
+namespace
+{
+var makeToolRegistryObject()
+{
+ return var (std::make_unique());
+}
+
+void setToolRegistryProperty (var& object, const Identifier& name, const var& value)
+{
+ if (auto* dynamicObject = object.getDynamicObject())
+ dynamicObject->setProperty (name, value);
+}
+
+var makeToolRegistryErrorObject (const String& message)
+{
+ auto object = makeToolRegistryObject();
+ setToolRegistryProperty (object, "error", true);
+ setToolRegistryProperty (object, "message", message);
+ return object;
+}
+} // namespace
+
+void LLMToolRegistry::registerTool (LLMTool tool)
+{
+ const ScopedLock lock (mutex);
+ tools[tool.name] = std::move (tool);
+ lookupCache.reset();
+}
+
+void LLMToolRegistry::unregisterTool (const String& name)
+{
+ const ScopedLock lock (mutex);
+ tools.erase (name);
+ lookupCache.reset();
+}
+
+bool LLMToolRegistry::contains (const String& name) const noexcept
+{
+ const ScopedLock lock (mutex);
+ return tools.find (name) != tools.end();
+}
+
+const LLMTool* LLMToolRegistry::findTool (const String& name) const noexcept
+{
+ const ScopedLock lock (mutex);
+
+ if (auto iter = tools.find (name); iter != tools.end())
+ {
+ lookupCache = iter->second;
+ return std::addressof (*lookupCache);
+ }
+
+ lookupCache.reset();
+ return nullptr;
+}
+
+std::vector LLMToolRegistry::getAllTools() const
+{
+ const ScopedLock lock (mutex);
+
+ std::vector result;
+ result.reserve (tools.size());
+
+ for (const auto& entry : tools)
+ result.push_back (entry.second);
+
+ return result;
+}
+
+var LLMToolRegistry::toToolsArray() const
+{
+ var result;
+
+ for (const auto& tool : getAllTools())
+ result.append (tool.toJsonSchema());
+
+ return result;
+}
+
+var LLMToolRegistry::dispatchToolCall (const String& name, const var& arguments) const
+{
+ std::optional tool;
+
+ {
+ const ScopedLock lock (mutex);
+
+ if (auto iter = tools.find (name); iter != tools.end())
+ tool = iter->second;
+ }
+
+ if (! tool.has_value())
+ return makeToolRegistryErrorObject ("Unknown tool '" + name + "'");
+
+ return tool->execute (arguments);
+}
+
+} // namespace yup
diff --git a/modules/yup_ai/llm/yup_LLMToolRegistry.h b/modules/yup_ai/llm/yup_LLMToolRegistry.h
new file mode 100644
index 000000000..73f6e6cdc
--- /dev/null
+++ b/modules/yup_ai/llm/yup_LLMToolRegistry.h
@@ -0,0 +1,64 @@
+/*
+ ==============================================================================
+
+ This file is part of the YUP library.
+ Copyright (c) 2026 - kunitoki@gmail.com
+
+ YUP is an open source library subject to open-source licensing.
+
+ The code included in this file is provided under the terms of the ISC license
+ http://www.isc.org/downloads/software-support-policy/isc-license. Permission
+ to use, copy, modify, and/or distribute this software for any purpose with or
+ without fee is hereby granted provided that the above copyright notice and
+ this permission notice appear in all copies.
+
+ YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
+ EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
+ DISCLAIMED.
+
+ ==============================================================================
+*/
+
+namespace yup
+{
+
+//==============================================================================
+/** Thread-safe storage and dispatch for LLM tools.
+
+ @tags{AI}
+*/
+class YUP_API LLMToolRegistry
+{
+public:
+ /** Adds or replaces a tool by name. */
+ void registerTool (LLMTool tool);
+
+ /** Removes a tool by name if it exists. */
+ void unregisterTool (const String& name);
+
+ /** Returns true if a tool with this name exists. */
+ bool contains (const String& name) const noexcept;
+
+ /** Returns a pointer to a copied cache entry for immediate read-only use.
+
+ Prefer getAllTools() or dispatchToolCall() for thread-safe ownership
+ across longer lifetimes.
+ */
+ const LLMTool* findTool (const String& name) const noexcept;
+
+ /** Returns a snapshot of all registered tools. */
+ std::vector getAllTools() const;
+
+ /** Converts all registered tools to the OpenAI tools array. */
+ var toToolsArray() const;
+
+ /** Dispatches a tool call by name. Missing tools return an error object. */
+ var dispatchToolCall (const String& name, const var& arguments) const;
+
+private:
+ mutable CriticalSection mutex;
+ mutable std::optional lookupCache;
+ std::unordered_map tools;
+};
+
+} // namespace yup
diff --git a/modules/yup_ai/mcp/yup_MCPClient.cpp b/modules/yup_ai/mcp/yup_MCPClient.cpp
new file mode 100644
index 000000000..8b0985b92
--- /dev/null
+++ b/modules/yup_ai/mcp/yup_MCPClient.cpp
@@ -0,0 +1,325 @@
+/*
+ ==============================================================================
+
+ This file is part of the YUP library.
+ Copyright (c) 2026 - kunitoki@gmail.com
+
+ YUP is an open source library subject to open-source licensing.
+
+ The code included in this file is provided under the terms of the ISC license
+ http://www.isc.org/downloads/software-support-policy/isc-license. Permission
+ to use, copy, modify, and/or distribute this software for any purpose with or
+ without fee is hereby granted provided that the above copyright notice and
+ this permission notice appear in all copies.
+
+ YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
+ EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
+ DISCLAIMED.
+
+ ==============================================================================
+*/
+
+namespace yup
+{
+namespace
+{
+var makeMCPClientObject()
+{
+ return var (std::make_unique());
+}
+
+void setMCPClientProperty (var& object, const Identifier& name, const var& value)
+{
+ if (auto* dynamicObject = object.getDynamicObject())
+ dynamicObject->setProperty (name, value);
+}
+
+var makeRequestParamsWithNameAndArguments (const String& toolName, const var& arguments)
+{
+ auto params = makeMCPClientObject();
+ setMCPClientProperty (params, "name", toolName);
+ setMCPClientProperty (params, "arguments", arguments);
+ return params;
+}
+
+var makeResourceReadParams (const String& uri)
+{
+ auto params = makeMCPClientObject();
+ setMCPClientProperty (params, "uri", uri);
+ return params;
+}
+
+var makeInitializeParams (const MCPCapabilities& capabilities)
+{
+ auto params = makeMCPClientObject();
+ setMCPClientProperty (params, "protocolVersion", "2024-11-05");
+ setMCPClientProperty (params, "capabilities", capabilities.toVar());
+
+ auto clientInfo = makeMCPClientObject();
+ setMCPClientProperty (clientInfo, "name", "YUP");
+ setMCPClientProperty (clientInfo, "version", "1.0.0");
+ setMCPClientProperty (params, "clientInfo", clientInfo);
+
+ return params;
+}
+
+var unwrapToolCallResult (const var& result)
+{
+ if (auto* content = result["content"].getArray())
+ {
+ if (content->isEmpty())
+ return {};
+
+ const auto& firstContent = content->getReference (0);
+
+ if (firstContent["type"].toString() == "text")
+ return firstContent["text"];
+
+ if (! firstContent["json"].isVoid())
+ return firstContent["json"];
+ }
+
+ return result;
+}
+
+ResultValue unwrapResourceReadResult (const var& result)
+{
+ if (auto* contents = result["contents"].getArray())
+ {
+ if (contents->isEmpty())
+ return makeResultValueFail ("MCP resource response did not contain content");
+
+ const auto& firstContent = contents->getReference (0);
+ if (firstContent.hasProperty ("text"))
+ return makeResultValueOk (firstContent["text"].toString());
+
+ if (firstContent.hasProperty ("blob"))
+ return makeResultValueOk (firstContent["blob"].toString());
+ }
+
+ if (result.isString())
+ return makeResultValueOk (result.toString());
+
+ return makeResultValueFail ("MCP resource response did not contain readable text");
+}
+
+bool schemaMarksParameterRequired (const var& schema, const String& parameterName)
+{
+ if (auto* required = schema["required"].getArray())
+ for (const auto& requiredName : *required)
+ if (parameterName == requiredName.toString())
+ return true;
+
+ return false;
+}
+
+std::optional> schemaPropertiesToParameters (const var& schema);
+
+LLMTool::Parameter schemaPropertyToParameter (const Identifier& name, const var& schema, bool required)
+{
+ LLMTool::Parameter parameter;
+ parameter.name = name.toString();
+ parameter.type = schema["type"].toString();
+ parameter.description = schema["description"].toString();
+ parameter.required = required;
+
+ if (schema.hasProperty ("enum"))
+ parameter.enumValues = schema["enum"];
+
+ if (schema.hasProperty ("default"))
+ parameter.defaultValue = schema["default"];
+
+ if (auto nestedProperties = schemaPropertiesToParameters (schema); nestedProperties.has_value())
+ parameter.properties = std::move (*nestedProperties);
+ else if (auto nestedItems = schemaPropertiesToParameters (schema["items"]); nestedItems.has_value())
+ parameter.properties = std::move (*nestedItems);
+
+ return parameter;
+}
+
+std::optional> schemaPropertiesToParameters (const var& schema)
+{
+ auto* properties = schema["properties"].getDynamicObject();
+ if (properties == nullptr)
+ return std::nullopt;
+
+ std::vector parameters;
+
+ for (const auto& property : properties->getProperties())
+ {
+ const auto propertyName = property.name.toString();
+ parameters.push_back (schemaPropertyToParameter (property.name,
+ property.value,
+ schemaMarksParameterRequired (schema, propertyName)));
+ }
+
+ return parameters;
+}
+} // namespace
+
+struct MCPClient::Pimpl
+{
+ explicit Pimpl (std::unique_ptr transportToUse)
+ : transport (std::move (transportToUse))
+ {
+ }
+
+ ResultValue sendRequest (const String& method, std::optional params)
+ {
+ if (transport == nullptr)
+ return makeResultValueFail ("MCP client has no transport");
+
+ if (! transport->isConnected())
+ {
+ if (auto startResult = transport->start(); startResult.failed())
+ return makeResultValueFail (startResult.getErrorMessage());
+ }
+
+ JsonRpcRequest request;
+ request.id = static_cast (nextRequestId++);
+ request.method = method;
+ request.params = std::move (params);
+
+ if (auto sendResult = transport->sendMessage (request.toVar()); sendResult.failed())
+ return makeResultValueFail (sendResult.getErrorMessage());
+
+ for (;;)
+ {
+ auto received = transport->receiveMessage();
+ if (received.failed())
+ return makeResultValueFail (received.getErrorMessage());
+
+ auto response = JsonRpcResponse::fromVar (received.getReference());
+ if (! response.has_value())
+ continue;
+
+ if (response->id.equals (*request.id))
+ return makeResultValueOk (std::move (*response));
+ }
+ }
+
+ Result sendNotification (const String& method, std::optional params)
+ {
+ if (transport == nullptr)
+ return Result::fail ("MCP client has no transport");
+
+ JsonRpcRequest notification;
+ notification.method = method;
+ notification.params = std::move (params);
+
+ return transport->sendMessage (notification.toVar());
+ }
+
+ std::unique_ptr transport;
+ int64 nextRequestId = 1;
+};
+
+MCPClient::MCPClient (std::unique_ptr transport)
+ : pimpl (std::make_unique (std::move (transport)))
+{
+}
+
+MCPClient::~MCPClient() = default;
+
+Result MCPClient::initialize (MCPCapabilities clientCapabilities)
+{
+ auto response = pimpl->sendRequest ("initialize", makeInitializeParams (clientCapabilities));
+ if (response.failed())
+ return Result::fail (response.getErrorMessage());
+
+ if (response.getReference().isError())
+ return Result::fail (response.getReference().error->message);
+
+ return pimpl->sendNotification ("notifications/initialized", std::nullopt);
+}
+
+std::vector MCPClient::listTools()
+{
+ std::vector result;
+
+ auto response = pimpl->sendRequest ("tools/list", std::nullopt);
+ if (response.failed() || response.getReference().isError() || ! response.getReference().result.has_value())
+ return result;
+
+ if (auto* tools = (*response.getReference().result)["tools"].getArray())
+ for (const auto& toolVar : *tools)
+ if (auto tool = MCPToolDefinition::fromVar (toolVar))
+ result.push_back (std::move (*tool));
+
+ return result;
+}
+
+ResultValue MCPClient::callTool (const String& toolName, const var& arguments)
+{
+ auto response = pimpl->sendRequest ("tools/call", makeRequestParamsWithNameAndArguments (toolName, arguments));
+ if (response.failed())
+ return makeResultValueFail (response.getErrorMessage());
+
+ if (response.getReference().isError())
+ return makeResultValueFail (response.getReference().error->message);
+
+ if (! response.getReference().result.has_value())
+ return makeResultValueFail ("MCP tool call response did not contain a result");
+
+ return makeResultValueOk (unwrapToolCallResult (*response.getReference().result));
+}
+
+std::vector MCPClient::listResources()
+{
+ std::vector result;
+
+ auto response = pimpl->sendRequest ("resources/list", std::nullopt);
+ if (response.failed() || response.getReference().isError() || ! response.getReference().result.has_value())
+ return result;
+
+ if (auto* resources = (*response.getReference().result)["resources"].getArray())
+ for (const auto& resourceVar : *resources)
+ if (auto resource = MCPResourceDefinition::fromVar (resourceVar))
+ result.push_back (std::move (*resource));
+
+ return result;
+}
+
+ResultValue MCPClient::readResource (const String& uri)
+{
+ auto response = pimpl->sendRequest ("resources/read", makeResourceReadParams (uri));
+ if (response.failed())
+ return makeResultValueFail (response.getErrorMessage());
+
+ if (response.getReference().isError())
+ return makeResultValueFail (response.getReference().error->message);
+
+ if (! response.getReference().result.has_value())
+ return makeResultValueFail ("MCP resource response did not contain a result");
+
+ return unwrapResourceReadResult (*response.getReference().result);
+}
+
+void MCPClient::registerToolsWith (LLMToolRegistry& registry)
+{
+ for (auto toolDefinition : listTools())
+ {
+ LLMTool tool;
+ tool.name = toolDefinition.name;
+ tool.description = toolDefinition.description;
+
+ if (auto parameters = schemaPropertiesToParameters (toolDefinition.inputSchema))
+ tool.parameters = std::move (*parameters);
+
+ tool.setHandler ([this, toolName = tool.name] (const var& arguments)
+ {
+ auto callResult = callTool (toolName, arguments);
+ return callResult.wasOk() ? callResult.getValue()
+ : var (callResult.getErrorMessage());
+ });
+
+ registry.registerTool (std::move (tool));
+ }
+}
+
+MCPTransport* MCPClient::getTransport() noexcept
+{
+ return pimpl->transport.get();
+}
+
+} // namespace yup
diff --git a/modules/yup_ai/mcp/yup_MCPClient.h b/modules/yup_ai/mcp/yup_MCPClient.h
new file mode 100644
index 000000000..2b8b709a0
--- /dev/null
+++ b/modules/yup_ai/mcp/yup_MCPClient.h
@@ -0,0 +1,66 @@
+/*
+ ==============================================================================
+
+ This file is part of the YUP library.
+ Copyright (c) 2026 - kunitoki@gmail.com
+
+ YUP is an open source library subject to open-source licensing.
+
+ The code included in this file is provided under the terms of the ISC license
+ http://www.isc.org/downloads/software-support-policy/isc-license. Permission
+ to use, copy, modify, and/or distribute this software for any purpose with or
+ without fee is hereby granted provided that the above copyright notice and
+ this permission notice appear in all copies.
+
+ YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
+ EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
+ DISCLAIMED.
+
+ ==============================================================================
+*/
+
+namespace yup
+{
+
+//==============================================================================
+/** Synchronous MCP client over an `MCPTransport`.
+
+ The client performs JSON-RPC request/response correlation and exposes common
+ MCP methods for initialization, tool discovery, tool calls, and resources.
+
+ @tags{AI}
+*/
+class YUP_API MCPClient
+{
+public:
+ /** Connects this client to an MCP server using the supplied transport. */
+ explicit MCPClient (std::unique_ptr transport);
+ ~MCPClient();
+
+ /** Performs the MCP `initialize` handshake and sends the initialized notification. */
+ Result initialize (MCPCapabilities clientCapabilities = {});
+
+ /** Requests the server's available tools. */
+ std::vector listTools();
+
+ /** Calls a server tool with JSON-compatible arguments. */
+ ResultValue callTool (const String& toolName, const var& arguments);
+
+ /** Requests the server's available resources. */
+ std::vector listResources();
+
+ /** Reads a resource by URI, returning text content when available. */
+ ResultValue readResource (const String& uri);
+
+ /** Imports remote MCP tools into an LLM tool registry. */
+ void registerToolsWith (LLMToolRegistry& registry);
+
+ /** Returns the underlying transport. */
+ MCPTransport* getTransport() noexcept;
+
+private:
+ struct Pimpl;
+ std::unique_ptr pimpl;
+};
+
+} // namespace yup
diff --git a/modules/yup_ai/mcp/yup_MCPServer.cpp b/modules/yup_ai/mcp/yup_MCPServer.cpp
new file mode 100644
index 000000000..b5de71a19
--- /dev/null
+++ b/modules/yup_ai/mcp/yup_MCPServer.cpp
@@ -0,0 +1,357 @@
+/*
+ ==============================================================================
+
+ This file is part of the YUP library.
+ Copyright (c) 2026 - kunitoki@gmail.com
+
+ YUP is an open source library subject to open-source licensing.
+
+ The code included in this file is provided under the terms of the ISC license
+ http://www.isc.org/downloads/software-support-policy/isc-license. Permission
+ to use, copy, modify, and/or distribute this software for any purpose with or
+ without fee is hereby granted provided that the above copyright notice and
+ this permission notice appear in all copies.
+
+ YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
+ EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
+ DISCLAIMED.
+
+ ==============================================================================
+*/
+
+namespace yup
+{
+namespace
+{
+var makeMCPServerObject()
+{
+ return var (std::make_unique());
+}
+
+void setMCPServerProperty (var& object, const Identifier& name, const var& value)
+{
+ if (auto* dynamicObject = object.getDynamicObject())
+ dynamicObject->setProperty (name, value);
+}
+
+JsonRpcResponse makeMCPErrorResponse (const var& id, int code, const String& message)
+{
+ JsonRpcResponse response;
+ response.id = id;
+ response.error = JsonRpcError { code, message, std::nullopt };
+ return response;
+}
+
+var makeTextContent (const String& text)
+{
+ auto content = makeMCPServerObject();
+ setMCPServerProperty (content, "type", "text");
+ setMCPServerProperty (content, "text", text);
+ return content;
+}
+
+var makeJsonContent (const var& value)
+{
+ auto content = makeMCPServerObject();
+ setMCPServerProperty (content, "type", "json");
+ setMCPServerProperty (content, "json", value);
+ return content;
+}
+
+var makeToolCallResult (const var& value)
+{
+ var content;
+
+ if (value.isString())
+ content.append (makeTextContent (value.toString()));
+ else
+ content.append (makeJsonContent (value));
+
+ auto result = makeMCPServerObject();
+ setMCPServerProperty (result, "content", content);
+ return result;
+}
+
+MCPToolDefinition toolDefinitionFromLLMTool (const LLMTool& tool)
+{
+ auto schema = tool.toJsonSchema();
+
+ MCPToolDefinition definition;
+ definition.name = tool.name;
+ definition.description = tool.description;
+ definition.inputSchema = schema["function"]["parameters"];
+ return definition;
+}
+} // namespace
+
+struct MCPServer::Pimpl
+{
+ struct ResourceEntry
+ {
+ MCPResourceDefinition definition;
+ std::function reader;
+ };
+
+ explicit Pimpl (Options optionsToUse)
+ : options (std::move (optionsToUse))
+ {
+ }
+
+ void registerTool (MCPToolDefinition definition, LLMTool tool)
+ {
+ {
+ const ScopedLock lock (mutex);
+ toolDefinitions[definition.name] = std::move (definition);
+ options.capabilities.supportsTools = true;
+ }
+
+ toolRegistry.registerTool (std::move (tool));
+ }
+
+ void sendResponse (const JsonRpcResponse& response)
+ {
+ auto* currentTransport = transport.get();
+ if (currentTransport != nullptr)
+ currentTransport->sendMessage (response.toVar());
+ }
+
+ var makeInitializeResult() const
+ {
+ auto result = makeMCPServerObject();
+ setMCPServerProperty (result, "protocolVersion", "2024-11-05");
+ setMCPServerProperty (result, "capabilities", options.capabilities.toVar());
+
+ auto serverInfo = makeMCPServerObject();
+ setMCPServerProperty (serverInfo, "name", options.serverName);
+ setMCPServerProperty (serverInfo, "version", options.serverVersion);
+ setMCPServerProperty (result, "serverInfo", serverInfo);
+
+ return result;
+ }
+
+ var makeToolsListResult() const
+ {
+ var tools;
+
+ {
+ const ScopedLock lock (mutex);
+ for (const auto& entry : toolDefinitions)
+ tools.append (entry.second.toVar());
+ }
+
+ auto result = makeMCPServerObject();
+ setMCPServerProperty (result, "tools", tools);
+ return result;
+ }
+
+ var callTool (const var& params) const
+ {
+ const auto toolName = params["name"].toString();
+ if (toolName.isEmpty())
+ return makeToolCallResult (var ("Missing MCP tool name"));
+
+ return makeToolCallResult (toolRegistry.dispatchToolCall (toolName, params["arguments"]));
+ }
+
+ var makeResourcesListResult() const
+ {
+ var resources;
+
+ {
+ const ScopedLock lock (mutex);
+ for (const auto& entry : resourcesByUri)
+ resources.append (entry.second.definition.toVar());
+ }
+
+ auto result = makeMCPServerObject();
+ setMCPServerProperty (result, "resources", resources);
+ return result;
+ }
+
+ ResultValue readResource (const var& params) const
+ {
+ const auto uri = params["uri"].toString();
+
+ ResourceEntry entry;
+ {
+ const ScopedLock lock (mutex);
+ auto iter = resourcesByUri.find (uri);
+ if (iter == resourcesByUri.end())
+ return makeResultValueFail ("Unknown MCP resource '" + uri + "'");
+
+ entry = iter->second;
+ }
+
+ auto content = makeMCPServerObject();
+ setMCPServerProperty (content, "uri", entry.definition.uri);
+ setMCPServerProperty (content, "mimeType", entry.definition.mimeType);
+ setMCPServerProperty (content, "text", entry.reader ? entry.reader() : String());
+
+ var contents;
+ contents.append (content);
+
+ auto result = makeMCPServerObject();
+ setMCPServerProperty (result, "contents", contents);
+ return makeResultValueOk (result);
+ }
+
+ std::optional handleRequest (const JsonRpcRequest& request)
+ {
+ if (request.isNotification())
+ return std::nullopt;
+
+ JsonRpcResponse response;
+ response.id = *request.id;
+
+ if (request.method == "initialize")
+ response.result = makeInitializeResult();
+ else if (request.method == "tools/list")
+ response.result = makeToolsListResult();
+ else if (request.method == "tools/call")
+ response.result = callTool (request.params.value_or (var()));
+ else if (request.method == "resources/list")
+ response.result = makeResourcesListResult();
+ else if (request.method == "resources/read")
+ {
+ auto result = readResource (request.params.value_or (var()));
+ if (result.failed())
+ return makeMCPErrorResponse (*request.id, MCPErrorCodes::invalidParams, result.getErrorMessage());
+
+ response.result = result.getValue();
+ }
+ else
+ {
+ response.error = JsonRpcError { MCPErrorCodes::methodNotFound, "Unknown MCP method '" + request.method + "'", std::nullopt };
+ }
+
+ return response;
+ }
+
+ void handleMessage (const var& message)
+ {
+ auto request = JsonRpcRequest::fromVar (message);
+ if (! request.has_value())
+ {
+ sendResponse (makeMCPErrorResponse (message["id"], MCPErrorCodes::invalidRequest, "Invalid JSON-RPC request"));
+ return;
+ }
+
+ if (auto response = handleRequest (*request))
+ sendResponse (*response);
+ }
+
+ Options options;
+ LLMToolRegistry toolRegistry;
+ mutable CriticalSection mutex;
+ std::unordered_map toolDefinitions;
+ std::unordered_map resourcesByUri;
+ std::unique_ptr transport;
+ bool running = false;
+};
+
+MCPServer::MCPServer()
+ : MCPServer (Options {})
+{
+}
+
+MCPServer::MCPServer (Options options)
+ : pimpl (std::make_unique (std::move (options)))
+{
+}
+
+MCPServer::~MCPServer()
+{
+ stop();
+}
+
+void MCPServer::registerTool (MCPToolDefinition tool, LLMTool::Handler handler)
+{
+ LLMTool llmTool;
+ llmTool.name = tool.name;
+ llmTool.description = tool.description;
+ llmTool.setHandler (std::move (handler));
+
+ pimpl->registerTool (std::move (tool), std::move (llmTool));
+}
+
+void MCPServer::registerTool (LLMTool tool)
+{
+ auto definition = toolDefinitionFromLLMTool (tool);
+ pimpl->registerTool (std::move (definition), std::move (tool));
+}
+
+void MCPServer::unregisterTool (const String& name)
+{
+ {
+ const ScopedLock lock (pimpl->mutex);
+ pimpl->toolDefinitions.erase (name);
+ }
+
+ pimpl->toolRegistry.unregisterTool (name);
+}
+
+void MCPServer::registerResource (MCPResourceDefinition resource, std::function reader)
+{
+ const ScopedLock lock (pimpl->mutex);
+ const auto uri = resource.uri;
+ pimpl->resourcesByUri[uri] = Pimpl::ResourceEntry { std::move (resource), std::move (reader) };
+ pimpl->options.capabilities.supportsResources = true;
+}
+
+void MCPServer::unregisterResource (const String& uri)
+{
+ const ScopedLock lock (pimpl->mutex);
+ pimpl->resourcesByUri.erase (uri);
+}
+
+Result MCPServer::start (std::unique_ptr transport)
+{
+ if (transport == nullptr)
+ return Result::fail ("Cannot start MCP server without a transport");
+
+ stop();
+
+ pimpl->transport = std::move (transport);
+ pimpl->transport->setMessageHandler ([this] (const var& message)
+ {
+ pimpl->handleMessage (message);
+ });
+
+ auto result = pimpl->transport->start();
+ if (result.failed())
+ {
+ pimpl->transport.reset();
+ return result;
+ }
+
+ pimpl->running = true;
+ return Result::ok();
+}
+
+void MCPServer::stop()
+{
+ if (pimpl->transport != nullptr)
+ {
+ pimpl->transport->stop();
+ pimpl->transport.reset();
+ }
+
+ pimpl->running = false;
+}
+
+bool MCPServer::isRunning() const noexcept
+{
+ return pimpl->running;
+}
+
+Result MCPServer::startStdio()
+{
+ return Result::fail ("MCP stdio transport is not implemented yet");
+}
+
+Result MCPServer::startHttp (int)
+{
+ return Result::fail ("MCP HTTP transport is not implemented yet");
+}
+
+} // namespace yup
diff --git a/modules/yup_ai/mcp/yup_MCPServer.h b/modules/yup_ai/mcp/yup_MCPServer.h
new file mode 100644
index 000000000..99ec85ea1
--- /dev/null
+++ b/modules/yup_ai/mcp/yup_MCPServer.h
@@ -0,0 +1,82 @@
+/*
+ ==============================================================================
+
+ This file is part of the YUP library.
+ Copyright (c) 2026 - kunitoki@gmail.com
+
+ YUP is an open source library subject to open-source licensing.
+
+ The code included in this file is provided under the terms of the ISC license
+ http://www.isc.org/downloads/software-support-policy/isc-license. Permission
+ to use, copy, modify, and/or distribute this software for any purpose with or
+ without fee is hereby granted provided that the above copyright notice and
+ this permission notice appear in all copies.
+
+ YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
+ EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
+ DISCLAIMED.
+
+ ==============================================================================
+*/
+
+namespace yup
+{
+
+//==============================================================================
+/** MCP server that exposes local YUP tools and resources over an `MCPTransport`.
+
+ The server handles JSON-RPC messages for initialization, `tools/list`,
+ `tools/call`, `resources/list`, and `resources/read`.
+
+ @tags{AI}
+*/
+class YUP_API MCPServer
+{
+public:
+ struct Options
+ {
+ String serverName = "YUP Application";
+ String serverVersion = "1.0.0";
+ MCPCapabilities capabilities = {};
+ };
+
+ MCPServer();
+ explicit MCPServer (Options options);
+ ~MCPServer();
+
+ /** Registers a tool definition and handler. */
+ void registerTool (MCPToolDefinition tool, LLMTool::Handler handler);
+
+ /** Registers an LLM tool, deriving the MCP tool definition from its JSON Schema. */
+ void registerTool (LLMTool tool);
+
+ /** Removes a tool by name. */
+ void unregisterTool (const String& name);
+
+ /** Registers a readable MCP resource. */
+ void registerResource (MCPResourceDefinition resource, std::function reader);
+
+ /** Removes a resource by URI. */
+ void unregisterResource (const String& uri);
+
+ /** Starts serving messages on the supplied transport. */
+ Result start (std::unique_ptr transport);
+
+ /** Stops serving and releases the transport. */
+ void stop();
+
+ /** Returns true while a transport is active. */
+ bool isRunning() const noexcept;
+
+ /** Convenience placeholder for future stdio transport support. */
+ Result startStdio();
+
+ /** Convenience placeholder for future HTTP transport support. */
+ Result startHttp (int port);
+
+private:
+ struct Pimpl;
+ std::unique_ptr pimpl;
+};
+
+} // namespace yup
diff --git a/modules/yup_ai/mcp/yup_MCPTransport.h b/modules/yup_ai/mcp/yup_MCPTransport.h
new file mode 100644
index 000000000..6fd5d7d41
--- /dev/null
+++ b/modules/yup_ai/mcp/yup_MCPTransport.h
@@ -0,0 +1,59 @@
+/*
+ ==============================================================================
+
+ This file is part of the YUP library.
+ Copyright (c) 2026 - kunitoki@gmail.com
+
+ YUP is an open source library subject to open-source licensing.
+
+ The code included in this file is provided under the terms of the ISC license
+ http://www.isc.org/downloads/software-support-policy/isc-license. Permission
+ to use, copy, modify, and/or distribute this software for any purpose with or
+ without fee is hereby granted provided that the above copyright notice and
+ this permission notice appear in all copies.
+
+ YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
+ EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
+ DISCLAIMED.
+
+ ==============================================================================
+*/
+
+namespace yup
+{
+
+//==============================================================================
+/** Abstract transport for JSON-RPC messages used by MCP.
+
+ Implementations may use stdio, HTTP/SSE, sockets, or in-process queues. The
+ payload is always a JSON-compatible `var` object.
+
+ @tags{AI}
+*/
+class YUP_API MCPTransport
+{
+public:
+ using MessageHandler = std::function;
+
+ virtual ~MCPTransport() = default;
+
+ /** Sends one JSON-RPC message. */
+ virtual Result sendMessage (const var& message) = 0;
+
+ /** Receives the next JSON-RPC message, blocking until timeout when supported. */
+ virtual ResultValue receiveMessage (int timeoutMs = -1) = 0;
+
+ /** Installs a callback for asynchronous incoming messages. */
+ virtual void setMessageHandler (MessageHandler handler) = 0;
+
+ /** Starts the transport. */
+ virtual Result start() = 0;
+
+ /** Stops the transport and releases any underlying connection. */
+ virtual void stop() = 0;
+
+ /** Returns true while the transport can send and receive messages. */
+ virtual bool isConnected() const noexcept = 0;
+};
+
+} // namespace yup
diff --git a/modules/yup_ai/mcp/yup_MCPTypes.cpp b/modules/yup_ai/mcp/yup_MCPTypes.cpp
new file mode 100644
index 000000000..be54312e2
--- /dev/null
+++ b/modules/yup_ai/mcp/yup_MCPTypes.cpp
@@ -0,0 +1,244 @@
+/*
+ ==============================================================================
+
+ This file is part of the YUP library.
+ Copyright (c) 2026 - kunitoki@gmail.com
+
+ YUP is an open source library subject to open-source licensing.
+
+ The code included in this file is provided under the terms of the ISC license
+ http://www.isc.org/downloads/software-support-policy/isc-license. Permission
+ to use, copy, modify, and/or distribute this software for any purpose with or
+ without fee is hereby granted provided that the above copyright notice and
+ this permission notice appear in all copies.
+
+ YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
+ EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
+ DISCLAIMED.
+
+ ==============================================================================
+*/
+
+namespace yup
+{
+namespace
+{
+var makeMCPObject()
+{
+ return var (std::make_unique());
+}
+
+void setMCPProperty (var& object, const Identifier& name, const var& value)
+{
+ if (auto* dynamicObject = object.getDynamicObject())
+ dynamicObject->setProperty (name, value);
+}
+
+bool hasPresentProperty (const var& object, const Identifier& name)
+{
+ return object.hasProperty (name) && ! object[name].isUndefined();
+}
+} // namespace
+
+var JsonRpcError::toVar() const
+{
+ auto object = makeMCPObject();
+ setMCPProperty (object, "code", code);
+ setMCPProperty (object, "message", message);
+
+ if (data.has_value())
+ setMCPProperty (object, "data", *data);
+
+ return object;
+}
+
+std::optional JsonRpcError::fromVar (const var& value)
+{
+ if (! value.isObject())
+ return std::nullopt;
+
+ JsonRpcError result;
+ result.code = static_cast (value["code"]);
+ result.message = value["message"].toString();
+
+ if (hasPresentProperty (value, "data"))
+ result.data = value["data"];
+
+ return result;
+}
+
+var JsonRpcRequest::toVar() const
+{
+ auto object = makeMCPObject();
+ setMCPProperty (object, "jsonrpc", jsonrpc);
+ setMCPProperty (object, "method", method);
+
+ if (id.has_value())
+ setMCPProperty (object, "id", *id);
+
+ if (params.has_value())
+ setMCPProperty (object, "params", *params);
+
+ return object;
+}
+
+std::optional JsonRpcRequest::fromVar (const var& value)
+{
+ if (! value.isObject())
+ return std::nullopt;
+
+ const auto version = value["jsonrpc"].toString();
+ const auto method = value["method"].toString();
+ if (version != "2.0" || method.isEmpty() || value.hasProperty ("result") || value.hasProperty ("error"))
+ return std::nullopt;
+
+ JsonRpcRequest result;
+ result.jsonrpc = version;
+ result.method = method;
+
+ if (hasPresentProperty (value, "id"))
+ result.id = value["id"];
+
+ if (hasPresentProperty (value, "params"))
+ result.params = value["params"];
+
+ return result;
+}
+
+var JsonRpcResponse::toVar() const
+{
+ auto object = makeMCPObject();
+ setMCPProperty (object, "jsonrpc", jsonrpc);
+ setMCPProperty (object, "id", id);
+
+ if (error.has_value())
+ setMCPProperty (object, "error", error->toVar());
+ else
+ setMCPProperty (object, "result", result.value_or (var()));
+
+ return object;
+}
+
+std::optional JsonRpcResponse::fromVar (const var& value)
+{
+ if (! value.isObject())
+ return std::nullopt;
+
+ const auto version = value["jsonrpc"].toString();
+ if (version != "2.0" || value.hasProperty ("method") || ! value.hasProperty ("id"))
+ return std::nullopt;
+
+ JsonRpcResponse response;
+ response.jsonrpc = version;
+ response.id = value["id"];
+
+ if (hasPresentProperty (value, "error"))
+ {
+ auto parsedError = JsonRpcError::fromVar (value["error"]);
+ if (! parsedError.has_value())
+ return std::nullopt;
+
+ response.error = std::move (*parsedError);
+ }
+ else if (hasPresentProperty (value, "result"))
+ {
+ response.result = value["result"];
+ }
+ else
+ {
+ return std::nullopt;
+ }
+
+ return response;
+}
+
+var MCPCapabilities::toVar() const
+{
+ auto object = makeMCPObject();
+
+ if (supportsTools)
+ setMCPProperty (object, "tools", makeMCPObject());
+
+ if (supportsResources)
+ setMCPProperty (object, "resources", makeMCPObject());
+
+ if (supportsPrompts)
+ setMCPProperty (object, "prompts", makeMCPObject());
+
+ if (supportsLogging)
+ setMCPProperty (object, "logging", makeMCPObject());
+
+ return object;
+}
+
+MCPCapabilities MCPCapabilities::fromVar (const var& value)
+{
+ MCPCapabilities capabilities;
+
+ if (! value.isObject())
+ return capabilities;
+
+ capabilities.supportsTools = hasPresentProperty (value, "tools");
+ capabilities.supportsResources = hasPresentProperty (value, "resources");
+ capabilities.supportsPrompts = hasPresentProperty (value, "prompts");
+ capabilities.supportsLogging = hasPresentProperty (value, "logging");
+
+ return capabilities;
+}
+
+var MCPToolDefinition::toVar() const
+{
+ auto object = makeMCPObject();
+ setMCPProperty (object, "name", name);
+ setMCPProperty (object, "description", description);
+ setMCPProperty (object, "inputSchema", inputSchema);
+ return object;
+}
+
+std::optional MCPToolDefinition::fromVar (const var& value)
+{
+ if (! value.isObject())
+ return std::nullopt;
+
+ MCPToolDefinition result;
+ result.name = value["name"].toString();
+ result.description = value["description"].toString();
+ result.inputSchema = value["inputSchema"];
+
+ if (result.name.isEmpty())
+ return std::nullopt;
+
+ return result;
+}
+
+var MCPResourceDefinition::toVar() const
+{
+ auto object = makeMCPObject();
+ setMCPProperty (object, "uri", uri);
+ setMCPProperty (object, "name", name);
+ setMCPProperty (object, "description", description);
+ setMCPProperty (object, "mimeType", mimeType);
+ return object;
+}
+
+std::optional MCPResourceDefinition::fromVar (const var& value)
+{
+ if (! value.isObject())
+ return std::nullopt;
+
+ MCPResourceDefinition result;
+ result.uri = value["uri"].toString();
+ result.name = value["name"].toString();
+ result.description = value["description"].toString();
+ result.mimeType = value["mimeType"].toString();
+
+ if (result.mimeType.isEmpty())
+ result.mimeType = "application/json";
+
+ if (result.uri.isEmpty() || result.name.isEmpty())
+ return std::nullopt;
+
+ return result;
+}
+
+} // namespace yup
diff --git a/modules/yup_ai/mcp/yup_MCPTypes.h b/modules/yup_ai/mcp/yup_MCPTypes.h
new file mode 100644
index 000000000..1c7af67f6
--- /dev/null
+++ b/modules/yup_ai/mcp/yup_MCPTypes.h
@@ -0,0 +1,159 @@
+/*
+ ==============================================================================
+
+ This file is part of the YUP library.
+ Copyright (c) 2026 - kunitoki@gmail.com
+
+ YUP is an open source library subject to open-source licensing.
+
+ The code included in this file is provided under the terms of the ISC license
+ http://www.isc.org/downloads/software-support-policy/isc-license. Permission
+ to use, copy, modify, and/or distribute this software for any purpose with or
+ without fee is hereby granted provided that the above copyright notice and
+ this permission notice appear in all copies.
+
+ YUP IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES, WHETHER
+ EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR PURPOSE, ARE
+ DISCLAIMED.
+
+ ==============================================================================
+*/
+
+namespace yup
+{
+
+//==============================================================================
+/** JSON-RPC 2.0 and MCP error codes.
+
+ @tags{AI}
+*/
+namespace MCPErrorCodes
+{
+constexpr int parseError = -32700;
+constexpr int invalidRequest = -32600;
+constexpr int methodNotFound = -32601;
+constexpr int invalidParams = -32602;
+constexpr int internalError = -32603;
+} // namespace MCPErrorCodes
+
+//==============================================================================
+/** JSON-RPC 2.0 error object.
+
+ @tags{AI}
+*/
+struct YUP_API JsonRpcError
+{
+ int code = MCPErrorCodes::internalError;
+ String message;
+ std::optional data;
+
+ /** Serialises this error to `{ "code", "message", "data" }`. */
+ var toVar() const;
+
+ /** Parses a JSON-RPC error object. */
+ static std::optional fromVar (const var& value);
+};
+
+//==============================================================================
+/** JSON-RPC 2.0 request or notification envelope.
+
+ Requests have an id. Notifications omit it.
+
+ @tags{AI}
+*/
+struct YUP_API JsonRpcRequest
+{
+ String jsonrpc = "2.0";
+ std::optional id;
+ String method;
+ std::optional params;
+
+ /** Returns true if this message omits an id and therefore expects no response. */
+ bool isNotification() const noexcept { return ! id.has_value(); }
+
+ /** Serialises this request to a JSON-compatible object. */
+ var toVar() const;
+
+ /** Parses a JSON-RPC request or notification. */
+ static std::optional fromVar (const var& value);
+};
+
+//==============================================================================
+/** JSON-RPC 2.0 response envelope.
+
+ @tags{AI}
+*/
+struct YUP_API JsonRpcResponse
+{
+ String jsonrpc = "2.0";
+ var id;
+ std::optional result;
+ std::optional error;
+
+ /** Returns true if this response contains an error object. */
+ bool isError() const noexcept { return error.has_value(); }
+
+ /** Serialises this response to a JSON-compatible object. */
+ var toVar() const;
+
+ /** Parses a JSON-RPC response. */
+ static std::optional fromVar (const var& value);
+};
+
+//==============================================================================
+/** MCP client or server capability flags.
+
+ @tags{AI}
+*/
+struct YUP_API MCPCapabilities
+{
+ bool supportsTools = false;
+ bool supportsResources = false;
+ bool supportsPrompts = false;
+ bool supportsLogging = false;
+
+ /** Serialises this capability set to an MCP capabilities object. */
+ var toVar() const;
+
+ /** Parses an MCP capabilities object. */
+ static MCPCapabilities fromVar (const var& value);
+};
+
+//==============================================================================
+/** MCP tool definition returned by `tools/list`.
+
+ @tags{AI}
+*/
+struct YUP_API MCPToolDefinition
+{
+ String name;
+ String description;
+ var inputSchema;
+
+ /** Serialises this tool definition to MCP's `tools/list` shape. */
+ var toVar() const;
+
+ /** Parses an MCP tool definition. */
+ static std::optional fromVar (const var& value);
+};
+
+//==============================================================================
+/** MCP resource definition returned by `resources/list`.
+
+ @tags{AI}
+*/
+struct YUP_API MCPResourceDefinition
+{
+ String uri;
+ String name;
+ String description;
+ String mimeType = "application/json";
+
+ /** Serialises this resource definition to MCP's `resources/list` shape. */
+ var toVar() const;
+
+ /** Parses an MCP resource definition. */
+ static std::optional fromVar (const var& value);
+};
+
+} // namespace yup
diff --git a/modules/yup_ai/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