@rohirik/openltm-core 2.8.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.
- package/README.md +67 -0
- package/assets/opencode/agents/aegis.md +211 -0
- package/assets/opencode/plugins/aegis.ts +3 -0
- package/assets/opencode/skills/AgentTrustBoundaries/ContextCrushDefense.md +104 -0
- package/assets/opencode/skills/AgentTrustBoundaries/SKILL.md +31 -0
- package/assets/opencode/skills/AgentTrustBoundaries/TrustBoundaryPatterns.md +114 -0
- package/assets/opencode/skills/AgentTrustBoundaries/Workflows/DefendContextCrush.md +27 -0
- package/assets/opencode/skills/AgentTrustBoundaries/Workflows/HandleUntrustedContent.md +27 -0
- package/assets/opencode/skills/CommandPathSafety/CommandInjectionPatterns.md +95 -0
- package/assets/opencode/skills/CommandPathSafety/PathTraversalAndInstallerSafety.md +106 -0
- package/assets/opencode/skills/CommandPathSafety/SKILL.md +31 -0
- package/assets/opencode/skills/CommandPathSafety/Workflows/EnforcePathBoundaries.md +27 -0
- package/assets/opencode/skills/CommandPathSafety/Workflows/HardenCommandExecution.md +27 -0
- package/assets/opencode/skills/SecretSafeHandling/CloudCredentialPatterns.md +106 -0
- package/assets/opencode/skills/SecretSafeHandling/SKILL.md +31 -0
- package/assets/opencode/skills/SecretSafeHandling/SecretHandlingPlaybook.md +102 -0
- package/assets/opencode/skills/SecretSafeHandling/Workflows/DesignSecretSafeFlow.md +27 -0
- package/assets/opencode/skills/SecretSafeHandling/Workflows/RemoveSecretExposure.md +27 -0
- package/package.json +41 -0
- package/src/__tests__/cli/claude.test.ts +122 -0
- package/src/__tests__/cli/detect.test.ts +91 -0
- package/src/__tests__/cli/install.test.ts +161 -0
- package/src/__tests__/cli/opencode.test.ts +169 -0
- package/src/__tests__/cli/pi.test.ts +113 -0
- package/src/__tests__/cli.test.ts +70 -0
- package/src/__tests__/events/crossProcess.test.ts +82 -0
- package/src/__tests__/events/index.test.ts +32 -0
- package/src/__tests__/extensions.test.ts +81 -0
- package/src/__tests__/migrations/retention.test.ts +118 -0
- package/src/__tests__/queue/index.test.ts +61 -0
- package/src/__tests__/scheduler/index.test.ts +39 -0
- package/src/__tests__/vec/index.test.ts +130 -0
- package/src/__tests__/vec/parity.test.ts +70 -0
- package/src/adapterTypes.ts +23 -0
- package/src/cli/_shared.ts +120 -0
- package/src/cli/bin.ts +97 -0
- package/src/cli/claude.ts +124 -0
- package/src/cli/detect.ts +55 -0
- package/src/cli/hook.ts +25 -0
- package/src/cli/index.ts +22 -0
- package/src/cli/install.ts +185 -0
- package/src/cli/opencode.ts +193 -0
- package/src/cli/pi.ts +74 -0
- package/src/cli/types.ts +78 -0
- package/src/config.ts +163 -0
- package/src/context.ts +172 -0
- package/src/dao/conflicts.ts +26 -0
- package/src/dao/contextItems.ts +70 -0
- package/src/dao/embeddings.ts +78 -0
- package/src/dao/index.ts +9 -0
- package/src/dao/provenanceAudit.ts +108 -0
- package/src/dao/types.ts +142 -0
- package/src/db.ts +780 -0
- package/src/dedup.ts +12 -0
- package/src/embeddings.ts +386 -0
- package/src/events/index.ts +130 -0
- package/src/extensions.ts +140 -0
- package/src/graph.ts +268 -0
- package/src/index.ts +95 -0
- package/src/janitor/archive.ts +66 -0
- package/src/janitor/decay.ts +60 -0
- package/src/janitor/dedup.ts +333 -0
- package/src/janitor/embeddings.ts +209 -0
- package/src/janitor/index.ts +215 -0
- package/src/janitor/promote.ts +188 -0
- package/src/janitor/providers/anthropic.ts +91 -0
- package/src/janitor/providers/cohere.ts +135 -0
- package/src/janitor/providers/gemini.ts +156 -0
- package/src/janitor/providers/ollama.ts +177 -0
- package/src/janitor/providers/openai.ts +121 -0
- package/src/janitor/providers/openrouter.ts +182 -0
- package/src/janitor/providers/types.ts +154 -0
- package/src/janitor/providers/utils.ts +35 -0
- package/src/janitor/supersedes.ts +199 -0
- package/src/lib/honker.ts +54 -0
- package/src/lib/honkerTypes.ts +109 -0
- package/src/lib/jsonlLogger.ts +92 -0
- package/src/lib/writeQueue.ts +28 -0
- package/src/migrations.ts +415 -0
- package/src/paths.ts +22 -0
- package/src/proposals.ts +120 -0
- package/src/providers/disabled.ts +19 -0
- package/src/providers/embeddingProvider.ts +49 -0
- package/src/providers/gemini.ts +37 -0
- package/src/providers/index.ts +2 -0
- package/src/providers/ollama.ts +43 -0
- package/src/providers/openai.ts +35 -0
- package/src/queue/index.ts +53 -0
- package/src/queue/worker.ts +77 -0
- package/src/recall/categorise.ts +139 -0
- package/src/recall/explainer.ts +76 -0
- package/src/scheduler/index.ts +97 -0
- package/src/schema.sql +191 -0
- package/src/secretsScrubber.ts +105 -0
- package/src/shared-db.ts +158 -0
- package/src/vec/index.ts +161 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gemini.ts — Google Gemini provider for embeddings and LLM chat.
|
|
3
|
+
* Uses the Gemini REST API directly (no SDK dependency).
|
|
4
|
+
*/
|
|
5
|
+
import {
|
|
6
|
+
SETTING_KEYS,
|
|
7
|
+
type ChatInput,
|
|
8
|
+
type ChatResult,
|
|
9
|
+
type EmbedInput,
|
|
10
|
+
type EmbedResult,
|
|
11
|
+
type EmbeddingProvider,
|
|
12
|
+
type EmbeddingVector,
|
|
13
|
+
type LLMProvider,
|
|
14
|
+
} from "./types.js";
|
|
15
|
+
import { httpErrorResult, makeApiKeyGetter, makeModelGetter } from "./utils.js";
|
|
16
|
+
|
|
17
|
+
const GEMINI_API_BASE = "https://generativelanguage.googleapis.com/v1beta";
|
|
18
|
+
|
|
19
|
+
const getApiKey = makeApiKeyGetter(SETTING_KEYS.GEMINI_API_KEY, "GEMINI_API_KEY", "Gemini");
|
|
20
|
+
const getEmbedModel = makeModelGetter(SETTING_KEYS.GEMINI_EMBED_MODEL);
|
|
21
|
+
const getLlmModel = makeModelGetter(SETTING_KEYS.GEMINI_LLM_MODEL);
|
|
22
|
+
|
|
23
|
+
export const geminiEmbedding: EmbeddingProvider = {
|
|
24
|
+
name: "gemini",
|
|
25
|
+
|
|
26
|
+
async embed(input: EmbedInput): Promise<EmbedResult> {
|
|
27
|
+
const apiKey = getApiKey();
|
|
28
|
+
const model = getEmbedModel();
|
|
29
|
+
|
|
30
|
+
// Gemini batchEmbedContents supports up to 100 texts per call
|
|
31
|
+
const requests = input.texts.map((text) => ({
|
|
32
|
+
model: `models/${model}`,
|
|
33
|
+
content: { parts: [{ text }] },
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
const res = await fetch(
|
|
37
|
+
`${GEMINI_API_BASE}/models/${model}:batchEmbedContents?key=${apiKey}`,
|
|
38
|
+
{
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: { "Content-Type": "application/json" },
|
|
41
|
+
body: JSON.stringify({ requests }),
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
if (!res.ok) {
|
|
46
|
+
const body = await res.text();
|
|
47
|
+
throw new Error(`Gemini embed failed (${res.status}): ${body}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const data = (await res.json()) as {
|
|
51
|
+
embeddings: Array<{ values: number[] }>;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const vectors: EmbeddingVector[] = data.embeddings.map(
|
|
55
|
+
(e) => new Float32Array(e.values),
|
|
56
|
+
);
|
|
57
|
+
const dimensions = vectors[0]?.length ?? 0;
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
vectors,
|
|
61
|
+
model,
|
|
62
|
+
dimensions,
|
|
63
|
+
// Gemini doesn't return token counts for embeddings; estimate
|
|
64
|
+
totalTokens: input.texts.reduce(
|
|
65
|
+
(sum, t) => sum + Math.ceil(t.length / 4),
|
|
66
|
+
0,
|
|
67
|
+
),
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
async verify(): Promise<{ ok: boolean; error?: string }> {
|
|
72
|
+
try {
|
|
73
|
+
const apiKey = getApiKey();
|
|
74
|
+
// Verify the key by listing models — works regardless of which embed model is configured
|
|
75
|
+
const res = await fetch(`${GEMINI_API_BASE}/models?key=${apiKey}&pageSize=1`);
|
|
76
|
+
if (!res.ok) return httpErrorResult(res);
|
|
77
|
+
return { ok: true };
|
|
78
|
+
} catch (e) {
|
|
79
|
+
return { ok: false, error: String(e) };
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const geminiLLM: LLMProvider = {
|
|
85
|
+
name: "gemini",
|
|
86
|
+
|
|
87
|
+
async chat(input: ChatInput): Promise<ChatResult> {
|
|
88
|
+
const apiKey = getApiKey();
|
|
89
|
+
const model = getLlmModel();
|
|
90
|
+
|
|
91
|
+
// Convert ChatMessage[] to Gemini format
|
|
92
|
+
// Gemini uses "user"/"model" roles; system goes in systemInstruction
|
|
93
|
+
const systemMsg = input.messages.find((m) => m.role === "system");
|
|
94
|
+
const contents = input.messages
|
|
95
|
+
.filter((m) => m.role !== "system")
|
|
96
|
+
.map((m) => ({
|
|
97
|
+
role: m.role === "assistant" ? "model" : "user",
|
|
98
|
+
parts: [{ text: m.content }],
|
|
99
|
+
}));
|
|
100
|
+
|
|
101
|
+
const body: Record<string, unknown> = {
|
|
102
|
+
contents,
|
|
103
|
+
generationConfig: {
|
|
104
|
+
maxOutputTokens: input.maxTokens ?? 1024,
|
|
105
|
+
temperature: input.temperature ?? 0.1,
|
|
106
|
+
...(input.jsonMode ? { responseMimeType: "application/json" } : {}),
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
if (systemMsg) {
|
|
111
|
+
body.systemInstruction = { parts: [{ text: systemMsg.content }] };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const res = await fetch(
|
|
115
|
+
`${GEMINI_API_BASE}/models/${model}:generateContent?key=${apiKey}`,
|
|
116
|
+
{
|
|
117
|
+
method: "POST",
|
|
118
|
+
headers: { "Content-Type": "application/json" },
|
|
119
|
+
body: JSON.stringify(body),
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
if (!res.ok) {
|
|
124
|
+
const errBody = await res.text();
|
|
125
|
+
throw new Error(`Gemini chat failed (${res.status}): ${errBody}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const data = (await res.json()) as {
|
|
129
|
+
candidates: Array<{ content: { parts: Array<{ text: string }> } }>;
|
|
130
|
+
usageMetadata?: {
|
|
131
|
+
promptTokenCount?: number;
|
|
132
|
+
candidatesTokenCount?: number;
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const content = data.candidates?.[0]?.content?.parts?.[0]?.text ?? "";
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
content,
|
|
140
|
+
model,
|
|
141
|
+
promptTokens: data.usageMetadata?.promptTokenCount ?? 0,
|
|
142
|
+
completionTokens: data.usageMetadata?.candidatesTokenCount ?? 0,
|
|
143
|
+
};
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
async verify(): Promise<{ ok: boolean; error?: string }> {
|
|
147
|
+
try {
|
|
148
|
+
const apiKey = getApiKey();
|
|
149
|
+
const res = await fetch(`${GEMINI_API_BASE}/models?key=${apiKey}&pageSize=1`);
|
|
150
|
+
if (!res.ok) return httpErrorResult(res);
|
|
151
|
+
return { ok: true };
|
|
152
|
+
} catch (e) {
|
|
153
|
+
return { ok: false, error: String(e) };
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ollama.ts — Ollama local provider for embeddings and LLM chat.
|
|
3
|
+
* Connects to a local Ollama instance (default http://localhost:11434).
|
|
4
|
+
* No API key required — fully local inference.
|
|
5
|
+
*/
|
|
6
|
+
import { getSetting } from "../../shared-db.js";
|
|
7
|
+
import {
|
|
8
|
+
SETTING_KEYS,
|
|
9
|
+
getDefault,
|
|
10
|
+
type ChatInput,
|
|
11
|
+
type ChatResult,
|
|
12
|
+
type EmbedInput,
|
|
13
|
+
type EmbedResult,
|
|
14
|
+
type EmbeddingProvider,
|
|
15
|
+
type EmbeddingVector,
|
|
16
|
+
type LLMProvider,
|
|
17
|
+
} from "./types.js";
|
|
18
|
+
|
|
19
|
+
function getBaseUrl(): string {
|
|
20
|
+
return (
|
|
21
|
+
getSetting(SETTING_KEYS.OLLAMA_BASE_URL) ||
|
|
22
|
+
process.env.OLLAMA_BASE_URL ||
|
|
23
|
+
getDefault(SETTING_KEYS.OLLAMA_BASE_URL)
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getEmbedModel(): string {
|
|
28
|
+
return (
|
|
29
|
+
getSetting(SETTING_KEYS.OLLAMA_EMBED_MODEL) ||
|
|
30
|
+
getDefault(SETTING_KEYS.OLLAMA_EMBED_MODEL)
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getLlmModel(): string {
|
|
35
|
+
return (
|
|
36
|
+
getSetting(SETTING_KEYS.OLLAMA_LLM_MODEL) ||
|
|
37
|
+
getDefault(SETTING_KEYS.OLLAMA_LLM_MODEL)
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Verify Ollama is reachable and the specified model is pulled.
|
|
43
|
+
* Shared between embedding and LLM providers.
|
|
44
|
+
*/
|
|
45
|
+
async function verifyModel(model: string): Promise<{ ok: boolean; error?: string }> {
|
|
46
|
+
try {
|
|
47
|
+
const baseUrl = getBaseUrl();
|
|
48
|
+
|
|
49
|
+
const pingRes = await fetch(`${baseUrl}/api/tags`, {
|
|
50
|
+
signal: AbortSignal.timeout(3000),
|
|
51
|
+
});
|
|
52
|
+
if (!pingRes.ok) {
|
|
53
|
+
return { ok: false, error: `Ollama not reachable at ${baseUrl}` };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const tags = (await pingRes.json()) as {
|
|
57
|
+
models: Array<{ name: string }>;
|
|
58
|
+
};
|
|
59
|
+
const available = tags.models.map((m) => m.name.split(":")[0]);
|
|
60
|
+
if (!available.includes(model.split(":")[0])) {
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
error: `Model "${model}" not found. Available: ${available.join(", ")}. Run: ollama pull ${model}`,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { ok: true };
|
|
68
|
+
} catch (e) {
|
|
69
|
+
return { ok: false, error: String(e) };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const ollamaEmbedding: EmbeddingProvider = {
|
|
74
|
+
name: "ollama",
|
|
75
|
+
|
|
76
|
+
async embed(input: EmbedInput): Promise<EmbedResult> {
|
|
77
|
+
const baseUrl = getBaseUrl();
|
|
78
|
+
const model = getEmbedModel();
|
|
79
|
+
|
|
80
|
+
// Ollama doesn't support batch embedding natively; we call per-text
|
|
81
|
+
// and parallelize with Promise.all (local, so latency is low)
|
|
82
|
+
const results = await Promise.all(
|
|
83
|
+
input.texts.map(async (text) => {
|
|
84
|
+
const res = await fetch(`${baseUrl}/api/embed`, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
headers: { "Content-Type": "application/json" },
|
|
87
|
+
body: JSON.stringify({ model, input: text }),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (!res.ok) {
|
|
91
|
+
const body = await res.text();
|
|
92
|
+
throw new Error(`Ollama embed failed (${res.status}): ${body}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const data = (await res.json()) as {
|
|
96
|
+
embeddings: number[][];
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const embedding = data.embeddings[0];
|
|
100
|
+
if (!embedding) throw new Error("Ollama returned empty embedding");
|
|
101
|
+
return embedding;
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const vectors: EmbeddingVector[] = results.map(
|
|
106
|
+
(v) => new Float32Array(v),
|
|
107
|
+
);
|
|
108
|
+
const dimensions = vectors[0]?.length ?? 0;
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
vectors,
|
|
112
|
+
model,
|
|
113
|
+
dimensions,
|
|
114
|
+
// Ollama doesn't report token usage for embeddings
|
|
115
|
+
totalTokens: 0,
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
async verify(): Promise<{ ok: boolean; error?: string }> {
|
|
120
|
+
return verifyModel(getEmbedModel());
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export const ollamaLLM: LLMProvider = {
|
|
125
|
+
name: "ollama",
|
|
126
|
+
|
|
127
|
+
async chat(input: ChatInput): Promise<ChatResult> {
|
|
128
|
+
const baseUrl = getBaseUrl();
|
|
129
|
+
const model = getLlmModel();
|
|
130
|
+
|
|
131
|
+
const body: Record<string, unknown> = {
|
|
132
|
+
model,
|
|
133
|
+
messages: input.messages.map((m) => ({
|
|
134
|
+
role: m.role,
|
|
135
|
+
content: m.content,
|
|
136
|
+
})),
|
|
137
|
+
stream: false,
|
|
138
|
+
options: {
|
|
139
|
+
num_predict: input.maxTokens ?? 1024,
|
|
140
|
+
temperature: input.temperature ?? 0.1,
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
if (input.jsonMode) {
|
|
145
|
+
body.format = "json";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const res = await fetch(`${baseUrl}/api/chat`, {
|
|
149
|
+
method: "POST",
|
|
150
|
+
headers: { "Content-Type": "application/json" },
|
|
151
|
+
body: JSON.stringify(body),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
if (!res.ok) {
|
|
155
|
+
const errBody = await res.text();
|
|
156
|
+
throw new Error(`Ollama chat failed (${res.status}): ${errBody}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const data = (await res.json()) as {
|
|
160
|
+
message: { content: string };
|
|
161
|
+
model: string;
|
|
162
|
+
prompt_eval_count?: number;
|
|
163
|
+
eval_count?: number;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
content: data.message?.content ?? "",
|
|
168
|
+
model: data.model || model,
|
|
169
|
+
promptTokens: data.prompt_eval_count ?? 0,
|
|
170
|
+
completionTokens: data.eval_count ?? 0,
|
|
171
|
+
};
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
async verify(): Promise<{ ok: boolean; error?: string }> {
|
|
175
|
+
return verifyModel(getLlmModel());
|
|
176
|
+
},
|
|
177
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* openai.ts — OpenAI provider for embeddings and LLM chat.
|
|
3
|
+
* Supports text-embedding-3-* for embeddings and GPT-4o family for LLM.
|
|
4
|
+
*/
|
|
5
|
+
import {
|
|
6
|
+
SETTING_KEYS,
|
|
7
|
+
type ChatInput,
|
|
8
|
+
type ChatResult,
|
|
9
|
+
type EmbedInput,
|
|
10
|
+
type EmbedResult,
|
|
11
|
+
type EmbeddingProvider,
|
|
12
|
+
type EmbeddingVector,
|
|
13
|
+
type LLMProvider,
|
|
14
|
+
} from "./types.js";
|
|
15
|
+
import { httpErrorResult, makeApiKeyGetter, makeModelGetter } from "./utils.js";
|
|
16
|
+
|
|
17
|
+
const OPENAI_API_BASE = "https://api.openai.com/v1";
|
|
18
|
+
|
|
19
|
+
const getApiKey = makeApiKeyGetter(SETTING_KEYS.OPENAI_API_KEY, "OPENAI_API_KEY", "OpenAI");
|
|
20
|
+
const getEmbedModel = makeModelGetter(SETTING_KEYS.OPENAI_EMBED_MODEL);
|
|
21
|
+
const getLlmModel = makeModelGetter(SETTING_KEYS.OPENAI_LLM_MODEL);
|
|
22
|
+
|
|
23
|
+
function authHeaders(apiKey: string) {
|
|
24
|
+
return { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const openaiEmbedding: EmbeddingProvider = {
|
|
28
|
+
name: "openai",
|
|
29
|
+
|
|
30
|
+
async embed(input: EmbedInput): Promise<EmbedResult> {
|
|
31
|
+
const apiKey = getApiKey();
|
|
32
|
+
const model = getEmbedModel();
|
|
33
|
+
|
|
34
|
+
const res = await fetch(`${OPENAI_API_BASE}/embeddings`, {
|
|
35
|
+
method: "POST",
|
|
36
|
+
headers: authHeaders(apiKey),
|
|
37
|
+
body: JSON.stringify({ model, input: input.texts }),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
if (!res.ok) return Promise.reject(new Error((await httpErrorResult(res)).error));
|
|
41
|
+
|
|
42
|
+
const data = (await res.json()) as {
|
|
43
|
+
data: Array<{ embedding: number[]; index: number }>;
|
|
44
|
+
usage: { total_tokens: number };
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// OpenAI guarantees response order matches input order
|
|
48
|
+
const vectors: EmbeddingVector[] = data.data.map((e) => new Float32Array(e.embedding));
|
|
49
|
+
const dimensions = vectors[0]?.length ?? 0;
|
|
50
|
+
|
|
51
|
+
return { vectors, model, dimensions, totalTokens: data.usage.total_tokens };
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
async verify(): Promise<{ ok: boolean; error?: string }> {
|
|
55
|
+
try {
|
|
56
|
+
await this.embed({ texts: ["test"] });
|
|
57
|
+
return { ok: true };
|
|
58
|
+
} catch (e) {
|
|
59
|
+
return { ok: false, error: String(e) };
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const openaiLLM: LLMProvider = {
|
|
65
|
+
name: "openai",
|
|
66
|
+
|
|
67
|
+
async chat(input: ChatInput): Promise<ChatResult> {
|
|
68
|
+
const apiKey = getApiKey();
|
|
69
|
+
const model = getLlmModel();
|
|
70
|
+
|
|
71
|
+
const res = await fetch(`${OPENAI_API_BASE}/chat/completions`, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: authHeaders(apiKey),
|
|
74
|
+
body: JSON.stringify({
|
|
75
|
+
model,
|
|
76
|
+
messages: input.messages,
|
|
77
|
+
max_tokens: input.maxTokens ?? 1024,
|
|
78
|
+
temperature: input.temperature ?? 0.1,
|
|
79
|
+
...(input.jsonMode ? { response_format: { type: "json_object" } } : {}),
|
|
80
|
+
}),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (!res.ok) {
|
|
84
|
+
const { error } = await httpErrorResult(res);
|
|
85
|
+
throw new Error(`OpenAI chat failed: ${error}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const data = (await res.json()) as {
|
|
89
|
+
choices: Array<{ message: { content: string } }>;
|
|
90
|
+
model: string;
|
|
91
|
+
usage: { prompt_tokens: number; completion_tokens: number };
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
content: data.choices[0]?.message.content ?? "",
|
|
96
|
+
model: data.model,
|
|
97
|
+
promptTokens: data.usage.prompt_tokens,
|
|
98
|
+
completionTokens: data.usage.completion_tokens,
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
async verify(): Promise<{ ok: boolean; error?: string }> {
|
|
103
|
+
try {
|
|
104
|
+
const apiKey = getApiKey();
|
|
105
|
+
const model = getLlmModel();
|
|
106
|
+
const res = await fetch(`${OPENAI_API_BASE}/chat/completions`, {
|
|
107
|
+
method: "POST",
|
|
108
|
+
headers: authHeaders(apiKey),
|
|
109
|
+
body: JSON.stringify({
|
|
110
|
+
model,
|
|
111
|
+
messages: [{ role: "user", content: "Reply with OK" }],
|
|
112
|
+
max_tokens: 5,
|
|
113
|
+
}),
|
|
114
|
+
});
|
|
115
|
+
if (!res.ok) return httpErrorResult(res);
|
|
116
|
+
return { ok: true };
|
|
117
|
+
} catch (e) {
|
|
118
|
+
return { ok: false, error: String(e) };
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* openrouter.ts — OpenRouter provider for embeddings and LLM chat.
|
|
3
|
+
* Uses OpenAI-compatible API endpoints.
|
|
4
|
+
*/
|
|
5
|
+
import { getSetting } from "../../shared-db.js";
|
|
6
|
+
import {
|
|
7
|
+
SETTING_KEYS,
|
|
8
|
+
getDefault,
|
|
9
|
+
type ChatInput,
|
|
10
|
+
type ChatResult,
|
|
11
|
+
type EmbedInput,
|
|
12
|
+
type EmbedResult,
|
|
13
|
+
type EmbeddingProvider,
|
|
14
|
+
type EmbeddingVector,
|
|
15
|
+
type LLMProvider,
|
|
16
|
+
} from "./types.js";
|
|
17
|
+
|
|
18
|
+
const OPENROUTER_API_BASE = "https://openrouter.ai/api/v1";
|
|
19
|
+
|
|
20
|
+
function getApiKey(): string {
|
|
21
|
+
const key =
|
|
22
|
+
getSetting(SETTING_KEYS.OPENROUTER_API_KEY) ||
|
|
23
|
+
process.env.OPENROUTER_API_KEY ||
|
|
24
|
+
"";
|
|
25
|
+
if (!key) throw new Error("OpenRouter API key not configured. Set it in Settings or OPENROUTER_API_KEY env var.");
|
|
26
|
+
return key;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getEmbedModel(): string {
|
|
30
|
+
return (
|
|
31
|
+
getSetting(SETTING_KEYS.OPENROUTER_EMBED_MODEL) ||
|
|
32
|
+
getDefault(SETTING_KEYS.OPENROUTER_EMBED_MODEL)
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getLlmModel(): string {
|
|
37
|
+
return (
|
|
38
|
+
getSetting(SETTING_KEYS.OPENROUTER_LLM_MODEL) ||
|
|
39
|
+
getDefault(SETTING_KEYS.OPENROUTER_LLM_MODEL)
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function headers(apiKey: string): Record<string, string> {
|
|
44
|
+
return {
|
|
45
|
+
Authorization: `Bearer ${apiKey}`,
|
|
46
|
+
"Content-Type": "application/json",
|
|
47
|
+
"HTTP-Referer": "https://github.com/rohirikman/claude-ltm",
|
|
48
|
+
"X-Title": "Claude LTM Janitor",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const openrouterEmbedding: EmbeddingProvider = {
|
|
53
|
+
name: "openrouter",
|
|
54
|
+
|
|
55
|
+
async embed(input: EmbedInput): Promise<EmbedResult> {
|
|
56
|
+
const apiKey = getApiKey();
|
|
57
|
+
const model = getEmbedModel();
|
|
58
|
+
|
|
59
|
+
const res = await fetch(`${OPENROUTER_API_BASE}/embeddings`, {
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: headers(apiKey),
|
|
62
|
+
body: JSON.stringify({
|
|
63
|
+
model,
|
|
64
|
+
input: input.texts,
|
|
65
|
+
}),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (!res.ok) {
|
|
69
|
+
const body = await res.text();
|
|
70
|
+
throw new Error(`OpenRouter embed failed (${res.status}): ${body}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const data = (await res.json()) as {
|
|
74
|
+
data: Array<{ embedding: number[]; index: number }>;
|
|
75
|
+
model: string;
|
|
76
|
+
usage?: { prompt_tokens?: number; total_tokens?: number };
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Sort by index to maintain input order
|
|
80
|
+
const sorted = data.data.sort((a, b) => a.index - b.index);
|
|
81
|
+
const vectors: EmbeddingVector[] = sorted.map(
|
|
82
|
+
(d) => new Float32Array(d.embedding),
|
|
83
|
+
);
|
|
84
|
+
const dimensions = vectors[0]?.length ?? 0;
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
vectors,
|
|
88
|
+
model: data.model || model,
|
|
89
|
+
dimensions,
|
|
90
|
+
totalTokens: data.usage?.total_tokens ?? 0,
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
async verify(): Promise<{ ok: boolean; error?: string }> {
|
|
95
|
+
try {
|
|
96
|
+
const apiKey = getApiKey();
|
|
97
|
+
const model = getEmbedModel();
|
|
98
|
+
const res = await fetch(`${OPENROUTER_API_BASE}/embeddings`, {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: headers(apiKey),
|
|
101
|
+
body: JSON.stringify({ model, input: ["test"] }),
|
|
102
|
+
});
|
|
103
|
+
if (!res.ok) {
|
|
104
|
+
const body = await res.text();
|
|
105
|
+
return { ok: false, error: `${res.status}: ${body}` };
|
|
106
|
+
}
|
|
107
|
+
return { ok: true };
|
|
108
|
+
} catch (e) {
|
|
109
|
+
return { ok: false, error: String(e) };
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const openrouterLLM: LLMProvider = {
|
|
115
|
+
name: "openrouter",
|
|
116
|
+
|
|
117
|
+
async chat(input: ChatInput): Promise<ChatResult> {
|
|
118
|
+
const apiKey = getApiKey();
|
|
119
|
+
const model = getLlmModel();
|
|
120
|
+
|
|
121
|
+
const body: Record<string, unknown> = {
|
|
122
|
+
model,
|
|
123
|
+
messages: input.messages.map((m) => ({
|
|
124
|
+
role: m.role,
|
|
125
|
+
content: m.content,
|
|
126
|
+
})),
|
|
127
|
+
max_tokens: input.maxTokens ?? 1024,
|
|
128
|
+
temperature: input.temperature ?? 0.1,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
if (input.jsonMode) {
|
|
132
|
+
body.response_format = { type: "json_object" };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const res = await fetch(`${OPENROUTER_API_BASE}/chat/completions`, {
|
|
136
|
+
method: "POST",
|
|
137
|
+
headers: headers(apiKey),
|
|
138
|
+
body: JSON.stringify(body),
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
if (!res.ok) {
|
|
142
|
+
const errBody = await res.text();
|
|
143
|
+
throw new Error(`OpenRouter chat failed (${res.status}): ${errBody}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const data = (await res.json()) as {
|
|
147
|
+
choices: Array<{ message: { content: string } }>;
|
|
148
|
+
model: string;
|
|
149
|
+
usage?: { prompt_tokens?: number; completion_tokens?: number };
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
content: data.choices?.[0]?.message?.content ?? "",
|
|
154
|
+
model: data.model || model,
|
|
155
|
+
promptTokens: data.usage?.prompt_tokens ?? 0,
|
|
156
|
+
completionTokens: data.usage?.completion_tokens ?? 0,
|
|
157
|
+
};
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
async verify(): Promise<{ ok: boolean; error?: string }> {
|
|
161
|
+
try {
|
|
162
|
+
const apiKey = getApiKey();
|
|
163
|
+
const model = getLlmModel();
|
|
164
|
+
const res = await fetch(`${OPENROUTER_API_BASE}/chat/completions`, {
|
|
165
|
+
method: "POST",
|
|
166
|
+
headers: headers(apiKey),
|
|
167
|
+
body: JSON.stringify({
|
|
168
|
+
model,
|
|
169
|
+
messages: [{ role: "user", content: "Reply with OK" }],
|
|
170
|
+
max_tokens: 5,
|
|
171
|
+
}),
|
|
172
|
+
});
|
|
173
|
+
if (!res.ok) {
|
|
174
|
+
const body = await res.text();
|
|
175
|
+
return { ok: false, error: `${res.status}: ${body}` };
|
|
176
|
+
}
|
|
177
|
+
return { ok: true };
|
|
178
|
+
} catch (e) {
|
|
179
|
+
return { ok: false, error: String(e) };
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
};
|