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 @@ -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.
Expand Down Expand Up @@ -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"
}
}

Expand All @@ -94,7 +97,15 @@ class LmStudioModelsConfig(
@Qualifier("aiModelWebClientBuilder")
webClientBuilder: ObjectProvider<WebClient.Builder>,
) : 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,
Expand All @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"))
}
}
Loading