|
@@ -1,7 +1,10 @@
|
|
|
import { define } from "@opencode-ai/plugin/effect/plugin"
|
|
import { define } from "@opencode-ai/plugin/effect/plugin"
|
|
|
-import { Duration, Effect, Schedule, Schema, Semaphore } from "effect"
|
|
|
|
|
|
|
+import { Document, type Entry } from "@opencode-ai/schema/config"
|
|
|
|
|
+import { Duration, Effect, Schedule, Schema, Semaphore, Stream } from "effect"
|
|
|
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
|
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
|
|
|
|
+import { Config } from "../../config.js"
|
|
|
import { Model } from "../../model.js"
|
|
import { Model } from "../../model.js"
|
|
|
|
|
+import { Provider } from "../../provider.js"
|
|
|
import type { PluginInternal } from "../internal.js"
|
|
import type { PluginInternal } from "../internal.js"
|
|
|
|
|
|
|
|
const providerID = "lmstudio"
|
|
const providerID = "lmstudio"
|
|
@@ -24,16 +27,16 @@ const RemoteModel = Schema.Struct({
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
const Response = Schema.Struct({ models: Schema.Array(RemoteModel) })
|
|
const Response = Schema.Struct({ models: Schema.Array(RemoteModel) })
|
|
|
-const discovery = new Map<string, { checked: number; models?: (typeof RemoteModel.Type)[] }>()
|
|
|
|
|
|
|
+const discovery = new Map<string, { checked: number; apiKey?: string; models?: (typeof RemoteModel.Type)[] }>()
|
|
|
const discoveryLock = Semaphore.makeUnsafe(1)
|
|
const discoveryLock = Semaphore.makeUnsafe(1)
|
|
|
|
|
|
|
|
export function make(origin = "http://127.0.0.1:1234", interval: Duration.Input = "30 seconds") {
|
|
export function make(origin = "http://127.0.0.1:1234", interval: Duration.Input = "30 seconds") {
|
|
|
- const baseURL = `${origin.replace(/\/+$/, "")}/v1`
|
|
|
|
|
- const endpoint = `${origin.replace(/\/+$/, "")}/api/v1/models`
|
|
|
|
|
return define({
|
|
return define({
|
|
|
id: "opencode.provider.lmstudio",
|
|
id: "opencode.provider.lmstudio",
|
|
|
effect: Effect.fn(function* (ctx) {
|
|
effect: Effect.fn(function* (ctx) {
|
|
|
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
|
|
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
|
|
|
|
|
+ const config = yield* Config.Service
|
|
|
|
|
+ const source = { current: configured(yield* config.entries(), origin) }
|
|
|
const loaded = { models: [] as (typeof RemoteModel.Type)[], hash: "[]" }
|
|
const loaded = { models: [] as (typeof RemoteModel.Type)[], hash: "[]" }
|
|
|
|
|
|
|
|
yield* ctx.integration.transform((integrations) => {
|
|
yield* ctx.integration.transform((integrations) => {
|
|
@@ -49,7 +52,11 @@ export function make(origin = "http://127.0.0.1:1234", interval: Duration.Input
|
|
|
catalog.provider.update(providerID, (provider) => {
|
|
catalog.provider.update(providerID, (provider) => {
|
|
|
provider.name = "LM Studio"
|
|
provider.name = "LM Studio"
|
|
|
provider.package = "@opencode-ai/ai/providers/openai-compatible"
|
|
provider.package = "@opencode-ai/ai/providers/openai-compatible"
|
|
|
- provider.settings = { baseURL, provider: providerID, apiKey: "" }
|
|
|
|
|
|
|
+ provider.settings = {
|
|
|
|
|
+ baseURL: source.current.baseURL,
|
|
|
|
|
+ provider: providerID,
|
|
|
|
|
+ apiKey: source.current.apiKey ?? "",
|
|
|
|
|
+ }
|
|
|
provider.integrationID = undefined
|
|
provider.integrationID = undefined
|
|
|
})
|
|
})
|
|
|
for (const item of loaded.models) {
|
|
for (const item of loaded.models) {
|
|
@@ -74,29 +81,42 @@ export function make(origin = "http://127.0.0.1:1234", interval: Duration.Input
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
const discover = Effect.fn("LMStudioPlugin.discover")(function* () {
|
|
const discover = Effect.fn("LMStudioPlugin.discover")(function* () {
|
|
|
|
|
+ const current = source.current
|
|
|
|
|
+ if (!current.endpoint) return undefined
|
|
|
return yield* discoveryLock.withPermit(
|
|
return yield* discoveryLock.withPermit(
|
|
|
Effect.gen(function* () {
|
|
Effect.gen(function* () {
|
|
|
- const cached = discovery.get(endpoint)
|
|
|
|
|
- if (cached && Date.now() - cached.checked < Duration.toMillis(interval)) return cached.models
|
|
|
|
|
- discovery.set(endpoint, { checked: Date.now(), models: cached?.models })
|
|
|
|
|
|
|
+ const cached = discovery.get(current.endpoint)
|
|
|
|
|
+ if (cached && cached.apiKey === current.apiKey && Date.now() - cached.checked < Duration.toMillis(interval))
|
|
|
|
|
+ return { source: current, models: cached.models }
|
|
|
|
|
+ discovery.set(current.endpoint, {
|
|
|
|
|
+ checked: Date.now(),
|
|
|
|
|
+ apiKey: current.apiKey,
|
|
|
|
|
+ models: cached && cached.apiKey === current.apiKey ? cached.models : undefined,
|
|
|
|
|
+ })
|
|
|
|
|
+ const request = current.apiKey
|
|
|
|
|
+ ? HttpClientRequest.get(current.endpoint).pipe(
|
|
|
|
|
+ HttpClientRequest.acceptJson,
|
|
|
|
|
+ HttpClientRequest.bearerToken(current.apiKey),
|
|
|
|
|
+ )
|
|
|
|
|
+ : HttpClientRequest.get(current.endpoint).pipe(HttpClientRequest.acceptJson)
|
|
|
const response = yield* http
|
|
const response = yield* http
|
|
|
- .execute(HttpClientRequest.get(endpoint).pipe(HttpClientRequest.acceptJson))
|
|
|
|
|
|
|
+ .execute(request)
|
|
|
.pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(Response)), Effect.timeout("1 second"))
|
|
.pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(Response)), Effect.timeout("1 second"))
|
|
|
const models = response.models
|
|
const models = response.models
|
|
|
.filter((model) => model.type === "llm" && model.key.length > 0)
|
|
.filter((model) => model.type === "llm" && model.key.length > 0)
|
|
|
.toSorted((a, b) => a.key.localeCompare(b.key))
|
|
.toSorted((a, b) => a.key.localeCompare(b.key))
|
|
|
- discovery.set(endpoint, { checked: Date.now(), models })
|
|
|
|
|
- return models
|
|
|
|
|
|
|
+ discovery.set(current.endpoint, { checked: Date.now(), apiKey: current.apiKey, models })
|
|
|
|
|
+ return { source: current, models }
|
|
|
}),
|
|
}),
|
|
|
)
|
|
)
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
const refresh = Effect.fn("LMStudioPlugin.refresh")(function* () {
|
|
const refresh = Effect.fn("LMStudioPlugin.refresh")(function* () {
|
|
|
- const models = yield* discover()
|
|
|
|
|
- if (!models) return
|
|
|
|
|
- const hash = JSON.stringify(models)
|
|
|
|
|
|
|
+ const result = yield* discover()
|
|
|
|
|
+ if (!result?.models || result.source !== source.current) return
|
|
|
|
|
+ const hash = JSON.stringify(result.models)
|
|
|
if (hash === loaded.hash) return
|
|
if (hash === loaded.hash) return
|
|
|
- loaded.models = models
|
|
|
|
|
|
|
+ loaded.models = result.models
|
|
|
loaded.hash = hash
|
|
loaded.hash = hash
|
|
|
yield* ctx.integration.reload()
|
|
yield* ctx.integration.reload()
|
|
|
yield* ctx.catalog.reload()
|
|
yield* ctx.catalog.reload()
|
|
@@ -104,8 +124,50 @@ export function make(origin = "http://127.0.0.1:1234", interval: Duration.Input
|
|
|
|
|
|
|
|
// Keep the last successful inventory through transient outages instead of flickering model availability.
|
|
// Keep the last successful inventory through transient outages instead of flickering model availability.
|
|
|
yield* refresh().pipe(Effect.ignore, Effect.repeat(Schedule.spaced(interval)), Effect.forkScoped)
|
|
yield* refresh().pipe(Effect.ignore, Effect.repeat(Schedule.spaced(interval)), Effect.forkScoped)
|
|
|
|
|
+ const reload = Effect.fn("LMStudioPlugin.reload")(function* () {
|
|
|
|
|
+ const next = configured(yield* config.entries(), origin)
|
|
|
|
|
+ if (
|
|
|
|
|
+ next.baseURL === source.current.baseURL &&
|
|
|
|
|
+ next.apiKey === source.current.apiKey &&
|
|
|
|
|
+ next.endpoint === source.current.endpoint
|
|
|
|
|
+ )
|
|
|
|
|
+ return
|
|
|
|
|
+ source.current = next
|
|
|
|
|
+ loaded.models = []
|
|
|
|
|
+ loaded.hash = "[]"
|
|
|
|
|
+ yield* ctx.integration.reload()
|
|
|
|
|
+ yield* ctx.catalog.reload()
|
|
|
|
|
+ yield* refresh().pipe(Effect.ignore)
|
|
|
|
|
+ })
|
|
|
|
|
+ yield* ctx.event.subscribe().pipe(
|
|
|
|
|
+ Stream.filter((event) => event.type === "config.updated"),
|
|
|
|
|
+ Stream.runForEach(reload),
|
|
|
|
|
+ Effect.forkScoped({ startImmediately: true }),
|
|
|
|
|
+ )
|
|
|
}),
|
|
}),
|
|
|
} satisfies PluginInternal.InternalPlugin)
|
|
} satisfies PluginInternal.InternalPlugin)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
export const LMStudioPlugin = make()
|
|
export const LMStudioPlugin = make()
|
|
|
|
|
+
|
|
|
|
|
+function configured(entries: readonly Entry[], origin: string) {
|
|
|
|
|
+ const settings = entries
|
|
|
|
|
+ .filter((entry): entry is Document => entry.type === "document")
|
|
|
|
|
+ .flatMap((entry) => {
|
|
|
|
|
+ const settings = entry.info.providers?.[providerID]?.settings
|
|
|
|
|
+ return settings ? [settings] : []
|
|
|
|
|
+ })
|
|
|
|
|
+ .reduce<Provider.Settings | undefined>((result, item) => Provider.mergeOverlay(result, item), undefined)
|
|
|
|
|
+ const baseURL = (
|
|
|
|
|
+ typeof settings?.baseURL === "string" ? settings.baseURL : `${origin.replace(/\/+$/, "")}/v1`
|
|
|
|
|
+ ).replace(/\/+$/, "")
|
|
|
|
|
+ const apiKey = typeof settings?.apiKey === "string" ? settings.apiKey : undefined
|
|
|
|
|
+ if (!URL.canParse(baseURL)) return { baseURL, apiKey }
|
|
|
|
|
+ const url = new URL(baseURL)
|
|
|
|
|
+ if (url.protocol !== "http:" && url.protocol !== "https:") return { baseURL, apiKey }
|
|
|
|
|
+ const prefix = url.pathname.endsWith("/v1") ? url.pathname.slice(0, -3) : url.pathname.replace(/\/+$/, "")
|
|
|
|
|
+ url.pathname = `${prefix}/api/v1/models`
|
|
|
|
|
+ url.search = ""
|
|
|
|
|
+ url.hash = ""
|
|
|
|
|
+ return { baseURL, apiKey, endpoint: url.toString() }
|
|
|
|
|
+}
|