main
1import { LLMProvider, Message, ToolDefinition, LLMResponse } from "./types.js";
2import { TSchema, Kind } from "@sinclair/typebox";
3
4interface OllamaMessage {
5 role: "system" | "user" | "assistant";
6 content: string;
7}
8
9interface OllamaTool {
10 type: "function";
11 function: {
12 name: string;
13 description: string;
14 parameters: Record<string, unknown>;
15 };
16}
17
18interface OllamaToolCall {
19 function: {
20 name: string;
21 arguments: Record<string, unknown>;
22 };
23}
24
25interface OllamaResponse {
26 message: {
27 role: string;
28 content: string;
29 tool_calls?: OllamaToolCall[];
30 };
31 done: boolean;
32 done_reason?: string;
33}
34
35export class OllamaProvider implements LLMProvider {
36 name = "ollama";
37 private baseUrl: string;
38 private model: string;
39
40 constructor(baseUrl: string, model: string) {
41 this.baseUrl = baseUrl.replace(/\/$/, "");
42 this.model = model;
43 }
44
45 async chat(
46 messages: Message[],
47 options: {
48 systemPrompt?: string;
49 tools?: ToolDefinition[];
50 maxTokens?: number;
51 }
52 ): Promise<LLMResponse> {
53 const ollamaMessages: OllamaMessage[] = [];
54
55 if (options.systemPrompt) {
56 ollamaMessages.push({
57 role: "system",
58 content: options.systemPrompt,
59 });
60 }
61
62 for (const m of messages) {
63 if (m.role === "system") continue;
64 ollamaMessages.push({
65 role: m.role,
66 content: m.content,
67 });
68 }
69
70 const tools: OllamaTool[] | undefined = options.tools?.map((t) => ({
71 type: "function" as const,
72 function: {
73 name: t.name,
74 description: t.description,
75 parameters: typeboxToJsonSchema(t.parameters),
76 },
77 }));
78
79 const response = await fetch(`${this.baseUrl}/api/chat`, {
80 method: "POST",
81 headers: { "Content-Type": "application/json" },
82 body: JSON.stringify({
83 model: this.model,
84 messages: ollamaMessages,
85 tools: tools?.length ? tools : undefined,
86 stream: false,
87 options: {
88 num_predict: options.maxTokens || 4096,
89 },
90 }),
91 });
92
93 if (!response.ok) {
94 throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
95 }
96
97 const data = (await response.json()) as OllamaResponse;
98
99 const textContent = data.message.content || null;
100 const toolCalls: LLMResponse["toolCalls"] = [];
101
102 if (data.message.tool_calls) {
103 for (const tc of data.message.tool_calls) {
104 toolCalls.push({
105 id: `call_${Date.now()}_${Math.random().toString(36).slice(2)}`,
106 name: tc.function.name,
107 arguments: tc.function.arguments || {},
108 });
109 }
110 }
111
112 let stopReason: LLMResponse["stopReason"] = "end_turn";
113 if (toolCalls.length > 0) {
114 stopReason = "tool_use";
115 } else if (data.done_reason === "length") {
116 stopReason = "max_tokens";
117 }
118
119 return {
120 content: textContent,
121 toolCalls,
122 stopReason,
123 };
124 }
125}
126
127function typeboxToJsonSchema(schema: TSchema): Record<string, unknown> {
128 const result: Record<string, unknown> = {
129 type: getJsonSchemaType(schema),
130 };
131
132 if (schema.description) {
133 result.description = schema.description;
134 }
135
136 if (schema[Kind] === "Object" && schema.properties) {
137 result.properties = {};
138 const required: string[] = [];
139
140 for (const [key, prop] of Object.entries(
141 schema.properties as Record<string, TSchema>
142 )) {
143 (result.properties as Record<string, unknown>)[key] =
144 typeboxToJsonSchema(prop);
145 if (prop[Kind] !== "Optional") {
146 required.push(key);
147 }
148 }
149
150 if (required.length > 0) {
151 result.required = required;
152 }
153 }
154
155 if (schema[Kind] === "Array" && schema.items) {
156 result.items = typeboxToJsonSchema(schema.items as TSchema);
157 }
158
159 return result;
160}
161
162function getJsonSchemaType(schema: TSchema): string {
163 const kind = schema[Kind];
164 switch (kind) {
165 case "String":
166 return "string";
167 case "Number":
168 case "Integer":
169 return "number";
170 case "Boolean":
171 return "boolean";
172 case "Array":
173 return "array";
174 case "Object":
175 return "object";
176 default:
177 return "object";
178 }
179}