Skip to content
← Public packages

@jpfi/stories

Create personalized Teddy & Pinguin bedtime stories for your child and get the finished text back.

FollowFork

src/teddy-api.ts

447 lines · 13.2 KB · TypeScript
Raw
import type { PackageStorage } from "kody:runtime";

import type {
  ContentLocale,
  IdempotencyKey,
  NormalizedRequest,
  Story,
  StoryPhase,
} from "./domain";
import { storyLibraryUrl } from "./domain";

const TOKEN_PLACEHOLDER = "{{secret:teddyPinguinToken}}";

const DEFAULT_API_BASE_URL = "https://www.teddy-pinguin.com";

// Teddy mints run ids with crypto.randomUUID(), which never returns the nil UUID.
const IDENTITY_PROBE_RUN_ID = "00000000-0000-0000-0000-000000000000";

const RUN_STATUSES = [
  "queued",
  "running",
  "cancelling",
  "accepted",
  "rejected",
  "cancelled",
  "failed",
] as const;

export type RunStatus = (typeof RUN_STATUSES)[number];

const STORY_PHASES = [
  "planning",
  "writing",
  "reviewing",
  "continuity_memory",
  "finalizing",
] as const;

export type RunState = {
  runId: string;
  status: RunStatus;
  phase: StoryPhase | null;
  storyHref: string | null;
  error: { code: string; phase: StoryPhase | "preparing" | null; retryable: boolean } | null;
};

export type ApiFailure =
  | { kind: "unauthenticated" }
  | { kind: "not_found"; code: string }
  | { kind: "quota_exhausted"; code: string }
  | { kind: "rate_limited"; retryAfterSeconds: number | null }
  | { kind: "rejected_request"; code: string; field: string | null }
  | { kind: "transient"; retryAfterSeconds: number | null }
  | { kind: "unexpected"; code: string };

export type StartRunResult =
  | { ok: true; run: RunState }
  | { ok: false; failure: ApiFailure };

export type ReadRunResult = { ok: true; run: RunState } | { ok: false; failure: ApiFailure };

export type LoadStoryResult = { ok: true; story: Story } | { ok: false; failure: ApiFailure };

export type CreateSeriesResult =
  | { ok: true; seriesId: string }
  | { ok: false; failure: ApiFailure };

export type TeddyClient = {
  readonly apiBaseUrl: string;
  startRun(request: NormalizedRequest, key: IdempotencyKey): Promise<StartRunResult>;
  createSeries(input: {
    contentLocale: ContentLocale;
    continueFromStoryId?: string;
  }): Promise<CreateSeriesResult>;
  readRun(runId: string): Promise<ReadRunResult>;
  loadStory(storyHref: string, fallbackLocale: ContentLocale): Promise<LoadStoryResult>;
  probeIdentity(): Promise<"authenticated" | "unauthenticated" | "unreachable">;
};

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function isRunStatus(value: unknown): value is RunStatus {
  return typeof value === "string" && (RUN_STATUSES as readonly string[]).includes(value);
}

function isStoryPhase(value: unknown): value is StoryPhase {
  return typeof value === "string" && (STORY_PHASES as readonly string[]).includes(value);
}

function parseErrorPhase(value: unknown): StoryPhase | "preparing" | null {
  if (value === "preparing") {
    return "preparing";
  }
  if (isStoryPhase(value)) {
    return value;
  }
  return null;
}

function parseProblem(payload: unknown): { code: string; field: string | null } | null {
  if (!isRecord(payload) || typeof payload.code !== "string" || !payload.code) {
    return null;
  }
  const errors = payload.errors;
  const first = Array.isArray(errors) && isRecord(errors[0]) ? errors[0] : null;
  const field = first && typeof first.field === "string" ? first.field : null;
  return { code: payload.code, field };
}

function parseRetryAfter(header: string | null): number | null {
  if (!header) {
    return null;
  }
  const seconds = Number.parseInt(header, 10);
  if (!Number.isFinite(seconds) || seconds < 0) {
    return null;
  }
  return seconds;
}

async function readJson(response: Response): Promise<unknown> {
  try {
    return await response.json();
  } catch {
    return undefined;
  }
}

function authHeaders(): HeadersInit {
  return { Authorization: `Bearer ${TOKEN_PLACEHOLDER}` };
}

function resolveApiUrl(apiBaseUrl: string, href: string): URL | null {
  try {
    const url = new URL(href, apiBaseUrl);
    if (url.origin !== new URL(apiBaseUrl).origin) {
      return null;
    }
    return url;
  } catch {
    return null;
  }
}

async function classify(response: Response): Promise<ApiFailure> {
  const problem = parseProblem(await readJson(response));
  const code = problem?.code;
  const retryAfterSeconds = parseRetryAfter(response.headers.get("retry-after"));
  if (response.status === 401) {
    return { kind: "unauthenticated" };
  }
  if (response.status === 403 && code === "STORY_LIMIT_REACHED") {
    return { kind: "quota_exhausted", code };
  }
  if (response.status === 404) {
    return { kind: "not_found", code: code ?? "NOT_FOUND" };
  }
  if (response.status === 429) {
    return { kind: "rate_limited", retryAfterSeconds };
  }
  if (response.status === 400 || response.status === 409) {
    return {
      kind: "rejected_request",
      code: code ?? `HTTP_${response.status}`,
      field: problem?.field ?? null,
    };
  }
  if (response.status === 408 || response.status === 425 || response.status >= 500) {
    return { kind: "transient", retryAfterSeconds };
  }
  return { kind: "unexpected", code: code ?? `HTTP_${response.status}` };
}

function classifyThrow(): ApiFailure {
  return { kind: "transient", retryAfterSeconds: null };
}

function parseRunSnapshot(payload: unknown): RunState | null {
  if (!isRecord(payload) || !isRecord(payload.run)) {
    return null;
  }
  const run = payload.run;
  if (typeof run.id !== "string" || !run.id || !isRunStatus(run.status)) {
    return null;
  }
  const result = isRecord(run.result) ? run.result : null;
  const href = result && typeof result.href === "string" && result.href ? result.href : null;
  const errorRecord = isRecord(run.error) ? run.error : null;
  const error =
    errorRecord && typeof errorRecord.code === "string" && errorRecord.code
      ? {
          code: errorRecord.code,
          phase: parseErrorPhase(errorRecord.phase),
          retryable: errorRecord.retryable === true,
        }
      : null;
  return {
    runId: run.id,
    status: run.status,
    phase: isStoryPhase(run.phase) ? run.phase : null,
    storyHref: href,
    error,
  };
}

function parseStory(
  payload: unknown,
  apiBaseUrl: string,
  fallbackLocale: ContentLocale,
): Story | null {
  if (!isRecord(payload) || !isRecord(payload.story)) {
    return null;
  }
  const story = payload.story;
  if (
    typeof story.id !== "string" ||
    !story.id ||
    typeof story.title !== "string" ||
    typeof story.summary !== "string" ||
    typeof story.content !== "string"
  ) {
    return null;
  }
  const contentLocale: ContentLocale =
    story.contentLocale === "en" || story.contentLocale === "de"
      ? story.contentLocale
      : fallbackLocale;
  const output = isRecord(story.output) ? story.output : null;
  const estimated =
    output && typeof output.estimatedReadSeconds === "number" && Number.isFinite(output.estimatedReadSeconds)
      ? output.estimatedReadSeconds
      : null;
  const target =
    output && typeof output.targetReadMinutes === "number" && Number.isFinite(output.targetReadMinutes)
      ? output.targetReadMinutes
      : null;
  const readMinutes =
    estimated !== null ? Math.max(1, Math.round(estimated / 60)) : target;
  const seriesRecord = isRecord(story.series) ? story.series : null;
  const series =
    seriesRecord &&
    typeof seriesRecord.id === "string" &&
    seriesRecord.id &&
    typeof seriesRecord.position === "number" &&
    Number.isInteger(seriesRecord.position)
      ? { id: seriesRecord.id, position: seriesRecord.position }
      : null;
  return {
    id: story.id,
    title: story.title,
    summary: story.summary,
    prose: story.content,
    contentLocale,
    readMinutes,
    libraryUrl: storyLibraryUrl(apiBaseUrl, story.id, contentLocale),
    series,
  };
}

function httpsOrigin(value: string): string | null {
  try {
    const url = new URL(value);
    if (url.protocol !== "https:") {
      return null;
    }
    return url.origin;
  } catch {
    return null;
  }
}

async function readApiBaseUrl(storage: PackageStorage): Promise<string> {
  const override = await storage?.get("apiBaseUrl");
  if (typeof override === "string") {
    const origin = httpsOrigin(override.trim());
    if (origin) {
      return origin;
    }
  }
  return DEFAULT_API_BASE_URL;
}

async function startRun(
  apiBaseUrl: string,
  request: NormalizedRequest,
  key: IdempotencyKey,
): Promise<StartRunResult> {
  let response: Response;
  try {
    response = await fetch(`${apiBaseUrl}/api/v1/story-generation-runs`, {
      method: "POST",
      headers: {
        ...authHeaders(),
        "Content-Type": "application/json",
        "Idempotency-Key": key,
      },
      body: JSON.stringify({
        storyIdea: request.storyIdea,
        contentLocale: request.contentLocale,
        ...(request.seriesId ? { seriesId: request.seriesId } : {}),
        output: {
          format: "prose",
          targetReadMinutes: request.targetReadMinutes,
        },
      }),
    });
  } catch {
    return { ok: false, failure: classifyThrow() };
  }
  if (!response.ok) {
    return { ok: false, failure: await classify(response) };
  }
  if (response.status !== 202) {
    return { ok: false, failure: { kind: "unexpected", code: "INVALID_TEDDY_RESPONSE" } };
  }
  const run = parseRunSnapshot(await readJson(response));
  if (!run) {
    return { ok: false, failure: { kind: "unexpected", code: "INVALID_TEDDY_RESPONSE" } };
  }
  return { ok: true, run };
}

async function createSeries(
  apiBaseUrl: string,
  input: { contentLocale: ContentLocale; continueFromStoryId?: string },
): Promise<CreateSeriesResult> {
  let response: Response;
  try {
    response = await fetch(`${apiBaseUrl}/api/v1/story-series`, {
      method: "POST",
      headers: {
        ...authHeaders(),
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        contentLocale: input.contentLocale,
        ...(input.continueFromStoryId
          ? { continueFromStoryId: input.continueFromStoryId }
          : {}),
      }),
    });
  } catch {
    return { ok: false, failure: classifyThrow() };
  }
  if (!response.ok) {
    return { ok: false, failure: await classify(response) };
  }
  if (response.status !== 201) {
    return { ok: false, failure: { kind: "unexpected", code: "INVALID_TEDDY_RESPONSE" } };
  }
  const payload = await readJson(response);
  const series = isRecord(payload) && isRecord(payload.series) ? payload.series : null;
  if (!series || typeof series.id !== "string" || !series.id) {
    return { ok: false, failure: { kind: "unexpected", code: "INVALID_TEDDY_RESPONSE" } };
  }
  return { ok: true, seriesId: series.id };
}

async function readRun(apiBaseUrl: string, runId: string): Promise<ReadRunResult> {
  let response: Response;
  try {
    response = await fetch(
      `${apiBaseUrl}/api/v1/story-generation-runs/${encodeURIComponent(runId)}`,
      { headers: authHeaders() },
    );
  } catch {
    return { ok: false, failure: classifyThrow() };
  }
  if (!response.ok) {
    return { ok: false, failure: await classify(response) };
  }
  if (response.status !== 200) {
    return { ok: false, failure: { kind: "unexpected", code: "INVALID_TEDDY_RESPONSE" } };
  }
  const run = parseRunSnapshot(await readJson(response));
  if (!run) {
    return { ok: false, failure: { kind: "unexpected", code: "INVALID_TEDDY_RESPONSE" } };
  }
  return { ok: true, run };
}

async function loadStory(
  apiBaseUrl: string,
  storyHref: string,
  fallbackLocale: ContentLocale,
): Promise<LoadStoryResult> {
  const url = resolveApiUrl(apiBaseUrl, storyHref);
  if (!url) {
    return { ok: false, failure: { kind: "unexpected", code: "INVALID_TEDDY_RESPONSE" } };
  }
  let response: Response;
  try {
    response = await fetch(url, { headers: authHeaders() });
  } catch {
    return { ok: false, failure: classifyThrow() };
  }
  if (!response.ok) {
    return { ok: false, failure: await classify(response) };
  }
  if (response.status !== 200) {
    return { ok: false, failure: { kind: "unexpected", code: "INVALID_TEDDY_RESPONSE" } };
  }
  const story = parseStory(await readJson(response), apiBaseUrl, fallbackLocale);
  if (!story) {
    return { ok: false, failure: { kind: "unexpected", code: "INVALID_TEDDY_RESPONSE" } };
  }
  return { ok: true, story };
}

async function probeIdentity(
  apiBaseUrl: string,
): Promise<"authenticated" | "unauthenticated" | "unreachable"> {
  let response: Response;
  try {
    response = await fetch(
      `${apiBaseUrl}/api/v1/story-generation-runs/${IDENTITY_PROBE_RUN_ID}`,
      { headers: authHeaders() },
    );
  } catch {
    return "unreachable";
  }
  if (response.status === 401) {
    return "unauthenticated";
  }
  if (response.status === 404) {
    const problem = parseProblem(await readJson(response));
    if (problem?.code === "STORY_RUN_NOT_FOUND") {
      return "authenticated";
    }
    return "unreachable";
  }
  return "unreachable";
}

export async function openTeddyClient(storage: PackageStorage): Promise<TeddyClient> {
  const apiBaseUrl = await readApiBaseUrl(storage);
  return {
    apiBaseUrl,
    startRun: (request, key) => startRun(apiBaseUrl, request, key),
    createSeries: (input) => createSeries(apiBaseUrl, input),
    readRun: (runId) => readRun(apiBaseUrl, runId),
    loadStory: (storyHref, fallbackLocale) => loadStory(apiBaseUrl, storyHref, fallbackLocale),
    probeIdentity: () => probeIdentity(apiBaseUrl),
  };
}