om-memory-system 6.2.0-next.243 → 6.2.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.
- package/dist/config.js +3 -0
- package/dist/core/memorisation-operations.js +2 -0
- package/dist/services/ai/provider-error-kind.d.ts +7 -0
- package/dist/services/ai/provider-error-kind.js +9 -0
- package/dist/services/ai/providers/anthropic-messages.js +3 -2
- package/dist/services/ai/providers/base-provider.d.ts +2 -0
- package/dist/services/ai/providers/base-provider.js +4 -0
- package/dist/services/ai/providers/google-gemini.js +3 -2
- package/dist/services/ai/providers/openai-chat-completion.js +3 -2
- package/dist/services/ai/providers/openai-responses.js +3 -2
- package/dist/services/api-handlers.d.ts +3 -24
- package/dist/services/api-handlers.js +4 -114
- package/dist/services/client.js +1 -0
- package/dist/services/global-config-writer.d.ts +12 -5
- package/dist/services/global-config-writer.js +23 -8
- package/dist/services/linked-search.d.ts +2 -0
- package/dist/services/openrouter-providers.d.ts +31 -0
- package/dist/services/openrouter-providers.js +124 -0
- package/dist/services/openrouter-routes.d.ts +9 -0
- package/dist/services/openrouter-routes.js +68 -0
- package/dist/services/openrouter-routing-config.d.ts +48 -0
- package/dist/services/openrouter-routing-config.js +143 -0
- package/dist/services/retrieval-rerank.d.ts +2 -0
- package/dist/services/retrieval-rerank.js +4 -1
- package/dist/services/settings-snapshot.d.ts +1 -0
- package/dist/services/settings-snapshot.js +5 -0
- package/dist/services/tag-migration.d.ts +48 -0
- package/dist/services/tag-migration.js +183 -0
- package/dist/services/web-route-modules.js +4 -0
- package/dist/web/assets/index-DgFc9nSc.js +171 -0
- package/dist/web/assets/index-Tc6q6ju1.css +2 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-CoYuPYCS.css +0 -2
- package/dist/web/assets/index-jbZuE9HV.js +0 -171
package/dist/config.js
CHANGED
|
@@ -11,6 +11,7 @@ import { parseIgnoredDirectories, parseImportPathMaps } from "./importer/import-
|
|
|
11
11
|
import { normaliseSkillConfig, projectSkillOverride, } from "./services/skill-learning/skill-config.js";
|
|
12
12
|
import * as visibleRecall from "./services/visible-recall-config.js";
|
|
13
13
|
import * as injectionOptions from "./services/injection-options-config.js";
|
|
14
|
+
import { keepGlobalProvider } from "./services/openrouter-routing-config.js";
|
|
14
15
|
import { checkedMemoryLimit, MEMORY_LIMITS } from "./utils/memory-limits.js";
|
|
15
16
|
import { defaultSimilarityThreshold } from "./utils/threshold-default.js";
|
|
16
17
|
import { RERANKER_TIMEOUT_DEFAULT_MS } from "./services/request-timeouts.js";
|
|
@@ -539,6 +540,8 @@ export function initConfig(directory, options = {}) {
|
|
|
539
540
|
delete projectOverrides.claudeConfigDir;
|
|
540
541
|
delete projectOverrides.webServerAutoStart;
|
|
541
542
|
delete projectOverrides.webServerEnabled;
|
|
543
|
+
// The routing choice of the OpenRouter card is global, like the endpoint and the key.
|
|
544
|
+
keepGlobalProvider(globalConfig, projectOverrides, (message) => log(message, { directory }));
|
|
542
545
|
const merged = { ...globalConfig, ...projectOverrides };
|
|
543
546
|
// The active memory space picks the storage folder unless the project sets its own (ADR-041).
|
|
544
547
|
const globalStoragePath = storagePathFrom(globalConfig);
|
|
@@ -121,6 +121,8 @@ export async function runRecall(args, linked, rerank = false) {
|
|
|
121
121
|
includeLinked: true,
|
|
122
122
|
kind: "memorisation",
|
|
123
123
|
reranker: rerank,
|
|
124
|
+
// A reranked recall still answers "no memorisation matched" when nothing clears the threshold.
|
|
125
|
+
thresholdAfterRerank: true,
|
|
124
126
|
...(wanted.length > 0 ? { limit: LABEL_SEARCH_POOL } : {}),
|
|
125
127
|
});
|
|
126
128
|
if (!result.success)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sorts a memory-model error into a kind the web page can explain in words.
|
|
3
|
+
* `no_credit` and `auth` fail the same way for every memory, so a run that
|
|
4
|
+
* hits one stops instead of calling the model again for each memory.
|
|
5
|
+
*/
|
|
6
|
+
export type ProviderErrorKind = "no_credit" | "auth" | "other";
|
|
7
|
+
export declare function providerErrorKind(message: string): ProviderErrorKind;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const NO_CREDIT = /\b402\b|insufficient[_ ](balance|credits?|funds|quota)|out of credits?|credit balance/i;
|
|
2
|
+
const AUTH = /\b401\b|\b403\b|invalid[_ ]api[_ ]key|unauthori[sz]ed|incorrect api key/i;
|
|
3
|
+
export function providerErrorKind(message) {
|
|
4
|
+
if (NO_CREDIT.test(message))
|
|
5
|
+
return "no_credit";
|
|
6
|
+
if (AUTH.test(message))
|
|
7
|
+
return "auth";
|
|
8
|
+
return "other";
|
|
9
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { applySafeExtraParams, BaseAIProvider, describeValidationError, isTransportError, } from "./base-provider.js";
|
|
1
|
+
import { applySafeExtraParams, BaseAIProvider, describeValidationError, isTransportError, redactApiKey, } from "./base-provider.js";
|
|
2
2
|
import { AISessionManager } from "../session/ai-session-manager.js";
|
|
3
3
|
import { ToolSchemaConverter } from "../tools/tool-schema.js";
|
|
4
4
|
import { log } from "../../logger.js";
|
|
@@ -113,7 +113,8 @@ export class AnthropicMessagesProvider extends BaseAIProvider {
|
|
|
113
113
|
});
|
|
114
114
|
clearTimeout(timeout);
|
|
115
115
|
if (!response.ok) {
|
|
116
|
-
const
|
|
116
|
+
const rawError = await response.text().catch(() => response.statusText);
|
|
117
|
+
const errorText = redactApiKey(rawError, this.config.apiKey);
|
|
117
118
|
log(this.apiErrorLogLabel(), {
|
|
118
119
|
provider: this.getProviderName(),
|
|
119
120
|
model: this.config.model,
|
|
@@ -54,6 +54,8 @@ export declare function validationLogFields(error: unknown, stopReason: string |
|
|
|
54
54
|
};
|
|
55
55
|
/** The error a failed tool reply returns. It says when the reply was cut off. */
|
|
56
56
|
export declare function validationFailureMessage(error: unknown, stopReason: string | undefined): string;
|
|
57
|
+
/** An error body from the API with the configured key removed, so it is safe to log and return. */
|
|
58
|
+
export declare function redactApiKey(text: string, apiKey: string | undefined): string;
|
|
57
59
|
export declare function applySafeExtraParams(requestBody: Record<string, any>, extraParams: Record<string, unknown>): void;
|
|
58
60
|
export declare abstract class BaseAIProvider {
|
|
59
61
|
protected config: ProviderConfig;
|
|
@@ -55,6 +55,10 @@ export function validationFailureMessage(error, stopReason) {
|
|
|
55
55
|
const cutOff = stopReason === "length" ? " (the reply was cut off)" : "";
|
|
56
56
|
return `Validation failed: ${describeValidationError(error)}${cutOff}`;
|
|
57
57
|
}
|
|
58
|
+
/** An error body from the API with the configured key removed, so it is safe to log and return. */
|
|
59
|
+
export function redactApiKey(text, apiKey) {
|
|
60
|
+
return apiKey ? text.split(apiKey).join("[redacted]") : text;
|
|
61
|
+
}
|
|
58
62
|
const PROTECTED_KEYS = new Set([
|
|
59
63
|
"model",
|
|
60
64
|
"messages",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BaseAIProvider, describeValidationError, isTransportError, } from "./base-provider.js";
|
|
1
|
+
import { BaseAIProvider, describeValidationError, isTransportError, redactApiKey, } from "./base-provider.js";
|
|
2
2
|
import { AISessionManager } from "../session/ai-session-manager.js";
|
|
3
3
|
import { log } from "../../logger.js";
|
|
4
4
|
import { UserProfileValidator } from "../validators/user-profile-validator.js";
|
|
@@ -147,7 +147,8 @@ export class GoogleGeminiProvider extends BaseAIProvider {
|
|
|
147
147
|
});
|
|
148
148
|
clearTimeout(timeout);
|
|
149
149
|
if (!response.ok) {
|
|
150
|
-
const
|
|
150
|
+
const rawError = await response.text().catch(() => response.statusText);
|
|
151
|
+
const errorText = redactApiKey(rawError, this.config.apiKey);
|
|
151
152
|
log("Gemini API error", {
|
|
152
153
|
provider: this.getProviderName(),
|
|
153
154
|
model: this.config.model,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BaseAIProvider, applySafeExtraParams, validationFailureMessage, validationLogFields, isTransportError, } from "./base-provider.js";
|
|
1
|
+
import { BaseAIProvider, applySafeExtraParams, validationFailureMessage, validationLogFields, isTransportError, redactApiKey, } from "./base-provider.js";
|
|
2
2
|
import { log } from "../../logger.js";
|
|
3
3
|
import { UserProfileValidator } from "../validators/user-profile-validator.js";
|
|
4
4
|
import { parseRetryAfter } from "../../../core/capture-retry-policy.js";
|
|
@@ -205,7 +205,8 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider {
|
|
|
205
205
|
});
|
|
206
206
|
clearTimeout(timeout);
|
|
207
207
|
if (!response.ok) {
|
|
208
|
-
const
|
|
208
|
+
const rawError = await response.text().catch(() => response.statusText);
|
|
209
|
+
const errorText = redactApiKey(rawError, this.config.apiKey);
|
|
209
210
|
log("OpenAI Chat Completion API error", {
|
|
210
211
|
provider: this.getProviderName(),
|
|
211
212
|
model: this.config.model,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BaseAIProvider, applySafeExtraParams, isTransportError, } from "./base-provider.js";
|
|
1
|
+
import { BaseAIProvider, applySafeExtraParams, isTransportError, redactApiKey, } from "./base-provider.js";
|
|
2
2
|
import { AISessionManager } from "../session/ai-session-manager.js";
|
|
3
3
|
import { ToolSchemaConverter } from "../tools/tool-schema.js";
|
|
4
4
|
import { log } from "../../logger.js";
|
|
@@ -62,7 +62,8 @@ export class OpenAIResponsesProvider extends BaseAIProvider {
|
|
|
62
62
|
});
|
|
63
63
|
clearTimeout(timeout);
|
|
64
64
|
if (!response.ok) {
|
|
65
|
-
const
|
|
65
|
+
const rawError = await response.text().catch(() => response.statusText);
|
|
66
|
+
const errorText = redactApiKey(rawError, this.config.apiKey);
|
|
66
67
|
log("OpenAI Responses API error", {
|
|
67
68
|
provider: this.getProviderName(),
|
|
68
69
|
model: this.config.model,
|
|
@@ -155,27 +155,6 @@ export declare function handleCategoryCleanupStatus(userId?: string): Promise<Ap
|
|
|
155
155
|
export declare function handleCategoryCleanupApply(userId?: string, body?: any, deps?: CategoryCleanupDeps): Promise<ApiResponse<any>>;
|
|
156
156
|
export declare function handleCategoryCleanupUndo(userId?: string, deps?: CategoryCleanupDeps): Promise<ApiResponse<any>>;
|
|
157
157
|
export declare function handleUpdateProfileItem(body?: any): Promise<ApiResponse<any>>;
|
|
158
|
-
export
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}>>;
|
|
162
|
-
interface MigrationProgress {
|
|
163
|
-
processed: number;
|
|
164
|
-
total: number;
|
|
165
|
-
currentBatch: number;
|
|
166
|
-
totalBatches: number;
|
|
167
|
-
isComplete: boolean;
|
|
168
|
-
errors: string[];
|
|
169
|
-
}
|
|
170
|
-
export declare function handleGetTagMigrationProgress(): Promise<ApiResponse<MigrationProgress>>;
|
|
171
|
-
/**
|
|
172
|
-
* Tag the memories that have no tags, a few per request. Only untagged
|
|
173
|
-
* memories are touched: tagged ones keep their vectors. A memory that fails
|
|
174
|
-
* is recorded and passed over, so one bad row cannot stall the run.
|
|
175
|
-
*/
|
|
176
|
-
export declare function handleRunTagMigrationBatch(batchSize?: number): Promise<ApiResponse<{
|
|
177
|
-
processed: number;
|
|
178
|
-
total: number;
|
|
179
|
-
hasMore: boolean;
|
|
180
|
-
}>>;
|
|
181
|
-
export {};
|
|
158
|
+
export { handleDetectTagMigration, handleGetTagMigrationProgress } from "./tag-migration.js";
|
|
159
|
+
/** Tags untagged memories with the memory model. The work lives in tag-migration.ts. */
|
|
160
|
+
export declare function handleRunTagMigrationBatch(batchSize?: number): Promise<import("./tag-migration.js").ApiResponse<import("./tag-migration.js").TagMigrationBatch>>;
|
|
@@ -12,7 +12,7 @@ import { userPromptManager } from "./user-prompt/user-prompt-manager.js";
|
|
|
12
12
|
import { saveProfile } from "./user-profile/merge-and-save.js";
|
|
13
13
|
import { sortProfileItems, stripProfileVectors } from "../utils/profile.js";
|
|
14
14
|
import { resolveWebProfileUserId } from "./profile-identity.js";
|
|
15
|
-
import {
|
|
15
|
+
import { runTagMigrationBatch } from "./tag-migration.js";
|
|
16
16
|
import { loadMemoryRows, memorisationIdsAmong, MEMORISATION_EDIT_REFUSAL, MEMORISATION_TYPE_REFUSAL, webUpdateRefusal, } from "./web-memory-rows.js";
|
|
17
17
|
async function getAllMemoryShards() {
|
|
18
18
|
await ensureTursoReady();
|
|
@@ -996,118 +996,8 @@ export async function handleUpdateProfileItem(body) {
|
|
|
996
996
|
return { success: false, error: String(error) };
|
|
997
997
|
}
|
|
998
998
|
}
|
|
999
|
-
export
|
|
1000
|
-
|
|
1001
|
-
await ensureTursoReady();
|
|
1002
|
-
let untaggedCount = 0;
|
|
1003
|
-
for (const shard of await tursoShardManager.getAllShards("project", "")) {
|
|
1004
|
-
const db = await tursoConnectionManager.getConnection(shard.dbPath);
|
|
1005
|
-
const row = await db.get(`SELECT COUNT(*) as count FROM memories WHERE ${UNTAGGED_NORMAL_PREDICATE}`);
|
|
1006
|
-
untaggedCount += Number(row?.count ?? 0);
|
|
1007
|
-
}
|
|
1008
|
-
return { success: true, data: { needsMigration: untaggedCount > 0, count: untaggedCount } };
|
|
1009
|
-
}
|
|
1010
|
-
catch (error) {
|
|
1011
|
-
return { success: false, error: String(error) };
|
|
1012
|
-
}
|
|
1013
|
-
}
|
|
1014
|
-
const idleMigration = () => ({
|
|
1015
|
-
processed: 0,
|
|
1016
|
-
total: 0,
|
|
1017
|
-
currentBatch: 0,
|
|
1018
|
-
totalBatches: 0,
|
|
1019
|
-
isComplete: true,
|
|
1020
|
-
errors: [],
|
|
1021
|
-
});
|
|
1022
|
-
let migrationProgress = idleMigration();
|
|
1023
|
-
/** The untagged memories this migration run covers, fixed when the run starts. */
|
|
1024
|
-
let migrationQueue = [];
|
|
1025
|
-
export async function handleGetTagMigrationProgress() {
|
|
1026
|
-
return { success: true, data: migrationProgress };
|
|
1027
|
-
}
|
|
1028
|
-
/**
|
|
1029
|
-
* Tag the memories that have no tags, a few per request. Only untagged
|
|
1030
|
-
* memories are touched: tagged ones keep their vectors. A memory that fails
|
|
1031
|
-
* is recorded and passed over, so one bad row cannot stall the run.
|
|
1032
|
-
*/
|
|
999
|
+
export { handleDetectTagMigration, handleGetTagMigrationProgress } from "./tag-migration.js";
|
|
1000
|
+
/** Tags untagged memories with the memory model. The work lives in tag-migration.ts. */
|
|
1033
1001
|
export async function handleRunTagMigrationBatch(batchSize = 5) {
|
|
1034
|
-
|
|
1035
|
-
await ensureTursoReady();
|
|
1036
|
-
const { AIProviderFactory } = await import("./ai/ai-provider-factory.js");
|
|
1037
|
-
const { buildMemoryProviderConfig } = await import("./ai/provider-config.js");
|
|
1038
|
-
const providerConfig = buildMemoryProviderConfig(CONFIG, {
|
|
1039
|
-
maxIterations: 1,
|
|
1040
|
-
iterationTimeout: 30000,
|
|
1041
|
-
});
|
|
1042
|
-
const provider = AIProviderFactory.createProvider(CONFIG.memoryProvider, providerConfig);
|
|
1043
|
-
if (migrationProgress.isComplete) {
|
|
1044
|
-
migrationQueue = [];
|
|
1045
|
-
for (const shard of await tursoShardManager.getAllShards("project", "")) {
|
|
1046
|
-
const db = await tursoConnectionManager.getConnection(shard.dbPath);
|
|
1047
|
-
const rows = await db.all(`SELECT id FROM memories WHERE ${UNTAGGED_NORMAL_PREDICATE} ORDER BY id`);
|
|
1048
|
-
for (const row of rows)
|
|
1049
|
-
migrationQueue.push({ id: String(row.id), dbPath: shard.dbPath });
|
|
1050
|
-
}
|
|
1051
|
-
migrationProgress = {
|
|
1052
|
-
...idleMigration(),
|
|
1053
|
-
total: migrationQueue.length,
|
|
1054
|
-
totalBatches: Math.ceil(migrationQueue.length / batchSize),
|
|
1055
|
-
isComplete: migrationQueue.length === 0,
|
|
1056
|
-
};
|
|
1057
|
-
}
|
|
1058
|
-
const batch = migrationQueue.slice(migrationProgress.processed, migrationProgress.processed + batchSize);
|
|
1059
|
-
for (const item of batch) {
|
|
1060
|
-
try {
|
|
1061
|
-
const db = await tursoConnectionManager.getConnection(item.dbPath);
|
|
1062
|
-
const m = await db.get("SELECT * FROM memories WHERE id = ?", [item.id]);
|
|
1063
|
-
// Tagged or deleted since the run started: nothing to do.
|
|
1064
|
-
if (!m || (m.tags && String(m.tags).trim()))
|
|
1065
|
-
continue;
|
|
1066
|
-
const prompt = `Generate 2-4 short technical tags for this memory content:\n\n${m.content}\n\nReturn ONLY a comma-separated list of tags.`;
|
|
1067
|
-
const result = await provider.executeToolCall("You are a technical tagger.", prompt, {
|
|
1068
|
-
type: "function",
|
|
1069
|
-
function: {
|
|
1070
|
-
name: "save_tags",
|
|
1071
|
-
description: "Save generated tags",
|
|
1072
|
-
parameters: {
|
|
1073
|
-
type: "object",
|
|
1074
|
-
properties: { tags: { type: "array", items: { type: "string" } } },
|
|
1075
|
-
required: ["tags"],
|
|
1076
|
-
},
|
|
1077
|
-
},
|
|
1078
|
-
}, `migration_${m.id}`);
|
|
1079
|
-
const tags = Array.isArray(result.data?.tags)
|
|
1080
|
-
? result.data.tags.map((t) => String(t).trim().toLowerCase()).filter(Boolean)
|
|
1081
|
-
: [];
|
|
1082
|
-
if (!result.success || tags.length === 0) {
|
|
1083
|
-
throw new Error(result.error ?? "The model returned no tags");
|
|
1084
|
-
}
|
|
1085
|
-
const vector = await embeddingService.embedWithTimeout(m.content, { task: "document" });
|
|
1086
|
-
const tagsVector = await embeddingService.embedWithTimeout(formatTagsForEmbedding(tags), {
|
|
1087
|
-
task: "document",
|
|
1088
|
-
});
|
|
1089
|
-
// One statement, so a memory never ends up tagged with stale vectors.
|
|
1090
|
-
await tursoVectorSearch.updateVector(db, m.id, vector, tagsVector, tags.join(","));
|
|
1091
|
-
}
|
|
1092
|
-
catch (e) {
|
|
1093
|
-
const errorMsg = String(e);
|
|
1094
|
-
migrationProgress.errors.push(errorMsg);
|
|
1095
|
-
log("Migration error for memory", { id: item.id, error: errorMsg }, "error");
|
|
1096
|
-
}
|
|
1097
|
-
finally {
|
|
1098
|
-
migrationProgress.processed++;
|
|
1099
|
-
}
|
|
1100
|
-
}
|
|
1101
|
-
migrationProgress.currentBatch++;
|
|
1102
|
-
const hasMore = migrationProgress.processed < migrationProgress.total;
|
|
1103
|
-
if (!hasMore)
|
|
1104
|
-
migrationProgress.isComplete = true;
|
|
1105
|
-
return {
|
|
1106
|
-
success: true,
|
|
1107
|
-
data: { processed: migrationProgress.processed, total: migrationProgress.total, hasMore },
|
|
1108
|
-
};
|
|
1109
|
-
}
|
|
1110
|
-
catch (error) {
|
|
1111
|
-
return { success: false, error: String(error) };
|
|
1112
|
-
}
|
|
1002
|
+
return runTagMigrationBatch(CONFIG, batchSize);
|
|
1113
1003
|
}
|
package/dist/services/client.js
CHANGED
|
@@ -101,6 +101,7 @@ export class LocalMemoryClient {
|
|
|
101
101
|
maxMemories,
|
|
102
102
|
similarityThreshold: CONFIG.similarityThreshold,
|
|
103
103
|
kind: options.kind ?? "normal",
|
|
104
|
+
thresholdAfterRerank: options.thresholdAfterRerank,
|
|
104
105
|
}, reranker, {
|
|
105
106
|
search: (shardList, vector, tag, limit, threshold, queryText, kind) => tursoVectorSearch.searchAcrossShards(shardList, vector, tag, limit, threshold, queryText, keywordBoost, "boost", undefined, kind),
|
|
106
107
|
log: (message, meta) => log(message, meta),
|
|
@@ -12,12 +12,19 @@ export declare class ConfigConflictError extends Error {
|
|
|
12
12
|
constructor();
|
|
13
13
|
}
|
|
14
14
|
export declare function readGlobalConfigRevision(): string;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
/** A route that owns a separate set of keys passes it; the general save may not touch them. */
|
|
18
|
-
options?: {
|
|
15
|
+
type WriteOptions = {
|
|
16
|
+
/** A route that owns a separate set of keys passes it; the general save may not touch them. */
|
|
19
17
|
only?: Set<string>;
|
|
20
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Builds the edits from the config as read inside the save queue, against the
|
|
20
|
+
* checked revision, so a read-modify-write cannot lose another save. Returning
|
|
21
|
+
* no edits writes nothing. `edits` is then ignored.
|
|
22
|
+
*/
|
|
23
|
+
derive?: (current: Record<string, unknown>) => Record<string, unknown>;
|
|
24
|
+
};
|
|
25
|
+
/** Write only page-editable keys; reject edits based on a stale config revision. */
|
|
26
|
+
export declare function writeGlobalConfigKeys(edits: Record<string, unknown>, expectedRevision: string, options?: WriteOptions): Promise<{
|
|
21
27
|
revision: string;
|
|
22
28
|
migratedLegacy: boolean;
|
|
23
29
|
}>;
|
|
30
|
+
export {};
|
|
@@ -103,6 +103,10 @@ const RESETTABLE_KEYS = [
|
|
|
103
103
|
];
|
|
104
104
|
const QUERY_RULE = `openingContextQuery must be text of ${OPENING_CONTEXT_QUERY_MAX_LENGTH} characters or fewer`;
|
|
105
105
|
function isValidEdit(key, value) {
|
|
106
|
+
// Written only by the OpenRouter routing route; `undefined` removes the key.
|
|
107
|
+
if (key === "memoryExtraParams" || key === "memoryExtraParams.provider") {
|
|
108
|
+
return (value === undefined || (typeof value === "object" && value !== null && !Array.isArray(value)));
|
|
109
|
+
}
|
|
106
110
|
// `undefined` removes the key from the file. `null` is the wire form of
|
|
107
111
|
// "reset to default", accepted for the memory limits and the optional reranker keys.
|
|
108
112
|
const memoryLimit = getMemoryLimitRule(key);
|
|
@@ -229,12 +233,8 @@ let queue = Promise.resolve();
|
|
|
229
233
|
let pending = 0;
|
|
230
234
|
let lastBaseRevision;
|
|
231
235
|
let lastWrittenRevision;
|
|
232
|
-
/**
|
|
233
|
-
|
|
234
|
-
/** A route that owns a separate set of keys passes it; the general save may not touch them. */
|
|
235
|
-
options = {}) {
|
|
236
|
-
if (!edits || !Object.keys(edits).length)
|
|
237
|
-
throw new Error("No settings to save");
|
|
236
|
+
/** Refuse an edit to a key the caller may not write, or a value the key does not accept. */
|
|
237
|
+
function checkEdits(edits, options) {
|
|
238
238
|
for (const [key, value] of Object.entries(edits)) {
|
|
239
239
|
const allowed = options.only ? options.only.has(key) : keys.has(key);
|
|
240
240
|
if (!allowed)
|
|
@@ -265,6 +265,14 @@ options = {}) {
|
|
|
265
265
|
if (missing !== null)
|
|
266
266
|
throw new Error(`Skill folder not found: ${missing}`);
|
|
267
267
|
}
|
|
268
|
+
}
|
|
269
|
+
/** Write only page-editable keys; reject edits based on a stale config revision. */
|
|
270
|
+
export async function writeGlobalConfigKeys(edits, expectedRevision, options = {}) {
|
|
271
|
+
if (!options.derive) {
|
|
272
|
+
if (!edits || !Object.keys(edits).length)
|
|
273
|
+
throw new Error("No settings to save");
|
|
274
|
+
checkEdits(edits, options);
|
|
275
|
+
}
|
|
268
276
|
const wasQueued = pending > 0;
|
|
269
277
|
pending++;
|
|
270
278
|
const save = queue.then(async () => {
|
|
@@ -285,8 +293,15 @@ options = {}) {
|
|
|
285
293
|
if (errors.length || !current || typeof current !== "object" || Array.isArray(current)) {
|
|
286
294
|
throw new Error("Config contains invalid JSONC");
|
|
287
295
|
}
|
|
288
|
-
|
|
289
|
-
|
|
296
|
+
const applied = options.derive ? options.derive(current) : edits;
|
|
297
|
+
if (options.derive) {
|
|
298
|
+
if (!Object.keys(applied).length) {
|
|
299
|
+
return { revision: original.revision, migratedLegacy: false };
|
|
300
|
+
}
|
|
301
|
+
checkEdits(applied, options);
|
|
302
|
+
}
|
|
303
|
+
checkMemorisationSettingsEdits(applied, memorisationSettingsFrom(current));
|
|
304
|
+
for (const [key, value] of Object.entries(applied)) {
|
|
290
305
|
// A nested identifier maps to its JSONC leaf path, so compaction.enabled
|
|
291
306
|
// writes the leaf, never a literal dotted key.
|
|
292
307
|
const jsoncPath = (getMemoryLimitRule(key)?.path ?? key.split("."));
|
|
@@ -21,6 +21,8 @@ export interface MemoryReadOptions {
|
|
|
21
21
|
limit?: number;
|
|
22
22
|
/** Search only: rerank this read whatever the general `rerankerEnabled` switch says. */
|
|
23
23
|
reranker?: boolean;
|
|
24
|
+
/** Search only: with the reranker on, still drop hits below `similarityThreshold`. */
|
|
25
|
+
thresholdAfterRerank?: boolean;
|
|
24
26
|
}
|
|
25
27
|
/** The settings a linked search needs; the caller snapshots them from CONFIG. */
|
|
26
28
|
export interface LinkedSearchSettings {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The providers that serve one OpenRouter model, each with a data policy label.
|
|
3
|
+
* It reads two public OpenRouter lists without an API key: the model's endpoints
|
|
4
|
+
* and the zero data retention (ZDR) endpoints. `fetch` and the clock come in as
|
|
5
|
+
* arguments, so the module needs no config and tests need no network.
|
|
6
|
+
*/
|
|
7
|
+
export type ProviderPolicy = "zdr" | "safety" | "unknown";
|
|
8
|
+
export type ProviderRow = {
|
|
9
|
+
slug: string;
|
|
10
|
+
name: string;
|
|
11
|
+
policy: ProviderPolicy;
|
|
12
|
+
};
|
|
13
|
+
export type ProviderFailure = "unknown-model" | "timeout" | "unreachable";
|
|
14
|
+
export type ProvidersResult = {
|
|
15
|
+
ok: true;
|
|
16
|
+
providers: ProviderRow[];
|
|
17
|
+
} | {
|
|
18
|
+
ok: false;
|
|
19
|
+
reason: ProviderFailure;
|
|
20
|
+
error: string;
|
|
21
|
+
};
|
|
22
|
+
/** True for `author/slug` model IDs. Dot-only parts are refused so the URL cannot climb out of /models/. */
|
|
23
|
+
export declare function isModelId(value: unknown): value is string;
|
|
24
|
+
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
|
25
|
+
export declare function createOpenRouterProviders(deps: {
|
|
26
|
+
fetch: FetchLike;
|
|
27
|
+
now: () => number;
|
|
28
|
+
timeoutMs?: number;
|
|
29
|
+
}): {
|
|
30
|
+
list: (model: string) => Promise<ProvidersResult>;
|
|
31
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The providers that serve one OpenRouter model, each with a data policy label.
|
|
3
|
+
* It reads two public OpenRouter lists without an API key: the model's endpoints
|
|
4
|
+
* and the zero data retention (ZDR) endpoints. `fetch` and the clock come in as
|
|
5
|
+
* arguments, so the module needs no config and tests need no network.
|
|
6
|
+
*/
|
|
7
|
+
const BASE = "https://openrouter.ai/api/v1";
|
|
8
|
+
const ZDR_TTL_MS = 60 * 60 * 1000;
|
|
9
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
10
|
+
const MODEL_ID = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._:-]+$/;
|
|
11
|
+
/** From least private to most private is unknown, safety, zdr. */
|
|
12
|
+
const PRIVACY_RANK = { unknown: 0, safety: 1, zdr: 2 };
|
|
13
|
+
/** True for `author/slug` model IDs. Dot-only parts are refused so the URL cannot climb out of /models/. */
|
|
14
|
+
export function isModelId(value) {
|
|
15
|
+
return (typeof value === "string" &&
|
|
16
|
+
MODEL_ID.test(value) &&
|
|
17
|
+
value.split("/").every((part) => !/^\.+$/.test(part)));
|
|
18
|
+
}
|
|
19
|
+
class ListError extends Error {
|
|
20
|
+
reason;
|
|
21
|
+
constructor(reason, message) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.reason = reason;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const text = (value) => typeof value === "string" && value.trim() ? value : undefined;
|
|
27
|
+
export function createOpenRouterProviders(deps) {
|
|
28
|
+
const timeoutMs = deps.timeoutMs ?? FETCH_TIMEOUT_MS;
|
|
29
|
+
let zdrCache;
|
|
30
|
+
/** One keyless GET. Returns undefined for a 404, so the caller can name the model. */
|
|
31
|
+
async function getJson(url) {
|
|
32
|
+
let response;
|
|
33
|
+
try {
|
|
34
|
+
response = await deps.fetch(url, {
|
|
35
|
+
method: "GET",
|
|
36
|
+
headers: { Accept: "application/json" },
|
|
37
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
const name = error instanceof Error ? error.name : "";
|
|
42
|
+
if (name === "TimeoutError" || name === "AbortError") {
|
|
43
|
+
throw new ListError("timeout", "OpenRouter did not answer within 10 seconds.");
|
|
44
|
+
}
|
|
45
|
+
throw new ListError("unreachable", "OpenRouter could not be reached.");
|
|
46
|
+
}
|
|
47
|
+
if (response.status === 404)
|
|
48
|
+
return undefined;
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
throw new ListError("unreachable", `OpenRouter answered with status ${response.status}.`);
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
return await response.json();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
throw new ListError("unreachable", "OpenRouter sent a reply that could not be read.");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** `model_id` + `tag` -> safety_retention for every ZDR endpoint. Cached for an hour. */
|
|
60
|
+
async function zdrFlags() {
|
|
61
|
+
if (zdrCache && deps.now() - zdrCache.at < ZDR_TTL_MS)
|
|
62
|
+
return zdrCache.flags;
|
|
63
|
+
const body = (await getJson(`${BASE}/endpoints/zdr`));
|
|
64
|
+
if (!body || !Array.isArray(body.data)) {
|
|
65
|
+
throw new ListError("unreachable", "OpenRouter sent a zero data retention list in an unknown form.");
|
|
66
|
+
}
|
|
67
|
+
const flags = new Map();
|
|
68
|
+
for (const row of body.data) {
|
|
69
|
+
const model = text(row?.model_id);
|
|
70
|
+
const tag = text(row?.tag);
|
|
71
|
+
// A missing or non-boolean flag is not stored, so the endpoint stays `unknown`.
|
|
72
|
+
if (model && tag && typeof row.safety_retention === "boolean") {
|
|
73
|
+
flags.set(`${model}\n${tag}`, row.safety_retention);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Only a good answer is cached, so a failed fetch is tried again on the next request.
|
|
77
|
+
zdrCache = { at: deps.now(), flags };
|
|
78
|
+
return flags;
|
|
79
|
+
}
|
|
80
|
+
async function list(model) {
|
|
81
|
+
try {
|
|
82
|
+
const [body, flags] = await Promise.all([
|
|
83
|
+
getJson(`${BASE}/models/${model.split("/").map(encodeURIComponent).join("/")}/endpoints`),
|
|
84
|
+
zdrFlags(),
|
|
85
|
+
]);
|
|
86
|
+
const endpoints = body?.data?.endpoints;
|
|
87
|
+
if (body === undefined || (Array.isArray(endpoints) && endpoints.length === 0)) {
|
|
88
|
+
throw new ListError("unknown-model", "OpenRouter has no providers for that model.");
|
|
89
|
+
}
|
|
90
|
+
if (!Array.isArray(endpoints)) {
|
|
91
|
+
throw new ListError("unreachable", "OpenRouter sent a provider list in an unknown form.");
|
|
92
|
+
}
|
|
93
|
+
const bySlug = new Map();
|
|
94
|
+
for (const endpoint of endpoints) {
|
|
95
|
+
const tag = text(endpoint?.tag);
|
|
96
|
+
const name = text(endpoint?.provider_name);
|
|
97
|
+
const slug = tag ? tag.split("/")[0] : name?.toLowerCase();
|
|
98
|
+
if (!slug)
|
|
99
|
+
continue;
|
|
100
|
+
const modelId = text(endpoint?.model_id);
|
|
101
|
+
const flag = tag && modelId ? flags.get(`${modelId}\n${tag}`) : undefined;
|
|
102
|
+
const policy = flag === undefined ? "unknown" : flag ? "safety" : "zdr";
|
|
103
|
+
const seen = bySlug.get(slug);
|
|
104
|
+
if (!seen) {
|
|
105
|
+
bySlug.set(slug, { slug, name: name ?? slug, policy });
|
|
106
|
+
}
|
|
107
|
+
else if (PRIVACY_RANK[policy] < PRIVACY_RANK[seen.policy]) {
|
|
108
|
+
seen.policy = policy;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const providers = [...bySlug.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
112
|
+
if (providers.length === 0) {
|
|
113
|
+
throw new ListError("unknown-model", "OpenRouter has no providers for that model.");
|
|
114
|
+
}
|
|
115
|
+
return { ok: true, providers };
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (error instanceof ListError)
|
|
119
|
+
return { ok: false, reason: error.reason, error: error.message };
|
|
120
|
+
return { ok: false, reason: "unreachable", error: "OpenRouter could not be reached." };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { list };
|
|
124
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { RouteResult } from "./chat/chat-routes.js";
|
|
2
|
+
/**
|
|
3
|
+
* The OpenRouter routing routes of the External API card: the provider list of a
|
|
4
|
+
* model with data policy labels, and the save of `memoryExtraParams.provider`.
|
|
5
|
+
* Returns undefined for paths it does not own.
|
|
6
|
+
*/
|
|
7
|
+
export declare function routeOpenRouter(path: string, method: string, url: URL, readBody: () => Promise<any>, ctx: {
|
|
8
|
+
cwd: string;
|
|
9
|
+
}): Promise<RouteResult | undefined>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { log } from "./logger.js";
|
|
2
|
+
import { createOpenRouterProviders, isModelId } from "./openrouter-providers.js";
|
|
3
|
+
import { isOpenRouterUrl, parseRouting, removeProviderEdits, routingEdits, } from "./openrouter-routing-config.js";
|
|
4
|
+
const PROVIDERS_PATH = "/api/settings/external-api/openrouter-providers";
|
|
5
|
+
const ROUTING_PATH = "/api/settings/external-api/openrouter-routing";
|
|
6
|
+
/** Reads `globalThis.fetch` at call time, so a test can replace it. */
|
|
7
|
+
const providers = createOpenRouterProviders({
|
|
8
|
+
fetch: (input, init) => globalThis.fetch(input, init),
|
|
9
|
+
now: () => Date.now(),
|
|
10
|
+
});
|
|
11
|
+
const NOT_OPENROUTER = "Save the OpenRouter endpoint first. Routing applies only to an OpenRouter API URL.";
|
|
12
|
+
const fail = (status, error) => ({ status, body: { error } });
|
|
13
|
+
/**
|
|
14
|
+
* The OpenRouter routing routes of the External API card: the provider list of a
|
|
15
|
+
* model with data policy labels, and the save of `memoryExtraParams.provider`.
|
|
16
|
+
* Returns undefined for paths it does not own.
|
|
17
|
+
*/
|
|
18
|
+
export async function routeOpenRouter(path, method, url, readBody, ctx) {
|
|
19
|
+
if (path === PROVIDERS_PATH && method === "GET") {
|
|
20
|
+
const model = url.searchParams.get("model");
|
|
21
|
+
if (!isModelId(model)) {
|
|
22
|
+
return fail(400, "Enter a model ID in the form author/model, for example deepseek/deepseek-chat");
|
|
23
|
+
}
|
|
24
|
+
return { status: 200, body: await providers.list(model) };
|
|
25
|
+
}
|
|
26
|
+
if (path === ROUTING_PATH && method === "POST") {
|
|
27
|
+
const body = await readBody();
|
|
28
|
+
if (typeof body?.revision !== "string")
|
|
29
|
+
return fail(400, "Revision required");
|
|
30
|
+
// `routing: null` removes the whole provider object. `{}` clears the managed fields only.
|
|
31
|
+
const removeAll = body.routing === null;
|
|
32
|
+
const parsed = removeAll ? { ok: true, routing: {} } : parseRouting(body.routing);
|
|
33
|
+
if (!parsed.ok)
|
|
34
|
+
return fail(400, parsed.error);
|
|
35
|
+
const { writeGlobalConfigKeys, ConfigConflictError } = await import("./global-config-writer.js");
|
|
36
|
+
let provider;
|
|
37
|
+
let revision;
|
|
38
|
+
try {
|
|
39
|
+
({ revision } = await writeGlobalConfigKeys({}, body.revision, {
|
|
40
|
+
only: new Set(["memoryExtraParams", "memoryExtraParams.provider"]),
|
|
41
|
+
derive: (current) => {
|
|
42
|
+
// A clearing save is always allowed, so a stale provider can go after a switch of endpoint.
|
|
43
|
+
if (Object.keys(parsed.routing).length > 0 && !isOpenRouterUrl(current.memoryApiUrl)) {
|
|
44
|
+
throw new Error(NOT_OPENROUTER);
|
|
45
|
+
}
|
|
46
|
+
if (removeAll)
|
|
47
|
+
return removeProviderEdits(current.memoryExtraParams);
|
|
48
|
+
const result = routingEdits(current.memoryExtraParams, parsed.routing);
|
|
49
|
+
provider = result.provider;
|
|
50
|
+
return result.edits;
|
|
51
|
+
},
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
return fail(error instanceof ConfigConflictError ? 409 : 400, error instanceof Error ? error.message : "Routing not saved");
|
|
56
|
+
}
|
|
57
|
+
// The file is already saved; a reload problem must not report the save as failed.
|
|
58
|
+
try {
|
|
59
|
+
const { refreshConfigIfChanged } = await import("../config.js");
|
|
60
|
+
refreshConfigIfChanged(ctx.cwd);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
log("Routing saved, but reloading settings failed", { error: error instanceof Error ? error.message : String(error) }, "error");
|
|
64
|
+
}
|
|
65
|
+
return { status: 200, body: { revision, provider: provider ?? null } };
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|