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}