Spaces:
Runtime error
Runtime error
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;
|