@olares/lares-core 0.15.7 → 0.15.9
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/README.md +2 -1
- package/brand/identity.js +33 -3
- package/drive/cat.js +62 -0
- package/drive/download.js +3 -3
- package/drive/execute.js +8 -2
- package/drive/files-path.js +33 -0
- package/drive/flowstudio-files.js +87 -0
- package/drive/ls.js +3 -3
- package/drive/paths.js +23 -13
- package/drive/tools.js +4 -2
- package/entrance/surface.js +96 -0
- package/files/app-link.js +64 -0
- package/files/backend-client.js +266 -0
- package/files/deliverables.js +55 -1
- package/files/intake.js +0 -63
- package/files/mention.js +1 -51
- package/files/preview-groups.js +71 -3
- package/files/preview-local.js +69 -0
- package/files/preview-workspace.js +114 -13
- package/files/preview.js +171 -16
- package/files/produced-gate.js +197 -0
- package/files/produced-mentions.js +19 -0
- package/files/published-tools.js +69 -0
- package/files/published-turn-data.js +84 -0
- package/i18n/chat-model.js +5 -5
- package/i18n/mcp.js +83 -0
- package/i18n/mobile.js +96 -2
- package/i18n/preview.js +10 -0
- package/i18n/search.js +13 -7
- package/i18n/skills.js +31 -0
- package/i18n/voice.js +88 -19
- package/larepass/chat.js +9 -0
- package/larepass/host.js +7 -2
- package/larepass/mux.js +210 -2
- package/larepass/process-fold.js +239 -0
- package/larepass/queue.js +44 -0
- package/larepass/rpc.js +50 -3
- package/larepass/runtime.js +215 -83
- package/larepass/session-cache.js +6 -0
- package/larepass/settings.js +15 -7
- package/larepass/transcript.js +49 -11
- package/mcp/approval.js +152 -0
- package/mcp/client.js +38 -0
- package/mcp/config.js +139 -0
- package/media/catalog-row.js +244 -0
- package/media/generation.js +707 -0
- package/media/tool.js +92 -0
- package/olares/entrance.js +67 -15
- package/olares/identity.js +13 -136
- package/olares/session-identity.js +12 -12
- package/olares/trusted-host.js +0 -29
- package/package.json +48 -4
- package/router/catalog-cache.d.ts +1 -1
- package/router/catalog-cache.js +25 -14
- package/router/catalog-sync.js +8 -3
- package/router/catalog.d.ts +6 -1
- package/router/catalog.js +18 -2
- package/router/gateway.d.ts +9 -1
- package/router/gateway.js +40 -4
- package/router/gemini-schema.js +125 -0
- package/router/generation-guard.js +82 -0
- package/router/models.d.ts +6 -0
- package/router/models.js +22 -3
- package/router/search.js +10 -2
- package/router/session-model.js +53 -0
- package/router/shim.js +104 -3
- package/router/sref.js +126 -0
- package/router/stt.js +43 -12
- package/router/tts.js +118 -0
- package/search/client.js +5 -3
- package/search/config.js +53 -39
- package/search/menu.js +45 -10
- package/skills/client.js +42 -0
- package/skills/packs.js +40 -0
- package/skills/state.d.ts +22 -0
- package/skills/state.js +142 -0
- package/tools/crypto-uuid.js +3 -6
- package/tools/head-script.js +15 -0
- package/voice/agent.js +50 -0
- package/voice/capture.js +250 -0
- package/voice/client.js +126 -24
- package/voice/config.js +11 -1
- package/voice/duplex.js +44 -0
- package/voice/endpoint.js +57 -0
- package/voice/host/bluetooth-ready.py +1196 -0
- package/voice/host/install-host.sh +102 -0
- package/voice/input.js +4 -0
- package/voice/install.js +146 -0
- package/voice/languages.js +11 -1
- package/voice/link.js +86 -0
- package/voice/pcm.js +100 -0
- package/voice/playback.js +7 -0
- package/voice/problem.js +46 -0
- package/voice/recorder.js +79 -4
- package/voice/script.js +35 -0
- package/voice/service.js +253 -10
- package/voice/session.js +128 -0
- package/voice/speech.js +93 -0
- package/files/preview-cache.js +0 -100
package/router/catalog-sync.js
CHANGED
|
@@ -18,6 +18,8 @@ function messageOf(err) {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
let adapterRevision = 0;
|
|
21
|
+
/** What the last successful refresh wrote, so periodic reconciles stay quiet. */
|
|
22
|
+
let publishedModels = null;
|
|
21
23
|
/** @type {Set<(revision: number) => void>} */
|
|
22
24
|
const revisionListeners = new Set();
|
|
23
25
|
|
|
@@ -45,9 +47,8 @@ function bumpRevision() {
|
|
|
45
47
|
*/
|
|
46
48
|
async function performRefresh(ports) {
|
|
47
49
|
const previous = catalogCache.snapshot();
|
|
48
|
-
catalogCache.invalidate();
|
|
49
50
|
try {
|
|
50
|
-
const { payload } = await catalogCache.get();
|
|
51
|
+
const { payload } = await catalogCache.get({ refresh: true });
|
|
51
52
|
const models = chatModelsFromRouterCatalog(payload);
|
|
52
53
|
await ports.mutateSettings(LLM_SETTINGS_NS, catalogSettingsOps(models));
|
|
53
54
|
const current = ports.currentSelection();
|
|
@@ -55,7 +56,11 @@ async function performRefresh(ports) {
|
|
|
55
56
|
await ports.saveSelection({ provider: ROUTER_PROVIDER_ID, model: pickDefaultModel(models).id });
|
|
56
57
|
}
|
|
57
58
|
forgetSttModel();
|
|
58
|
-
|
|
59
|
+
const serialized = JSON.stringify(models);
|
|
60
|
+
if (serialized !== publishedModels) {
|
|
61
|
+
publishedModels = serialized;
|
|
62
|
+
bumpRevision();
|
|
63
|
+
}
|
|
59
64
|
return models;
|
|
60
65
|
} catch (err) {
|
|
61
66
|
catalogCache.restore(previous);
|
package/router/catalog.d.ts
CHANGED
|
@@ -4,12 +4,17 @@ export interface RouterCatalogRow {
|
|
|
4
4
|
mode: string | null;
|
|
5
5
|
/** Whether Router declares native image input for this chat model. */
|
|
6
6
|
supportsVision: boolean;
|
|
7
|
+
/** Speech recognition, from `stt` / `stt_stream`. */
|
|
8
|
+
supportsStt: boolean;
|
|
9
|
+
/** Speech playback, from `tts`. */
|
|
10
|
+
supportsTts: boolean;
|
|
7
11
|
/** pi-ai level → Router wire spelling, or null when the model takes no effort. */
|
|
8
12
|
reasoningEfforts: Record<string, string> | null;
|
|
9
13
|
/** Tokens one request may span, or null when Router states none. */
|
|
10
14
|
contextWindow: number | null;
|
|
11
|
-
/**
|
|
15
|
+
/** Output tokens a turn may reserve: Router's limit capped at a quarter of the window. */
|
|
12
16
|
maxTokens: number | null;
|
|
13
17
|
}
|
|
14
18
|
|
|
19
|
+
export function outputTokenBudget(declared: number | null, contextWindow: number | null): number | null;
|
|
15
20
|
export function routerCatalogRows(payload: unknown): RouterCatalogRow[];
|
package/router/catalog.js
CHANGED
|
@@ -49,6 +49,19 @@ function tokenCount(value) {
|
|
|
49
49
|
return Number.isInteger(value) && value > 0 ? value : null;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Output tokens a turn may ask for. A local engine reports its whole context
|
|
54
|
+
* as max_output_tokens, and a request that reserves all of it cannot fit
|
|
55
|
+
* beside its own prompt: the engine's KV budget refuses it or, worse, admits
|
|
56
|
+
* it and evicts every other request. A quarter of the window leaves room for
|
|
57
|
+
* the prompt and still exceeds any reply a chat turn writes.
|
|
58
|
+
*/
|
|
59
|
+
export function outputTokenBudget(declared, contextWindow) {
|
|
60
|
+
if (contextWindow === null) return declared;
|
|
61
|
+
const cap = Math.max(1, Math.floor(contextWindow / 4));
|
|
62
|
+
return declared === null ? cap : Math.min(declared, cap);
|
|
63
|
+
}
|
|
64
|
+
|
|
52
65
|
export function routerCatalogRows(payload) {
|
|
53
66
|
if (!payload || typeof payload !== "object" || !Array.isArray(payload.data)) return [];
|
|
54
67
|
const rows = [];
|
|
@@ -59,14 +72,17 @@ export function routerCatalogRows(payload) {
|
|
|
59
72
|
if (!SAFE_MODEL_ID.test(id) || seen.has(id)) continue;
|
|
60
73
|
seen.add(id);
|
|
61
74
|
const flags = capabilities(item);
|
|
75
|
+
const contextWindow = tokenCount(item.context_size);
|
|
62
76
|
rows.push({
|
|
63
77
|
id,
|
|
64
78
|
name: id,
|
|
65
79
|
mode: String(item.mode ?? "").trim().toLowerCase() || null,
|
|
66
80
|
supportsVision: flags.has("vision"),
|
|
81
|
+
supportsStt: flags.has("stt") || flags.has("stt_stream"),
|
|
82
|
+
supportsTts: flags.has("tts"),
|
|
67
83
|
reasoningEfforts: reasoningEfforts(item, flags),
|
|
68
|
-
contextWindow
|
|
69
|
-
maxTokens: tokenCount(item.max_output_tokens),
|
|
84
|
+
contextWindow,
|
|
85
|
+
maxTokens: outputTokenBudget(tokenCount(item.max_output_tokens), contextWindow),
|
|
70
86
|
});
|
|
71
87
|
}
|
|
72
88
|
return rows;
|
package/router/gateway.d.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
export function routerGatewayUrl(env?: NodeJS.ProcessEnv): string;
|
|
2
2
|
export function routerShimBaseUrl(env?: NodeJS.ProcessEnv): string;
|
|
3
|
-
export function
|
|
3
|
+
export function routerEndUser(
|
|
4
|
+
env?: NodeJS.ProcessEnv,
|
|
5
|
+
incoming?: Record<string, string | string[] | undefined>,
|
|
6
|
+
): string;
|
|
7
|
+
export function routerAuthHeaders(
|
|
8
|
+
apiKey: string | null | undefined,
|
|
9
|
+
olaresAppId: string | null | undefined,
|
|
10
|
+
olaresUser?: string | null,
|
|
11
|
+
): Record<string, string>;
|
|
4
12
|
export function routerHeaders(env?: NodeJS.ProcessEnv): Record<string, string>;
|
package/router/gateway.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { olaresUsername } from "../olares/identity.js";
|
|
2
|
+
import { getLatestIdentity } from "../olares/session-identity.js";
|
|
3
|
+
|
|
1
4
|
export function routerGatewayUrl(env = process.env) {
|
|
2
5
|
return (env.LLM_GATEWAY_URL ?? "http://router-svc.router-shared/v1").replace(/\/+$/, "");
|
|
3
6
|
}
|
|
@@ -8,14 +11,47 @@ export function routerShimBaseUrl(env = process.env) {
|
|
|
8
11
|
return `http://127.0.0.1:${env.PORT ?? 8080}/llm/v1`;
|
|
9
12
|
}
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
/**
|
|
15
|
+
* End user for Router/FlowStudio: the person logged into this Lares, not the
|
|
16
|
+
* shared FlowStudio chart owner. Session identity wins; chart install user is
|
|
17
|
+
* the fallback for background fetches with no request.
|
|
18
|
+
*/
|
|
19
|
+
export function routerEndUser(env = process.env, incoming = {}) {
|
|
20
|
+
const fromHeaders = incomingUser(incoming);
|
|
21
|
+
if (fromHeaders) return fromHeaders;
|
|
22
|
+
const fromSession = olaresUsername(getLatestIdentity()?.user);
|
|
23
|
+
if (fromSession) return fromSession;
|
|
24
|
+
return olaresUsername(env.OLARES_USERNAME || env.BFL_USERNAME || "");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function incomingUser(incoming) {
|
|
28
|
+
if (!incoming || typeof incoming !== "object") return "";
|
|
29
|
+
const get = (name) => {
|
|
30
|
+
const raw = incoming[name] ?? incoming[name.toLowerCase()];
|
|
31
|
+
if (Array.isArray(raw)) return raw[0] ?? "";
|
|
32
|
+
return raw ?? "";
|
|
33
|
+
};
|
|
34
|
+
return olaresUsername(
|
|
35
|
+
get("x-bfl-user") || get("remote-user") || get("authelia-remote-user") || "",
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function routerAuthHeaders(apiKey, olaresAppId, olaresUser) {
|
|
40
|
+
/** @type {Record<string, string>} */
|
|
41
|
+
const headers = apiKey?.trim()
|
|
42
|
+
? { authorization: `Bearer ${apiKey.trim()}` }
|
|
43
|
+
: { "x-caller-appid": olaresAppId?.trim() || "lares" };
|
|
44
|
+
const user = olaresUsername(olaresUser);
|
|
45
|
+
if (user) {
|
|
46
|
+
headers["x-bfl-user"] = user;
|
|
47
|
+
headers["remote-user"] = user;
|
|
48
|
+
}
|
|
49
|
+
return headers;
|
|
14
50
|
}
|
|
15
51
|
|
|
16
52
|
export function routerHeaders(env = process.env) {
|
|
17
53
|
return {
|
|
18
|
-
...routerAuthHeaders(env.LARES_ROUTER_API_KEY, env.OLARES_APP_ID),
|
|
54
|
+
...routerAuthHeaders(env.LARES_ROUTER_API_KEY, env.OLARES_APP_ID, routerEndUser(env)),
|
|
19
55
|
accept: "application/json",
|
|
20
56
|
};
|
|
21
57
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini's OpenAI-compat endpoint copies each tool's `parameters` onto the
|
|
3
|
+
* Generative Language Schema proto. That proto has no `additionalProperties`,
|
|
4
|
+
* `oneOf`, `const`, or OpenAI `strict`, so a normal tool list 400s before the
|
|
5
|
+
* model speaks. Full JSON Schema stays intact for every other model on the
|
|
6
|
+
* same route.
|
|
7
|
+
*
|
|
8
|
+
* Allowed fields are the Schema message:
|
|
9
|
+
* https://ai.google.dev/api/caching#Schema
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const SCHEMA_FIELDS = new Set([
|
|
13
|
+
"anyOf",
|
|
14
|
+
"default",
|
|
15
|
+
"description",
|
|
16
|
+
"enum",
|
|
17
|
+
"example",
|
|
18
|
+
"format",
|
|
19
|
+
"items",
|
|
20
|
+
"maxItems",
|
|
21
|
+
"maxLength",
|
|
22
|
+
"maxProperties",
|
|
23
|
+
"maximum",
|
|
24
|
+
"minItems",
|
|
25
|
+
"minLength",
|
|
26
|
+
"minProperties",
|
|
27
|
+
"minimum",
|
|
28
|
+
"nullable",
|
|
29
|
+
"pattern",
|
|
30
|
+
"properties",
|
|
31
|
+
"propertyOrdering",
|
|
32
|
+
"required",
|
|
33
|
+
"title",
|
|
34
|
+
"type",
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
/** @param {unknown} model */
|
|
38
|
+
export function isGeminiModel(model) {
|
|
39
|
+
return String(model ?? "")
|
|
40
|
+
.split(/[/@]/)
|
|
41
|
+
.some((part) => part.toLowerCase().startsWith("gemini"));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {unknown} schema
|
|
46
|
+
* @returns {unknown}
|
|
47
|
+
*/
|
|
48
|
+
export function geminiSchema(schema) {
|
|
49
|
+
if (Array.isArray(schema)) return schema.map((item) => geminiSchema(item));
|
|
50
|
+
if (schema == null || typeof schema !== "object") return schema;
|
|
51
|
+
|
|
52
|
+
const source = { ...schema };
|
|
53
|
+
if (source.const !== undefined && source.enum === undefined) {
|
|
54
|
+
source.enum = [source.const];
|
|
55
|
+
}
|
|
56
|
+
if (source.oneOf !== undefined && source.anyOf === undefined) {
|
|
57
|
+
source.anyOf = source.oneOf;
|
|
58
|
+
}
|
|
59
|
+
if (Array.isArray(source.type)) {
|
|
60
|
+
const types = source.type.filter((item) => item !== "null");
|
|
61
|
+
if (source.type.includes("null")) source.nullable = true;
|
|
62
|
+
if (types.length === 1) source.type = types[0];
|
|
63
|
+
else if (types.length > 1 && source.anyOf === undefined) {
|
|
64
|
+
source.anyOf = types.map((type) => ({ type }));
|
|
65
|
+
delete source.type;
|
|
66
|
+
} else {
|
|
67
|
+
delete source.type;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** @type {Record<string, unknown>} */
|
|
72
|
+
const out = {};
|
|
73
|
+
for (const [key, value] of Object.entries(source)) {
|
|
74
|
+
if (!SCHEMA_FIELDS.has(key)) continue;
|
|
75
|
+
if (key === "properties" && value && typeof value === "object" && !Array.isArray(value)) {
|
|
76
|
+
/** @type {Record<string, unknown>} */
|
|
77
|
+
const properties = {};
|
|
78
|
+
for (const [name, property] of Object.entries(value)) properties[name] = geminiSchema(property);
|
|
79
|
+
out.properties = properties;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (key === "items") {
|
|
83
|
+
out.items = geminiSchema(value);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (key === "anyOf" && Array.isArray(value)) {
|
|
87
|
+
out.anyOf = value.map((item) => geminiSchema(item));
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
out[key] = value;
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @param {unknown} payload
|
|
97
|
+
* @returns {unknown}
|
|
98
|
+
*/
|
|
99
|
+
export function adaptGeminiChatBody(payload) {
|
|
100
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return payload;
|
|
101
|
+
if (!isGeminiModel(payload.model) || !Array.isArray(payload.tools)) return payload;
|
|
102
|
+
return {
|
|
103
|
+
...payload,
|
|
104
|
+
tools: payload.tools.map((tool) => {
|
|
105
|
+
if (!tool || tool.type !== "function" || !tool.function || typeof tool.function !== "object") return tool;
|
|
106
|
+
const fn = { ...tool.function };
|
|
107
|
+
delete fn.strict;
|
|
108
|
+
if (fn.parameters && typeof fn.parameters === "object") fn.parameters = geminiSchema(fn.parameters);
|
|
109
|
+
return { ...tool, function: fn };
|
|
110
|
+
}),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** @param {Buffer} body @returns {Buffer} */
|
|
115
|
+
export function adaptGeminiChatBuffer(body) {
|
|
116
|
+
let payload;
|
|
117
|
+
try {
|
|
118
|
+
payload = JSON.parse(body.toString("utf8"));
|
|
119
|
+
} catch {
|
|
120
|
+
return body;
|
|
121
|
+
}
|
|
122
|
+
const next = adaptGeminiChatBody(payload);
|
|
123
|
+
if (next === payload) return body;
|
|
124
|
+
return Buffer.from(JSON.stringify(next));
|
|
125
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep the agent from generating on FlowStudio around the in-process shim.
|
|
3
|
+
*
|
|
4
|
+
* Only the shim (`$LARES_LLM_BASE_URL`, also what `olares-cli router call` and
|
|
5
|
+
* the `media_generate` tool use) stamps the logged-in user as an encrypted
|
|
6
|
+
* `sref`. A shell call straight to Router's data plane or to FlowStudio lands
|
|
7
|
+
* the job, and its files, under the shared chart owner instead, where this
|
|
8
|
+
* user cannot open them. This is a text check on what the agent is about to
|
|
9
|
+
* run: it stops the obvious routes the skills used to allow, and the skills
|
|
10
|
+
* say the same thing in words.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const GENERATION_PATH =
|
|
14
|
+
/\/(?:v1\/)?(?:images\/generations|images\/edits|videos|music\/generations|generations)(?![a-z0-9_-])/i;
|
|
15
|
+
|
|
16
|
+
/** FlowStudio's own API or service, which no skill has a reason to call directly. */
|
|
17
|
+
const FLOWSTUDIO_DIRECT = [
|
|
18
|
+
/flowstudio-svc/i,
|
|
19
|
+
/flowstudio-shared/i,
|
|
20
|
+
/\/api\/v1\/generations/i,
|
|
21
|
+
/\/api\/jobs(?![a-z0-9_-])/i,
|
|
22
|
+
/\/api\/projects\/[^\s"']*\/(?:run|jobs|generate)/i,
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const ROUTER_VARIABLE = /\$\{?LLM_GATEWAY_URL\}?/;
|
|
26
|
+
const ROUTER_SERVICE = /router-svc(?:\.router-shared)?/i;
|
|
27
|
+
|
|
28
|
+
export const GENERATION_BYPASS_REASON =
|
|
29
|
+
"Generate through the media_generate tool or $LARES_LLM_BASE_URL (the in-process Router shim) only. " +
|
|
30
|
+
"A direct call to Router's data plane or to FlowStudio files the job under the shared app owner, " +
|
|
31
|
+
"so this user can neither see the job nor open its files. Do not retry another way; " +
|
|
32
|
+
"if the shim is unavailable, tell the user generation is unavailable right now.";
|
|
33
|
+
|
|
34
|
+
function routerHosts(env) {
|
|
35
|
+
const hosts = [];
|
|
36
|
+
try {
|
|
37
|
+
const raw = env.LLM_GATEWAY_URL?.trim();
|
|
38
|
+
if (raw) hosts.push(new URL(raw).host.toLowerCase());
|
|
39
|
+
} catch {
|
|
40
|
+
// An unparsable gateway URL only removes one pattern; the others still apply.
|
|
41
|
+
}
|
|
42
|
+
return hosts;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function mentionsRouter(text, env) {
|
|
46
|
+
if (ROUTER_VARIABLE.test(text) || ROUTER_SERVICE.test(text)) return true;
|
|
47
|
+
const lower = text.toLowerCase();
|
|
48
|
+
return routerHosts(env).some((host) => lower.includes(host));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {string} text a shell command, code snippet, or URL the agent will run
|
|
53
|
+
* @returns {string | null} the refusal reason, or null when the text is fine
|
|
54
|
+
*/
|
|
55
|
+
export function detectGenerationBypass(text, env = process.env) {
|
|
56
|
+
if (typeof text !== "string" || text.trim() === "") return null;
|
|
57
|
+
if (FLOWSTUDIO_DIRECT.some((pattern) => pattern.test(text))) return GENERATION_BYPASS_REASON;
|
|
58
|
+
if (mentionsRouter(text, env) && GENERATION_PATH.test(text)) return GENERATION_BYPASS_REASON;
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Tools whose arguments are something the agent executes or fetches. */
|
|
63
|
+
const GUARDED_TOOLS = new Map([
|
|
64
|
+
["bash", ["command"]],
|
|
65
|
+
["pwsh", ["command"]],
|
|
66
|
+
["web_fetch", ["url"]],
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Pre-execute decision for one tool call; `prior` is what later listeners decided.
|
|
71
|
+
* @returns {{ kind: string, reason?: string }}
|
|
72
|
+
*/
|
|
73
|
+
export function decideGenerationGuard({ name, args, prior }, env = process.env) {
|
|
74
|
+
if (prior && prior.kind === "deny") return prior;
|
|
75
|
+
const fields = GUARDED_TOOLS.get(name);
|
|
76
|
+
if (!fields || args == null || typeof args !== "object") return prior;
|
|
77
|
+
for (const field of fields) {
|
|
78
|
+
const reason = detectGenerationBypass(args[field], env);
|
|
79
|
+
if (reason) return { kind: "deny", reason };
|
|
80
|
+
}
|
|
81
|
+
return prior;
|
|
82
|
+
}
|
package/router/models.d.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { RouterCatalogRow } from "./catalog.js";
|
|
2
2
|
|
|
3
3
|
export type RouterModelEntry = RouterCatalogRow;
|
|
4
|
+
export const ROUTER_RETRY_POLICY: Readonly<{
|
|
5
|
+
mode: "normal";
|
|
6
|
+
maxRetries: number;
|
|
7
|
+
retryableCodes: string[];
|
|
8
|
+
backoff: { initialDelayMs: number; maxDelayMs: number; jitterRatio: number };
|
|
9
|
+
}>;
|
|
4
10
|
export function isChatModelId(id: string): boolean;
|
|
5
11
|
export function isChatModel(model: RouterModelEntry): boolean;
|
|
6
12
|
export function modelsFromRouterCatalog(payload: unknown): RouterModelEntry[];
|
package/router/models.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { writeCatalogSeed } from "./catalog-cache.js";
|
|
2
2
|
import { routerCatalogRows } from "./catalog.js";
|
|
3
|
-
import { routerAuthHeaders, routerGatewayUrl, routerShimBaseUrl } from "./gateway.js";
|
|
3
|
+
import { routerAuthHeaders, routerEndUser, routerGatewayUrl, routerShimBaseUrl } from "./gateway.js";
|
|
4
4
|
|
|
5
5
|
export const ROUTER_PROVIDER_ID = "olares-router";
|
|
6
6
|
export const LLM_SETTINGS_NS = "llm-pi-ai";
|
|
@@ -8,6 +8,21 @@ export const LLM_SETTINGS_NS = "llm-pi-ai";
|
|
|
8
8
|
const NON_CHAT_HINTS = /embed|whisper|tts|speech|ocr|clip|stt|asr|transcri/i;
|
|
9
9
|
const PLACEHOLDER_MODEL = /^(?:default|deepseek-v4-(?:flash|pro))$/i;
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Model-request retries on the Router route. The dsh default gives up after
|
|
13
|
+
* five tries and refuses any Retry-After over ten seconds, which is shorter
|
|
14
|
+
* than a local single-slot engine stays busy with a title or compaction
|
|
15
|
+
* request, so a turn failed on a model that was about to be free. Waits here
|
|
16
|
+
* reach a minute each and several minutes in total; every wait is an
|
|
17
|
+
* `llm/retry` event the conversation shows while it lasts.
|
|
18
|
+
*/
|
|
19
|
+
export const ROUTER_RETRY_POLICY = Object.freeze({
|
|
20
|
+
mode: "normal",
|
|
21
|
+
maxRetries: 12,
|
|
22
|
+
retryableCodes: ["EMPTY_RESPONSE", "RATE_LIMIT", "SERVER", "TIMEOUT", "TRANSPORT"],
|
|
23
|
+
backoff: { initialDelayMs: 1_000, maxDelayMs: 60_000, jitterRatio: 0.1 },
|
|
24
|
+
});
|
|
25
|
+
|
|
11
26
|
export function isChatModelId(id) {
|
|
12
27
|
return !NON_CHAT_HINTS.test(id);
|
|
13
28
|
}
|
|
@@ -73,10 +88,14 @@ export async function fetchChatModels() {
|
|
|
73
88
|
*/
|
|
74
89
|
export async function fetchRouterModels(env) {
|
|
75
90
|
const routerUrl = String(env.routerUrl ?? "").replace(/\/+$/, "") || routerGatewayUrl();
|
|
76
|
-
const res = await fetch(`${routerUrl}/models
|
|
91
|
+
const res = await fetch(`${routerUrl}/models`, {
|
|
77
92
|
method: "GET",
|
|
78
93
|
headers: {
|
|
79
|
-
...routerAuthHeaders(
|
|
94
|
+
...routerAuthHeaders(
|
|
95
|
+
env.routerApiKey,
|
|
96
|
+
env.olaresAppId,
|
|
97
|
+
env.olaresUsername || routerEndUser(),
|
|
98
|
+
),
|
|
80
99
|
accept: "application/json",
|
|
81
100
|
},
|
|
82
101
|
signal: AbortSignal.timeout(15_000),
|
package/router/search.js
CHANGED
|
@@ -22,6 +22,13 @@ export function searchWebErrorCode(code) {
|
|
|
22
22
|
return SEARCH_WEB_CODES[code] ?? "WEB_PROVIDER_ERROR";
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Router's own default-search category. Sending it defers the choice to the
|
|
27
|
+
* Router console, which is where the search services are configured; Router
|
|
28
|
+
* refuses it outright when nothing stands behind it.
|
|
29
|
+
*/
|
|
30
|
+
export const ROUTER_DEFAULT_SEARCH_ROUTE = "default-search";
|
|
31
|
+
|
|
25
32
|
export function searchModelsFromRouterCatalog(payload) {
|
|
26
33
|
return routerCatalogRows(payload)
|
|
27
34
|
.filter((model) => model.mode === "search")
|
|
@@ -57,8 +64,9 @@ export function searchSourcesFromRouter(payload) {
|
|
|
57
64
|
|
|
58
65
|
const DEFAULT_TIMEOUT_MS = 20_000;
|
|
59
66
|
|
|
60
|
-
|
|
61
|
-
|
|
67
|
+
/** @param {{ refresh?: boolean }} [options] */
|
|
68
|
+
export async function fetchRouterSearchModels(options = {}) {
|
|
69
|
+
const { payload } = await catalogCache.get(options);
|
|
62
70
|
return searchModelsFromRouterCatalog(payload);
|
|
63
71
|
}
|
|
64
72
|
|
package/router/session-model.js
CHANGED
|
@@ -117,6 +117,59 @@ export function effortMenuItems(reasoning) {
|
|
|
117
117
|
return items;
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/** The selection the composer shows: the session's pending choice, else the catalog default. */
|
|
121
|
+
export function sessionModelSelection(projection, catalogDefault) {
|
|
122
|
+
const next = projection?.next;
|
|
123
|
+
if (next && typeof next.provider === "string" && typeof next.model === "string") return next;
|
|
124
|
+
if (
|
|
125
|
+
catalogDefault
|
|
126
|
+
&& typeof catalogDefault.provider === "string"
|
|
127
|
+
&& typeof catalogDefault.model === "string"
|
|
128
|
+
) {
|
|
129
|
+
return catalogDefault;
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* `session.modelCatalog` → the flat list the composer already renders.
|
|
136
|
+
* Reasoning stays on the model; that is the same metadata the desktop menu reads.
|
|
137
|
+
*/
|
|
138
|
+
export function composerModelsFromCatalog(catalog, selection) {
|
|
139
|
+
if (!catalog || typeof catalog !== "object") return null;
|
|
140
|
+
const models = [];
|
|
141
|
+
for (const group of Array.isArray(catalog.groups) ? catalog.groups : []) {
|
|
142
|
+
const provider = typeof group?.id === "string" ? group.id : "";
|
|
143
|
+
if (!provider) continue;
|
|
144
|
+
for (const model of group.models ?? []) {
|
|
145
|
+
if (!model?.id) continue;
|
|
146
|
+
models.push({
|
|
147
|
+
provider,
|
|
148
|
+
providerName: group.name || provider,
|
|
149
|
+
id: model.id,
|
|
150
|
+
name: model.name || model.id,
|
|
151
|
+
...(model.description ? { description: model.description } : {}),
|
|
152
|
+
...(model.reasoning ? { reasoning: model.reasoning } : {}),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const current = selection ?? null;
|
|
157
|
+
const routable = Boolean(
|
|
158
|
+
current
|
|
159
|
+
&& models.some((model) => model.provider === current.provider && model.id === current.model),
|
|
160
|
+
);
|
|
161
|
+
return {
|
|
162
|
+
default: current,
|
|
163
|
+
routable,
|
|
164
|
+
failures: (Array.isArray(catalog.failures) ? catalog.failures : []).map((entry) => ({
|
|
165
|
+
provider: entry.provider ?? entry.id,
|
|
166
|
+
name: entry.name,
|
|
167
|
+
message: entry.message,
|
|
168
|
+
})),
|
|
169
|
+
models,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
120
173
|
export function selectionKey(selection) {
|
|
121
174
|
return `${selection.provider}\u0000${selection.model ?? selection.id}`;
|
|
122
175
|
}
|
package/router/shim.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { request as httpRequest } from "node:http";
|
|
2
2
|
import { request as httpsRequest } from "node:https";
|
|
3
3
|
import { readBody, sendError, sendJson } from "../tools/http.js";
|
|
4
|
+
import { attachFlowstudioFilesPaths } from "../drive/flowstudio-files.js";
|
|
4
5
|
import { catalogCache } from "./catalog-cache.js";
|
|
5
6
|
import { carriesWebpImage, transcodeWebpImages } from "../media/router-images.js";
|
|
6
|
-
import { routerAuthHeaders, routerGatewayUrl } from "./gateway.js";
|
|
7
|
+
import { routerAuthHeaders, routerEndUser, routerGatewayUrl } from "./gateway.js";
|
|
7
8
|
import { STT_MAX_AUDIO_BYTES } from "./stt.js";
|
|
9
|
+
import { adaptGeminiChatBuffer } from "./gemini-schema.js";
|
|
10
|
+
import { isGenerationCreateRoute, withSrefBuffer } from "./sref.js";
|
|
8
11
|
|
|
9
12
|
export const SHIM_PATH = "/llm/v1";
|
|
10
13
|
export const SHIM_CHAT_TIMEOUT_MS = 120_000;
|
|
@@ -33,6 +36,11 @@ export function isAudioShimPath(suffix) {
|
|
|
33
36
|
return /(^|\/)audio(\/|$)/.test(suffix);
|
|
34
37
|
}
|
|
35
38
|
|
|
39
|
+
/** GET /generations/:id — not /content, not create. */
|
|
40
|
+
export function isGenerationPoll(suffix) {
|
|
41
|
+
return /^generations\/[^/]+$/.test(String(suffix ?? "").replace(/\/+$/, ""));
|
|
42
|
+
}
|
|
43
|
+
|
|
36
44
|
export function shimBudget(suffix) {
|
|
37
45
|
const audio = isAudioShimPath(suffix);
|
|
38
46
|
return {
|
|
@@ -53,7 +61,11 @@ export function shimRequestHeaders(incoming, env = process.env) {
|
|
|
53
61
|
}
|
|
54
62
|
Object.assign(
|
|
55
63
|
headers,
|
|
56
|
-
routerAuthHeaders(
|
|
64
|
+
routerAuthHeaders(
|
|
65
|
+
env.LARES_ROUTER_API_KEY?.trim() || null,
|
|
66
|
+
env.OLARES_APP_ID?.trim() || "lares",
|
|
67
|
+
routerEndUser(env, incoming),
|
|
68
|
+
),
|
|
57
69
|
);
|
|
58
70
|
return headers;
|
|
59
71
|
}
|
|
@@ -139,7 +151,78 @@ function downstreamCancellation(req, res) {
|
|
|
139
151
|
return controller;
|
|
140
152
|
}
|
|
141
153
|
|
|
142
|
-
|
|
154
|
+
function isJsonContentType(value) {
|
|
155
|
+
return String(value ?? "").toLowerCase().includes("application/json");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function collectStream(stream, maxBytes, signal) {
|
|
159
|
+
return new Promise((resolve, reject) => {
|
|
160
|
+
const chunks = [];
|
|
161
|
+
let total = 0;
|
|
162
|
+
const onAbort = () => {
|
|
163
|
+
stream.destroy();
|
|
164
|
+
reject(signal?.reason instanceof Error ? signal.reason : new Error("aborted"));
|
|
165
|
+
};
|
|
166
|
+
if (signal?.aborted) {
|
|
167
|
+
onAbort();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
171
|
+
stream.on("data", (chunk) => {
|
|
172
|
+
total += chunk.length;
|
|
173
|
+
if (total > maxBytes) {
|
|
174
|
+
stream.destroy();
|
|
175
|
+
reject(new Error("generation poll body exceeded the read limit"));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
chunks.push(chunk);
|
|
179
|
+
});
|
|
180
|
+
stream.on("end", () => {
|
|
181
|
+
signal?.removeEventListener("abort", onAbort);
|
|
182
|
+
resolve(Buffer.concat(chunks));
|
|
183
|
+
});
|
|
184
|
+
stream.on("error", (err) => {
|
|
185
|
+
signal?.removeEventListener("abort", onAbort);
|
|
186
|
+
reject(err);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function enrichGenerationPoll(up, res, req, env, cancellation, deps) {
|
|
192
|
+
const status = up.statusCode ?? 502;
|
|
193
|
+
const headers = shimResponseHeaders(up.headers);
|
|
194
|
+
let raw;
|
|
195
|
+
try {
|
|
196
|
+
raw = await collectStream(up, SHIM_CHAT_MAX_BYTES, cancellation.signal);
|
|
197
|
+
} catch (err) {
|
|
198
|
+
if (cancellation.signal.aborted || res.headersSent) return;
|
|
199
|
+
sendError(res, err, "llm_proxy_failed");
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (cancellation.signal.aborted || res.headersSent) return;
|
|
203
|
+
const json = status === 200 && isJsonContentType(headers["content-type"] ?? up.headers["content-type"]);
|
|
204
|
+
if (!json) {
|
|
205
|
+
res.writeHead(status, headers);
|
|
206
|
+
res.end(raw);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
let payload;
|
|
210
|
+
try {
|
|
211
|
+
payload = JSON.parse(raw.toString("utf8"));
|
|
212
|
+
} catch {
|
|
213
|
+
res.writeHead(status, headers);
|
|
214
|
+
res.end(raw);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const enriched = await attachFlowstudioFilesPaths(payload, routerEndUser(env, req.headers), {
|
|
218
|
+
signal: cancellation.signal,
|
|
219
|
+
cat: deps.catFiles,
|
|
220
|
+
});
|
|
221
|
+
if (cancellation.signal.aborted || res.headersSent) return;
|
|
222
|
+
sendJson(res, status, enriched);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function proxyToRouter(req, res, env = process.env, deps = {}) {
|
|
143
226
|
if (isModelsGet(req)) {
|
|
144
227
|
serveCachedModels(req, res);
|
|
145
228
|
return;
|
|
@@ -154,6 +237,7 @@ export function proxyToRouter(req, res, env = process.env) {
|
|
|
154
237
|
const transport = target.protocol === "https:" ? httpsRequest : httpRequest;
|
|
155
238
|
const method = (req.method ?? "GET").toUpperCase();
|
|
156
239
|
const cancellation = downstreamCancellation(req, res);
|
|
240
|
+
const enrichPoll = method === "GET" && isGenerationPoll(suffix);
|
|
157
241
|
|
|
158
242
|
const run = async () => {
|
|
159
243
|
let body;
|
|
@@ -163,6 +247,12 @@ export function proxyToRouter(req, res, env = process.env) {
|
|
|
163
247
|
message: budget.tooLargeMessage,
|
|
164
248
|
});
|
|
165
249
|
if (!budget.audio && carriesWebpImage(body)) body = await transcodeWebpImages(body);
|
|
250
|
+
if (method === "POST" && isGenerationCreateRoute(suffix)) {
|
|
251
|
+
body = withSrefBuffer(body, routerEndUser(env, req.headers), env);
|
|
252
|
+
}
|
|
253
|
+
if (method === "POST" && suffix.replace(/\/+$/, "") === "chat/completions") {
|
|
254
|
+
body = adaptGeminiChatBuffer(body);
|
|
255
|
+
}
|
|
166
256
|
headers["content-length"] = String(body.length);
|
|
167
257
|
}
|
|
168
258
|
if (cancellation.signal.aborted) return;
|
|
@@ -175,6 +265,17 @@ export function proxyToRouter(req, res, env = process.env) {
|
|
|
175
265
|
up.destroy();
|
|
176
266
|
return;
|
|
177
267
|
}
|
|
268
|
+
if (enrichPoll) {
|
|
269
|
+
void enrichGenerationPoll(up, res, req, env, cancellation, deps).catch((err) => {
|
|
270
|
+
if (cancellation.signal.aborted) return;
|
|
271
|
+
if (res.headersSent) {
|
|
272
|
+
res.destroy();
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
sendError(res, err, "llm_proxy_failed");
|
|
276
|
+
});
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
178
279
|
res.writeHead(up.statusCode ?? 502, shimResponseHeaders(up.headers));
|
|
179
280
|
up.pipe(res);
|
|
180
281
|
},
|