From a0e0f73bd1ad77f84db0d5c156c4f92dd1eca7e2 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:35:18 -0400 Subject: [PATCH 01/22] refactor(dashboard): simplify onboarding layout --- src/components/dashboard-quickstart.tsx | 72 +++-------- src/routes/dashboard.tsx | 151 +++++++----------------- 2 files changed, 59 insertions(+), 164 deletions(-) diff --git a/src/components/dashboard-quickstart.tsx b/src/components/dashboard-quickstart.tsx index 36ef61a..80953fc 100644 --- a/src/components/dashboard-quickstart.tsx +++ b/src/components/dashboard-quickstart.tsx @@ -1,10 +1,4 @@ -import { - ArrowUpRight, - Check, - Copy, - Play, - TerminalWindow, -} from "@phosphor-icons/react" +import { Check, Copy } from "@phosphor-icons/react" import { Button } from "@/components/ui/button" import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard" @@ -32,20 +26,15 @@ function CopyButton({ label, value }: { label: string; value: string }) { function Step({ children, - icon, number, title, }: { children: React.ReactNode - icon: React.ReactNode number: string title: string }) { return ( -
  • -
    - {icon} -
    +
  • Step {number} @@ -61,67 +50,40 @@ export function DashboardQuickstart() { return (

    -
    -
    -

    - Start with the CLI -

    -

    - Connect your account and parse a document in two commands. -

    -
    - +
    +

    + Start with the CLI +

    +

    + Connect your account and parse a document in two commands. +

    -
      - } - number="01" - title="Connect your account" - > +
        +

        Approve the browser prompt. The CLI creates and stores its own key.

        -
        - +
        + {loginCommand}
        - } - number="02" - title="Parse a document" - > -
        - + +
        + {parseCommand}
      - -

      - Building an integration?{" "} - - Use the TypeScript SDK - - . -

    ) } diff --git a/src/routes/dashboard.tsx b/src/routes/dashboard.tsx index 27dc30d..858ce27 100644 --- a/src/routes/dashboard.tsx +++ b/src/routes/dashboard.tsx @@ -1,10 +1,4 @@ -import { - ArrowUpRight, - BookOpenText, - BracketsCurly, - CloudArrowUp, - SignOut, -} from "@phosphor-icons/react" +import { BookOpenText, CloudArrowUp, SignOut } from "@phosphor-icons/react" import { createFileRoute, redirect, useRouter } from "@tanstack/react-router" import { useState } from "react" @@ -33,8 +27,6 @@ export const Route = createFileRoute("/dashboard")({ search: { redirect: location.href }, }) } - - return { session } }, head: () => ({ meta: [ @@ -47,7 +39,6 @@ export const Route = createFileRoute("/dashboard")({ function DashboardPage() { const router = useRouter() - const { session } = Route.useRouteContext() const [signingOut, setSigningOut] = useState(false) async function signOut() { @@ -60,17 +51,7 @@ function DashboardPage() { return (
    - -
    +
    {createdKey ? ( - + Copy this key now It will not be shown again. -
    - +
    + {createdKey} + +
    + ) +} diff --git a/src/components/posthog-bootstrap.tsx b/src/components/posthog-bootstrap.tsx new file mode 100644 index 0000000..a9686c9 --- /dev/null +++ b/src/components/posthog-bootstrap.tsx @@ -0,0 +1,18 @@ +import { useEffect } from "react" + +import type { PublicPostHogConfig } from "@/integrations/posthog/config" +import { initializePostHog } from "@/integrations/posthog/browser" + +export function PostHogBootstrap({ + config, +}: { + config: PublicPostHogConfig | undefined +}) { + useEffect(() => { + if (config) { + initializePostHog(config) + } + }, [config]) + + return null +} diff --git a/src/components/root-error.tsx b/src/components/root-error.tsx new file mode 100644 index 0000000..1b8d6d0 --- /dev/null +++ b/src/components/root-error.tsx @@ -0,0 +1,40 @@ +import { Link } from "@tanstack/react-router" +import type { ErrorComponentProps } from "@tanstack/react-router" +import { useEffect } from "react" + +import { FileRouterBrand } from "@/components/file-router-brand" +import { Button } from "@/components/ui/button" +import { captureBrowserException } from "@/integrations/posthog/browser" + +export function RootError({ error, reset }: ErrorComponentProps) { + useEffect(() => { + captureBrowserException(error, { + path: + typeof window === "undefined" ? undefined : window.location.pathname, + }) + }, [error]) + + return ( +
    + +
    +

    + Something went wrong +

    +

    + FileRouter hit an unexpected error +

    +

    + Try the request again. If it keeps failing, contact the team with the + time it happened. +

    +
    + + +
    +
    +
    + ) +} diff --git a/src/integrations/autumn/managed-execution-cost.ts b/src/integrations/autumn/managed-execution-cost.ts new file mode 100644 index 0000000..be9453e --- /dev/null +++ b/src/integrations/autumn/managed-execution-cost.ts @@ -0,0 +1,98 @@ +import type { ProviderOutcome } from "@/workflows/document-results" + +const MILLIUSD_PER_USD = 1_000 + +// Provider list prices verified 2026-07-21: +// https://www.llamaindex.ai/pricing +// https://mistral.ai/pricing/api/ +// https://www.datalab.to/pricing +// Prefer provider-reported cost so discounts and option-specific pricing flow +// through unchanged. +const LLAMAPARSE_USD_PER_CREDIT = 0.00125 + +// Covers the Worker, Workflow, D1, and R2 work around one provider execution. +const PLATFORM_USD_PER_EXECUTION = 0.00025 + +// Native parser estimates use https://developers.cloudflare.com/containers/pricing/ +// plus one sleep-after window per execution. Shared warm windows make this +// estimate deliberately conservative at higher volume. +const CONTAINER_IDLE_ALLOWANCE_SECONDS = 60 +const CONTAINER_CPU_USD_PER_VCPU_SECOND = 0.00002 +const CONTAINER_MEMORY_USD_PER_GIB_SECOND = 0.0000025 +const CONTAINER_DISK_USD_PER_GB_SECOND = 0.00000007 + +export type ParsedProviderOutcome = Extract< + ProviderOutcome, + { status: "parsed" } +> + +export interface ManagedExecutionEstimate { + rawCostUsd: number + units: number +} + +export function estimateManagedExecution( + provider: ParsedProviderOutcome +): ManagedExecutionEstimate | undefined { + const providerCost = providerCostUsd(provider) + if (providerCost === undefined) { + return undefined + } + const rawCostUsd = providerCost + PLATFORM_USD_PER_EXECUTION + return { + rawCostUsd, + units: rawCostUsd * MILLIUSD_PER_USD, + } +} + +function providerCostUsd(provider: ParsedProviderOutcome): number | undefined { + const reportedCost = provider.usage?.costUsd + if (isNonNegativeFinite(reportedCost)) { + return reportedCost + } + + switch (provider.provider) { + case "llamaparse": { + const credits = provider.usage?.credits + return isNonNegativeFinite(credits) + ? credits * LLAMAPARSE_USD_PER_CREDIT + : undefined + } + case "mistral-ocr": + case "datalab": + return undefined + case "liteparse": + return containerCostUsd(provider.durationMs, { + diskGb: 8, + memoryGib: 4, + vcpu: 0.5, + }) + case "pdf-inspector": + return containerCostUsd(provider.durationMs, { + diskGb: 4, + memoryGib: 1, + vcpu: 0.25, + }) + default: + throw new Error(`Cannot price unknown provider: ${provider.provider}`) + } +} + +function containerCostUsd( + durationMs: number, + resources: { diskGb: number; memoryGib: number; vcpu: number } +): number { + const activeSeconds = Math.max(0, durationMs) / 1_000 + const memoryAndDiskPerSecond = + resources.memoryGib * CONTAINER_MEMORY_USD_PER_GIB_SECOND + + resources.diskGb * CONTAINER_DISK_USD_PER_GB_SECOND + const cpuPerSecond = resources.vcpu * CONTAINER_CPU_USD_PER_VCPU_SECOND + return ( + activeSeconds * (memoryAndDiskPerSecond + cpuPerSecond) + + CONTAINER_IDLE_ALLOWANCE_SECONDS * memoryAndDiskPerSecond + ) +} + +function isNonNegativeFinite(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0 +} diff --git a/src/integrations/autumn/managed-execution.test.ts b/src/integrations/autumn/managed-execution.test.ts new file mode 100644 index 0000000..4563de4 --- /dev/null +++ b/src/integrations/autumn/managed-execution.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, test, vi } from "vite-plus/test" + +import { estimateManagedExecution } from "@/integrations/autumn/managed-execution-cost" +import { + MANAGED_EXECUTION_FEATURE_ID, + MANAGED_EXECUTION_FREE_PLAN_ID, + trackManagedExecutionForAccount, +} from "@/integrations/autumn/managed-execution" +import type { ProviderOutcome } from "@/workflows/document-results" + +function parsed( + input: Partial> & + Pick, "provider"> +): Extract { + return { + durationMs: 1_000, + pageCount: 1, + resultKey: "jobs/test/result.json", + status: "parsed", + ...input, + } +} + +describe("managed execution pricing", () => { + test("uses provider-reported dollar cost when available", () => { + expect( + estimateManagedExecution( + parsed({ + pageCount: 10, + provider: "datalab", + usage: { costUsd: 0.04, pages: 10 }, + }) + ) + ).toEqual({ rawCostUsd: 0.04025, units: 40.25 }) + }) + + test("converts LlamaParse credits into upstream cost", () => { + expect( + estimateManagedExecution( + parsed({ + pageCount: 10, + provider: "llamaparse", + usage: { credits: 30, pages: 10 }, + }) + ) + ).toEqual({ rawCostUsd: 0.03775, units: 37.75 }) + }) + + test("prices Mistral OCR from processed pages", () => { + expect( + estimateManagedExecution( + parsed({ + pageCount: 10, + provider: "mistral-ocr", + usage: { costUsd: 0.04, pages: 10 }, + }) + ) + ).toEqual({ rawCostUsd: 0.04025, units: 40.25 }) + }) + + test("does not guess mode-dependent provider prices", () => { + expect( + estimateManagedExecution( + parsed({ pageCount: 10, provider: "llamaparse", usage: { pages: 10 } }) + ) + ).toBeUndefined() + expect( + estimateManagedExecution( + parsed({ pageCount: 10, provider: "datalab", usage: { pages: 10 } }) + ) + ).toBeUndefined() + expect( + estimateManagedExecution( + parsed({ pageCount: 10, provider: "mistral-ocr", usage: { pages: 10 } }) + ) + ).toBeUndefined() + }) + + test("estimates native parser compute from its provisioned resources", () => { + const estimate = estimateManagedExecution( + parsed({ durationMs: 10_000, provider: "liteparse" }) + ) + + expect(estimate).toBeDefined() + if (!estimate) { + throw new Error("Expected a native parser estimate.") + } + expect(estimate.rawCostUsd).toBeCloseTo(0.0010892) + expect(estimate.units).toBeCloseTo(1.0892) + }) + + test("syncs the customer and records idempotent provider events", async () => { + const getOrCreate = vi.fn().mockResolvedValue({}) + const track = vi.fn().mockResolvedValue({}) + + await expect( + trackManagedExecutionForAccount( + { customers: { getOrCreate }, track }, + { + jobId: "job-123", + operation: "compare", + providers: [ + parsed({ + pageCount: 2, + provider: "mistral-ocr", + usage: { costUsd: 0.008, pages: 2 }, + }), + { + durationMs: 100, + error: { message: "failed" }, + provider: "datalab", + status: "failed", + }, + ], + userId: "user-123", + }, + { email: "dev@example.com", name: "Developer" } + ) + ).resolves.toEqual({ trackedProviders: 1, unpricedProviders: [] }) + + expect(getOrCreate).toHaveBeenCalledWith({ + autoEnablePlanId: MANAGED_EXECUTION_FREE_PLAN_ID, + customerId: "user-123", + email: "dev@example.com", + metadata: { account_type: "developer", product: "filerouter" }, + name: "Developer", + }) + expect(track).toHaveBeenCalledWith( + expect.objectContaining({ + customerId: "user-123", + featureId: MANAGED_EXECUTION_FEATURE_ID, + value: 8.25, + }), + { + headers: { + "Idempotency-Key": "document-job:job-123:mistral-ocr", + }, + } + ) + }) +}) diff --git a/src/integrations/autumn/managed-execution.ts b/src/integrations/autumn/managed-execution.ts new file mode 100644 index 0000000..ca018b2 --- /dev/null +++ b/src/integrations/autumn/managed-execution.ts @@ -0,0 +1,151 @@ +import { Autumn } from "autumn-js" +import { eq } from "drizzle-orm" + +import { user } from "@/db/schema" +import { createDb } from "@/db/server" +import { estimateManagedExecution } from "@/integrations/autumn/managed-execution-cost" +import type { + ManagedExecutionEstimate, + ParsedProviderOutcome, +} from "@/integrations/autumn/managed-execution-cost" +import type { ProviderOutcome } from "@/workflows/document-results" + +export const MANAGED_EXECUTION_FEATURE_ID = "managed_execution_units" +export const MANAGED_EXECUTION_FREE_PLAN_ID = "developer" + +export interface AutumnUsageClient { + customers: { + getOrCreate: ( + request: { + autoEnablePlanId: string + customerId: string + email: string + metadata: Record + name: string + }, + options?: RequestInit + ) => Promise + } + track: ( + request: { + customerId: string + featureId: string + properties: Record + value: number + }, + options?: RequestInit + ) => Promise +} + +interface AutumnUsageEnv { + AUTUMN_SECRET_KEY?: string + DB: D1Database +} + +export interface TrackManagedExecutionInput { + jobId: string + operation: "compare" | "parse" + providers: Array + userId: string +} + +export async function trackManagedExecutionUsage( + env: AutumnUsageEnv, + input: TrackManagedExecutionInput, + client = createAutumnClient(env.AUTUMN_SECRET_KEY) +): Promise< + | { skipped: true } + | { trackedProviders: number; unpricedProviders: Array } +> { + if (!client) { + return { skipped: true } + } + + const account = await createDb(env.DB) + .select({ email: user.email, name: user.name }) + .from(user) + .where(eq(user.id, input.userId)) + .get() + if (!account) { + throw new Error(`Cannot meter job ${input.jobId}: user not found.`) + } + + return trackManagedExecutionForAccount(client, input, account) +} + +export async function trackManagedExecutionForAccount( + client: AutumnUsageClient, + input: TrackManagedExecutionInput, + account: { email: string; name: string } +): Promise<{ trackedProviders: number; unpricedProviders: Array }> { + await client.customers.getOrCreate({ + autoEnablePlanId: MANAGED_EXECUTION_FREE_PLAN_ID, + customerId: input.userId, + email: account.email, + metadata: { account_type: "developer", product: "filerouter" }, + name: account.name, + }) + + const parsedProviders = input.providers.filter( + (provider): provider is ParsedProviderOutcome => + provider.status === "parsed" + ) + const unpricedProviders: Array = [] + const pricedProviders = parsedProviders.flatMap((provider) => { + const estimate = estimateManagedExecution(provider) + if (!estimate) { + unpricedProviders.push(provider.provider) + return [] + } + return [{ estimate, provider }] + }) + await Promise.all( + pricedProviders.map(({ estimate, provider }) => { + return client.track( + { + customerId: input.userId, + featureId: MANAGED_EXECUTION_FEATURE_ID, + properties: usageProperties(input, provider, estimate), + value: estimate.units, + }, + { + headers: { + "Idempotency-Key": `document-job:${input.jobId}:${provider.provider}`, + }, + } + ) + }) + ) + + return { trackedProviders: pricedProviders.length, unpricedProviders } +} + +function usageProperties( + input: TrackManagedExecutionInput, + provider: ParsedProviderOutcome, + estimate: ManagedExecutionEstimate +): Record { + return { + duration_ms: provider.durationMs, + job_id: input.jobId, + operation: input.operation, + pages: provider.usage?.pages ?? provider.pageCount, + provider: provider.provider, + raw_cost_usd: estimate.rawCostUsd, + ...(provider.usage?.costUsd !== undefined && { + provider_cost_usd: provider.usage.costUsd, + }), + ...(provider.usage?.credits !== undefined && { + provider_credits: provider.usage.credits, + }), + } +} + +function createAutumnClient( + secretKey: string | undefined +): AutumnUsageClient | undefined { + if (!secretKey?.trim()) { + return undefined + } + return new Autumn({ secretKey, timeoutMs: 10_000 }) +} diff --git a/src/integrations/posthog/browser.ts b/src/integrations/posthog/browser.ts new file mode 100644 index 0000000..cc0e6c8 --- /dev/null +++ b/src/integrations/posthog/browser.ts @@ -0,0 +1,105 @@ +import type { BeforeSendFn, Properties } from "posthog-js" + +import type { PublicPostHogConfig } from "@/integrations/posthog/config" + +type PostHogClient = (typeof import("posthog-js/dist/module.slim"))["default"] + +let clientPromise: Promise | undefined + +export function initializePostHog(config: PublicPostHogConfig): void { + if (clientPromise || typeof window === "undefined") { + return + } + + clientPromise = import("posthog-js/dist/module.slim") + .then(({ default: posthog }) => { + posthog.init(config.token, { + api_host: config.host, + autocapture: false, + before_send: sanitizeEventUrls, + capture_exceptions: true, + capture_pageleave: false, + capture_pageview: "history_change", + defaults: "2026-05-30", + person_profiles: "identified_only", + session_recording: { + maskAllInputs: true, + maskCapturedNetworkRequestFn: sanitizeRecordedRequest, + }, + tracing_headers: [window.location.hostname], + }) + return posthog + }) + .catch(() => undefined) +} + +export function captureBrowserEvent( + event: string, + properties?: Properties +): void { + withClient((posthog) => posthog.capture(event, properties)) +} + +export function captureBrowserException( + error: unknown, + properties?: Properties +): void { + withClient((posthog) => posthog.captureException(error, properties)) +} + +export function identifyBrowserUser(userId: string): void { + withClient((posthog) => posthog.identify(userId)) +} + +export function resetBrowserUser(): void { + withClient((posthog) => posthog.reset()) +} + +function withClient(action: (posthog: PostHogClient) => void): void { + void clientPromise?.then((posthog) => { + if (posthog) { + action(posthog) + } + }) +} + +function sanitizeEventUrls(event: Parameters[0]) { + if (!event?.properties) { + return event + } + return { + ...event, + properties: { + ...event.properties, + ...sanitizeUrlProperty(event.properties, "$current_url"), + ...sanitizeUrlProperty(event.properties, "$referrer"), + }, + } +} + +function sanitizeUrlProperty( + properties: Properties, + key: "$current_url" | "$referrer" +): Properties { + const value = properties[key] + if (typeof value !== "string" || !value) { + return {} + } + return { [key]: stripUrlDetails(value) } +} + +function sanitizeRecordedRequest(request: T): T { + if (request.name) { + request.name = stripUrlDetails(request.name) + } + return request +} + +function stripUrlDetails(value: string): string { + try { + const url = new URL(value) + return `${url.origin}${url.pathname}` + } catch { + return value.split("?")[0]?.split("#")[0] ?? "" + } +} diff --git a/src/integrations/posthog/config.functions.ts b/src/integrations/posthog/config.functions.ts new file mode 100644 index 0000000..5ed6218 --- /dev/null +++ b/src/integrations/posthog/config.functions.ts @@ -0,0 +1,7 @@ +import { createServerFn } from "@tanstack/react-start" + +import { readPublicPostHogConfig } from "@/integrations/posthog/config.server" + +export const getPublicPostHogConfig = createServerFn({ method: "GET" }).handler( + () => readPublicPostHogConfig() +) diff --git a/src/integrations/posthog/config.server.ts b/src/integrations/posthog/config.server.ts new file mode 100644 index 0000000..25e0d54 --- /dev/null +++ b/src/integrations/posthog/config.server.ts @@ -0,0 +1,10 @@ +import { env } from "cloudflare:workers" + +import { + getPostHogConfig, + type PostHogEnv, +} from "@/integrations/posthog/config" + +export function readPublicPostHogConfig() { + return getPostHogConfig(env as Cloudflare.Env & PostHogEnv) +} diff --git a/src/integrations/posthog/config.ts b/src/integrations/posthog/config.ts new file mode 100644 index 0000000..ddd9966 --- /dev/null +++ b/src/integrations/posthog/config.ts @@ -0,0 +1,24 @@ +export const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com" + +export interface PostHogEnv { + POSTHOG_HOST?: string + POSTHOG_PROJECT_TOKEN?: string +} + +export interface PublicPostHogConfig { + host: string + token: string +} + +export function getPostHogConfig( + env: PostHogEnv +): PublicPostHogConfig | undefined { + const token = env.POSTHOG_PROJECT_TOKEN?.trim() + if (!token) { + return undefined + } + return { + host: env.POSTHOG_HOST?.trim() || DEFAULT_POSTHOG_HOST, + token, + } +} diff --git a/src/integrations/posthog/server.ts b/src/integrations/posthog/server.ts new file mode 100644 index 0000000..578a123 --- /dev/null +++ b/src/integrations/posthog/server.ts @@ -0,0 +1,91 @@ +import { PostHog } from "posthog-node/edge" + +import { + getPostHogConfig, + type PostHogEnv, +} from "@/integrations/posthog/config" +import { + emitWideEvent, + serializeError, + type ObservabilityEnv, +} from "@/observability/log" + +type ServerTelemetryEnv = PostHogEnv & ObservabilityEnv + +const DISTINCT_ID_HEADER = "x-posthog-distinct-id" +const SESSION_ID_HEADER = "x-posthog-session-id" +const WINDOW_ID_HEADER = "x-posthog-window-id" + +type ServerTelemetry = { + distinctId: string + properties?: Record +} & ( + | { event: string; exception?: unknown } + | { event?: string; exception: unknown } +) + +export function captureServerTelemetry( + env: ServerTelemetryEnv, + telemetry: ServerTelemetry +): Promise { + const config = getPostHogConfig(env) + if (!config) { + return Promise.resolve() + } + + return deliver(env, config, telemetry) +} + +export function postHogDistinctId(request: Request, fallback: string): string { + return request.headers.get(DISTINCT_ID_HEADER) ?? fallback +} + +export function postHogTraceProperties( + request: Request +): Record { + return Object.fromEntries( + [ + ["$session_id", request.headers.get(SESSION_ID_HEADER)], + ["$window_id", request.headers.get(WINDOW_ID_HEADER)], + ].filter((entry): entry is [string, string] => Boolean(entry[1])) + ) +} + +async function deliver( + env: ServerTelemetryEnv, + config: NonNullable>, + telemetry: ServerTelemetry +): Promise { + const client = new PostHog(config.token, { + flushAt: 1, + flushInterval: 0, + host: config.host, + }) + try { + if (telemetry.event) { + await client.captureImmediate({ + distinctId: telemetry.distinctId, + event: telemetry.event, + properties: telemetry.properties, + }) + } + if (telemetry.exception !== undefined) { + client.captureException( + telemetry.exception, + telemetry.distinctId, + telemetry.properties + ) + } + await client.shutdown() + } catch (error) { + emitWideEvent(env, "error", { + attempted_event: telemetry.event ?? "$exception", + event: "telemetry_delivery_failed", + job_id: telemetry.properties?.job_id, + provider: "posthog", + request_id: telemetry.properties?.request_id, + service: "filerouter", + ...serializeError(error), + }) + } +} diff --git a/src/lib/auth.functions.ts b/src/lib/auth.functions.ts index 51b7ae6..0eada3f 100644 --- a/src/lib/auth.functions.ts +++ b/src/lib/auth.functions.ts @@ -1,12 +1,10 @@ import { createServerFn } from "@tanstack/react-start" import { getRequestHeaders } from "@tanstack/react-start/server" -export const getSession = createServerFn({ method: "GET" }).handler( - async () => { - const { getSessionFromHeaders } = await import("@/lib/auth-queries.server") +import { getSessionFromHeaders } from "@/lib/auth-queries.server" - return getSessionFromHeaders(getRequestHeaders()) - } +export const getSession = createServerFn({ method: "GET" }).handler(() => + getSessionFromHeaders(getRequestHeaders()) ) export type AuthSession = Awaited> diff --git a/src/lib/document-jobs.server.ts b/src/lib/document-jobs.server.ts index 92f7aca..b83ace4 100644 --- a/src/lib/document-jobs.server.ts +++ b/src/lib/document-jobs.server.ts @@ -10,6 +10,7 @@ import { } from "@/lib/document-limits" import { HttpError } from "@/lib/http.server" import { hashToken } from "@/lib/tokens.server" +import { emitWideEvent, serializeError } from "@/observability/log" import type { DocumentWorkflowParams } from "@/workflows/document-workflow" type CreateDocumentJobResult = { @@ -25,6 +26,7 @@ export async function createDocumentJob( userId: string, env: Cloudflare.Env, idempotencyKey: string, + requestId: string, validatedJson?: unknown ): Promise { const input = await readDocumentJobInput(request, validatedJson) @@ -116,7 +118,9 @@ export async function createDocumentJob( outputs: input.outputs, ...(input.pages && { pages: input.pages }), providers: input.providers, + requestId, source: workflowSource, + userId, ...(input.providerOptions && { providerOptions: input.providerOptions }), } try { @@ -138,9 +142,12 @@ export async function createDocumentJob( } finally { if (!sourceOwnedByJob) { await env.FILEROUTER_FILES.delete(sourceKey).catch((error: unknown) => { - console.error("Failed to remove unowned document source", { - error, - sourceKey, + emitWideEvent(env, "error", { + event: "document_source_cleanup_failed", + job_id: id, + request_id: requestId, + service: "filerouter-api", + ...serializeError(error), }) }) } @@ -177,7 +184,8 @@ function bytesToHex(value: ArrayBuffer): string { export async function getDocumentJobResponse( id: string, userId: string, - env: Cloudflare.Env + env: Cloudflare.Env, + requestId: string ) { const job = await createDb(env.DB) .select() @@ -207,10 +215,12 @@ export async function getDocumentJobResponse( .set({ resultKey: null }) .where(eq(documentJob.id, job.id)) } catch (error) { - console.error("Failed to remove expired document result", { - error, - jobId: job.id, - resultKey: job.resultKey, + emitWideEvent(env, "error", { + event: "document_result_cleanup_failed", + job_id: job.id, + request_id: requestId, + service: "filerouter-api", + ...serializeError(error), }) } } diff --git a/src/lib/hosted-providers.server.ts b/src/lib/hosted-providers.server.ts index 54787fa..e126a40 100644 --- a/src/lib/hosted-providers.server.ts +++ b/src/lib/hosted-providers.server.ts @@ -4,6 +4,7 @@ import type { FileRouterProvider, ProviderParseOptions } from "@file_router/sdk" import { HttpError } from "@/lib/http.server" import { createNativeParserProvider } from "@/lib/native-parser.server" +import { JOB_ID_HEADER, REQUEST_ID_HEADER } from "@/observability/log" const blockedTransportOptions: Record> = { datalab: new Set([ @@ -77,14 +78,20 @@ const validateProviderOptions: Record< } export function createHostedProviders( - env: Cloudflare.Env + env: Cloudflare.Env, + context: { jobId: string; requestId: string } ): Record { const managed = builtInProviders({ datalabApiKey: env.DATALAB_API_KEY, llamaCloudApiKey: env.LLAMA_CLOUD_API_KEY, mistralApiKey: env.MISTRAL_API_KEY, }) - const nativeFetch = (request: Request) => env.NATIVE_PARSERS.fetch(request) + const nativeFetch = (request: Request) => { + const headers = new Headers(request.headers) + headers.set(JOB_ID_HEADER, context.jobId) + headers.set(REQUEST_ID_HEADER, context.requestId) + return env.NATIVE_PARSERS.fetch(new Request(request, { headers })) + } return { ...managed, liteparse: createNativeParserProvider({ diff --git a/src/observability/log.ts b/src/observability/log.ts new file mode 100644 index 0000000..6703359 --- /dev/null +++ b/src/observability/log.ts @@ -0,0 +1,80 @@ +export const REQUEST_ID_HEADER = "x-request-id" +export const JOB_ID_HEADER = "x-filerouter-job-id" + +type LogLevel = "error" | "info" + +type VersionMetadata = { + id: string + tag: string + timestamp: string +} + +export interface ObservabilityEnv { + ENVIRONMENT?: string + WORKER_VERSION?: VersionMetadata +} + +export type WideEvent = Record & { + event: string + service: string +} + +export function emitWideEvent( + env: ObservabilityEnv, + level: LogLevel, + event: WideEvent +): void { + const release = env.WORKER_VERSION + const record = { + timestamp: new Date().toISOString(), + environment: + env.ENVIRONMENT ?? (import.meta.env.DEV ? "development" : "production"), + ...(release && { + release_id: release.id, + release_tag: release.tag, + release_uploaded_at: release.timestamp, + }), + ...event, + } + + console[level](record) +} + +export function serializeError(error: unknown): { + error_message: string + error_type: string +} { + return error instanceof Error + ? { error_message: error.message, error_type: error.name } + : { error_message: "Unknown error", error_type: "UnknownError" } +} + +export function requestIdFrom(request: Request): string { + const value = request.headers.get(REQUEST_ID_HEADER)?.trim() + return value && value.length <= 128 ? value : crypto.randomUUID() +} + +export function withRequestId( + request: Request, + requestId = requestIdFrom(request) +): Request { + if (request.headers.get(REQUEST_ID_HEADER) === requestId) { + return request + } + const headers = new Headers(request.headers) + headers.set(REQUEST_ID_HEADER, requestId) + return new Request(request, { headers }) +} + +export function withResponseRequestId( + response: Response, + requestId: string +): Response { + const headers = new Headers(response.headers) + headers.set("X-Request-Id", requestId) + return new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText, + }) +} diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index c9662b7..cb5ad12 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -10,6 +10,10 @@ import type { QueryClient } from "@tanstack/react-query" import type { ReactNode } from "react" import { ThemeProvider } from "@/components/theme-provider" +import { NotFoundPage } from "@/components/not-found-page" +import { PostHogBootstrap } from "@/components/posthog-bootstrap" +import { RootError } from "@/components/root-error" +import { getPublicPostHogConfig } from "@/integrations/posthog/config.functions" import type { AuthSession } from "@/lib/session-query" import appCss from "../styles.css?url" @@ -20,6 +24,7 @@ interface RouterContext { } export const Route = createRootRouteWithContext()({ + loader: () => getPublicPostHogConfig(), head: () => ({ meta: [ { @@ -98,16 +103,14 @@ export const Route = createRootRouteWithContext()({ }, ], }), - notFoundComponent: () => ( -
    -

    404

    -

    The requested page could not be found.

    -
    - ), + errorComponent: RootError, + notFoundComponent: NotFoundPage, shellComponent: RootDocument, }) function RootDocument({ children }: { children: ReactNode }) { + const postHogConfig = Route.useLoaderData() + return ( @@ -115,6 +118,7 @@ function RootDocument({ children }: { children: ReactNode }) { + {children} {import.meta.env.DEV ? ( ({ meta: [ @@ -38,14 +45,28 @@ export const Route = createFileRoute("/dashboard")({ }) function DashboardPage() { + const { session } = Route.useRouteContext() const router = useRouter() const [signingOut, setSigningOut] = useState(false) + useEffect(() => { + identifyBrowserUser(session.user.id) + }, [session.user.id]) + async function signOut() { setSigningOut(true) - await authClient.signOut() - await router.invalidate() - await router.navigate({ to: "/" }) + try { + const result = await authClient.signOut() + if (result.error) { + throw new Error(result.error.message) + } + resetBrowserUser() + await router.invalidate() + await router.navigate({ to: "/" }) + } catch (error) { + captureBrowserException(error, { operation: "sign_out" }) + setSigningOut(false) + } } return ( diff --git a/src/routes/device.tsx b/src/routes/device.tsx index 63bebaf..0db68d1 100644 --- a/src/routes/device.tsx +++ b/src/routes/device.tsx @@ -6,11 +6,16 @@ import { } from "@phosphor-icons/react" import { useMutation, useQuery } from "@tanstack/react-query" import { createFileRoute, redirect } from "@tanstack/react-router" -import { useState } from "react" +import { useEffect, useState } from "react" import { FileRouterBrand } from "@/components/file-router-brand" import { ModeToggle } from "@/components/mode-toggle" import { Button } from "@/components/ui/button" +import { + captureBrowserEvent, + captureBrowserException, + identifyBrowserUser, +} from "@/integrations/posthog/browser" import { authClient } from "@/lib/auth-client" import { getAuthSessionQueryOptions } from "@/lib/session-query" @@ -33,6 +38,8 @@ export const Route = createFileRoute("/device")({ search: { redirect: location.href }, }) } + + return { session } }, head: () => ({ meta: [ @@ -44,9 +51,14 @@ export const Route = createFileRoute("/device")({ }) function DeviceAuthorizationPage() { + const { session } = Route.useRouteContext() const { code } = Route.useSearch() const [outcome, setOutcome] = useState<"approved" | "denied" | null>(null) + useEffect(() => { + identifyBrowserUser(session.user.id) + }, [session.user.id]) + const verification = useQuery({ enabled: Boolean(code), queryKey: ["auth", "device", code], @@ -70,7 +82,12 @@ function DeviceAuthorizationPage() { } return action === "approve" ? "approved" : "denied" }, - onSuccess: setOutcome, + onSuccess: (result) => { + captureBrowserEvent("cli_authorization_completed", { outcome: result }) + setOutcome(result) + }, + onError: (error) => + captureBrowserException(error, { operation: "cli_authorization" }), }) const error = code @@ -118,7 +135,7 @@ function DeviceAuthorizationPage() {

    Approve the terminal displaying this code:

    -
    +
    {code || "Missing code"}
    diff --git a/src/routes/privacy.tsx b/src/routes/privacy.tsx index a238258..9575b24 100644 --- a/src/routes/privacy.tsx +++ b/src/routes/privacy.tsx @@ -19,7 +19,7 @@ const privacyDocument = { "API-key information, such as key name, prefix, permissions, creation and expiration dates, request counts, rate-limit state, and last-use time.", "Document-job information, such as filenames, source URLs, selected providers, requested outputs, page selections, provider options, job status, errors, page count, timestamps, and request or idempotency hashes.", "Document contents and results when you use FileRouter-hosted processing, including uploaded source files and provider responses you ask FileRouter to return or retain.", - "Operational information, such as request identifiers, service errors, infrastructure logs, and diagnostic data needed to secure and operate the service.", + "Operational and product information, such as request identifiers, page visits, clicks and product actions, session replays, service errors, infrastructure logs, and diagnostic data needed to secure, operate, and improve the service.", "Communications and scheduling information you provide when you contact us, join the community, request support, or book a meeting through Cal.com.", ], }, @@ -40,7 +40,7 @@ const privacyDocument = { }, { title: "Service providers and recipients", - body: "We use Cloudflare for application hosting, networking, logs, databases, durable workflows, and object storage; Google for optional sign-in; Cal.com for meeting scheduling; and supported document-processing services such as LlamaParse, Mistral AI, and Datalab when selected for a hosted job. We may add or replace providers as FileRouter evolves. Providers process information under their own terms and privacy notices, and their retention may differ from FileRouter's.", + body: "We use Cloudflare for application hosting, networking, logs, databases, durable workflows, and object storage; PostHog for product analytics, error tracking, and session replay; Autumn for usage metering and billing records; Google for optional sign-in; Cal.com for meeting scheduling; and supported document-processing services such as LlamaParse, Mistral AI, and Datalab when selected for a hosted job. We may add or replace providers as FileRouter evolves. Providers process information under their own terms and privacy notices, and their retention may differ from FileRouter's.", }, { title: "Retention", @@ -58,7 +58,7 @@ const privacyDocument = { }, { title: "Cookies and browser storage", - body: "FileRouter uses cookies and browser storage for authentication, session security, and preferences such as theme. The Cal.com scheduling experience may use its own cookies or similar technology. See the FileRouter Cookie Policy for more information.", + body: "FileRouter uses cookies and browser storage for authentication, session security, preferences such as theme, and PostHog analytics. PostHog session replay masks form inputs, excludes generated API keys and CLI device codes, and removes query strings from captured URLs. The Cal.com scheduling experience may use its own cookies or similar technology. See the FileRouter Cookie Policy for more information.", }, { title: "Your choices and rights", diff --git a/src/routes/sign-in.tsx b/src/routes/sign-in.tsx index 2ac094c..c09f09c 100644 --- a/src/routes/sign-in.tsx +++ b/src/routes/sign-in.tsx @@ -6,6 +6,7 @@ import { AuthLegalNotice } from "@/components/auth-legal-notice" import { FileRouterLogo } from "@/components/file-router-logo" import { GoogleIcon } from "@/components/google-icon" import { Button } from "@/components/ui/button" +import { captureBrowserException } from "@/integrations/posthog/browser" import { authClient } from "@/lib/auth-client" import { getAuthSessionQueryOptions } from "@/lib/session-query" @@ -49,7 +50,6 @@ function SignInPage() { async function continueWithGoogle() { setLoading(true) setError(null) - try { const result = await authClient.signIn.social({ provider: "google", @@ -57,11 +57,22 @@ function SignInPage() { }) if (result.error) { - setError(result.error.message ?? "Unable to continue with Google.") - setLoading(false) + const message = + result.error.message ?? "Unable to continue with Google." + const authError = new Error(message) + captureBrowserException(authError, { + operation: "sign_in", + provider: "google", + }) + setError(message) } - } catch { + } catch (error) { + captureBrowserException(error, { + operation: "sign_in", + provider: "google", + }) setError("Unable to continue with Google. Please try again.") + } finally { setLoading(false) } } diff --git a/src/server.ts b/src/server.ts index d94f82c..01455f7 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,20 +1,110 @@ import handler from "@tanstack/react-start/server-entry" import { api } from "@/api/app" +import { + captureServerTelemetry, + postHogDistinctId, + postHogTraceProperties, +} from "@/integrations/posthog/server" import { runDocumentRetentionCleanup } from "@/lib/document-retention.server" import { isHonoApiPath } from "@/lib/request-routing" +import { + emitWideEvent, + requestIdFrom, + serializeError, + withRequestId, + withResponseRequestId, +} from "@/observability/log" export { DocumentWorkflow } from "@/workflows/document-workflow" export default { fetch(request, env, context) { - const pathname = new URL(request.url).pathname + const requestId = requestIdFrom(request) + const routedRequest = withRequestId(request, requestId) + const pathname = new URL(routedRequest.url).pathname if (isHonoApiPath(pathname)) { - return api.fetch(request, env, context) + return api.fetch(routedRequest, env, context) } - return handler.fetch(request) + return handleWebsiteRequest(routedRequest, env, context, requestId) }, scheduled(_controller, env, context) { - context.waitUntil(runDocumentRetentionCleanup(env)) + context.waitUntil(runScheduledCleanup(env)) }, } satisfies ExportedHandler + +async function handleWebsiteRequest( + request: Request, + env: Cloudflare.Env, + context: ExecutionContext, + requestId: string +): Promise { + const startedAt = Date.now() + const url = new URL(request.url) + let response: Response | undefined + let failure: unknown + + try { + response = await handler.fetch(request) + return withResponseRequestId(response, requestId) + } catch (error) { + failure = error + context.waitUntil( + captureServerTelemetry(env, { + distinctId: postHogDistinctId(request, requestId), + exception: error, + properties: { + ...postHogTraceProperties(request), + path: url.pathname, + request_id: requestId, + service: "filerouter-web", + }, + }) + ) + throw error + } finally { + const status = response?.status ?? 500 + emitWideEvent(env, status >= 500 ? "error" : "info", { + colo: request.cf?.colo, + duration_ms: Date.now() - startedAt, + event: "http_request_completed", + method: request.method, + outcome: + status >= 500 + ? "error" + : status === 404 + ? "not_found" + : status >= 400 + ? "rejected" + : "success", + path: url.pathname, + request_id: requestId, + service: "filerouter-web", + status_code: status, + ...(failure ? serializeError(failure) : {}), + }) + } +} + +async function runScheduledCleanup(env: Cloudflare.Env): Promise { + const startedAt = Date.now() + try { + const result = await runDocumentRetentionCleanup(env) + emitWideEvent(env, "info", { + ...result, + duration_ms: Date.now() - startedAt, + event: "document_retention_completed", + outcome: "success", + service: "filerouter", + }) + } catch (error) { + emitWideEvent(env, "error", { + duration_ms: Date.now() - startedAt, + event: "document_retention_completed", + outcome: "error", + service: "filerouter", + ...serializeError(error), + }) + throw error + } +} diff --git a/src/workflows/document-results.ts b/src/workflows/document-results.ts index d379ded..6abeea6 100644 --- a/src/workflows/document-results.ts +++ b/src/workflows/document-results.ts @@ -16,6 +16,7 @@ export type ProviderOutcome = provider: string resultKey: string status: "parsed" + usage?: ParseResult["usage"] } export async function storeProviderResult( @@ -33,6 +34,7 @@ export async function storeProviderResult( provider: result.provider, resultKey, status: "parsed", + ...(result.usage && { usage: result.usage }), } } diff --git a/src/workflows/document-workflow.ts b/src/workflows/document-workflow.ts index 73071a9..82a6f43 100644 --- a/src/workflows/document-workflow.ts +++ b/src/workflows/document-workflow.ts @@ -16,6 +16,8 @@ import type { import { documentJob } from "@/db/schema" import { createDb } from "@/db/server" +import { trackManagedExecutionUsage } from "@/integrations/autumn/managed-execution" +import { captureServerTelemetry } from "@/integrations/posthog/server" import { MAX_HOSTED_UPLOAD_BYTES } from "@/lib/document-limits" import { createProviderSourceUrl } from "@/lib/document-source.server" import { resultExpiresAt } from "@/lib/document-retention" @@ -26,6 +28,7 @@ import { } from "@/workflows/document-results" import type { ProviderOutcome } from "@/workflows/document-results" import { materializeDocumentSource } from "@/workflows/document-source" +import { emitWideEvent, serializeError } from "@/observability/log" export interface DocumentWorkflowParams { fileName: string @@ -36,7 +39,9 @@ export interface DocumentWorkflowParams { pages?: Array providers: Array providerOptions?: ProviderParseOptions + requestId: string source: { key: string; url?: string } + userId: string } const SINGLE_ATTEMPT = { @@ -53,11 +58,34 @@ const RETRYABLE_CHECK = { timeout: "1 minute" as const, } +const RETRYABLE_METERING = { + retries: { + backoff: "exponential" as const, + delay: "5 seconds" as const, + limit: 5, + }, + timeout: "1 minute" as const, +} + type ProviderStepStatus = | { status: "pending" | "running" } | { error: string; status: "failed" } | Extract +type CleanupOutcome = + | { status: "not_run" | "skipped" } + | { object_count: number; status: "deleted" } + | ({ status: "failed" } & ReturnType) + +type MeteringOutcome = + | { status: "not_run" | "skipped" } + | { + status: "tracked" + tracked_providers: number + unpriced_providers: Array + } + | ({ status: "failed" } & ReturnType) + export class DocumentWorkflow extends WorkflowEntrypoint< Cloudflare.Env, DocumentWorkflowParams @@ -67,17 +95,24 @@ export class DocumentWorkflow extends WorkflowEntrypoint< step: WorkflowStep ) { const params = assertParams(event.payload) + const observedStartedAt = Date.now() let providerResults: Array = [] - const startedAt = await step.do("mark job running", async () => { - const timestamp = new Date() - await createDb(this.env.DB) - .update(documentJob) - .set({ status: "running", updatedAt: timestamp }) - .where(eq(documentJob.id, params.jobId)) - return timestamp.toISOString() - }) + let resultCleanup: CleanupOutcome = { status: "not_run" } + let sourceCleanup: CleanupOutcome = { status: "not_run" } + let metering: MeteringOutcome = { status: "not_run" } + let pageCount: number | undefined + let outcome: "complete" | "failed" = "failed" + let failure: unknown try { + const startedAt = await step.do("mark job running", async () => { + const timestamp = new Date() + await createDb(this.env.DB) + .update(documentJob) + .set({ status: "running", updatedAt: timestamp }) + .where(eq(documentJob.id, params.jobId)) + return timestamp.toISOString() + }) await step.do("materialize document source", SINGLE_ATTEMPT, () => materializeDocumentSource( this.env.FILEROUTER_FILES, @@ -85,7 +120,10 @@ export class DocumentWorkflow extends WorkflowEntrypoint< params.fileName ) ) - const configured = createHostedProviders(this.env) + const configured = createHostedProviders(this.env, { + jobId: params.jobId, + requestId: params.requestId, + }) const input = await sourceInput(this.env, params) providerResults = await Promise.all( params.providers.map((providerId) => @@ -99,22 +137,31 @@ export class DocumentWorkflow extends WorkflowEntrypoint< providerResults, startedAt ) + pageCount = stored.pageCount if (params.operation === "compare") { - await deleteResultObjects( + resultCleanup = await deleteResultObjects( step, this.env.FILEROUTER_FILES, providerResultKeys(providerResults), - "delete comparison parts", - params.jobId + "delete comparison parts" ) + } else { + resultCleanup = { status: "skipped" } } - const sourceDeleted = await deleteDocumentSource( + sourceCleanup = await deleteDocumentSource( step, this.env, params, "delete document source" ) + metering = await trackUsageWithoutFailingJob( + step, + this.env, + params, + providerResults + ) + await step.do("mark job complete", async () => { const completedAt = new Date() await createDb(this.env.DB) @@ -123,7 +170,7 @@ export class DocumentWorkflow extends WorkflowEntrypoint< pageCount: stored.pageCount, resultKey: stored.resultKey, resultExpiresAt: resultExpiresAt(completedAt), - ...(sourceDeleted && { sourceKey: null }), + ...(sourceCleanup.status === "deleted" && { sourceKey: null }), status: "complete", updatedAt: completedAt, }) @@ -131,20 +178,21 @@ export class DocumentWorkflow extends WorkflowEntrypoint< return { status: "complete" } }) + outcome = "complete" return { status: "complete" as const } } catch (error) { + failure = error const message = errorMessage(error) - await deleteResultObjects( + resultCleanup = await deleteResultObjects( step, this.env.FILEROUTER_FILES, [ ...providerResultKeys(providerResults), `jobs/${params.jobId}/result.json`, ], - "delete failed results", - params.jobId + "delete failed results" ) - const sourceDeleted = await deleteDocumentSource( + sourceCleanup = await deleteDocumentSource( step, this.env, params, @@ -155,7 +203,7 @@ export class DocumentWorkflow extends WorkflowEntrypoint< .update(documentJob) .set({ error: message, - ...(sourceDeleted && { sourceKey: null }), + ...(sourceCleanup.status === "deleted" && { sourceKey: null }), status: "failed", updatedAt: new Date(), }) @@ -168,10 +216,88 @@ export class DocumentWorkflow extends WorkflowEntrypoint< return { error: message, status: "failed" } }) throw error + } finally { + const durationMs = Date.now() - observedStartedAt + const providerOutcomes = providerResults.map(providerLogFields) + const parsedProviders = providerResults.filter( + (provider) => provider.status === "parsed" + ) + const failedProviders = providerResults.length - parsedProviders.length + emitWideEvent(this.env, outcome === "failed" ? "error" : "info", { + duration_ms: durationMs, + event: "document_job_completed", + failed_provider_count: failedProviders, + job_id: params.jobId, + metering, + operation: params.operation, + outcome, + page_count: pageCount, + provider_count: providerResults.length, + provider_outcomes: providerOutcomes, + request_id: params.requestId, + result_cleanup: resultCleanup, + service: "filerouter-workflow", + source_cleanup: sourceCleanup, + successful_provider_count: parsedProviders.length, + user_id: params.userId, + ...(failure ? serializeError(failure) : {}), + }) + + const properties = { + duration_ms: durationMs, + failed_provider_count: failedProviders, + job_id: params.jobId, + metering_status: metering.status, + operation: params.operation, + outcome, + page_count: pageCount, + provider_count: providerResults.length, + providers: providerResults.map((provider) => provider.provider), + request_id: params.requestId, + successful_provider_count: parsedProviders.length, + } + this.ctx.waitUntil( + captureServerTelemetry(this.env, { + distinctId: params.userId, + event: "document_job_completed", + ...(failure ? { exception: failure } : {}), + properties, + }) + ) } } } +async function trackUsageWithoutFailingJob( + step: WorkflowStep, + env: Cloudflare.Env, + params: DocumentWorkflowParams, + providers: Array +): Promise { + try { + const result = await step.do( + "track managed execution", + RETRYABLE_METERING, + () => + trackManagedExecutionUsage(env, { + jobId: params.jobId, + operation: params.operation, + providers, + userId: params.userId, + }) + ) + return "skipped" in result + ? { status: "skipped" } + : { + status: "tracked", + tracked_providers: result.trackedProviders, + unpriced_providers: result.unpricedProviders, + } + } catch (error) { + return { status: "failed", ...serializeError(error) } + } +} + function providerResultKeys(results: Array): Array { return results.flatMap((result) => result.status === "parsed" ? [result.resultKey] : [] @@ -182,23 +308,19 @@ async function deleteResultObjects( step: WorkflowStep, bucket: R2Bucket, keys: Array, - stepName: string, - jobId: string -): Promise { + stepName: string +): Promise { if (keys.length === 0) { - return + return { status: "skipped" } } try { await step.do(stepName, SINGLE_ATTEMPT, async () => { await bucket.delete(keys) return true }) + return { object_count: keys.length, status: "deleted" } } catch (error) { - console.error("Failed to delete document result objects", { - error: errorMessage(error), - jobId, - keys, - }) + return { status: "failed", ...serializeError(error) } } } @@ -324,23 +446,38 @@ async function deleteDocumentSource( env: Cloudflare.Env, params: DocumentWorkflowParams, stepName: string -): Promise { +): Promise { const sourceKey = params.source.key try { - return await step.do(stepName, SINGLE_ATTEMPT, async () => { + await step.do(stepName, SINGLE_ATTEMPT, async () => { await env.FILEROUTER_FILES.delete(sourceKey) return true }) + return { object_count: 1, status: "deleted" } } catch (error) { - console.error("Failed to delete document source", { - error: errorMessage(error), - jobId: params.jobId, - sourceKey, - }) - return false + return { status: "failed", ...serializeError(error) } } } +function providerLogFields(provider: ProviderOutcome) { + return provider.status === "parsed" + ? { + duration_ms: provider.durationMs, + engine: provider.engine, + page_count: provider.pageCount, + provider: provider.provider, + status: provider.status, + usage: provider.usage, + } + : { + duration_ms: provider.durationMs, + error_code: provider.error.code, + error_message: provider.error.message, + provider: provider.provider, + status: provider.status, + } +} + function parseOptions(params: DocumentWorkflowParams, provider: string) { return { includeRaw: params.includeRaw, @@ -379,6 +516,8 @@ function assertParams( ): DocumentWorkflowParams { if ( !params.jobId || + !params.requestId || + !params.userId || params.providers.length === 0 || params.outputs.length === 0 || !params.source.key diff --git a/test/worker/api.test.ts b/test/worker/api.test.ts index c8d400d..4b28ec7 100644 --- a/test/worker/api.test.ts +++ b/test/worker/api.test.ts @@ -241,13 +241,15 @@ describe("FileRouter Worker", () => { urlJobRequest("https://example.com/report.pdf"), "user-idempotent", testEnv, - "retry-report-1" + "retry-report-1", + "request-idempotent-1" ) const replay = await createDocumentJob( urlJobRequest("https://example.com/report.pdf"), "user-idempotent", testEnv, - "retry-report-1" + "retry-report-1", + "request-idempotent-2" ) expect(first).toMatchObject({ @@ -262,7 +264,8 @@ describe("FileRouter Worker", () => { urlJobRequest("https://example.com/different.pdf"), "user-idempotent", testEnv, - "retry-report-1" + "retry-report-1", + "request-idempotent-3" ) ).rejects.toMatchObject({ code: "idempotency_conflict", status: 409 }) }) @@ -273,7 +276,8 @@ describe("FileRouter Worker", () => { urlJobRequest("https://example.com/report.pdf"), "user-complete-result", envWithWorkflow(vi.fn().mockResolvedValue({ id: "workflow-result" })), - "complete-result-1" + "complete-result-1", + "request-complete-result" ) const resultKey = `jobs/${created.job.id}/result.json` const result = { @@ -292,7 +296,8 @@ describe("FileRouter Worker", () => { const response = await getDocumentJobResponse( created.job.id, "user-complete-result", - env + env, + "request-complete-result" ) expect(response).toBeInstanceOf(Response) if (!(response instanceof Response)) { @@ -313,7 +318,8 @@ describe("FileRouter Worker", () => { urlJobRequest("https://example.com/expired.pdf"), "user-expired-result", envWithWorkflow(vi.fn().mockResolvedValue({ id: "workflow-expired" })), - "expired-result-1" + "expired-result-1", + "request-expired-result" ) const resultKey = `jobs/${created.job.id}/result.json` await env.FILEROUTER_FILES.put(resultKey, '{"outputs":{}}') @@ -324,7 +330,12 @@ describe("FileRouter Worker", () => { .run() await expect( - getDocumentJobResponse(created.job.id, "user-expired-result", env) + getDocumentJobResponse( + created.job.id, + "user-expired-result", + env, + "request-expired-result" + ) ).rejects.toMatchObject({ code: "result_expired", status: 410 }) expect(await env.FILEROUTER_FILES.head(resultKey)).toBeNull() }) @@ -344,7 +355,13 @@ describe("FileRouter Worker", () => { }) await expect( - createDocumentJob(request, "user-cleanup", testEnv, "cleanup-report-1") + createDocumentJob( + request, + "user-cleanup", + testEnv, + "cleanup-report-1", + "request-cleanup" + ) ).rejects.toThrow("workflow unavailable") const jobs = await env.DB.prepare( diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 8342c37..053b26c 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,9 +1,10 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 619e98540e661ee28b635ba51756dd49) +// Generated by Wrangler by running `wrangler types` (hash: d0d8ecd2cd90bd137f06ad1cfe3141e0) // Runtime types generated with workerd@1.20260714.1 2026-07-19 global_fetch_strictly_public,nodejs_compat interface __BaseEnv_Env { FILEROUTER_FILES: R2Bucket DB: D1Database + WORKER_VERSION: WorkerVersionMetadata BETTER_AUTH_SECRET: string BETTER_AUTH_URL: string GOOGLE_CLIENT_ID: string @@ -11,6 +12,7 @@ interface __BaseEnv_Env { LLAMA_CLOUD_API_KEY: string MISTRAL_API_KEY: string DATALAB_API_KEY: string + AUTUMN_SECRET_KEY: string NATIVE_PARSERS: Fetcher /* filerouter-native-parsers */ DOCUMENT_WORKFLOW: Workflow< Parameters[0]["payload"] @@ -39,6 +41,7 @@ declare namespace NodeJS { | "LLAMA_CLOUD_API_KEY" | "MISTRAL_API_KEY" | "DATALAB_API_KEY" + | "AUTUMN_SECRET_KEY" > > {} } diff --git a/wrangler.jsonc b/wrangler.jsonc index c9219a9..1a8299a 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -12,6 +12,18 @@ ], "observability": { "enabled": true, + "logs": { + "enabled": true, + "head_sampling_rate": 1, + "invocation_logs": true, + }, + "traces": { + "enabled": true, + "head_sampling_rate": 1, + }, + }, + "version_metadata": { + "binding": "WORKER_VERSION", }, "d1_databases": [ { From ae117300767cdb4ad66c4384e5abe672e41d2ab2 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:47:38 -0400 Subject: [PATCH 11/22] fix(workflows): reconcile ambiguous job startup --- src/lib/document-jobs.server.ts | 34 ++++++++++---- test/worker/api.test.ts | 83 +++++++++++++++++++++++++-------- 2 files changed, 90 insertions(+), 27 deletions(-) diff --git a/src/lib/document-jobs.server.ts b/src/lib/document-jobs.server.ts index b83ace4..1e67d1d 100644 --- a/src/lib/document-jobs.server.ts +++ b/src/lib/document-jobs.server.ts @@ -124,14 +124,7 @@ export async function createDocumentJob( ...(input.providerOptions && { providerOptions: input.providerOptions }), } try { - await env.DOCUMENT_WORKFLOW.create({ - id, - params, - retention: { - errorRetention: "7 days", - successRetention: "1 day", - }, - }) + await startDocumentWorkflow(env.DOCUMENT_WORKFLOW, id, params) } catch (error) { await db.delete(documentJob).where(eq(documentJob.id, id)) throw error @@ -154,6 +147,31 @@ export async function createDocumentJob( } } +async function startDocumentWorkflow( + workflow: Cloudflare.Env["DOCUMENT_WORKFLOW"], + id: string, + params: DocumentWorkflowParams +): Promise { + try { + await workflow.createBatch([ + { + id, + params, + retention: { + errorRetention: "7 days", + successRetention: "1 day", + }, + }, + ]) + } catch (error) { + try { + await workflow.get(id) + } catch { + throw error + } + } +} + async function storeUploadedSource( input: Awaited>, sourceKey: string, diff --git a/test/worker/api.test.ts b/test/worker/api.test.ts index 4b28ec7..7e278c5 100644 --- a/test/worker/api.test.ts +++ b/test/worker/api.test.ts @@ -71,8 +71,8 @@ describe("FileRouter Worker", () => { body: { name: "Binary upload test", userId: "user-binary-upload" }, }) ) - const createWorkflow = vi.fn().mockResolvedValue({ id: "workflow-upload" }) - const testEnv = envWithWorkflow(createWorkflow) + const createBatch = vi.fn().mockResolvedValue([{ id: "workflow-upload" }]) + const testEnv = envWithWorkflow(createBatch) const response = await api.fetch( new Request("https://filerouter.test/api/v1/jobs", { body: '{"document":true}', @@ -92,7 +92,7 @@ describe("FileRouter Worker", () => { const sourceKey = `jobs/${job.id}/source` try { - expect(createWorkflow).toHaveBeenCalledOnce() + expect(createBatch).toHaveBeenCalledOnce() const source = await env.FILEROUTER_FILES.head(sourceKey) expect(source?.httpMetadata?.contentType).toBe("application/json") } finally { @@ -234,8 +234,8 @@ describe("FileRouter Worker", () => { test("replays idempotent jobs without starting another Workflow", async () => { await insertUser("user-idempotent") - const createWorkflow = vi.fn().mockResolvedValue({ id: "workflow-1" }) - const testEnv = envWithWorkflow(createWorkflow) + const createBatch = vi.fn().mockResolvedValue([{ id: "workflow-1" }]) + const testEnv = envWithWorkflow(createBatch) const first = await createDocumentJob( urlJobRequest("https://example.com/report.pdf"), @@ -257,7 +257,7 @@ describe("FileRouter Worker", () => { replayed: false, }) expect(replay).toEqual({ ...first, replayed: true }) - expect(createWorkflow).toHaveBeenCalledTimes(1) + expect(createBatch).toHaveBeenCalledTimes(1) await expect( createDocumentJob( @@ -275,7 +275,7 @@ describe("FileRouter Worker", () => { const created = await createDocumentJob( urlJobRequest("https://example.com/report.pdf"), "user-complete-result", - envWithWorkflow(vi.fn().mockResolvedValue({ id: "workflow-result" })), + envWithWorkflow(vi.fn().mockResolvedValue([{ id: "workflow-result" }])), "complete-result-1", "request-complete-result" ) @@ -317,7 +317,7 @@ describe("FileRouter Worker", () => { const created = await createDocumentJob( urlJobRequest("https://example.com/expired.pdf"), "user-expired-result", - envWithWorkflow(vi.fn().mockResolvedValue({ id: "workflow-expired" })), + envWithWorkflow(vi.fn().mockResolvedValue([{ id: "workflow-expired" }])), "expired-result-1", "request-expired-result" ) @@ -343,16 +343,10 @@ describe("FileRouter Worker", () => { test("cleans up D1 and R2 when Workflow startup fails", async () => { await insertUser("user-cleanup") const testEnv = envWithWorkflow( - vi.fn().mockRejectedValue(new Error("workflow unavailable")) + vi.fn().mockRejectedValue(new Error("workflow unavailable")), + vi.fn().mockRejectedValue(new Error("workflow not found")) ) - const request = new Request("https://filerouter.test/api/v1/jobs", { - body: new Blob(["document"], { type: "application/pdf" }), - headers: { - "Content-Type": "application/pdf", - "X-FileRouter-Filename": "report.pdf", - }, - method: "POST", - }) + const request = uploadJobRequest() await expect( createDocumentJob( @@ -374,6 +368,43 @@ describe("FileRouter Worker", () => { expect(jobs?.count).toBe(0) expect(objects.objects).toHaveLength(0) }) + + test("preserves a job when Workflow startup succeeded ambiguously", async () => { + await insertUser("user-workflow-reconcile") + const createBatch = vi + .fn() + .mockRejectedValue(new Error("workflow response was lost")) + const getWorkflow = vi.fn().mockResolvedValue({ id: "workflow-reconciled" }) + const testEnv = envWithWorkflow(createBatch, getWorkflow) + const request = uploadJobRequest() + + const created = await createDocumentJob( + request, + "user-workflow-reconcile", + testEnv, + "reconcile-report-1", + "request-reconcile" + ) + const sourceKey = `jobs/${created.job.id}/source` + + try { + expect(created.job.status).toBe("queued") + expect(createBatch).toHaveBeenCalledOnce() + expect(createBatch).toHaveBeenCalledWith([ + expect.objectContaining({ + id: created.job.id, + params: expect.objectContaining({ jobId: created.job.id }), + }), + ]) + expect(getWorkflow).toHaveBeenCalledWith(created.job.id) + expect(await env.FILEROUTER_FILES.head(sourceKey)).not.toBeNull() + } finally { + await env.FILEROUTER_FILES.delete(sourceKey) + await env.DB.prepare("DELETE FROM document_job WHERE id = ?") + .bind(created.job.id) + .run() + } + }) }) function urlJobRequest(url: string): Request { @@ -388,6 +419,17 @@ function urlJobRequest(url: string): Request { }) } +function uploadJobRequest(): Request { + return new Request("https://filerouter.test/api/v1/jobs", { + body: new Blob(["document"], { type: "application/pdf" }), + headers: { + "Content-Type": "application/pdf", + "X-FileRouter-Filename": "report.pdf", + }, + method: "POST", + }) +} + async function insertUser(id: string): Promise { await env.DB.prepare( "INSERT INTO user (id, name, email, email_verified, created_at, updated_at) VALUES (?, ?, ?, 1, ?, ?)" @@ -402,9 +444,12 @@ function authRequest(path: string, init?: RequestInit): Promise { ) } -function envWithWorkflow(create: ReturnType): Cloudflare.Env { +function envWithWorkflow( + createBatch: ReturnType, + get: ReturnType = vi.fn() +): Cloudflare.Env { return { ...env, - DOCUMENT_WORKFLOW: { create }, + DOCUMENT_WORKFLOW: { createBatch, get }, } as unknown as Cloudflare.Env } From 65a7c5d9ad060a0a89b35eb8a4e3e91e61bbba09 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:47:57 -0400 Subject: [PATCH 12/22] fix(workflows): retry recoverable workflow steps --- src/workflows/document-workflow.ts | 92 ++++++++++++++++++------------ 1 file changed, 54 insertions(+), 38 deletions(-) diff --git a/src/workflows/document-workflow.ts b/src/workflows/document-workflow.ts index 82a6f43..e1e0309 100644 --- a/src/workflows/document-workflow.ts +++ b/src/workflows/document-workflow.ts @@ -1,5 +1,9 @@ import { WorkflowEntrypoint } from "cloudflare:workers" -import type { WorkflowEvent, WorkflowStep } from "cloudflare:workers" +import type { + WorkflowEvent, + WorkflowStep, + WorkflowStepConfig, +} from "cloudflare:workers" import { and, eq } from "drizzle-orm" import { FileRouterError, @@ -44,28 +48,38 @@ export interface DocumentWorkflowParams { userId: string } -const SINGLE_ATTEMPT = { - retries: { backoff: "constant" as const, delay: 0, limit: 1 }, - timeout: "15 minutes" as const, -} +const PROVIDER_EXECUTION_STEP = { + retries: { backoff: "constant", delay: 0, limit: 1 }, + timeout: "15 minutes", +} as const satisfies WorkflowStepConfig + +const STORAGE_STEP = { + retries: { backoff: "exponential", delay: "2 seconds", limit: 3 }, + timeout: "15 minutes", +} as const satisfies WorkflowStepConfig -const RETRYABLE_CHECK = { +const CLEANUP_STEP = { + retries: { backoff: "exponential", delay: "2 seconds", limit: 3 }, + timeout: "1 minute", +} as const satisfies WorkflowStepConfig + +const PROVIDER_STATUS_STEP = { retries: { - backoff: "exponential" as const, - delay: "10 seconds" as const, + backoff: "exponential", + delay: "10 seconds", limit: 3, }, - timeout: "1 minute" as const, -} + timeout: "1 minute", +} as const satisfies WorkflowStepConfig -const RETRYABLE_METERING = { +const METERING_STEP = { retries: { - backoff: "exponential" as const, - delay: "5 seconds" as const, + backoff: "exponential", + delay: "5 seconds", limit: 5, }, - timeout: "1 minute" as const, -} + timeout: "1 minute", +} as const satisfies WorkflowStepConfig type ProviderStepStatus = | { status: "pending" | "running" } @@ -113,7 +127,7 @@ export class DocumentWorkflow extends WorkflowEntrypoint< .where(eq(documentJob.id, params.jobId)) return timestamp.toISOString() }) - await step.do("materialize document source", SINGLE_ATTEMPT, () => + await step.do("materialize document source", STORAGE_STEP, () => materializeDocumentSource( this.env.FILEROUTER_FILES, params.source, @@ -275,16 +289,13 @@ async function trackUsageWithoutFailingJob( providers: Array ): Promise { try { - const result = await step.do( - "track managed execution", - RETRYABLE_METERING, - () => - trackManagedExecutionUsage(env, { - jobId: params.jobId, - operation: params.operation, - providers, - userId: params.userId, - }) + const result = await step.do("track managed execution", METERING_STEP, () => + trackManagedExecutionUsage(env, { + jobId: params.jobId, + operation: params.operation, + providers, + userId: params.userId, + }) ) return "skipped" in result ? { status: "skipped" } @@ -314,7 +325,7 @@ async function deleteResultObjects( return { status: "skipped" } } try { - await step.do(stepName, SINGLE_ATTEMPT, async () => { + await step.do(stepName, CLEANUP_STEP, async () => { await bucket.delete(keys) return true }) @@ -347,12 +358,15 @@ async function processProvider( try { return provider.jobs ? await processAsyncProvider(step, provider, input, params, env) - : await step.do(`process ${provider.id}`, SINGLE_ATTEMPT, async () => - storeProviderResult( - env.FILEROUTER_FILES, - params.jobId, - await provider.parse(input, parseOptions(params, provider.id)) - ) + : await step.do( + `process ${provider.id}`, + PROVIDER_EXECUTION_STEP, + async () => + storeProviderResult( + env.FILEROUTER_FILES, + params.jobId, + await provider.parse(input, parseOptions(params, provider.id)) + ) ) } catch (error) { return { @@ -376,15 +390,17 @@ async function processAsyncProvider( throw new Error(`Provider ${provider.id} does not support durable jobs.`) } - const job = await step.do(`submit ${provider.id}`, SINGLE_ATTEMPT, async () => - jobs.submit(input, parseOptions(params, provider.id)) + const job = await step.do( + `submit ${provider.id}`, + PROVIDER_EXECUTION_STEP, + async () => jobs.submit(input, parseOptions(params, provider.id)) ) const deadline = new Date(job.submittedAt).getTime() + 14 * 60 * 1000 while (Date.now() < deadline) { const status: ProviderStepStatus = await step.do( `check ${provider.id}`, - RETRYABLE_CHECK, + PROVIDER_STATUS_STEP, async () => { const current = await jobs.get(job, parseOptions(params, provider.id)) if (current.status !== "complete") { @@ -430,7 +446,7 @@ async function persistResult( return { pageCount: result.pageCount, resultKey: result.resultKey } } - return step.do("store comparison", SINGLE_ATTEMPT, () => + return step.do("store comparison", STORAGE_STEP, () => storeComparisonResult(bucket, { fileName: params.fileName, jobId: params.jobId, @@ -449,7 +465,7 @@ async function deleteDocumentSource( ): Promise { const sourceKey = params.source.key try { - await step.do(stepName, SINGLE_ATTEMPT, async () => { + await step.do(stepName, CLEANUP_STEP, async () => { await env.FILEROUTER_FILES.delete(sourceKey) return true }) From 6804d6f73f6a4458b18b18ac260d014c84511b17 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:48:11 -0400 Subject: [PATCH 13/22] feat(sdk): expose recoverable hosted jobs --- packages/filerouter/src/client.ts | 284 +++------------ packages/filerouter/src/hosted.ts | 13 + packages/filerouter/src/index.ts | 10 + packages/filerouter/src/internal/timeout.ts | 33 ++ packages/filerouter/src/jobs.ts | 373 ++++++++++++++++++++ packages/filerouter/test/client.test.ts | 2 +- packages/filerouter/test/jobs.test.ts | 200 +++++++++++ 7 files changed, 688 insertions(+), 227 deletions(-) create mode 100644 packages/filerouter/src/internal/timeout.ts create mode 100644 packages/filerouter/src/jobs.ts create mode 100644 packages/filerouter/test/jobs.test.ts diff --git a/packages/filerouter/src/client.ts b/packages/filerouter/src/client.ts index 1bf8916..a55795f 100644 --- a/packages/filerouter/src/client.ts +++ b/packages/filerouter/src/client.ts @@ -1,24 +1,16 @@ import { FileRouterError } from "./errors" -import { - FILEROUTER_DEFAULT_API_URL, - HOSTED_JOB_HEADERS, - HOSTED_JOBS_PATH, - MAX_HOSTED_PROVIDER_OPTIONS_HEADER_BYTES, -} from "./hosted" -import type { HostedJobAccepted, HostedJobResponse } from "./hosted" +import { FILEROUTER_DEFAULT_API_URL } from "./hosted" import { readEnv, trimTrailingSlash } from "./internal/env" -import { requestJson } from "./internal/http" -import { resolveParseInput } from "./internal/input" -import { assertTimeoutMs } from "./internal/provider-options" -import { abortableSleep } from "./internal/sleep" -import { DEFAULT_PARSE_OUTPUT } from "./types" +import { withTimeout } from "./internal/timeout" +import { DEFAULT_HOSTED_JOB_TIMEOUT_MS, HostedJobs } from "./jobs" import type { - CompareOptions, - CompareResult, - ParseInput, - ParseOptions, - ParseResult, -} from "./types" + FileRouterJobs, + HostedCompareJobOptions, + HostedCompareOptions, + HostedParseJobOptions, + HostedParseOptions, +} from "./jobs" +import type { CompareResult, ParseInput, ParseResult } from "./types" export interface FileRouterClientOptions { apiKey?: string @@ -27,29 +19,8 @@ export interface FileRouterClientOptions { pollingIntervalMs?: number } -export interface HostedParseOptions extends ParseOptions { - idempotencyKey?: string -} - -export interface HostedCompareOptions extends CompareOptions { - idempotencyKey?: string -} - -interface JobRequest { - operation: "compare" | "parse" - includeRaw?: boolean - outputs: Array - pages?: Array - provider?: string - providerOptions?: ParseOptions["providerOptions"] - providers?: Array -} - export class FileRouterClient { - readonly #apiKey: string - readonly #baseURL: string - readonly #fetch: typeof globalThis.fetch | undefined - readonly #pollingIntervalMs: number + readonly jobs: FileRouterJobs constructor(options: FileRouterClientOptions = {}) { const apiKey = options.apiKey ?? readEnv("FILEROUTER_API_KEY") @@ -59,210 +30,71 @@ export class FileRouterClient { }) } - this.#apiKey = apiKey - this.#baseURL = trimTrailingSlash( - options.baseURL ?? - readEnv("FILEROUTER_API_URL") ?? - FILEROUTER_DEFAULT_API_URL - ) - this.#fetch = options.fetch - this.#pollingIntervalMs = options.pollingIntervalMs ?? 1000 + this.jobs = new HostedJobs({ + apiKey, + baseURL: trimTrailingSlash( + options.baseURL ?? + readEnv("FILEROUTER_API_URL") ?? + FILEROUTER_DEFAULT_API_URL + ), + ...(options.fetch && { fetch: options.fetch }), + ...(options.pollingIntervalMs !== undefined && { + pollingIntervalMs: options.pollingIntervalMs, + }), + }) } async parse( input: ParseInput, options: HostedParseOptions = {} ): Promise { - return this.#runJob( - input, - { + const { timeoutMs = DEFAULT_HOSTED_JOB_TIMEOUT_MS, ...jobOptions } = options + const startedAt = Date.now() + + const job = await withTimeout(timeoutMs, options.signal, (signal) => + this.jobs.create(input, { + ...jobOptions, operation: "parse", - ...(options.includeRaw !== undefined && { - includeRaw: options.includeRaw, - }), - outputs: options.outputs ?? [DEFAULT_PARSE_OUTPUT], - ...(options.pages && { pages: options.pages }), - ...(options.provider && { provider: options.provider }), - ...(options.providerOptions && { - providerOptions: options.providerOptions, - }), - }, - options + signal, + } satisfies HostedParseJobOptions) ) + return this.jobs.wait(job, { + timeoutMs: remainingTimeout(timeoutMs, startedAt), + ...(options.signal && { signal: options.signal }), + }) } async compare( input: ParseInput, options: HostedCompareOptions = {} ): Promise { - return this.#runJob( - input, - { - operation: "compare", - ...(options.includeRaw !== undefined && { - includeRaw: options.includeRaw, - }), - outputs: options.outputs ?? [DEFAULT_PARSE_OUTPUT], - ...(options.pages && { pages: options.pages }), - ...(options.providerOptions && { - providerOptions: options.providerOptions, - }), - ...(options.providers && { providers: options.providers }), - }, - options - ) - } - - async #runJob( - input: ParseInput, - request: JobRequest, - options: { - idempotencyKey?: string - signal?: AbortSignal - timeoutMs?: number - } - ): Promise { - const timeoutController = new AbortController() - const timeoutMs = options.timeoutMs ?? 10 * 60 * 1000 - assertTimeoutMs(timeoutMs) - const timeout = setTimeout(() => timeoutController.abort(), timeoutMs) - if (timeoutMs <= 0) { - timeoutController.abort() - } - const signal = options.signal - ? AbortSignal.any([options.signal, timeoutController.signal]) - : timeoutController.signal - - try { - const job = await this.#createJob( - input, - request, - signal, - options.idempotencyKey - ) - return await this.#waitForJob(job.id, signal) - } catch (error) { - if (timeoutController.signal.aborted && !options.signal?.aborted) { - throw new FileRouterError("FileRouter job timed out.", { - code: "Timeout", - }) - } - throw error - } finally { - clearTimeout(timeout) - } - } - - async #createJob( - input: ParseInput, - request: JobRequest, - signal: AbortSignal, - idempotencyKey: string = crypto.randomUUID() - ): Promise { - const serializedProviderOptions = request.providerOptions - ? stringifyJson(request.providerOptions, "Hosted provider options") - : undefined - const resolved = await resolveParseInput(input, signal) - const headers = this.#headers() - headers.set("Idempotency-Key", idempotencyKey) - let body: BodyInit + const { timeoutMs = DEFAULT_HOSTED_JOB_TIMEOUT_MS, ...jobOptions } = options + const startedAt = Date.now() - if (resolved.kind === "url") { - headers.set("Content-Type", "application/json") - body = stringifyJson( - { ...request, source: { url: resolved.url } }, - "Hosted job request" - ) - } else { - headers.set("Content-Type", "application/octet-stream") - headers.set(HOSTED_JOB_HEADERS.contentType, resolved.mimeType) - headers.set( - HOSTED_JOB_HEADERS.fileName, - encodeURIComponent(resolved.name) - ) - headers.set(HOSTED_JOB_HEADERS.operation, request.operation) - headers.set(HOSTED_JOB_HEADERS.outputs, request.outputs.join(",")) - if (request.pages) { - headers.set(HOSTED_JOB_HEADERS.pages, request.pages.join(",")) - } - if (request.includeRaw !== undefined) { - headers.set(HOSTED_JOB_HEADERS.includeRaw, String(request.includeRaw)) - } - if (serializedProviderOptions !== undefined) { - const encodedProviderOptions = encodeURIComponent( - serializedProviderOptions - ) - if ( - encodedProviderOptions.length > - MAX_HOSTED_PROVIDER_OPTIONS_HEADER_BYTES - ) { - throw new FileRouterError( - `Hosted provider options exceed the ${MAX_HOSTED_PROVIDER_OPTIONS_HEADER_BYTES / 1024} KiB header limit.`, - { code: "InvalidInput" } - ) - } - headers.set(HOSTED_JOB_HEADERS.providerOptions, encodedProviderOptions) - } - if (request.provider) { - headers.set(HOSTED_JOB_HEADERS.provider, request.provider) - } - if (request.providers) { - headers.set(HOSTED_JOB_HEADERS.providers, request.providers.join(",")) - } - body = resolved.data - } - - return requestJson( - `${this.#baseURL}${HOSTED_JOBS_PATH}`, - { - body, - fetch: this.#fetch, - headers, - method: "POST", - providerId: "filerouter", + const job = await withTimeout(timeoutMs, options.signal, (signal) => + this.jobs.create(input, { + ...jobOptions, + operation: "compare", signal, - } + } satisfies HostedCompareJobOptions) ) - } - - async #waitForJob(id: string, signal: AbortSignal): Promise { - while (true) { - const job = await requestJson>( - `${this.#baseURL}${HOSTED_JOBS_PATH}/${encodeURIComponent(id)}`, - { - fetch: this.#fetch, - headers: this.#headers(), - providerId: "filerouter", - signal, - } - ) - - if (job.status === "complete") { - return job.result - } - if (job.status === "failed") { - throw new FileRouterError(job.error, { code: "ParseFailed" }) - } - await abortableSleep(this.#pollingIntervalMs, signal) - } - } - - #headers(): Headers { - return new Headers({ Authorization: `Bearer ${this.#apiKey}` }) + return this.jobs.wait(job, { + timeoutMs: remainingTimeout(timeoutMs, startedAt), + ...(options.signal && { signal: options.signal }), + }) } } -function stringifyJson(value: unknown, label: string): string { - try { - const serialized = JSON.stringify(value) - if (serialized === undefined) { - throw new TypeError("Value is not JSON serializable.") - } - return serialized - } catch (cause) { - throw new FileRouterError(`${label} could not be serialized as JSON.`, { - cause, - code: "InvalidInput", - }) - } +function remainingTimeout(timeoutMs: number, startedAt: number): number { + return Math.max(0, timeoutMs - (Date.now() - startedAt)) } + +export type { + FileRouterJobs, + HostedCompareJobOptions, + HostedCompareOptions, + HostedJobGetOptions, + HostedJobWaitOptions, + HostedParseJobOptions, + HostedParseOptions, +} from "./jobs" diff --git a/packages/filerouter/src/hosted.ts b/packages/filerouter/src/hosted.ts index 1ac1943..396fb7f 100644 --- a/packages/filerouter/src/hosted.ts +++ b/packages/filerouter/src/hosted.ts @@ -27,8 +27,21 @@ export const hostedJobStatuses = [ export type HostedJobStatus = (typeof hostedJobStatuses)[number] +export type HostedJobOperation = "compare" | "parse" + export type HostedJobAccepted = { id: string; status: HostedJobStatus } +export interface HostedJobHandle< + Operation extends HostedJobOperation = HostedJobOperation, +> extends HostedJobAccepted { + idempotencyKey: string + operation: Operation +} + +export type HostedParseJob = HostedJobHandle<"parse"> +export type HostedCompareJob = HostedJobHandle<"compare"> +export type HostedJob = HostedCompareJob | HostedParseJob + export type HostedJobResponse = | { error: string; id: string; status: "failed" } | { id: string; result: Result; status: "complete" } diff --git a/packages/filerouter/src/index.ts b/packages/filerouter/src/index.ts index ea58a1e..b5c1483 100644 --- a/packages/filerouter/src/index.ts +++ b/packages/filerouter/src/index.ts @@ -8,7 +8,12 @@ export { export { FileRouterClient, type FileRouterClientOptions, + type FileRouterJobs, + type HostedCompareJobOptions, type HostedCompareOptions, + type HostedJobGetOptions, + type HostedJobWaitOptions, + type HostedParseJobOptions, type HostedParseOptions, } from "./client" export { @@ -25,9 +30,14 @@ export { HOSTED_JOBS_PATH, MAX_HOSTED_PROVIDER_OPTIONS_HEADER_BYTES, hostedJobStatuses, + type HostedCompareJob, + type HostedJob, type HostedJobAccepted, + type HostedJobHandle, + type HostedJobOperation, type HostedJobResponse, type HostedJobStatus, + type HostedParseJob, } from "./hosted" export { normalizeDocumentFileName, diff --git a/packages/filerouter/src/internal/timeout.ts b/packages/filerouter/src/internal/timeout.ts new file mode 100644 index 0000000..fda6038 --- /dev/null +++ b/packages/filerouter/src/internal/timeout.ts @@ -0,0 +1,33 @@ +import { FileRouterError } from "../errors" +import { assertTimeoutMs } from "./provider-options" + +export async function withTimeout( + timeoutMs: number, + signal: AbortSignal | undefined, + operation: (signal: AbortSignal) => Promise +): Promise { + assertTimeoutMs(timeoutMs) + + const timeoutController = new AbortController() + const timeout = setTimeout(() => timeoutController.abort(), timeoutMs) + if (timeoutMs === 0) { + timeoutController.abort() + } + const operationSignal = signal + ? AbortSignal.any([signal, timeoutController.signal]) + : timeoutController.signal + + try { + operationSignal.throwIfAborted() + return await operation(operationSignal) + } catch (error) { + if (timeoutController.signal.aborted && !signal?.aborted) { + throw new FileRouterError("FileRouter job timed out.", { + code: "Timeout", + }) + } + throw error + } finally { + clearTimeout(timeout) + } +} diff --git a/packages/filerouter/src/jobs.ts b/packages/filerouter/src/jobs.ts new file mode 100644 index 0000000..c943d84 --- /dev/null +++ b/packages/filerouter/src/jobs.ts @@ -0,0 +1,373 @@ +import { FileRouterError } from "./errors" +import { + HOSTED_JOB_HEADERS, + HOSTED_JOBS_PATH, + MAX_HOSTED_PROVIDER_OPTIONS_HEADER_BYTES, +} from "./hosted" +import type { + HostedCompareJob, + HostedJob, + HostedJobAccepted, + HostedJobHandle, + HostedJobOperation, + HostedJobResponse, + HostedParseJob, +} from "./hosted" +import { requestJson } from "./internal/http" +import { resolveParseInput } from "./internal/input" +import { abortableSleep } from "./internal/sleep" +import { withTimeout } from "./internal/timeout" +import { DEFAULT_PARSE_OUTPUT } from "./types" +import type { + CompareOptions, + CompareResult, + ParseInput, + ParseOptions, + ParseOutput, + ParseResult, +} from "./types" + +export const DEFAULT_HOSTED_JOB_TIMEOUT_MS = 10 * 60 * 1000 + +const MAX_TRANSIENT_ATTEMPTS = 3 +const INITIAL_RETRY_DELAY_MS = 250 +const MAX_RETRY_DELAY_MS = 10_000 + +export interface HostedParseOptions extends ParseOptions { + idempotencyKey?: string +} + +export interface HostedCompareOptions extends CompareOptions { + idempotencyKey?: string +} + +export interface HostedParseJobOptions extends Omit< + HostedParseOptions, + "timeoutMs" +> { + operation?: "parse" +} + +export interface HostedCompareJobOptions extends Omit< + HostedCompareOptions, + "timeoutMs" +> { + operation: "compare" +} + +export interface HostedJobGetOptions { + signal?: AbortSignal +} + +export interface HostedJobWaitOptions< + Result = ParseResult | CompareResult, +> extends HostedJobGetOptions { + onStatus?: (job: HostedJobResponse) => void + timeoutMs?: number +} + +type HostedResultFor = + Operation extends "parse" ? ParseResult : CompareResult + +export interface FileRouterJobs { + create( + input: ParseInput, + options?: HostedParseJobOptions + ): Promise + create( + input: ParseInput, + options: HostedCompareJobOptions + ): Promise + + get( + job: HostedJobHandle, + options?: HostedJobGetOptions + ): Promise>> + get( + id: string, + options?: HostedJobGetOptions + ): Promise> + + wait( + job: HostedJobHandle, + options?: HostedJobWaitOptions> + ): Promise> + wait( + id: string, + options?: HostedJobWaitOptions + ): Promise +} + +interface HostedJobsOptions { + apiKey: string + baseURL: string + fetch?: typeof globalThis.fetch + pollingIntervalMs?: number +} + +interface JobRequest { + operation: "compare" | "parse" + includeRaw?: boolean + outputs: Array + pages?: Array + provider?: string + providerOptions?: ParseOptions["providerOptions"] + providers?: Array +} + +export class HostedJobs implements FileRouterJobs { + readonly #apiKey: string + readonly #baseURL: string + readonly #fetch: typeof globalThis.fetch | undefined + readonly #pollingIntervalMs: number + + constructor(options: HostedJobsOptions) { + this.#apiKey = options.apiKey + this.#baseURL = options.baseURL + this.#fetch = options.fetch + this.#pollingIntervalMs = options.pollingIntervalMs ?? 1000 + } + + create( + input: ParseInput, + options?: HostedParseJobOptions + ): Promise + create( + input: ParseInput, + options: HostedCompareJobOptions + ): Promise + async create( + input: ParseInput, + options: HostedParseJobOptions | HostedCompareJobOptions = {} + ): Promise { + const idempotencyKey = options.idempotencyKey ?? crypto.randomUUID() + const request = createJobRequest(options) + const resolved = await resolveParseInput(input, options.signal) + const headers = this.#headers() + headers.set("Idempotency-Key", idempotencyKey) + const body = createRequestBody(headers, resolved, request) + + const accepted = await retryTransient( + () => + requestJson(`${this.#baseURL}${HOSTED_JOBS_PATH}`, { + body, + headers, + method: "POST", + providerId: "filerouter", + ...(this.#fetch && { fetch: this.#fetch }), + ...(options.signal && { signal: options.signal }), + }), + options.signal + ) + + return { ...accepted, idempotencyKey, operation: request.operation } + } + + get( + job: HostedJobHandle, + options?: HostedJobGetOptions + ): Promise>> + get( + id: string, + options?: HostedJobGetOptions + ): Promise> + get( + job: HostedJob | string, + options: HostedJobGetOptions = {} + ): Promise> { + return this.#get(jobId(job), options.signal) + } + + wait( + job: HostedJobHandle, + options?: HostedJobWaitOptions> + ): Promise> + wait( + id: string, + options?: HostedJobWaitOptions + ): Promise + wait( + job: HostedJob | string, + options: HostedJobWaitOptions = {} + ): Promise { + return withTimeout( + options.timeoutMs ?? DEFAULT_HOSTED_JOB_TIMEOUT_MS, + options.signal, + (signal) => this.#wait(jobId(job), signal, options.onStatus) + ) + } + + async #get( + id: string, + signal?: AbortSignal + ): Promise> { + return requestJson>( + `${this.#baseURL}${HOSTED_JOBS_PATH}/${encodeURIComponent(id)}`, + { + headers: this.#headers(), + providerId: "filerouter", + ...(this.#fetch && { fetch: this.#fetch }), + ...(signal && { signal }), + } + ) + } + + async #wait( + id: string, + signal: AbortSignal, + onStatus?: (job: HostedJobResponse) => void + ): Promise { + let previousStatus: HostedJobResponse["status"] | undefined + + while (true) { + const job = await retryTransient( + () => this.#get(id, signal), + signal + ) + + if (job.status !== previousStatus) { + onStatus?.(job) + previousStatus = job.status + } + if (job.status === "complete") { + return job.result + } + if (job.status === "failed") { + throw new FileRouterError(job.error, { + code: "ParseFailed", + providerId: "filerouter", + }) + } + await abortableSleep(this.#pollingIntervalMs, signal) + } + } + + #headers(): Headers { + return new Headers({ Authorization: `Bearer ${this.#apiKey}` }) + } +} + +function createJobRequest( + options: HostedParseJobOptions | HostedCompareJobOptions +): JobRequest { + const operation = options.operation ?? "parse" + const request: JobRequest = { + operation, + outputs: options.outputs ?? [DEFAULT_PARSE_OUTPUT], + ...(options.includeRaw !== undefined && { + includeRaw: options.includeRaw, + }), + ...(options.pages && { pages: options.pages }), + ...(options.providerOptions && { + providerOptions: options.providerOptions, + }), + } + + if (options.operation === "compare") { + return { + ...request, + ...(options.providers && { providers: options.providers }), + } + } + + return { + ...request, + ...(options.provider && { provider: options.provider }), + } +} + +function createRequestBody( + headers: Headers, + input: Awaited>, + request: JobRequest +): BodyInit { + if (input.kind === "url") { + headers.set("Content-Type", "application/json") + return stringifyJson( + { ...request, source: { url: input.url } }, + "Hosted job request" + ) + } + + headers.set("Content-Type", "application/octet-stream") + headers.set(HOSTED_JOB_HEADERS.contentType, input.mimeType) + headers.set(HOSTED_JOB_HEADERS.fileName, encodeURIComponent(input.name)) + headers.set(HOSTED_JOB_HEADERS.operation, request.operation) + headers.set(HOSTED_JOB_HEADERS.outputs, request.outputs.join(",")) + if (request.pages) { + headers.set(HOSTED_JOB_HEADERS.pages, request.pages.join(",")) + } + if (request.includeRaw !== undefined) { + headers.set(HOSTED_JOB_HEADERS.includeRaw, String(request.includeRaw)) + } + if (request.providerOptions) { + const providerOptions = encodeURIComponent( + stringifyJson(request.providerOptions, "Hosted provider options") + ) + if (providerOptions.length > MAX_HOSTED_PROVIDER_OPTIONS_HEADER_BYTES) { + throw new FileRouterError( + `Hosted provider options exceed the ${MAX_HOSTED_PROVIDER_OPTIONS_HEADER_BYTES / 1024} KiB header limit.`, + { code: "InvalidInput" } + ) + } + headers.set(HOSTED_JOB_HEADERS.providerOptions, providerOptions) + } + if (request.provider) { + headers.set(HOSTED_JOB_HEADERS.provider, request.provider) + } + if (request.providers) { + headers.set(HOSTED_JOB_HEADERS.providers, request.providers.join(",")) + } + return input.data +} + +async function retryTransient( + operation: () => Promise, + signal?: AbortSignal +): Promise { + for (let attempt = 1; ; attempt += 1) { + try { + return await operation() + } catch (error) { + if ( + attempt >= MAX_TRANSIENT_ATTEMPTS || + !FileRouterError.isInstance(error) || + !error.retryable + ) { + throw error + } + + await abortableSleep(retryDelay(error, attempt), signal) + } + } +} + +function retryDelay(error: FileRouterError, attempt: number): number { + if (error.retryAfterMs !== undefined) { + return error.retryAfterMs + } + const ceiling = Math.min( + INITIAL_RETRY_DELAY_MS * 2 ** (attempt - 1), + MAX_RETRY_DELAY_MS + ) + return Math.round(ceiling * (0.5 + Math.random() * 0.5)) +} + +function jobId(job: HostedJob | string): string { + return typeof job === "string" ? job : job.id +} + +function stringifyJson(value: unknown, label: string): string { + try { + const serialized = JSON.stringify(value) + if (serialized === undefined) { + throw new TypeError("Value is not JSON serializable.") + } + return serialized + } catch (cause) { + throw new FileRouterError(`${label} could not be serialized as JSON.`, { + cause, + code: "InvalidInput", + }) + } +} diff --git a/packages/filerouter/test/client.test.ts b/packages/filerouter/test/client.test.ts index 11be6d5..54db922 100644 --- a/packages/filerouter/test/client.test.ts +++ b/packages/filerouter/test/client.test.ts @@ -197,7 +197,7 @@ describe("FileRouterClient", () => { fetch: fetchMock, }) - const request = client.parse("https://example.com/report.pdf") + const request = client.jobs.get("job-rate-limited") await expect(request).rejects.toMatchObject({ code: "RateLimit", diff --git a/packages/filerouter/test/jobs.test.ts b/packages/filerouter/test/jobs.test.ts new file mode 100644 index 0000000..e1bde8c --- /dev/null +++ b/packages/filerouter/test/jobs.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, expectTypeOf, test, vi } from "vite-plus/test" + +import { FileRouterClient } from "../src/client" +import type { HostedJobResponse } from "../src/hosted" +import type { CompareResult, ParseResult } from "../src/types" + +describe("FileRouterClient jobs", () => { + test("creates a recoverable parse job without polling", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(Response.json({ id: "job-1", status: "queued" })) + const client = createClient(fetchMock) + + const job = await client.jobs.create("https://example.com/report.pdf", { + idempotencyKey: "stable-job-key", + provider: "llamaparse", + }) + + expect(job).toEqual({ + id: "job-1", + idempotencyKey: "stable-job-key", + operation: "parse", + status: "queued", + }) + expect(fetchMock).toHaveBeenCalledOnce() + expect(readJsonBody(fetchMock, 0)).toEqual({ + operation: "parse", + outputs: ["markdown"], + provider: "llamaparse", + source: { url: "https://example.com/report.pdf" }, + }) + }) + + test("creates typed comparison jobs", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(Response.json({ id: "job-2", status: "queued" })) + const client = createClient(fetchMock) + + const job = await client.jobs.create("https://example.com/report.pdf", { + operation: "compare", + providers: ["llamaparse", "mistral-ocr"], + }) + + expect(job.operation).toBe("compare") + expectTypeOf(() => client.jobs.wait(job)).returns.toEqualTypeOf< + Promise + >() + expect(readJsonBody(fetchMock, 0)).toMatchObject({ + operation: "compare", + providers: ["llamaparse", "mistral-ocr"], + }) + }) + + test("reads a job without waiting", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(Response.json({ id: "job-3", status: "running" })) + const client = createClient(fetchMock) + + await expect(client.jobs.get("job-3")).resolves.toEqual({ + id: "job-3", + status: "running", + }) + expect(requestUrl(fetchMock, 0)).toBe( + "https://example.com/api/v1/jobs/job-3" + ) + }) + + test("waits on an existing job and reports status changes once", async () => { + const result = parseResult("job-4") + const fetchMock = vi + .fn() + .mockResolvedValueOnce(Response.json({ id: "job-4", status: "queued" })) + .mockResolvedValueOnce(Response.json({ id: "job-4", status: "queued" })) + .mockResolvedValueOnce(Response.json({ id: "job-4", status: "running" })) + .mockResolvedValueOnce( + Response.json({ id: "job-4", result, status: "complete" }) + ) + const client = createClient(fetchMock) + const statuses: Array["status"]> = [] + + await expect( + client.jobs.wait("job-4", { + onStatus: (job) => statuses.push(job.status), + }) + ).resolves.toEqual(result) + expect(statuses).toEqual(["queued", "running", "complete"]) + }) + + test("retries ambiguous creation failures with one idempotency key", async () => { + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new TypeError("connection reset")) + .mockResolvedValueOnce(Response.json({ id: "job-5", status: "queued" })) + const client = createClient(fetchMock) + + const job = await client.jobs.create("https://example.com/report.pdf") + + expect(job.id).toBe("job-5") + expect(fetchMock).toHaveBeenCalledTimes(2) + const firstKey = requestHeaders(fetchMock, 0).get("idempotency-key") + expect(firstKey).toBeTruthy() + expect(requestHeaders(fetchMock, 1).get("idempotency-key")).toBe(firstKey) + expect(job.idempotencyKey).toBe(firstKey) + }) + + test("retries transient polling failures", async () => { + const result = parseResult("job-6") + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new TypeError("temporary network failure")) + .mockResolvedValueOnce( + Response.json({ id: "job-6", result, status: "complete" }) + ) + const client = createClient(fetchMock) + + await expect(client.jobs.wait("job-6")).resolves.toEqual( + result + ) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + test("surfaces terminal job failures", async () => { + const fetchMock = vi.fn().mockResolvedValue( + Response.json({ + error: "The provider rejected the document.", + id: "job-7", + status: "failed", + }) + ) + const client = createClient(fetchMock) + + await expect(client.jobs.wait("job-7")).rejects.toMatchObject({ + code: "ParseFailed", + message: "The provider rejected the document.", + providerId: "filerouter", + }) + }) + + test("applies wait timeouts before polling", async () => { + const fetchMock = vi.fn() + const client = createClient(fetchMock) + + await expect( + client.jobs.wait("job-8", { timeoutMs: 0 }) + ).rejects.toMatchObject({ code: "Timeout" }) + expect(fetchMock).not.toHaveBeenCalled() + }) +}) + +function createClient(fetchMock: typeof fetch): FileRouterClient { + return new FileRouterClient({ + apiKey: "fr_test_key", + baseURL: "https://example.com", + fetch: fetchMock, + pollingIntervalMs: 0, + }) +} + +function parseResult(id: string): ParseResult { + return { + id, + outputs: { markdown: "# Parsed" }, + pageCount: 1, + provider: "llamaparse", + timing: { + completedAt: "2026-07-21T00:00:01.000Z", + durationMs: 1000, + startedAt: "2026-07-21T00:00:00.000Z", + }, + warnings: [], + } +} + +function readJsonBody( + fetchMock: ReturnType, + call: number +): unknown { + const body = fetchMock.mock.calls[call]?.[1]?.body + if (typeof body !== "string") { + throw new TypeError("Expected a JSON request body.") + } + return JSON.parse(body) +} + +function requestHeaders( + fetchMock: ReturnType, + call: number +): Headers { + return new Headers(fetchMock.mock.calls[call]?.[1]?.headers) +} + +function requestUrl(fetchMock: ReturnType, call: number): string { + const input = fetchMock.mock.calls[call]?.[0] + if (typeof input === "string") { + return input + } + return input instanceof URL ? input.href : input.url +} From 824d33e76d581bd32d0aa6eb79c93344f4447d66 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:13:23 -0400 Subject: [PATCH 14/22] refactor(validation): centralize record guards --- packages/filerouter/src/datalab.ts | 3 ++- packages/filerouter/src/internal/http.ts | 5 +---- packages/filerouter/src/internal/record.ts | 3 +++ packages/filerouter/src/llamaparse.ts | 4 +--- src/lib/api-auth.server.ts | 5 +---- src/lib/hosted-providers.server.ts | 5 +---- src/lib/record.ts | 3 +++ 7 files changed, 12 insertions(+), 16 deletions(-) create mode 100644 packages/filerouter/src/internal/record.ts create mode 100644 src/lib/record.ts diff --git a/packages/filerouter/src/datalab.ts b/packages/filerouter/src/datalab.ts index 76f17da..873ecd7 100644 --- a/packages/filerouter/src/datalab.ts +++ b/packages/filerouter/src/datalab.ts @@ -1,9 +1,10 @@ import { FileRouterError } from "./errors" import { readEnv, trimTrailingSlash } from "./internal/env" -import { isRecord, requestJson } from "./internal/http" +import { requestJson } from "./internal/http" import { selectOutputs } from "./internal/outputs" import { providerOptions } from "./internal/provider-options" import { waitForProviderJob } from "./internal/polling" +import { isRecord } from "./internal/record" import { DEFAULT_PARSE_OUTPUT } from "./types" import type { FileRouterProvider, diff --git a/packages/filerouter/src/internal/http.ts b/packages/filerouter/src/internal/http.ts index bc8e320..434916e 100644 --- a/packages/filerouter/src/internal/http.ts +++ b/packages/filerouter/src/internal/http.ts @@ -1,5 +1,6 @@ import { FileRouterError, toFileRouterError } from "../errors" import type { FileRouterErrorCode } from "../errors" +import { isRecord } from "./record" export interface JsonRequestOptions extends RequestInit { fetch?: typeof globalThis.fetch | undefined @@ -92,7 +93,3 @@ async function readJson(response: Response): Promise { return response.json() } - -export function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null -} diff --git a/packages/filerouter/src/internal/record.ts b/packages/filerouter/src/internal/record.ts new file mode 100644 index 0000000..c8dc521 --- /dev/null +++ b/packages/filerouter/src/internal/record.ts @@ -0,0 +1,3 @@ +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/packages/filerouter/src/llamaparse.ts b/packages/filerouter/src/llamaparse.ts index d67db16..6c76399 100644 --- a/packages/filerouter/src/llamaparse.ts +++ b/packages/filerouter/src/llamaparse.ts @@ -3,6 +3,7 @@ import { readEnv } from "./internal/env" import { selectOutputs } from "./internal/outputs" import { waitForProviderJob } from "./internal/polling" import { providerOptions } from "./internal/provider-options" +import { isRecord } from "./internal/record" import { DEFAULT_PARSE_OUTPUT } from "./types" import type { ClientOptions, LlamaCloud } from "@llamaindex/llama-cloud" import type { @@ -538,8 +539,5 @@ const stringField = (value: unknown): string | undefined => const numberField = (value: unknown): number | undefined => typeof value === "number" && Number.isFinite(value) ? value : undefined -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null - const objectField = (value: unknown): Record => isRecord(value) ? value : {} diff --git a/src/lib/api-auth.server.ts b/src/lib/api-auth.server.ts index d447295..d42fa9b 100644 --- a/src/lib/api-auth.server.ts +++ b/src/lib/api-auth.server.ts @@ -1,5 +1,6 @@ import { withAuth } from "@/lib/auth.server" import { HttpError } from "@/lib/http.server" +import { isRecord } from "@/lib/record" export interface ApiPrincipal { credentialId: string @@ -83,7 +84,3 @@ function getApiKeyLimitError(error: unknown): HttpError | undefined { }), }) } - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null -} diff --git a/src/lib/hosted-providers.server.ts b/src/lib/hosted-providers.server.ts index e126a40..ee2927f 100644 --- a/src/lib/hosted-providers.server.ts +++ b/src/lib/hosted-providers.server.ts @@ -4,6 +4,7 @@ import type { FileRouterProvider, ProviderParseOptions } from "@file_router/sdk" import { HttpError } from "@/lib/http.server" import { createNativeParserProvider } from "@/lib/native-parser.server" +import { isRecord } from "@/lib/record" import { JOB_ID_HEADER, REQUEST_ID_HEADER } from "@/observability/log" const blockedTransportOptions: Record> = { @@ -176,7 +177,3 @@ function assertNoBlockedOptions( ) } } - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) -} diff --git a/src/lib/record.ts b/src/lib/record.ts new file mode 100644 index 0000000..c8dc521 --- /dev/null +++ b/src/lib/record.ts @@ -0,0 +1,3 @@ +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} From 00cd6cbbd416cda633395e7350a9fb07a18bf345 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:06:47 -0400 Subject: [PATCH 15/22] fix(sdk): harden hosted job recovery --- packages/filerouter/src/errors.ts | 1 + packages/filerouter/src/index.ts | 1 + packages/filerouter/src/internal/http.ts | 3 +++ packages/filerouter/src/internal/timeout.ts | 24 +++++++++++++----- packages/filerouter/src/jobs.ts | 2 ++ packages/filerouter/test/client.test.ts | 21 ++++++++++++++++ packages/filerouter/test/jobs.test.ts | 27 +++++++++++++++++++++ 7 files changed, 73 insertions(+), 6 deletions(-) diff --git a/packages/filerouter/src/errors.ts b/packages/filerouter/src/errors.ts index 7587cdb..8b3ebba 100644 --- a/packages/filerouter/src/errors.ts +++ b/packages/filerouter/src/errors.ts @@ -4,6 +4,7 @@ export type FileRouterErrorCode = | "ProviderUnavailable" | "InvalidInput" | "Auth" + | "PaymentRequired" | "RateLimit" | "Timeout" | "ParseFailed" diff --git a/packages/filerouter/src/index.ts b/packages/filerouter/src/index.ts index b5c1483..0d59052 100644 --- a/packages/filerouter/src/index.ts +++ b/packages/filerouter/src/index.ts @@ -43,6 +43,7 @@ export { normalizeDocumentFileName, resolveDocumentMimeType, } from "./internal/input" +export { selectOutputs as selectParseOutputs } from "./internal/outputs" export type { CompareOptions, CompareProviderResult, diff --git a/packages/filerouter/src/internal/http.ts b/packages/filerouter/src/internal/http.ts index 434916e..2b0b45f 100644 --- a/packages/filerouter/src/internal/http.ts +++ b/packages/filerouter/src/internal/http.ts @@ -47,6 +47,9 @@ function errorCodeForStatus(status: number): FileRouterErrorCode { if (status === 401 || status === 403) { return "Auth" } + if (status === 402) { + return "PaymentRequired" + } if (status === 408 || status === 504) { return "Timeout" } diff --git a/packages/filerouter/src/internal/timeout.ts b/packages/filerouter/src/internal/timeout.ts index fda6038..6970a82 100644 --- a/packages/filerouter/src/internal/timeout.ts +++ b/packages/filerouter/src/internal/timeout.ts @@ -9,25 +9,37 @@ export async function withTimeout( assertTimeoutMs(timeoutMs) const timeoutController = new AbortController() - const timeout = setTimeout(() => timeoutController.abort(), timeoutMs) + const timeoutError = new FileRouterError("FileRouter job timed out.", { + code: "Timeout", + }) + const timeout = setTimeout( + () => timeoutController.abort(timeoutError), + timeoutMs + ) if (timeoutMs === 0) { - timeoutController.abort() + timeoutController.abort(timeoutError) } const operationSignal = signal ? AbortSignal.any([signal, timeoutController.signal]) : timeoutController.signal + let rejectOnAbort: (() => void) | undefined try { operationSignal.throwIfAborted() - return await operation(operationSignal) + const aborted = new Promise((_, reject) => { + rejectOnAbort = () => reject(operationSignal.reason) + operationSignal.addEventListener("abort", rejectOnAbort, { once: true }) + }) + return await Promise.race([operation(operationSignal), aborted]) } catch (error) { if (timeoutController.signal.aborted && !signal?.aborted) { - throw new FileRouterError("FileRouter job timed out.", { - code: "Timeout", - }) + throw timeoutError } throw error } finally { clearTimeout(timeout) + if (rejectOnAbort) { + operationSignal.removeEventListener("abort", rejectOnAbort) + } } } diff --git a/packages/filerouter/src/jobs.ts b/packages/filerouter/src/jobs.ts index c943d84..9679644 100644 --- a/packages/filerouter/src/jobs.ts +++ b/packages/filerouter/src/jobs.ts @@ -15,6 +15,7 @@ import type { } from "./hosted" import { requestJson } from "./internal/http" import { resolveParseInput } from "./internal/input" +import { assertPages } from "./internal/provider-options" import { abortableSleep } from "./internal/sleep" import { withTimeout } from "./internal/timeout" import { DEFAULT_PARSE_OUTPUT } from "./types" @@ -140,6 +141,7 @@ export class HostedJobs implements FileRouterJobs { input: ParseInput, options: HostedParseJobOptions | HostedCompareJobOptions = {} ): Promise { + assertPages(options.pages) const idempotencyKey = options.idempotencyKey ?? crypto.randomUUID() const request = createJobRequest(options) const resolved = await resolveParseInput(input, options.signal) diff --git a/packages/filerouter/test/client.test.ts b/packages/filerouter/test/client.test.ts index 54db922..1541755 100644 --- a/packages/filerouter/test/client.test.ts +++ b/packages/filerouter/test/client.test.ts @@ -211,6 +211,27 @@ describe("FileRouterClient", () => { }) }) + test("identifies an empty hosted credit balance", async () => { + const client = new FileRouterClient({ + apiKey: "fr_test_key", + baseURL: "https://example.com", + fetch: vi + .fn() + .mockResolvedValue( + Response.json({ detail: "You're out of credits." }, { status: 402 }) + ), + }) + + await expect(client.jobs.get("job-payment-required")).rejects.toMatchObject( + { + code: "PaymentRequired", + providerId: "filerouter", + retryable: false, + statusCode: 402, + } + ) + }) + test("normalizes hosted network failures", async () => { const client = new FileRouterClient({ apiKey: "fr_test_key", diff --git a/packages/filerouter/test/jobs.test.ts b/packages/filerouter/test/jobs.test.ts index e1bde8c..da66099 100644 --- a/packages/filerouter/test/jobs.test.ts +++ b/packages/filerouter/test/jobs.test.ts @@ -147,6 +147,33 @@ describe("FileRouterClient jobs", () => { ).rejects.toMatchObject({ code: "Timeout" }) expect(fetchMock).not.toHaveBeenCalled() }) + + test("enforces wait timeouts when custom fetch ignores abort signals", async () => { + const fetchMock = vi.fn( + () => new Promise(() => undefined) + ) + const client = createClient(fetchMock) + + await expect( + client.jobs.wait("job-9", { timeoutMs: 10 }) + ).rejects.toMatchObject({ code: "Timeout" }) + expect(fetchMock).toHaveBeenCalledOnce() + }) + + test("validates pages before resolving file input", async () => { + const fetchMock = vi.fn() + const client = createClient(fetchMock) + + await expect( + client.jobs.create( + { kind: "file", path: "/file-that-must-not-be-read.pdf" }, + { pages: [0] } + ) + ).rejects.toMatchObject({ + message: "Pages must be positive, one-based integers.", + }) + expect(fetchMock).not.toHaveBeenCalled() + }) }) function createClient(fetchMock: typeof fetch): FileRouterClient { From 84dec3cb5385798c412277060b253b1357bb5fd4 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:07:31 -0400 Subject: [PATCH 16/22] fix(native-parsers): harden hosted execution boundaries --- package.json | 2 +- pnpm-lock.yaml | 7 +- .../engines/liteparse/server.ts | 37 +++++++--- .../engines/pdf-inspector/Dockerfile | 2 +- .../engines/pdf-inspector/options.ts | 13 ++++ .../engines/pdf-inspector/server.ts | 12 ++-- .../native-parsers/engines/shared/http.ts | 52 +++++++------- .../engines/shared/observability.ts | 5 +- services/native-parsers/package.json | 2 + services/native-parsers/src/observability.ts | 15 ++-- services/native-parsers/test/options.test.ts | 17 +++++ services/native-parsers/vitest.config.ts | 5 ++ src/lib/document-job-input.server.test.ts | 53 +++++++++++++- src/lib/document-job-input.server.ts | 1 - src/lib/hosted-providers.server.ts | 69 ++++++++++++++++++- src/lib/native-parser.server.ts | 45 +----------- src/lib/public-url.ts | 5 +- src/workflows/document-source.test.ts | 3 + 18 files changed, 237 insertions(+), 108 deletions(-) create mode 100644 services/native-parsers/engines/pdf-inspector/options.ts create mode 100644 services/native-parsers/test/options.test.ts create mode 100644 services/native-parsers/vitest.config.ts diff --git a/package.json b/package.json index 6227774..330f4a7 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "db:migrate": "wrangler d1 migrations apply DB --remote", "db:check": "drizzle-kit check", "r2:lifecycle": "wrangler r2 bucket lifecycle set filerouter-files --file infra/r2-lifecycle.json --force", - "test": "vp test run --passWithNoTests && pnpm test:worker && pnpm --filter @file_router/sdk test && pnpm --filter @file_router/cli test", + "test": "vp test run --passWithNoTests && pnpm test:worker && pnpm --filter @file_router/sdk test && pnpm --filter @file_router/cli test && pnpm --filter @file_router/native-parsers test", "test:worker": "vitest run --config vitest.worker.config.ts", "lint": "vp lint", "format": "vp fmt", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28abf82..a192918 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -217,7 +217,7 @@ importers: version: 6.0.3 vitest: specifier: 4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0))(jsdom@28.1.0(@noble/hashes@2.2.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.5(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0))(jsdom@28.1.0(@noble/hashes@2.2.0)) packages/filerouter: dependencies: @@ -242,7 +242,7 @@ importers: version: 6.0.3 vitest: specifier: 4.1.10 - version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.5(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0))(jsdom@28.1.0(@noble/hashes@2.2.0)) + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/browser-preview@4.1.10)(@voidzero-dev/vite-plus-core@0.2.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0))(jsdom@28.1.0(@noble/hashes@2.2.0)) services/native-parsers: dependencies: @@ -259,6 +259,9 @@ importers: typescript: specifier: ^6 version: 6.0.3 + vite-plus: + specifier: 'catalog:' + version: 0.2.5(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@voidzero-dev/vite-plus-core@0.2.5(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.28.1)(jiti@2.7.0)(jsdom@28.1.0(@noble/hashes@2.2.0))(tsx@4.23.1)(typescript@6.0.3)(yaml@2.9.0) wrangler: specifier: ^4.112.0 version: 4.112.0(@cloudflare/workers-types@5.20260721.1) diff --git a/services/native-parsers/engines/liteparse/server.ts b/services/native-parsers/engines/liteparse/server.ts index d8ce79f..7bb0614 100644 --- a/services/native-parsers/engines/liteparse/server.ts +++ b/services/native-parsers/engines/liteparse/server.ts @@ -25,6 +25,7 @@ const ENGINE_VERSION = runtimePackage.dependencies["@llamaindex/liteparse"] const MAX_INPUT_BYTES = 100 * 1024 * 1024 const MAX_OUTPUT_BYTES = 64 * 1024 * 1024 const MAX_PAGES = 500 +const MAX_DPI = 300 const OFFICE_EXTENSIONS = new Set([ ".csv", ".doc", @@ -300,7 +301,7 @@ function readRawOptions(value: unknown): LiteParseHostedOptions["raw"] { raw.skipDiagonalText, "raw.skipDiagonalText" ) - const dpi = readPositiveNumber(raw.dpi, "raw.dpi") + const dpi = readPositiveNumber(raw.dpi, "raw.dpi", MAX_DPI) const cropBox = readCropBox(raw.cropBox) return { ...(cropBox && { cropBox }), @@ -314,14 +315,23 @@ function readRawOptions(value: unknown): LiteParseHostedOptions["raw"] { } } -function readPositiveNumber(value: unknown, name: string): number | undefined { +function readPositiveNumber( + value: unknown, + name: string, + max: number +): number | undefined { if (value === undefined) { return undefined } - if (typeof value === "number" && Number.isFinite(value) && value > 0) { + if ( + typeof value === "number" && + Number.isFinite(value) && + value > 0 && + value <= max + ) { return value } - throw invalidOptions(`${name} must be a positive number.`) + throw invalidOptions(`${name} must be greater than 0 and at most ${max}.`) } function readCropBox(value: unknown): LiteParseConfig["cropBox"] | undefined { @@ -330,16 +340,21 @@ function readCropBox(value: unknown): LiteParseConfig["cropBox"] | undefined { } const cropBox = readObject(value, "raw.cropBox must be an object.") assertAllowedKeys(cropBox, ["bottom", "left", "right", "top"]) - const bottom = readFiniteNumber(cropBox.bottom, "raw.cropBox.bottom") - const left = readFiniteNumber(cropBox.left, "raw.cropBox.left") - const right = readFiniteNumber(cropBox.right, "raw.cropBox.right") - const top = readFiniteNumber(cropBox.top, "raw.cropBox.top") + const bottom = readFraction(cropBox.bottom, "raw.cropBox.bottom") + const left = readFraction(cropBox.left, "raw.cropBox.left") + const right = readFraction(cropBox.right, "raw.cropBox.right") + const top = readFraction(cropBox.top, "raw.cropBox.top") return { bottom, left, right, top } } -function readFiniteNumber(value: unknown, name: string): number { - if (typeof value !== "number" || !Number.isFinite(value)) { - throw invalidOptions(`${name} must be a finite number.`) +function readFraction(value: unknown, name: string): number { + if ( + typeof value !== "number" || + !Number.isFinite(value) || + value < 0 || + value > 1 + ) { + throw invalidOptions(`${name} must be between 0 and 1.`) } return value } diff --git a/services/native-parsers/engines/pdf-inspector/Dockerfile b/services/native-parsers/engines/pdf-inspector/Dockerfile index cff7336..27f859d 100644 --- a/services/native-parsers/engines/pdf-inspector/Dockerfile +++ b/services/native-parsers/engines/pdf-inspector/Dockerfile @@ -7,7 +7,7 @@ COPY pdf-inspector/package.json pdf-inspector/package-lock.json ./pdf-inspector/ RUN cd pdf-inspector && npm ci --omit=dev --no-audit --no-fund COPY shared ./shared -COPY pdf-inspector/server.ts ./pdf-inspector/server.ts +COPY pdf-inspector/*.ts ./pdf-inspector/ USER node EXPOSE 8080 diff --git a/services/native-parsers/engines/pdf-inspector/options.ts b/services/native-parsers/engines/pdf-inspector/options.ts new file mode 100644 index 0000000..2730cd9 --- /dev/null +++ b/services/native-parsers/engines/pdf-inspector/options.ts @@ -0,0 +1,13 @@ +import { ParserRequestError } from "../shared/http.ts" + +export const MAX_PDF_INSPECTOR_PAGES = 1_000 + +export function assertPdfInspectorPageLimit(pageCount: number): void { + if (pageCount > MAX_PDF_INSPECTOR_PAGES) { + throw new ParserRequestError( + 413, + "provider_limit_exceeded", + `PDF Inspector supports at most ${MAX_PDF_INSPECTOR_PAGES} pages per request.` + ) + } +} diff --git a/services/native-parsers/engines/pdf-inspector/server.ts b/services/native-parsers/engines/pdf-inspector/server.ts index e909b92..d330395 100644 --- a/services/native-parsers/engines/pdf-inspector/server.ts +++ b/services/native-parsers/engines/pdf-inspector/server.ts @@ -1,6 +1,7 @@ import { classifyPdf, extractPagesMarkdown } from "@firecrawl/pdf-inspector" import runtimePackage from "./package.json" with { type: "json" } +import { assertPdfInspectorPageLimit } from "./options.ts" import type { NativeParserResult } from "../shared/contracts.ts" import { ParserRequestError, startParserServer } from "../shared/http.ts" @@ -9,7 +10,6 @@ import { readNativeParserOptions, readObject } from "../shared/options.ts" const ENGINE_VERSION = runtimePackage.dependencies["@firecrawl/pdf-inspector"] const MAX_INPUT_BYTES = 50 * 1024 * 1024 const MAX_OUTPUT_BYTES = 32 * 1024 * 1024 -const MAX_PAGES = 1_000 startParserServer({ handler: async ({ bytes, options }) => parsePdf(bytes, options), @@ -33,13 +33,9 @@ function parsePdf(bytes: Buffer, value: unknown): NativeParserResult { ) } const classification = classifyPdf(bytes) - if (classification.pageCount > MAX_PAGES && !requestedPages) { - throw new ParserRequestError( - 413, - "provider_limit_exceeded", - `PDF Inspector supports at most ${MAX_PAGES} pages per request.` - ) - } + assertPdfInspectorPageLimit( + requestedPages?.length ?? classification.pageCount + ) if (requestedPages?.some((page) => page > classification.pageCount)) { throw new ParserRequestError( 400, diff --git a/services/native-parsers/engines/shared/http.ts b/services/native-parsers/engines/shared/http.ts index cf5f4fb..accc7b4 100644 --- a/services/native-parsers/engines/shared/http.ts +++ b/services/native-parsers/engines/shared/http.ts @@ -102,30 +102,34 @@ export function startParserServer(options: ParserServerOptions): void { if (requestActive) { activeRequests -= 1 } - emitWideEvent(status >= 500 ? "error" : "info", { - active_requests: activeRequests, - body_bytes: bodyBytes, - content_length: numericHeader( - singleHeader(request.headers["content-length"]) - ), - content_type: - singleHeader(request.headers["content-type"]) ?? undefined, - duration_ms: Date.now() - startedAt, - error_code: errorCode, - event: "parser_engine_request_completed", - job_id: singleHeader(request.headers[JOB_ID_HEADER]), - max_concurrency: options.maxConcurrency, - method: request.method, - outcome: - status >= 500 ? "error" : status >= 400 ? "rejected" : "success", - parser: options.parserId, - path, - release_id: singleHeader(request.headers[RELEASE_ID_HEADER]), - request_id: requestId, - service: `filerouter-${options.parserId}`, - status_code: status, - ...(failure ? serializeError(failure) : {}), - }) + emitWideEvent( + status >= 500 ? "error" : "info", + { + active_requests: activeRequests, + body_bytes: bodyBytes, + content_length: numericHeader( + singleHeader(request.headers["content-length"]) + ), + content_type: + singleHeader(request.headers["content-type"]) ?? undefined, + duration_ms: Date.now() - startedAt, + error_code: errorCode, + event: "parser_engine_request_completed", + job_id: singleHeader(request.headers[JOB_ID_HEADER]), + max_concurrency: options.maxConcurrency, + method: request.method, + outcome: + status >= 500 ? "error" : status >= 400 ? "rejected" : "success", + parser: options.parserId, + path, + release_id: singleHeader(request.headers[RELEASE_ID_HEADER]), + request_id: requestId, + service: `filerouter-${options.parserId}`, + status_code: status, + ...(failure ? serializeError(failure) : {}), + }, + process.env.NODE_ENV ?? "production" + ) } }) diff --git a/services/native-parsers/engines/shared/observability.ts b/services/native-parsers/engines/shared/observability.ts index 264144d..e58ff84 100644 --- a/services/native-parsers/engines/shared/observability.ts +++ b/services/native-parsers/engines/shared/observability.ts @@ -4,11 +4,12 @@ export const RELEASE_ID_HEADER = "x-filerouter-release-id" export function emitWideEvent( level: "error" | "info", - event: Record & { event: string; service: string } + event: Record & { event: string; service: string }, + environment = "production" ): void { const record = JSON.stringify({ timestamp: new Date().toISOString(), - environment: process.env.NODE_ENV ?? "production", + environment, ...event, }) console[level](record) diff --git a/services/native-parsers/package.json b/services/native-parsers/package.json index e7c3734..f86ced5 100644 --- a/services/native-parsers/package.json +++ b/services/native-parsers/package.json @@ -6,6 +6,7 @@ "cf-typegen": "wrangler types worker-configuration.d.ts --include-runtime false", "deploy": "wrangler deploy --config wrangler.jsonc", "deploy:dry-run": "wrangler deploy --config wrangler.jsonc --dry-run", + "test": "vp test run --config vitest.config.ts", "typecheck": "tsc --noEmit && tsc -p engines/liteparse && tsc -p engines/pdf-inspector && tsc -p engines/shared" }, "dependencies": { @@ -15,6 +16,7 @@ "@cloudflare/workers-types": "5.20260721.1", "@types/node": "^22", "typescript": "^6", + "vite-plus": "catalog:", "wrangler": "^4.112.0" } } diff --git a/services/native-parsers/src/observability.ts b/services/native-parsers/src/observability.ts index 577415c..7429478 100644 --- a/services/native-parsers/src/observability.ts +++ b/services/native-parsers/src/observability.ts @@ -1,6 +1,9 @@ -export const REQUEST_ID_HEADER = "x-request-id" -export const JOB_ID_HEADER = "x-filerouter-job-id" -export const RELEASE_ID_HEADER = "x-filerouter-release-id" +export { + JOB_ID_HEADER, + RELEASE_ID_HEADER, + REQUEST_ID_HEADER, + serializeError, +} from "../engines/shared/observability" export function emitWideEvent( env: Cloudflare.Env, @@ -17,12 +20,6 @@ export function emitWideEvent( }) } -export function serializeError(error: unknown) { - return error instanceof Error - ? { error_message: error.message, error_type: error.name } - : { error_message: "Unknown error", error_type: "UnknownError" } -} - export function responseWithRequestId( response: Response, requestId: string diff --git a/services/native-parsers/test/options.test.ts b/services/native-parsers/test/options.test.ts new file mode 100644 index 0000000..fa7fd4a --- /dev/null +++ b/services/native-parsers/test/options.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "vite-plus/test" + +import { + assertPdfInspectorPageLimit, + MAX_PDF_INSPECTOR_PAGES, +} from "../engines/pdf-inspector/options.ts" + +describe("native parser limits", () => { + test("caps selected PDF Inspector pages", () => { + expect(() => + assertPdfInspectorPageLimit(MAX_PDF_INSPECTOR_PAGES) + ).not.toThrow() + expect(() => + assertPdfInspectorPageLimit(MAX_PDF_INSPECTOR_PAGES + 1) + ).toThrow("PDF Inspector supports at most 1000 pages per request.") + }) +}) diff --git a/services/native-parsers/vitest.config.ts b/services/native-parsers/vitest.config.ts new file mode 100644 index 0000000..828c22b --- /dev/null +++ b/services/native-parsers/vitest.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from "vite-plus" + +export default defineConfig({ + test: { include: ["test/**/*.test.ts"] }, +}) diff --git a/src/lib/document-job-input.server.test.ts b/src/lib/document-job-input.server.test.ts index 136d1b5..248db88 100644 --- a/src/lib/document-job-input.server.test.ts +++ b/src/lib/document-job-input.server.test.ts @@ -110,10 +110,39 @@ describe("document job input", () => { request({ liteparse: { inventedOption: true } }) ).rejects.toMatchObject({ status: 400 }) await expect( - request({ liteparse: { ocr: "auto", screenshots: true } }) + request({ liteparse: { raw: "not-an-object" } }) + ).rejects.toMatchObject({ status: 400 }) + await expect( + request({ liteparse: { raw: { dpi: 301 } } }) + ).rejects.toMatchObject({ status: 400 }) + await expect( + request({ + liteparse: { + raw: { cropBox: { bottom: 0, left: -0.1, right: 1, top: 1 } }, + }, + }) + ).rejects.toMatchObject({ status: 400 }) + await expect( + request({ + liteparse: { + ocr: "auto", + raw: { + cropBox: { bottom: 0, left: 0, right: 1, top: 1 }, + dpi: 300, + }, + screenshots: true, + }, + }) ).resolves.toMatchObject({ providerOptions: { - liteparse: { ocr: "auto", screenshots: true }, + liteparse: { + ocr: "auto", + raw: { + cropBox: { bottom: 0, left: 0, right: 1, top: 1 }, + dpi: 300, + }, + screenshots: true, + }, }, }) }) @@ -139,6 +168,26 @@ describe("document job input", () => { }) }) + test("rejects Mistral models missing from the hosted rate card", async () => { + await expect( + readDocumentJobInput( + new Request("https://filerouter.test/api/v1/jobs", { + body: JSON.stringify({ + operation: "parse", + outputs: ["markdown"], + provider: "mistral-ocr", + providerOptions: { + "mistral-ocr": { model: "unpriced-preview-model" }, + }, + source: { url: "https://example.com/report.pdf" }, + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + }) + ) + ).rejects.toMatchObject({ status: 400 }) + }) + test("infers upload MIME type from its filename", async () => { const input = await readDocumentJobInput( new Request("https://filerouter.test/api/v1/jobs", { diff --git a/src/lib/document-job-input.server.ts b/src/lib/document-job-input.server.ts index 6996924..9e4b7b9 100644 --- a/src/lib/document-job-input.server.ts +++ b/src/lib/document-job-input.server.ts @@ -149,7 +149,6 @@ function parseHeaderOptions( if (pages?.some((page) => !Number.isSafeInteger(page) || page < 1)) { throw new HttpError(400, "Pages must be positive, one-based integers.") } - if (!optionsHeader) { return pages ? { pages: [...new Set(pages)] } : {} } diff --git a/src/lib/hosted-providers.server.ts b/src/lib/hosted-providers.server.ts index ee2927f..5e766c0 100644 --- a/src/lib/hosted-providers.server.ts +++ b/src/lib/hosted-providers.server.ts @@ -53,6 +53,14 @@ const liteParseRawOptions = new Set([ "skipDiagonalText", ]) +const liteParseCropBoxOptions = new Set(["bottom", "left", "right", "top"]) +const MAX_LITEPARSE_DPI = 300 +const HOSTED_MISTRAL_OCR_MODELS = new Set([ + "mistral-ocr-2512", + "mistral-ocr-4-0", + "mistral-ocr-latest", +]) + const validateProviderOptions: Record< ProviderId, (options: Record) => void @@ -66,11 +74,21 @@ const validateProviderOptions: Record< llamaparse: (options) => assertNoBlockedOptions("llamaparse", options), liteparse(options) { assertOnlyOptions("liteparse", options, liteParseOptions) - if (isRecord(options.raw)) { - assertOnlyOptions("liteparse", options.raw, liteParseRawOptions) + validateLiteParseRawOptions(options.raw) + }, + "mistral-ocr"(options) { + assertNoBlockedOptions("mistral-ocr", options) + if ( + options.model !== undefined && + (typeof options.model !== "string" || + !HOSTED_MISTRAL_OCR_MODELS.has(options.model)) + ) { + throw new HttpError( + 400, + "Hosted mistral-ocr only accepts models on the published rate card." + ) } }, - "mistral-ocr": (options) => assertNoBlockedOptions("mistral-ocr", options), "pdf-inspector"(options) { if (Object.keys(options).length > 0) { throw new HttpError(400, "Hosted pdf-inspector accepts no options.") @@ -78,6 +96,51 @@ const validateProviderOptions: Record< }, } +function validateLiteParseRawOptions(value: unknown): void { + if (value === undefined) { + return + } + if (!isRecord(value)) { + throw new HttpError(400, "Hosted liteparse raw options must be an object.") + } + assertOnlyOptions("liteparse", value, liteParseRawOptions) + + if ( + value.dpi !== undefined && + (typeof value.dpi !== "number" || + !Number.isFinite(value.dpi) || + value.dpi <= 0 || + value.dpi > MAX_LITEPARSE_DPI) + ) { + throw new HttpError( + 400, + `Hosted liteparse dpi must be greater than 0 and at most ${MAX_LITEPARSE_DPI}.` + ) + } + + if (value.cropBox === undefined) { + return + } + if (!isRecord(value.cropBox)) { + throw new HttpError(400, "Hosted liteparse cropBox must be an object.") + } + assertOnlyOptions("liteparse", value.cropBox, liteParseCropBoxOptions) + for (const side of liteParseCropBoxOptions) { + const fraction = value.cropBox[side] + if ( + typeof fraction !== "number" || + !Number.isFinite(fraction) || + fraction < 0 || + fraction > 1 + ) { + throw new HttpError( + 400, + `Hosted liteparse cropBox.${side} must be between 0 and 1.` + ) + } + } +} + export function createHostedProviders( env: Cloudflare.Env, context: { jobId: string; requestId: string } diff --git a/src/lib/native-parser.server.ts b/src/lib/native-parser.server.ts index 450a655..2e37a67 100644 --- a/src/lib/native-parser.server.ts +++ b/src/lib/native-parser.server.ts @@ -1,9 +1,7 @@ import { z } from "zod" -import { FileRouterError } from "@file_router/sdk" +import { FileRouterError, selectParseOutputs } from "@file_router/sdk" import type { FileRouterProvider, - ParseOutput, - ParseOutputValues, ParseResult, ProviderInput, } from "@file_router/sdk" @@ -133,7 +131,7 @@ async function parseNative( const confidence = native.metadata.confidence return { id: crypto.randomUUID(), - outputs: selectOutputs(options.outputs ?? ["markdown"], { + outputs: selectParseOutputs(options.outputs ?? ["markdown"], { images: native.images, markdown: native.markdown, metadata, @@ -156,45 +154,6 @@ async function parseNative( } } -function selectOutputs( - requested: Array, - available: Partial -): ParseResult["outputs"] { - const outputs: ParseResult["outputs"] = {} - for (const output of requested) { - assignOutput(outputs, output, available) - } - return outputs -} - -function assignOutput( - outputs: ParseResult["outputs"], - output: ParseOutput, - available: Partial -): void { - switch (output) { - case "images": - if (available.images !== undefined) outputs.images = available.images - return - case "markdown": - if (available.markdown !== undefined) - outputs.markdown = available.markdown - return - case "metadata": - if (available.metadata !== undefined) - outputs.metadata = available.metadata - return - case "pages": - if (available.pages !== undefined) outputs.pages = available.pages - return - case "text": - if (available.text !== undefined) outputs.text = available.text - return - default: - return - } -} - function nativeParserError( providerId: NativeParserId, status: number, diff --git a/src/lib/public-url.ts b/src/lib/public-url.ts index c7f141d..914fd88 100644 --- a/src/lib/public-url.ts +++ b/src/lib/public-url.ts @@ -3,7 +3,10 @@ export function readPublicHttpUrl(value: string): URL { if (url.protocol !== "http:" && url.protocol !== "https:") { throw new Error("URLs must use HTTP or HTTPS.") } - const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, "") + const hostname = url.hostname + .toLowerCase() + .replace(/^\[|\]$/g, "") + .replace(/\.$/, "") if ( hostname === "localhost" || hostname === "0.0.0.0" || diff --git a/src/workflows/document-source.test.ts b/src/workflows/document-source.test.ts index ebbd698..eca83fe 100644 --- a/src/workflows/document-source.test.ts +++ b/src/workflows/document-source.test.ts @@ -14,6 +14,9 @@ describe("hosted document URL policy", () => { "http://127.0.0.1/report.pdf", "http://169.254.169.254/latest/meta-data", "http://192.168.1.4/report.pdf", + "http://localhost./report.pdf", + "http://service.local./report.pdf", + "http://127.0.0.1./report.pdf", "http://[::1]/report.pdf", "http://[fd00::1]/report.pdf", ])("rejects non-public source %s", (source) => { From b4e62a80719b72c2ab4ef38a19000f50997a1ae3 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:07:59 -0400 Subject: [PATCH 17/22] feat(billing): add hosted credits and checkout --- .dev.vars.example | 2 +- src/api/contracts.ts | 1 + src/api/problem.ts | 1 + src/components/dashboard-billing.tsx | 72 +++++++ src/integrations/autumn/billing.functions.ts | 45 ++++ .../autumn/billing.server.test.ts | 113 ++++++++++ src/integrations/autumn/billing.server.ts | 197 ++++++++++++++++++ src/integrations/autumn/client.server.ts | 21 ++ src/integrations/autumn/config.ts | 10 + .../autumn/managed-execution-cost.ts | 16 +- .../autumn/managed-execution.test.ts | 90 ++++---- src/integrations/autumn/managed-execution.ts | 69 ++---- src/lib/http.server.ts | 1 + src/routes/dashboard.tsx | 3 + vitest.worker.config.ts | 1 + worker-configuration.d.ts | 8 +- wrangler.jsonc | 3 + 17 files changed, 538 insertions(+), 115 deletions(-) create mode 100644 src/components/dashboard-billing.tsx create mode 100644 src/integrations/autumn/billing.functions.ts create mode 100644 src/integrations/autumn/billing.server.test.ts create mode 100644 src/integrations/autumn/billing.server.ts create mode 100644 src/integrations/autumn/client.server.ts create mode 100644 src/integrations/autumn/config.ts diff --git a/.dev.vars.example b/.dev.vars.example index 4396b33..067f20d 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -15,7 +15,7 @@ BETTER_AUTH_URL="http://localhost:3000" # DATALAB_API_KEY="" # Autumn usage metering. Use a sandbox key for local development. -# AUTUMN_SECRET_KEY="" +# AUTUMN_SECRET_KEY="am_sk_test_..." # Optional product analytics and error tracking. The project token is public; # leave it unset to disable PostHog without changing application behavior. diff --git a/src/api/contracts.ts b/src/api/contracts.ts index 3f0a61b..bde4830 100644 --- a/src/api/contracts.ts +++ b/src/api/contracts.ts @@ -135,6 +135,7 @@ export const createJobRoute = createRoute({ }, 400: problem, 401: problem, + 402: problem, 409: problem, 413: problem, 429: problem, diff --git a/src/api/problem.ts b/src/api/problem.ts index 3f4347c..2a0df04 100644 --- a/src/api/problem.ts +++ b/src/api/problem.ts @@ -6,6 +6,7 @@ import type { HttpError } from "@/lib/http.server" const statusMetadata = { 400: { code: "bad_request", title: "Bad Request" }, 401: { code: "unauthorized", title: "Unauthorized" }, + 402: { code: "payment_required", title: "Payment Required" }, 403: { code: "forbidden", title: "Forbidden" }, 404: { code: "not_found", title: "Not Found" }, 409: { code: "conflict", title: "Conflict" }, diff --git a/src/components/dashboard-billing.tsx b/src/components/dashboard-billing.tsx new file mode 100644 index 0000000..baa3b98 --- /dev/null +++ b/src/components/dashboard-billing.tsx @@ -0,0 +1,72 @@ +import { ArrowSquareOut, Coins } from "@phosphor-icons/react" +import { useMutation, useQuery } from "@tanstack/react-query" + +import { Button } from "@/components/ui/button" +import { + getBillingSummary, + startCreditCheckout, +} from "@/integrations/autumn/billing.functions" + +export function DashboardBilling() { + const summary = useQuery({ + queryFn: () => getBillingSummary(), + queryKey: ["hosted-billing-summary"], + staleTime: 30_000, + }) + const checkout = useMutation({ + mutationFn: () => startCreditCheckout(), + onSuccess: ({ paymentUrl }) => window.location.assign(paymentUrl), + }) + + if (summary.data && !summary.data.enabled) { + return null + } + + const remaining = summary.data + ? summary.data.remainingCredits === null + ? "Unlimited" + : formatCredits(summary.data.remainingCredits) + : "—" + + return ( +
    +
    + +

    Credit balance

    +
    +

    + {remaining} +

    +

    + {summary.data + ? `${formatCredits(summary.data.includedCredits)} free credits each month. Purchased credits never expire.` + : "Loading balance…"} +

    + + {checkout.isError ? ( +

    + Could not open checkout. Try again. +

    + ) : null} +
    + ) +} + +function formatCredits(value: number): string { + return new Intl.NumberFormat("en-US", { + maximumFractionDigits: 2, + }).format(value) +} diff --git a/src/integrations/autumn/billing.functions.ts b/src/integrations/autumn/billing.functions.ts new file mode 100644 index 0000000..0d87f7e --- /dev/null +++ b/src/integrations/autumn/billing.functions.ts @@ -0,0 +1,45 @@ +import { env as workerEnv } from "cloudflare:workers" +import { createServerFn } from "@tanstack/react-start" +import { getRequestHeaders } from "@tanstack/react-start/server" + +import { + createHostedCreditCheckout, + getHostedBillingSummary, +} from "@/integrations/autumn/billing.server" +import { getSessionFromHeaders } from "@/lib/auth-queries.server" + +export const getBillingSummary = createServerFn({ method: "GET" }).handler( + async () => { + const account = await requireAccount() + return getHostedBillingSummary(workerEnv, account) + } +) + +export const startCreditCheckout = createServerFn({ method: "POST" }).handler( + async () => { + const account = await requireAccount() + const baseUrl = workerEnv.BETTER_AUTH_URL?.trim() + if (!baseUrl) { + throw new Error("BETTER_AUTH_URL is not configured.") + } + return { + paymentUrl: await createHostedCreditCheckout( + workerEnv, + account, + new URL("/dashboard?credits=added", baseUrl).toString() + ), + } + } +) + +async function requireAccount() { + const session = await getSessionFromHeaders(getRequestHeaders()) + if (!session) { + throw new Error("Authentication required.") + } + return { + email: session.user.email, + id: session.user.id, + name: session.user.name, + } +} diff --git a/src/integrations/autumn/billing.server.test.ts b/src/integrations/autumn/billing.server.test.ts new file mode 100644 index 0000000..2a597ff --- /dev/null +++ b/src/integrations/autumn/billing.server.test.ts @@ -0,0 +1,113 @@ +import type { Balance, Customer } from "autumn-js" +import { describe, expect, test, vi } from "vite-plus/test" + +import { + createHostedCreditCheckout, + getHostedBillingSummary, + requireHostedCredit, +} from "@/integrations/autumn/billing.server" +import type { + AutumnAccount, + AutumnBillingClient, +} from "@/integrations/autumn/billing.server" +import { + HOSTED_CREDIT_FEATURE_ID, + HOSTED_CREDIT_TOP_UP_PLAN_ID, + HOSTED_TOP_UP_CREDITS, +} from "@/integrations/autumn/config" + +const account: AutumnAccount = { + email: "dev@example.com", + id: "user-123", + name: "Developer", +} +const env = { + AUTUMN_SECRET_KEY: "test", + HOSTED_BILLING_ENABLED: "true", +} + +describe("hosted billing", () => { + test("summarizes the account-level hosted credit balance", async () => { + const client = autumnClient({ + [HOSTED_CREDIT_FEATURE_ID]: balance({ + remaining: 12_345.5, + }), + }) + + await expect( + getHostedBillingSummary(env, account, client) + ).resolves.toMatchObject({ + enabled: true, + includedCredits: 5_000, + remainingCredits: 12_345.5, + topUpCredits: 10_000, + topUpPriceUsd: 10, + }) + }) + + test("rejects hosted work when the account has no credits", async () => { + const client = autumnClient({ + [HOSTED_CREDIT_FEATURE_ID]: balance({ remaining: 0 }), + }) + vi.mocked(client.check).mockResolvedValue({ allowed: false }) + + await expect( + requireHostedCredit(env, account, client) + ).rejects.toMatchObject({ code: "insufficient_credits", status: 402 }) + }) + + test("creates the fixed Stripe top-up checkout", async () => { + const client = autumnClient({}) + vi.mocked(client.billing.attach).mockResolvedValue({ + paymentUrl: "https://checkout.stripe.com/test", + }) + + await expect( + createHostedCreditCheckout( + env, + account, + "https://filerouter.dev/dashboard?credits=added", + client + ) + ).resolves.toBe("https://checkout.stripe.com/test") + expect(client.billing.attach).toHaveBeenCalledWith( + expect.objectContaining({ + customerId: account.id, + featureQuantities: [ + { + featureId: HOSTED_CREDIT_FEATURE_ID, + quantity: HOSTED_TOP_UP_CREDITS, + }, + ], + planId: HOSTED_CREDIT_TOP_UP_PLAN_ID, + redirectMode: "always", + }) + ) + }) +}) + +function autumnClient(balances: Record): AutumnBillingClient { + return { + billing: { attach: vi.fn() }, + check: vi.fn().mockResolvedValue({ allowed: true }), + customers: { + getOrCreate: vi.fn().mockResolvedValue({ balances } as Customer), + }, + } +} + +function balance(overrides: Partial): Balance { + return { + breakdown: [], + featureId: HOSTED_CREDIT_FEATURE_ID, + granted: 5_000, + maxPurchase: null, + nextResetAt: null, + overageAllowed: false, + remaining: 5_000, + rollovers: [], + unlimited: false, + usage: 0, + ...overrides, + } +} diff --git a/src/integrations/autumn/billing.server.ts b/src/integrations/autumn/billing.server.ts new file mode 100644 index 0000000..63bdda1 --- /dev/null +++ b/src/integrations/autumn/billing.server.ts @@ -0,0 +1,197 @@ +import type { Balance, Customer } from "autumn-js" +import { eq } from "drizzle-orm" + +import { user } from "@/db/schema" +import { createDb } from "@/db/server" +import { + HOSTED_CREDIT_FEATURE_ID, + HOSTED_CREDIT_TOP_UP_PLAN_ID, + HOSTED_FREE_PLAN_ID, + HOSTED_MONTHLY_CREDITS, + HOSTED_TOP_UP_CREDITS, + HOSTED_TOP_UP_PRICE_USD, +} from "@/integrations/autumn/config" +import { createAutumnClient } from "@/integrations/autumn/client.server" +import { HttpError } from "@/lib/http.server" + +export interface AutumnAccount { + email: string + id: string + name: string +} + +interface AutumnBillingEnv { + AUTUMN_SECRET_KEY?: string + HOSTED_BILLING_ENABLED?: string +} + +export interface AutumnBillingClient { + billing: { + attach: (request: { + customerId: string + enablePlanImmediately: boolean + featureQuantities: Array<{ featureId: string; quantity: number }> + metadata: Record + planId: string + redirectMode: "always" + successUrl: string + }) => Promise<{ paymentUrl: string | null }> + } + check: (request: { + customerId: string + featureId: string + requiredBalance: number + }) => Promise<{ allowed: boolean }> + customers: { + getOrCreate: (request: { + autoEnablePlanId: string + customerId: string + email: string + metadata: Record + name: string + }) => Promise + } +} + +export interface HostedBillingSummary { + enabled: boolean + includedCredits: number + remainingCredits: number | null + topUpCredits: number + topUpPriceUsd: number +} + +export async function getHostedBillingSummary( + env: AutumnBillingEnv, + account: AutumnAccount, + client: AutumnBillingClient | undefined = createAutumnClient(env) +): Promise { + if (!client) { + return emptySummary() + } + + const customer = await ensureAutumnCustomer(client, account) + const balance = customer.balances[HOSTED_CREDIT_FEATURE_ID] + return { + enabled: true, + includedCredits: HOSTED_MONTHLY_CREDITS, + remainingCredits: normalizeCredits(balance), + topUpCredits: HOSTED_TOP_UP_CREDITS, + topUpPriceUsd: HOSTED_TOP_UP_PRICE_USD, + } +} + +export async function requireHostedCredit( + env: AutumnBillingEnv, + account: AutumnAccount, + client: AutumnBillingClient | undefined = createAutumnClient(env) +): Promise { + if (!client) { + return + } + + await ensureAutumnCustomer(client, account) + const access = await client.check({ + customerId: account.id, + featureId: HOSTED_CREDIT_FEATURE_ID, + requiredBalance: 1, + }) + if (!access.allowed) { + throw new HttpError( + 402, + "You're out of credits. Add credits in the dashboard to continue.", + { code: "insufficient_credits" } + ) + } +} + +export async function requireHostedCreditForUser( + env: AutumnBillingEnv & { DB: D1Database }, + userId: string, + client: AutumnBillingClient | undefined = createAutumnClient(env) +): Promise { + if (!client) { + return + } + const account = await findAutumnAccount(env.DB, userId) + await requireHostedCredit(env, account, client) +} + +export async function createHostedCreditCheckout( + env: AutumnBillingEnv, + account: AutumnAccount, + successUrl: string, + client: AutumnBillingClient | undefined = createAutumnClient(env) +): Promise { + if (!client) { + throw new Error("Hosted billing is not enabled.") + } + + await ensureAutumnCustomer(client, account) + const checkout = await client.billing.attach({ + customerId: account.id, + enablePlanImmediately: true, + featureQuantities: [ + { + featureId: HOSTED_CREDIT_FEATURE_ID, + quantity: HOSTED_TOP_UP_CREDITS, + }, + ], + metadata: { product: "filerouter", purchase: "hosted_credits" }, + planId: HOSTED_CREDIT_TOP_UP_PLAN_ID, + redirectMode: "always", + successUrl, + }) + if (!checkout.paymentUrl) { + throw new Error("Autumn did not return a checkout URL.") + } + return checkout.paymentUrl +} + +export function ensureAutumnCustomer( + client: Pick, + account: AutumnAccount +): Promise { + return client.customers.getOrCreate({ + autoEnablePlanId: HOSTED_FREE_PLAN_ID, + customerId: account.id, + email: account.email, + metadata: { account_type: "developer", product: "filerouter" }, + name: account.name, + }) +} + +export async function findAutumnAccount( + database: D1Database, + userId: string +): Promise { + const account = await createDb(database) + .select({ email: user.email, id: user.id, name: user.name }) + .from(user) + .where(eq(user.id, userId)) + .get() + if (!account) { + throw new Error(`Cannot find Autumn account for user ${userId}.`) + } + return account +} + +function emptySummary(): HostedBillingSummary { + return { + enabled: false, + includedCredits: HOSTED_MONTHLY_CREDITS, + remainingCredits: 0, + topUpCredits: HOSTED_TOP_UP_CREDITS, + topUpPriceUsd: HOSTED_TOP_UP_PRICE_USD, + } +} + +function normalizeCredits(balance: Balance | undefined): number | null { + if (balance?.unlimited) { + return null + } + if (!balance) { + return 0 + } + return Math.max(0, balance.remaining) +} diff --git a/src/integrations/autumn/client.server.ts b/src/integrations/autumn/client.server.ts new file mode 100644 index 0000000..7e15614 --- /dev/null +++ b/src/integrations/autumn/client.server.ts @@ -0,0 +1,21 @@ +import { Autumn } from "autumn-js" + +interface AutumnRuntimeEnv { + AUTUMN_SECRET_KEY?: string + HOSTED_BILLING_ENABLED?: string +} + +export function createAutumnClient(env: AutumnRuntimeEnv): Autumn | undefined { + if (env.HOSTED_BILLING_ENABLED !== "true") { + return undefined + } + + const secretKey = env.AUTUMN_SECRET_KEY?.trim() + if (!secretKey) { + throw new Error( + "HOSTED_BILLING_ENABLED is true but AUTUMN_SECRET_KEY is not configured." + ) + } + + return new Autumn({ failOpen: false, secretKey, timeoutMs: 10_000 }) +} diff --git a/src/integrations/autumn/config.ts b/src/integrations/autumn/config.ts new file mode 100644 index 0000000..4d9f836 --- /dev/null +++ b/src/integrations/autumn/config.ts @@ -0,0 +1,10 @@ +export const HOSTED_CREDIT_FEATURE_ID = "managed_execution_units" +export const HOSTED_FREE_PLAN_ID = "free" +export const HOSTED_CREDIT_TOP_UP_PLAN_ID = "credits_10k" + +export const HOSTED_MONTHLY_CREDITS = 5_000 +export const HOSTED_TOP_UP_CREDITS = 10_000 +export const HOSTED_TOP_UP_PRICE_USD = 10 + +export const HOSTED_RATE_CARD_VERSION = "2026-07-21.v1" +export const HOSTED_BETA_MARGIN_MULTIPLIER = 1.1 diff --git a/src/integrations/autumn/managed-execution-cost.ts b/src/integrations/autumn/managed-execution-cost.ts index be9453e..262e2e7 100644 --- a/src/integrations/autumn/managed-execution-cost.ts +++ b/src/integrations/autumn/managed-execution-cost.ts @@ -1,3 +1,4 @@ +import { HOSTED_BETA_MARGIN_MULTIPLIER } from "@/integrations/autumn/config" import type { ProviderOutcome } from "@/workflows/document-results" const MILLIUSD_PER_USD = 1_000 @@ -13,10 +14,8 @@ const LLAMAPARSE_USD_PER_CREDIT = 0.00125 // Covers the Worker, Workflow, D1, and R2 work around one provider execution. const PLATFORM_USD_PER_EXECUTION = 0.00025 -// Native parser estimates use https://developers.cloudflare.com/containers/pricing/ -// plus one sleep-after window per execution. Shared warm windows make this -// estimate deliberately conservative at higher volume. -const CONTAINER_IDLE_ALLOWANCE_SECONDS = 60 +// Native parser estimates use https://developers.cloudflare.com/containers/pricing/. +// Shared idle time belongs to the container lifecycle, not an individual job. const CONTAINER_CPU_USD_PER_VCPU_SECOND = 0.00002 const CONTAINER_MEMORY_USD_PER_GIB_SECOND = 0.0000025 const CONTAINER_DISK_USD_PER_GB_SECOND = 0.00000007 @@ -27,8 +26,8 @@ export type ParsedProviderOutcome = Extract< > export interface ManagedExecutionEstimate { + credits: number rawCostUsd: number - units: number } export function estimateManagedExecution( @@ -40,8 +39,8 @@ export function estimateManagedExecution( } const rawCostUsd = providerCost + PLATFORM_USD_PER_EXECUTION return { + credits: rawCostUsd * MILLIUSD_PER_USD * HOSTED_BETA_MARGIN_MULTIPLIER, rawCostUsd, - units: rawCostUsd * MILLIUSD_PER_USD, } } @@ -87,10 +86,7 @@ function containerCostUsd( resources.memoryGib * CONTAINER_MEMORY_USD_PER_GIB_SECOND + resources.diskGb * CONTAINER_DISK_USD_PER_GB_SECOND const cpuPerSecond = resources.vcpu * CONTAINER_CPU_USD_PER_VCPU_SECOND - return ( - activeSeconds * (memoryAndDiskPerSecond + cpuPerSecond) + - CONTAINER_IDLE_ALLOWANCE_SECONDS * memoryAndDiskPerSecond - ) + return activeSeconds * (memoryAndDiskPerSecond + cpuPerSecond) } function isNonNegativeFinite(value: unknown): value is number { diff --git a/src/integrations/autumn/managed-execution.test.ts b/src/integrations/autumn/managed-execution.test.ts index 4563de4..bf4d46c 100644 --- a/src/integrations/autumn/managed-execution.test.ts +++ b/src/integrations/autumn/managed-execution.test.ts @@ -1,11 +1,8 @@ import { describe, expect, test, vi } from "vite-plus/test" import { estimateManagedExecution } from "@/integrations/autumn/managed-execution-cost" -import { - MANAGED_EXECUTION_FEATURE_ID, - MANAGED_EXECUTION_FREE_PLAN_ID, - trackManagedExecutionForAccount, -} from "@/integrations/autumn/managed-execution" +import { trackManagedExecution } from "@/integrations/autumn/managed-execution" +import { HOSTED_CREDIT_FEATURE_ID } from "@/integrations/autumn/config" import type { ProviderOutcome } from "@/workflows/document-results" function parsed( @@ -23,39 +20,42 @@ function parsed( describe("managed execution pricing", () => { test("uses provider-reported dollar cost when available", () => { - expect( - estimateManagedExecution( - parsed({ - pageCount: 10, - provider: "datalab", - usage: { costUsd: 0.04, pages: 10 }, - }) - ) - ).toEqual({ rawCostUsd: 0.04025, units: 40.25 }) + const estimate = estimateManagedExecution( + parsed({ + pageCount: 10, + provider: "datalab", + usage: { costUsd: 0.04, pages: 10 }, + }) + ) + + expect(estimate?.credits).toBeCloseTo(44.275) + expect(estimate?.rawCostUsd).toBeCloseTo(0.04025) }) test("converts LlamaParse credits into upstream cost", () => { - expect( - estimateManagedExecution( - parsed({ - pageCount: 10, - provider: "llamaparse", - usage: { credits: 30, pages: 10 }, - }) - ) - ).toEqual({ rawCostUsd: 0.03775, units: 37.75 }) + const estimate = estimateManagedExecution( + parsed({ + pageCount: 10, + provider: "llamaparse", + usage: { credits: 30, pages: 10 }, + }) + ) + + expect(estimate?.credits).toBeCloseTo(41.525) + expect(estimate?.rawCostUsd).toBeCloseTo(0.03775) }) test("prices Mistral OCR from processed pages", () => { - expect( - estimateManagedExecution( - parsed({ - pageCount: 10, - provider: "mistral-ocr", - usage: { costUsd: 0.04, pages: 10 }, - }) - ) - ).toEqual({ rawCostUsd: 0.04025, units: 40.25 }) + const estimate = estimateManagedExecution( + parsed({ + pageCount: 10, + provider: "mistral-ocr", + usage: { costUsd: 0.04, pages: 10 }, + }) + ) + + expect(estimate?.credits).toBeCloseTo(44.275) + expect(estimate?.rawCostUsd).toBeCloseTo(0.04025) }) test("does not guess mode-dependent provider prices", () => { @@ -85,17 +85,16 @@ describe("managed execution pricing", () => { if (!estimate) { throw new Error("Expected a native parser estimate.") } - expect(estimate.rawCostUsd).toBeCloseTo(0.0010892) - expect(estimate.units).toBeCloseTo(1.0892) + expect(estimate.rawCostUsd).toBeCloseTo(0.0004556) + expect(estimate.credits).toBeCloseTo(0.50116) }) - test("syncs the customer and records idempotent provider events", async () => { - const getOrCreate = vi.fn().mockResolvedValue({}) + test("records idempotent provider events", async () => { const track = vi.fn().mockResolvedValue({}) await expect( - trackManagedExecutionForAccount( - { customers: { getOrCreate }, track }, + trackManagedExecution( + { track }, { jobId: "job-123", operation: "compare", @@ -113,23 +112,15 @@ describe("managed execution pricing", () => { }, ], userId: "user-123", - }, - { email: "dev@example.com", name: "Developer" } + } ) ).resolves.toEqual({ trackedProviders: 1, unpricedProviders: [] }) - expect(getOrCreate).toHaveBeenCalledWith({ - autoEnablePlanId: MANAGED_EXECUTION_FREE_PLAN_ID, - customerId: "user-123", - email: "dev@example.com", - metadata: { account_type: "developer", product: "filerouter" }, - name: "Developer", - }) expect(track).toHaveBeenCalledWith( expect.objectContaining({ customerId: "user-123", - featureId: MANAGED_EXECUTION_FEATURE_ID, - value: 8.25, + featureId: HOSTED_CREDIT_FEATURE_ID, + overageBehavior: "overflow", }), { headers: { @@ -137,5 +128,6 @@ describe("managed execution pricing", () => { }, } ) + expect(track.mock.calls[0]?.[0].value).toBeCloseTo(9.075) }) }) diff --git a/src/integrations/autumn/managed-execution.ts b/src/integrations/autumn/managed-execution.ts index ca018b2..08ad724 100644 --- a/src/integrations/autumn/managed-execution.ts +++ b/src/integrations/autumn/managed-execution.ts @@ -1,8 +1,8 @@ -import { Autumn } from "autumn-js" -import { eq } from "drizzle-orm" - -import { user } from "@/db/schema" -import { createDb } from "@/db/server" +import { createAutumnClient } from "@/integrations/autumn/client.server" +import { + HOSTED_CREDIT_FEATURE_ID, + HOSTED_RATE_CARD_VERSION, +} from "@/integrations/autumn/config" import { estimateManagedExecution } from "@/integrations/autumn/managed-execution-cost" import type { ManagedExecutionEstimate, @@ -10,28 +10,14 @@ import type { } from "@/integrations/autumn/managed-execution-cost" import type { ProviderOutcome } from "@/workflows/document-results" -export const MANAGED_EXECUTION_FEATURE_ID = "managed_execution_units" -export const MANAGED_EXECUTION_FREE_PLAN_ID = "developer" - export interface AutumnUsageClient { - customers: { - getOrCreate: ( - request: { - autoEnablePlanId: string - customerId: string - email: string - metadata: Record - name: string - }, - options?: RequestInit - ) => Promise - } track: ( request: { customerId: string featureId: string properties: Record value: number + overageBehavior: "overflow" }, options?: RequestInit ) => Promise @@ -39,7 +25,7 @@ export interface AutumnUsageClient { interface AutumnUsageEnv { AUTUMN_SECRET_KEY?: string - DB: D1Database + HOSTED_BILLING_ENABLED?: string } export interface TrackManagedExecutionInput { @@ -52,7 +38,7 @@ export interface TrackManagedExecutionInput { export async function trackManagedExecutionUsage( env: AutumnUsageEnv, input: TrackManagedExecutionInput, - client = createAutumnClient(env.AUTUMN_SECRET_KEY) + client = createAutumnClient(env) ): Promise< | { skipped: true } | { trackedProviders: number; unpricedProviders: Array } @@ -61,31 +47,13 @@ export async function trackManagedExecutionUsage( return { skipped: true } } - const account = await createDb(env.DB) - .select({ email: user.email, name: user.name }) - .from(user) - .where(eq(user.id, input.userId)) - .get() - if (!account) { - throw new Error(`Cannot meter job ${input.jobId}: user not found.`) - } - - return trackManagedExecutionForAccount(client, input, account) + return trackManagedExecution(client, input) } -export async function trackManagedExecutionForAccount( +export async function trackManagedExecution( client: AutumnUsageClient, - input: TrackManagedExecutionInput, - account: { email: string; name: string } + input: TrackManagedExecutionInput ): Promise<{ trackedProviders: number; unpricedProviders: Array }> { - await client.customers.getOrCreate({ - autoEnablePlanId: MANAGED_EXECUTION_FREE_PLAN_ID, - customerId: input.userId, - email: account.email, - metadata: { account_type: "developer", product: "filerouter" }, - name: account.name, - }) - const parsedProviders = input.providers.filter( (provider): provider is ParsedProviderOutcome => provider.status === "parsed" @@ -104,9 +72,10 @@ export async function trackManagedExecutionForAccount( return client.track( { customerId: input.userId, - featureId: MANAGED_EXECUTION_FEATURE_ID, + featureId: HOSTED_CREDIT_FEATURE_ID, + overageBehavior: "overflow", properties: usageProperties(input, provider, estimate), - value: estimate.units, + value: estimate.credits, }, { headers: { @@ -131,6 +100,7 @@ function usageProperties( operation: input.operation, pages: provider.usage?.pages ?? provider.pageCount, provider: provider.provider, + rate_card_version: HOSTED_RATE_CARD_VERSION, raw_cost_usd: estimate.rawCostUsd, ...(provider.usage?.costUsd !== undefined && { provider_cost_usd: provider.usage.costUsd, @@ -140,12 +110,3 @@ function usageProperties( }), } } - -function createAutumnClient( - secretKey: string | undefined -): AutumnUsageClient | undefined { - if (!secretKey?.trim()) { - return undefined - } - return new Autumn({ secretKey, timeoutMs: 10_000 }) -} diff --git a/src/lib/http.server.ts b/src/lib/http.server.ts index ac06483..0b4532f 100644 --- a/src/lib/http.server.ts +++ b/src/lib/http.server.ts @@ -1,6 +1,7 @@ export type HttpErrorStatus = | 400 | 401 + | 402 | 403 | 404 | 409 diff --git a/src/routes/dashboard.tsx b/src/routes/dashboard.tsx index 164b999..dbafd1f 100644 --- a/src/routes/dashboard.tsx +++ b/src/routes/dashboard.tsx @@ -10,6 +10,7 @@ import { GitHubIcon, } from "@/components/community-links" import { DashboardQuickstart } from "@/components/dashboard-quickstart" +import { DashboardBilling } from "@/components/dashboard-billing" import { ModeToggle } from "@/components/mode-toggle" import { Button } from "@/components/ui/button" import { @@ -95,6 +96,8 @@ function DashboardPage() {