BangSo/apps/mobile/lib/api.ts

808 lines
26 KiB
TypeScript
Raw Permalink Normal View History

2026-09-01 16:51:19 +00:00
import type {
Bot,
BotSection,
ComputerMode,
Group,
Me,
MessageBlock,
ModelCatalogEntry,
ModelCredential,
Space,
SpaceNavigation,
} from "@rakazo/contracts";
import {
isRunTerminalEvent,
mergeThreadHistory,
prependThreadHistoryPage,
progressMessageId,
reduceLiveMessageBlocks,
runFailureError,
type ThreadHistory,
upsertMessageById,
} from "@rakazo/core";
import * as SecureStore from "expo-secure-store";
import { defaultApiBase, type EndpointResult, normalizeApiBase } from "./endpoint";
import { resumeLiveNotifications } from "./live-notifications";
import {
clearSessionToken,
loadSessionToken,
restoreSessionToken,
saveSessionToken,
snapshotSessionToken,
tokenFromAuthResponse,
} from "./session";
const ENDPOINT_KEY = "rakazo.api_base";
const SPACE_KEY = "rakazo.space_id";
const SPACE_ROLLBACK_KEY = "rakazo.space_rollback";
const RPC_TIMEOUT_MS = 8_000;
let cachedApiBase: string | undefined;
let cachedSpaceId = "";
function responseErrorMessage(body: unknown, fallback: string): string {
return typeof body === "object" && body && "message" in body
? String((body as { message?: string }).message ?? fallback)
: fallback;
}
export function currentApiBase() {
const parsed = normalizeApiBase(cachedApiBase ?? defaultApiBase());
if (!parsed.ok) throw new Error(parsed.error);
return parsed.url;
}
export async function loadApiBase() {
let apiBase = defaultApiBase();
try {
const stored = await SecureStore.getItemAsync(ENDPOINT_KEY);
if (stored) {
const parsed = normalizeApiBase(stored);
if (parsed.ok) {
apiBase = parsed.url;
}
}
} catch {
// SecureStore is unavailable in some test / web hosts.
}
cachedApiBase = apiBase;
try {
const storedSpace = (await SecureStore.getItemAsync(SPACE_KEY)) ?? "";
cachedSpaceId = storedSpace;
if (!storedSpace) await recoverSpaceRollback(cachedApiBase);
} catch {
// Keep any in-memory selection when SecureStore is temporarily unavailable.
}
return cachedApiBase;
}
export async function selectSpace(id: string) {
if (!(await clearStoredValue(SPACE_ROLLBACK_KEY))) return false;
try {
await SecureStore.setItemAsync(SPACE_KEY, id);
cachedSpaceId = id;
await resumeLiveNotifications(currentApiBase(), await loadSessionToken(), id).catch(
() => undefined,
);
return true;
} catch {
return false;
}
}
export function selectedSpaceId(): string | null {
return cachedSpaceId || null;
}
export async function selectInitialSpace(id: string) {
if (selectedSpaceId()) return true;
return selectSpace(id);
}
async function clearSpace(): Promise<boolean> {
const spaceCleared = await clearStoredValue(SPACE_KEY);
const rollbackCleared = await clearStoredValue(SPACE_ROLLBACK_KEY);
if (!spaceCleared || !rollbackCleared) return false;
cachedSpaceId = "";
return true;
}
async function clearStoredValue(key: string): Promise<boolean> {
try {
await SecureStore.deleteItemAsync(key);
return true;
} catch {
try {
await SecureStore.setItemAsync(key, "");
return true;
} catch {
return false;
}
}
}
async function snapshotSpace(): Promise<{ ok: true; value: string } | { ok: false }> {
if (cachedSpaceId) return { ok: true, value: cachedSpaceId };
try {
return { ok: true, value: (await SecureStore.getItemAsync(SPACE_KEY)) ?? "" };
} catch {
return { ok: false };
}
}
/** Clears session + space for an endpoint change. Restores both if either wipe fails. */
async function clearCredentialsForEndpointChange(): Promise<
{ ok: true; previousToken: string; previousSpace: string } | { ok: false; result: EndpointResult }
> {
const previousToken = await snapshotSessionToken();
const previousSpace = await snapshotSpace();
if (!previousToken.ok || !previousSpace.ok) {
return {
ok: false,
result: { ok: false, error: "Could not clear the previous server session" },
};
}
const rollbackReady = previousSpace.value
? await saveSpaceRollback(previousSpace.value)
: await clearStoredValue(SPACE_ROLLBACK_KEY);
if (!rollbackReady) {
return {
ok: false,
result: { ok: false, error: "Could not clear the previous server session" },
};
}
const sessionCleared = await clearSessionToken();
cachedSpaceId = "";
const spaceCleared = await clearStoredValue(SPACE_KEY);
if (sessionCleared && spaceCleared) {
return { ok: true, previousToken: previousToken.value, previousSpace: previousSpace.value };
}
await restoreCredentials(previousToken.value, previousSpace.value);
return { ok: false, result: { ok: false, error: "Could not clear the previous server session" } };
}
async function restoreCredentials(previousToken: string, previousSpace: string) {
if (previousToken) await restoreSessionToken(previousToken);
if (previousSpace) {
cachedSpaceId = previousSpace;
try {
await SecureStore.setItemAsync(SPACE_KEY, previousSpace);
await clearStoredValue(SPACE_ROLLBACK_KEY);
} catch {
// The endpoint-bound rollback record restores this selection after restart.
}
}
if (previousToken) {
await resumeLiveNotifications(currentApiBase(), previousToken, previousSpace).catch(
() => undefined,
);
}
}
async function saveSpaceRollback(spaceId: string): Promise<boolean> {
try {
await SecureStore.setItemAsync(
SPACE_ROLLBACK_KEY,
JSON.stringify({ apiBase: currentApiBase(), spaceId }),
);
return true;
} catch {
return false;
}
}
async function recoverSpaceRollback(apiBase: string) {
const stored = await SecureStore.getItemAsync(SPACE_ROLLBACK_KEY);
if (!stored) return;
let parsed: unknown;
try {
parsed = JSON.parse(stored);
} catch {
await clearStoredValue(SPACE_ROLLBACK_KEY);
return;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
await clearStoredValue(SPACE_ROLLBACK_KEY);
return;
}
const rollback = parsed as { apiBase?: unknown; spaceId?: unknown };
if (rollback.apiBase !== apiBase || typeof rollback.spaceId !== "string" || !rollback.spaceId) {
await clearStoredValue(SPACE_ROLLBACK_KEY);
return;
}
try {
cachedSpaceId = rollback.spaceId;
await SecureStore.setItemAsync(SPACE_KEY, rollback.spaceId);
await clearStoredValue(SPACE_ROLLBACK_KEY);
} catch {
// Keep a valid recovery record for the next launch when storage is writable.
}
}
export async function saveApiBase(input: string): Promise<EndpointResult> {
const parsed = normalizeApiBase(input);
if (!parsed.ok) return parsed;
if (parsed.url === defaultApiBase()) return resetApiBase();
const previous = currentApiBase();
let cleared: { previousToken: string; previousSpace: string } | undefined;
if (parsed.url !== previous) {
const result = await clearCredentialsForEndpointChange();
if (!result.ok) return result.result;
cleared = result;
}
try {
await SecureStore.setItemAsync(ENDPOINT_KEY, parsed.url);
} catch {
if (cleared) await restoreCredentials(cleared.previousToken, cleared.previousSpace);
return { ok: false, error: "Could not save the server URL" };
}
cachedApiBase = parsed.url;
await clearStoredValue(SPACE_ROLLBACK_KEY);
return parsed;
}
export async function resetApiBase(): Promise<EndpointResult> {
const previous = currentApiBase();
const url = defaultApiBase();
let cleared: { previousToken: string; previousSpace: string } | undefined;
if (url !== previous) {
const result = await clearCredentialsForEndpointChange();
if (!result.ok) return result.result;
cleared = result;
}
try {
await SecureStore.deleteItemAsync(ENDPOINT_KEY);
} catch {
if (cleared) {
await restoreCredentials(cleared.previousToken, cleared.previousSpace);
return { ok: false, error: "Could not clear the custom server URL" };
}
}
cachedApiBase = url;
await clearStoredValue(SPACE_ROLLBACK_KEY);
return { ok: true, url };
}
export async function authHeaders(
spaceId: string | null = selectedSpaceId(),
): Promise<Record<string, string>> {
const token = await loadSessionToken();
return {
...(token ? { authorization: `Bearer ${token}` } : {}),
...(spaceId ? { "x-rakazo-space-id": spaceId } : {}),
};
}
export type ApiRequestContext = {
apiBase: string;
headers: Record<string, string>;
};
export async function captureApiRequestContext(): Promise<ApiRequestContext> {
const apiBase = currentApiBase();
const headers = await authHeaders(selectedSpaceId());
if (apiBase !== currentApiBase()) {
throw new Error("The server changed while starting the request");
}
return { apiBase, headers };
}
async function authenticateWithEmail(
action: "sign-in" | "sign-up",
input: { email: string; password: string; name?: string },
) {
const res = await fetch(`${currentApiBase()}/api/auth/${action}/email`, {
method: "POST",
headers: { "content-type": "application/json", origin: "rakazo://" },
body: JSON.stringify(input),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(responseErrorMessage(body, `Could not ${action.replace("-", " ")}`));
}
const token = tokenFromAuthResponse(res, body);
if (!token)
throw new Error(`${action === "sign-in" ? "Sign-in" : "Sign-up"} did not return a session`);
if (!(await clearSpace())) throw new Error("Could not clear the previous space");
await saveSessionToken(token);
}
export function signIn(email: string, password: string) {
return authenticateWithEmail("sign-in", { email, password });
}
export function signUp(email: string, password: string, name: string) {
return authenticateWithEmail("sign-up", { email, password, name });
}
export type PasswordResetCapabilities = { passwordReset: boolean; resetUrl: string | null };
export async function passwordResetCapabilities(): Promise<PasswordResetCapabilities> {
const response = await fetch(`${currentApiBase()}/api/auth/capabilities`, {
headers: { origin: "rakazo://" },
});
if (!response.ok) throw new Error("Could not load password recovery settings");
return (await response.json()) as PasswordResetCapabilities;
}
export async function requestPasswordReset(email: string, redirectTo: string): Promise<void> {
const response = await fetch(`${currentApiBase()}/api/auth/request-password-reset`, {
method: "POST",
headers: { "content-type": "application/json", origin: "rakazo://" },
body: JSON.stringify({ email, redirectTo }),
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(responseErrorMessage(body, "Could not send reset email"));
}
export async function changePassword(currentPassword: string, newPassword: string): Promise<void> {
const response = await fetch(`${currentApiBase()}/api/auth/change-password`, {
method: "POST",
headers: { "content-type": "application/json", origin: "rakazo://", ...(await authHeaders()) },
body: JSON.stringify({ currentPassword, newPassword, revokeOtherSessions: true }),
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(responseErrorMessage(body, "Could not change password"));
}
export async function signOut() {
await rpc("notifications/unregisterPush").catch(() => undefined);
const headers = await authHeaders();
await fetch(`${currentApiBase()}/api/auth/sign-out`, {
method: "POST",
headers: { "content-type": "application/json", origin: "rakazo://", ...headers },
}).catch(() => undefined);
const sessionCleared = await clearSessionToken();
const spaceCleared = await clearSpace();
if (!sessionCleared || !spaceCleared) throw new Error("Could not clear the local session");
}
export async function deleteAccount(password: string) {
await rpc("notifications/unregisterPush").catch(() => undefined);
const res = await fetch(`${currentApiBase()}/api/auth/delete-user`, {
method: "POST",
headers: { "content-type": "application/json", origin: "rakazo://", ...(await authHeaders()) },
body: JSON.stringify({ password }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(responseErrorMessage(body, "Could not delete account"));
}
await clearSessionToken();
await clearSpace();
}
export async function rpc<T>(
proc: string,
body: unknown = {},
options: {
signal?: AbortSignal;
timeoutMs?: number | null;
requestContext?: ApiRequestContext;
} = {},
): Promise<T> {
const controller = new AbortController();
const abort = () => controller.abort();
if (options.signal?.aborted) abort();
else options.signal?.addEventListener("abort", abort, { once: true });
const timer =
options.timeoutMs === null ? undefined : setTimeout(abort, options.timeoutMs ?? RPC_TIMEOUT_MS);
try {
const res = await fetch(`${options.requestContext?.apiBase ?? currentApiBase()}/rpc/${proc}`, {
method: "POST",
headers: {
"content-type": "application/json",
origin: "rakazo://",
...(options.requestContext?.headers ?? (await authHeaders())),
},
body: JSON.stringify({ json: body }),
signal: controller.signal,
});
const parsed = (await res.json()) as { json?: T; error?: { message?: string } };
if (!res.ok || parsed.error) throw new Error(parsed.error?.message ?? `rpc ${proc} failed`);
return parsed.json as T;
} finally {
if (timer) clearTimeout(timer);
options.signal?.removeEventListener("abort", abort);
}
}
export type MobileBot = Pick<
Bot,
| "id"
| "name"
| "preview"
| "title"
| "color"
| "avatarShape"
| "hasAvatarImage"
| "notifyOnFinish"
| "threadId"
| "pinned"
| "status"
| "sectionId"
| "archivedAt"
| "unread"
| "updatedAt"
| "computerMode"
> &
Partial<Pick<Bot, "parentBotId" | "spaceId">>;
export type MobileBotSection = BotSection;
export type MobileMe = Pick<
Me,
"name" | "email" | "spaceId" | "defaultProvider" | "defaultModel" | "needsModel" | "avatarStyle"
>;
export type MobileModel = ModelCatalogEntry;
export type MobileModelCredential = ModelCredential;
export type MobileMessage = {
id: string;
threadId?: string;
seq?: number;
runId?: string;
role: "user" | "bot" | "system";
botId?: string;
replyToMessageId?: string;
thumbsUp?: boolean;
blocks: MessageBlock[];
};
export type MobileGroup = Pick<
Group,
| "id"
| "name"
| "preview"
| "pinned"
| "sectionId"
| "archivedAt"
| "unread"
| "updatedAt"
| "members"
> &
Partial<Pick<Group, "spaceId">>;
export type MobileSpace = Space;
export type MobileSpaceNavigation = SpaceNavigation;
export type MobileSnapshot = {
botId?: string;
groupId?: string;
groupName?: string;
threadId: string;
cursor?: number;
messages: MobileMessage[];
olderCursor: number | null;
run: { id: string; botId?: string; status: string; error?: string | null } | null;
activeRuns?: Array<{ id: string; botId?: string; status: string }>;
members?: MobileGroup["members"];
computer?: {
state: string;
controlHolder: string;
screenAvailable: boolean;
mode: ComputerMode;
busyBotName: string | null;
};
};
export function shouldApplyMobileThreadRefresh(input: {
requestEpoch: number;
currentEpoch: number;
targetBotId: string | undefined;
targetGroupId: string | undefined;
activeBotId: string | undefined;
activeGroupId: string | undefined;
}) {
return (
input.requestEpoch === input.currentEpoch &&
input.targetBotId === input.activeBotId &&
input.targetGroupId === input.activeGroupId
);
}
export type MobileMessagePage = ThreadHistory<MobileMessage>;
export function mergeMobileSnapshot(
prev: MobileSnapshot | null,
next: MobileSnapshot,
preserveLoadedHistory = false,
): MobileSnapshot {
return mergeThreadHistory(prev, next, preserveLoadedHistory);
}
export function prependMobileMessagePage(
prev: MobileSnapshot | null,
page: MobileMessagePage,
): MobileSnapshot | null {
return prependThreadHistoryPage(prev, page);
}
const MESSAGING_PROVIDER_LABELS: Record<string, string> = {
sendblue: "iMessage",
slack: "Slack",
whatsapp: "WhatsApp",
telegram: "Telegram",
};
export function messagingProviderLabel(provider: string): string {
return MESSAGING_PROVIDER_LABELS[provider] ?? provider;
}
export function blockText(message: MobileMessage) {
return message.blocks
.map((block) => {
if (block.kind === "channel_message") {
return `${messagingProviderLabel(block.provider)} · ${block.fromLabel}: ${block.text}`;
}
if (block.kind === "subagent") {
return `${block.name ?? "subagent"}: ${block.result || block.progress || block.task || ""}`;
}
if (block.kind === "child_bot") {
return `${block.status === "archived" ? "Archived" : block.status === "deleted" ? "Deleted" : "Bot"} ${block.name ?? ""}`;
}
if (block.kind === "chart") return `[chart: ${block.name ?? "chart"}]`;
if (block.kind === "image") return `[image: ${block.name ?? "attachment"}]`;
if (block.kind === "file") {
return `[file: ${block.name ?? "attachment"}${block.size ? ` (${block.size} bytes)` : ""}]`;
}
if (block.kind === "steps") {
return (block.steps ?? [])
.map((step) => `${step.label}${step.count > 1 ? ` ×${step.count}` : ""}`)
.join(" · ");
}
return ("text" in block ? block.text : "state" in block ? block.state : "") ?? "";
})
.filter(Boolean)
.join("\n");
}
type ThreadEvent = {
id?: string;
botId?: string;
type: string;
seq?: number;
runId?: string;
payload?: Record<string, unknown>;
};
function takeMobileLiveMessage(
snapshot: MobileSnapshot,
liveId: string,
): { previous: MobileMessage | undefined; remaining: MobileMessage[] } {
let previous: MobileMessage | undefined;
const remaining: MobileMessage[] = [];
for (const message of snapshot.messages) {
if (message.id === liveId) {
previous = message;
} else if (!message.id.startsWith("progress:") || message.runId) {
remaining.push(message);
}
}
return { previous, remaining };
}
export async function subscribeThread(
target: { botId: string } | { groupId: string },
cursor: number,
onEvent: (event: ThreadEvent) => void,
signal: AbortSignal,
) {
const res = await fetch(`${currentApiBase()}/rpc/threads/subscribe`, {
method: "POST",
headers: {
"content-type": "application/json",
accept: "text/event-stream",
origin: "rakazo://",
...(await authHeaders()),
},
body: JSON.stringify({ json: { ...target, cursor } }),
signal,
});
if (!res.ok || !res.body) throw new Error(`rpc threads/subscribe failed (${res.status})`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (!signal.aborted) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const chunks = buffer.split("\n\n");
buffer = chunks.pop() ?? "";
for (const chunk of chunks) {
const data = chunk
.split("\n")
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trim())
.join("");
if (!data || data === "[DONE]") continue;
try {
const parsed = JSON.parse(data) as { json?: ThreadEvent; error?: { message?: string } };
if (parsed.json?.type) onEvent(parsed.json);
} catch {
// ignore keepalives and partial frames
}
}
}
}
export function applyMobileThreadEvent(
prev: MobileSnapshot | null,
event: ThreadEvent,
): MobileSnapshot | null {
if (!prev) return prev;
if (event.type === "thread.cleared") {
return {
...prev,
cursor: event.seq,
messages: [],
olderCursor: null,
run: null,
activeRuns: [],
};
}
if (event.type === "run.waiting_input") {
const progressId = progressMessageId(event);
const messages = prev.messages.filter((message) => message.id !== progressId);
const progressCleared = messages.length !== prev.messages.length;
const runChanged = Boolean(
prev.run && prev.run.id === event.runId && prev.run.status !== "waiting_input",
);
const activeRunChanged = prev.activeRuns?.some(
(candidate) => candidate.id === event.runId && candidate.status !== "waiting_input",
);
const cursor = event.seq ?? prev.cursor;
if (!runChanged && !activeRunChanged && !progressCleared) {
return cursor === prev.cursor ? prev : { ...prev, cursor };
}
const run = runChanged && prev.run ? { ...prev.run, status: "waiting_input" } : prev.run;
const activeRuns = activeRunChanged
? prev.activeRuns?.map((candidate) =>
candidate.id === event.runId ? { ...candidate, status: "waiting_input" } : candidate,
)
: prev.activeRuns;
return { ...prev, cursor, run, activeRuns, messages };
}
if (isRunTerminalEvent(event)) {
const activeRuns = prev.activeRuns?.filter((candidate) => candidate.id !== event.runId);
const failure = runFailureError(event);
const primaryEnded = prev.run?.id === event.runId ? prev.run : null;
// A group member run can fail while another is displayed; see reduceThreadSnapshot.
const endedRun =
primaryEnded ?? prev.activeRuns?.find((candidate) => candidate.id === event.runId) ?? null;
return {
...prev,
cursor: event.seq ?? prev.cursor,
messages: prev.messages.filter((message) => message.id !== progressMessageId(event)),
// A failed run stays in run so the thread can say why it stopped (see reduceThreadSnapshot).
run:
endedRun && failure
? { ...endedRun, status: "failed", error: failure }
: primaryEnded
? (activeRuns?.[0] ?? null)
: prev.run,
activeRuns,
};
}
if (event.type === "thread.progress") {
const progressId = progressMessageId(event);
const { previous, remaining } = takeMobileLiveMessage(prev, progressId);
const streaming: MobileMessage = {
id: progressId,
role: "bot",
blocks: reduceLiveMessageBlocks((previous?.blocks ?? []) as MessageBlock[], {
type: "progress",
payload: event.payload,
}),
...(event.botId ? { botId: event.botId } : {}),
...(event.runId ? { runId: event.runId } : {}),
};
return {
...prev,
cursor: event.seq ?? prev.cursor,
messages: [...remaining, streaming],
};
}
if (event.type === "agent.tool.called") {
const progressId = progressMessageId(event);
const { previous, remaining } = takeMobileLiveMessage(prev, progressId);
const streaming: MobileMessage = {
id: progressId,
role: "bot",
blocks: reduceLiveMessageBlocks((previous?.blocks ?? []) as MessageBlock[], {
type: "tool",
name: String(event.payload?.name ?? ""),
}),
...(event.botId ? { botId: event.botId } : {}),
...(event.runId ? { runId: event.runId } : {}),
};
return {
...prev,
cursor: event.seq ?? prev.cursor,
messages: [...remaining, streaming],
};
}
if (event.type === "thread.subagent") {
const agentId = String(event.payload?.agentId ?? event.id ?? "live");
const status = event.payload?.status;
const streaming: MobileMessage = {
id: `subagent:${agentId}`,
role: "bot",
...(event.botId ? { botId: event.botId } : {}),
...(event.runId ? { runId: event.runId } : {}),
blocks: [
{
kind: "subagent",
agentId,
name: String(event.payload?.name ?? "subagent"),
task: String(event.payload?.task ?? ""),
status: status === "completed" || status === "failed" ? status : "running",
progress: event.payload?.progress ? String(event.payload.progress) : undefined,
result: event.payload?.result ? String(event.payload.result) : undefined,
},
],
};
return {
...prev,
cursor: event.seq ?? prev.cursor,
messages: [...prev.messages.filter((message) => message.id !== streaming.id), streaming],
};
}
if (event.type === "thread.message.reaction") {
const messageId = String(event.payload?.messageId ?? "");
return {
...prev,
cursor: event.seq ?? prev.cursor,
messages: prev.messages.map((message) =>
message.id === messageId
? { ...message, thumbsUp: event.payload?.thumbsUp === true }
: message,
),
};
}
if (event.type === "thread.message.created" || event.type === "thread.message.updated") {
const { remaining } = takeMobileLiveMessage(prev, progressMessageId(event));
const next: MobileMessage = {
id: String(event.payload?.messageId ?? event.id ?? `msg:${event.seq ?? 0}`),
runId: event.runId ? String(event.runId) : undefined,
role: (event.payload?.role as MobileMessage["role"]) ?? "bot",
blocks: (event.payload?.blocks as MobileMessage["blocks"]) ?? [],
botId: event.botId ?? (event.payload?.botId ? String(event.payload.botId) : undefined),
replyToMessageId: event.payload?.replyToMessageId
? String(event.payload.replyToMessageId)
: undefined,
thumbsUp: event.payload?.thumbsUp === true,
};
return {
...prev,
cursor: event.seq ?? prev.cursor,
messages: upsertMessageById(
remaining.filter(
(message) =>
!(
message.id.startsWith("subagent:") &&
next.blocks.some(
(block) => block.kind === "subagent" && message.id === `subagent:${block.agentId}`,
)
),
),
next,
),
};
}
return prev;
}
export {
apiBaseWarning,
defaultApiBase,
displayApiHost,
normalizeApiBase,
probeApiBase,
usesCustomApiBase,
} from "./endpoint";
export { loadSessionToken };