Skip to content

Commit

Permalink
more work for #51
Browse files Browse the repository at this point in the history
  • Loading branch information
mathieuancelin committed Jan 3, 2025
1 parent 041e878 commit 455c781
Show file tree
Hide file tree
Showing 10 changed files with 265 additions and 45 deletions.
15 changes: 14 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ ThisBuild / version := "1.0.0-dev"
ThisBuild / organization := "com.cloud-apim"
ThisBuild / organizationName := "Cloud-APIM"

lazy val langchain4jVersion = "0.34.0"
lazy val langchain4jVersion = "1.0.0-alpha1" //"0.34.0"
lazy val jacksonVersion = "2.15.3"
lazy val jackson = Seq(
ExclusionRule("com.fasterxml.jackson.core", "jackson-databind"),
ExclusionRule("io.opentelemetry"),
Expand Down Expand Up @@ -33,7 +34,19 @@ lazy val root = (project in file("."))
),
libraryDependencies ++= Seq(
"fr.maif" %% "otoroshi" % "16.19.0" % "provided" excludeAll (netty: _*),
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////
"com.fasterxml.jackson.core" % "jackson-annotations" % jacksonVersion,
"com.fasterxml.jackson.core" % "jackson-core" % jacksonVersion,
"com.fasterxml.jackson.core" % "jackson-databind" % jacksonVersion,
"com.fasterxml.jackson.dataformat" % "jackson-dataformat-cbor" % jacksonVersion,
"com.fasterxml.jackson.dataformat" % "jackson-dataformat-yaml" % jacksonVersion,
"com.fasterxml.jackson.datatype" % "jackson-datatype-jdk8" % jacksonVersion,
"com.fasterxml.jackson.datatype" % "jackson-datatype-jsr310" % jacksonVersion,
"com.fasterxml.jackson.module" % "jackson-module-parameter-names" % jacksonVersion,
"com.fasterxml.jackson.module" % "jackson-module-scala_2.12" % jacksonVersion,
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////
"dev.langchain4j" % "langchain4j" % langchain4jVersion excludeAll(all: _*),
"dev.langchain4j" % "langchain4j-mcp" % langchain4jVersion excludeAll(all: _*),
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// for rapid dev purposes, the following 2 are marked as provided. needs to be not "provided" for release ////////
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.cloud.apim.otoroshi.extensions.aigateway.entities

import otoroshi.env.Env
import otoroshi_plugins.com.cloud.apim.otoroshi.extensions.aigateway.plugins.McpTester
import play.api.libs.json.{JsObject, JsValue, Json}

import scala.concurrent.{ExecutionContext, Future}

object LlmFunctions {

def callToolsOpenai(functions: Seq[GenericApiResponseChoiceMessageToolCall])(implicit ec: ExecutionContext, env: Env): Future[Seq[JsValue]] = {
val (wasmFunctions, mcpConnectors) = functions.partition(_.isWasm)
val wasmFunctionsF = WasmFunction._callToolsOpenai(wasmFunctions)(ec, env)
val mcpConnectorsF = McpTester.callToolsOpenai(mcpConnectors)(ec, env)
for {
wasmFunctionsR <- wasmFunctionsF
mcpConnectorsR <- mcpConnectorsF
} yield wasmFunctionsR ++ mcpConnectorsR
}

def callToolsOllama(functions: Seq[GenericApiResponseChoiceMessageToolCall])(implicit ec: ExecutionContext, env: Env): Future[Seq[JsValue]] = {
val (wasmFunctions, mcpConnectors) = functions.partition(_.isWasm)
val wasmFunctionsF = WasmFunction._callToolsOllama(wasmFunctions)(ec, env)
val mcpConnectorsF = McpTester.callToolsOllama(mcpConnectors)(ec, env)
for {
wasmFunctionsR <- wasmFunctionsF
mcpConnectorsR <- mcpConnectorsF
} yield wasmFunctionsR ++ mcpConnectorsR
}

def tools(wasmFunctions: Seq[String], mcpConnectors: Seq[String])(implicit env: Env): JsObject = {
val tools: Seq[JsObject] = WasmFunction._tools(wasmFunctions) ++ McpTester.tools(mcpConnectors)
Json.obj(
"tools" -> tools
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,10 @@ case class WasmFunction(
}

case class GenericApiResponseChoiceMessageToolCallFunction(raw: JsObject) {
lazy val name: String = raw.select("name").asString
lazy val raw_name: String = raw.select("name").asString
lazy val name: String = raw_name.replaceFirst("wasm___", "").replaceFirst("mcp___", "")
lazy val isWasm: Boolean = raw_name.startsWith("wasm___")
lazy val isMcp: Boolean = raw_name.startsWith("mcp___")
lazy val arguments: String = {
raw.select("arguments").asValue match {
case JsString(str) => str
Expand All @@ -249,6 +252,8 @@ case class GenericApiResponseChoiceMessageToolCallFunction(raw: JsObject) {
case class GenericApiResponseChoiceMessageToolCall(raw: JsObject) {
lazy val id: String = raw.select("id").asOpt[String].getOrElse(raw.select("function").select("name").asString)
lazy val function: GenericApiResponseChoiceMessageToolCallFunction = GenericApiResponseChoiceMessageToolCallFunction(raw.select("function").asObject)
lazy val isWasm: Boolean = function.isWasm
lazy val isMcp: Boolean = function.isMcp
}

object WasmFunction {
Expand All @@ -266,14 +271,14 @@ object WasmFunction {
private val modulesCache = Scaffeine().maximumSize(1000).expireAfterWrite(120.seconds).build[String, String]
val logger = Logger("WasmFunction")

def tools(functions: Seq[String])(implicit env: Env): JsObject = {
Json.obj(
"tools" -> JsArray(functions.flatMap(id => env.adminExtensions.extension[AiExtension].flatMap(ext => ext.states.toolFunction(id))).map { function =>
def _tools(functions: Seq[String])(implicit env: Env): Seq[JsObject] = {
/*Json.obj(
"tools" -> JsArray(*/functions.flatMap(id => env.adminExtensions.extension[AiExtension].flatMap(ext => ext.states.toolFunction(id))).map { function =>
val required: JsArray = function.required.map(v => JsArray(v.map(_.json))).getOrElse(JsArray(function.parameters.value.keySet.toSeq.map(_.json)))
Json.obj(
"type" -> "function",
"function" -> Json.obj(
"name" -> function.id, //function.name,
"name" -> s"wasm___${function.id}", //function.name,
"description" -> function.description,
"strict" -> function.strict,
"parameters" -> Json.obj(
Expand All @@ -284,8 +289,8 @@ object WasmFunction {
)
)
)
})
)
}/*)
)*/
}

private def call(functions: Seq[GenericApiResponseChoiceMessageToolCall])(f: (String, GenericApiResponseChoiceMessageToolCall) => Source[JsValue, _])(implicit ec: ExecutionContext, env: Env): Future[Seq[JsValue]] = {
Expand All @@ -312,7 +317,7 @@ object WasmFunction {
.runWith(Sink.seq)(env.otoroshiMaterializer)
}

def callToolsOpenai(functions: Seq[GenericApiResponseChoiceMessageToolCall])(implicit ec: ExecutionContext, env: Env): Future[Seq[JsValue]] = {
def _callToolsOpenai(functions: Seq[GenericApiResponseChoiceMessageToolCall])(implicit ec: ExecutionContext, env: Env): Future[Seq[JsValue]] = {
call(functions) { (resp, tc) =>
Source(List(Json.obj("role" -> "assistant", "tool_calls" -> Json.arr(tc.raw)), Json.obj(
"role" -> "tool",
Expand All @@ -322,7 +327,7 @@ object WasmFunction {
}
}

def callToolsOllama(functions: Seq[GenericApiResponseChoiceMessageToolCall])(implicit ec: ExecutionContext, env: Env): Future[Seq[JsValue]] = {
def _callToolsOllama(functions: Seq[GenericApiResponseChoiceMessageToolCall])(implicit ec: ExecutionContext, env: Env): Future[Seq[JsValue]] = {
call(functions) { (resp, tc) =>
Source(List(Json.obj("role" -> "assistant", "content" -> "", "tool_calls" -> Json.arr(tc.raw)), Json.obj(
"role" -> "tool",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,27 @@
package otoroshi_plugins.com.cloud.apim.otoroshi.extensions.aigateway.plugins

import akka.stream.Materializer
import akka.stream.scaladsl.{Sink, Source}
import akka.util.ByteString
import com.cloud.apim.otoroshi.extensions.aigateway.entities.GenericApiResponseChoiceMessageToolCall
import com.fasterxml.jackson.databind.ObjectMapper
import com.google.gson.Gson
import dev.langchain4j.agent.tool.{ToolExecutionRequest, ToolSpecification}
import dev.langchain4j.mcp.client.DefaultMcpClient
import dev.langchain4j.mcp.client.transport.stdio.StdioMcpTransport
import dev.langchain4j.model.chat.request.json._
import otoroshi.env.Env
import otoroshi.next.plugins.api._
import otoroshi.next.proxy.NgProxyEngineError
import otoroshi.utils.syntax.implicits._
import otoroshi_plugins.com.cloud.apim.extensions.aigateway.AiExtension
import play.api.libs.json.{Format, JsArray, JsError, JsObject, JsResult, JsSuccess, JsValue, Json}
import play.api.libs.json._
import play.api.mvc.Results

import java.util.UUID
import java.util.concurrent.Executors
import scala.concurrent.{ExecutionContext, Future}
import scala.jdk.CollectionConverters.{asScalaBufferConverter, mapAsScalaMapConverter}
import scala.util.{Failure, Success, Try}

case class McpProxyEndpointConfig(refs: Seq[String]) extends NgPluginConfig {
Expand Down Expand Up @@ -50,10 +61,10 @@ object McpProxyEndpointConfig {
}
}

class McpProxyEndpoint extends NgBackendCall {
class McpLocalProxyEndpoint extends NgBackendCall {

override def name: String = "Cloud APIM - MCP Proxy Endpoint"
override def description: Option[String] = "Expose tool functions as a MCP server".some
override def name: String = "Cloud APIM - MCP Local Proxy Endpoint"
override def description: Option[String] = "Exposes tool functions as an MCP server with a local proxy provided by @cloud-admin/otoroshi-mcp-proxy".some

override def core: Boolean = false
override def visibility: NgPluginVisibility = NgPluginVisibility.NgUserLand
Expand All @@ -68,7 +79,7 @@ class McpProxyEndpoint extends NgBackendCall {

override def start(env: Env): Future[Unit] = {
env.adminExtensions.extension[AiExtension].foreach { ext =>
ext.logger.info("the 'MCP Proxy Endpoint' plugin is available !")
ext.logger.info("the 'MCP Local Proxy Endpoint' plugin is available !")
}
().vfuture
}
Expand Down Expand Up @@ -130,7 +141,155 @@ class McpProxyEndpoint extends NgBackendCall {
}
} else {
val config = ctx.cachedConfig(internalName)(McpProxyEndpointConfig.format).getOrElse(McpProxyEndpointConfig.default)
call(Json.obj(), config, ctx)
call(Json.obj(
"method" -> "tools/get",
"params" -> Json.obj(),
), config, ctx)
}
}
}

object McpTester {

private val ec: ExecutionContext = ExecutionContext.fromExecutorService(Executors.newFixedThreadPool(2))
private val mapper = new ObjectMapper()
private val gson = new Gson()

def withClient[T](f: DefaultMcpClient => T): T = {
val stdioTransport = new StdioMcpTransport.Builder()
.command(java.util.List.of("/Users/mathieuancelin/.nvm/versions/node/v18.19.0/bin/node", "/Users/mathieuancelin/projects/clever-ai/mpc-test/mcp-otoroshi-proxy/bin/proxy.js"))
.logEvents(true) // only if you want to see the traffic in the log
.build()
// val sseTransport = new HttpMcpTransport.Builder()
// .sseUrl("http://localhost:3001/sse")
// //.postUrl("http://localhost:3001/message")
// .logRequests(true) // if you want to see the traffic in the log
// .logResponses(true)
// .build()
val mcpClient = new DefaultMcpClient.Builder()
.transport(stdioTransport)
.build()
// mcpClient.listTools()
// val res = mcpClient.executeTool()
try {
f(mcpClient)
} finally {
mcpClient.close()
}
}

def listTools(): Seq[ToolSpecification] = {
withClient(_.listTools().asScala)
}

def callAsync(name: String = "get-rooms", args: String = "{}"): Future[String] = {
Future.apply {
val request = ToolExecutionRequest.builder().id(UUID.randomUUID().toString()).name(name).arguments(args).build()
withClient(_.executeTool(request))
}(ec)
}

def call(name: String = "get-rooms", args: String = "{}"): String = {
val request = ToolExecutionRequest.builder().id(UUID.randomUUID().toString()).name(name).arguments(args).build()
withClient(_.executeTool(request))
}

private def schemaToJson(el: JsonSchemaElement): JsObject = {
el match {
case s: JsonBooleanSchema => Json.obj("description" -> s.description(), "type" -> "boolean")
case s: JsonEnumSchema => Json.obj("description" -> s.description(), "type" -> "string", "enum" -> (s.enumValues().asScala.toSeq))
case s: JsonIntegerSchema => Json.obj("description" -> s.description(), "type" -> "integer")
case s: JsonNumberSchema => Json.obj("description" -> s.description(), "type" -> "number")
case s: JsonStringSchema => Json.obj("description" -> s.description(), "type" -> "string")
case s: JsonObjectSchema => {
val additionalProperties: scala.Boolean = Option(s.additionalProperties()).map(_.booleanValue()).getOrElse(false)
val required: Seq[String] = Option(s.required()).map(_.asScala.toSeq).getOrElse(Seq.empty)
val properties: JsObject = JsObject(Option(s.properties()).map(_.asScala).getOrElse(Map.empty[String, JsonSchemaElement]).mapValues { el =>
schemaToJson(el)
})
val definitions: JsObject = JsObject(Option(s.definitions()).map(_.asScala).getOrElse(Map.empty[String, JsonSchemaElement]).mapValues { el =>
schemaToJson(el)
})
Json.obj(
"description" -> s.description(),
"type" -> "object",
"required" -> required,
"properties" -> properties,
"definitions" -> definitions,
"additionalProperties" -> additionalProperties,
)
}
case s: JsonAnyOfSchema => Json.obj("description" -> s.description(), "anyOf" -> JsArray(s.anyOf().asScala.toSeq.map(schemaToJson)))
case s: JsonArraySchema => Json.obj("description" -> s.description(), "type" -> "array", "items" ->schemaToJson(s.items()))
case s: JsonReferenceSchema => Json.obj("$ref" -> s.reference())
case _ => Json.parse(gson.toJson(el)).asObject
}
}

def tools(connectors: Seq[String]): Seq[JsObject] = {
/*Json.obj(
"tools" -> JsArray(*/listTools().map { function =>
val additionalProperties: scala.Boolean = Option(function.parameters().additionalProperties()).map(_.booleanValue()).getOrElse(false)
val required: Seq[String] = Option(function.parameters().required()).map(_.asScala.toSeq).getOrElse(Seq.empty)
val properties: JsObject = JsObject(Option(function.parameters().properties()).map(_.asScala).getOrElse(Map.empty[String, JsonSchemaElement]).mapValues { el =>
schemaToJson(el)
})
val definitions: JsObject = JsObject(Option(function.parameters().definitions()).map(_.asScala).getOrElse(Map.empty[String, JsonSchemaElement]).mapValues { el =>
schemaToJson(el)
})
Json.obj(
"type" -> "function",
"function" -> Json.obj(
"name" -> s"mcp___${function.name()}",
"description" -> function.description(),
"strict" -> true,
"parameters" -> Json.obj(
"type" -> "object",
"required" -> required,
"additionalProperties" -> additionalProperties,
"properties" -> properties,
"definitions" -> definitions,
)
)
)
}/*)
)*/
}

private def callTool(functions: Seq[GenericApiResponseChoiceMessageToolCall])(f: (String, GenericApiResponseChoiceMessageToolCall) => Source[JsValue, _])(implicit ec: ExecutionContext, env: Env): Future[Seq[JsValue]] = {
Source(functions.toList)
.mapAsync(1) { toolCall =>
val fid = toolCall.function.name
println(s"calling function '${fid}' with args: '${toolCall.function.arguments}'")
callAsync(fid, toolCall.function.arguments).map { r =>
(r, toolCall).some
}
}
.collect {
case Some(t) => t
}
.flatMapConcat {
case (resp, tc) => f(resp, tc)
}
.runWith(Sink.seq)(env.otoroshiMaterializer)
}

def callToolsOpenai(functions: Seq[GenericApiResponseChoiceMessageToolCall])(implicit ec: ExecutionContext, env: Env): Future[Seq[JsValue]] = {
callTool(functions) { (resp, tc) =>
Source(List(Json.obj("role" -> "assistant", "tool_calls" -> Json.arr(tc.raw)), Json.obj(
"role" -> "tool",
"content" -> resp,
"tool_call_id" -> tc.id
)))
}
}

def callToolsOllama(functions: Seq[GenericApiResponseChoiceMessageToolCall])(implicit ec: ExecutionContext, env: Env): Future[Seq[JsValue]] = {
callTool(functions) { (resp, tc) =>
Source(List(Json.obj("role" -> "assistant", "content" -> "", "tool_calls" -> Json.arr(tc.raw)), Json.obj(
"role" -> "tool",
"content" -> resp,
)))
}
}
}
Loading

0 comments on commit 455c781

Please sign in to comment.