index.ts 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. /**
  2. * OpenCode v2 Plugin: Auto-discover models from OpenAI-compatible endpoints.
  3. *
  4. * Config-driven: iterates over the providers defined in opencode.jsonc and
  5. * discovers models for every provider whose `package` contains "openai-compatible"
  6. * AND which has opted in via `settings.discover_models === true`
  7. * (compat: `settings.modelsDiscovery.enabled === true`).
  8. *
  9. * The plugin never creates providers itself — it only augments existing ones.
  10. * Discovered models are merged into the provider's existing models (dedup by id).
  11. */
  12. import { Plugin, Model } from "@opencode/plugin";
  13. interface Target {
  14. id: string;
  15. baseURL: string;
  16. apiKey: string;
  17. }
  18. function isOpenAICompatible(pkg: unknown): boolean {
  19. return typeof pkg === "string" && pkg.toLowerCase().includes("openai-compatible");
  20. }
  21. function hasOptIn(settings: Record<string, unknown>): boolean {
  22. if (settings.discover_models === true) return true;
  23. const md = settings.modelsDiscovery;
  24. if (md && typeof md === "object" && (md as Record<string, unknown>).enabled === true) {
  25. return true;
  26. }
  27. return false;
  28. }
  29. async function fetchModels(
  30. baseURL: string,
  31. apiKey: string
  32. ): Promise<Model.Info[]> {
  33. const headers: Record<string, string> = { "Content-Type": "application/json" };
  34. if (apiKey) {
  35. headers["Authorization"] = `Bearer ${apiKey}`;
  36. }
  37. const res = await fetch(`${baseURL}/models`, { headers });
  38. if (!res.ok) {
  39. throw new Error(`HTTP ${res.status} from ${baseURL}/models`);
  40. }
  41. const json = await res.json();
  42. const rawModels = (json.data || []) as Array<Record<string, unknown>>;
  43. return rawModels.map((m) => {
  44. const modelId = typeof m.id === "string" ? m.id : String(m.id);
  45. const contextLength =
  46. typeof m.context_length === "number" ? m.context_length : 131072;
  47. const maxTokens = typeof m.max_tokens === "number" ? m.max_tokens : 32768;
  48. const inputMods = Array.isArray(m.input_modalities)
  49. ? (m.input_modalities as string[])
  50. : ["text"];
  51. const outputMods = Array.isArray(m.output_modalities)
  52. ? (m.output_modalities as string[])
  53. : ["text"];
  54. return {
  55. id: modelId,
  56. modelID: modelId,
  57. providerID: "", // filled in by caller
  58. name: modelId,
  59. capabilities: {
  60. tools: true,
  61. input: inputMods,
  62. output: outputMods,
  63. },
  64. variants: [],
  65. cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }],
  66. limit: {
  67. context: contextLength,
  68. output: Math.min(maxTokens, contextLength),
  69. },
  70. time: {
  71. released: Math.floor(Date.now() / 1000),
  72. },
  73. status: "active" as const,
  74. enabled: true,
  75. } as unknown as Model.Info;
  76. });
  77. }
  78. export default Plugin.define({
  79. id: "opencode.openai-compatible-model-discovery",
  80. async setup(ctx) {
  81. // 1. Collect discovery targets from configured providers (sync).
  82. const targets: Target[] = [];
  83. try {
  84. await ctx.provider.transform((editor) => {
  85. for (const rec of editor.list()) {
  86. const info = rec.provider as Record<string, unknown>;
  87. if (!isOpenAICompatible(info.package)) continue;
  88. const settings =
  89. info.settings && typeof info.settings === "object"
  90. ? (info.settings as Record<string, unknown>)
  91. : {};
  92. if (!hasOptIn(settings)) continue;
  93. const baseURL =
  94. typeof settings.baseURL === "string" ? settings.baseURL : undefined;
  95. if (!baseURL) {
  96. console.warn(
  97. `[openai-discovery] Provider "${info.id}" has no settings.baseURL — skipping`
  98. );
  99. continue;
  100. }
  101. const apiKey =
  102. typeof settings.apiKey === "string" ? settings.apiKey : "";
  103. targets.push({ id: String(info.id), baseURL, apiKey });
  104. }
  105. });
  106. } catch (error) {
  107. console.error(
  108. `[openai-discovery] Failed to read providers: ${error instanceof Error ? error.message : String(error)}`
  109. );
  110. return;
  111. }
  112. if (targets.length === 0) {
  113. console.log(
  114. "[openai-discovery] No opted-in openai-compatible providers found — nothing to do"
  115. );
  116. return;
  117. }
  118. // 2. Discover + merge per target (isolated errors).
  119. for (const target of targets) {
  120. try {
  121. const models = await fetchModels(target.baseURL, target.apiKey);
  122. // stamp providerID
  123. for (const m of models) {
  124. (m as { providerID: string }).providerID = target.id;
  125. }
  126. await ctx.provider.transform((editor) => {
  127. const source = editor.get(target.id);
  128. if (!source) {
  129. console.warn(
  130. `[openai-discovery] Provider "${target.id}" no longer exists — skipping merge`
  131. );
  132. return;
  133. }
  134. const existing = [...source.models.values()];
  135. const existingIDs = new Set(existing.map((model) => String(model.id)));
  136. const merged = [
  137. ...existing,
  138. ...models.filter((m) => !existingIDs.has(String(m.id))),
  139. ];
  140. editor.models.set(target.id, merged);
  141. });
  142. console.log(
  143. `[openai-discovery] Registered ${models.length} models in provider "${target.id}"`
  144. );
  145. } catch (error) {
  146. console.error(
  147. `[openai-discovery] Failed to discover models for "${target.id}": ${error instanceof Error ? error.message : String(error)}`
  148. );
  149. }
  150. }
  151. },
  152. });