feat: move runner to typescript, refactor
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user