@olares/lares-core 0.15.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.
Files changed (82) hide show
  1. package/README.md +23 -0
  2. package/brand/identity.d.ts +9 -0
  3. package/brand/identity.js +82 -0
  4. package/brand/manifest.js +15 -0
  5. package/drive/download.js +40 -0
  6. package/drive/execute.js +143 -0
  7. package/drive/files-path.js +55 -0
  8. package/drive/ls.js +126 -0
  9. package/drive/paths.js +320 -0
  10. package/drive/tools.js +206 -0
  11. package/drive/url-download.js +314 -0
  12. package/files/deliverables.js +32 -0
  13. package/files/disposition.js +20 -0
  14. package/files/filename.js +22 -0
  15. package/files/intake.js +217 -0
  16. package/files/limits.js +1 -0
  17. package/files/markdown.js +88 -0
  18. package/files/mention.js +64 -0
  19. package/files/preview-cache.js +100 -0
  20. package/files/preview-groups.js +21 -0
  21. package/files/preview-workspace.js +342 -0
  22. package/files/preview.js +305 -0
  23. package/files/upload-client.js +40 -0
  24. package/files/upload-http.js +22 -0
  25. package/files/upload.js +104 -0
  26. package/i18n/chat-model-switch.js +41 -0
  27. package/i18n/chat-model.js +40 -0
  28. package/i18n/files.js +39 -0
  29. package/i18n/mobile.js +115 -0
  30. package/i18n/preview.js +57 -0
  31. package/i18n/search.js +33 -0
  32. package/i18n/t.js +22 -0
  33. package/i18n/voice.js +70 -0
  34. package/icons/mark.js +22 -0
  35. package/larepass/chat.js +134 -0
  36. package/larepass/context-provenance.js +65 -0
  37. package/larepass/host.js +80 -0
  38. package/larepass/mux.js +194 -0
  39. package/larepass/questions.js +16 -0
  40. package/larepass/rpc.js +60 -0
  41. package/larepass/runtime.js +395 -0
  42. package/larepass/session-cache.js +104 -0
  43. package/larepass/settings.js +100 -0
  44. package/larepass/stage-copy.js +16 -0
  45. package/larepass/tool-row.js +132 -0
  46. package/larepass/transcript.js +332 -0
  47. package/media/ffmpeg-run.js +117 -0
  48. package/media/router-images.js +63 -0
  49. package/olares/entrance.js +45 -0
  50. package/olares/identity.js +190 -0
  51. package/olares/session-identity.js +48 -0
  52. package/olares/trusted-host.js +37 -0
  53. package/package.json +111 -0
  54. package/router/catalog-sync.js +87 -0
  55. package/router/catalog.d.ts +15 -0
  56. package/router/catalog.js +73 -0
  57. package/router/gateway.d.ts +4 -0
  58. package/router/gateway.js +21 -0
  59. package/router/models-client.js +39 -0
  60. package/router/models.d.ts +16 -0
  61. package/router/models.js +133 -0
  62. package/router/search.js +142 -0
  63. package/router/session-model.js +143 -0
  64. package/router/shim.js +134 -0
  65. package/router/stt.js +200 -0
  66. package/search/client.js +38 -0
  67. package/search/config.js +98 -0
  68. package/search/menu.js +22 -0
  69. package/tools/async.js +34 -0
  70. package/tools/crypto-uuid.js +26 -0
  71. package/tools/http.js +123 -0
  72. package/tools/json-file.js +18 -0
  73. package/voice/client.js +90 -0
  74. package/voice/config.js +59 -0
  75. package/voice/languages.js +29 -0
  76. package/voice/recorder.js +43 -0
  77. package/voice/service.js +83 -0
  78. package/workspace/env.js +13 -0
  79. package/workspace/home.js +9 -0
  80. package/workspace/path.js +80 -0
  81. package/workspace/seed.js +13 -0
  82. package/workspace/session.js +42 -0
@@ -0,0 +1,87 @@
1
+ import { createInFlightCoalescer } from "../tools/async.js";
2
+ import {
3
+ LLM_SETTINGS_NS,
4
+ ROUTER_PROVIDER_ID,
5
+ catalogSettingsOps,
6
+ defaultNeedsRepair,
7
+ fetchChatModels,
8
+ listedCatalog,
9
+ parseDefaultModelRequest,
10
+ pickDefaultModel,
11
+ unregisteredRouterFailure,
12
+ } from "./models.js";
13
+
14
+ function messageOf(err) {
15
+ return err instanceof Error ? err.message : String(err);
16
+ }
17
+
18
+ /**
19
+ * @param {{
20
+ * mutateSettings: (ns: string, ops: unknown[]) => Promise<void>,
21
+ * currentSelection: () => { provider: string, model: string },
22
+ * saveSelection: (next: { provider: string, model: string }) => Promise<void>,
23
+ * }} ports
24
+ */
25
+ async function performRefresh(ports) {
26
+ const models = await fetchChatModels();
27
+ await ports.mutateSettings(LLM_SETTINGS_NS, catalogSettingsOps(models));
28
+ const current = ports.currentSelection();
29
+ if (defaultNeedsRepair(current, models)) {
30
+ await ports.saveSelection({ provider: ROUTER_PROVIDER_ID, model: pickDefaultModel(models).id });
31
+ }
32
+ return models;
33
+ }
34
+
35
+ const coalesceRefresh = createInFlightCoalescer();
36
+
37
+ export function refreshCatalog(ports) {
38
+ return coalesceRefresh(() => performRefresh(ports));
39
+ }
40
+
41
+ /**
42
+ * @param {{
43
+ * listProviders: () => { id: string, name: string }[],
44
+ * listModels: (provider: string) => Promise<{ id: string, name: string, description?: string }[]>,
45
+ * }} ports
46
+ */
47
+ export async function listCatalog(ports) {
48
+ const provider = ports.listProviders().find((entry) => entry.id === ROUTER_PROVIDER_ID);
49
+ if (!provider) {
50
+ return { models: [], failures: unregisteredRouterFailure() };
51
+ }
52
+ try {
53
+ return { models: listedCatalog(provider, await ports.listModels(ROUTER_PROVIDER_ID)), failures: [] };
54
+ } catch (err) {
55
+ return {
56
+ models: [],
57
+ failures: [{ provider: ROUTER_PROVIDER_ID, name: provider.name, message: messageOf(err) }],
58
+ };
59
+ }
60
+ }
61
+
62
+ export function currentDefault(ports) {
63
+ const { provider, model } = ports.currentSelection();
64
+ return { provider, model };
65
+ }
66
+
67
+ /**
68
+ * @param {{
69
+ * resolveCallConfig: (asked: { provider: string, model: string }) => Promise<{ provider: string, model: string }>,
70
+ * saveSelection: (next: { provider: string, model: string }) => Promise<void>,
71
+ * }} ports
72
+ */
73
+ export async function saveDefault(ports, request) {
74
+ const asked = parseDefaultModelRequest(request);
75
+ let resolved;
76
+ try {
77
+ resolved = await ports.resolveCallConfig(asked);
78
+ } catch (err) {
79
+ throw Object.assign(new Error(messageOf(err)), { code: "model_unavailable", status: 400 });
80
+ }
81
+ await ports.saveSelection({ provider: resolved.provider, model: resolved.model });
82
+ return { provider: resolved.provider, model: resolved.model };
83
+ }
84
+
85
+ export function catalogState(ports, listed) {
86
+ return { default: currentDefault(ports), models: listed.models, failures: listed.failures };
87
+ }
@@ -0,0 +1,15 @@
1
+ export interface RouterCatalogRow {
2
+ id: string;
3
+ name: string;
4
+ mode: string | null;
5
+ /** Whether Router declares native image input for this chat model. */
6
+ supportsVision: boolean;
7
+ /** pi-ai level → Router wire spelling, or null when the model takes no effort. */
8
+ reasoningEfforts: Record<string, string> | null;
9
+ /** Tokens one request may span, or null when Router states none. */
10
+ contextWindow: number | null;
11
+ /** Longest reply the model can produce, or null when Router states none. */
12
+ maxTokens: number | null;
13
+ }
14
+
15
+ export function routerCatalogRows(payload: unknown): RouterCatalogRow[];
@@ -0,0 +1,73 @@
1
+ const SAFE_MODEL_ID = /^[^\u0000-\u001f\u007f]{1,512}$/;
2
+ /** pi-ai's thinking vocabulary; a Router level outside it has no wire spelling to declare. */
3
+ const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
4
+
5
+ /**
6
+ * The capability flags one catalog row declares, named without Router's
7
+ * `supports_` prefix. Router spells them differently per surface — the gateway
8
+ * catalog lists bare tokens (`vision`), its admin API a `supports_*`-keyed
9
+ * dict — and both spellings mean the same flag.
10
+ */
11
+ function capabilities(item) {
12
+ const declared = Array.isArray(item.supports)
13
+ ? item.supports
14
+ : item.supports && typeof item.supports === "object"
15
+ ? Object.keys(item.supports).filter((key) => item.supports[key] === true)
16
+ : [];
17
+ const flags = new Set();
18
+ for (const entry of declared) {
19
+ const flag = String(entry ?? "").trim().toLowerCase().replace(/^supports_/, "");
20
+ if (flag) flags.add(flag);
21
+ }
22
+ if (item.supports_vision === true) flags.add("vision");
23
+ return flags;
24
+ }
25
+
26
+ /**
27
+ * The effort levels Router advertises for one model, keyed by pi-ai level with
28
+ * Router's own spelling as the wire value. A dict offering nothing beyond `off`
29
+ * is no capability at all, and pi-ai rejects it.
30
+ */
31
+ function reasoningEfforts(item, flags) {
32
+ if (!flags.has("reasoning_effort")) return null;
33
+ const options = Array.isArray(item.reasoning_effort?.options) ? item.reasoning_effort.options : [];
34
+ const efforts = {};
35
+ for (const option of options) {
36
+ const level = String(option ?? "").trim().toLowerCase();
37
+ if (THINKING_LEVELS.has(level)) efforts[level] = level;
38
+ }
39
+ return Object.keys(efforts).some((level) => level !== "off") ? efforts : null;
40
+ }
41
+
42
+ /**
43
+ * A token count Router states, or null when it states none. Router omits the
44
+ * key rather than sending a zero, so anything non-positive here is a payload
45
+ * that disagrees with its own contract and is treated as silence — a window of
46
+ * zero would fail pi-ai's own validation on the way in.
47
+ */
48
+ function tokenCount(value) {
49
+ return Number.isInteger(value) && value > 0 ? value : null;
50
+ }
51
+
52
+ export function routerCatalogRows(payload) {
53
+ if (!payload || typeof payload !== "object" || !Array.isArray(payload.data)) return [];
54
+ const rows = [];
55
+ const seen = new Set();
56
+ for (const item of payload.data) {
57
+ if (!item || typeof item !== "object") continue;
58
+ const id = String(item.id ?? "").trim();
59
+ if (!SAFE_MODEL_ID.test(id) || seen.has(id)) continue;
60
+ seen.add(id);
61
+ const flags = capabilities(item);
62
+ rows.push({
63
+ id,
64
+ name: id,
65
+ mode: String(item.mode ?? "").trim().toLowerCase() || null,
66
+ supportsVision: flags.has("vision"),
67
+ reasoningEfforts: reasoningEfforts(item, flags),
68
+ contextWindow: tokenCount(item.context_size),
69
+ maxTokens: tokenCount(item.max_output_tokens),
70
+ });
71
+ }
72
+ return rows;
73
+ }
@@ -0,0 +1,4 @@
1
+ export function routerGatewayUrl(env?: NodeJS.ProcessEnv): string;
2
+ export function routerShimBaseUrl(env?: NodeJS.ProcessEnv): string;
3
+ export function routerAuthHeaders(apiKey: string | null | undefined, olaresAppId: string | null | undefined): Record<string, string>;
4
+ export function routerHeaders(env?: NodeJS.ProcessEnv): Record<string, string>;
@@ -0,0 +1,21 @@
1
+ export function routerGatewayUrl(env = process.env) {
2
+ return (env.LLM_GATEWAY_URL ?? "http://router-svc.router-shared/v1").replace(/\/+$/, "");
3
+ }
4
+
5
+ export function routerShimBaseUrl(env = process.env) {
6
+ const configured = env.LARES_LLM_BASE_URL?.trim();
7
+ if (configured) return configured.replace(/\/+$/, "");
8
+ return `http://127.0.0.1:${env.PORT ?? 8080}/llm/v1`;
9
+ }
10
+
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
+
16
+ export function routerHeaders(env = process.env) {
17
+ return {
18
+ ...routerAuthHeaders(env.LARES_ROUTER_API_KEY, env.OLARES_APP_ID),
19
+ accept: "application/json",
20
+ };
21
+ }
@@ -0,0 +1,39 @@
1
+ import { createSnapshotStore } from "../tools/async.js";
2
+
3
+ export const API = "/api/lares/models";
4
+
5
+ const settings = createSnapshotStore();
6
+
7
+ async function readPayload(res, path) {
8
+ const payload = await res.json().catch(() => ({}));
9
+ if (!res.ok) {
10
+ throw new Error(payload?.error?.message ?? payload?.error?.code ?? `${path} → ${res.status}`);
11
+ }
12
+ return payload;
13
+ }
14
+
15
+ export function rememberedModelSettings() {
16
+ return settings.peek();
17
+ }
18
+
19
+ export async function loadModelSettings(options = {}) {
20
+ return settings.load(fetchState, options);
21
+ }
22
+
23
+ export async function fetchState() {
24
+ return readPayload(await fetch(API), "/");
25
+ }
26
+
27
+ export async function refreshModels() {
28
+ return settings.remember(await readPayload(await fetch(`${API}/refresh`, { method: "POST" }), "/refresh"));
29
+ }
30
+
31
+ /** @param {{ provider: string, model: string }} selection */
32
+ export async function setDefaultModel(selection) {
33
+ const res = await fetch(`${API}/default`, {
34
+ method: "POST",
35
+ headers: { "content-type": "application/json" },
36
+ body: JSON.stringify(selection),
37
+ });
38
+ return settings.remember(await readPayload(res, "/default"));
39
+ }
@@ -0,0 +1,16 @@
1
+ import type { RouterCatalogRow } from "./catalog.js";
2
+
3
+ export type RouterModelEntry = RouterCatalogRow;
4
+ export function isChatModelId(id: string): boolean;
5
+ export function isChatModel(model: RouterModelEntry): boolean;
6
+ export function modelsFromRouterCatalog(payload: unknown): RouterModelEntry[];
7
+ export function pickChatModelId(catalog: RouterModelEntry[]): string | null;
8
+ export function isPlaceholderModelId(id: string | null | undefined): boolean;
9
+ export function chatModelsFromRouterCatalog(payload: unknown): Array<Record<string, unknown> & { id: string; name: string }>;
10
+ export function pickDefaultModel<T extends { id: string }>(models: T[]): T | null;
11
+ export function fetchChatModels(): Promise<Array<Record<string, unknown> & { id: string; name: string }>>;
12
+ export function fetchRouterModels(env: {
13
+ routerUrl?: string | null;
14
+ routerApiKey?: string | null;
15
+ olaresAppId?: string | null;
16
+ }): Promise<RouterModelEntry[]>;
@@ -0,0 +1,133 @@
1
+ import { routerCatalogRows } from "./catalog.js";
2
+ import { routerAuthHeaders, routerGatewayUrl, routerShimBaseUrl } from "./gateway.js";
3
+
4
+ export const ROUTER_PROVIDER_ID = "olares-router";
5
+ export const LLM_SETTINGS_NS = "llm-pi-ai";
6
+
7
+ const NON_CHAT_HINTS = /embed|whisper|tts|speech|ocr|clip|stt|asr|transcri/i;
8
+ const PLACEHOLDER_MODEL = /^(?:default|deepseek-v4-(?:flash|pro))$/i;
9
+
10
+ export function isChatModelId(id) {
11
+ return !NON_CHAT_HINTS.test(id);
12
+ }
13
+
14
+ export function isChatModel(model) {
15
+ return model.mode ? model.mode === "chat" : isChatModelId(model.id);
16
+ }
17
+
18
+ export function modelsFromRouterCatalog(payload) {
19
+ return routerCatalogRows(payload);
20
+ }
21
+
22
+ function isMtpModelId(id) {
23
+ return /\bmtp\b/i.test(id);
24
+ }
25
+
26
+ export function pickChatModelId(catalog) {
27
+ const chat = catalog.filter(isChatModel);
28
+ return chat.find((model) => isMtpModelId(model.id))?.id ?? chat[0]?.id ?? null;
29
+ }
30
+
31
+ export function isPlaceholderModelId(id) {
32
+ if (!id) return true;
33
+ const trimmed = id.trim();
34
+ return !trimmed || PLACEHOLDER_MODEL.test(trimmed);
35
+ }
36
+
37
+ export function chatModelsFromRouterCatalog(payload) {
38
+ return routerCatalogRows(payload)
39
+ .filter(isChatModel)
40
+ .map(({ id, name, supportsVision, reasoningEfforts, contextWindow, maxTokens }) => ({
41
+ id,
42
+ name,
43
+ ...(supportsVision ? { input: ["text", "image"] } : {}),
44
+ ...(reasoningEfforts === null ? {} : { reasoningEfforts }),
45
+ ...(contextWindow === null ? {} : { contextWindow }),
46
+ ...(maxTokens === null ? {} : { maxTokens }),
47
+ }));
48
+ }
49
+
50
+ export function pickDefaultModel(models) {
51
+ return models.find((model) => isMtpModelId(model.id)) ?? models[0] ?? null;
52
+ }
53
+
54
+ export function catalogFailure(code, status, message) {
55
+ return Object.assign(new Error(message), { code, status });
56
+ }
57
+
58
+ export async function fetchChatModels() {
59
+ const response = await fetch(`${routerShimBaseUrl()}/models`, {
60
+ headers: { accept: "application/json" },
61
+ signal: AbortSignal.timeout(15_000),
62
+ });
63
+ if (!response.ok) {
64
+ throw catalogFailure("router_unavailable", 503, `Router /models returned ${response.status}`);
65
+ }
66
+ return chatModelsFromRouterCatalog(await response.json());
67
+ }
68
+
69
+ /**
70
+ * GET ${LLM_GATEWAY_URL}/models with in-cluster app identity.
71
+ * @param {{ routerUrl?: string | null, routerApiKey?: string | null, olaresAppId?: string | null }} env
72
+ */
73
+ export async function fetchRouterModels(env) {
74
+ const routerUrl = String(env.routerUrl ?? "").replace(/\/+$/, "") || routerGatewayUrl();
75
+ const res = await fetch(`${routerUrl}/models`, {
76
+ method: "GET",
77
+ headers: {
78
+ ...routerAuthHeaders(env.routerApiKey, env.olaresAppId),
79
+ accept: "application/json",
80
+ },
81
+ signal: AbortSignal.timeout(15_000),
82
+ });
83
+ if (!res.ok) throw new Error(`Router /models returned ${res.status}`);
84
+ return modelsFromRouterCatalog(await res.json());
85
+ }
86
+
87
+ export function parseDefaultModelRequest(request) {
88
+ const model = typeof request?.model === "string" ? request.model.trim() : "";
89
+ if (!model) throw catalogFailure("bad_request", 400, "model is required");
90
+ const asked = typeof request?.provider === "string" ? request.provider.trim() : "";
91
+ if (asked && asked !== ROUTER_PROVIDER_ID) {
92
+ throw catalogFailure("bad_request", 400, "provider must be olares-router");
93
+ }
94
+ if (isPlaceholderModelId(model)) {
95
+ throw catalogFailure("model_unavailable", 400, "Router has no configured chat model");
96
+ }
97
+ return { provider: ROUTER_PROVIDER_ID, model };
98
+ }
99
+
100
+ export function defaultNeedsRepair(current, models) {
101
+ return models.length > 0
102
+ && (current.provider !== ROUTER_PROVIDER_ID || !models.some((model) => model.id === current.model));
103
+ }
104
+
105
+ export function listedCatalogModel(provider, model) {
106
+ if (isPlaceholderModelId(model.id)) return null;
107
+ return {
108
+ provider: ROUTER_PROVIDER_ID,
109
+ providerName: provider.name,
110
+ id: model.id,
111
+ name: model.name || model.id,
112
+ ...(model.description ? { description: model.description } : {}),
113
+ ...(model.reasoningEfforts ? { reasoningEfforts: model.reasoningEfforts } : {}),
114
+ ...(model.reasoning ? { reasoning: model.reasoning } : {}),
115
+ };
116
+ }
117
+
118
+ export function catalogSettingsOps(models) {
119
+ return [{ op: "set", path: ["providers", ROUTER_PROVIDER_ID, "models"], value: models }];
120
+ }
121
+
122
+ export function unregisteredRouterFailure() {
123
+ return [{ provider: ROUTER_PROVIDER_ID, name: "Olares Router", message: "provider is not registered" }];
124
+ }
125
+
126
+ export function listedCatalog(provider, models) {
127
+ const listed = [];
128
+ for (const model of models) {
129
+ const entry = listedCatalogModel(provider, model);
130
+ if (entry) listed.push(entry);
131
+ }
132
+ return listed;
133
+ }
@@ -0,0 +1,142 @@
1
+ import { routerCatalogRows } from "./catalog.js";
2
+ import { routerGatewayUrl, routerHeaders } from "./gateway.js";
3
+
4
+ export class SearchError extends Error {
5
+ constructor(message, code, options) {
6
+ super(message, options);
7
+ this.code = code;
8
+ }
9
+ }
10
+
11
+ export const SEARCH_WEB_CODES = {
12
+ no_model: "WEB_PROVIDER_CONFIGURED_UNAVAILABLE",
13
+ empty_query: "WEB_PROVIDER_ERROR",
14
+ aborted: "WEB_ABORTED",
15
+ timeout: "WEB_PROVIDER_ERROR",
16
+ credential: "WEB_PROVIDER_CREDENTIAL_MISSING",
17
+ failed: "WEB_PROVIDER_ERROR",
18
+ };
19
+
20
+ export function searchWebErrorCode(code) {
21
+ return SEARCH_WEB_CODES[code] ?? "WEB_PROVIDER_ERROR";
22
+ }
23
+
24
+ export function searchModelsFromRouterCatalog(payload) {
25
+ return routerCatalogRows(payload)
26
+ .filter((model) => model.mode === "search")
27
+ .map(({ id, name }) => ({ id, name }));
28
+ }
29
+
30
+ export function searchSourcesFromRouter(payload) {
31
+ if (!payload || typeof payload !== "object" || !Array.isArray(payload.results)) return [];
32
+ const sources = [];
33
+ for (const item of payload.results) {
34
+ if (sources.length >= 10) break;
35
+ if (!item || typeof item !== "object") continue;
36
+ const url = String(item.url ?? "").trim();
37
+ if (!url || url.length > 2_048) continue;
38
+ try {
39
+ const parsed = new URL(url);
40
+ if (!["http:", "https:"].includes(parsed.protocol)) continue;
41
+ } catch {
42
+ continue;
43
+ }
44
+ const title = String(item.title ?? "").trim().slice(0, 500);
45
+ const snippet = String(item.snippet ?? item.content ?? "").trim().slice(0, 5_000);
46
+ const publishedAt = String(item.publishedAt ?? item.published_at ?? "").trim().slice(0, 100);
47
+ sources.push({
48
+ url,
49
+ ...(title ? { title } : {}),
50
+ ...(snippet ? { snippet } : {}),
51
+ ...(publishedAt ? { publishedAt } : {}),
52
+ });
53
+ }
54
+ return sources;
55
+ }
56
+
57
+ const DEFAULT_TIMEOUT_MS = 20_000;
58
+
59
+ export async function fetchRouterSearchModels() {
60
+ const response = await fetch(`${routerGatewayUrl()}/models`, {
61
+ method: "GET",
62
+ headers: routerHeaders(),
63
+ signal: AbortSignal.timeout(15_000),
64
+ });
65
+ if (!response.ok) {
66
+ throw Object.assign(new Error(`Router /models returned ${response.status}`), {
67
+ code: "router_unavailable",
68
+ status: 503,
69
+ });
70
+ }
71
+ return searchModelsFromRouterCatalog(await response.json());
72
+ }
73
+
74
+ /**
75
+ * @param {string} model
76
+ * @param {string} query
77
+ * @param {{ maxResults?: number, signal?: AbortSignal, timeoutMs?: number }} [opts]
78
+ */
79
+ export async function routerSearch(model, query, opts = {}) {
80
+ const selectedModel = model.trim();
81
+ const q = query.trim();
82
+ if (!selectedModel) {
83
+ throw new SearchError("No Router search service selected", "no_model");
84
+ }
85
+ if (!q) throw new SearchError("query is required", "empty_query");
86
+
87
+ const controller = new AbortController();
88
+ let timedOut = false;
89
+ const timeoutMs = Number.isFinite(opts.timeoutMs)
90
+ ? Math.max(1, Number(opts.timeoutMs))
91
+ : DEFAULT_TIMEOUT_MS;
92
+ const onAbort = () => controller.abort(opts.signal?.reason);
93
+ if (opts.signal) {
94
+ if (opts.signal.aborted) {
95
+ throw new SearchError("Router search aborted", "aborted", { cause: opts.signal.reason });
96
+ }
97
+ opts.signal.addEventListener("abort", onAbort, { once: true });
98
+ }
99
+ const timer = setTimeout(() => {
100
+ timedOut = true;
101
+ controller.abort(new Error("timeout"));
102
+ }, timeoutMs);
103
+
104
+ try {
105
+ const response = await fetch(`${routerGatewayUrl()}/search`, {
106
+ method: "POST",
107
+ redirect: "error",
108
+ headers: {
109
+ ...routerHeaders(),
110
+ "content-type": "application/json",
111
+ },
112
+ body: JSON.stringify({
113
+ model: selectedModel,
114
+ query: q,
115
+ max_results: Math.max(1, Math.min(Number(opts.maxResults ?? 5) || 5, 10)),
116
+ }),
117
+ signal: controller.signal,
118
+ });
119
+ if (response.status === 401 || response.status === 403) {
120
+ throw new SearchError("Router rejected Lares search access", "credential");
121
+ }
122
+ if (!response.ok) {
123
+ throw new SearchError(`Router search returned ${response.status}`, "failed");
124
+ }
125
+ return {
126
+ sources: searchSourcesFromRouter(await response.json()),
127
+ truncated: false,
128
+ };
129
+ } catch (err) {
130
+ if (err instanceof SearchError) throw err;
131
+ if (opts.signal?.aborted) {
132
+ throw new SearchError("Router search aborted", "aborted", { cause: err });
133
+ }
134
+ if (timedOut) {
135
+ throw new SearchError("Router search timed out", "timeout", { cause: err });
136
+ }
137
+ throw new SearchError(`Router search request failed: ${String(err)}`, "failed", { cause: err });
138
+ } finally {
139
+ clearTimeout(timer);
140
+ opts.signal?.removeEventListener("abort", onAbort);
141
+ }
142
+ }
@@ -0,0 +1,143 @@
1
+ export function isComposerModelAvailable(subagentAddress) {
2
+ return subagentAddress === undefined;
3
+ }
4
+
5
+ export function bindComposerModelDirectory(directory, available) {
6
+ return {
7
+ available,
8
+ directory: directory.store,
9
+ load() {
10
+ if (!available) return Promise.resolve();
11
+ return directory.load().catch(() => {});
12
+ },
13
+ select(selection) {
14
+ if (!available) return Promise.resolve(false);
15
+ return directory.select(selection).then(
16
+ () => true,
17
+ () => false,
18
+ );
19
+ },
20
+ };
21
+ }
22
+
23
+ export function findListedModel(groups, current) {
24
+ if (!current) return undefined;
25
+ for (const group of groups) {
26
+ for (const model of group.models) {
27
+ if (group.id === current.provider && model.id === current.model) return model;
28
+ }
29
+ }
30
+ return undefined;
31
+ }
32
+
33
+ export function isSameSessionModel(current, group, model) {
34
+ return Boolean(current && current.provider === group.id && current.model === model.id);
35
+ }
36
+
37
+ export function currentEffortId(current, reasoning) {
38
+ return current?.reasoningEffort ?? reasoning?.defaultEffort;
39
+ }
40
+
41
+ const EFFORT_ORDER = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
42
+
43
+ /** Router catalog dict `{ low: "low" }` → the effort menu shape the switcher already uses. */
44
+ export function reasoningFromEfforts(raw) {
45
+ if (!raw || typeof raw !== "object") return undefined;
46
+ if (Array.isArray(raw.efforts)) {
47
+ return raw.efforts.length ? raw : undefined;
48
+ }
49
+ const efforts = [];
50
+ for (const id of EFFORT_ORDER) {
51
+ if (raw[id] != null) efforts.push({ id, name: id });
52
+ }
53
+ if (!efforts.length) {
54
+ for (const id of Object.keys(raw)) {
55
+ if (typeof id === "string" && id) efforts.push({ id, name: id });
56
+ }
57
+ }
58
+ if (!efforts.length) return undefined;
59
+ return {
60
+ efforts,
61
+ defaultEffort: efforts.find((row) => row.id !== "off")?.id ?? efforts[0].id,
62
+ };
63
+ }
64
+
65
+ export function reasoningOfModel(model) {
66
+ return reasoningFromEfforts(model?.reasoning ?? model?.reasoningEfforts);
67
+ }
68
+
69
+ export function modelSwitchSelection(group, model) {
70
+ return {
71
+ provider: group.id,
72
+ model: model.id,
73
+ ...(model.reasoning?.defaultEffort === undefined ? {} : { reasoningEffort: model.reasoning.defaultEffort }),
74
+ };
75
+ }
76
+
77
+ export function effortSwitchSelection(current, level) {
78
+ return {
79
+ provider: current.provider,
80
+ model: current.model,
81
+ ...(level === undefined ? {} : { reasoningEffort: level }),
82
+ };
83
+ }
84
+
85
+ export function effortDisplayLabel(reasoning, effortId, defaultLabel) {
86
+ if (reasoning === undefined) return undefined;
87
+ if (effortId === undefined) return defaultLabel;
88
+ return reasoning.efforts.find((level) => level.id === effortId)?.name ?? effortId;
89
+ }
90
+
91
+ export function sessionModelLabel(currentModel, current, selectLabel) {
92
+ return currentModel?.name ?? current?.model ?? selectLabel;
93
+ }
94
+
95
+ export function effortMenuRows(reasoning, defaultLabel) {
96
+ return effortMenuItems(reasoning).map((item) =>
97
+ item.providerDefault
98
+ ? { key: "provider-default", id: undefined, name: defaultLabel }
99
+ : { key: `effort:${item.id}`, id: item.id, name: item.name, description: item.description },
100
+ );
101
+ }
102
+
103
+ export function effortMenuItems(reasoning) {
104
+ if (reasoning === undefined) return [];
105
+ const items = [];
106
+ if (reasoning.defaultEffort === undefined) {
107
+ items.push({ id: undefined, providerDefault: true });
108
+ }
109
+ for (const effort of reasoning.efforts) {
110
+ items.push({
111
+ id: effort.id,
112
+ providerDefault: false,
113
+ name: effort.name,
114
+ description: effort.description,
115
+ });
116
+ }
117
+ return items;
118
+ }
119
+
120
+ export function selectionKey(selection) {
121
+ return `${selection.provider}\u0000${selection.model ?? selection.id}`;
122
+ }
123
+
124
+ export function groupModelsByProvider(models) {
125
+ const groups = [];
126
+ const index = new Map();
127
+ for (const model of models) {
128
+ let group = index.get(model.provider);
129
+ if (group === undefined) {
130
+ group = { provider: model.provider, models: [] };
131
+ index.set(model.provider, group);
132
+ groups.push(group);
133
+ }
134
+ group.models.push(model);
135
+ }
136
+ return groups;
137
+ }
138
+
139
+ export function catalogDefaultReady(models, defaultSelection) {
140
+ if (!defaultSelection) return false;
141
+ const current = selectionKey(defaultSelection);
142
+ return models.some((model) => selectionKey({ provider: model.provider, model: model.id }) === current);
143
+ }