Ver código fonte

feat: config-driven model discovery with opt-in flag

Paul Klumpp 1 semana atrás
pai
commit
680fcd1529

+ 142 - 83
.opencode/plugins/opencode-openai-compatible-model-discovery/index.ts

@@ -1,108 +1,167 @@
 /**
  * OpenCode v2 Plugin: Auto-discover models from OpenAI-compatible endpoints.
  *
- * Queries GET /v1/models and registers all discovered models under the
- * configured provider. If the provider does not exist yet, it is created.
+ * Config-driven: iterates over the providers defined in opencode.jsonc and
+ * discovers models for every provider whose `package` contains "openai-compatible"
+ * AND which has opted in via `settings.discover_models === true`
+ * (compat: `settings.modelsDiscovery.enabled === true`).
+ *
+ * The plugin never creates providers itself — it only augments existing ones.
+ * Discovered models are merged into the provider's existing models (dedup by id).
  */
 
 import { Plugin, Model } from "@opencode/plugin";
 
-// Configuration
-const PROVIDER_ID = "OmniRoute";
-const PROVIDER_NAME = "OmniRoute";
-const BASE_URL = "http://localhost:20128/v1";
-const API_KEY = "sk-e9ac58c0b78cd2bc-f17d3e-f6beea67";
+interface Target {
+  id: string;
+  baseURL: string;
+  apiKey: string;
+}
+
+function isOpenAICompatible(pkg: unknown): boolean {
+  return typeof pkg === "string" && pkg.toLowerCase().includes("openai-compatible");
+}
+
+function hasOptIn(settings: Record<string, unknown>): boolean {
+  if (settings.discover_models === true) return true;
+  const md = settings.modelsDiscovery;
+  if (md && typeof md === "object" && (md as Record<string, unknown>).enabled === true) {
+    return true;
+  }
+  return false;
+}
+
+async function fetchModels(
+  baseURL: string,
+  apiKey: string
+): Promise<Model.Info[]> {
+  const headers: Record<string, string> = { "Content-Type": "application/json" };
+  if (apiKey) {
+    headers["Authorization"] = `Bearer ${apiKey}`;
+  }
+
+  const res = await fetch(`${baseURL}/models`, { headers });
+  if (!res.ok) {
+    throw new Error(`HTTP ${res.status} from ${baseURL}/models`);
+  }
+
+  const json = await res.json();
+  const rawModels = (json.data || []) as Array<Record<string, unknown>>;
+
+  return rawModels.map((m) => {
+    const modelId = typeof m.id === "string" ? m.id : String(m.id);
+    const contextLength =
+      typeof m.context_length === "number" ? m.context_length : 131072;
+    const maxTokens = typeof m.max_tokens === "number" ? m.max_tokens : 32768;
+    const inputMods = Array.isArray(m.input_modalities)
+      ? (m.input_modalities as string[])
+      : ["text"];
+    const outputMods = Array.isArray(m.output_modalities)
+      ? (m.output_modalities as string[])
+      : ["text"];
+
+    return {
+      id: modelId,
+      modelID: modelId,
+      providerID: "", // filled in by caller
+      name: modelId,
+      capabilities: {
+        tools: true,
+        input: inputMods,
+        output: outputMods,
+      },
+      variants: [],
+      cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }],
+      limit: {
+        context: contextLength,
+        output: Math.min(maxTokens, contextLength),
+      },
+      time: {
+        released: Math.floor(Date.now() / 1000),
+      },
+      status: "active" as const,
+      enabled: true,
+    } as unknown as Model.Info;
+  });
+}
 
 export default Plugin.define({
   id: "opencode.openai-compatible-model-discovery",
 
   async setup(ctx) {
+    // 1. Collect discovery targets from configured providers (sync).
+    const targets: Target[] = [];
     try {
-      const headers: Record<string, string> = { "Content-Type": "application/json" };
-      if (API_KEY) {
-        headers["Authorization"] = `Bearer ${API_KEY}`;
-      }
-
-      const res = await fetch(`${BASE_URL}/models`, { headers });
-      if (!res.ok) {
-        throw new Error(`HTTP ${res.status} from ${BASE_URL}/models`);
-      }
-
-      const json = await res.json();
-      const rawModels = (json.data || []) as Array<Record<string, unknown>>;
+      await ctx.provider.transform((editor) => {
+        for (const rec of editor.list()) {
+          const info = rec.provider as Record<string, unknown>;
+          if (!isOpenAICompatible(info.package)) continue;
+          const settings =
+            info.settings && typeof info.settings === "object"
+              ? (info.settings as Record<string, unknown>)
+              : {};
+          if (!hasOptIn(settings)) continue;
+          const baseURL =
+            typeof settings.baseURL === "string" ? settings.baseURL : undefined;
+          if (!baseURL) {
+            console.warn(
+              `[openai-discovery] Provider "${info.id}" has no settings.baseURL — skipping`
+            );
+            continue;
+          }
+          const apiKey =
+            typeof settings.apiKey === "string" ? settings.apiKey : "";
+          targets.push({ id: String(info.id), baseURL, apiKey });
+        }
+      });
+    } catch (error) {
+      console.error(
+        `[openai-discovery] Failed to read providers: ${error instanceof Error ? error.message : String(error)}`
+      );
+      return;
+    }
 
-      const models = rawModels.map((m) => {
-        const modelId = typeof m.id === "string" ? m.id : String(m.id);
-        const contextLength =
-          typeof m.context_length === "number" ? m.context_length : 131072;
-        const maxTokens = typeof m.max_tokens === "number" ? m.max_tokens : 32768;
-        const inputMods = Array.isArray(m.input_modalities)
-          ? (m.input_modalities as string[])
-          : ["text"];
-        const outputMods = Array.isArray(m.output_modalities)
-          ? (m.output_modalities as string[])
-          : ["text"];
+    if (targets.length === 0) {
+      console.log(
+        "[openai-discovery] No opted-in openai-compatible providers found — nothing to do"
+      );
+      return;
+    }
 
-        return {
-          id: modelId,
-          modelID: modelId,
-          providerID: PROVIDER_ID,
-          name: modelId,
-          capabilities: {
-            tools: true,
-            input: inputMods,
-            output: outputMods,
-          },
-          variants: [],
-          cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }],
-          limit: {
-            context: contextLength,
-            output: Math.min(maxTokens, contextLength),
-          },
-          time: {
-            released: Math.floor(Date.now() / 1000),
-          },
-          status: "active" as const,
-          enabled: true,
-        } as unknown as Model.Info;
-      });
+    // 2. Discover + merge per target (isolated errors).
+    for (const target of targets) {
+      try {
+        const models = await fetchModels(target.baseURL, target.apiKey);
+        // stamp providerID
+        for (const m of models) {
+          (m as { providerID: string }).providerID = target.id;
+        }
 
-      ctx.provider.transform((editor) => {
-        const source = editor.get(PROVIDER_ID);
-        if (source) {
-          // Merge discovered models into the existing provider
+        await ctx.provider.transform((editor) => {
+          const source = editor.get(target.id);
+          if (!source) {
+            console.warn(
+              `[openai-discovery] Provider "${target.id}" no longer exists — skipping merge`
+            );
+            return;
+          }
           const existing = [...source.models.values()];
           const existingIDs = new Set(existing.map((model) => String(model.id)));
           const merged = [
             ...existing,
             ...models.filter((m) => !existingIDs.has(String(m.id))),
           ];
-          editor.models.set(PROVIDER_ID, merged);
-        } else {
-          // Create the provider with all discovered models
-          editor.add({
-            info: {
-              id: PROVIDER_ID,
-              name: PROVIDER_NAME,
-              activation: "enabled" as const,
-              package: "aisdk:@ai-sdk/openai-compatible",
-              settings: {
-                baseURL: BASE_URL,
-                apiKey: API_KEY,
-              },
-            } as never,
-            models,
-          });
-        }
-      });
+          editor.models.set(target.id, merged);
+        });
 
-      console.log(
-        `[openai-discovery] Registered ${models.length} models in provider "${PROVIDER_ID}"`
-      );
-    } catch (error) {
-      console.error(
-        `[openai-discovery] Failed to discover models: ${error instanceof Error ? error.message : String(error)}`
-      );
+        console.log(
+          `[openai-discovery] Registered ${models.length} models in provider "${target.id}"`
+        );
+      } catch (error) {
+        console.error(
+          `[openai-discovery] Failed to discover models for "${target.id}": ${error instanceof Error ? error.message : String(error)}`
+        );
+      }
     }
   },
-});
+});

+ 31 - 16
README.md

@@ -13,14 +13,15 @@ Provider **OmniRoute** registriert.
 OpenCode startet
   └─ lädt Plugin (Konvention: .opencode/plugins/<name>/)
       └─ setup(ctx)
-          └─ fetch http://localhost:20128/v1/models   → ~900 Modelle
-              └─ ctx.provider.transform(editor)
-                  ├─ Provider "OmniRoute" existiert?  → Modelle mergen
-                  └─ sonst                            → provider.add() + Modelle anlegen
+          └─ ctx.provider.transform(editor)  → Provider mit openai-compatible + discover_models sammeln
+              └─ pro Provider: fetch {baseURL}/models → Modelle mergen (dedup by id)
 ```
 
-Das Plugin **erstellt den Provider selbst** (inkl. `baseURL`/`apiKey`) –
-eine Provider-Definition in der `opencode.jsonc` ist deshalb **nicht nötig**.
+Das Plugin **erstellt keine Provider** – es ergänzt nur bereits in der
+`opencode.jsonc` definierte Provider. Ein Provider wird berücksichtigt, wenn
+sein `package` "openai-compatible" enthält **und** `settings.discover_models`
+auf `true` gesetzt ist (Compat: `settings.modelsDiscovery.enabled === true`).
+Bestehende manuelle Models bleiben erhalten (Merge, dedup by id).
 Wichtig: Das Plugin füllt alle Pflichtfelder des `Model.Info`-Schemas
 (`modelID`, `providerID`, `capabilities.tools`, `variants`, `cost`, `limit`,
 `time.released`, `status`, `enabled`) – fehlt eines davon, verwirft OpenCode
@@ -39,13 +40,25 @@ Die Dateien einfach ins Projekt kopieren:
     └── tsconfig.json
 ```
 
-**Fertig.** Kein Eintrag in irgendeiner Config nötig – OpenCode lädt
-Plugins aus `.opencode/plugins/` automatisch, sobald ein Client das
-Projektverzeichnis öffnet. Änderungen an `index.ts` werden per File-Watcher
-sofort neu geladen (Hot-Reload, kein Neustart).
+**Fertig** (Plugin-Laden). Zusätzlich muss der zu discovernde Provider in der
+`opencode.jsonc` definiert sein, mit Opt-in-Flag:
 
-Endpunkt anpassen: Konstanten am Kopf von `index.ts` editieren
-(`PROVIDER_ID`, `BASE_URL`, `API_KEY`).
+```jsonc
+{
+  "providers": {
+    "OmniRoute": {
+      "package": "aisdk:@ai-sdk/openai-compatible",
+      "settings": {
+        "baseURL": "http://localhost:20128/v1",
+        "apiKey": "sk-...",
+        "discover_models": true
+      }
+    }
+  }
+}
+```
+
+Endpunkt/Key anpassen: in der `opencode.jsonc` (nicht mehr in `index.ts`).
 
 ## Installation (global, alle Projekte)
 
@@ -120,10 +133,12 @@ Installiere das OpenCode-Plugin opencode-openai-compatible-model-discovery globa
 NICHT tun:
 - Das alte opencode-models-discovery installieren oder referenzieren
   (fehlerhaft, wurde nie erfolgreich betrieben).
-- Ein "modelsDiscovery"-Feld in irgendeine opencode.jsonc schreiben
-  (existiert im v2-Schema nicht).
-- Einen provider in opencode.jsonc anlegen (das Plugin erstellt den
-  Provider selbst – sonst gibt es Duplikate).
+- Ein "modelsDiscovery"-Feld auf Top-Level in opencode.jsonc schreiben
+  (existiert im v2-Schema nicht). Stattdessen `discover_models: true`
+  innerhalb von `settings`.
+- Den Opt-in-Flag vergessen: ohne `settings.discover_models: true`
+  (bzw. `settings.modelsDiscovery.enabled: true`) werden keine Models
+  discovered.
 ```
 
 ## Verifizieren