/** * API sends the odidere HTTP requests and decodes the server's * Server-Sent Events (SSE) streams. It is the only module that opens * network connections. It never reads the DOM and never touches * localStorage: callers keep what it returns and render it elsewhere. * * The two streaming methods, inference and subscribe, are async * generators. Each yielded value is one decoded SSE event: * { type, data }. The server terminates a stream with a 'done' * event. A generator that ends without 'done' reports that delivery * stopped, not that the work stopped: the server keeps an accepted * inference running after the client disconnects, and a new * subscribe call re-attaches to it and replays what was saved. * * Every failed request throws ApiError, which carries the HTTP * status. Status 409 reports either that the conversation is already * running inference, or that a message changed since the caller last * read it; the caller should re-sync before it retries. */ export class ApiError extends Error { /** * @param {number} status - HTTP status code. * @param {string} body - response body, often empty. */ constructor(status, body) { super(`HTTP ${status}${body ? `: ${body}` : ''}`); this.name = 'ApiError'; this.status = status; this.body = body; } } export class Api { // --- Models and voices --- /** * getModels lists the models of every configured provider, each * with an availability status, and the server's default selection. * @returns {Promise<{providers: Object, default_provider: string, default_model: string}>} */ async getModels() { return this._json('/models'); } /** * getVoices lists the voices of the TTS server. The TTS server is * a separate service, not part of the odidere API. * @param {string} url - TTS server base URL. * @returns {Promise} */ async getVoices(url) { return this._json(`${url}/v1/audio/voices`); } // --- Conversations --- /** * listConversations returns every conversation of the user, most * recently updated first. Each row holds id, title, created_at, * and updated_at. It holds no messages. * @returns {Promise>} */ async listConversations() { return this._json('/conversations'); } /** * getConversation returns one conversation row. Use it to verify * that a conversation exists before the local selection moves to * it. It returns no messages. * @param {string} id * @returns {Promise} */ async getConversation(id) { return this._json(`/conversations/${encodeURIComponent(id)}`); } /** * createConversation saves a new conversation with its initial * system message. An empty systemMessage makes the server use its * configured default. * @param {{title?: string, systemMessage?: string}} [body] * @returns {Promise} the saved conversation row. */ async createConversation({ title, systemMessage } = {}) { const body = {}; if (title !== undefined) body.title = title; if (systemMessage !== undefined) body.system_message = systemMessage; return this._json('/conversations', { method: 'POST', body }); } /** * renameConversation sets a new title for a conversation. The * server trims the title and rejects an empty one. The returned * row holds the saved title; the caller uses it to update the * picker. * @param {string} id * @param {string} title * @returns {Promise} the saved conversation row. */ async renameConversation(id, title) { return this._json(`/conversations/${encodeURIComponent(id)}`, { method: 'PATCH', body: { title }, }); } /** * deleteConversation removes the conversation, all its messages, * and its tool execution records. It also cancels an ongoing * inference. This cannot be undone. * @param {string} id */ async deleteConversation(id) { await this._request(`/conversations/${encodeURIComponent(id)}`, { method: 'DELETE', }); } /** * stopConversation asks the server to cancel the conversation's * ongoing inference. The response acknowledges the request, not * completed cancellation: the events that follow report the * outcome of the work that already finished. * @param {string} id */ async stopConversation(id) { await this._request(`/conversations/${encodeURIComponent(id)}/stop`, { method: 'POST', }); } // --- Streaming --- /** * inference starts one inference run and yields its events. The * optional message is new user input; without it the run continues * from saved history, which is how a run resumes after a tool * approval or denial. * * The server rejects the request with status 409 while another run * is reserved or ongoing. * * @param {string} id - conversation ID. * @param {Object} body - request fields; message, provider, model, * max_iterations, thinking_budget_tokens, stream. * @param {AbortSignal} [signal] - ends delivery, not the run. */ async *inference(id, body, signal) { yield* this._stream( `/conversations/${encodeURIComponent(id)}/inference`, body, signal, ); } /** * subscribe synchronizes the caller's message cache with the saved * conversation and yields the events that follow. * * manifest lists the message IDs and timestamps the caller already * holds. The first events are a diff: one message_saved per row * that is missing or newer than the manifest, then one * message_deleted per manifest ID that is no longer saved. An * empty manifest requests every saved row. * * When an inference is reserved or ongoing, the stream then follows * its events and ends with done. When none is, the stream ends * after the diff without done. * * @param {string} id - conversation ID. * @param {Array<{id: string, updated_at: string}>} manifest * @param {AbortSignal} [signal] */ async *subscribe(id, manifest, signal) { yield* this._stream( `/conversations/${encodeURIComponent(id)}/subscribe`, { manifest }, signal, ); } // --- Message edits --- /** * insertMessage saves a new message. For a user or assistant * message, pass message only. For a tool result, pass the tool * message and message_id and updated_at of its parent assistant * message: message_id names the assistant, updated_at is the * assistant's timestamp from the caller's last read. * @param {string} id - conversation ID. * @param {Object} body - message, and for tool results * message_id and updated_at. * @returns {Promise} the saved message row. */ async insertMessage(id, body) { return this._json(`/conversations/${encodeURIComponent(id)}/messages`, { method: 'POST', body, }); } /** * updateMessage replaces the model-visible content of one saved * message. The role may not change. updated_at is the row's * timestamp from the caller's last read; a mismatch returns 409. * For a tool result, mid identifies the parent assistant message * and updated_at is the tool result's timestamp. * @param {string} id - conversation ID. * @param {string} mid - message ID. * @param {Object} body - message and updated_at. * @returns {Promise} the saved message row. */ async updateMessage(id, mid, body) { return this._json( `/conversations/${encodeURIComponent(id)}` + `/messages/${encodeURIComponent(mid)}`, { method: 'PUT', body }, ); } /** * deleteMessage removes one saved message. updated_at is the * row's timestamp from the caller's last read; a mismatch returns * 409. * @param {string} id - conversation ID. * @param {string} mid - message ID. * @param {string} updatedAt */ async deleteMessage(id, mid, updatedAt) { await this._request( `/conversations/${encodeURIComponent(id)}` + `/messages/${encodeURIComponent(mid)}`, { method: 'DELETE', body: { updated_at: updatedAt } }, ); } /** * updateToolCall replaces one call of a saved assistant message. * The replacement must keep the call ID: the execution record and * any saved result are keyed by it. updated_at is the assistant * message's timestamp from the caller's last read. * @param {string} id - conversation ID. * @param {string} mid - assistant message ID. * @param {string} cid - tool call ID. * @param {Object} body - call and updated_at. * @returns {Promise} the saved assistant message row. */ async updateToolCall(id, mid, cid, body) { return this._json( `/conversations/${encodeURIComponent(id)}` + `/messages/${encodeURIComponent(mid)}` + `/calls/${encodeURIComponent(cid)}`, { method: 'PUT', body }, ); } /** * deleteToolCall removes the call, its saved result, and its * execution record together. updated_at is the assistant message's * timestamp from the caller's last read. * @param {string} id - conversation ID. * @param {string} mid - assistant message ID. * @param {string} cid - tool call ID. * @param {string} updatedAt */ async deleteToolCall(id, mid, cid, updatedAt) { await this._request( `/conversations/${encodeURIComponent(id)}` + `/messages/${encodeURIComponent(mid)}` + `/calls/${encodeURIComponent(cid)}`, { method: 'DELETE', body: { updated_at: updatedAt } }, ); } // --- Tool approvals --- /** * listPendingExecutions lists every tool call that waits for a * user decision, across all conversations. Filter the result to * the conversation in view before showing it. * @returns {Promise>} execution rows with status * 'pending'. */ async listPendingExecutions() { return this._json('/executions'); } /** * decideExecution records the user's decision for one tool call. * An approved call runs on the conversation's next inference; a * denied call does not. The status transition is refused once the * row has left 'pending'. * @param {string} executionId * @param {'approved' | 'denied'} status */ async decideExecution(executionId, status) { await this._request(`/executions/${encodeURIComponent(executionId)}`, { method: 'PUT', body: { status }, }); } // --- Private --- /** * _request sends one request and checks the response. It returns * the open Response and throws ApiError when the server rejects * the request. Signals from the caller propagate as AbortError. * @private */ async _request(path, { method = 'GET', body, signal } = {}) { const res = await fetch(path, { method, signal, headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined, body: body !== undefined ? JSON.stringify(body) : undefined, }); if (!res.ok) { throw new ApiError(res.status, (await res.text().catch(() => '')).trim()); } return res; } /** * _json sends one request and parses its JSON body. A response * with no body (204 No Content) resolves to null. * @private */ async _json(path, options) { const res = await this._request(path, options); return res.status === 204 ? null : res.json(); } /** * _stream sends one POST request and yields the response's decoded * SSE events. See readEvents. * @private */ async *_stream(path, body, signal) { const res = await this._request(path, { method: 'POST', body, signal, }); yield* readEvents(res); } } /** * readEvents decodes one SSE response into { type, data } events. * It buffers across network chunk boundaries, accepts LF and CRLF * line endings, joins multi-line data fields, and skips comment * lines. Events without an event field get type 'message'. A line * with invalid JSON is logged and skipped; the stream continues. * @param {Response} response */ export async function* readEvents(response) { const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let type = 'message'; let data = []; try { while (true) { const result = await reader.read(); buffer += decoder.decode(result.value, { stream: !result.done }); while (true) { const newline = buffer.indexOf('\n'); if (newline === -1) break; const line = buffer.slice(0, newline).replace(/\r$/, ''); buffer = buffer.slice(newline + 1); if (line === '') { if (data.length) { const text = data.join('\n'); try { yield { type, data: JSON.parse(text) }; } catch (error) { console.error('failed to parse SSE data:', text, error); } } type = 'message'; data = []; continue; } if (line.startsWith(':')) continue; const colon = line.indexOf(':'); const field = colon === -1 ? line : line.slice(0, colon); const value = colon === -1 ? '' : line.slice(colon + 1).replace(/^ /, ''); if (field === 'event') type = value; if (field === 'data') data.push(value); } if (result.done) break; } } finally { await reader.cancel().catch(() => {}); reader.releaseLock(); } }