feat: initial Lam Agent extension (#1)
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
import { DEFAULT_HOST } from "./constants";
|
||||
import type { ApiMessage, ModelInfo, ToolCall, ToolDefinition } from "./types";
|
||||
|
||||
/**
|
||||
* Thin client for the Ollama HTTP API.
|
||||
*
|
||||
* Everything here is plain `fetch`, deliberately: the extension runs in Node
|
||||
* inside Vicinae's extension runtime, so there is no reason to pull in the
|
||||
* `ollama` npm package and its own transport for four endpoints.
|
||||
*/
|
||||
|
||||
/** An error with enough context for the UI to render something actionable rather than a raw status code. */
|
||||
export class OllamaError extends Error {
|
||||
readonly status?: number;
|
||||
readonly hint?: string;
|
||||
|
||||
constructor(message: string, options: { status?: number; hint?: string } = {}) {
|
||||
super(message);
|
||||
this.name = "OllamaError";
|
||||
this.status = options.status;
|
||||
this.hint = options.hint;
|
||||
}
|
||||
}
|
||||
|
||||
/** Accepts `https://ollama.com/`, `http://localhost:11434`, or an empty preference, and yields a base URL with no trailing slash. */
|
||||
export function normalizeHost(host: string | undefined): string {
|
||||
const trimmed = (host ?? "").trim();
|
||||
return (trimmed || DEFAULT_HOST).replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function authHeaders(apiKey: string): Record<string, string> {
|
||||
const key = apiKey.trim();
|
||||
return key ? { Authorization: `Bearer ${key}` } : {};
|
||||
}
|
||||
|
||||
async function safeText(response: Response): Promise<string> {
|
||||
try {
|
||||
return await response.text();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function describeHttpError(status: number, bodyText: string, host: string): OllamaError {
|
||||
const detail = bodyText.trim().slice(0, 300);
|
||||
|
||||
switch (status) {
|
||||
case 401:
|
||||
case 403:
|
||||
return new OllamaError("Ollama rejected the API key.", {
|
||||
status,
|
||||
hint: "Check the Ollama API Key preference. Create a new key at https://ollama.com/settings/keys.",
|
||||
});
|
||||
case 404:
|
||||
return new OllamaError(`Ollama returned 404 from ${host}.`, {
|
||||
status,
|
||||
hint: "The model name may be wrong, or this host may not expose that endpoint. Local Ollama servers do not provide the web tools.",
|
||||
});
|
||||
case 429:
|
||||
return new OllamaError("Ollama is rate limiting this key.", {
|
||||
status,
|
||||
hint: "Wait a moment and retry, or switch to a lighter model.",
|
||||
});
|
||||
default:
|
||||
return new OllamaError(`Ollama request failed with HTTP ${status}.`, { status, hint: detail });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits newline-delimited JSON into parsed objects.
|
||||
*
|
||||
* Exported for tests. Ollama streams NDJSON, and a network chunk can land
|
||||
* anywhere: mid-line, or between the bytes of a multi-byte UTF-8 character.
|
||||
* Both are handled here by buffering until a newline arrives and letting
|
||||
* TextDecoder hold incomplete sequences back.
|
||||
*/
|
||||
export async function* parseNdjsonStream<T>(stream: ReadableStream<Uint8Array>): AsyncGenerator<T> {
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
let newlineIndex = buffer.indexOf("\n");
|
||||
while (newlineIndex !== -1) {
|
||||
const line = buffer.slice(0, newlineIndex).trim();
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
if (line) yield JSON.parse(line) as T;
|
||||
newlineIndex = buffer.indexOf("\n");
|
||||
}
|
||||
}
|
||||
|
||||
// Flush whatever the decoder is still holding, then the final unterminated line.
|
||||
buffer += decoder.decode();
|
||||
const tail = buffer.trim();
|
||||
if (tail) yield JSON.parse(tail) as T;
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
// The stream was already torn down, typically because the request was aborted.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface OllamaChatEvent {
|
||||
message?: {
|
||||
role?: string;
|
||||
content?: string;
|
||||
thinking?: string;
|
||||
tool_calls?: ToolCall[];
|
||||
};
|
||||
done?: boolean;
|
||||
done_reason?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ChatStreamChunk {
|
||||
content?: string;
|
||||
thinking?: string;
|
||||
toolCalls?: ToolCall[];
|
||||
done: boolean;
|
||||
doneReason?: string;
|
||||
}
|
||||
|
||||
export interface StreamChatOptions {
|
||||
host: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
messages: ApiMessage[];
|
||||
tools?: ToolDefinition[];
|
||||
/** Omit, or pass "off", to leave reasoning disabled. */
|
||||
think?: string | boolean;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams a chat completion.
|
||||
*
|
||||
* Because this is an async generator, the request is not issued until the
|
||||
* caller starts iterating, which lets the UI attach its loading state first.
|
||||
*/
|
||||
export async function* streamChat(options: StreamChatOptions): AsyncGenerator<ChatStreamChunk> {
|
||||
const host = normalizeHost(options.host);
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: options.model,
|
||||
messages: options.messages,
|
||||
stream: true,
|
||||
};
|
||||
if (options.tools && options.tools.length > 0) body.tools = options.tools;
|
||||
if (options.think && options.think !== "off") body.think = options.think;
|
||||
|
||||
const response = await fetch(`${host}/api/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders(options.apiKey) },
|
||||
body: JSON.stringify(body),
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw describeHttpError(response.status, await safeText(response), host);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new OllamaError("Ollama returned an empty response body.");
|
||||
}
|
||||
|
||||
for await (const event of parseNdjsonStream<OllamaChatEvent>(response.body)) {
|
||||
if (event.error) {
|
||||
throw new OllamaError(event.error);
|
||||
}
|
||||
|
||||
yield {
|
||||
// Ollama sends empty strings for absent fields; normalise to undefined so
|
||||
// callers can use a simple truthiness check.
|
||||
content: event.message?.content || undefined,
|
||||
thinking: event.message?.thinking || undefined,
|
||||
toolCalls: event.message?.tool_calls,
|
||||
done: Boolean(event.done),
|
||||
doneReason: event.done_reason,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface ListModelsOptions {
|
||||
host: string;
|
||||
apiKey: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Lists the models this API key can reach. On Ollama Cloud this reflects the account's entitlements. */
|
||||
export async function listModels(options: ListModelsOptions): Promise<ModelInfo[]> {
|
||||
const host = normalizeHost(options.host);
|
||||
|
||||
const response = await fetch(`${host}/api/tags`, {
|
||||
headers: authHeaders(options.apiKey),
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw describeHttpError(response.status, await safeText(response), host);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
models?: Array<{ name?: string; model?: string; details?: { parameter_size?: string } }>;
|
||||
};
|
||||
|
||||
return (payload.models ?? [])
|
||||
.map((model) => ({
|
||||
name: model.name ?? model.model ?? "",
|
||||
parameterSize: model.details?.parameter_size,
|
||||
}))
|
||||
.filter((model) => model.name.length > 0)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export interface WebSearchResult {
|
||||
title: string;
|
||||
url: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface WebSearchOptions {
|
||||
host: string;
|
||||
apiKey: string;
|
||||
query: string;
|
||||
maxResults?: number;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export async function webSearch(options: WebSearchOptions): Promise<WebSearchResult[]> {
|
||||
const host = normalizeHost(options.host);
|
||||
|
||||
const body: Record<string, unknown> = { query: options.query };
|
||||
if (typeof options.maxResults === "number") body.max_results = options.maxResults;
|
||||
|
||||
const response = await fetch(`${host}/api/web_search`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders(options.apiKey) },
|
||||
body: JSON.stringify(body),
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw describeHttpError(response.status, await safeText(response), host);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as { results?: WebSearchResult[] };
|
||||
return payload.results ?? [];
|
||||
}
|
||||
|
||||
export interface WebFetchResult {
|
||||
title: string;
|
||||
content: string;
|
||||
links: string[];
|
||||
}
|
||||
|
||||
export interface WebFetchOptions {
|
||||
host: string;
|
||||
apiKey: string;
|
||||
url: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export async function webFetch(options: WebFetchOptions): Promise<WebFetchResult> {
|
||||
const host = normalizeHost(options.host);
|
||||
|
||||
const response = await fetch(`${host}/api/web_fetch`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders(options.apiKey) },
|
||||
body: JSON.stringify({ url: options.url }),
|
||||
signal: options.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw describeHttpError(response.status, await safeText(response), host);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as Partial<WebFetchResult>;
|
||||
return {
|
||||
title: payload.title ?? options.url,
|
||||
content: payload.content ?? "",
|
||||
links: payload.links ?? [],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user