obsidian-hermes-plugin/src/api-client.ts
Helm 3b35f59ec8 Initial commit: Obsidian Hermes Chat plugin
- Streaming chat sidebar with OpenAI-compatible API client
- Path-referencing context injection via MCP tools
- Dark theme styling with Obsidian CSS variables
- Settings: gateway URL, API key, context/model toggles
- SSE streaming with fetch timeout protection
- Session continuity via X-Hermes-Session-Id headers
2026-07-10 11:18:23 -04:00

378 lines
10 KiB
TypeScript

import type { GatewayConfig, ChatCompletionResponse } from "./types";
/**
* HTTP client for the Hermes Gateway's OpenAI-compatible REST API.
* Handles Bearer auth, SSE streaming, session header continuity,
* and model/capabilities discovery.
*
* Uses the standard fetch() API available in Obsidian's Electron runtime.
*/
export class ApiClient {
private baseUrl: string;
private apiKey: string;
private model: string;
private currentSessionId: string | null;
constructor(config: GatewayConfig) {
this.baseUrl = config.baseUrl.replace(/\/+$/, ""); // strip trailing slashes
this.apiKey = config.apiKey;
this.model = config.model;
this.currentSessionId = null;
}
/** Update configuration without recreating the client. Preserves session. */
updateConfig(config: Partial<GatewayConfig>): void {
if (config.baseUrl !== undefined) {
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
}
if (config.apiKey !== undefined) {
this.apiKey = config.apiKey;
}
if (config.model !== undefined) {
this.model = config.model;
}
// Do NOT reset currentSessionId — preserve conversation continuity
}
/** Get the current session ID, if one has been established. */
getSessionId(): string | null {
return this.currentSessionId;
}
/** The root URL without the /v1 suffix, e.g. http://192.168.1.12:8642 */
private get rootUrl(): string {
// baseUrl ends with /v1; strip it to reach the server root
if (this.baseUrl.endsWith("/v1")) {
return this.baseUrl.slice(0, -3);
}
return new URL("/", this.baseUrl).href.replace(/\/+$/, "");
}
/**
* Fetch with a configurable timeout. Aborts the request if it takes
* longer than `timeoutMs` milliseconds.
*/
private async fetchWithTimeout(
url: string,
options: RequestInit,
timeoutMs: number = 60000
): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
return response;
} finally {
clearTimeout(timeout);
}
}
/**
* Extract a useful error message from a non-OK response.
* Tries JSON first, falls back to plain text (truncated), and finally
* to the HTTP status line.
*/
private async extractErrorBody(response: Response): Promise<string> {
let detail = "";
const contentType = response.headers.get("content-type") ?? "";
// Only attempt JSON if the server says it sent JSON
if (contentType.includes("application/json")) {
try {
const errBody = await response.json();
if (errBody.error?.message) {
detail = errBody.error.message;
} else if (typeof errBody.message === "string") {
detail = errBody.message;
} else if (typeof errBody.error === "string") {
detail = errBody.error;
}
if (detail) return detail;
} catch {
// JSON parsing failed — fall through to text
}
}
// Try reading as plain text (e.g. HTML error page, gateway nginx page)
try {
const text = await response.text();
if (text.trim()) {
// Truncate to avoid dumping huge HTML pages into error messages
detail = text.trim().substring(0, 300);
return detail;
}
} catch {
// Body isn't readable at all
}
// Ultimate fallback
return response.statusText || `HTTP ${response.status}`;
}
/**
* Send a chat completion request (non-streaming).
* Returns the assistant's response with session and usage metadata.
*/
async chat(
messages: { role: string; content: string }[],
stream: boolean = false
): Promise<ChatCompletionResponse> {
const headers: Record<string, string> = {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
};
if (this.currentSessionId) {
headers["X-Hermes-Session-Id"] = this.currentSessionId;
}
const response = await this.fetchWithTimeout(
`${this.baseUrl}/chat/completions`,
{
method: "POST",
headers,
body: JSON.stringify({
model: this.model,
messages,
stream,
}),
}
);
if (!response.ok) {
const detail = await this.extractErrorBody(response);
throw new Error(`API error ${response.status}: ${detail}`);
}
// Capture session ID from response headers for continuity
const sessionId = response.headers.get("X-Hermes-Session-Id");
if (sessionId) {
this.currentSessionId = sessionId;
}
const data = await response.json();
if (data.error) {
throw new Error(data.error.message || "Unknown API error");
}
const choice = data.choices?.[0];
const content: string = choice?.message?.content ?? "";
return {
content,
sessionId: this.currentSessionId ?? "",
model: data.model ?? this.model,
usage: data.usage,
};
}
/**
* Send a streaming chat completion request.
* Calls `onDelta` with each text chunk as it arrives via SSE.
* Returns the final ChatCompletionResponse after the stream concludes.
*/
async streamChat(
messages: { role: string; content: string }[],
onDelta: (text: string) => void
): Promise<ChatCompletionResponse> {
const headers: Record<string, string> = {
Authorization: `Bearer ${this.apiKey}`,
"Content-Type": "application/json",
};
if (this.currentSessionId) {
headers["X-Hermes-Session-Id"] = this.currentSessionId;
}
const response = await this.fetchWithTimeout(
`${this.baseUrl}/chat/completions`,
{
method: "POST",
headers,
body: JSON.stringify({
model: this.model,
messages,
stream: true,
}),
}
);
if (!response.ok) {
const detail = await this.extractErrorBody(response);
throw new Error(`API error ${response.status}: ${detail}`);
}
// Capture session ID from response headers
const sessionId = response.headers.get("X-Hermes-Session-Id");
if (sessionId) {
this.currentSessionId = sessionId;
}
// Read the SSE stream
const reader = response.body?.getReader();
if (!reader) {
throw new Error("Response body is not readable");
}
const decoder = new TextDecoder("utf-8");
let buffer = "";
let fullContent = "";
let model = this.model;
let usage: ChatCompletionResponse["usage"];
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// SSE events are separated by double newlines
const parts = buffer.split("\n\n");
// The last part may be incomplete; keep it in the buffer
buffer = parts.pop() ?? "";
for (const part of parts) {
const lines = part.split("\n");
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const jsonStr = line.slice(6).trim();
if (jsonStr === "[DONE]") {
// Stream finished signal — continue processing any remaining data
continue;
}
try {
const event = JSON.parse(jsonStr);
if (event.error) {
throw new Error(event.error.message || "Stream error");
}
const delta = event.choices?.[0]?.delta?.content;
if (delta) {
fullContent += delta;
onDelta(delta);
}
// Capture model and usage from the final event
if (event.model) {
model = event.model;
}
if (event.usage) {
usage = event.usage;
}
} catch (e) {
// If it's our own thrown error, rethrow
if (
e instanceof Error &&
e.message !== "Unexpected token" &&
!e.message.includes("JSON")
) {
throw e;
}
// Otherwise skip malformed SSE lines
}
}
}
}
// Flush any remaining data in the buffer
if (buffer.trim()) {
const lines = buffer.split("\n");
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const jsonStr = line.slice(6).trim();
if (jsonStr === "[DONE]") continue;
try {
const event = JSON.parse(jsonStr);
const delta = event.choices?.[0]?.delta?.content;
if (delta) {
fullContent += delta;
onDelta(delta);
}
} catch {
// ignore
}
}
}
return {
content: fullContent,
sessionId: this.currentSessionId ?? "",
model,
usage,
};
}
/**
* List available models from the gateway.
* GET /v1/models
*/
async getModels(): Promise<{ id: string }[]> {
const response = await this.fetchWithTimeout(
`${this.baseUrl}/models`,
{
headers: {
Authorization: `Bearer ${this.apiKey}`,
},
}
);
if (!response.ok) {
const detail = await this.extractErrorBody(response);
throw new Error(`Failed to fetch models: ${response.status}${detail}`);
}
const data = await response.json();
return data.data ?? [];
}
/**
* Fetch gateway capabilities.
* GET /v1/capabilities
*/
async getCapabilities(): Promise<any> {
const response = await this.fetchWithTimeout(
`${this.baseUrl}/capabilities`,
{
headers: {
Authorization: `Bearer ${this.apiKey}`,
},
}
);
if (!response.ok) {
const detail = await this.extractErrorBody(response);
throw new Error(
`Failed to fetch capabilities: ${response.status}${detail}`
);
}
return response.json();
}
/**
* Health check against the gateway root.
* GET /health → returns true if status === "ok"
* Uses a shorter 10-second timeout since this is a liveness probe.
*/
async healthCheck(): Promise<boolean> {
try {
const response = await this.fetchWithTimeout(
`${this.rootUrl}/health`,
{},
10000 // 10-second timeout for health checks
);
if (!response.ok) return false;
const data = await response.json();
return data.status === "ok";
} catch {
return false;
}
}
}