mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(opencode-plugin): auto-discover models while running + force sync (#8101)
* feat(opencode-plugin): auto-discover models while running + force sync Add Pi-parity discovery for OpenCode: - autoSyncIntervalMs background refresh (default 5m, min 60s, 0=off) - omniroute_sync_models tool to force cache invalidate + /v1/models refetch - /omni-sync and /omni-autosync command templates (OpenCode has no slash API) * docs(opencode-plugin): document auto model discovery + /omni-sync Document Pi-parity catalog refresh for OpenCode: - autoSyncIntervalMs background discovery (default 5m) - omniroute_sync_models force-refresh tool - /omni-sync and /omni-autosync command templates --------- Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
This commit is contained in:
@@ -10,7 +10,8 @@ This plugin solves that by:
|
||||
|
||||
- Fetching `/v1/models` and `/api/combos` **at OpenCode startup, in Node.js** — no CORS, no WebView restrictions
|
||||
- Emitting the provider block **dynamically** in the plugin's `config`/`provider` hook — so `opencode.json` only needs the plugin entry, not a static `provider.omniroute`
|
||||
- Re-fetching on a configurable TTL (default 5 min), so new models / combo changes in the OmniRoute UI appear without restarting OpenCode
|
||||
- Re-fetching on a configurable TTL (default 5 min) **and** background auto-discovery while OpenCode is running (`autoSyncIntervalMs`, default 5 min), so new models / combo changes appear without restarting OpenCode
|
||||
- Exposing a force-refresh path (`omniroute_sync_models` tool + `/omni-sync` command template) equivalent to Pi `/omni sync`
|
||||
- Computing `limit.context` for combos as `min(member.context_length)` from the live catalog (no more `null` values that cause 4K-token truncation)
|
||||
- **Auto-pickup of `interleaved` capability** for thinking models (merged via PR #3138)
|
||||
|
||||
@@ -73,6 +74,9 @@ Peer dep: `@opencode-ai/plugin` (managed by your OpenCode install).
|
||||
{
|
||||
"providerId": "omniroute",
|
||||
"baseURL": "https://or.example.com",
|
||||
// Background re-discovery while OpenCode is running (Pi parity).
|
||||
// Default 300000 (5 min). Minimum 60000. Set 0 to disable.
|
||||
"autoSyncIntervalMs": 300000,
|
||||
},
|
||||
],
|
||||
],
|
||||
@@ -88,6 +92,27 @@ opencode auth login --provider omniroute
|
||||
|
||||
Restart OpenCode. `/models` lists the full live catalog. Variants (`-low`, `-medium`, `-high`, `-thinking`) and combos appear as first-class IDs — OmniRoute is the source of truth, no client-side synthesis.
|
||||
|
||||
### Live catalog refresh (auto + force)
|
||||
|
||||
While OpenCode is running, the plugin keeps the model catalog fresh in two ways:
|
||||
|
||||
| Mechanism | Default | What it does |
|
||||
| --- | --- | --- |
|
||||
| `modelCacheTtl` | `300000` (5 min) | On-demand TTL: next provider/models hook after expiry re-fetches `/v1/models` |
|
||||
| `autoSyncIntervalMs` | `300000` (5 min) | Background timer: proactively invalidates + re-fetches while the harness is running. Min `60000`. Set `0` to disable background polling (TTL still applies) |
|
||||
|
||||
**Force sync now** (Pi `/omni sync` equivalent) — OpenCode has no Pi-style slash-command registration API, so the plugin wires both a tool and command templates:
|
||||
|
||||
1. **Tool:** `omniroute_sync_models` — invalidates in-memory + disk caches, re-fetches `GET /v1/models` (and combos/enrichment when enabled), returns `{ ok, count, ... }`.
|
||||
2. **Command templates** (type these in OpenCode):
|
||||
- `/omni-sync` — asks the agent to call `omniroute_sync_models` and report the result
|
||||
- `/omni-autosync` — asks the agent to report current `autoSyncIntervalMs` / `modelCacheTtl` status
|
||||
|
||||
```text
|
||||
/omni-sync
|
||||
/omni-autosync
|
||||
```
|
||||
|
||||
## Multi-instance (prod + preprod side-by-side)
|
||||
|
||||
> ⚠ OC ≤1.15.5 dedupes plugin loads by absolute module path. Two `plugin:` entries pointing at the same `dist/index.js` collapse into one (last-listed options win). Workaround: install the plugin twice into separate directories so each entry resolves to a distinct module file. v0.2.x will introduce an `instances: [...]` shape that registers N providers from a single load.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@omniroute/opencode-plugin",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.1",
|
||||
"description": "OpenCode plugin for the OmniRoute AI Gateway. Drives dynamic model discovery, /connect auth flow, and multi-instance OmniRoute providers via the official @opencode-ai/plugin contract.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
@@ -23,7 +23,7 @@
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"clean": "rm -rf dist",
|
||||
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts",
|
||||
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts tests/auto-sync.test.ts",
|
||||
"prepublishOnly": "npm run clean && npm run build && npm test"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
@@ -54,6 +54,7 @@ import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { AuthHook, Config, Plugin, PluginOptions, ProviderHook } from "@opencode-ai/plugin";
|
||||
import { tool } from "@opencode-ai/plugin";
|
||||
import type { Model as ModelV2 } from "@opencode-ai/sdk/v2";
|
||||
import { z } from "zod";
|
||||
import { logger as _logger, setLogLevel, type LogLevel as _LogLevel } from "./logger.js";
|
||||
@@ -88,6 +89,10 @@ import {
|
||||
* from providerId.
|
||||
* - `modelCacheTtl` `/v1/models` TTL cache duration in milliseconds.
|
||||
* Default: 300_000 (5 min).
|
||||
* - `autoSyncIntervalMs` Background catalog re-discovery while OpenCode is
|
||||
* running. Default: 300_000 (5 min). Minimum: 60_000.
|
||||
* Set `0` to disable background auto-sync (TTL on-demand
|
||||
* discovery still applies via `modelCacheTtl`).
|
||||
* - `baseURL` Override base URL for this OmniRoute instance. When
|
||||
* absent, the loader falls back to a credential-attached
|
||||
* baseURL set by `/connect`.
|
||||
@@ -190,6 +195,12 @@ const optionsSchema = z
|
||||
.optional(),
|
||||
displayName: z.string().min(1).optional(),
|
||||
modelCacheTtl: z.number().positive().optional(),
|
||||
/**
|
||||
* Background auto-discovery interval while the harness is running.
|
||||
* `0` disables background polling. Values in (0, 60000) are clamped up
|
||||
* to 60000. Default when unset: 300000.
|
||||
*/
|
||||
autoSyncIntervalMs: z.number().int().nonnegative().optional(),
|
||||
baseURL: z.string().url().optional(),
|
||||
managementReadToken: z.string().min(1).optional(),
|
||||
features: featuresSchema.optional(),
|
||||
@@ -217,6 +228,29 @@ export const PLUGIN_GIT_HASH: string =
|
||||
|
||||
export const DEFAULT_MODEL_CACHE_TTL_MS = 300_000 as const;
|
||||
|
||||
/** Default background auto-discovery interval (matches modelCacheTtl default). */
|
||||
export const DEFAULT_AUTO_SYNC_INTERVAL_MS = 300_000 as const;
|
||||
|
||||
/** Minimum positive background auto-discovery interval. */
|
||||
export const MIN_AUTO_SYNC_INTERVAL_MS = 60_000 as const;
|
||||
|
||||
/**
|
||||
* Sanitize background auto-sync interval.
|
||||
* - unset/invalid → default 300_000
|
||||
* - explicit 0 → disabled
|
||||
* - (0, 60000) → clamped to 60000
|
||||
* - ≥ 60000 → kept as integer ms
|
||||
*/
|
||||
export function sanitizeAutoSyncIntervalMs(value: unknown): number {
|
||||
if (value === undefined || value === null) return DEFAULT_AUTO_SYNC_INTERVAL_MS;
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return DEFAULT_AUTO_SYNC_INTERVAL_MS;
|
||||
const n = Math.trunc(value);
|
||||
if (n === 0) return 0;
|
||||
if (n < 0) return DEFAULT_AUTO_SYNC_INTERVAL_MS;
|
||||
if (n < MIN_AUTO_SYNC_INTERVAL_MS) return MIN_AUTO_SYNC_INTERVAL_MS;
|
||||
return n;
|
||||
}
|
||||
|
||||
// Manual trim helpers avoid polynomial-regex CodeQL warnings on
|
||||
// user-supplied baseURL strings (string.replace(/\/+$/, "")). The same
|
||||
// behaviour, no backtracking.
|
||||
@@ -243,7 +277,7 @@ function trimLeadingDashes(value: string): string {
|
||||
* sees a consistent identifier.
|
||||
*/
|
||||
export function resolveOmniRoutePluginOptions(opts?: OmniRoutePluginOptions): Required<
|
||||
Pick<OmniRoutePluginOptions, "providerId" | "displayName" | "modelCacheTtl">
|
||||
Pick<OmniRoutePluginOptions, "providerId" | "displayName" | "modelCacheTtl" | "autoSyncIntervalMs">
|
||||
> & {
|
||||
/**
|
||||
* #6859: the UNPREFIXED provider id ("omniroute", "omniroute-preprod", …).
|
||||
@@ -277,17 +311,22 @@ export function resolveOmniRoutePluginOptions(opts?: OmniRoutePluginOptions): Re
|
||||
typeof opts?.modelCacheTtl === "number" && opts.modelCacheTtl > 0
|
||||
? opts.modelCacheTtl
|
||||
: DEFAULT_MODEL_CACHE_TTL_MS;
|
||||
const autoSyncIntervalMs = sanitizeAutoSyncIntervalMs(opts?.autoSyncIntervalMs);
|
||||
return {
|
||||
providerId,
|
||||
omnirouteProviderId,
|
||||
displayName,
|
||||
modelCacheTtl,
|
||||
autoSyncIntervalMs,
|
||||
baseURL: opts?.baseURL,
|
||||
managementReadToken: opts?.managementReadToken,
|
||||
features: opts?.features,
|
||||
};
|
||||
}
|
||||
|
||||
/** Fully resolved plugin options (defaults applied). */
|
||||
export type ResolvedOmniRoutePluginOptions = ReturnType<typeof resolveOmniRoutePluginOptions>;
|
||||
|
||||
/**
|
||||
* Strip a leading "opencode-" prefix (added only for the OC native-adapter
|
||||
* gate — see `resolveOmniRoutePluginOptions`) so the returned id is safe to
|
||||
@@ -523,8 +562,378 @@ export function createOmniRouteAuthHook(opts?: OmniRoutePluginOptions): AuthHook
|
||||
* opencode.json), NOT as a closure binding. Multi-instance support follows
|
||||
* from each plugin tuple invoking the factory with its own opts.
|
||||
*/
|
||||
/**
|
||||
* Invalidate in-memory fetch cache entries for a baseURL (all credential keys).
|
||||
* Returns number of entries removed.
|
||||
*/
|
||||
export function invalidateOmniRouteFetchCache(
|
||||
cache: OmniRouteFetchCache,
|
||||
baseURL?: string,
|
||||
): number {
|
||||
if (!baseURL) {
|
||||
const n = cache.size;
|
||||
cache.clear();
|
||||
return n;
|
||||
}
|
||||
const prefix = `${baseURL}::`;
|
||||
let removed = 0;
|
||||
for (const key of [...cache.keys()]) {
|
||||
if (key.startsWith(prefix) || key === baseURL) {
|
||||
cache.delete(key);
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve API credentials for force-sync / background refresh without
|
||||
* depending on the provider.models auth context.
|
||||
*/
|
||||
export async function resolveOmniRouteRuntimeAuth(
|
||||
resolved: ResolvedOmniRoutePluginOptions,
|
||||
readAuthJson?: OmniRouteReadAuthJson,
|
||||
): Promise<{ apiKey: string; baseURL: string; managementReadToken: string } | null> {
|
||||
const reader = readAuthJson ?? defaultReadAuthJson;
|
||||
let authJson: AuthJsonShape | undefined | null;
|
||||
try {
|
||||
authJson = await reader();
|
||||
} catch {
|
||||
authJson = undefined;
|
||||
}
|
||||
if (authJson === null) authJson = undefined;
|
||||
|
||||
const bareKey = resolved.providerId.startsWith("opencode-")
|
||||
? resolved.providerId.slice("opencode-".length)
|
||||
: resolved.providerId;
|
||||
const lookupKeys = [resolved.providerId];
|
||||
if (bareKey !== resolved.providerId) lookupKeys.push(bareKey);
|
||||
if (resolved.omnirouteProviderId && !lookupKeys.includes(resolved.omnirouteProviderId)) {
|
||||
lookupKeys.push(resolved.omnirouteProviderId);
|
||||
}
|
||||
|
||||
let entry: AuthJsonApiEntry | undefined;
|
||||
for (const k of lookupKeys) {
|
||||
const e = authJson?.[k];
|
||||
if (
|
||||
e &&
|
||||
(e as { type?: unknown }).type === "api" &&
|
||||
typeof (e as { key?: unknown }).key === "string" &&
|
||||
((e as { key: string }).key).length > 0
|
||||
) {
|
||||
entry = e as AuthJsonApiEntry;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const apiKey = entry?.key ?? "";
|
||||
if (!apiKey) return null;
|
||||
|
||||
const authBaseURL =
|
||||
entry && typeof (entry as { baseURL?: unknown }).baseURL === "string"
|
||||
? (entry as { baseURL: string }).baseURL
|
||||
: "";
|
||||
const baseURL = resolved.baseURL ?? (authBaseURL || "");
|
||||
if (!baseURL) return null;
|
||||
const managementReadToken = resolved.managementReadToken ?? apiKey;
|
||||
return { apiKey, baseURL, managementReadToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-refresh OmniRoute catalog: clear memory + disk cache, re-fetch /v1/models
|
||||
* (and optional management endpoints), and repopulate the shared cache.
|
||||
* OpenCode equivalent of Pi `/omni sync`.
|
||||
*/
|
||||
export async function forceSyncOmniRouteModels(args: {
|
||||
resolved: ResolvedOmniRoutePluginOptions;
|
||||
cache: OmniRouteFetchCache;
|
||||
readAuthJson?: OmniRouteReadAuthJson;
|
||||
fetcher?: OmniRouteModelsFetcher;
|
||||
combosFetcher?: OmniRouteCombosFetcher;
|
||||
autoCombosFetcher?: OmniRouteAutoCombosFetcher;
|
||||
enrichmentFetcher?: OmniRouteEnrichmentFetcher;
|
||||
compressionMetaFetcher?: OmniRouteCompressionMetaFetcher;
|
||||
providersFetcher?: OmniRouteProvidersFetcher;
|
||||
now?: () => number;
|
||||
}): Promise<{
|
||||
ok: boolean;
|
||||
count: number;
|
||||
combos: number;
|
||||
provider: string;
|
||||
baseURL?: string;
|
||||
clearedMemory: number;
|
||||
clearedDisk: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
const resolved = args.resolved;
|
||||
const cache = args.cache;
|
||||
const now = args.now ?? Date.now;
|
||||
const fetcher = args.fetcher ?? defaultOmniRouteModelsFetcher;
|
||||
const combosFetcher = args.combosFetcher ?? defaultOmniRouteCombosFetcher;
|
||||
const autoCombosFetcher = args.autoCombosFetcher ?? defaultOmniRouteAutoCombosFetcher;
|
||||
const enrichmentFetcher = args.enrichmentFetcher ?? defaultOmniRouteEnrichmentFetcher;
|
||||
const compressionMetaFetcher =
|
||||
args.compressionMetaFetcher ?? defaultOmniRouteCompressionMetaFetcher;
|
||||
const providersFetcher = args.providersFetcher ?? defaultOmniRouteProvidersFetcher;
|
||||
const features = resolved.features ?? {};
|
||||
const wantCombos = features.combos !== false;
|
||||
const wantAutoCombos = features.autoCombos !== false;
|
||||
const wantEnrichment = features.enrichment !== false;
|
||||
const wantCompressionMeta = features.compressionMetadata === true;
|
||||
const wantUsableOnly = features.usableOnly === true;
|
||||
const wantDiskCache = features.diskCache !== false;
|
||||
|
||||
const auth = await resolveOmniRouteRuntimeAuth(
|
||||
resolved,
|
||||
args.readAuthJson ?? defaultReadAuthJson,
|
||||
);
|
||||
if (!auth) {
|
||||
return {
|
||||
ok: false,
|
||||
count: 0,
|
||||
combos: 0,
|
||||
provider: resolved.omnirouteProviderId,
|
||||
clearedMemory: 0,
|
||||
clearedDisk: false,
|
||||
error:
|
||||
"No OmniRoute credentials/baseURL available. Run `opencode connect omniroute` or set plugin baseURL.",
|
||||
};
|
||||
}
|
||||
|
||||
const clearedMemory = invalidateOmniRouteFetchCache(cache, auth.baseURL);
|
||||
// Clear residual entries from prior baseURL history as well.
|
||||
const clearedAll = invalidateOmniRouteFetchCache(cache);
|
||||
let clearedDisk = false;
|
||||
if (wantDiskCache) {
|
||||
clearedDisk = await clearDiskSnapshot(resolved.providerId);
|
||||
if (resolved.omnirouteProviderId !== resolved.providerId) {
|
||||
clearedDisk = (await clearDiskSnapshot(resolved.omnirouteProviderId)) || clearedDisk;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const rawModels = await fetcher(auth.baseURL, auth.apiKey, 10_000);
|
||||
let rawCombos: OmniRouteRawCombo[] = [];
|
||||
if (wantCombos) {
|
||||
try {
|
||||
rawCombos = await combosFetcher(auth.baseURL, auth.managementReadToken, 10_000);
|
||||
} catch (err) {
|
||||
console.warn("[omniroute-plugin] force sync: combos fetch failed", err);
|
||||
}
|
||||
}
|
||||
let rawAutoCombos: OmniRouteRawAutoCombo[] = [];
|
||||
if (wantAutoCombos) {
|
||||
try {
|
||||
rawAutoCombos = await autoCombosFetcher(auth.baseURL, auth.managementReadToken, 5_000);
|
||||
} catch {
|
||||
/* soft-fail */
|
||||
}
|
||||
}
|
||||
let rawEnrichment: OmniRouteEnrichmentMap = new Map();
|
||||
if (wantEnrichment) {
|
||||
try {
|
||||
rawEnrichment = await enrichmentFetcher(auth.baseURL, auth.managementReadToken, 10_000);
|
||||
} catch {
|
||||
rawEnrichment = new Map();
|
||||
}
|
||||
}
|
||||
let rawCompressionCombos: OmniRouteCompressionCombo[] = [];
|
||||
if (wantCompressionMeta) {
|
||||
try {
|
||||
rawCompressionCombos = await compressionMetaFetcher(
|
||||
auth.baseURL,
|
||||
auth.managementReadToken,
|
||||
10_000,
|
||||
);
|
||||
} catch {
|
||||
rawCompressionCombos = [];
|
||||
}
|
||||
}
|
||||
let rawConnections: OmniRouteProviderConnection[] = [];
|
||||
if (wantUsableOnly) {
|
||||
try {
|
||||
rawConnections = await providersFetcher(auth.baseURL, auth.managementReadToken, 10_000);
|
||||
} catch {
|
||||
rawConnections = [];
|
||||
}
|
||||
}
|
||||
|
||||
const t = now();
|
||||
const entry = {
|
||||
rawModels,
|
||||
rawCombos,
|
||||
rawAutoCombos,
|
||||
rawEnrichment,
|
||||
rawCompressionCombos,
|
||||
rawConnections,
|
||||
expiresAt: t + resolved.modelCacheTtl,
|
||||
};
|
||||
const cacheKey = modelsCacheKey(
|
||||
auth.baseURL,
|
||||
`${auth.apiKey}\0${auth.managementReadToken}`,
|
||||
);
|
||||
cache.set(cacheKey, entry);
|
||||
|
||||
if (wantDiskCache) {
|
||||
try {
|
||||
const fingerprint = diskSnapshotIdentityFingerprint(
|
||||
auth.baseURL,
|
||||
auth.apiKey,
|
||||
auth.managementReadToken,
|
||||
);
|
||||
const { expiresAt: _expiresAt, ...diskEntry } = entry;
|
||||
await defaultDiskSnapshotWriter(resolved.providerId, diskEntry, fingerprint);
|
||||
} catch {
|
||||
/* soft-fail disk write */
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`[omniroute-plugin] force sync ok providerId=${resolved.providerId} ` +
|
||||
`models=${rawModels.length} combos=${rawCombos.length} ` +
|
||||
`clearedMemory=${clearedMemory + clearedAll} disk=${clearedDisk}`,
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
count: rawModels.length,
|
||||
combos: rawCombos.length,
|
||||
provider: resolved.omnirouteProviderId,
|
||||
baseURL: auth.baseURL,
|
||||
clearedMemory: clearedMemory + clearedAll,
|
||||
clearedDisk,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
ok: false,
|
||||
count: 0,
|
||||
combos: 0,
|
||||
provider: resolved.omnirouteProviderId,
|
||||
baseURL: auth.baseURL,
|
||||
clearedMemory: clearedMemory + clearedAll,
|
||||
clearedDisk,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createOmniRouteSyncModelsTool(args: {
|
||||
resolved: ResolvedOmniRoutePluginOptions;
|
||||
cache: OmniRouteFetchCache;
|
||||
}): ReturnType<typeof tool> {
|
||||
const { resolved, cache } = args;
|
||||
return tool({
|
||||
description:
|
||||
"Force-refresh the OmniRoute model catalog (OpenCode equivalent of Pi `/omni sync`). " +
|
||||
"Invalidates in-memory and disk caches, then re-fetches GET /v1/models (and combos when enabled).",
|
||||
args: {
|
||||
reason: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optional reason for the sync (logging only)"),
|
||||
},
|
||||
async execute(toolArgs) {
|
||||
const result = await forceSyncOmniRouteModels({ resolved, cache });
|
||||
const reason = toolArgs.reason ? ` reason=${toolArgs.reason}` : "";
|
||||
if (!result.ok) {
|
||||
return {
|
||||
title: "OmniRoute sync failed",
|
||||
output:
|
||||
`OmniRoute model sync failed for provider=${result.provider}.${reason}\n` +
|
||||
`${result.error ?? "unknown error"}`,
|
||||
metadata: result,
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: `OmniRoute sync: ${result.count} models`,
|
||||
output:
|
||||
`OmniRoute models synced.` +
|
||||
`\nprovider: ${result.provider}` +
|
||||
`\nbaseURL: ${result.baseURL}` +
|
||||
`\nmodels: ${result.count}` +
|
||||
`\ncombos: ${result.combos}` +
|
||||
`\nclearedMemoryEntries: ${result.clearedMemory}` +
|
||||
`\nclearedDiskSnapshot: ${result.clearedDisk}` +
|
||||
`\nTTL: ${resolved.modelCacheTtl}ms` +
|
||||
`\nautoSyncIntervalMs: ${resolved.autoSyncIntervalMs}` +
|
||||
reason,
|
||||
metadata: result,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start background auto-discovery while the harness is running.
|
||||
* Quiet: only logs when the model count changes or on errors.
|
||||
* Returns a stop function.
|
||||
*/
|
||||
export function startOmniRouteAutoSync(args: {
|
||||
resolved: ResolvedOmniRoutePluginOptions;
|
||||
cache: OmniRouteFetchCache;
|
||||
intervalMs?: number;
|
||||
}): () => void {
|
||||
const resolved = args.resolved;
|
||||
const cache = args.cache;
|
||||
const intervalMs = args.intervalMs ?? resolved.autoSyncIntervalMs;
|
||||
if (!intervalMs || intervalMs <= 0) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
let stopped = false;
|
||||
let inFlight: Promise<void> | null = null;
|
||||
let lastCount: number | undefined;
|
||||
|
||||
const tick = () => {
|
||||
if (stopped) return;
|
||||
if (inFlight) return;
|
||||
inFlight = (async () => {
|
||||
const result = await forceSyncOmniRouteModels({ resolved, cache });
|
||||
if (!result.ok) {
|
||||
console.warn(
|
||||
`[omniroute-plugin] auto-sync failed providerId=${resolved.providerId}: ${result.error}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (lastCount === undefined) {
|
||||
lastCount = result.count;
|
||||
return;
|
||||
}
|
||||
if (result.count !== lastCount) {
|
||||
console.warn(
|
||||
`[omniroute-plugin] auto-sync catalog size changed ${lastCount} → ${result.count} ` +
|
||||
`(providerId=${resolved.providerId})`,
|
||||
);
|
||||
lastCount = result.count;
|
||||
}
|
||||
})()
|
||||
.catch((err) => {
|
||||
console.warn("[omniroute-plugin] auto-sync tick error", err);
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = null;
|
||||
});
|
||||
};
|
||||
|
||||
// Delay first background tick by one interval so session start is not doubled
|
||||
// with the normal provider.models cold fetch. Manual tool remains immediate.
|
||||
const timer = setInterval(tick, intervalMs);
|
||||
if (typeof timer === "object" && timer && "unref" in timer && typeof timer.unref === "function") {
|
||||
timer.unref();
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`[omniroute-plugin] auto-sync enabled intervalMs=${intervalMs} providerId=${resolved.providerId}`,
|
||||
);
|
||||
|
||||
return () => {
|
||||
stopped = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}
|
||||
|
||||
export const OmniRoutePlugin: Plugin = async (_input, options) => {
|
||||
const resolved = coercePluginOptions(options);
|
||||
const resolved = resolveOmniRoutePluginOptions(coercePluginOptions(options));
|
||||
// T-07: a single per-plugin-instance cache shared between the provider
|
||||
// hook (T-03/T-05) and the config-shim hook (T-07). On OC ≥1.14.49 both
|
||||
// hooks fire within the same Plugin invocation, so a shared cache keeps
|
||||
@@ -553,10 +962,53 @@ export const OmniRoutePlugin: Plugin = async (_input, options) => {
|
||||
|
||||
// Wire log level: startupDebug:true → "debug", explicit logLevel wins.
|
||||
setLogLevel(resolved.features?.startupDebug ? "debug" : (resolved.features?.logLevel ?? "warn"));
|
||||
|
||||
// Background auto-discovery while the harness is running (Pi parity).
|
||||
// Interval 0 disables. TTL on-demand discovery still works via modelCacheTtl.
|
||||
startOmniRouteAutoSync({ resolved, cache: sharedCache });
|
||||
|
||||
const syncTool = createOmniRouteSyncModelsTool({ resolved, cache: sharedCache });
|
||||
const bareProviderId = resolved.omnirouteProviderId;
|
||||
|
||||
// Config hook: keep existing catalog shim, and register slash command
|
||||
// templates that ask the agent to call the force-sync tool (OpenCode has no
|
||||
// Pi-style registerCommand API; tools + command templates are the native path).
|
||||
const baseConfigHook = createOmniRouteConfigHook(resolved, { cache: sharedCache });
|
||||
const configWithSyncCommand = async (input: Config) => {
|
||||
await baseConfigHook(input);
|
||||
const cfg = input as Config & {
|
||||
command?: Record<
|
||||
string,
|
||||
{ template: string; description?: string; agent?: string; model?: string; subtask?: boolean }
|
||||
>;
|
||||
};
|
||||
if (!cfg.command) cfg.command = {};
|
||||
if (!cfg.command["omni-sync"]) {
|
||||
cfg.command["omni-sync"] = {
|
||||
description: "Force-refresh OmniRoute model catalog (like Pi /omni sync)",
|
||||
template:
|
||||
`Force-refresh the OmniRoute model catalog now using the omniroute_sync_models tool ` +
|
||||
`(provider ${bareProviderId}). After the tool returns, briefly report model count and whether the sync succeeded.`,
|
||||
};
|
||||
}
|
||||
if (!cfg.command["omni-autosync"]) {
|
||||
cfg.command["omni-autosync"] = {
|
||||
description: "Show OmniRoute auto-sync / cache status",
|
||||
template:
|
||||
`Report OmniRoute discovery status for provider ${bareProviderId}: ` +
|
||||
`autoSyncIntervalMs=${resolved.autoSyncIntervalMs}, modelCacheTtl=${resolved.modelCacheTtl}. ` +
|
||||
`If the user asked to refresh now, call omniroute_sync_models.`,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
auth: createOmniRouteAuthHook(resolved),
|
||||
provider: createOmniRouteProviderHook(resolved, { cache: sharedCache }),
|
||||
config: createOmniRouteConfigHook(resolved, { cache: sharedCache }),
|
||||
config: configWithSyncCommand,
|
||||
tool: {
|
||||
omniroute_sync_models: syncTool,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4061,6 +4513,19 @@ export function diskSnapshotPath(providerId: string): string {
|
||||
return path.join(dir, "plugins", `omniroute-${providerId}.json`);
|
||||
}
|
||||
|
||||
/** Best-effort delete of the disk snapshot for a provider (force-sync). */
|
||||
export async function clearDiskSnapshot(providerId: string): Promise<boolean> {
|
||||
const file = diskSnapshotPath(providerId);
|
||||
try {
|
||||
await unlink(file);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException | undefined)?.code;
|
||||
if (code === "ENOENT") return false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export type OmniRouteDiskSnapshotWriter = (
|
||||
providerId: string,
|
||||
entry: Omit<OmniRouteFetchCacheEntry, "expiresAt">,
|
||||
|
||||
130
@omniroute/opencode-plugin/tests/auto-sync.test.ts
Normal file
130
@omniroute/opencode-plugin/tests/auto-sync.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Auto-discovery + force-sync (OpenCode parity with Pi `/omni sync`).
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
sanitizeAutoSyncIntervalMs,
|
||||
DEFAULT_AUTO_SYNC_INTERVAL_MS,
|
||||
MIN_AUTO_SYNC_INTERVAL_MS,
|
||||
parseOmniRoutePluginOptions,
|
||||
resolveOmniRoutePluginOptions,
|
||||
invalidateOmniRouteFetchCache,
|
||||
forceSyncOmniRouteModels,
|
||||
type OmniRouteFetchCache,
|
||||
} from "../src/index.js";
|
||||
|
||||
test("sanitizeAutoSyncIntervalMs: unset → default 300000", () => {
|
||||
assert.equal(sanitizeAutoSyncIntervalMs(undefined), DEFAULT_AUTO_SYNC_INTERVAL_MS);
|
||||
assert.equal(sanitizeAutoSyncIntervalMs(null), DEFAULT_AUTO_SYNC_INTERVAL_MS);
|
||||
});
|
||||
|
||||
test("sanitizeAutoSyncIntervalMs: 0 disables", () => {
|
||||
assert.equal(sanitizeAutoSyncIntervalMs(0), 0);
|
||||
});
|
||||
|
||||
test("sanitizeAutoSyncIntervalMs: clamps below min to 60000", () => {
|
||||
assert.equal(sanitizeAutoSyncIntervalMs(1), MIN_AUTO_SYNC_INTERVAL_MS);
|
||||
assert.equal(sanitizeAutoSyncIntervalMs(59_999), MIN_AUTO_SYNC_INTERVAL_MS);
|
||||
});
|
||||
|
||||
test("sanitizeAutoSyncIntervalMs: keeps valid values", () => {
|
||||
assert.equal(sanitizeAutoSyncIntervalMs(60_000), 60_000);
|
||||
assert.equal(sanitizeAutoSyncIntervalMs(300_000), 300_000);
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions accepts autoSyncIntervalMs including 0", () => {
|
||||
assert.equal(parseOmniRoutePluginOptions({ autoSyncIntervalMs: 0 }).autoSyncIntervalMs, 0);
|
||||
assert.equal(parseOmniRoutePluginOptions({ autoSyncIntervalMs: 120_000 }).autoSyncIntervalMs, 120_000);
|
||||
});
|
||||
|
||||
test("resolveOmniRoutePluginOptions defaults autoSyncIntervalMs to 300000", () => {
|
||||
const r = resolveOmniRoutePluginOptions({});
|
||||
assert.equal(r.autoSyncIntervalMs, DEFAULT_AUTO_SYNC_INTERVAL_MS);
|
||||
});
|
||||
|
||||
test("resolveOmniRoutePluginOptions clamps low positive autoSyncIntervalMs", () => {
|
||||
const r = resolveOmniRoutePluginOptions({ autoSyncIntervalMs: 5000 });
|
||||
assert.equal(r.autoSyncIntervalMs, MIN_AUTO_SYNC_INTERVAL_MS);
|
||||
});
|
||||
|
||||
test("invalidateOmniRouteFetchCache clears by baseURL prefix", () => {
|
||||
const cache: OmniRouteFetchCache = new Map();
|
||||
cache.set("https://a.example/v1::abc", {
|
||||
rawModels: [],
|
||||
rawCombos: [],
|
||||
rawAutoCombos: [],
|
||||
rawEnrichment: new Map(),
|
||||
rawCompressionCombos: [],
|
||||
rawConnections: [],
|
||||
expiresAt: Date.now() + 1000,
|
||||
});
|
||||
cache.set("https://b.example/v1::def", {
|
||||
rawModels: [],
|
||||
rawCombos: [],
|
||||
rawAutoCombos: [],
|
||||
rawEnrichment: new Map(),
|
||||
rawCompressionCombos: [],
|
||||
rawConnections: [],
|
||||
expiresAt: Date.now() + 1000,
|
||||
});
|
||||
const removed = invalidateOmniRouteFetchCache(cache, "https://a.example/v1");
|
||||
assert.equal(removed, 1);
|
||||
assert.equal(cache.size, 1);
|
||||
assert.equal(cache.has("https://b.example/v1::def"), true);
|
||||
});
|
||||
|
||||
test("forceSyncOmniRouteModels: fetches, populates cache, returns count", async () => {
|
||||
const cache: OmniRouteFetchCache = new Map();
|
||||
const resolved = resolveOmniRoutePluginOptions({
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://omniroute.example/v1",
|
||||
autoSyncIntervalMs: 0,
|
||||
features: {
|
||||
combos: false,
|
||||
autoCombos: false,
|
||||
enrichment: false,
|
||||
compressionMetadata: false,
|
||||
usableOnly: false,
|
||||
diskCache: false,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await forceSyncOmniRouteModels({
|
||||
resolved,
|
||||
cache,
|
||||
readAuthJson: async () => ({
|
||||
omniroute: { type: "api", key: "test-key" },
|
||||
}),
|
||||
fetcher: async () => [
|
||||
{ id: "model-a", object: "model" },
|
||||
{ id: "model-b", object: "model" },
|
||||
],
|
||||
now: () => 1_000_000,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.count, 2);
|
||||
assert.equal(result.provider, "omniroute");
|
||||
assert.equal(cache.size, 1);
|
||||
const entry = [...cache.values()][0];
|
||||
assert.equal(entry.rawModels.length, 2);
|
||||
assert.equal(entry.expiresAt, 1_000_000 + resolved.modelCacheTtl);
|
||||
});
|
||||
|
||||
test("forceSyncOmniRouteModels: missing auth returns error", async () => {
|
||||
const cache: OmniRouteFetchCache = new Map();
|
||||
const resolved = resolveOmniRoutePluginOptions({
|
||||
providerId: "omniroute",
|
||||
baseURL: "https://omniroute.example/v1",
|
||||
autoSyncIntervalMs: 0,
|
||||
features: { diskCache: false },
|
||||
});
|
||||
const result = await forceSyncOmniRouteModels({
|
||||
resolved,
|
||||
cache,
|
||||
readAuthJson: async () => ({}),
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error ?? "", /credentials|baseURL|connect/i);
|
||||
});
|
||||
Reference in New Issue
Block a user