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): 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; } /** Restore a previously-saved session ID (from plugin data). */ setSessionId(id: string): void { this.currentSessionId = id || null; } /** 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 { 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 { 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 { const headers: Record = { 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 { const headers: Record = { 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 { 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 { 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; } } }