← Public packages
@jpfi/stories
Create personalized Teddy & Pinguin bedtime stories for your child and get the finished text back.
src/teddy-api.ts
447 lines · 13.2 KB · TypeScriptimport 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),
};
}