Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ public OllamaChatModel(
defaultOptions != null ? defaultOptions : OllamaOptions.builder().build();
this.formatter = formatter != null ? formatter : new OllamaChatFormatter();
this.stream = stream;
setNativeStructuredOutput(true);
setNativeStructuredOutputWithTools(false);

HttpTransport transport =
httpTransport != null ? httpTransport : HttpTransportFactory.getDefault();
Expand Down Expand Up @@ -272,6 +274,8 @@ public static class Builder {
private ProxyConfig proxyConfig;
private int contextWindowSize = -1;
private boolean stream = true;
private boolean nativeStructuredOutput = true;
private boolean nativeStructuredOutputWithTools = false;

public Builder modelName(String modelName) {
this.modelName = modelName;
Expand Down Expand Up @@ -364,6 +368,37 @@ public Builder contextWindowSize(int contextWindowSize) {
return this;
}

/**
* Sets whether this model supports native structured output via Ollama's
* {@code format} field.
*
* <p>Defaults to {@code true}. Set to {@code false} for endpoints that do not
* support JSON Schema-constrained output; the agent will use the synthetic
* {@code generate_response} tool instead.
*
* @param nativeStructuredOutput false to disable native structured output
* @return this builder instance
*/
public Builder nativeStructuredOutput(boolean nativeStructuredOutput) {
this.nativeStructuredOutput = nativeStructuredOutput;
return this;
}

/**
* Sets whether this model handles native structured output alongside tool calling.
*
* <p>Defaults to {@code false} because Ollama does not document guaranteed behavior
* for combining {@code format} and {@code tools}. Set to {@code true} for models and
* endpoints where that combination has been verified.
*
* @param nativeStructuredOutputWithTools true to use native output with tools
* @return this builder instance
*/
public Builder nativeStructuredOutputWithTools(boolean nativeStructuredOutputWithTools) {
this.nativeStructuredOutputWithTools = nativeStructuredOutputWithTools;
return this;
}

public OllamaChatModel build() {
OllamaOptions finalOptions =
defaultOptions != null
Expand All @@ -381,6 +416,8 @@ public OllamaChatModel build() {
OllamaChatModel model =
new OllamaChatModel(
modelName, baseUrl, finalOptions, formatter, transport, stream);
model.setNativeStructuredOutput(nativeStructuredOutput);
model.setNativeStructuredOutputWithTools(nativeStructuredOutputWithTools);
if (contextWindowSize >= 0) {
model.setContextWindowSize(contextWindowSize);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.agentscope.core.formatter.ResponseFormat;
import io.agentscope.core.model.ExecutionConfig;
import io.agentscope.core.model.GenerateOptions;
import io.agentscope.core.util.JsonUtils;
Expand Down Expand Up @@ -392,6 +393,16 @@ public static OllamaOptions fromGenerateOptions(GenerateOptions genOptions) {
builder.thinkOption(ThinkOption.ThinkBoolean.ENABLED);
}

ResponseFormat responseFormat = genOptions.getResponseFormat();
if (responseFormat != null) {
if ("json_schema".equals(responseFormat.getType())) {
// Ollama expects the schema itself, without the OpenAI response-format wrapper.
builder.format(responseFormat.getJsonSchema().getSchema());
} else if ("json_object".equals(responseFormat.getType())) {
builder.format("json");
}
}

// Map additional parameters from map
if (genOptions.getAdditionalBodyParams() != null) {
applyAdditionalParams(builder, genOptions.getAdditionalBodyParams());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
/*
* Copyright 2024-2026 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.agentscope.extensions.model.ollama;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import com.fasterxml.jackson.databind.JsonNode;
import io.agentscope.core.ReActAgent;
import io.agentscope.core.message.Base64Source;
import io.agentscope.core.message.ImageBlock;
import io.agentscope.core.message.Msg;
import io.agentscope.core.message.MsgRole;
import io.agentscope.core.message.TextBlock;
import io.agentscope.core.message.ThinkingBlock;
import io.agentscope.core.model.transport.HttpRequest;
import io.agentscope.core.model.transport.HttpResponse;
import io.agentscope.core.model.transport.HttpTransport;
import io.agentscope.core.tool.Tool;
import io.agentscope.core.tool.Toolkit;
import io.agentscope.core.util.JsonUtils;
import io.agentscope.extensions.model.ollama.options.OllamaOptions;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import reactor.core.publisher.Flux;

class OllamaStructuredOutputTest {
private static final String JSON = "{\"description\":[\"a red apple\"],\"name\":\"apple\"}";
private static final String IMAGE =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aG1kAAAAASUVORK5CYII=";

public static class ImageDescription {
public List<String> description;
public String name;
}

public static class Tools {
@Tool(name = "describe", description = "Describe an image")
public String describe() {
return "a red apple";
}
}

@Test
void builderExposesNativeStructuredOutputConfiguration() {
HttpTransport transport = mock(HttpTransport.class);
OllamaChatModel model =
OllamaChatModel.builder().modelName("qwen3.5:9b").httpTransport(transport).build();
assertTrue(model.supportsNativeStructuredOutput());
assertFalse(model.supportsNativeStructuredOutputWithTools());

OllamaChatModel configured =
OllamaChatModel.builder()
.modelName("qwen3.5:9b")
.httpTransport(transport)
.nativeStructuredOutput(false)
.nativeStructuredOutputWithTools(true)
.build();
assertFalse(configured.supportsNativeStructuredOutput());
assertTrue(configured.supportsNativeStructuredOutputWithTools());
}

@ParameterizedTest
@ValueSource(booleans = {true, false})
void structuredImageResponsePreservesJsonAndFinalUsage(boolean streaming) {
HttpTransport transport = mock(HttpTransport.class);
if (streaming) {
when(transport.stream(any(HttpRequest.class)))
.thenReturn(
Flux.just(
frame(
Map.of(
"role",
"assistant",
"thinking",
"Inspecting the image."),
false),
frame(
Map.of(
"role",
"assistant",
"content",
JSON.substring(0, 23)),
false),
frame(
Map.of(
"role",
"assistant",
"content",
JSON.substring(23)),
false),
frame(Map.of("role", "assistant", "content", ""), true)));
} else {
when(transport.execute(any(HttpRequest.class)))
.thenReturn(
HttpResponse.builder()
.statusCode(200)
.body(frame(Map.of("role", "assistant", "content", JSON), true))
.build());
}

OllamaChatModel model =
OllamaChatModel.builder().modelName("qwen3.5:9b").stream(streaming)
.httpTransport(transport)
.defaultOptions(OllamaOptions.builder().format("json").build())
.build();
Msg input =
Msg.builder()
.role(MsgRole.USER)
.content(
List.of(
TextBlock.builder().text("Describe this image").build(),
ImageBlock.builder()
.source(
Base64Source.builder()
.mediaType("image/png")
.data(IMAGE)
.build())
.build()))
.build();
try (ReActAgent agent = ReActAgent.builder().name("vision").model(model).build()) {
Msg result =
agent.call(List.of(input), ImageDescription.class)
.block(Duration.ofSeconds(10));
assertNotNull(result);
assertEquals("apple", result.getStructuredData(ImageDescription.class).name);
assertEquals(JSON, result.getTextContent());
assertNotNull(result.getUsage());
assertEquals(23, result.getUsage().getInputTokens());
assertEquals(12, result.getUsage().getOutputTokens());
if (streaming) {
assertEquals(
"Inspecting the image.",
result.getContentBlocks(ThinkingBlock.class).get(0).getThinking());
}
}

ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
if (streaming) {
verify(transport).stream(captor.capture());
} else {
verify(transport).execute(captor.capture());
}
JsonNode request =
JsonUtils.getJsonCodec().fromJson(captor.getValue().getBody(), JsonNode.class);
assertEquals("object", request.path("format").path("type").asText());
assertTrue(request.path("format").path("properties").has("name"));
assertTrue(request.path("format").path("properties").has("description"));
assertFalse(request.path("format").has("json_schema"));
assertFalse(request.has("tools"));
assertTrue(
request.path("messages").findValues("images").stream()
.anyMatch(images -> images.get(0).asText().equals(IMAGE)));
}

@Test
void businessToolsDefaultToSyntheticStructuredOutput() {
HttpTransport transport = mock(HttpTransport.class);
Map<String, Object> call =
Map.of(
"function",
Map.of(
"name",
"generate_response",
"arguments",
Map.of(
"response",
Map.of(
"name",
"apple",
"description",
List.of("a red apple")))));
when(transport.execute(any(HttpRequest.class)))
.thenReturn(
HttpResponse.builder()
.statusCode(200)
.body(
frame(
Map.of(
"role",
"assistant",
"content",
"",
"tool_calls",
List.of(call)),
true))
.build());
Toolkit toolkit = new Toolkit();
toolkit.registerTool(new Tools());
OllamaChatModel model =
OllamaChatModel.builder().modelName("qwen3.5:9b").stream(false)
.httpTransport(transport)
.build();
try (ReActAgent agent =
ReActAgent.builder().name("tools").model(model).toolkit(toolkit).build()) {
Msg result =
agent.call("Describe an image", ImageDescription.class)
.block(Duration.ofSeconds(10));
assertNotNull(result);
assertEquals("apple", result.getStructuredData(ImageDescription.class).name);
assertEquals(12, result.getUsage().getOutputTokens());
}

ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
verify(transport).execute(captor.capture());
JsonNode request =
JsonUtils.getJsonCodec().fromJson(captor.getValue().getBody(), JsonNode.class);
assertFalse(request.has("format"));
List<JsonNode> names = request.path("tools").findValues("name");
assertTrue(names.stream().anyMatch(name -> "generate_response".equals(name.asText())));
assertTrue(names.stream().anyMatch(name -> "describe".equals(name.asText())));
}

@Test
void businessToolsCanOptIntoNativeStructuredOutput() {
HttpTransport transport = mock(HttpTransport.class);
when(transport.execute(any(HttpRequest.class)))
.thenReturn(
HttpResponse.builder()
.statusCode(200)
.body(frame(Map.of("role", "assistant", "content", JSON), true))
.build());
Toolkit toolkit = new Toolkit();
toolkit.registerTool(new Tools());
OllamaChatModel model =
OllamaChatModel.builder().modelName("qwen3.5:9b").stream(false)
.httpTransport(transport)
.nativeStructuredOutputWithTools(true)
.build();
try (ReActAgent agent =
ReActAgent.builder().name("tools").model(model).toolkit(toolkit).build()) {
Msg result =
agent.call("Describe an image", ImageDescription.class)
.block(Duration.ofSeconds(10));
assertNotNull(result);
assertEquals("apple", result.getStructuredData(ImageDescription.class).name);
}

ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
verify(transport).execute(captor.capture());
JsonNode request =
JsonUtils.getJsonCodec().fromJson(captor.getValue().getBody(), JsonNode.class);
assertEquals("object", request.path("format").path("type").asText());
List<JsonNode> names = request.path("tools").findValues("name");
assertTrue(names.stream().anyMatch(name -> "describe".equals(name.asText())));
assertFalse(names.stream().anyMatch(name -> "generate_response".equals(name.asText())));
}

private static String frame(Map<String, Object> message, boolean done) {
return JsonUtils.getJsonCodec()
.toJson(
Map.of(
"model",
"qwen3.5:9b",
"message",
message,
"done",
done,
"prompt_eval_count",
done ? 23 : 0,
"eval_count",
done ? 12 : 0));
}
}
Loading
Loading