|
@@ -1,108 +1,167 @@
|
|
|
/**
|
|
/**
|
|
|
* OpenCode v2 Plugin: Auto-discover models from OpenAI-compatible endpoints.
|
|
* 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";
|
|
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({
|
|
export default Plugin.define({
|
|
|
id: "opencode.openai-compatible-model-discovery",
|
|
id: "opencode.openai-compatible-model-discovery",
|
|
|
|
|
|
|
|
async setup(ctx) {
|
|
async setup(ctx) {
|
|
|
|
|
+ // 1. Collect discovery targets from configured providers (sync).
|
|
|
|
|
+ const targets: Target[] = [];
|
|
|
try {
|
|
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 existing = [...source.models.values()];
|
|
|
const existingIDs = new Set(existing.map((model) => String(model.id)));
|
|
const existingIDs = new Set(existing.map((model) => String(model.id)));
|
|
|
const merged = [
|
|
const merged = [
|
|
|
...existing,
|
|
...existing,
|
|
|
...models.filter((m) => !existingIDs.has(String(m.id))),
|
|
...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)}`
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
},
|
|
},
|
|
|
-});
|
|
|
|
|
|
|
+});
|