Browse Source

feat: fully config-driven, no hardcoded provider id

Paul Klumpp 1 tuần trước cách đây
mục cha
commit
3b3f9894aa

+ 85 - 51
.opencode/plugins/opencode-openai-compatible-model-discovery/index.ts

@@ -1,8 +1,11 @@
 /**
  * OpenCode v2 Plugin: Auto-discover models from OpenAI-compatible endpoints.
  *
- * Reads endpoint/key from opencode.jsonc (project or global) when available,
- * falling back to the built-in defaults. Creates the "OmniRoute" provider itself.
+ * Config-driven: reads providers from opencode.jsonc (project or global).
+ * For every provider whose `package` contains "openai-compatible" (and which
+ * has opted in via `settings.discover_models === true`, or no opt-in flag set
+ * for backward compat), fetches /models and registers them under that provider.
+ * Creates the provider if it does not exist yet.
  */
 
 import { Plugin, Model } from "@opencode/plugin";
@@ -14,7 +17,7 @@ function parseJsonc(text: string): any {
   return JSON.parse(s);
 }
 
-function readConfig(): { baseURL: string; apiKey: string } {
+function readConfigProviders(): Record<string, any> {
   const fs = require("node:fs");
   const path = require("node:path");
   const os = require("node:os");
@@ -23,69 +26,100 @@ function readConfig(): { baseURL: string; apiKey: string } {
     path.join(process.cwd(), "opencode.json"),
     path.join(os.homedir(), ".config", "opencode", "opencode.jsonc"),
   ];
+  const merged: Record<string, any> = {};
   for (const c of candidates) {
     try {
       if (!fs.existsSync(c)) continue;
       const cfg = parseJsonc(fs.readFileSync(c, "utf8"));
       const provs = cfg?.providers || cfg?.provider;
-      if (!provs || typeof provs !== "object") continue;
-      for (const info of Object.values(provs) as any[]) {
-        const p = info as Record<string, unknown>;
-        if (typeof p.package === "string" && p.package.includes("openai-compatible")) {
-          const settings = (p.settings && typeof p.settings === "object") ? (p.settings as Record<string, unknown>) : {};
-          const baseURL = typeof settings.baseURL === "string" ? settings.baseURL : undefined;
-          const apiKey = typeof settings.apiKey === "string" ? settings.apiKey : "";
-          if (baseURL) return { baseURL, apiKey };
+      if (provs && typeof provs === "object") {
+        for (const [k, v] of Object.entries(provs)) {
+          if (!merged[k]) merged[k] = v;
         }
       }
     } catch {}
   }
-  return { baseURL: "http://localhost:20128/v1", apiKey: "sk-e9ac58c0b78cd2bc-f17d3e-f6beea67" };
+  return merged;
 }
 
-const { baseURL: BASE_URL, apiKey: API_KEY } = readConfig();
-const PROVIDER_ID = "OmniRoute";
-const PROVIDER_NAME = "OmniRoute";
+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 {
+  // backward compat: if no discover_models flag present, default to enabled
+  if (settings.discover_models === undefined && settings.modelsDiscovery === undefined) return true;
+  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;
+  if (settings.discover_models === false) return false;
+  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: "", 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) {
-    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>>;
-      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"];
-        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;
-      });
-      ctx.provider.transform((editor) => {
-        const source = editor.get(PROVIDER_ID);
-        if (source) {
-          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 {
-          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 });
-        }
-      });
-      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)}`);
+    const providers = readConfigProviders();
+    const targets: Target[] = [];
+    for (const [id, info] of Object.entries(providers)) {
+      const p = info as Record<string, unknown>;
+      if (!isOpenAICompatible(p.package)) continue;
+      const settings = p.settings && typeof p.settings === "object" ? (p.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 "${id}" has no settings.baseURL — skipping`); continue; }
+      const apiKey = typeof settings.apiKey === "string" ? settings.apiKey : "";
+      targets.push({ id, baseURL, apiKey });
+    }
+    if (targets.length === 0) {
+      console.log("[openai-discovery] No opted-in openai-compatible providers found — nothing to do");
+      return;
+    }
+    for (const target of targets) {
+      try {
+        const models = await fetchModels(target.baseURL, target.apiKey);
+        for (const m of models) (m as { providerID: string }).providerID = target.id;
+        await ctx.provider.transform((editor) => {
+          const source = editor.get(target.id);
+          if (source) {
+            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(target.id, merged);
+          } else {
+            editor.add({ info: { id: target.id, name: target.id, activation: "enabled" as const, package: "aisdk:@ai-sdk/openai-compatible", settings: { baseURL: target.baseURL, apiKey: target.apiKey } } as never, models });
+          }
+        });
+        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)}`);
+      }
     }
   },
 });