mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-13 02:12:10 +03:00
Real and nasty precisely because it is silent: `z.string().url()` accepts `localhost:20128` as scheme `localhost:` plus a path, every model gets published with an unusable api url, and the failure happens inside the client so the gateway logs show nothing. Backing the option schema, the publish boundary and the snapshot filter with one `isHttpUrl` in v2 is the right call — those three cannot drift apart. Duplicating the predicate in v1 rather than sharing it is also correct, since the two packages ship independently. --- Validated in one consolidated worktree cut from `release/v3.8.51`, boarded together with the rest of this batch — zero conflicts between them. - `typecheck:core` clean; `check:changelog-integrity` OK - complexity 2799 / baseline 3218 and cognitive-complexity 1265 / baseline 1437 — both under baseline - 86 focused assertions green across the batch's 10 unit test files, plus 16/16 on the v1 plugin option schema and 16/16 on the v2 option tests - `check-file-size` rebaselined for this batch's real growth (annotation `_rebaseline_2026_09_11_mergebatch_v3851_maxmad_opencode`, landed on #13141). `open-sse/utils/stream.ts` was deliberately left frozen: it is already 3115 > 3098 on the pure tip with zero contribution from this batch. ⚠️ base-red inherited: #12732 — `Docs Gates`, `Merge integrity`, `No new ESLint warnings`, `Unit Tests fast-path` and `Fast Quality Gates` all reproduce on the pure `release/v3.8.51` tip (provider count 356 vs the 358 the modules define, SKILL.md drift, and the `stream.ts` freeze above). None of them touch these diffs. Thanks @maxmad64bis.
340 lines
13 KiB
TypeScript
340 lines
13 KiB
TypeScript
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
|
|
import { normaliseFreeLabel } from "./naming.js";
|
|
|
|
export interface OmniRouteRawModelEntry {
|
|
id: string;
|
|
object?: string;
|
|
owned_by?: string;
|
|
root?: string | null;
|
|
parent?: string | null;
|
|
context_length?: number;
|
|
max_input_tokens?: number;
|
|
max_output_tokens?: number;
|
|
input_modalities?: string[];
|
|
output_modalities?: string[];
|
|
capabilities?: {
|
|
tool_calling?: boolean;
|
|
reasoning?: boolean;
|
|
vision?: boolean;
|
|
thinking?: boolean;
|
|
attachment?: boolean;
|
|
structured_output?: boolean;
|
|
temperature?: boolean;
|
|
/** Runtime-learned or synced reasoning tiers (server-gated, blind-mapped). */
|
|
effort_tiers?: string[];
|
|
};
|
|
release_date?: string;
|
|
last_updated?: string;
|
|
api_format?: string;
|
|
}
|
|
|
|
/**
|
|
* Fetcher contract: returns the raw `/v1/models` entry list from a running
|
|
* OmniRoute instance. Surfaced as a dependency so unit tests can inject a
|
|
* stub without monkey-patching global `fetch`.
|
|
*
|
|
* Why we inline this instead of using `@omniroute/opencode-provider`'s
|
|
* `fetchLiveModels`: the sibling helper returns a stripped `{id, name,
|
|
* contextLength?}` shape that drops the `capabilities` / `*_modalities` /
|
|
* `max_*_tokens` blocks the mapping needs for ModelV2 pass-through.
|
|
*/
|
|
export type OmniRouteModelsFetcher = (
|
|
baseURL: string,
|
|
apiKey: string,
|
|
timeoutMs?: number
|
|
) => Promise<OmniRouteRawModelEntry[]>;
|
|
|
|
/**
|
|
* Default fetcher: `GET <baseURL>/v1/models` with bearer auth + AbortController
|
|
* timeout. Accepts both the `{object:"list", data:[…]}` envelope OmniRoute
|
|
* emits today and a bare-array envelope (defensive — keeps the plugin
|
|
* working if a future OmniRoute build trims the wrapper). Anything that
|
|
* isn't an object with a string `id` is filtered out silently.
|
|
*/
|
|
export const defaultOmniRouteModelsFetcher: OmniRouteModelsFetcher = async (
|
|
baseURL,
|
|
apiKey,
|
|
timeoutMs = 10_000
|
|
) => {
|
|
if (!apiKey) throw new Error("[omniroute-v2] apiKey required to fetch /v1/models");
|
|
if (!baseURL) throw new Error("[omniroute-v2] baseURL required to fetch /v1/models");
|
|
|
|
const trimmed = trimTrailingSlashes(baseURL);
|
|
// Tolerate both `https://host` and `https://host/v1` forms — the gateway
|
|
// exposes /v1/models either way; we just don't want a double `/v1/v1`.
|
|
const url = /\/v\d+$/.test(trimmed) ? `${trimmed}/models` : `${trimmed}/v1/models`;
|
|
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
try {
|
|
const res = await fetch(url, {
|
|
method: "GET",
|
|
headers: {
|
|
Authorization: `Bearer ${apiKey}`,
|
|
Accept: "application/json",
|
|
},
|
|
signal: controller.signal,
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`[omniroute-v2] GET ${url} failed: ${res.status} ${res.statusText}`);
|
|
}
|
|
const body = (await res.json()) as unknown;
|
|
const rawList: unknown[] = Array.isArray(body)
|
|
? body
|
|
: body && typeof body === "object" && Array.isArray((body as { data?: unknown }).data)
|
|
? ((body as { data: unknown[] }).data as unknown[])
|
|
: [];
|
|
const out: OmniRouteRawModelEntry[] = [];
|
|
for (const r of rawList) {
|
|
if (r && typeof r === "object" && typeof (r as { id?: unknown }).id === "string") {
|
|
out.push(r as OmniRouteRawModelEntry);
|
|
}
|
|
}
|
|
return out;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
};
|
|
|
|
// Manual trim helpers avoid polynomial-regex CodeQL warnings on
|
|
// user-supplied baseURL strings (string.replace(/\/+$/, "")). The same
|
|
// behaviour, no backtracking.
|
|
function trimTrailingSlashes(value: string): string {
|
|
let i = value.length;
|
|
while (i > 0 && value.charCodeAt(i - 1) === 0x2f /* "/" */) i--;
|
|
return i === value.length ? value : value.slice(0, i);
|
|
}
|
|
|
|
/**
|
|
* Ensure a baseURL ends with `/v1` so the OpenAI-compat SDK constructs
|
|
* `/v1/chat/completions` correctly. The Anthropic SDK does NOT want `/v1`
|
|
* (it appends `/v1/messages` automatically), so callers should branch on
|
|
* format first.
|
|
*/
|
|
/**
|
|
* A url the AI SDK can actually call. `new URL()` alone is not enough: it
|
|
* parses `localhost:20128` as the scheme `localhost:` and `ftp://host` as ftp,
|
|
* both of which reach `fetch` and fail there. Mirrors the `isHttpUrl` guard the
|
|
* settings schema applies to `headroomUrl`.
|
|
*/
|
|
export function isHttpUrl(value: unknown): boolean {
|
|
if (typeof value !== "string") return false;
|
|
try {
|
|
const { protocol } = new URL(value);
|
|
return protocol === "http:" || protocol === "https:";
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function ensureV1Suffix(url: string): string {
|
|
const trimmed = trimTrailingSlashes(url);
|
|
return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`;
|
|
}
|
|
|
|
export interface ApiFormatV2 {
|
|
allowAnthropic?: boolean;
|
|
anthropicModels?: string[];
|
|
/**
|
|
* Deprecated v1 prefix list (default v1:
|
|
* `cc,claude,anthropic,kiro,kr`). Accepted for backward compatibility:
|
|
* prefix OR allowlist routes to anthropic, with a one-time deprecation
|
|
* warning pointing at `anthropicModels`. Prefer full IDs.
|
|
*/
|
|
anthropicPrefixes?: string[];
|
|
}
|
|
|
|
/** Default v1 prefix list, kept so copied v1 configs keep routing. */
|
|
export const DEFAULT_ANTHROPIC_PREFIXES_V1 = ["cc", "claude", "anthropic", "kiro", "kr"];
|
|
|
|
const warnedPrefixLists = new Set<string>();
|
|
|
|
function warnDeprecatedPrefixesOnce(prefixes: string[]): void {
|
|
const key = [...prefixes].sort().join(",");
|
|
if (warnedPrefixLists.has(key)) return;
|
|
warnedPrefixLists.add(key);
|
|
console.warn(
|
|
"[omniroute-plugin] [WARN] apiFormat.anthropicPrefixes is deprecated; convert to anthropicModels (full IDs)"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The Anthropic SDK block appends `/v1/messages` itself, so it needs the
|
|
* gateway root. A config carrying the `/v1` the OpenAI-compatible block wants
|
|
* would otherwise produce `/v1/v1/messages`.
|
|
*/
|
|
function stripV1Suffix(baseURL: string): string {
|
|
return baseURL.replace(/\/v1\/?$/, "");
|
|
}
|
|
|
|
/**
|
|
* Resolve the API block (id + url + npm package) for a given model id.
|
|
*
|
|
* v2 rule: a model routes to the Anthropic SDK block when
|
|
* `apiFormat.allowAnthropic === true` AND (its FULL id is allowlisted in
|
|
* `apiFormat.anthropicModels` OR its prefix is listed in the deprecated
|
|
* `apiFormat.anthropicPrefixes`, defaulting to the v1 list when prefixes
|
|
* are absent). The deprecated path warns once per prefix list. With
|
|
* neither allowlist nor prefix match, the model stays openai-compatible.
|
|
*/
|
|
export function resolveApiBlockV2(
|
|
modelId: string,
|
|
baseURL: string,
|
|
apiFormat?: ApiFormatV2
|
|
): { id: string; url: string; npm: string } {
|
|
if (apiFormat?.allowAnthropic === true) {
|
|
if ((apiFormat.anthropicModels ?? []).includes(modelId)) {
|
|
return {
|
|
id: "anthropic",
|
|
url: stripV1Suffix(trimTrailingSlashes(baseURL)),
|
|
npm: "@ai-sdk/anthropic",
|
|
};
|
|
}
|
|
const prefixes = apiFormat.anthropicPrefixes ?? DEFAULT_ANTHROPIC_PREFIXES_V1;
|
|
if (apiFormat.anthropicPrefixes !== undefined) warnDeprecatedPrefixesOnce(prefixes);
|
|
const slash = modelId.indexOf("/");
|
|
const prefix = slash === -1 ? modelId : modelId.slice(0, slash);
|
|
if (prefixes.includes(prefix)) {
|
|
return {
|
|
id: "anthropic",
|
|
url: stripV1Suffix(trimTrailingSlashes(baseURL)),
|
|
npm: "@ai-sdk/anthropic",
|
|
};
|
|
}
|
|
}
|
|
return {
|
|
id: "openai-compatible",
|
|
url: ensureV1Suffix(baseURL),
|
|
npm: "@ai-sdk/openai-compatible",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Map a raw `/v1/models` entry → `ModelV2` (the type @opencode-ai/sdk/v2
|
|
* exports as `Model`, re-exported by @opencode-ai/plugin as `ModelV2`).
|
|
*
|
|
* ModelV2 requires a much richer shape than a flat record. Concretely it
|
|
* expects:
|
|
* - flat `id`, `name`, `providerID`, `api: {id,url,npm}`
|
|
* - nested `capabilities: { temperature, reasoning, attachment, toolcall,
|
|
* input:{text,audio,image,video,pdf}, output:{…}, interleaved }`
|
|
* - `cost: { input, output, cache:{read,write} }` (NOT optional)
|
|
* - `limit: { context, input?, output }`
|
|
* - `status: "alpha"|"beta"|"deprecated"|"active"`, `options:{}`, `headers:{}`
|
|
* - `release_date: string`
|
|
*
|
|
* Field adaptations:
|
|
* 1. Flat `tool_call` / `reasoning` / `attachment` / `modalities`
|
|
* top-level fields don't exist in ModelV2 — folded into
|
|
* `capabilities.{toolcall, reasoning, attachment, input.*, output.*}`.
|
|
* 2. `cost: undefined` is illegal (cost is required). OmniRoute doesn't
|
|
* surface pricing on /v1/models, so we emit a zeroed cost block.
|
|
* Downstream opencode reads this for display only — the live pricing
|
|
* is OmniRoute's responsibility at routing time.
|
|
* 3. `tool_call` → `toolcall` (ModelV2 field name; one word).
|
|
* 4. `attachment` maps from `capabilities.vision` per OmniRoute
|
|
* convention: vision = ability to receive image attachments. If the
|
|
* raw entry happens to expose an explicit `capabilities.attachment`,
|
|
* that wins.
|
|
* 5. `thinking` from OmniRoute has no 1:1 ModelV2 slot. We OR it into
|
|
* `reasoning` so thinking-only models still surface a non-false
|
|
* reasoning flag.
|
|
* 6. `last_updated` from OmniRoute has no ModelV2 slot — dropped.
|
|
* `release_date` lands in ModelV2.release_date with `""` fallback
|
|
* (the field is required as `string`).
|
|
* 7. `temperature: true` per OmniRoute convention (OpenAI-compat mode
|
|
* always supports the temperature knob). If a raw entry sets
|
|
* `capabilities.temperature` explicitly, that wins.
|
|
* 8. Input/output modality arrays: each known modality flips its boolean.
|
|
* Unknown strings (future OmniRoute additions) are ignored — when the
|
|
* server adds new modalities we can map them here without breaking
|
|
* existing entries.
|
|
* 9. `status: "active"` — OmniRoute doesn't tier models alpha/beta on
|
|
* /v1/models, and opencode needs a non-deprecated status to expose
|
|
* the model in the picker. If a future entry surfaces an explicit
|
|
* lifecycle hint we can map it then.
|
|
* 10. `options: {}` and `headers: {}` left empty — they're escape hatches
|
|
* for opencode users to attach per-model overrides; the provider
|
|
* plugin must not preempt them.
|
|
* 11. `limit.input` is OPTIONAL on ModelV2 (the `?` modifier). We only
|
|
* emit it when OmniRoute supplies `max_input_tokens` — keeps the
|
|
* shape clean for combo entries that only carry context_length.
|
|
*/
|
|
export function mapRawModelToModelV2(
|
|
raw: OmniRouteRawModelEntry,
|
|
ctx: { providerId: string; baseURL: string; apiFormat?: ApiFormatV2 }
|
|
): ModelV2 {
|
|
const caps = raw.capabilities ?? {};
|
|
// effort_tiers loop: server-declared tiers become ModelV2 variants so the
|
|
// UI offers exactly the tiers OmniRoute vouches for (instead of opencode's
|
|
// invented [low, medium, high] fallback). Blind: filtering/exclusion rules
|
|
// live server-side. Absent/empty/malformed => key omitted ENTIRELY (an
|
|
// empty variants object would suppress opencode's fallback for this model).
|
|
const declaredTiers = Array.isArray(caps.effort_tiers)
|
|
? caps.effort_tiers.filter((t): t is string => typeof t === "string" && t.length > 0)
|
|
: [];
|
|
const variants =
|
|
declaredTiers.length > 0
|
|
? Object.fromEntries(declaredTiers.map((tier) => [tier, { reasoningEffort: tier }]))
|
|
: undefined;
|
|
const inMods = new Set(raw.input_modalities ?? ["text"]);
|
|
const outMods = new Set(raw.output_modalities ?? ["text"]);
|
|
|
|
return {
|
|
// OC's static-catalog reader parses the key on `/` to recover
|
|
// `(providerID, modelID)`. If the raw id is already provider-prefixed
|
|
// (e.g. `cc/claude-opus-4-7` from the `cc` Claude Code alias, or
|
|
// `nvidia/llama-3-70b` from a provider that ships prefixed ids), leave
|
|
// it as-is — double-prefixing breaks OC's lookup. Bare **combo** ids
|
|
// (`owned_by: "combo"`, e.g. `gpt-5.6-sol`) must also stay unprefixed:
|
|
// OpenCode looks up `-m <plugin>/<combo>` as model id `<combo>` under
|
|
// the plugin provider. Other bare ids still prefix with
|
|
// `providerId` so credentials resolve as `(omniroute, model)`.
|
|
id: raw.id.includes("/") || raw.owned_by === "combo" ? raw.id : `${ctx.providerId}/${raw.id}`,
|
|
/**
|
|
* Display name. Falls back to raw.id when no enrichment is available;
|
|
* the caller overlays `/api/pricing/models` data via enrichment when
|
|
* the enrichment feature is enabled.
|
|
*/
|
|
name: normaliseFreeLabel(raw.id),
|
|
capabilities: {
|
|
temperature: caps.temperature ?? true,
|
|
reasoning: Boolean(caps.reasoning || caps.thinking),
|
|
attachment: Boolean(caps.attachment ?? caps.vision ?? false),
|
|
toolcall: Boolean(caps.tool_calling ?? false),
|
|
input: {
|
|
text: inMods.has("text"),
|
|
audio: inMods.has("audio"),
|
|
image: inMods.has("image"),
|
|
video: inMods.has("video"),
|
|
pdf: inMods.has("pdf"),
|
|
},
|
|
output: {
|
|
text: outMods.has("text"),
|
|
audio: outMods.has("audio"),
|
|
image: outMods.has("image"),
|
|
video: outMods.has("video"),
|
|
pdf: outMods.has("pdf"),
|
|
},
|
|
interleaved: Boolean(caps.thinking),
|
|
},
|
|
cost: {
|
|
input: 0,
|
|
output: 0,
|
|
cache: { read: 0, write: 0 },
|
|
},
|
|
limit: {
|
|
context: typeof raw.context_length === "number" ? raw.context_length : 0,
|
|
...(typeof raw.max_input_tokens === "number" ? { input: raw.max_input_tokens } : {}),
|
|
output: typeof raw.max_output_tokens === "number" ? raw.max_output_tokens : 0,
|
|
},
|
|
...(variants ? { variants } : {}),
|
|
status: "active",
|
|
options: {},
|
|
headers: {},
|
|
release_date: raw.release_date ?? "",
|
|
providerID: ctx.providerId,
|
|
api: resolveApiBlockV2(raw.id, ctx.baseURL, ctx.apiFormat),
|
|
};
|
|
}
|