diff --git a/README.md b/README.md index 6167141a..7d4549a4 100644 --- a/README.md +++ b/README.md @@ -55,14 +55,16 @@ Interweb currently supports the following data providers: 6. **Ipernity**: - Search: discover photos and images within one of the largest non-commercial clubs. - Describe: obtain photo information using its url. -7. **OpenAI**: +7. **Ollama**: + - Interact with open-source LLMs for natural language understanding and generation. +8. **OpenAI**: - Interact with OpenAI's ChatGPT for natural language understanding and generation. -8. **SlideShare**: +9. **SlideShare**: - Search: find presentations and documents for various topics. -9. **Vimeo**: - - Search: locate videos created by creative content creators. - - Describe: obtain video information using its url. -10. **YouTube**: +10. **Vimeo**: + - Search: locate videos created by creative content creators. + - Describe: obtain video information using its url. +11. **YouTube**: - Search: for videos in the largest video hosting platform. - Describe: obtain detailed information about a video using its url. diff --git a/connectors/OllamaConnector/pom.xml b/connectors/OllamaConnector/pom.xml new file mode 100644 index 00000000..731816cb --- /dev/null +++ b/connectors/OllamaConnector/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + + + de.l3s.interweb + interweb-parent + 4.0.0-SNAPSHOT + ../../pom.xml + + + connector-ollama + 4.0.0-SNAPSHOT + jar + + + + de.l3s.interweb + interweb-core + + + io.quarkus + quarkus-rest-client-reactive-jackson + + + + io.quarkus + quarkus-junit5 + test + + + + + + + io.smallrye + jandex-maven-plugin + + + make-index + + jandex + + + + + + + diff --git a/connectors/OllamaConnector/src/main/java/de/l3s/interweb/connector/ollama/OllamaClient.java b/connectors/OllamaConnector/src/main/java/de/l3s/interweb/connector/ollama/OllamaClient.java new file mode 100644 index 00000000..bb7cec13 --- /dev/null +++ b/connectors/OllamaConnector/src/main/java/de/l3s/interweb/connector/ollama/OllamaClient.java @@ -0,0 +1,38 @@ +package de.l3s.interweb.connector.ollama; + +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import io.quarkus.rest.client.reactive.ClientExceptionMapper; +import io.smallrye.mutiny.Uni; +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +import de.l3s.interweb.connector.ollama.entity.CompletionResponse; +import de.l3s.interweb.connector.ollama.entity.TagsResponse; +import de.l3s.interweb.connector.ollama.entity.CompletionBody; +import de.l3s.interweb.core.ConnectorException; + +@Path("") +@Consumes(MediaType.APPLICATION_JSON) +@Produces(MediaType.APPLICATION_JSON) +@RegisterRestClient(configKey = "ollama") +public interface OllamaClient { + + /** + * Ollama Completion API + * https://github.com/ollama/ollama/blob/main/docs/api.md + */ + @POST + @Path("/api/chat") + Uni chatCompletions(CompletionBody body); + + @GET + @Path("/api/tags") + Uni tags(); + + @ClientExceptionMapper + static RuntimeException toException(Response response) { + return new ConnectorException("Remote service responded with HTTP " + response.getStatus(), response.readEntity(String.class)); + } +} diff --git a/connectors/OllamaConnector/src/main/java/de/l3s/interweb/connector/ollama/OllamaConnector.java b/connectors/OllamaConnector/src/main/java/de/l3s/interweb/connector/ollama/OllamaConnector.java new file mode 100644 index 00000000..c9b395b0 --- /dev/null +++ b/connectors/OllamaConnector/src/main/java/de/l3s/interweb/connector/ollama/OllamaConnector.java @@ -0,0 +1,106 @@ +package de.l3s.interweb.connector.ollama; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.HashMap; + +import jakarta.enterprise.context.Dependent; + +import io.smallrye.mutiny.Uni; +import org.eclipse.microprofile.rest.client.inject.RestClient; + +import de.l3s.interweb.connector.ollama.entity.TagsResponse; +import de.l3s.interweb.connector.ollama.entity.CompletionBody; +import de.l3s.interweb.core.ConnectorException; +import de.l3s.interweb.core.completion.CompletionConnector; +import de.l3s.interweb.core.completion.CompletionQuery; +import de.l3s.interweb.core.completion.CompletionResults; +import de.l3s.interweb.core.completion.Message; +import de.l3s.interweb.core.completion.UsagePrice; +import de.l3s.interweb.core.completion.Usage; +import de.l3s.interweb.core.completion.Choice; + +import org.jboss.logging.Logger; + +@Dependent +public class OllamaConnector implements CompletionConnector { + private static final Logger log = Logger.getLogger(OllamaConnector.class); + + private static final Map models = new HashMap<>(); + + @Override + public String getName() { + return "Ollama"; + } + + @Override + public String getBaseUrl() { + return "https://ollama.com/"; + } + + @Override + public String[] getModels() { + return models.keySet().toArray(new String[0]); + } + + @Override + public UsagePrice getPrice(String model) { + return models.get(model); + } + + @RestClient + OllamaClient ollama; + + @Override + public Uni complete(CompletionQuery query) throws ConnectorException { + return ollama.chatCompletions(new CompletionBody(query)).map(response -> { + Usage usage = new Usage( + response.getPromptEvalCount(), + response.getEvalCount() + ); + + List choices = List.of( + new Choice( + 0, + response.getDoneReason(), + new Message( + Message.Role.assistant, + response.getMessage().getContent() + ) + ) + ); + + CompletionResults results = new CompletionResults(); + results.setModel(response.getModel()); + results.setUsage(usage); + results.setChoices(choices); + results.setCreated(Instant.now()); + return results; + }); + } + + @Override + public boolean validate() { + TagsResponse tags; + try { + tags = ollama.tags().await().indefinitely(); + } catch (Exception e) { + log.error("Failed to validate Ollama connector", e); + return false; + } + + List models = tags.getModels().stream().map(model -> model.getName()).toList(); + if (models.isEmpty()) { + log.warn("No models found in Ollama connector"); + return false; + } + + OllamaConnector.models.clear(); + for (String model : models) { + OllamaConnector.models.put(model, new UsagePrice(0, 0)); + } + + return true; + } +} diff --git a/connectors/OllamaConnector/src/main/java/de/l3s/interweb/connector/ollama/entity/CompletionBody.java b/connectors/OllamaConnector/src/main/java/de/l3s/interweb/connector/ollama/entity/CompletionBody.java new file mode 100644 index 00000000..c9e0682c --- /dev/null +++ b/connectors/OllamaConnector/src/main/java/de/l3s/interweb/connector/ollama/entity/CompletionBody.java @@ -0,0 +1,48 @@ +package de.l3s.interweb.connector.ollama.entity; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +import de.l3s.interweb.core.completion.CompletionQuery; + +@JsonInclude(JsonInclude.Include.NON_NULL) +@RegisterForReflection +public final class CompletionBody { + + private String model; + + private List messages; + + private CompletionOptions options; + + private final Boolean stream = false; + + public CompletionBody(CompletionQuery query) { + this.model = query.getModel(); + + this.messages = query.getMessages().stream() + .map(CompletionMessage::new) + .toList(); + + this.options = new CompletionOptions(query); + } + + public String getModel() { + return model; + } + + public List getMessages() { + return messages; + } + + public CompletionOptions getOptions() { + return options; + } + + public Boolean getStream() { + return stream; + } +} diff --git a/connectors/OllamaConnector/src/main/java/de/l3s/interweb/connector/ollama/entity/CompletionMessage.java b/connectors/OllamaConnector/src/main/java/de/l3s/interweb/connector/ollama/entity/CompletionMessage.java new file mode 100644 index 00000000..669c4808 --- /dev/null +++ b/connectors/OllamaConnector/src/main/java/de/l3s/interweb/connector/ollama/entity/CompletionMessage.java @@ -0,0 +1,35 @@ +package de.l3s.interweb.connector.ollama.entity; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +import de.l3s.interweb.core.completion.Message; + +@RegisterForReflection +public final class CompletionMessage { + private String role; + private String content; + + public CompletionMessage() { + } + + public CompletionMessage(Message message) { + this.role = message.getRole().name(); + this.content = message.getContent(); + } + + public String getRole() { + return role; + } + + public void setRole(String role) { + this.role = role; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } +} diff --git a/connectors/OllamaConnector/src/main/java/de/l3s/interweb/connector/ollama/entity/CompletionOptions.java b/connectors/OllamaConnector/src/main/java/de/l3s/interweb/connector/ollama/entity/CompletionOptions.java new file mode 100644 index 00000000..4f0ce2c1 --- /dev/null +++ b/connectors/OllamaConnector/src/main/java/de/l3s/interweb/connector/ollama/entity/CompletionOptions.java @@ -0,0 +1,69 @@ +package de.l3s.interweb.connector.ollama.entity; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import de.l3s.interweb.core.completion.CompletionQuery; + + +public class CompletionOptions { + private Integer seed; + private String stop; + @JsonProperty("num_predict") + private Integer numPredict; + private Double temperature; + @JsonProperty("top_p") + private Double topP; + + public CompletionOptions() { + } + + public CompletionOptions(CompletionQuery query) { + this.seed = query.getSeed(); + if (query.getStop() != null && query.getStop().length > 0) { + this.stop = query.getStop()[0]; + } + this.numPredict = query.getMaxTokens(); + this.temperature = query.getTemperature(); + this.topP = query.getTopP(); + } + + public Integer getSeed() { + return seed; + } + + public void setSeed(Integer seed) { + this.seed = seed; + } + + public String getStop() { + return stop; + } + + public void setStop(String stop) { + this.stop = stop; + } + + public Integer getNumPredict() { + return numPredict; + } + + public void setNumPredict(Integer numPredict) { + this.numPredict = numPredict; + } + + public Double getTemperature() { + return temperature; + } + + public void setTemperature(Double temperature) { + this.temperature = temperature; + } + + public Double getTopP() { + return topP; + } + + public void setTopP(Double topP) { + this.topP = topP; + } +} diff --git a/connectors/OllamaConnector/src/main/java/de/l3s/interweb/connector/ollama/entity/CompletionResponse.java b/connectors/OllamaConnector/src/main/java/de/l3s/interweb/connector/ollama/entity/CompletionResponse.java new file mode 100644 index 00000000..44e39d36 --- /dev/null +++ b/connectors/OllamaConnector/src/main/java/de/l3s/interweb/connector/ollama/entity/CompletionResponse.java @@ -0,0 +1,117 @@ +package de.l3s.interweb.connector.ollama.entity; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public class CompletionResponse { + + private String model; + @JsonProperty("created_at") + private String createdAt; + private CompletionMessage message; + @JsonProperty("done_reason") + private String doneReason; + private Boolean done; + @JsonProperty("total_duration") + private Long totalDuration; + @JsonProperty("load_duration") + private Long loadDuration; + @JsonProperty("prompt_eval_count") + private Integer promptEvalCount; + @JsonProperty("prompt_eval_duration") + private Long promptEvalDuration; + @JsonProperty("eval_count") + private Integer evalCount; + @JsonProperty("eval_duration") + private Long evalDuration; + + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } + + public String getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(String createdAt) { + this.createdAt = createdAt; + } + + public CompletionMessage getMessage() { + return message; + } + + public void setMessage(CompletionMessage message) { + this.message = message; + } + + public String getDoneReason() { + return doneReason; + } + + public void setDoneReason(String doneReason) { + this.doneReason = doneReason; + } + + public Boolean getDone() { + return done; + } + + public void setDone(Boolean done) { + this.done = done; + } + + public Long getTotalDuration() { + return totalDuration; + } + + public void setTotalDuration(Long totalDuration) { + this.totalDuration = totalDuration; + } + + public Long getLoadDuration() { + return loadDuration; + } + + public void setLoadDuration(Long loadDuration) { + this.loadDuration = loadDuration; + } + + public Integer getPromptEvalCount() { + return promptEvalCount; + } + + public void setPromptEvalCount(Integer promptEvalCount) { + this.promptEvalCount = promptEvalCount; + } + + public Long getPromptEvalDuration() { + return promptEvalDuration; + } + + public void setPromptEvalDuration(Long promptEvalDuration) { + this.promptEvalDuration = promptEvalDuration; + } + + public Integer getEvalCount() { + return evalCount; + } + + public void setEvalCount(Integer evalCount) { + this.evalCount = evalCount; + } + + public Long getEvalDuration() { + return evalDuration; + } + + public void setEvalDuration(Long evalDuration) { + this.evalDuration = evalDuration; + } +} diff --git a/connectors/OllamaConnector/src/main/java/de/l3s/interweb/connector/ollama/entity/TagsModel.java b/connectors/OllamaConnector/src/main/java/de/l3s/interweb/connector/ollama/entity/TagsModel.java new file mode 100644 index 00000000..f8534c23 --- /dev/null +++ b/connectors/OllamaConnector/src/main/java/de/l3s/interweb/connector/ollama/entity/TagsModel.java @@ -0,0 +1,22 @@ +package de.l3s.interweb.connector.ollama.entity; + +public class TagsModel { + private String name; + private String model; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } +} diff --git a/connectors/OllamaConnector/src/main/java/de/l3s/interweb/connector/ollama/entity/TagsResponse.java b/connectors/OllamaConnector/src/main/java/de/l3s/interweb/connector/ollama/entity/TagsResponse.java new file mode 100644 index 00000000..2a9a1065 --- /dev/null +++ b/connectors/OllamaConnector/src/main/java/de/l3s/interweb/connector/ollama/entity/TagsResponse.java @@ -0,0 +1,18 @@ +package de.l3s.interweb.connector.ollama.entity; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +import java.util.List; + +@RegisterForReflection +public class TagsResponse { + private List models; + + public List getModels() { + return models; + } + + public void setModels(List models) { + this.models = models; + } +} diff --git a/connectors/OllamaConnector/src/main/resources/application.properties b/connectors/OllamaConnector/src/main/resources/application.properties new file mode 100644 index 00000000..c79141e9 --- /dev/null +++ b/connectors/OllamaConnector/src/main/resources/application.properties @@ -0,0 +1,3 @@ +# Required properties, recommended to set via environment variables (for tests, create .env in the root of the module) +connector.ollama.url= +quarkus.rest-client.ollama.url=${connector.ollama.url} diff --git a/connectors/OllamaConnector/src/test/java/de/l3s/interweb/connector/ollama/OllamaConnectorTest.java b/connectors/OllamaConnector/src/test/java/de/l3s/interweb/connector/ollama/OllamaConnectorTest.java new file mode 100644 index 00000000..b39c5b02 --- /dev/null +++ b/connectors/OllamaConnector/src/test/java/de/l3s/interweb/connector/ollama/OllamaConnectorTest.java @@ -0,0 +1,52 @@ +package de.l3s.interweb.connector.ollama; + +import static org.junit.jupiter.api.Assertions.*; + +import jakarta.inject.Inject; + +import io.quarkus.test.junit.QuarkusTest; +import org.jboss.logging.Logger; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import de.l3s.interweb.core.ConnectorException; +import de.l3s.interweb.core.completion.Choice; +import de.l3s.interweb.core.completion.CompletionQuery; +import de.l3s.interweb.core.completion.CompletionResults; +import de.l3s.interweb.core.completion.Message; + + +@Disabled +@QuarkusTest +class OllamaConnectorTest { + private static final Logger log = Logger.getLogger(OllamaConnectorTest.class); + + @Inject + OllamaConnector connector; + + @Test + void validate() throws ConnectorException { + assertTrue(connector.validate()); + } + + @Test + void complete() throws ConnectorException { + connector.validate(); + CompletionQuery query = new CompletionQuery(); + query.addMessage("You are Interweb Assistant, a helpful chat bot. Your name is not Claude it is Interweb Assistant.", Message.Role.system); + query.addMessage("What is your name?.", Message.Role.user); + query.setMaxTokens(100); + query.setTemperature(20.0); + query.setTopP(1.0); + + query.setModel("llama3"); + + CompletionResults results = connector.complete(query).await().indefinitely(); + + assertEquals(1, results.getChoices().size()); + log.infov("user: {0}", query.getMessages().getLast().getContent()); + for (Choice result : results.getChoices()) { + log.infov("assistant: {0}", result.getMessage().getContent()); + } + } +} diff --git a/interweb-server/example.env b/interweb-server/example.env index 161003f9..dbaf1660 100644 --- a/interweb-server/example.env +++ b/interweb-server/example.env @@ -23,3 +23,5 @@ CONNECTOR_SLIDESHARE_SECRET= CONNECTOR_OPENAI_URL=https://....openai.azure.com CONNECTOR_OPENAI_APIKEY= + +CONNECTOR_OLLAMA_URL= diff --git a/interweb-server/pom.xml b/interweb-server/pom.xml index b6fae108..5a0f9e58 100644 --- a/interweb-server/pom.xml +++ b/interweb-server/pom.xml @@ -49,6 +49,11 @@ connector-ipernity 4.0.0-SNAPSHOT + + de.l3s.interweb + connector-ollama + 4.0.0-SNAPSHOT + de.l3s.interweb connector-openai diff --git a/pom.xml b/pom.xml index 1e06ee07..344af1d1 100644 --- a/pom.xml +++ b/pom.xml @@ -43,6 +43,7 @@ connectors/GiphyConnector connectors/GoogleConnector connectors/IpernityConnector + connectors/OllamaConnector connectors/OpenaiConnector connectors/SlideShareConnector connectors/VimeoConnector