feat: initial Lam Agent extension (#1)

This commit was merged in pull request #1.
This commit is contained in:
2026-10-03 20:32:40 +00:00
parent 1d39aabb50
commit 8b507e7953
36 changed files with 7136 additions and 2 deletions
+290
View File
@@ -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 ?? [],
};
}