Просмотр исходного кода

feat(core): configure lm studio discovery

Shoubhit Dash 1 день назад
Родитель
Сommit
05478afeff

+ 77 - 15
packages/core/src/plugin/provider/lmstudio.ts

@@ -1,7 +1,10 @@
 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 { Config } from "../../config.js"
 import { Model } from "../../model.js"
+import { Provider } from "../../provider.js"
 import type { PluginInternal } from "../internal.js"
 
 const providerID = "lmstudio"
@@ -24,16 +27,16 @@ const RemoteModel = Schema.Struct({
 })
 
 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)
 
 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({
     id: "opencode.provider.lmstudio",
     effect: Effect.fn(function* (ctx) {
       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: "[]" }
 
       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) => {
           provider.name = "LM Studio"
           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
         })
         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 current = source.current
+        if (!current.endpoint) return undefined
         return yield* discoveryLock.withPermit(
           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
-              .execute(HttpClientRequest.get(endpoint).pipe(HttpClientRequest.acceptJson))
+              .execute(request)
               .pipe(Effect.flatMap(HttpClientResponse.schemaBodyJson(Response)), Effect.timeout("1 second"))
             const models = response.models
               .filter((model) => model.type === "llm" && model.key.length > 0)
               .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 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
-        loaded.models = models
+        loaded.models = result.models
         loaded.hash = hash
         yield* ctx.integration.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.
       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)
 }
 
 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() }
+}

+ 78 - 2
packages/core/test/plugin/provider-lmstudio.test.ts

@@ -1,4 +1,6 @@
+import { Bus } from "@opencode-ai/core/bus"
 import { Catalog } from "@opencode-ai/core/catalog"
+import { Config } from "@opencode-ai/core/config"
 import { Integration } from "@opencode-ai/core/integration"
 import { Model } from "@opencode-ai/core/model"
 import { Plugin } from "@opencode-ai/core/plugin"
@@ -6,12 +8,14 @@ import { PluginHost } from "@opencode-ai/core/plugin/host"
 import { LMStudioPlugin, make } from "@opencode-ai/core/plugin/provider/lmstudio"
 import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
 import { Provider } from "@opencode-ai/core/provider"
+import { Document, Event, Info } from "@opencode-ai/schema/config"
 import { describe, expect } from "bun:test"
-import { Duration, Effect } from "effect"
+import { Duration, Effect, Layer, Schema } from "effect"
 import { testEffect } from "../lib/effect"
 import { PluginTestLayer } from "./fixture"
 
-const it = testEffect(PluginTestLayer)
+const it = testEffect(Layer.merge(PluginTestLayer, Config.testLayer()))
+const decode = Schema.decodeUnknownSync(Info)
 
 const addPlugin = Effect.fn(function* (origin: string, interval: Duration.Input = "1 hour") {
   const plugin = yield* Plugin.Service
@@ -150,6 +154,71 @@ describe("LMStudioPlugin", () => {
     ),
   )
 
+  it.live("discovers from configured endpoints with bearer authentication", () =>
+    Effect.acquireUseRelease(
+      Effect.sync(() => {
+        const requests: Array<{ authorization: string | null; path: string }> = []
+        const model = (key: string) => ({
+          type: "llm",
+          key,
+          display_name: key,
+          loaded_instances: [],
+          max_context_length: 32_768,
+        })
+        return {
+          requests,
+          initial: Bun.serve({ port: 0, fetch: () => Response.json({ models: [model("initial-model")] }) }),
+          configured: Bun.serve({
+            port: 0,
+            fetch: (request) => {
+              requests.push({
+                authorization: request.headers.get("authorization"),
+                path: new URL(request.url).pathname,
+              })
+              return Response.json({ models: [model("configured-model")] })
+            },
+          }),
+        }
+      }),
+      ({ requests, initial, configured }) =>
+        Effect.gen(function* () {
+          const bus = yield* Bus.Service
+          const catalog = yield* Catalog.Service
+          const config = yield* Config.Test
+          const providerID = Provider.ID.make("lmstudio")
+          yield* addPlugin(initial.url.origin)
+          yield* eventually(
+            catalog.model.get(providerID, Model.ID.make("initial-model")),
+            (model) => model !== undefined,
+          )
+
+          const baseURL = `${configured.url.origin}/proxy/v1`
+          yield* config.setEntries([configuration(baseURL, "secret")])
+          yield* bus.publish(Event.Updated, {})
+          yield* eventually(
+            catalog.model.get(providerID, Model.ID.make("configured-model")),
+            (model) => model !== undefined,
+          )
+
+          expect(requests).toContainEqual({ authorization: "Bearer secret", path: "/proxy/api/v1/models" })
+          expect(yield* catalog.model.get(providerID, Model.ID.make("initial-model"))).toBeUndefined()
+          expect((yield* catalog.provider.get(providerID))?.settings).toEqual({
+            baseURL,
+            provider: "lmstudio",
+            apiKey: "secret",
+          })
+
+          requests.splice(0)
+          yield* config.setEntries([configuration(baseURL, "secret"), configuration(baseURL, null)])
+          yield* bus.publish(Event.Updated, {})
+          yield* eventually(catalog.provider.get(providerID), (provider) => provider?.settings?.apiKey === "")
+          expect(requests).toContainEqual({ authorization: null, path: "/proxy/api/v1/models" })
+        }),
+      ({ initial, configured }) => Effect.promise(() => Promise.all([initial.stop(true), configured.stop(true)])),
+    ),
+    10_000,
+  )
+
   it.live("shares discovery requests across plugin instances", () =>
     Effect.acquireUseRelease(
       Effect.sync(() => {
@@ -260,3 +329,10 @@ describe("LMStudioPlugin", () => {
     ),
   )
 })
+
+function configuration(baseURL: string, apiKey: string | null) {
+  return new Document({
+    type: "document",
+    info: decode({ providers: { lmstudio: { settings: { baseURL, apiKey } } } }),
+  })
+}

+ 18 - 0
packages/www/content/docs/(Configure)/models.mdx

@@ -176,6 +176,24 @@ OpenCode refreshes the inventory in the background and reads context, vision, an
 Studio. Embedding models are excluded because they cannot drive a session. Disable discovery with
 `"plugins": ["-opencode.provider.lmstudio"]`.
 
+For a different host or port, configure the OpenAI-compatible base URL. Models are still discovered automatically:
+
+```jsonc title="opencode.jsonc"
+{
+  "$schema": "https://opencode.ai/config.json",
+  "providers": {
+    "lmstudio": {
+      "settings": {
+        "baseURL": "http://127.0.0.1:5678/v1",
+        "apiKey": "{env:LMSTUDIO_API_KEY}",
+      },
+    },
+  },
+}
+```
+
+Omit `apiKey` when LM Studio authentication is disabled.
+
 For an OpenAI-compatible server, define a provider package, endpoint, and at least one model:
 
 ```jsonc title="opencode.jsonc"