diff --git a/embabel-agent-autoconfigure/models/embabel-agent-lmstudio-autoconfigure/src/main/kotlin/com/embabel/agent/config/models/lmstudio/LmStudioModelsConfig.kt b/embabel-agent-autoconfigure/models/embabel-agent-lmstudio-autoconfigure/src/main/kotlin/com/embabel/agent/config/models/lmstudio/LmStudioModelsConfig.kt index 750900ace..9108b250d 100644 --- a/embabel-agent-autoconfigure/models/embabel-agent-lmstudio-autoconfigure/src/main/kotlin/com/embabel/agent/config/models/lmstudio/LmStudioModelsConfig.kt +++ b/embabel-agent-autoconfigure/models/embabel-agent-lmstudio-autoconfigure/src/main/kotlin/com/embabel/agent/config/models/lmstudio/LmStudioModelsConfig.kt @@ -45,7 +45,7 @@ class LmStudioProperties : RetryProperties { /** * Base URL for LM Studio endpoint */ - var baseUrl: String = "http://127.0.0.1:1234" + var baseUrl: String = DEFAULT_BASE_URL /** * API key for LM Studio. Defaults to null as apiKey isn't supported yet. @@ -74,7 +74,10 @@ class LmStudioProperties : RetryProperties { override val propertyPrefix: String = PREFIX companion object { - const val PREFIX = "embabel.agent.platform.models.lmstudio" + const val PREFIX = "embabel.agent.platform.models.lmstudio" + const val DEFAULT_HOST = "localhost" + const val DEFAULT_PORT = 1234 + const val DEFAULT_BASE_URL = "http://$DEFAULT_HOST:$DEFAULT_PORT" } } @@ -94,7 +97,15 @@ class LmStudioModelsConfig( @Qualifier("aiModelWebClientBuilder") webClientBuilder: ObjectProvider, ) : OpenAiCompatibleModelFactory( - baseUrl = lmStudioProperties.baseUrl, + // The SDK appends `/chat/completions` to whatever it is given — the factory's + // own docs say to bake the full path into the base URL — while discovery + // below appends `/v1/models` to the SAME property. Both cannot be satisfied + // by one configured value, and the mismatch is silent: models are listed + // (discovery is right) and then every completion 404s with LM Studio's + // "Unexpected endpoint or method", which surfaces to the caller as the + // baffling "`choices` is not set". Normalise here so the configured value + // stays the plain server address a user would write. + baseUrl = chatBaseUrl(lmStudioProperties.baseUrl), apiKey = lmStudioProperties.apiKey, completionsPath = null, embeddingsPath = null, @@ -105,6 +116,18 @@ class LmStudioModelsConfig( private val log = LoggerFactory.getLogger(LmStudioModelsConfig::class.java) + companion object { + /** + * The URL the OpenAI SDK should treat as its API root: LM Studio serves + * the OpenAI surface under `/v1`, so append it unless the operator has + * already done so. + */ + internal fun chatBaseUrl(baseUrl: String): String { + val clean = baseUrl.trimEnd('/') + return if (clean.endsWith("/v1")) clean else "$clean/v1" + } + } + // OpenAI-compatible models response @JsonIgnoreProperties(ignoreUnknown = true) private data class ModelResponse( @@ -197,7 +220,11 @@ class LmStudioModelsConfig( .requestFactory(requestFactory) .build() - val cleanBaseUrl = baseUrl?.trimEnd('/') ?: "http://127.0.0.1:1234" + // The RAW configured address, deliberately — NOT the inherited `baseUrl`, + // which is normalised to end in /v1 for the SDK. Reading that here + // built `…/v1/api/v1/models`, discovered nothing, and left the + // appliance unable to start with a local default model. + val cleanBaseUrl = lmStudioProperties.baseUrl.trimEnd('/') val apiUrl = if (cleanBaseUrl.contains("/api")) { cleanBaseUrl } else { @@ -230,7 +257,7 @@ class LmStudioModelsConfig( response.models?: emptyList() } catch (e: Exception) { - log.warn("Failed to load models from {}: {}", baseUrl, e.message) + log.warn("Failed to load models from {}: {}", lmStudioProperties.baseUrl, e.message) emptyList() } } diff --git a/embabel-agent-autoconfigure/models/embabel-agent-lmstudio-autoconfigure/src/test/kotlin/com/embabel/agent/config/models/lmstudio/LmStudioModelsConfigTest.kt b/embabel-agent-autoconfigure/models/embabel-agent-lmstudio-autoconfigure/src/test/kotlin/com/embabel/agent/config/models/lmstudio/LmStudioModelsConfigTest.kt index 93b953cf5..48a088ab1 100644 --- a/embabel-agent-autoconfigure/models/embabel-agent-lmstudio-autoconfigure/src/test/kotlin/com/embabel/agent/config/models/lmstudio/LmStudioModelsConfigTest.kt +++ b/embabel-agent-autoconfigure/models/embabel-agent-lmstudio-autoconfigure/src/test/kotlin/com/embabel/agent/config/models/lmstudio/LmStudioModelsConfigTest.kt @@ -16,6 +16,7 @@ package com.embabel.agent.config.models.lmstudio import io.micrometer.observation.ObservationRegistry +import kotlin.test.assertEquals import io.mockk.* import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach @@ -191,4 +192,26 @@ class LmStudioModelsConfigTest { } } + + // ----------------------------------------------------------------------- + // The chat base URL. Discovery appends `/v1/models` to the configured value + // while the OpenAI SDK appends `/chat/completions` to it, so one of the two + // has to normalise — and getting this wrong is silent: models list fine and + // every completion 404s as "`choices` is not set". + // ----------------------------------------------------------------------- + + @Test + fun `chat base url gains the v1 the SDK expects`() { + assertEquals("${LmStudioProperties.DEFAULT_BASE_URL}/v1", LmStudioModelsConfig.chatBaseUrl(LmStudioProperties.DEFAULT_BASE_URL)) + } + + @Test + fun `a trailing slash does not produce a double slash`() { + assertEquals("${LmStudioProperties.DEFAULT_BASE_URL}/v1", LmStudioModelsConfig.chatBaseUrl("${LmStudioProperties.DEFAULT_BASE_URL}/")) + } + + @Test + fun `an operator who already wrote v1 is not given two`() { + assertEquals("http://host.docker.internal:${LmStudioProperties.DEFAULT_PORT}/v1", LmStudioModelsConfig.chatBaseUrl("http://host.docker.internal:${LmStudioProperties.DEFAULT_PORT}/v1")) + } }