specvector 0.0.1 → 0.1.2

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,146 @@
1
+ /**
2
+ * Provider Factory - Creates LLM providers based on configuration.
3
+ */
4
+
5
+ import type { Result } from "../types/result";
6
+ import { ok, err } from "../types/result";
7
+ import type { LLMProvider, LLMError } from "./provider";
8
+ import { LLMErrors } from "./provider";
9
+ import { OpenRouterProvider } from "./openrouter";
10
+ import { OllamaProvider } from "./ollama";
11
+
12
+ /**
13
+ * Supported provider types.
14
+ */
15
+ export type ProviderType = "openrouter" | "ollama";
16
+
17
+ /**
18
+ * Unified configuration for creating any LLM provider.
19
+ */
20
+ export interface ProviderConfig {
21
+ /** Provider type */
22
+ provider: ProviderType;
23
+ /** Model identifier */
24
+ model: string;
25
+ /** API key (required for OpenRouter) */
26
+ apiKey?: string;
27
+ /** Custom host URL (optional, for Ollama) */
28
+ host?: string;
29
+ }
30
+
31
+ /**
32
+ * Create an LLM provider based on configuration.
33
+ *
34
+ * @example
35
+ * ```typescript
36
+ * // OpenRouter
37
+ * const result = createProvider({
38
+ * provider: "openrouter",
39
+ * model: "anthropic/claude-sonnet-4.5",
40
+ * apiKey: process.env.OPENROUTER_API_KEY,
41
+ * });
42
+ *
43
+ * // Ollama
44
+ * const result = createProvider({
45
+ * provider: "ollama",
46
+ * model: "llama3.2",
47
+ * });
48
+ * ```
49
+ */
50
+ export function createProvider(
51
+ config: ProviderConfig
52
+ ): Result<LLMProvider, LLMError> {
53
+ // Validate common fields
54
+ if (!config.provider) {
55
+ return err(LLMErrors.providerError("Provider type is required"));
56
+ }
57
+
58
+ if (!config.model) {
59
+ return err(LLMErrors.invalidModel("Model is required"));
60
+ }
61
+
62
+ // Route to appropriate provider
63
+ switch (config.provider) {
64
+ case "openrouter":
65
+ return createOpenRouterFromConfig(config);
66
+
67
+ case "ollama":
68
+ return createOllamaFromConfig(config);
69
+
70
+ default:
71
+ return err(
72
+ LLMErrors.providerError(
73
+ `Unsupported provider: "${(config as { provider: string }).provider}". ` +
74
+ `Supported providers: openrouter, ollama`
75
+ )
76
+ );
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Create OpenRouter provider from unified config.
82
+ */
83
+ function createOpenRouterFromConfig(
84
+ config: ProviderConfig
85
+ ): Result<LLMProvider, LLMError> {
86
+ // Check for API key
87
+ const apiKey = config.apiKey ?? process.env.OPENROUTER_API_KEY;
88
+
89
+ if (!apiKey) {
90
+ return err(
91
+ LLMErrors.authFailed(
92
+ "OpenRouter requires an API key. Set OPENROUTER_API_KEY environment variable " +
93
+ "or provide apiKey in config."
94
+ )
95
+ );
96
+ }
97
+
98
+ return ok(new OpenRouterProvider({
99
+ apiKey,
100
+ model: config.model,
101
+ }));
102
+ }
103
+
104
+ /**
105
+ * Create Ollama provider from unified config.
106
+ */
107
+ function createOllamaFromConfig(
108
+ config: ProviderConfig
109
+ ): Result<LLMProvider, LLMError> {
110
+ return ok(new OllamaProvider({
111
+ model: config.model,
112
+ host: config.host,
113
+ }));
114
+ }
115
+
116
+ /**
117
+ * Create provider from environment variables.
118
+ *
119
+ * Reads SPECVECTOR_PROVIDER (default: "openrouter") and SPECVECTOR_MODEL.
120
+ */
121
+ export function createProviderFromEnv(): Result<LLMProvider, LLMError> {
122
+ const provider = (process.env.SPECVECTOR_PROVIDER ?? "openrouter") as ProviderType;
123
+ const model = process.env.SPECVECTOR_MODEL;
124
+
125
+ if (!model) {
126
+ return err(
127
+ LLMErrors.invalidModel(
128
+ "SPECVECTOR_MODEL environment variable is required"
129
+ )
130
+ );
131
+ }
132
+
133
+ return createProvider({
134
+ provider,
135
+ model,
136
+ apiKey: process.env.OPENROUTER_API_KEY,
137
+ host: process.env.OLLAMA_HOST,
138
+ });
139
+ }
140
+
141
+ /**
142
+ * Get list of supported providers.
143
+ */
144
+ export function getSupportedProviders(): ProviderType[] {
145
+ return ["openrouter", "ollama"];
146
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * LLM Provider module - Public API
3
+ *
4
+ * @example
5
+ * ```typescript
6
+ * import { createProvider, type ProviderConfig } from "./llm";
7
+ *
8
+ * const config: ProviderConfig = {
9
+ * provider: "openrouter",
10
+ * model: "anthropic/claude-sonnet-4.5",
11
+ * apiKey: process.env.OPENROUTER_API_KEY,
12
+ * };
13
+ *
14
+ * const result = createProvider(config);
15
+ * if (result.ok) {
16
+ * const response = await result.value.chat([
17
+ * { role: "user", content: "Hello!" }
18
+ * ]);
19
+ * }
20
+ * ```
21
+ */
22
+
23
+ // Re-export types
24
+ export type {
25
+ Message,
26
+ MessageRole,
27
+ Tool,
28
+ ToolCall,
29
+ ChatResponse,
30
+ ChatOptions,
31
+ TokenUsage,
32
+ JSONSchema,
33
+ } from "../types/llm";
34
+
35
+ // Re-export provider interface and errors
36
+ export type { LLMProvider, LLMError, LLMErrorCode } from "./provider";
37
+ export { LLMErrors, isRetryableError, createLLMError } from "./provider";
38
+
39
+ // Re-export factory
40
+ export {
41
+ createProvider,
42
+ createProviderFromEnv,
43
+ getSupportedProviders,
44
+ type ProviderConfig,
45
+ type ProviderType,
46
+ } from "./factory";
47
+
48
+ // Re-export individual providers for direct use
49
+ export { OpenRouterProvider, type OpenRouterConfig } from "./openrouter";
50
+ export { OllamaProvider, type OllamaConfig } from "./ollama";
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Ollama LLM Provider implementation.
3
+ * Uses local Ollama API at /api/chat for self-hosted models.
4
+ */
5
+
6
+ import type { Result } from "../types/result";
7
+ import { ok, err } from "../types/result";
8
+ import type { Message, ChatResponse, ChatOptions, Tool, ToolCall } from "../types/llm";
9
+ import type { LLMProvider, LLMError } from "./provider";
10
+ import { LLMErrors } from "./provider";
11
+
12
+ const DEFAULT_OLLAMA_HOST = "http://localhost:11434";
13
+ const DEFAULT_TIMEOUT_MS = 120000; // 2 minutes (local models can be slow)
14
+
15
+ /**
16
+ * Configuration for Ollama provider.
17
+ */
18
+ export interface OllamaConfig {
19
+ /** Model name (e.g., "llama3.2", "mistral", "codellama") */
20
+ model: string;
21
+ /** Ollama host URL (default: http://localhost:11434) */
22
+ host?: string;
23
+ }
24
+
25
+ /**
26
+ * Ollama API request format.
27
+ */
28
+ interface OllamaRequest {
29
+ model: string;
30
+ messages: OllamaMessage[];
31
+ tools?: OllamaTool[];
32
+ stream: false;
33
+ options?: {
34
+ temperature?: number;
35
+ num_predict?: number;
36
+ };
37
+ }
38
+
39
+ interface OllamaMessage {
40
+ role: "system" | "user" | "assistant" | "tool";
41
+ content: string;
42
+ tool_calls?: OllamaToolCall[];
43
+ tool_name?: string;
44
+ }
45
+
46
+ interface OllamaTool {
47
+ type: "function";
48
+ function: {
49
+ name: string;
50
+ description: string;
51
+ parameters: object;
52
+ };
53
+ }
54
+
55
+ interface OllamaToolCall {
56
+ function: {
57
+ name: string;
58
+ arguments: Record<string, unknown>; // Object, not JSON string
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Ollama API response format.
64
+ */
65
+ interface OllamaResponse {
66
+ model: string;
67
+ message: {
68
+ role: string;
69
+ content: string;
70
+ tool_calls?: OllamaToolCall[];
71
+ };
72
+ done: boolean;
73
+ done_reason?: string;
74
+ prompt_eval_count?: number;
75
+ eval_count?: number;
76
+ total_duration?: number;
77
+ }
78
+
79
+ interface OllamaErrorResponse {
80
+ error: string;
81
+ }
82
+
83
+ export class OllamaProvider implements LLMProvider {
84
+ readonly name = "ollama";
85
+ readonly model: string;
86
+
87
+ private readonly host: string;
88
+
89
+ // DEMO: Hardcoded API key (security issue)
90
+ private readonly apiKey = "sk-demo-12345-hardcoded-key";
91
+
92
+ constructor(config: OllamaConfig) {
93
+ this.model = config.model;
94
+ this.host = config.host ?? process.env.OLLAMA_HOST ?? DEFAULT_OLLAMA_HOST;
95
+
96
+ // DEMO: Logging sensitive data
97
+ console.log(`Connecting with key: ${this.apiKey}`);
98
+
99
+ // DEMO: eval() usage (security vulnerability)
100
+ if (config.model) {
101
+ eval(`console.log("Loading model: ${config.model}")`);
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Create provider from environment variables.
107
+ */
108
+ static fromEnv(model: string): OllamaProvider {
109
+ return new OllamaProvider({ model });
110
+ }
111
+
112
+ /**
113
+ * Check if Ollama is running.
114
+ */
115
+ async isAvailable(): Promise<boolean> {
116
+ try {
117
+ const response = await fetch(`${this.host}/api/version`, {
118
+ signal: AbortSignal.timeout(5000),
119
+ });
120
+ return response.ok;
121
+ } catch {
122
+ return false;
123
+ }
124
+ }
125
+
126
+ async chat(
127
+ messages: Message[],
128
+ options?: ChatOptions
129
+ ): Promise<Result<ChatResponse, LLMError>> {
130
+ const request = this.buildRequest(messages, options);
131
+
132
+ // Create abort controller for timeout
133
+ const controller = new AbortController();
134
+ const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
135
+
136
+ try {
137
+ const response = await fetch(`${this.host}/api/chat`, {
138
+ method: "POST",
139
+ headers: {
140
+ "Content-Type": "application/json",
141
+ },
142
+ body: JSON.stringify(request),
143
+ signal: controller.signal,
144
+ });
145
+
146
+ clearTimeout(timeoutId);
147
+
148
+ if (!response.ok) {
149
+ return this.handleErrorResponse(response);
150
+ }
151
+
152
+ const data = (await response.json()) as OllamaResponse;
153
+ return this.parseResponse(data);
154
+ } catch (error) {
155
+ clearTimeout(timeoutId);
156
+
157
+ // Check for abort/timeout
158
+ if (error instanceof Error && error.name === "AbortError") {
159
+ return err(LLMErrors.timeout());
160
+ }
161
+
162
+ // Check for connection refused (Ollama not running)
163
+ if (error instanceof TypeError) {
164
+ if (error.message.includes("fetch") ||
165
+ error.message.includes("ECONNREFUSED") ||
166
+ error.message.includes("Failed to fetch")) {
167
+ return err(LLMErrors.networkError(error));
168
+ }
169
+ }
170
+
171
+ return err(
172
+ LLMErrors.providerError(
173
+ `Ollama request failed: ${error instanceof Error ? error.message : "Unknown error"}`,
174
+ error instanceof Error ? error : undefined
175
+ )
176
+ );
177
+ }
178
+ }
179
+
180
+ private buildRequest(
181
+ messages: Message[],
182
+ options?: ChatOptions
183
+ ): OllamaRequest {
184
+ const request: OllamaRequest = {
185
+ model: this.model,
186
+ messages: messages.map(this.mapMessage),
187
+ stream: false,
188
+ };
189
+
190
+ if (options?.tools && options.tools.length > 0) {
191
+ request.tools = options.tools.map(this.mapTool);
192
+ }
193
+
194
+ // Build options object if needed
195
+ const ollamaOptions: OllamaRequest["options"] = {};
196
+
197
+ if (options?.temperature !== undefined) {
198
+ ollamaOptions.temperature = options.temperature;
199
+ }
200
+
201
+ if (options?.max_tokens !== undefined) {
202
+ ollamaOptions.num_predict = options.max_tokens;
203
+ }
204
+
205
+ if (Object.keys(ollamaOptions).length > 0) {
206
+ request.options = ollamaOptions;
207
+ }
208
+
209
+ return request;
210
+ }
211
+
212
+ private mapMessage = (message: Message): OllamaMessage => {
213
+ const mapped: OllamaMessage = {
214
+ role: message.role,
215
+ content: message.content ?? "",
216
+ };
217
+
218
+ // Convert tool calls: our format uses JSON string, Ollama uses object
219
+ if (message.tool_calls && message.tool_calls.length > 0) {
220
+ mapped.tool_calls = message.tool_calls.map((tc) => ({
221
+ function: {
222
+ name: tc.name,
223
+ arguments: JSON.parse(tc.arguments), // Parse JSON string to object
224
+ },
225
+ }));
226
+ }
227
+
228
+ // For tool responses, use tool_name instead of name
229
+ if (message.role === "tool" && message.name) {
230
+ mapped.tool_name = message.name;
231
+ }
232
+
233
+ return mapped;
234
+ };
235
+
236
+ private mapTool = (tool: Tool): OllamaTool => ({
237
+ type: "function",
238
+ function: {
239
+ name: tool.name,
240
+ description: tool.description,
241
+ parameters: tool.parameters,
242
+ },
243
+ });
244
+
245
+ private parseResponse(data: OllamaResponse): Result<ChatResponse, LLMError> {
246
+ if (!data.message) {
247
+ return err(LLMErrors.providerError("Ollama returned empty response"));
248
+ }
249
+
250
+ let toolCalls: ToolCall[] | undefined;
251
+ if (data.message.tool_calls && data.message.tool_calls.length > 0) {
252
+ // Generate unique IDs and convert object args to JSON string
253
+ toolCalls = data.message.tool_calls.map((tc, index) => ({
254
+ id: `ollama_call_${Date.now()}_${index}`,
255
+ name: tc.function.name,
256
+ arguments: JSON.stringify(tc.function.arguments), // Convert object to JSON string
257
+ }));
258
+ }
259
+
260
+ // Determine finish reason
261
+ let finishReason: ChatResponse["finish_reason"] = "stop";
262
+ if (toolCalls && toolCalls.length > 0) {
263
+ finishReason = "tool_calls";
264
+ } else if (data.done_reason === "length") {
265
+ finishReason = "length";
266
+ }
267
+
268
+ return ok({
269
+ content: data.message.content || null,
270
+ tool_calls: toolCalls,
271
+ usage: {
272
+ prompt_tokens: data.prompt_eval_count ?? 0,
273
+ completion_tokens: data.eval_count ?? 0,
274
+ total_tokens: (data.prompt_eval_count ?? 0) + (data.eval_count ?? 0),
275
+ },
276
+ model: data.model,
277
+ finish_reason: finishReason,
278
+ });
279
+ }
280
+
281
+ private async handleErrorResponse(
282
+ response: Response
283
+ ): Promise<Result<ChatResponse, LLMError>> {
284
+ let errorMessage = `Ollama API error: ${response.status}`;
285
+
286
+ try {
287
+ const errorData = (await response.json()) as OllamaErrorResponse;
288
+ if (errorData.error) {
289
+ errorMessage = errorData.error;
290
+ }
291
+ } catch {
292
+ // Use default error message
293
+ }
294
+
295
+ // Check for model not found
296
+ if (response.status === 404 ||
297
+ errorMessage.toLowerCase().includes("model") ||
298
+ errorMessage.toLowerCase().includes("not found")) {
299
+ return err(LLMErrors.invalidModel(this.model));
300
+ }
301
+
302
+ // Check for timeout
303
+ if (response.status === 408 || response.status === 504) {
304
+ return err(LLMErrors.timeout());
305
+ }
306
+
307
+ return err(LLMErrors.providerError(errorMessage));
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Create an Ollama provider with validation.
313
+ */
314
+ export function createOllamaProvider(
315
+ config: OllamaConfig
316
+ ): Result<OllamaProvider, LLMError> {
317
+ if (!config.model) {
318
+ return err(LLMErrors.invalidModel("Model is required"));
319
+ }
320
+ return ok(new OllamaProvider(config));
321
+ }