@olares/lares-core 0.15.7 → 0.15.10

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.
Files changed (100) hide show
  1. package/README.md +2 -1
  2. package/brand/identity.js +33 -3
  3. package/drive/cat.js +62 -0
  4. package/drive/download.js +3 -3
  5. package/drive/execute.js +8 -2
  6. package/drive/files-path.js +33 -0
  7. package/drive/flowstudio-files.js +87 -0
  8. package/drive/ls.js +3 -3
  9. package/drive/paths.js +23 -13
  10. package/drive/tools.js +4 -2
  11. package/entrance/surface.js +96 -0
  12. package/files/app-link.js +64 -0
  13. package/files/backend-client.js +266 -0
  14. package/files/deliverables.js +55 -1
  15. package/files/intake.js +0 -63
  16. package/files/mention.js +1 -51
  17. package/files/preview-groups.js +71 -3
  18. package/files/preview-local.js +69 -0
  19. package/files/preview-workspace.js +114 -13
  20. package/files/preview.js +171 -16
  21. package/files/produced-gate.js +197 -0
  22. package/files/produced-mentions.js +19 -0
  23. package/files/published-tools.js +69 -0
  24. package/files/published-turn-data.js +84 -0
  25. package/i18n/chat-model.js +5 -5
  26. package/i18n/mcp.js +83 -0
  27. package/i18n/mobile.js +96 -2
  28. package/i18n/preview.js +10 -0
  29. package/i18n/search.js +13 -7
  30. package/i18n/skills.js +31 -0
  31. package/i18n/voice.js +88 -19
  32. package/larepass/chat.js +9 -0
  33. package/larepass/host.js +7 -2
  34. package/larepass/mux.js +210 -2
  35. package/larepass/process-fold.js +239 -0
  36. package/larepass/queue.js +44 -0
  37. package/larepass/rpc.js +50 -3
  38. package/larepass/runtime.js +215 -83
  39. package/larepass/session-cache.js +6 -0
  40. package/larepass/settings.js +15 -7
  41. package/larepass/transcript.js +49 -11
  42. package/log/report.js +46 -0
  43. package/mcp/approval.js +152 -0
  44. package/mcp/client.js +38 -0
  45. package/mcp/config.js +139 -0
  46. package/media/catalog-row.js +244 -0
  47. package/media/generation.js +707 -0
  48. package/media/tool.js +92 -0
  49. package/olares/entrance.js +67 -15
  50. package/olares/identity.js +13 -136
  51. package/olares/session-identity.js +12 -12
  52. package/olares/trusted-host.js +0 -29
  53. package/package.json +49 -4
  54. package/router/catalog-cache.d.ts +1 -1
  55. package/router/catalog-cache.js +25 -14
  56. package/router/catalog-sync.js +8 -3
  57. package/router/catalog.d.ts +6 -1
  58. package/router/catalog.js +18 -2
  59. package/router/gateway.d.ts +9 -1
  60. package/router/gateway.js +40 -4
  61. package/router/gemini-schema.js +125 -0
  62. package/router/generation-guard.js +82 -0
  63. package/router/models.d.ts +6 -0
  64. package/router/models.js +22 -3
  65. package/router/search.js +10 -2
  66. package/router/session-model.js +53 -0
  67. package/router/shim.js +104 -3
  68. package/router/sref.js +126 -0
  69. package/router/stt.js +43 -12
  70. package/router/tts.js +118 -0
  71. package/search/client.js +5 -3
  72. package/search/config.js +53 -39
  73. package/search/menu.js +45 -10
  74. package/skills/client.js +42 -0
  75. package/skills/packs.js +40 -0
  76. package/skills/state.d.ts +22 -0
  77. package/skills/state.js +142 -0
  78. package/tools/crypto-uuid.js +3 -6
  79. package/tools/head-script.js +15 -0
  80. package/voice/agent.js +50 -0
  81. package/voice/capture.js +250 -0
  82. package/voice/client.js +126 -24
  83. package/voice/config.js +11 -1
  84. package/voice/duplex.js +44 -0
  85. package/voice/endpoint.js +57 -0
  86. package/voice/host/bluetooth-ready.py +1196 -0
  87. package/voice/host/install-host.sh +102 -0
  88. package/voice/input.js +4 -0
  89. package/voice/install.js +146 -0
  90. package/voice/languages.js +11 -1
  91. package/voice/link.js +86 -0
  92. package/voice/pcm.js +100 -0
  93. package/voice/playback.js +7 -0
  94. package/voice/problem.js +46 -0
  95. package/voice/recorder.js +79 -4
  96. package/voice/script.js +35 -0
  97. package/voice/service.js +253 -10
  98. package/voice/session.js +128 -0
  99. package/voice/speech.js +93 -0
  100. package/files/preview-cache.js +0 -100
@@ -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
- bumpRevision();
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);
@@ -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
- /** Longest reply the model can produce, or null when Router states none. */
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: tokenCount(item.context_size),
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;
@@ -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 routerAuthHeaders(apiKey: string | null | undefined, olaresAppId: string | null | undefined): Record<string, string>;
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
- export function routerAuthHeaders(apiKey, olaresAppId) {
12
- if (apiKey?.trim()) return { authorization: `Bearer ${apiKey.trim()}` };
13
- return { "x-caller-appid": olaresAppId?.trim() || "lares" };
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
+ }
@@ -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?include_not_ready=true`, {
91
+ const res = await fetch(`${routerUrl}/models`, {
77
92
  method: "GET",
78
93
  headers: {
79
- ...routerAuthHeaders(env.routerApiKey, env.olaresAppId),
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
- export async function fetchRouterSearchModels() {
61
- const { payload } = await catalogCache.get();
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
 
@@ -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(env.LARES_ROUTER_API_KEY?.trim() || null, env.OLARES_APP_ID?.trim() || "lares"),
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
- export function proxyToRouter(req, res, env = process.env) {
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
  },