Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package com.embabel.agent.config.models.docker
import com.embabel.agent.api.models.DockerLocalModels.Companion.PROVIDER
import com.embabel.agent.config.models.docker.DockerRetryProperties.Companion.PREFIX
import com.embabel.agent.openai.OpenAiChatOptionsConverter
import com.embabel.agent.openai.withOpenAiModel
import com.embabel.agent.spi.common.RetryProperties
import com.embabel.agent.spi.support.springai.SpringAiLlmService
import com.embabel.common.ai.autoconfig.ProviderInitialization
Expand Down Expand Up @@ -246,7 +247,7 @@ class DockerLocalModelsConfig(
name = model.id,
chatModel = chatModel,
provider = PROVIDER,
optionsConverter = OpenAiChatOptionsConverter,
optionsConverter = OpenAiChatOptionsConverter.withOpenAiModel(model.id),
knowledgeCutoffDate = null,
pricingModel = PricingModel.ALL_YOU_CAN_EAT,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,19 @@
import com.embabel.agent.config.models.docker.DockerConnectionProperties;
import com.embabel.agent.config.models.docker.DockerLocalModelsConfig;
import com.embabel.agent.config.models.docker.DockerRetryProperties;
import com.embabel.agent.spi.support.springai.SpringAiLlmService;
import com.embabel.common.ai.autoconfig.ProviderInitialization;
import com.embabel.common.ai.model.LlmOptions;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.Test;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;

Expand Down Expand Up @@ -157,6 +165,39 @@ void providerInitializationHasEmptyModelListWhenDockerUnavailable() {
});
}

/**
* Verifies that a model discovered from a Docker OpenAI-compatible endpoint binds its discovered id onto request-level options.
*/
@Test
void discoveredDockerModelServiceBindsConfiguredModelOnRequestOptions() throws IOException {
// Prepare
final HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
server.createContext("/engines/v1/models", exchange -> {
final byte[] body = """
{"object":"list","data":[{"id":"docker-test-model"}]}
""".getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
exchange.close();
});
server.start();
try {
// Execute
final String baseUrl = "http://localhost:" + server.getAddress().getPort() + "/engines";
contextRunner.withPropertyValues("embabel.agent.models.docker.base-url=" + baseUrl).run(context -> {
final SpringAiLlmService service = context.getBean("dockerModel-docker-test-model", SpringAiLlmService.class);
final OpenAiChatOptions options = (OpenAiChatOptions) service.getOptionsConverter().convertOptions(new LlmOptions());

// Verify
assertThat(options.getModel()).isEqualTo("docker-test-model");
});
}
finally {
server.stop(0);
}
}

@Override
public String toString() {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/
package com.embabel.agent.config.models.minimax

import com.embabel.agent.openai.withOpenAiModel
import com.embabel.agent.test.models.OptionsConverterTestSupport
import com.embabel.common.ai.model.LlmOptions
import org.junit.jupiter.api.Assertions.assertEquals
Expand Down Expand Up @@ -77,4 +78,18 @@ class MiniMaxOptionsConverterTest : OptionsConverterTestSupport<OpenAiChatOption
val options = optionsConverter.convertOptions(LlmOptions().withFrequencyPenalty(0.3))
assertEquals(0.3, options.frequencyPenalty)
}

@Test
fun `should bind configured MiniMax model`() {
// Prepare
val converter = MiniMaxOptionsConverter.withOpenAiModel("MiniMax-M1")
val llmOptions = LlmOptions().withTopP(0.8)

// Execute
val options = converter.convertOptions(llmOptions)

// Verify
assertEquals("MiniMax-M1", options.model)
assertEquals(0.8, options.topP)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import com.embabel.agent.config.models.openai.OpenAiProperties.Companion.PREFIX
import com.embabel.agent.openai.Gpt5ChatOptionsConverter
import com.embabel.agent.openai.OpenAiCompatibleModelFactory
import com.embabel.agent.openai.StandardOpenAiOptionsConverter
import com.embabel.agent.openai.withOpenAiModel
import com.embabel.agent.spi.LlmService
import com.embabel.agent.spi.common.RetryProperties
import com.embabel.agent.spi.support.springai.SpringAiLlmService
Expand Down Expand Up @@ -220,7 +221,7 @@ class OpenAiModelsConfig(
name = modelDef.modelId,
chatModel = chatModel,
provider = OpenAiModels.PROVIDER,
optionsConverter = optionsConverter,
optionsConverter = optionsConverter.withOpenAiModel(modelDef.modelId),
knowledgeCutoffDate = modelDef.knowledgeCutoffDate,
pricingModel = pricingModel,
thinkingSupported = true,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
* Copyright 2024-2026 Embabel Pty Ltd.
*
* 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
*
* http://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 com.embabel.agent.autoconfigure.models.openai;

import com.embabel.agent.spi.support.springai.SpringAiLlmService;
import com.embabel.common.ai.model.LlmOptions;
import org.junit.jupiter.api.Test;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;

import static org.assertj.core.api.Assertions.assertThat;

/**
* Verifies OpenAI model auto-configuration wiring that is specific to the Spring AI OpenAI provider.
*/
class AgentOpenAiAutoConfigurationTest {

private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(AgentOpenAiAutoConfiguration.class))
.withPropertyValues("embabel.agent.platform.models.openai.api-key=test-key");

/**
* Verifies that the standard OpenAI auto-configuration binds the configured catalog model id onto request-level options.
*/
@Test
void openAiModelServiceBindsConfiguredModelOnRequestOptions() {
// Prepare
final String beanName = "gpt41";
final String expectedModel = "gpt-4.1";

// Execute
contextRunner.run(context -> {
final SpringAiLlmService service = context.getBean(beanName, SpringAiLlmService.class);
final OpenAiChatOptions options = (OpenAiChatOptions) service.getOptionsConverter().convertOptions(new LlmOptions());

// Verify
assertThat(options.getModel()).isEqualTo(expectedModel);
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package com.embabel.agent.config.models.openai.custom
import com.embabel.agent.config.models.openai.custom.OpenAiCustomProperties.Companion.PREFIX
import com.embabel.agent.openai.OpenAiCompatibleModelFactory
import com.embabel.agent.openai.StandardOpenAiOptionsConverter
import com.embabel.agent.openai.withOpenAiModel
import com.embabel.agent.spi.LlmService
import com.embabel.agent.spi.common.RetryProperties
import com.embabel.agent.spi.support.springai.SpringAiLlmService
Expand Down Expand Up @@ -209,7 +210,7 @@ class OpenAiCustomModelsConfig(
name = modelId,
chatModel = chatModel,
provider = CUSTOM_PROVIDER,
optionsConverter = StandardOpenAiOptionsConverter,
optionsConverter = StandardOpenAiOptionsConverter.withOpenAiModel(modelId),
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@
package com.embabel.agent.autoconfigure.models.openai.custom;

import com.embabel.common.ai.autoconfig.ProviderInitialization;
import com.embabel.agent.spi.support.springai.SpringAiLlmService;
import com.embabel.common.ai.model.LlmOptions;
import org.junit.jupiter.api.Test;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;

Expand All @@ -42,14 +45,35 @@ class AgentOpenAiCustomAutoConfigurationTest {
*/
@Test
void registersCustomModelAndInitialization() {
// Act
// Prepare
final String modelBeanName = "test-model";

// Execute
contextRunner.run(context -> {
// Assert
// Verify
assertThat(context).hasSingleBean(ProviderInitialization.class);
assertThat(context).hasBean("test-model");
assertThat(context).hasBean(modelBeanName);
assertThat(context.getBean(ProviderInitialization.class).getRegisteredLlms())
.extracting(registeredModel -> registeredModel.getBeanName())
.contains("test-model");
.contains(modelBeanName);
});
}

/**
* Verifies that custom OpenAI-compatible models bind their declared model id onto request-level options.
*/
@Test
void customModelServiceBindsConfiguredModelOnRequestOptions() {
// Prepare
final String modelBeanName = "test-model";

// Execute
contextRunner.run(context -> {
final SpringAiLlmService service = context.getBean(modelBeanName, SpringAiLlmService.class);
final OpenAiChatOptions options = (OpenAiChatOptions) service.getOptionsConverter().convertOptions(new LlmOptions());

// Verify
assertThat(options.getModel()).isEqualTo(modelBeanName);
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ open class OpenAiCompatibleModelFactory(
name = model,
chatModel = chatModelOf(model),
provider = provider,
optionsConverter = optionsConverter,
optionsConverter = optionsConverter.withOpenAiModel(model),
pricingModel = pricingModel,
knowledgeCutoffDate = knowledgeCutoffDate,
)
Expand Down Expand Up @@ -377,7 +377,6 @@ object OpenAiChatOptionsConverter : OptionsConverter<OpenAiChatOptions> {
.maxTokens(options.maxTokens)
.presencePenalty(options.presencePenalty)
.frequencyPenalty(options.frequencyPenalty)
.topP(options.topP)
//.streamUsage(true) additional feature note
.build()
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,41 @@
*/
package com.embabel.agent.openai

import com.embabel.agent.spi.support.springai.SpringAiLlmService
import com.embabel.common.ai.model.LlmOptions
import com.embabel.common.ai.model.OptionsConverter
import com.embabel.common.util.loggerFor
import org.springframework.ai.openai.OpenAiChatModel
import org.springframework.ai.openai.OpenAiChatOptions

/**
* Wrap this converter so every request-level [OpenAiChatOptions] carries the resolved
* OpenAI-compatible wire model id for its [SpringAiLlmService].
*
* Spring AI 2.0 does not merge an [OpenAiChatModel]'s default model when runtime
* [OpenAiChatOptions] are present, so OpenAI-compatible providers need the model bound
* directly onto the request options produced by Embabel converters.
*
* @param model provider wire model id to force onto every converted request.
* @throws IllegalArgumentException if [model] is blank or the delegate does not return
* [OpenAiChatOptions].
*/
fun OptionsConverter<*>.withOpenAiModel(model: String): OptionsConverter<OpenAiChatOptions> {
require(model.isNotBlank()) {
"OpenAI-compatible model must not be blank"
}
return OptionsConverter { options ->
val converted: Any = convertOptions(options)
require(converted is OpenAiChatOptions) {
val type = converted.let { it::class.qualifiedName } ?: "null"
"OpenAI-compatible options converter must return OpenAiChatOptions for model '$model', but returned $type"
}
converted.mutate()
.model(model)
.build()
}
Comment thread
Copilot marked this conversation as resolved.
Outdated
}

/**
* Options converter for GPT-5 models that don't support temperature adjustment.
*/
Expand Down
Loading
Loading