@kal-elsam/kairo-runtime 0.1.5 → 0.2.1
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 +23 -0
- package/package.json +1 -1
- package/src/cli.js +54 -1
- package/src/global/action-planner.js +55 -3
- package/src/global/ink/orchestrator-app.js +36 -11
- package/src/global/ink/orchestrator-state.js +74 -3
- package/src/global/intelligence/backends/custom-http.js +175 -0
- package/src/global/intelligence/backends/ollama.js +164 -0
- package/src/global/intelligence/backends/openrouter.js +198 -0
- package/src/global/intelligence/context-compiler.js +338 -0
- package/src/global/intelligence/custom-url.js +82 -0
- package/src/global/intelligence/http.js +63 -0
- package/src/global/intelligence/index.js +38 -0
- package/src/global/intelligence/orchestrate.js +189 -0
- package/src/global/intelligence/registry.js +77 -0
- package/src/global/intelligence/router.js +191 -0
- package/src/global/intelligence/types.js +99 -0
- package/src/global/intelligence-cli.js +323 -0
- package/src/global/orchestrator.js +11 -0
- package/src/global/profile.js +153 -2
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
|
|
3
|
+
const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);
|
|
4
|
+
const LOCAL_HOSTNAMES = new Set(["localhost", "localhost.localdomain"]);
|
|
5
|
+
const ENV_NAME_PATTERN = /^[A-Z_][A-Z0-9_]*$/;
|
|
6
|
+
|
|
7
|
+
export function classifyCustomBaseUrl(baseUrl) {
|
|
8
|
+
let parsed;
|
|
9
|
+
try {
|
|
10
|
+
parsed = new URL(String(baseUrl));
|
|
11
|
+
} catch {
|
|
12
|
+
throw new Error("Custom provider baseUrl must be an absolute http(s) URL.");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) {
|
|
16
|
+
throw new Error("Custom provider baseUrl must use http or https.");
|
|
17
|
+
}
|
|
18
|
+
if (parsed.username || parsed.password) {
|
|
19
|
+
throw new Error("Custom provider baseUrl must not contain embedded credentials.");
|
|
20
|
+
}
|
|
21
|
+
if (parsed.search || parsed.hash) {
|
|
22
|
+
throw new Error("Custom provider baseUrl must not contain a query string or fragment.");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
26
|
+
if (!hostname) {
|
|
27
|
+
throw new Error("Custom provider baseUrl must include a host.");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const local = isLocalHostname(hostname);
|
|
31
|
+
if (!local && parsed.protocol !== "https:") {
|
|
32
|
+
throw new Error("Remote custom providers must use https; http is allowed only for local/private endpoints.");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
url: parsed,
|
|
37
|
+
normalizedBaseUrl: parsed.toString().replace(/\/+$/, ""),
|
|
38
|
+
hostname,
|
|
39
|
+
local,
|
|
40
|
+
credentialSafe: local || parsed.protocol === "https:"
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function isValidEnvironmentName(name) {
|
|
45
|
+
return typeof name === "string" && ENV_NAME_PATTERN.test(name);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isLocalHostname(hostname) {
|
|
49
|
+
if (LOCAL_HOSTNAMES.has(hostname)) return true;
|
|
50
|
+
|
|
51
|
+
const version = net.isIP(hostname);
|
|
52
|
+
if (version === 4) return isPrivateIpv4(hostname);
|
|
53
|
+
if (version === 6) return isPrivateIpv6(hostname);
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isPrivateIpv4(hostname) {
|
|
58
|
+
const octets = hostname.split(".").map(Number);
|
|
59
|
+
if (octets.length !== 4 || octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const [first, second] = octets;
|
|
64
|
+
return first === 10
|
|
65
|
+
|| (first === 172 && second >= 16 && second <= 31)
|
|
66
|
+
|| (first === 192 && second === 168)
|
|
67
|
+
|| (first === 169 && second === 254)
|
|
68
|
+
|| first === 127
|
|
69
|
+
|| first === 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isPrivateIpv6(hostname) {
|
|
73
|
+
const normalized = hostname.toLowerCase();
|
|
74
|
+
return normalized === "::1"
|
|
75
|
+
|| normalized === "::"
|
|
76
|
+
|| normalized.startsWith("fc")
|
|
77
|
+
|| normalized.startsWith("fd")
|
|
78
|
+
|| normalized.startsWith("fe8")
|
|
79
|
+
|| normalized.startsWith("fe9")
|
|
80
|
+
|| normalized.startsWith("fea")
|
|
81
|
+
|| normalized.startsWith("feb");
|
|
82
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
2
|
+
|
|
3
|
+
export async function fetchJson(url, {
|
|
4
|
+
method = "GET",
|
|
5
|
+
headers = {},
|
|
6
|
+
body = null,
|
|
7
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
8
|
+
fetchImpl = globalThis.fetch
|
|
9
|
+
} = {}) {
|
|
10
|
+
if (typeof fetchImpl !== "function") {
|
|
11
|
+
return {
|
|
12
|
+
ok: false,
|
|
13
|
+
status: 0,
|
|
14
|
+
data: null,
|
|
15
|
+
error: "fetch is not available in this runtime"
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const controller = new AbortController();
|
|
20
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const response = await fetchImpl(url, {
|
|
24
|
+
method,
|
|
25
|
+
headers,
|
|
26
|
+
body: body == null ? undefined : JSON.stringify(body),
|
|
27
|
+
signal: controller.signal
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const text = await response.text();
|
|
31
|
+
let data = null;
|
|
32
|
+
if (text) {
|
|
33
|
+
try {
|
|
34
|
+
data = JSON.parse(text);
|
|
35
|
+
} catch {
|
|
36
|
+
data = { raw: text };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
ok: response.ok,
|
|
42
|
+
status: response.status,
|
|
43
|
+
data,
|
|
44
|
+
error: response.ok ? null : summarizeHttpError(response.status, data, text)
|
|
45
|
+
};
|
|
46
|
+
} catch (error) {
|
|
47
|
+
return {
|
|
48
|
+
ok: false,
|
|
49
|
+
status: 0,
|
|
50
|
+
data: null,
|
|
51
|
+
error: error?.name === "AbortError" ? "request timed out" : (error?.message ?? String(error))
|
|
52
|
+
};
|
|
53
|
+
} finally {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function summarizeHttpError(status, data, text) {
|
|
59
|
+
if (data?.error?.message) return data.error.message;
|
|
60
|
+
if (typeof data?.error === "string") return data.error;
|
|
61
|
+
if (text) return text.slice(0, 200);
|
|
62
|
+
return `HTTP ${status}`;
|
|
63
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export {
|
|
2
|
+
BACKEND_IDS,
|
|
3
|
+
COST_CLASSES,
|
|
4
|
+
PRIVACY_CLASSES,
|
|
5
|
+
ROUTING_MODES,
|
|
6
|
+
OPENROUTER_FREE_MODEL,
|
|
7
|
+
DEFAULT_OLLAMA_HOST,
|
|
8
|
+
createModelDescriptor,
|
|
9
|
+
createRoutingDecision,
|
|
10
|
+
createUsageTelemetry
|
|
11
|
+
} from "./types.js";
|
|
12
|
+
|
|
13
|
+
export { createOllamaBackend } from "./backends/ollama.js";
|
|
14
|
+
export { createOpenRouterBackend } from "./backends/openrouter.js";
|
|
15
|
+
export { createCustomHttpBackend } from "./backends/custom-http.js";
|
|
16
|
+
|
|
17
|
+
export {
|
|
18
|
+
createDefaultBackends,
|
|
19
|
+
inspectIntelligenceBackends,
|
|
20
|
+
summarizeIntelligenceBackends,
|
|
21
|
+
resolveBackendById
|
|
22
|
+
} from "./registry.js";
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
compileContextPack,
|
|
26
|
+
isPrivatePath,
|
|
27
|
+
estimateTokens
|
|
28
|
+
} from "./context-compiler.js";
|
|
29
|
+
|
|
30
|
+
export {
|
|
31
|
+
resolveRoutingDecision,
|
|
32
|
+
classifyTaskWeight
|
|
33
|
+
} from "./router.js";
|
|
34
|
+
|
|
35
|
+
export {
|
|
36
|
+
runIntelligenceRequest,
|
|
37
|
+
explainRouting
|
|
38
|
+
} from "./orchestrate.js";
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { createDefaultBackends, inspectIntelligenceBackends, resolveBackendById } from "./registry.js";
|
|
2
|
+
import { compileContextPack } from "./context-compiler.js";
|
|
3
|
+
import { resolveRoutingDecision } from "./router.js";
|
|
4
|
+
import { createUsageTelemetry, PRIVACY_CLASSES } from "./types.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Orchestrates detect → context → route → privacy gate → invoke.
|
|
8
|
+
* Cloud transmission requires explicit consent. Credentials never touch disk.
|
|
9
|
+
*/
|
|
10
|
+
export async function runIntelligenceRequest({
|
|
11
|
+
workspaceRoot,
|
|
12
|
+
profile = {},
|
|
13
|
+
task = null,
|
|
14
|
+
prompt = null,
|
|
15
|
+
relevantPaths = [],
|
|
16
|
+
includePrivate = false,
|
|
17
|
+
cloudConsent = false,
|
|
18
|
+
confirmed = false,
|
|
19
|
+
env = process.env,
|
|
20
|
+
fetchImpl = globalThis.fetch,
|
|
21
|
+
backends = null,
|
|
22
|
+
tokenBudget = null
|
|
23
|
+
} = {}) {
|
|
24
|
+
const customProviders = Array.isArray(profile.customProviders) ? profile.customProviders : [];
|
|
25
|
+
const backendInstances = backends ?? createDefaultBackends({ env, fetchImpl, customProviders });
|
|
26
|
+
const inspections = await inspectIntelligenceBackends({
|
|
27
|
+
env,
|
|
28
|
+
fetchImpl,
|
|
29
|
+
customProviders,
|
|
30
|
+
backends: backendInstances
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const privateConfirmationRequired = includePrivate && !confirmed;
|
|
34
|
+
const contextPack = await compileContextPack({
|
|
35
|
+
workspaceRoot,
|
|
36
|
+
task: task ?? prompt,
|
|
37
|
+
relevantPaths,
|
|
38
|
+
includePrivate: includePrivate && confirmed,
|
|
39
|
+
stableBudgetTokens: profile.stableContextBudget ?? undefined,
|
|
40
|
+
requestBudgetTokens: profile.requestContextBudget ?? undefined
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const routing = resolveRoutingDecision({
|
|
44
|
+
backends: inspections,
|
|
45
|
+
profile,
|
|
46
|
+
contextPack,
|
|
47
|
+
task: task ?? prompt,
|
|
48
|
+
cloudConsent: Boolean(cloudConsent),
|
|
49
|
+
tokenBudget: tokenBudget ?? profile.tokenBudget ?? null
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const explanation = explainRouting(routing, contextPack, inspections);
|
|
53
|
+
const base = {
|
|
54
|
+
routing,
|
|
55
|
+
explanation,
|
|
56
|
+
contextPack: summarizeContextPack(contextPack),
|
|
57
|
+
backends: inspections
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
if (privateConfirmationRequired) {
|
|
61
|
+
return {
|
|
62
|
+
...base,
|
|
63
|
+
ok: false,
|
|
64
|
+
mode: routing.mode,
|
|
65
|
+
diagnosticsOnly: true,
|
|
66
|
+
result: null,
|
|
67
|
+
telemetry: null,
|
|
68
|
+
error: "Including private context requires explicit confirmation (--include-private --yes / --confirm)."
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!routing.canInvoke) {
|
|
73
|
+
return {
|
|
74
|
+
...base,
|
|
75
|
+
ok: false,
|
|
76
|
+
mode: routing.mode,
|
|
77
|
+
diagnosticsOnly: true,
|
|
78
|
+
result: null,
|
|
79
|
+
telemetry: null,
|
|
80
|
+
error: routing.reason
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (routing.privacyImpact === PRIVACY_CLASSES.CLOUD) {
|
|
85
|
+
if (!cloudConsent) {
|
|
86
|
+
return {
|
|
87
|
+
...base,
|
|
88
|
+
ok: false,
|
|
89
|
+
mode: routing.mode,
|
|
90
|
+
diagnosticsOnly: true,
|
|
91
|
+
result: null,
|
|
92
|
+
telemetry: null,
|
|
93
|
+
error: "Cloud transmission requires explicit consent."
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (!confirmed) {
|
|
97
|
+
return {
|
|
98
|
+
...base,
|
|
99
|
+
ok: false,
|
|
100
|
+
mode: routing.mode,
|
|
101
|
+
diagnosticsOnly: true,
|
|
102
|
+
result: null,
|
|
103
|
+
telemetry: null,
|
|
104
|
+
error: "Confirm cloud context transmission before invoke (--yes / --confirm)."
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const backend = resolveBackendById(backendInstances, routing.backendId);
|
|
110
|
+
if (!backend) {
|
|
111
|
+
return {
|
|
112
|
+
...base,
|
|
113
|
+
ok: false,
|
|
114
|
+
mode: routing.mode,
|
|
115
|
+
diagnosticsOnly: true,
|
|
116
|
+
result: null,
|
|
117
|
+
telemetry: null,
|
|
118
|
+
error: `Backend ${routing.backendId} is not loaded.`
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const result = await backend.invoke(contextPack, {
|
|
123
|
+
modelId: routing.model?.modelId,
|
|
124
|
+
prompt: prompt ?? task,
|
|
125
|
+
timeoutMs: profile.invokeTimeoutMs ?? undefined
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const telemetry = createUsageTelemetry({
|
|
129
|
+
...(result.usage ?? {}),
|
|
130
|
+
model: result.model ?? routing.model?.modelId,
|
|
131
|
+
backendId: routing.backendId,
|
|
132
|
+
fallbackUsed: Boolean(result.usage?.fallbackUsed)
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
...base,
|
|
137
|
+
ok: Boolean(result.ok),
|
|
138
|
+
mode: routing.mode,
|
|
139
|
+
diagnosticsOnly: false,
|
|
140
|
+
result,
|
|
141
|
+
telemetry,
|
|
142
|
+
error: result.ok ? null : (result.error ?? "Invoke failed.")
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function explainRouting(routing, contextPack, backends = []) {
|
|
147
|
+
const evidencePaths = (contextPack?.evidence ?? [])
|
|
148
|
+
.filter((entry) => entry.kind === "file")
|
|
149
|
+
.map((entry) => entry.path);
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
selectedBackend: routing.backendId,
|
|
153
|
+
selectedModel: routing.model?.modelId ?? null,
|
|
154
|
+
reason: routing.reason,
|
|
155
|
+
mode: routing.mode,
|
|
156
|
+
estimatedTokens: routing.estimatedTokens,
|
|
157
|
+
privacyImpact: routing.privacyImpact,
|
|
158
|
+
requiresCloudConsent: routing.requiresCloudConsent,
|
|
159
|
+
canInvoke: routing.canInvoke,
|
|
160
|
+
evidenceUsed: evidencePaths,
|
|
161
|
+
excludedPrivate: contextPack?.privacy?.excludedPrivate ?? [],
|
|
162
|
+
availableBackends: backends.map((entry) => ({
|
|
163
|
+
id: entry.id,
|
|
164
|
+
state: entry.state,
|
|
165
|
+
available: entry.available,
|
|
166
|
+
modelCount: entry.models?.length ?? 0
|
|
167
|
+
})),
|
|
168
|
+
fallback: routing.fallback
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function summarizeContextPack(contextPack) {
|
|
173
|
+
if (!contextPack) return null;
|
|
174
|
+
return {
|
|
175
|
+
workspaceRoot: contextPack.workspaceRoot,
|
|
176
|
+
estimatedTokens: contextPack.estimatedTokens,
|
|
177
|
+
project: contextPack.stable?.project ?? null,
|
|
178
|
+
evidenceCount: contextPack.evidence?.length ?? 0,
|
|
179
|
+
evidence: contextPack.evidence,
|
|
180
|
+
privacy: contextPack.privacy,
|
|
181
|
+
budgets: contextPack.budgets,
|
|
182
|
+
skills: contextPack.stable?.skills ?? [],
|
|
183
|
+
sdd: contextPack.stable?.sdd ?? false,
|
|
184
|
+
tdd: contextPack.stable?.tdd ?? false,
|
|
185
|
+
graphify: contextPack.stable?.graphify ?? null,
|
|
186
|
+
hasAgentsMd: Boolean(contextPack.stable?.agentsMd),
|
|
187
|
+
relevantFiles: (contextPack.perRequest?.files ?? []).map((file) => file.path)
|
|
188
|
+
};
|
|
189
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { BACKEND_IDS } from "./types.js";
|
|
2
|
+
import { createOllamaBackend } from "./backends/ollama.js";
|
|
3
|
+
import { createOpenRouterBackend } from "./backends/openrouter.js";
|
|
4
|
+
import { createCustomHttpBackend } from "./backends/custom-http.js";
|
|
5
|
+
import { CAPABILITY_STATES } from "../capability-states.js";
|
|
6
|
+
|
|
7
|
+
export function createDefaultBackends({
|
|
8
|
+
env = process.env,
|
|
9
|
+
fetchImpl = globalThis.fetch,
|
|
10
|
+
customProviders = []
|
|
11
|
+
} = {}) {
|
|
12
|
+
const backends = [
|
|
13
|
+
createOllamaBackend({ env, fetchImpl }),
|
|
14
|
+
createOpenRouterBackend({ env, fetchImpl })
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
for (const provider of customProviders) {
|
|
18
|
+
backends.push(createCustomHttpBackend({
|
|
19
|
+
...provider,
|
|
20
|
+
env,
|
|
21
|
+
fetchImpl
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return backends;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function inspectIntelligenceBackends({
|
|
29
|
+
env = process.env,
|
|
30
|
+
fetchImpl = globalThis.fetch,
|
|
31
|
+
customProviders = [],
|
|
32
|
+
backends = null
|
|
33
|
+
} = {}) {
|
|
34
|
+
const resolved = backends ?? createDefaultBackends({ env, fetchImpl, customProviders });
|
|
35
|
+
return Promise.all(resolved.map(async (backend) => {
|
|
36
|
+
const detection = await backend.detect();
|
|
37
|
+
const models = detection.detected || detection.available
|
|
38
|
+
? await backend.listModels()
|
|
39
|
+
: [];
|
|
40
|
+
const capabilities = await backend.capabilities();
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
...detection,
|
|
44
|
+
models,
|
|
45
|
+
capabilities
|
|
46
|
+
};
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function summarizeIntelligenceBackends(inspections) {
|
|
51
|
+
const byState = {};
|
|
52
|
+
for (const state of Object.values(CAPABILITY_STATES)) {
|
|
53
|
+
byState[state] = 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const entry of inspections) {
|
|
57
|
+
if (byState[entry.state] != null) {
|
|
58
|
+
byState[entry.state] += 1;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
total: inspections.length,
|
|
64
|
+
available: inspections.filter((entry) => entry.available).length,
|
|
65
|
+
localAvailable: inspections.some(
|
|
66
|
+
(entry) => entry.id === BACKEND_IDS.OLLAMA && entry.available
|
|
67
|
+
),
|
|
68
|
+
cloudAuthenticated: inspections.some(
|
|
69
|
+
(entry) => entry.id === BACKEND_IDS.OPENROUTER && entry.hasApiKey
|
|
70
|
+
),
|
|
71
|
+
byState
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function resolveBackendById(backends, backendId) {
|
|
76
|
+
return backends.find((backend) => backend.id === backendId) ?? null;
|
|
77
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BACKEND_IDS,
|
|
3
|
+
OPENROUTER_FREE_MODEL,
|
|
4
|
+
PRIVACY_CLASSES,
|
|
5
|
+
ROUTING_MODES,
|
|
6
|
+
createRoutingDecision
|
|
7
|
+
} from "./types.js";
|
|
8
|
+
import { estimateTokens } from "./context-compiler.js";
|
|
9
|
+
|
|
10
|
+
const TASK_WEIGHTS = {
|
|
11
|
+
architecture: "heavy",
|
|
12
|
+
security: "heavy",
|
|
13
|
+
review: "heavy",
|
|
14
|
+
diagnose: "light",
|
|
15
|
+
explain: "light",
|
|
16
|
+
scaffold: "light",
|
|
17
|
+
test: "light",
|
|
18
|
+
default: "light"
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function classifyTaskWeight(task = "") {
|
|
22
|
+
const text = String(task).toLowerCase();
|
|
23
|
+
if (/architect|adr|design system|security|threat/.test(text)) return "heavy";
|
|
24
|
+
if (/review|refactor complex|debug complex/.test(text)) return "heavy";
|
|
25
|
+
if (/test|scaffold|explain|status|diagnose|lint|format/.test(text)) return "light";
|
|
26
|
+
return TASK_WEIGHTS.default;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve which backend/model to use.
|
|
31
|
+
* Precedence: user override > Ollama local > OpenRouter free (consent) > diagnostics.
|
|
32
|
+
*/
|
|
33
|
+
export function resolveRoutingDecision({
|
|
34
|
+
backends = [],
|
|
35
|
+
profile = {},
|
|
36
|
+
contextPack = null,
|
|
37
|
+
task = null,
|
|
38
|
+
cloudConsent = false,
|
|
39
|
+
tokenBudget = null
|
|
40
|
+
} = {}) {
|
|
41
|
+
const estimatedTokens = contextPack?.estimatedTokens
|
|
42
|
+
?? estimateTokens(contextPack?.systemPrompt ?? "")
|
|
43
|
+
+ estimateTokens(task ?? "");
|
|
44
|
+
|
|
45
|
+
const budget = tokenBudget ?? profile.tokenBudget ?? null;
|
|
46
|
+
if (budget != null && estimatedTokens > budget) {
|
|
47
|
+
return createRoutingDecision({
|
|
48
|
+
backendId: null,
|
|
49
|
+
model: null,
|
|
50
|
+
reason: `Estimated tokens (${estimatedTokens}) exceed budget (${budget}). Compact context or raise tokenBudget.`,
|
|
51
|
+
estimatedTokens,
|
|
52
|
+
privacyImpact: PRIVACY_CLASSES.UNKNOWN,
|
|
53
|
+
mode: ROUTING_MODES.DIAGNOSTICS,
|
|
54
|
+
requiresCloudConsent: false,
|
|
55
|
+
canInvoke: false
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const override = resolveUserOverride(profile, backends);
|
|
60
|
+
if (override) {
|
|
61
|
+
return createRoutingDecision({
|
|
62
|
+
backendId: override.backend.id,
|
|
63
|
+
model: override.model,
|
|
64
|
+
reason: `User override: ${override.backend.id}/${override.model.modelId}`,
|
|
65
|
+
estimatedTokens,
|
|
66
|
+
privacyImpact: override.model.privacyClass,
|
|
67
|
+
mode: ROUTING_MODES.USER_OVERRIDE,
|
|
68
|
+
requiresCloudConsent: !override.model.local,
|
|
69
|
+
canInvoke: override.model.local || cloudConsent,
|
|
70
|
+
fallback: buildLocalFallback(backends)
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const ollama = backends.find((entry) => entry.id === BACKEND_IDS.OLLAMA);
|
|
75
|
+
if (ollama?.available && Array.isArray(ollama.models) && ollama.models.length > 0) {
|
|
76
|
+
const model = selectLocalModel(ollama.models, task);
|
|
77
|
+
return createRoutingDecision({
|
|
78
|
+
backendId: BACKEND_IDS.OLLAMA,
|
|
79
|
+
model,
|
|
80
|
+
reason: `Local-first: Ollama model ${model.modelId}`,
|
|
81
|
+
estimatedTokens,
|
|
82
|
+
privacyImpact: PRIVACY_CLASSES.LOCAL,
|
|
83
|
+
mode: ROUTING_MODES.LOCAL,
|
|
84
|
+
requiresCloudConsent: false,
|
|
85
|
+
canInvoke: true,
|
|
86
|
+
fallback: buildCloudFallback(backends, cloudConsent)
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const openrouter = backends.find((entry) => entry.id === BACKEND_IDS.OPENROUTER);
|
|
91
|
+
if (openrouter?.hasApiKey) {
|
|
92
|
+
const model = openrouter.models?.find((entry) => entry.modelId === OPENROUTER_FREE_MODEL)
|
|
93
|
+
?? openrouter.models?.[0]
|
|
94
|
+
?? {
|
|
95
|
+
provider: BACKEND_IDS.OPENROUTER,
|
|
96
|
+
modelId: OPENROUTER_FREE_MODEL,
|
|
97
|
+
local: false,
|
|
98
|
+
privacyClass: PRIVACY_CLASSES.CLOUD,
|
|
99
|
+
costClass: "free",
|
|
100
|
+
opaque: true
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
return createRoutingDecision({
|
|
104
|
+
backendId: BACKEND_IDS.OPENROUTER,
|
|
105
|
+
model,
|
|
106
|
+
reason: cloudConsent
|
|
107
|
+
? `Cloud fallback approved: ${model.modelId}`
|
|
108
|
+
: `OpenRouter available (${model.modelId}) but cloud consent required before invoke`,
|
|
109
|
+
estimatedTokens,
|
|
110
|
+
privacyImpact: PRIVACY_CLASSES.CLOUD,
|
|
111
|
+
mode: ROUTING_MODES.CLOUD_CONSENT,
|
|
112
|
+
requiresCloudConsent: true,
|
|
113
|
+
canInvoke: cloudConsent,
|
|
114
|
+
fallback: null
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return createRoutingDecision({
|
|
119
|
+
backendId: null,
|
|
120
|
+
model: null,
|
|
121
|
+
reason: "No intelligence backend available. Remaining in diagnostics/configuration mode.",
|
|
122
|
+
estimatedTokens,
|
|
123
|
+
privacyImpact: PRIVACY_CLASSES.UNKNOWN,
|
|
124
|
+
mode: ROUTING_MODES.DIAGNOSTICS,
|
|
125
|
+
requiresCloudConsent: false,
|
|
126
|
+
canInvoke: false
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function resolveUserOverride(profile, backends) {
|
|
131
|
+
const preferredBackend = profile.preferredBackend ?? null;
|
|
132
|
+
const preferredModel = profile.preferredModel ?? null;
|
|
133
|
+
if (!preferredBackend && !preferredModel) return null;
|
|
134
|
+
|
|
135
|
+
if (preferredBackend) {
|
|
136
|
+
const backend = backends.find((entry) => entry.id === preferredBackend);
|
|
137
|
+
if (!backend || (!backend.available && !backend.hasApiKey && !backend.detected)) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
const model = (backend.models ?? []).find((entry) => entry.modelId === preferredModel)
|
|
141
|
+
?? backend.models?.[0]
|
|
142
|
+
?? (preferredModel
|
|
143
|
+
? {
|
|
144
|
+
provider: preferredBackend,
|
|
145
|
+
modelId: preferredModel,
|
|
146
|
+
local: backend.id === BACKEND_IDS.OLLAMA,
|
|
147
|
+
privacyClass: backend.id === BACKEND_IDS.OLLAMA ? PRIVACY_CLASSES.LOCAL : PRIVACY_CLASSES.CLOUD,
|
|
148
|
+
costClass: "unknown",
|
|
149
|
+
opaque: true
|
|
150
|
+
}
|
|
151
|
+
: null);
|
|
152
|
+
if (!model) return null;
|
|
153
|
+
return { backend, model };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (preferredModel) {
|
|
157
|
+
for (const backend of backends) {
|
|
158
|
+
const model = (backend.models ?? []).find((entry) => entry.modelId === preferredModel);
|
|
159
|
+
if (model) return { backend, model };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function selectLocalModel(models, task) {
|
|
167
|
+
const weight = classifyTaskWeight(task);
|
|
168
|
+
if (weight === "heavy" && models.length > 1) {
|
|
169
|
+
return [...models].sort((a, b) => String(b.modelId).localeCompare(String(a.modelId)))[0];
|
|
170
|
+
}
|
|
171
|
+
return models[0];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function buildLocalFallback(backends) {
|
|
175
|
+
const ollama = backends.find((entry) => entry.id === BACKEND_IDS.OLLAMA && entry.available);
|
|
176
|
+
if (!ollama?.models?.length) return null;
|
|
177
|
+
return {
|
|
178
|
+
backendId: BACKEND_IDS.OLLAMA,
|
|
179
|
+
modelId: ollama.models[0].modelId
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function buildCloudFallback(backends, cloudConsent) {
|
|
184
|
+
const openrouter = backends.find((entry) => entry.id === BACKEND_IDS.OPENROUTER && entry.hasApiKey);
|
|
185
|
+
if (!openrouter) return null;
|
|
186
|
+
return {
|
|
187
|
+
backendId: BACKEND_IDS.OPENROUTER,
|
|
188
|
+
modelId: OPENROUTER_FREE_MODEL,
|
|
189
|
+
requiresConsent: !cloudConsent
|
|
190
|
+
};
|
|
191
|
+
}
|