feat: move runner to typescript, refactor

This commit is contained in:
2026-09-04 16:29:42 +05:00
parent d63cb77887
commit b9f98ea4b6
12 changed files with 649 additions and 263 deletions
@@ -1,28 +1,48 @@
const ACCEPT = "application/json, text/event-stream";
function parseMcpResponse(text) {
interface JsonRpcResponse {
result?: unknown;
error?: { code: number; message: string };
}
interface McpTool {
name: string;
description?: string;
inputSchema?: unknown;
}
type McpContentBlock = { type: string; text: string } | { type: string; [key: string]: unknown };
interface McpToolResult {
content?: McpContentBlock[];
}
function parseMcpResponse(text: string): JsonRpcResponse {
const trimmed = text.trim();
if (!trimmed) throw new Error("Empty MCP response");
if (trimmed.startsWith("{")) {
return JSON.parse(trimmed);
return JSON.parse(trimmed) as JsonRpcResponse;
}
if (trimmed.includes("event:") || trimmed.includes("data:")) {
let payload = "";
for (const line of trimmed.split(/\r?\n/)) {
if (line.startsWith("data:")) payload += line.slice(5).trim();
}
if (payload.startsWith("{")) return JSON.parse(payload);
if (payload.startsWith("{")) return JSON.parse(payload) as JsonRpcResponse;
}
throw new Error(`Unrecognized MCP response format: ${trimmed.slice(0, 200)}`);
}
export class McpClient {
constructor(url) {
private url: string;
private seq: number;
constructor(url: string) {
this.url = url;
this.seq = 0;
}
async request(method, params = {}) {
async request(method: string, params: Record<string, unknown> = {}): Promise<JsonRpcResponse> {
this.seq += 1;
const res = await fetch(this.url, {
method: "POST",
@@ -37,18 +57,18 @@ export class McpClient {
return rpc;
}
async listTools() {
async listTools(): Promise<McpTool[]> {
const rpc = await this.request("tools/list");
return rpc.result?.tools ?? [];
return (rpc.result as { tools?: McpTool[] })?.tools ?? [];
}
async callTool(name, args = {}) {
async callTool(name: string, args: Record<string, unknown> = {}): Promise<McpToolResult> {
const rpc = await this.request("tools/call", { name, arguments: args });
return rpc.result;
return rpc.result as McpToolResult;
}
}
export function textContentFrom(result) {
export function textContentFrom(result: McpToolResult | null | undefined): string {
if (!result?.content) return "";
return result.content
.map((b) => (b.type === "text" ? b.text : JSON.stringify(b)))
@@ -1,40 +1,113 @@
interface OllamaConstructorOpts {
url?: string;
timeoutMs?: number;
idleTimeoutMs?: number | null;
maxTotalMs?: number | null;
}
interface PingResult {
ok: boolean;
models: string[];
}
export type OllamaToolCall = {
function: { name: string; arguments: string };
};
export type OllamaToolDefinition = {
type: "function";
function: {
name: string;
description: string;
parameters: {
type: "object";
properties?: Record<string, unknown>;
[key: string]: unknown;
};
};
};
export type OllamaMessage = {
role: string;
content: string;
tool_calls?: OllamaToolCall[];
};
export type OllamaChunk = {
token: string | null;
content: string;
type: "content" | "tool_calls";
toolCalls?: OllamaToolCall[];
};
export interface ChatStreamOpts {
model: string;
messages: OllamaMessage[];
tools?: unknown[];
temperature?: number;
numCtx?: number;
timeoutMs?: number;
maxTotalMs?: number;
onChunk?: ((chunk: OllamaChunk) => void) | null;
}
export interface OllamaChatResult {
role: "assistant";
content: string;
toolCalls: OllamaToolCall[];
promptEvalCount: number;
evalCount: number;
raw: { stream: boolean };
}
export interface OllamaError extends Error {
partialContent?: string;
partialToolCalls?: OllamaToolCall[];
partialPrompt?: number;
partialGen?: number;
}
const DEFAULT_URL = "http://localhost:11434";
export class Ollama {
constructor({ url = DEFAULT_URL, timeoutMs = 600000, idleTimeoutMs = null, maxTotalMs = null } = {}) {
private url: string;
private timeoutMs: number;
private idleTimeoutMs: number;
private maxTotalMs: number;
constructor({ url = DEFAULT_URL, timeoutMs = 600000, idleTimeoutMs = null, maxTotalMs = null }: OllamaConstructorOpts = {}) {
this.url = url.replace(/\/$/, "");
this.timeoutMs = timeoutMs;
// Dynamic timeout: if set, the timer resets on every new token; the request
// only fails if this much time passes with NO progress (no new token).
this.idleTimeoutMs = idleTimeoutMs ?? timeoutMs;
// Absolute hard cap on one request's lifetime, even if tokens keep flowing
// (guards against a model looping/chatting forever). Defaults to 5 minutes.
this.maxTotalMs = maxTotalMs ?? 5 * 60_000;
}
async ping({ timeoutMs = 30000 } = {}) {
if (process.env.OLLAMA_SKIP_PING === "1") return { ok: true, model: process.env.OLLAMA_MODEL };
async ping({ timeoutMs = 30000 } = {}): Promise<PingResult> {
if (process.env.OLLAMA_SKIP_PING === "1") return { ok: true, models: [process.env.OLLAMA_MODEL ?? ""] };
const res = await fetch(`${this.url}/api/tags`, { signal: AbortSignal.timeout(timeoutMs) });
if (!res.ok) throw new Error(`Ollama not reachable (HTTP ${res.status}) at ${this.url}`);
const data = await res.json();
const data = await res.json() as { models?: Array<{ name: string }> };
const models = (data.models ?? []).map((m) => m.name);
return { ok: true, models };
}
/**
* Streaming chat with a dynamic (progress-based) timeout.
* The timeout only fires when `timeoutMs` passes with no new token/chunk.
* Optionally calls onChunk({token, content, type, toolCalls}) as data arrives.
* Returns the same shape as the old non-streaming chat().
*/
async chatStream({ model, messages, tools, temperature = 0, numCtx = 8192, timeoutMs, maxTotalMs, onChunk }) {
const body = {
async chatStream({
model,
messages,
tools,
temperature = 0,
numCtx = 8192,
timeoutMs,
maxTotalMs,
onChunk,
}: ChatStreamOpts): Promise<OllamaChatResult> {
const body: Record<string, unknown> = {
model,
messages,
stream: true,
options: { temperature },
};
if (numCtx) body.options.num_ctx = numCtx;
if (numCtx) (body.options as Record<string, unknown>).num_ctx = numCtx;
if (tools && tools.length) body.tools = tools;
const limitMs = timeoutMs ?? this.idleTimeoutMs;
@@ -60,20 +133,20 @@ export class Ollama {
const decoder = new TextDecoder();
let buffer = "";
let content = "";
let toolCalls = [];
let toolCalls: OllamaToolCall[] = [];
let promptEvalCount = 0;
let evalCount = 0;
let lastActivity = Date.now();
const guardInterval = setInterval(() => {
if (Date.now() - startedAt > totalCapMs) {
const err = new Error(`Request exceeded hard cap of ${totalCapMs}ms; aborting`);
const err = new Error(`Request exceeded hard cap of ${totalCapMs}ms; aborting`) as OllamaError;
err.name = "TimeoutError";
controller.abort(err);
return;
}
if (Date.now() - lastActivity > limitMs) {
const err = new Error(`No progress from Ollama for ${limitMs}ms; aborting`);
const err = new Error(`No progress from Ollama for ${limitMs}ms; aborting`) as OllamaError;
err.name = "TimeoutError";
controller.abort(err);
}
@@ -81,15 +154,13 @@ export class Ollama {
try {
for (;;) {
let chunk;
let chunk: ReadableStreamReadResult<Uint8Array>;
try {
chunk = await reader.read();
} catch (readErr) {
// Abort / network drop mid-stream. Surface whatever we got so the
// caller can tell "slowly progressing" from "hung".
const e = new Error(
`Aborted mid-stream after ${content.length} chars (toolCalls=${toolCalls.length}): ${readErr.message}`
);
`Aborted mid-stream after ${content.length} chars (toolCalls=${toolCalls.length}): ${(readErr as Error).message}`
) as OllamaError;
e.name = "TimeoutError";
e.partialContent = content;
e.partialToolCalls = toolCalls;
@@ -101,27 +172,27 @@ export class Ollama {
if (done) break;
lastActivity = Date.now();
buffer += decoder.decode(value, { stream: true });
let idx;
let idx: number;
while ((idx = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, idx).trim();
buffer = buffer.slice(idx + 1);
if (!line) continue;
let obj;
let obj: Record<string, unknown>;
try {
obj = JSON.parse(line);
} catch {
continue;
}
if (obj.prompt_eval_count != null) promptEvalCount = obj.prompt_eval_count;
if (obj.eval_count != null) evalCount = obj.eval_count;
const msg = obj.message ?? {};
if (obj.prompt_eval_count != null) promptEvalCount = obj.prompt_eval_count as number;
if (obj.eval_count != null) evalCount = obj.eval_count as number;
const msg = (obj.message ?? {}) as Record<string, unknown>;
if (msg.content) {
content += msg.content;
onChunk?.({ token: msg.content, content, type: "content" });
content += msg.content as string;
onChunk?.({ token: msg.content as string, content, type: "content" });
}
if (msg.tool_calls && msg.tool_calls.length) {
toolCalls = msg.tool_calls;
onChunk?.({ token: null, content, type: "tool_calls", toolCalls: msg.tool_calls });
if (msg.tool_calls && (msg.tool_calls as unknown[]).length) {
toolCalls = msg.tool_calls as OllamaToolCall[];
onChunk?.({ token: null, content, type: "tool_calls", toolCalls });
}
}
}
@@ -139,7 +210,7 @@ export class Ollama {
};
}
async chat({ model, messages, tools, temperature = 0, numCtx = 8192, timeoutMs }) {
async chat({ model, messages, tools, temperature = 0, numCtx = 8192, timeoutMs }: ChatStreamOpts): Promise<OllamaChatResult> {
return this.chatStream({
model,
messages,
@@ -151,7 +222,7 @@ export class Ollama {
});
}
async warmup({ model, text = "say OK", timeoutMs = 60000 }) {
async warmup({ model, text = "say OK", timeoutMs = 60000 }: { model: string; text?: string; timeoutMs?: number }): Promise<Partial<OllamaChatResult> & { error?: string }> {
try {
return await this.chatStream({
model,
@@ -159,15 +230,15 @@ export class Ollama {
timeoutMs,
});
} catch (err) {
return { error: err.message };
return { error: (err as Error).message };
}
}
}
export async function listModels() {
export async function listModels(): Promise<string[]> {
const o = new Ollama();
const res = await fetch(`${o.url}/api/tags`);
const res = await fetch(`${o["url"]}/api/tags`);
if (!res.ok) throw new Error(`Ollama not reachable (HTTP ${res.status})`);
const data = await res.json();
const data = await res.json() as { models?: Array<{ name: string }> };
return (data.models ?? []).map((m) => m.name);
}