Add WeatherTool and integration test with auth proxy

Introduces WeatherTool for fetching weather data via OpenWeatherMap API and its tool specification. Adds an integration test (WithAuth) using Testcontainers to verify OllamaAPI connectivity through an NGINX proxy with bearer token authentication. Also updates pom.xml to include the testcontainers-nginx dependency and minor improvements to OllamaAPI for request headers and Javadoc formatting. TypewriterTextarea now supports text alignment, with homepage header using center alignment.
This commit is contained in:
amithkoujalgi 2025-08-15 23:16:24 +05:30
parent 339f788832
commit 54d8cf4cd9
No known key found for this signature in database
GPG Key ID: E29A37746AF94B70
6 changed files with 262 additions and 38 deletions

View File

@ -1,6 +1,6 @@
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState, useRef } from 'react';
const TypewriterTextarea = ({ textContent, typingSpeed = 50, pauseBetweenSentences = 1000, height = '200px', width = '100%' }) => { const TypewriterTextarea = ({ textContent, typingSpeed = 50, pauseBetweenSentences = 1000, height = '200px', width = '100%', align = 'left' }) => {
const [text, setText] = useState(''); const [text, setText] = useState('');
const [sentenceIndex, setSentenceIndex] = useState(0); const [sentenceIndex, setSentenceIndex] = useState(0);
const [charIndex, setCharIndex] = useState(0); const [charIndex, setCharIndex] = useState(0);
@ -56,8 +56,10 @@ const TypewriterTextarea = ({ textContent, typingSpeed = 50, pauseBetweenSentenc
fontSize: '1rem', fontSize: '1rem',
backgroundColor: '#f4f4f4', backgroundColor: '#f4f4f4',
border: '1px solid #ccc', border: '1px solid #ccc',
textAlign: align,
resize: 'none', resize: 'none',
whiteSpace: 'pre-wrap', whiteSpace: 'pre-wrap',
color: 'black',
}} }}
/> />
); );

View File

@ -32,6 +32,7 @@ function HomepageHeader() {
pauseBetweenSentences={1200} pauseBetweenSentences={1200}
height='130px' height='130px'
width='100%' width='100%'
align='center'
/> />
</div> </div>
<div className={styles.buttons} > <div className={styles.buttons} >

View File

@ -223,6 +223,12 @@
<version>1.20.2</version> <version>1.20.2</version>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>nginx</artifactId>
<version>1.20.0</version>
<scope>test</scope>
</dependency>
</dependencies> </dependencies>
<distributionManagement> <distributionManagement>

View File

@ -51,7 +51,7 @@ import java.util.stream.Collectors;
/** /**
* The base Ollama API class. * The base Ollama API class.
*/ */
@SuppressWarnings({ "DuplicatedCode", "resource" }) @SuppressWarnings({"DuplicatedCode", "resource"})
public class OllamaAPI { public class OllamaAPI {
private static final Logger logger = LoggerFactory.getLogger(OllamaAPI.class); private static final Logger logger = LoggerFactory.getLogger(OllamaAPI.class);
@ -789,8 +789,9 @@ public class OllamaAPI {
String jsonData = Utils.getObjectMapper().writeValueAsString(requestBody); String jsonData = Utils.getObjectMapper().writeValueAsString(requestBody);
HttpClient httpClient = HttpClient.newHttpClient(); HttpClient httpClient = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder(uri) HttpRequest request = getRequestBuilderDefault(uri)
.header("Content-Type", "application/json") .header("Accept", "application/json")
.header("Content-type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonData)) .POST(HttpRequest.BodyPublishers.ofString(jsonData))
.build(); .build();

View File

@ -0,0 +1,88 @@
package io.github.ollama4j.tools.sampletools;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.ollama4j.tools.Tools;
public class WeatherTool {
private String openWeatherMapAPIKey = null;
public WeatherTool(String openWeatherMapAPIKey) {
this.openWeatherMapAPIKey = openWeatherMapAPIKey;
}
public String getCurrentWeather(Map<String, Object> arguments) {
String city = (String) arguments.get("cityName");
System.out.println("Finding weather for city: " + city);
String url = String.format("https://api.openweathermap.org/data/2.5/weather?q=%s&appid=%s&units=metric",
city,
this.openWeatherMapAPIKey);
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.build();
try {
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
ObjectMapper mapper = new ObjectMapper();
JsonNode root = mapper.readTree(response.body());
JsonNode main = root.path("main");
double temperature = main.path("temp").asDouble();
String description = root.path("weather").get(0).path("description").asText();
return String.format("Weather in %s: %.1f°C, %s", city, temperature, description);
} else {
return "Could not retrieve weather data for " + city + ". Status code: "
+ response.statusCode();
}
} catch (IOException | InterruptedException e) {
e.printStackTrace();
return "Error retrieving weather data: " + e.getMessage();
}
}
public Tools.ToolSpecification getSpecification() {
return Tools.ToolSpecification.builder()
.functionName("weather-reporter")
.functionDescription(
"You are a tool who simply finds the city name from the user's message input/query about weather.")
.toolFunction(this::getCurrentWeather)
.toolPrompt(
Tools.PromptFuncDefinition.builder()
.type("prompt")
.function(
Tools.PromptFuncDefinition.PromptFuncSpec
.builder()
.name("get-city-name")
.description("Get the city name")
.parameters(
Tools.PromptFuncDefinition.Parameters
.builder()
.type("object")
.properties(
Map.of(
"cityName",
Tools.PromptFuncDefinition.Property
.builder()
.type("string")
.description(
"The name of the city. e.g. Bengaluru")
.required(true)
.build()))
.required(java.util.List
.of("cityName"))
.build())
.build())
.build())
.build();
}
}

View File

@ -0,0 +1,126 @@
package io.github.ollama4j.integrationtests;
import io.github.ollama4j.OllamaAPI;
import io.github.ollama4j.samples.AnnotatedTool;
import io.github.ollama4j.tools.annotations.OllamaToolService;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.MethodOrderer.OrderAnnotation;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.NginxContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.ollama.OllamaContainer;
import org.testcontainers.utility.DockerImageName;
import org.testcontainers.utility.MountableFile;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.assertTrue;
@OllamaToolService(providers = {AnnotatedTool.class})
@TestMethodOrder(OrderAnnotation.class)
@SuppressWarnings({"HttpUrlsUsage", "SpellCheckingInspection", "resource", "ResultOfMethodCallIgnored"})
public class WithAuth {
private static final Logger LOG = LoggerFactory.getLogger(WithAuth.class);
private static final int NGINX_PORT = 80;
private static final int OLLAMA_INTERNAL_PORT = 11434;
private static final String OLLAMA_VERSION = "0.6.1";
private static OllamaContainer ollama;
private static GenericContainer<?> nginx;
private static OllamaAPI api;
@BeforeAll
public static void setUp() {
ollama = createOllamaContainer();
ollama.start();
nginx = createNginxContainer(ollama.getMappedPort(OLLAMA_INTERNAL_PORT));
nginx.start();
LOG.info("Using Testcontainer Ollama host...");
api = new OllamaAPI("http://" + nginx.getHost() + ":" + nginx.getMappedPort(NGINX_PORT));
api.setRequestTimeoutSeconds(120);
api.setVerbose(true);
api.setNumberOfRetriesForModelPull(3);
}
private static OllamaContainer createOllamaContainer() {
OllamaContainer container = new OllamaContainer("ollama/ollama:" + OLLAMA_VERSION);
container.addExposedPort(OLLAMA_INTERNAL_PORT);
return container;
}
private static String generateNginxConfig(int ollamaPort) {
return String.format("events {}\n" +
"\n" +
"http {\n" +
" server {\n" +
" listen 80;\n" +
"\n" +
" location / {\n" +
" set $auth_header $http_authorization;\n" +
"\n" +
" if ($auth_header != \"Bearer secret-token\") {\n" +
" return 401;\n" +
" }\n" +
"\n" +
" proxy_pass http://host.docker.internal:%s/;\n" +
" proxy_set_header Host $host;\n" +
" proxy_set_header X-Real-IP $remote_addr;\n" +
" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n" +
" proxy_set_header X-Forwarded-Proto $scheme;\n" +
" }\n" +
" }\n" +
"}\n", ollamaPort);
}
public static GenericContainer<?> createNginxContainer(int ollamaPort) {
File nginxConf;
try {
File tempDir = new File(System.getProperty("java.io.tmpdir"), "nginx-auth");
if (!tempDir.exists()) tempDir.mkdirs();
nginxConf = new File(tempDir, "nginx.conf");
try (FileWriter writer = new FileWriter(nginxConf)) {
writer.write(generateNginxConfig(ollamaPort));
}
return new NginxContainer<>(DockerImageName.parse("nginx:1.23.4-alpine"))
.withExposedPorts(NGINX_PORT)
.withCopyFileToContainer(
MountableFile.forHostPath(nginxConf.getAbsolutePath()),
"/etc/nginx/nginx.conf"
)
.withExtraHost("host.docker.internal", "host-gateway")
.waitingFor(
Wait.forHttp("/")
.forStatusCode(401)
.withStartupTimeout(Duration.ofSeconds(30))
);
} catch (IOException e) {
throw new RuntimeException("Failed to create nginx.conf", e);
}
}
@Test
@Order(1)
void testEndpoint() throws InterruptedException {
String ollamaUrl = "http://" + ollama.getHost() + ":" + ollama.getMappedPort(OLLAMA_INTERNAL_PORT);
String nginxUrl = "http://" + nginx.getHost() + ":" + nginx.getMappedPort(NGINX_PORT);
System.out.printf("Ollama service at %s is now accessible through the Nginx proxy at %s%n", ollamaUrl, nginxUrl);
api.setBearerAuth("secret-token");
Thread.sleep(1000);
assertTrue(api.ping(), "OllamaAPI failed to ping through NGINX with auth.");
}
}