@kal-elsam/kairo-runtime 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,175 @@
1
+ import {
2
+ BACKEND_IDS,
3
+ COST_CLASSES,
4
+ PRIVACY_CLASSES,
5
+ createModelDescriptor
6
+ } from "../types.js";
7
+ import { fetchJson } from "../http.js";
8
+ import { CAPABILITY_STATES } from "../../capability-states.js";
9
+ import { classifyCustomBaseUrl, isValidEnvironmentName } from "../custom-url.js";
10
+
11
+ /**
12
+ * OpenAI-compatible HTTP backend. API keys are read from env by name only —
13
+ * never from profile JSON or disk.
14
+ */
15
+ export function createCustomHttpBackend({
16
+ id = BACKEND_IDS.CUSTOM,
17
+ label = "Custom provider",
18
+ baseUrl,
19
+ modelId,
20
+ apiKeyEnv = null,
21
+ local: _local = false,
22
+ fetchImpl = globalThis.fetch,
23
+ env = process.env
24
+ } = {}) {
25
+ if (!baseUrl) {
26
+ throw new Error("Custom HTTP backend requires baseUrl.");
27
+ }
28
+
29
+ const location = classifyCustomBaseUrl(baseUrl);
30
+ const normalizedBase = location.normalizedBaseUrl;
31
+ if (apiKeyEnv != null && !isValidEnvironmentName(apiKeyEnv)) {
32
+ throw new Error("Custom provider apiKeyEnv must be a valid uppercase environment variable name.");
33
+ }
34
+ if (apiKeyEnv && !location.local) {
35
+ throw new Error(
36
+ "Remote custom providers cannot use apiKeyEnv in 0.2.0; use a built-in provider or a local endpoint."
37
+ );
38
+ }
39
+ const apiKey = apiKeyEnv ? (env[apiKeyEnv] ?? null) : null;
40
+
41
+ return {
42
+ id,
43
+ label,
44
+ local: location.local,
45
+
46
+ async detect() {
47
+ if (apiKeyEnv && !apiKey) {
48
+ return {
49
+ id,
50
+ label,
51
+ state: CAPABILITY_STATES.UNKNOWN,
52
+ detected: false,
53
+ available: false,
54
+ hasApiKey: false,
55
+ error: null,
56
+ recommendation: `Set ${apiKeyEnv} in the environment. Kairo never stores credentials.`
57
+ };
58
+ }
59
+
60
+ return {
61
+ id,
62
+ label,
63
+ state: CAPABILITY_STATES.DETECTED,
64
+ detected: true,
65
+ available: Boolean(modelId),
66
+ hasApiKey: apiKeyEnv ? Boolean(apiKey) : null,
67
+ error: null,
68
+ recommendation: modelId
69
+ ? `Custom provider configured (${modelId}).`
70
+ : "Custom provider baseUrl set; configure modelId in profile."
71
+ };
72
+ },
73
+
74
+ async listModels() {
75
+ if (!modelId) return [];
76
+ return [
77
+ createModelDescriptor({
78
+ provider: id,
79
+ modelId,
80
+ local: location.local,
81
+ costClass: location.local ? COST_CLASSES.LOCAL : COST_CLASSES.UNKNOWN,
82
+ privacyClass: location.local ? PRIVACY_CLASSES.LOCAL : PRIVACY_CLASSES.CLOUD,
83
+ opaque: true
84
+ })
85
+ ];
86
+ },
87
+
88
+ async capabilities() {
89
+ const detection = await this.detect();
90
+ return {
91
+ id,
92
+ local: location.local,
93
+ cloud: !location.local,
94
+ requiresApiKey: Boolean(apiKeyEnv),
95
+ requiresConsent: !location.local,
96
+ streaming: false,
97
+ tools: false,
98
+ state: detection.state,
99
+ baseUrl: normalizedBase,
100
+ modelId
101
+ };
102
+ },
103
+
104
+ async invoke(contextPack, request = {}) {
105
+ if (apiKeyEnv && !apiKey) {
106
+ return invokeError(id, `Missing ${apiKeyEnv}. Kairo never stores credentials.`);
107
+ }
108
+
109
+ const resolvedModel = request.modelId ?? modelId;
110
+ if (!resolvedModel) {
111
+ return invokeError(id, "Custom invoke requires modelId.");
112
+ }
113
+
114
+ const messages = [];
115
+ if (contextPack?.systemPrompt) {
116
+ messages.push({ role: "system", content: contextPack.systemPrompt });
117
+ }
118
+ if (Array.isArray(request.messages) && request.messages.length > 0) {
119
+ messages.push(...request.messages);
120
+ } else if (request.prompt) {
121
+ messages.push({ role: "user", content: request.prompt });
122
+ }
123
+
124
+ const headers = { "Content-Type": "application/json" };
125
+ if (apiKey && location.credentialSafe) headers.Authorization = `Bearer ${apiKey}`;
126
+
127
+ const result = await fetchJson(`${normalizedBase}/chat/completions`, {
128
+ method: "POST",
129
+ headers,
130
+ body: {
131
+ model: resolvedModel,
132
+ messages,
133
+ stream: false
134
+ },
135
+ timeoutMs: request.timeoutMs ?? 120000,
136
+ fetchImpl
137
+ });
138
+
139
+ if (!result.ok) {
140
+ return invokeError(id, result.error ?? "Custom provider chat failed.");
141
+ }
142
+
143
+ const content = result.data?.choices?.[0]?.message?.content ?? "";
144
+ const usage = result.data?.usage ?? {};
145
+
146
+ return {
147
+ ok: true,
148
+ backendId: id,
149
+ model: resolvedModel,
150
+ content,
151
+ usage: {
152
+ inputTokens: usage.prompt_tokens ?? null,
153
+ outputTokens: usage.completion_tokens ?? null,
154
+ cachedTokens: null,
155
+ estimatedCost: null,
156
+ model: resolvedModel,
157
+ backendId: id,
158
+ fallbackUsed: false
159
+ },
160
+ raw: result.data
161
+ };
162
+ }
163
+ };
164
+ }
165
+
166
+ function invokeError(backendId, message) {
167
+ return {
168
+ ok: false,
169
+ backendId,
170
+ model: null,
171
+ content: null,
172
+ error: message,
173
+ usage: null
174
+ };
175
+ }
@@ -0,0 +1,164 @@
1
+ import {
2
+ BACKEND_IDS,
3
+ COST_CLASSES,
4
+ DEFAULT_OLLAMA_HOST,
5
+ PRIVACY_CLASSES,
6
+ createModelDescriptor
7
+ } from "../types.js";
8
+ import { fetchJson } from "../http.js";
9
+ import { CAPABILITY_STATES } from "../../capability-states.js";
10
+
11
+ export function createOllamaBackend({
12
+ host = null,
13
+ fetchImpl = globalThis.fetch,
14
+ env = process.env
15
+ } = {}) {
16
+ const baseUrl = normalizeOllamaHost(host ?? env.OLLAMA_HOST ?? DEFAULT_OLLAMA_HOST);
17
+
18
+ return {
19
+ id: BACKEND_IDS.OLLAMA,
20
+ label: "Ollama",
21
+ local: true,
22
+
23
+ async detect() {
24
+ const result = await fetchJson(`${baseUrl}/api/tags`, {
25
+ timeoutMs: 2000,
26
+ fetchImpl
27
+ });
28
+
29
+ if (!result.ok) {
30
+ return {
31
+ id: BACKEND_IDS.OLLAMA,
32
+ label: "Ollama",
33
+ state: CAPABILITY_STATES.UNKNOWN,
34
+ detected: false,
35
+ available: false,
36
+ host: baseUrl,
37
+ error: result.error,
38
+ recommendation: `Start Ollama locally (${DEFAULT_OLLAMA_HOST}) or set OLLAMA_HOST.`
39
+ };
40
+ }
41
+
42
+ const models = parseOllamaModels(result.data);
43
+ return {
44
+ id: BACKEND_IDS.OLLAMA,
45
+ label: "Ollama",
46
+ state: models.length > 0 ? CAPABILITY_STATES.AVAILABLE : CAPABILITY_STATES.DETECTED,
47
+ detected: true,
48
+ available: models.length > 0,
49
+ host: baseUrl,
50
+ modelCount: models.length,
51
+ error: null,
52
+ recommendation: models.length > 0
53
+ ? `Ollama ready with ${models.length} local model(s).`
54
+ : "Ollama is running but no models are pulled yet."
55
+ };
56
+ },
57
+
58
+ async listModels() {
59
+ const result = await fetchJson(`${baseUrl}/api/tags`, { fetchImpl });
60
+ if (!result.ok) return [];
61
+ return parseOllamaModels(result.data);
62
+ },
63
+
64
+ async capabilities() {
65
+ const detection = await this.detect();
66
+ return {
67
+ id: BACKEND_IDS.OLLAMA,
68
+ local: true,
69
+ cloud: false,
70
+ requiresApiKey: false,
71
+ requiresConsent: false,
72
+ streaming: true,
73
+ tools: false,
74
+ state: detection.state,
75
+ host: baseUrl
76
+ };
77
+ },
78
+
79
+ async invoke(contextPack, request = {}) {
80
+ const modelId = request.modelId;
81
+ if (!modelId) {
82
+ return invokeError("Ollama invoke requires modelId.");
83
+ }
84
+
85
+ const messages = buildChatMessages(contextPack, request);
86
+ const result = await fetchJson(`${baseUrl}/api/chat`, {
87
+ method: "POST",
88
+ headers: { "Content-Type": "application/json" },
89
+ body: {
90
+ model: modelId,
91
+ messages,
92
+ stream: false,
93
+ options: request.options ?? undefined
94
+ },
95
+ timeoutMs: request.timeoutMs ?? 120000,
96
+ fetchImpl
97
+ });
98
+
99
+ if (!result.ok) {
100
+ return invokeError(result.error ?? "Ollama chat failed.");
101
+ }
102
+
103
+ const content = result.data?.message?.content ?? "";
104
+ return {
105
+ ok: true,
106
+ backendId: BACKEND_IDS.OLLAMA,
107
+ model: modelId,
108
+ content,
109
+ usage: {
110
+ inputTokens: result.data?.prompt_eval_count ?? null,
111
+ outputTokens: result.data?.eval_count ?? null,
112
+ cachedTokens: null,
113
+ estimatedCost: 0,
114
+ model: modelId,
115
+ backendId: BACKEND_IDS.OLLAMA,
116
+ fallbackUsed: false
117
+ },
118
+ raw: result.data
119
+ };
120
+ }
121
+ };
122
+ }
123
+
124
+ function parseOllamaModels(data) {
125
+ const models = Array.isArray(data?.models) ? data.models : [];
126
+ return models.map((entry) => createModelDescriptor({
127
+ provider: BACKEND_IDS.OLLAMA,
128
+ modelId: entry.name ?? entry.model,
129
+ local: true,
130
+ costClass: COST_CLASSES.LOCAL,
131
+ privacyClass: PRIVACY_CLASSES.LOCAL,
132
+ contextLimit: null,
133
+ tools: false,
134
+ reasoning: false
135
+ })).filter((entry) => Boolean(entry.modelId));
136
+ }
137
+
138
+ function normalizeOllamaHost(host) {
139
+ return String(host).replace(/\/+$/, "");
140
+ }
141
+
142
+ function buildChatMessages(contextPack, request) {
143
+ const messages = [];
144
+ if (contextPack?.systemPrompt) {
145
+ messages.push({ role: "system", content: contextPack.systemPrompt });
146
+ }
147
+ if (Array.isArray(request.messages) && request.messages.length > 0) {
148
+ messages.push(...request.messages);
149
+ } else if (request.prompt) {
150
+ messages.push({ role: "user", content: request.prompt });
151
+ }
152
+ return messages;
153
+ }
154
+
155
+ function invokeError(message) {
156
+ return {
157
+ ok: false,
158
+ backendId: BACKEND_IDS.OLLAMA,
159
+ model: null,
160
+ content: null,
161
+ error: message,
162
+ usage: null
163
+ };
164
+ }
@@ -0,0 +1,198 @@
1
+ import {
2
+ BACKEND_IDS,
3
+ COST_CLASSES,
4
+ OPENROUTER_FREE_MODEL,
5
+ PRIVACY_CLASSES,
6
+ createModelDescriptor
7
+ } from "../types.js";
8
+ import { fetchJson } from "../http.js";
9
+ import { CAPABILITY_STATES } from "../../capability-states.js";
10
+
11
+ const OPENROUTER_BASE = "https://openrouter.ai/api/v1";
12
+
13
+ export function createOpenRouterBackend({
14
+ fetchImpl = globalThis.fetch,
15
+ env = process.env,
16
+ apiKey = null
17
+ } = {}) {
18
+ const resolvedKey = apiKey ?? env.OPENROUTER_API_KEY ?? null;
19
+
20
+ return {
21
+ id: BACKEND_IDS.OPENROUTER,
22
+ label: "OpenRouter",
23
+ local: false,
24
+
25
+ async detect() {
26
+ if (!resolvedKey) {
27
+ return {
28
+ id: BACKEND_IDS.OPENROUTER,
29
+ label: "OpenRouter",
30
+ state: CAPABILITY_STATES.UNKNOWN,
31
+ detected: false,
32
+ available: false,
33
+ hasApiKey: false,
34
+ error: null,
35
+ recommendation: "Set OPENROUTER_API_KEY in the environment to enable OpenRouter (never stored by Kairo)."
36
+ };
37
+ }
38
+
39
+ return {
40
+ id: BACKEND_IDS.OPENROUTER,
41
+ label: "OpenRouter",
42
+ state: CAPABILITY_STATES.AUTHENTICATED,
43
+ detected: true,
44
+ available: true,
45
+ hasApiKey: true,
46
+ error: null,
47
+ recommendation: "OpenRouter API key detected in environment. Cloud use still requires explicit consent."
48
+ };
49
+ },
50
+
51
+ async listModels({ freeOnly = true } = {}) {
52
+ if (!resolvedKey) return [];
53
+
54
+ const result = await fetchJson(`${OPENROUTER_BASE}/models`, {
55
+ headers: {
56
+ Authorization: `Bearer ${resolvedKey}`,
57
+ "Content-Type": "application/json"
58
+ },
59
+ fetchImpl
60
+ });
61
+
62
+ if (!result.ok) {
63
+ return [
64
+ createModelDescriptor({
65
+ provider: BACKEND_IDS.OPENROUTER,
66
+ modelId: OPENROUTER_FREE_MODEL,
67
+ local: false,
68
+ costClass: COST_CLASSES.FREE,
69
+ privacyClass: PRIVACY_CLASSES.CLOUD,
70
+ opaque: true
71
+ })
72
+ ];
73
+ }
74
+
75
+ const models = Array.isArray(result.data?.data) ? result.data.data : [];
76
+ const mapped = models
77
+ .filter((entry) => {
78
+ if (!freeOnly) return true;
79
+ const id = entry.id ?? "";
80
+ return id.endsWith(":free") || id === OPENROUTER_FREE_MODEL;
81
+ })
82
+ .map((entry) => createModelDescriptor({
83
+ provider: BACKEND_IDS.OPENROUTER,
84
+ modelId: entry.id,
85
+ local: false,
86
+ costClass: COST_CLASSES.FREE,
87
+ privacyClass: PRIVACY_CLASSES.CLOUD,
88
+ contextLimit: entry.context_length ?? null,
89
+ tools: Boolean(entry.supported_parameters?.includes?.("tools")),
90
+ reasoning: false
91
+ }));
92
+
93
+ if (!mapped.some((entry) => entry.modelId === OPENROUTER_FREE_MODEL)) {
94
+ mapped.unshift(createModelDescriptor({
95
+ provider: BACKEND_IDS.OPENROUTER,
96
+ modelId: OPENROUTER_FREE_MODEL,
97
+ local: false,
98
+ costClass: COST_CLASSES.FREE,
99
+ privacyClass: PRIVACY_CLASSES.CLOUD,
100
+ opaque: true
101
+ }));
102
+ }
103
+
104
+ return mapped;
105
+ },
106
+
107
+ async capabilities() {
108
+ const detection = await this.detect();
109
+ return {
110
+ id: BACKEND_IDS.OPENROUTER,
111
+ local: false,
112
+ cloud: true,
113
+ requiresApiKey: true,
114
+ requiresConsent: true,
115
+ streaming: true,
116
+ tools: true,
117
+ freeRouterModel: OPENROUTER_FREE_MODEL,
118
+ state: detection.state,
119
+ hasApiKey: Boolean(resolvedKey)
120
+ };
121
+ },
122
+
123
+ async invoke(contextPack, request = {}) {
124
+ if (!resolvedKey) {
125
+ return invokeError("OPENROUTER_API_KEY is not set. Kairo never stores credentials.");
126
+ }
127
+
128
+ const modelId = request.modelId ?? OPENROUTER_FREE_MODEL;
129
+ const messages = buildChatMessages(contextPack, request);
130
+
131
+ const result = await fetchJson(`${OPENROUTER_BASE}/chat/completions`, {
132
+ method: "POST",
133
+ headers: {
134
+ Authorization: `Bearer ${resolvedKey}`,
135
+ "Content-Type": "application/json",
136
+ "HTTP-Referer": "https://github.com/Kal-elSam/harness",
137
+ "X-OpenRouter-Title": "Kairo Runtime"
138
+ },
139
+ body: {
140
+ model: modelId,
141
+ messages,
142
+ stream: false
143
+ },
144
+ timeoutMs: request.timeoutMs ?? 120000,
145
+ fetchImpl
146
+ });
147
+
148
+ if (!result.ok) {
149
+ return invokeError(result.error ?? "OpenRouter chat failed.");
150
+ }
151
+
152
+ const content = result.data?.choices?.[0]?.message?.content ?? "";
153
+ const usedModel = result.data?.model ?? modelId;
154
+ const usage = result.data?.usage ?? {};
155
+
156
+ return {
157
+ ok: true,
158
+ backendId: BACKEND_IDS.OPENROUTER,
159
+ model: usedModel,
160
+ content,
161
+ usage: {
162
+ inputTokens: usage.prompt_tokens ?? null,
163
+ outputTokens: usage.completion_tokens ?? null,
164
+ cachedTokens: usage.prompt_tokens_details?.cached_tokens ?? null,
165
+ estimatedCost: null,
166
+ model: usedModel,
167
+ backendId: BACKEND_IDS.OPENROUTER,
168
+ fallbackUsed: usedModel !== modelId
169
+ },
170
+ raw: result.data
171
+ };
172
+ }
173
+ };
174
+ }
175
+
176
+ function buildChatMessages(contextPack, request) {
177
+ const messages = [];
178
+ if (contextPack?.systemPrompt) {
179
+ messages.push({ role: "system", content: contextPack.systemPrompt });
180
+ }
181
+ if (Array.isArray(request.messages) && request.messages.length > 0) {
182
+ messages.push(...request.messages);
183
+ } else if (request.prompt) {
184
+ messages.push({ role: "user", content: request.prompt });
185
+ }
186
+ return messages;
187
+ }
188
+
189
+ function invokeError(message) {
190
+ return {
191
+ ok: false,
192
+ backendId: BACKEND_IDS.OPENROUTER,
193
+ model: null,
194
+ content: null,
195
+ error: message,
196
+ usage: null
197
+ };
198
+ }