ci/woodpecker/push/publish Pipeline failed
Co-authored-by: Jason Woltje <[email protected]>
133 lines
5.6 KiB
TypeScript
133 lines
5.6 KiB
TypeScript
/**
|
|
* Typed fetch wrappers for the Task-3 harness HTTP contract the chat selection
|
|
* UI depends on. Every response body is untrusted and is normalized through the
|
|
* runtime guards before it reaches state — a 404 (catalog) and a 422 (selection)
|
|
* are mapped to typed, body-free error results so a raw gateway body is never
|
|
* rendered, and the caller's requested tuple is preserved verbatim on failure.
|
|
*
|
|
* This module talks ONLY to the harness/chat-preferences endpoints. It never
|
|
* calls `/api/providers*` — provider identity lives inside the harness catalog.
|
|
*/
|
|
import { asHarnessCatalog, asHarnessSelection, asHarnessSummaries } from './runtime-guards';
|
|
import type { HarnessCatalog, HarnessSelection, HarnessSummary } from '@/lib/types';
|
|
|
|
/** A catalog fetch either yields the typed catalog or a typed unavailability —
|
|
* never a thrown raw body. */
|
|
export type CatalogResult =
|
|
| { ok: true; catalog: HarnessCatalog }
|
|
| { ok: false; code: 'catalog_unavailable'; harnessId: string; message: string };
|
|
|
|
export type SelectionErrorCode = 'selection_invalid' | 'model_unavailable';
|
|
|
|
/** A persist either confirms the stored tuple or reports a typed domain failure
|
|
* that echoes back the exact tuple the caller requested. */
|
|
export type SelectionPersistResult =
|
|
| { ok: true; selection: HarnessSelection }
|
|
| { ok: false; code: SelectionErrorCode; message: string; requested: HarnessSelection };
|
|
|
|
/** A safe, generic message for an unavailable catalog — the raw 404 body is
|
|
* never surfaced. */
|
|
const CATALOG_UNAVAILABLE_MESSAGE = 'This harness catalog is currently unavailable.';
|
|
|
|
/** A safe, generic message for a rejected selection. The untrusted 422 body's
|
|
* own `message` is deliberately NEVER surfaced — only this fixed copy — so a
|
|
* raw gateway detail can never leak into the UI. Only the closed `code` enum is
|
|
* read from the body. */
|
|
const SELECTION_REJECTED_MESSAGE = 'This selection was rejected.';
|
|
|
|
async function readJson(response: Response): Promise<unknown> {
|
|
return response.json().catch(() => null);
|
|
}
|
|
|
|
function safeSelectionCode(body: unknown): SelectionErrorCode {
|
|
if (typeof body === 'object' && body !== null && 'code' in body) {
|
|
const code = (body as { code: unknown }).code;
|
|
if (code === 'selection_invalid' || code === 'model_unavailable') return code;
|
|
}
|
|
// Default to the more conservative "invalid" classification for anything
|
|
// unrecognized rather than guessing "model_unavailable".
|
|
return 'selection_invalid';
|
|
}
|
|
|
|
/** `GET /api/harnesses` → the list of harness summaries. A non-OK response
|
|
* normalizes to an empty list (the UI then has no harness to select). */
|
|
export async function fetchHarnesses(): Promise<HarnessSummary[]> {
|
|
const response = await fetch('/api/harnesses', {
|
|
credentials: 'include',
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
if (!response.ok) return [];
|
|
return asHarnessSummaries(await readJson(response));
|
|
}
|
|
|
|
/** `GET /api/harnesses/:harnessId/catalog` → the harness-scoped catalog. A 404
|
|
* (or any non-OK) becomes a typed `catalog_unavailable` result rather than a
|
|
* fallback catalog or a rendered raw body. */
|
|
export async function fetchCatalog(harnessId: string): Promise<CatalogResult> {
|
|
const response = await fetch(`/api/harnesses/${encodeURIComponent(harnessId)}/catalog`, {
|
|
credentials: 'include',
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
if (!response.ok) {
|
|
return {
|
|
ok: false,
|
|
code: 'catalog_unavailable',
|
|
// Scoped to the requested harness id, never the untrusted body's echo.
|
|
harnessId,
|
|
message: CATALOG_UNAVAILABLE_MESSAGE,
|
|
};
|
|
}
|
|
return { ok: true, catalog: asHarnessCatalog(await readJson(response), harnessId) };
|
|
}
|
|
|
|
/** `GET /api/chat/preferences/selection` → the persisted tuple, or null when
|
|
* unset or malformed. */
|
|
export async function fetchPersistedSelection(): Promise<HarnessSelection | null> {
|
|
const response = await fetch('/api/chat/preferences/selection', {
|
|
credentials: 'include',
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
if (!response.ok) return null;
|
|
const body = await readJson(response);
|
|
if (typeof body !== 'object' || body === null) return null;
|
|
return asHarnessSelection((body as { selection?: unknown }).selection);
|
|
}
|
|
|
|
/** `PUT /api/chat/preferences/selection` with the structured tuple as the body.
|
|
* On success returns the confirmed selection; on a typed domain failure (422)
|
|
* or validation error, returns a typed result carrying the EXACT requested
|
|
* tuple — never the body's echo — and never the raw body text. */
|
|
export async function persistSelection(
|
|
selection: HarnessSelection,
|
|
): Promise<SelectionPersistResult> {
|
|
const requested: HarnessSelection = {
|
|
harnessId: selection.harnessId,
|
|
providerId: selection.providerId,
|
|
modelId: selection.modelId,
|
|
};
|
|
const response = await fetch('/api/chat/preferences/selection', {
|
|
method: 'PUT',
|
|
credentials: 'include',
|
|
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(requested),
|
|
});
|
|
if (!response.ok) {
|
|
const body = await readJson(response);
|
|
return {
|
|
ok: false,
|
|
code: safeSelectionCode(body),
|
|
// Fixed copy only — the untrusted body's message is never surfaced.
|
|
message: SELECTION_REJECTED_MESSAGE,
|
|
requested,
|
|
};
|
|
}
|
|
const body = await readJson(response);
|
|
const confirmed =
|
|
typeof body === 'object' && body !== null
|
|
? asHarnessSelection((body as { selection?: unknown }).selection)
|
|
: null;
|
|
// A malformed 2xx body is treated as a confirmation of exactly what we sent —
|
|
// the server accepted the tuple, so the requested tuple is the source of truth.
|
|
return { ok: true, selection: confirmed ?? requested };
|
|
}
|