File size: 17,124 Bytes
cd8bd0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import {
  parseOutboundUrl,
  isCloudMetadataHost,
  OutboundUrlGuardError,
} from "@/shared/network/outboundUrlGuard";

const CONFIG_PATH = path.join(os.homedir(), ".config", "opencode", "opencode.json");

/**
 * SSRF guard for the catalog fetch (CodeQL js/request-forgery #326). The catalog
 * source is the user's OWN OmniRoute instance, so loopback/private hosts are the
 * legitimate default and must stay allowed β€” we cannot use the public-only guard
 * here. What has NO legitimate use as a catalog source is the cloud-metadata /
 * link-local pivot (169.254.169.254, metadata.google.internal, …): that is the
 * classic SSRF→IAM-credential escalation and is blocked unconditionally, along
 * with non-http(s) protocols and embedded credentials (via parseOutboundUrl).
 */
export function assertSafeCatalogUrl(rawUrl: string): URL {
  const url = parseOutboundUrl(rawUrl); // throws on bad protocol / embedded creds
  if (isCloudMetadataHost(url.hostname)) {
    throw new OutboundUrlGuardError(
      "Blocked cloud-metadata catalog URL (SSRF protection)",
      { code: "OUTBOUND_URL_GUARD_BLOCKED", url: url.toString(), hostname: url.hostname }
    );
  }
  // Return the re-parsed URL so callers fetch the validated value (a `new URL()`
  // round-trip is a recognized request-forgery barrier β€” clears CodeQL #326).
  return url;
}

/**
 * OpenAI-compatible model entry β€” subset of fields the /v1/models endpoint
 * returns. Only the fields we need to emit `limit.context` / `limit.output`
 * are typed.
 */
interface CatalogModelEntry {
  id: string;
  owned_by?: string;
  /** OpenAI-compatible field name; some upstreams return this. */
  context_length?: number;
  max_context_window_tokens?: number;
  /** Optional max output tokens; used to populate `limit.output`. */
  max_output_tokens?: number;
  max_input_tokens?: number;
  /** Optional structured capability flags. */
  capabilities?: {
    attachment?: boolean;
    reasoning?: boolean;
    temperature?: boolean;
    tool_calling?: boolean;
    vision?: boolean;
  };
}

/** Per-model override carried over from the user's existing opencode.json. */
interface ExistingModelEntry {
  name?: string;
  attachment?: boolean;
  reasoning?: boolean;
  temperature?: boolean;
  tool_call?: boolean;
  limit?: { context?: number; input?: number; output?: number };
  // Allow arbitrary other keys to round-trip through untouched.
  [key: string]: unknown;
}

interface ExistingProviderEntry {
  name?: string;
  npm?: string;
  options?: Record<string, unknown>;
  models?: Record<string, ExistingModelEntry>;
  [key: string]: unknown;
}

interface ExistingConfig {
  $schema?: string;
  provider?: Record<string, ExistingProviderEntry>;
  model?: string;
  small_model?: string;
  [key: string]: unknown;
}

export interface CatalogFetchResult {
  /** Models keyed by id, as returned by /v1/models. */
  byId: Map<string, CatalogModelEntry>;
  /** Provider ids that had at least one model in the catalog. */
  providerIds: Set<string>;
  /** Models that have a usable `context_length` (positive finite number). */
  modelsWithContext: number;
  /** Total models returned by the catalog. */
  total: number;
}

/**
 * Fetch the live `/v1/models` catalog from OmniRoute. The catalog is the
 * single source of truth for context windows β€” opencode.json must NOT
 * hardcode values, otherwise we drift from the provider's actual limits.
 */
export async function fetchOmniRouteCatalog(
  baseUrl: string,
  apiKey: string,
  timeoutMs = 5_000
): Promise<CatalogFetchResult> {
  const cleanBase = baseUrl.replace(/\/+$/, "");
  const baseURL = cleanBase.endsWith("/v1") ? cleanBase : `${cleanBase}/v1`;

  const result: CatalogFetchResult = {
    byId: new Map(),
    providerIds: new Set(),
    modelsWithContext: 0,
    total: 0,
  };

  // SSRF guard (CodeQL #326): baseUrl is user-controlled β€” block the cloud-metadata
  // pivot before issuing the request. Loopback stays allowed. Fetch the VALIDATED,
  // re-parsed URL the guard returns (not the raw string) so the taint is severed.
  const safeUrl = assertSafeCatalogUrl(`${baseURL}/models`);

  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);
  try {
    const response = await fetch(safeUrl, {
      headers: { Authorization: `Bearer ${apiKey}` },
      signal: controller.signal,
    });
    if (!response.ok) {
      throw new Error(
        `OmniRoute /v1/models returned ${response.status} ${response.statusText}`
      );
    }
    const body = (await response.json()) as unknown;
    const list: unknown[] = Array.isArray(body)
      ? body
      : body && typeof body === "object" && Array.isArray((body as { data?: unknown[] }).data)
        ? ((body as { data: unknown[] }).data as unknown[])
        : [];
    for (const raw of list) {
      if (!raw || typeof raw !== "object") continue;
      const r = raw as CatalogModelEntry;
      if (typeof r.id !== "string" || !r.id.trim()) continue;
      const id = r.id.trim();
      result.byId.set(id, r);
      result.total += 1;
      if (typeof r.owned_by === "string" && r.owned_by.length > 0) {
        result.providerIds.add(r.owned_by);
      }
      const candidates = [r.context_length, r.max_context_window_tokens];
      if (candidates.some((c) => typeof c === "number" && Number.isFinite(c) && c > 0)) {
        result.modelsWithContext += 1;
      }
    }
  } finally {
    clearTimeout(timer);
  }
  return result;
}

/**
 * Resolve the context length for a single catalog entry.
 * Prefers `context_length` (OpenAI-compatible) over `max_context_window_tokens`
 * (llama.cpp-style). Returns `undefined` when neither is a positive integer β€”
 * this is intentional: we MUST NOT invent a default, because combos whose
 * targets' contexts are unknown to the catalog will mis-report a context
 * window. The user can override per-model via `limit.context` in their
 * existing opencode.json, or fix the upstream catalog.
 */
function resolveContextLength(entry: CatalogModelEntry): number | undefined {
  const candidates = [entry.context_length, entry.max_context_window_tokens];
  for (const c of candidates) {
    if (typeof c === "number" && Number.isFinite(c) && c > 0) return c;
  }
  return undefined;
}

/**
 * Build the entry that ends up under `provider.<name>.models[id]` in the
 * emitted opencode.json. Precedence:
 *
 *   1. Existing manual override in the user's opencode.json (`limit.context`).
 *   2. Catalog `context_length` / `max_context_window_tokens`.
 *
 * If neither is available, the entry is returned WITHOUT a `limit` block so
 * the caller can decide whether to skip the model entirely or surface a
 * warning. We never fabricate a default context window.
 */
function buildModelEntry(
  id: string,
  catalog: CatalogModelEntry | undefined,
  existing: ExistingModelEntry | undefined
): ExistingModelEntry {
  // Carry over user-set "name" first; fall back to id when absent.
  const name = (typeof existing?.name === "string" && existing.name.trim()) || id;

  const entry: ExistingModelEntry = { name };

  // Round-trip capability flags from the existing config (if any).
  for (const flag of ["attachment", "reasoning", "temperature", "tool_call"] as const) {
    const value = existing?.[flag];
    if (typeof value === "boolean") entry[flag] = value;
  }

  // Preserve any extra top-level keys the user set (variants, headers, etc.)
  // that we don't model explicitly.
  if (existing) {
    for (const [k, v] of Object.entries(existing)) {
      if (k === "name" || k === "limit") continue;
      if (v === undefined) continue;
      if (!(k in entry)) entry[k] = v;
    }
  }

  // Resolve the context window. Honor an explicit user override, then fall
  // back to the catalog. We do NOT synthesize a default β€” if the catalog
  // is unaware of a model's window, the opencode.json will simply omit
  // `limit.context` for that model and OpenCode's own heuristics apply.
  // (OpenCode v1 defaults to 128K when `limit.context` is missing.)
  const userLimit = existing?.limit?.context;
  const catalogLimit = catalog ? resolveContextLength(catalog) : undefined;
  const context =
    typeof userLimit === "number" && userLimit > 0
      ? userLimit
      : catalogLimit;

  // `limit.output` is REQUIRED by OpenCode's v1 provider schema (configV1).
  // Use the catalog's max_output_tokens when available; otherwise fall
  // back to the user's existing `limit.output` and finally to a small
  // default (8K) so OpenCode never errors on a totally missing output cap.
  // We do NOT default context β€” context is a property of the model and
  // we have no business guessing. Output is a per-request setting and a
  // small default is harmless when truly unknown.
  const userOutput = existing?.limit?.output;
  const catalogOutput =
    catalog && typeof catalog.max_output_tokens === "number" && catalog.max_output_tokens > 0
      ? catalog.max_output_tokens
      : undefined;
  const output =
    typeof userOutput === "number" && userOutput > 0
      ? userOutput
      : catalogOutput ?? 8_192;

  // Emit `limit` only if we have at least one of context/output. We never
  // emit a half-baked limit block with only an `output` (would be misleading).
  if (typeof context === "number" || typeof userOutput === "number" || typeof catalogOutput === "number") {
    const limit: { context?: number; input?: number; output?: number } = {};
    if (typeof context === "number") limit.context = context;
    if (typeof userOutput === "number" || typeof catalogOutput === "number") {
      limit.output =
        typeof userOutput === "number" && userOutput > 0
          ? userOutput
          : catalogOutput ?? 8_192;
    }
    const userInput = existing?.limit?.input;
    if (typeof userInput === "number" && userInput > 0) {
      limit.input = userInput;
    } else if (catalog) {
      const maxInput = catalog.max_input_tokens;
      if (typeof maxInput === "number" && maxInput > 0) limit.input = maxInput;
    }
    entry.limit = limit;
  }

  return entry;
}

/**
 * Load the user's current opencode.json (if any) so we can preserve names,
 * capability flags, and explicit `limit.context` overrides. JSONC comments
 * are not supported β€” we parse as plain JSON. If parsing fails, we fall
 * back to an empty config; the resulting write will lose comments, but
 * that matches the existing CLI behavior of `config set opencode`.
 */
function loadExistingConfig(): ExistingConfig {
  try {
    if (!fs.existsSync(CONFIG_PATH)) return {};
    const raw = fs.readFileSync(CONFIG_PATH, "utf8");
    return JSON.parse(raw) as ExistingConfig;
  } catch {
    return {};
  }
}

export interface GenerateOpencodeOptions {
  baseUrl: string;
  apiKey: string;
  model?: string;
  /**
   * Override the default `provider.id` used in the generated config.
   * Defaults to `"omniroute"`.
   */
  providerId?: string;
  /**
   * If `true` (default), the generator fetches the live `/v1/models` catalog
   * so every model entry has an explicit `limit.context`. The catalog is the
   * single source of truth for context windows; we never invent defaults.
   *
   * When the catalog request fails, the generator throws β€” opencode.json must
   * not be emitted with stale or fabricated values. The CLI can catch the
   * error and decide whether to surface it to the user.
   */
  fetchCatalog?: boolean;
  /**
   * Request timeout for the catalog fetch, in milliseconds. Defaults to 5s.
   */
  catalogTimeoutMs?: number;
}

/**
 * Generate a full `opencode.json` document for OmniRoute. The catalog is the
 * single source of truth for context windows β€” we never hardcode values.
 *
 * Behavior:
 *  - Preserves the user's existing provider name, npm, options, and
 *    per-model names / capability flags.
 *  - For each existing model id, the catalog's `context_length` wins
 *    unless the user already set an explicit `limit.context` in the file.
 *  - For each catalog model id the user did NOT have, a new entry is
 *    added with `limit.context` populated when the catalog has it.
 *  - If the catalog has no context for a model AND the user has no
 *    override, the model is emitted WITHOUT a `limit.context` field.
 *    OpenCode's own heuristic (typically 128K) applies.
 *  - Throws if the catalog fetch fails β€” the user must fix the upstream
 *    before we can generate a reliable opencode.json.
 */
export async function generateOpencodeConfig(
  options: GenerateOpencodeOptions
): Promise<string> {
  const cleanBase = options.baseUrl.replace(/\/+$/, "");
  const baseURL = cleanBase.endsWith("/v1") ? cleanBase : `${cleanBase}/v1`;

  const providerId = options.providerId?.trim() || "omniroute";
  const fetchCatalog = options.fetchCatalog !== false;
  const timeoutMs = options.catalogTimeoutMs ?? 5_000;

  // Fetch live catalog. The catalog is the source of truth β€” if it fails,
  // we refuse to write an opencode.json that could mislead OpenCode into
  // picking the wrong context window.
  let catalogById = new Map<string, CatalogModelEntry>();
  if (fetchCatalog) {
    const result = await fetchOmniRouteCatalog(baseURL, options.apiKey, timeoutMs);
    catalogById = result.byId;
  } else {
    throw new Error(
      "fetchCatalog=false is not supported. The catalog is the single source " +
        "of truth for context windows β€” without it, opencode.json would carry " +
        "fabricated or stale values."
    );
  }

  // Load existing config so we preserve names, capability flags, and any
  // explicit `limit.context` overrides the user has set.
  const existing = loadExistingConfig();
  const existingProvider = existing.provider?.[providerId];
  const existingModels = (existingProvider?.models ?? {}) as Record<string, ExistingModelEntry>;

  // Build the merged model map: catalog first, then existing (so existing
  // values can win for matching ids).
  const mergedIds = new Set<string>([...catalogById.keys(), ...Object.keys(existingModels)]);

  const mergedModels: Record<string, ExistingModelEntry> = {};
  for (const id of mergedIds) {
    mergedModels[id] = buildModelEntry(id, catalogById.get(id), existingModels[id]);
  }

  const provider: Record<string, unknown> = {
    name: existingProvider?.name ?? "OmniRoute",
    npm: existingProvider?.npm ?? "@ai-sdk/openai-compatible",
    options: {
      baseURL,
      apiKey: options.apiKey,
      ...(existingProvider?.options ?? {}),
    },
    models: mergedModels,
  };
  // Carry over any other provider-level keys the user set (e.g. headers).
  if (existingProvider) {
    for (const [k, v] of Object.entries(existingProvider)) {
      if (k === "name" || k === "npm" || k === "options" || k === "models") continue;
      provider[k] = v;
    }
  }

  const config: Record<string, unknown> = {
    $schema: existing.$schema ?? "https://opencode.ai/config.json",
    provider: { ...(existing.provider ?? {}), [providerId]: provider },
  };

  // Carry over top-level keys the user may have set (compaction, plugins,
  // permission, mcp, etc.). We intentionally do NOT preserve `model` /
  // `small_model` unless the generator was given an explicit model β€” the
  // user's top-level model selection may point at a model that no longer
  // exists, so we require an explicit value via `options.model`.
  for (const [k, v] of Object.entries(existing)) {
    if (k === "$schema" || k === "provider" || k === "model" || k === "small_model") continue;
    config[k] = v;
  }

  if (typeof options.model === "string" && options.model.trim()) {
    config.model = `${providerId}/${options.model.trim()}`;
  } else if (typeof existing.model === "string" && existing.model.trim()) {
    // Preserve the user's previous top-level `model` so a re-run doesn't
    // silently drop their selection.
    config.model = existing.model;
  }

  if (typeof existing.small_model === "string" && existing.small_model.trim()) {
    config.small_model = existing.small_model;
  }

  return JSON.stringify(config, null, 2);
}

/**
 * Synchronous variant used by the legacy CLI path. Emits a minimal
 * `opencode.json` (just provider options + top-level model) without a
 * catalog fetch. Kept for back-compat with the previous `config set
 * opencode` command; the async variant above is what callers should use
 * for the full, context-window-aware flow.
 */
export function generateOpencodeConfigSync(options: {
  baseUrl: string;
  apiKey: string;
  model?: string;
}): string {
  const cleanBase = options.baseUrl.replace(/\/+$/, "");
  const base = cleanBase.endsWith("/v1") ? cleanBase.slice(0, -3) : cleanBase;

  const config = {
    provider: "omniroute",
    baseURL: `${base}/v1`,
    apiKey: options.apiKey,
    model: options.model || "opencode",
  };

  return JSON.stringify(config, null, 2);
}

// Backwards-compatible default export: keeps the existing call sites in
// `config.mjs` working. The async variant above is the preferred entry
// point for new callers.
export default generateOpencodeConfigSync;