om-memory-system 6.2.0 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { CONFIG } from "../config.js";
1
+ import type { MemoryProviderRuntimeConfig } from "../services/ai/provider-config.js";
2
2
  export type ExternalApiTestResult = {
3
3
  ok: true;
4
4
  model: string;
@@ -10,7 +10,9 @@ export type ExternalApiTestResult = {
10
10
  * One small call to the saved external API from this process, with a small
11
11
  * output limit. The error never contains the key.
12
12
  */
13
- export declare function testExternalApi(config?: typeof CONFIG, createProvider?: (provider: string, providerConfig: Record<string, unknown>) => {
13
+ export declare function testExternalApi(given?: MemoryProviderRuntimeConfig & {
14
+ memoryProvider: string;
15
+ }, createProvider?: (provider: string, providerConfig: Record<string, unknown>) => {
14
16
  executeToolCall: (...args: any[]) => Promise<{
15
17
  success: boolean;
16
18
  error?: string;
@@ -1,5 +1,4 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { CONFIG } from "../config.js";
3
2
  import { safeHealthError } from "../services/safe-health-error.js";
4
3
  const OUTPUT_LIMIT = 64;
5
4
  const TIMEOUT_MS = 20_000;
@@ -15,7 +14,9 @@ function outputLimitParams(provider) {
15
14
  * One small call to the saved external API from this process, with a small
16
15
  * output limit. The error never contains the key.
17
16
  */
18
- export async function testExternalApi(config = CONFIG, createProvider) {
17
+ export async function testExternalApi(given, createProvider) {
18
+ // The real config loads only when no config is passed, so tests never read it.
19
+ const config = given ?? (await import("../config.js")).CONFIG;
19
20
  const secrets = [config.memoryApiKey];
20
21
  try {
21
22
  const { buildMemoryProviderConfig } = await import("../services/ai/provider-config.js");
@@ -1,5 +1,5 @@
1
1
  import type { ProviderConfig } from "./providers/base-provider.js";
2
- interface MemoryProviderRuntimeConfig {
2
+ export interface MemoryProviderRuntimeConfig {
3
3
  memoryProvider?: string;
4
4
  memoryModel?: string;
5
5
  memoryApiUrl?: string;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The OpenRouter model list, reduced to ID and name for the Model field
3
+ * suggestions. It reads one public OpenRouter list without an API key. `fetch`
4
+ * and the clock come in as arguments, so the module needs no config and tests
5
+ * need no network.
6
+ */
7
+ export type ModelRow = {
8
+ id: string;
9
+ name: string;
10
+ };
11
+ export type ModelsResult = {
12
+ ok: true;
13
+ models: ModelRow[];
14
+ } | {
15
+ ok: false;
16
+ error: string;
17
+ };
18
+ export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
19
+ export declare function createOpenRouterModels(deps: {
20
+ fetch: FetchLike;
21
+ now: () => number;
22
+ timeoutMs?: number;
23
+ }): {
24
+ list: () => Promise<ModelsResult>;
25
+ };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The OpenRouter model list, reduced to ID and name for the Model field
3
+ * suggestions. It reads one public OpenRouter list without an API key. `fetch`
4
+ * and the clock come in as arguments, so the module needs no config and tests
5
+ * need no network.
6
+ */
7
+ const URL_MODELS = "https://openrouter.ai/api/v1/models";
8
+ const CACHE_TTL_MS = 60 * 60 * 1000;
9
+ const FETCH_TIMEOUT_MS = 10_000;
10
+ const text = (value) => typeof value === "string" && value.trim() ? value : undefined;
11
+ export function createOpenRouterModels(deps) {
12
+ const timeoutMs = deps.timeoutMs ?? FETCH_TIMEOUT_MS;
13
+ let cache;
14
+ async function list() {
15
+ if (cache && deps.now() - cache.at < CACHE_TTL_MS)
16
+ return { ok: true, models: cache.models };
17
+ let response;
18
+ try {
19
+ response = await deps.fetch(URL_MODELS, {
20
+ method: "GET",
21
+ headers: { Accept: "application/json" },
22
+ signal: AbortSignal.timeout(timeoutMs),
23
+ });
24
+ }
25
+ catch (error) {
26
+ const name = error instanceof Error ? error.name : "";
27
+ if (name === "TimeoutError" || name === "AbortError") {
28
+ return { ok: false, error: "OpenRouter did not answer within 10 seconds." };
29
+ }
30
+ return { ok: false, error: "OpenRouter could not be reached." };
31
+ }
32
+ if (!response.ok) {
33
+ return { ok: false, error: `OpenRouter answered with status ${response.status}.` };
34
+ }
35
+ let body;
36
+ try {
37
+ body = await response.json();
38
+ }
39
+ catch {
40
+ return { ok: false, error: "OpenRouter sent a reply that could not be read." };
41
+ }
42
+ const rows = body?.data;
43
+ if (!Array.isArray(rows)) {
44
+ return { ok: false, error: "OpenRouter sent a model list in an unknown form." };
45
+ }
46
+ const models = [];
47
+ for (const row of rows) {
48
+ const id = text(row?.id);
49
+ if (id)
50
+ models.push({ id, name: text(row.name) ?? id });
51
+ }
52
+ models.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
53
+ // Only a good answer is cached, so a failed fetch is tried again on the next request.
54
+ cache = { at: deps.now(), models };
55
+ return { ok: true, models };
56
+ }
57
+ return { list };
58
+ }
@@ -5,10 +5,16 @@
5
5
  * arguments, so the module needs no config and tests need no network.
6
6
  */
7
7
  export type ProviderPolicy = "zdr" | "safety" | "unknown";
8
+ /** US dollars per 1M tokens. Only set when both values are known. */
9
+ export type ProviderPrice = {
10
+ input: number;
11
+ output: number;
12
+ };
8
13
  export type ProviderRow = {
9
14
  slug: string;
10
15
  name: string;
11
16
  policy: ProviderPolicy;
17
+ price?: ProviderPrice;
12
18
  };
13
19
  export type ProviderFailure = "unknown-model" | "timeout" | "unreachable";
14
20
  export type ProvidersResult = {
@@ -24,6 +24,16 @@ class ListError extends Error {
24
24
  }
25
25
  }
26
26
  const text = (value) => typeof value === "string" && value.trim() ? value : undefined;
27
+ /** A per-token price string such as "0.000000256" as US dollars per 1M tokens. Missing, non-numeric or negative gives undefined. */
28
+ function pricePerMillion(value) {
29
+ if (typeof value !== "string" || !value.trim())
30
+ return undefined;
31
+ const perToken = Number(value);
32
+ if (!Number.isFinite(perToken) || perToken < 0)
33
+ return undefined;
34
+ return Math.round(perToken * 1_000_000 * 1e6) / 1e6;
35
+ }
36
+ const lowest = (a, b) => a === undefined ? b : b === undefined ? a : Math.min(a, b);
27
37
  export function createOpenRouterProviders(deps) {
28
38
  const timeoutMs = deps.timeoutMs ?? FETCH_TIMEOUT_MS;
29
39
  let zdrCache;
@@ -91,6 +101,8 @@ export function createOpenRouterProviders(deps) {
91
101
  throw new ListError("unreachable", "OpenRouter sent a provider list in an unknown form.");
92
102
  }
93
103
  const bySlug = new Map();
104
+ // Lowest input and output price per slug, found independently across its endpoints.
105
+ const prices = new Map();
94
106
  for (const endpoint of endpoints) {
95
107
  const tag = text(endpoint?.tag);
96
108
  const name = text(endpoint?.provider_name);
@@ -100,6 +112,11 @@ export function createOpenRouterProviders(deps) {
100
112
  const modelId = text(endpoint?.model_id);
101
113
  const flag = tag && modelId ? flags.get(`${modelId}\n${tag}`) : undefined;
102
114
  const policy = flag === undefined ? "unknown" : flag ? "safety" : "zdr";
115
+ const pricing = endpoint?.pricing;
116
+ const lows = prices.get(slug) ?? {};
117
+ lows.input = lowest(lows.input, pricePerMillion(pricing?.prompt));
118
+ lows.output = lowest(lows.output, pricePerMillion(pricing?.completion));
119
+ prices.set(slug, lows);
103
120
  const seen = bySlug.get(slug);
104
121
  if (!seen) {
105
122
  bySlug.set(slug, { slug, name: name ?? slug, policy });
@@ -108,6 +125,12 @@ export function createOpenRouterProviders(deps) {
108
125
  seen.policy = policy;
109
126
  }
110
127
  }
128
+ // A price needs both values. A slug with only one of them shows no price.
129
+ for (const [slug, row] of bySlug) {
130
+ const { input, output } = prices.get(slug) ?? {};
131
+ if (input !== undefined && output !== undefined)
132
+ row.price = { input, output };
133
+ }
111
134
  const providers = [...bySlug.values()].sort((a, b) => a.name.localeCompare(b.name));
112
135
  if (providers.length === 0) {
113
136
  throw new ListError("unknown-model", "OpenRouter has no providers for that model.");
@@ -1,13 +1,19 @@
1
1
  import { log } from "./logger.js";
2
+ import { createOpenRouterModels } from "./openrouter-models.js";
2
3
  import { createOpenRouterProviders, isModelId } from "./openrouter-providers.js";
3
4
  import { isOpenRouterUrl, parseRouting, removeProviderEdits, routingEdits, } from "./openrouter-routing-config.js";
4
5
  const PROVIDERS_PATH = "/api/settings/external-api/openrouter-providers";
6
+ const MODELS_PATH = "/api/settings/external-api/openrouter-models";
5
7
  const ROUTING_PATH = "/api/settings/external-api/openrouter-routing";
6
8
  /** Reads `globalThis.fetch` at call time, so a test can replace it. */
7
9
  const providers = createOpenRouterProviders({
8
10
  fetch: (input, init) => globalThis.fetch(input, init),
9
11
  now: () => Date.now(),
10
12
  });
13
+ const models = createOpenRouterModels({
14
+ fetch: (input, init) => globalThis.fetch(input, init),
15
+ now: () => Date.now(),
16
+ });
11
17
  const NOT_OPENROUTER = "Save the OpenRouter endpoint first. Routing applies only to an OpenRouter API URL.";
12
18
  const fail = (status, error) => ({ status, body: { error } });
13
19
  /**
@@ -23,6 +29,9 @@ export async function routeOpenRouter(path, method, url, readBody, ctx) {
23
29
  }
24
30
  return { status: 200, body: await providers.list(model) };
25
31
  }
32
+ if (path === MODELS_PATH && method === "GET") {
33
+ return { status: 200, body: await models.list() };
34
+ }
26
35
  if (path === ROUTING_PATH && method === "POST") {
27
36
  const body = await readBody();
28
37
  if (typeof body?.revision !== "string")