diff --git a/src/app/api/proxy/route.ts b/src/app/api/proxy/route.ts index 915fff2..95ae770 100644 --- a/src/app/api/proxy/route.ts +++ b/src/app/api/proxy/route.ts @@ -335,10 +335,11 @@ export async function POST(request: Request) { } // Persist uploaded files (content-addressed) so the request can be duplicated - // later with its files intact. Skip for price-only requests. Reading a File's - // bytes does not consume it, so formData is still sent to deAPI below. + // later with its files intact — price checks included, since they are logged + // as jobs too. Reading a File's bytes does not consume it, so formData is + // still sent to deAPI below. let uploadedFiles: UploadedFile[] | undefined; - if (!isPriceCalc && fileEntries.length > 0) { + if (fileEntries.length > 0) { uploadedFiles = []; for (const { field, file } of fileEntries) { const buffer = Buffer.from(await file.arrayBuffer()); @@ -348,9 +349,9 @@ export async function POST(request: Request) { // Create or reuse the job entry before making the request. When the client // pre-created a 'sending' stub (providedJobId), update it in place so the row - // transitions sending -> pending without duplicating. Price-only checks are a - // throwaway pre-calculation — there is no request to restore/duplicate to — - // so they are NOT persisted to history (all job writes are skipped below). + // transitions sending -> pending without duplicating. Price-only checks are + // logged the same way (flagged with isPriceCheck) so the /price endpoints can + // be exercised — and their request/response inspected — from the jobs list. const jobId = providedJobId || generateJobId(); // Store the actual API path (without leading slash) as endpointId const jobEndpointId = targetPath.replace(/^\//, ''); @@ -370,20 +371,19 @@ export async function POST(request: Request) { createdAt: new Date().toISOString(), costCredits: estimatedPrice, estimateBreakdown, + isPriceCheck: isPriceCalc || undefined, }; - if (!isPriceCalc) { - if (providedJobId) { - // Preserve the stub's original createdAt (set when the user clicked Execute). - const jobUpdate: Partial = { ...job }; - delete jobUpdate.createdAt; - const updated = updateJob(jobId, jobUpdate); - // Stub missing (e.g. cleared before the proxy ran) — fall back to creating it. - if (!updated) addJob(job); - } else { - addJob(job); - } - persistedJobId = jobId; + if (providedJobId) { + // Preserve the stub's original createdAt (set when the user clicked Execute). + const jobUpdate: Partial = { ...job }; + delete jobUpdate.createdAt; + const updated = updateJob(jobId, jobUpdate); + // Stub missing (e.g. cleared before the proxy ran) — fall back to creating it. + if (!updated) addJob(job); + } else { + addJob(job); } + persistedJobId = jobId; // Make request to deAPI const controller = new AbortController(); @@ -457,15 +457,13 @@ export async function POST(request: Request) { // Update job with response if (!responseOk) { - if (!isPriceCalc) { - updateJob(jobId, { - rawResponse, - rawResponseHeaders, - status: 'failed', - error: rawResponse.error || rawResponse.message || `HTTP ${responseStatus}`, - completedAt: new Date().toISOString(), - }); - } + updateJob(jobId, { + rawResponse, + rawResponseHeaders, + status: 'failed', + error: rawResponse.error || rawResponse.message || `HTTP ${responseStatus}`, + completedAt: new Date().toISOString(), + }); return NextResponse.json({ success: false, @@ -476,22 +474,22 @@ export async function POST(request: Request) { }, { status: responseStatus }); } - // For async endpoints, extract request_id - if (endpoint.isAsync && rawResponse.data?.request_id) { + // For async endpoints, extract request_id. A price check never gets one (the + // /price endpoints answer synchronously), so it always falls through to the + // sync completion path below. + if (!isPriceCalc && endpoint.isAsync && rawResponse.data?.request_id) { // A request_id means the job was accepted and QUEUED — not yet being // computed. Mark it 'pending' (waiting in queue), not 'processing'; the // WebSocket / reconciliation poll flips it to 'processing' once a worker // actually starts, then to completed/failed. This avoids the misleading // "processing" flash (and the processing -> pending regression) right after // submit. - if (!isPriceCalc) { - updateJob(jobId, { - requestId: rawResponse.data.request_id, - rawResponse, - rawResponseHeaders, - status: 'pending', - }); - } + updateJob(jobId, { + requestId: rawResponse.data.request_id, + rawResponse, + rawResponseHeaders, + status: 'pending', + }); return NextResponse.json({ success: true, @@ -506,6 +504,7 @@ export async function POST(request: Request) { // Price-only check with the inline boost enabled: the /price endpoint quotes // the inference alone, so quote the boost as well and expose base/boost/total // under `_tester`. The API payload itself stays verbatim. + let priceCheckBoost: number | undefined; if (isPriceCalc && boostRequested && responseOk) { const basePrice = rawResponse?.data?.price ?? rawResponse?.price; const boostType = enhancementTypeForPath(endpoint.path); @@ -521,6 +520,7 @@ export async function POST(request: Request) { image: fileEntries.find((f) => f.file.type.startsWith('image/'))?.file, }); if (boostPrice !== undefined) { + priceCheckBoost = boostPrice; rawResponse._tester = { ...(rawResponse._tester || {}), base_price: basePrice, @@ -548,7 +548,22 @@ export async function POST(request: Request) { } else if (rawResponse.data?.balance !== undefined) { syncUpdateData.costCredits = rawResponse.data.balance; } - if (!isPriceCalc) updateJob(jobId, syncUpdateData); + // A price check's answer IS its price — there is no estimate/final pair to + // reconcile, so record the quote as the job's price (and the separately + // billed boost fee alongside it, when the check asked for one). + if (isPriceCalc) { + const quoted = rawResponse.data?.price ?? rawResponse.price; + if (typeof quoted === 'number') { + syncUpdateData.finalPrice = { + amount: quoted, + isEstimated: (rawResponse.data?.is_estimated ?? rawResponse.is_estimated) === true, + }; + if (priceCheckBoost !== undefined) { + syncUpdateData.estimateBreakdown = { base: quoted, boost: priceCheckBoost }; + } + } + } + updateJob(jobId, syncUpdateData); return NextResponse.json({ success: true, diff --git a/src/components/EndpointForm.tsx b/src/components/EndpointForm.tsx index 3ac4a94..bc334fb 100644 --- a/src/components/EndpointForm.tsx +++ b/src/components/EndpointForm.tsx @@ -880,11 +880,13 @@ export function EndpointForm({ endpoint, prefill, onSubmit, onPriceCheck, isSubm | { boost_price?: number; total_price?: number } | undefined; setPriceResult({ credits: price, boostCredits: tester?.boost_price || undefined }); - onPriceCheck?.(); } catch (err) { setPriceResult({ credits: 0, error: err instanceof Error ? err.message : 'Failed' }); } finally { setIsCheckingPrice(false); + // The proxy logs the price check as a job (success or failure), so pull it + // into the jobs list either way. + onPriceCheck?.(); } }; diff --git a/src/components/jobs/JobRow.tsx b/src/components/jobs/JobRow.tsx index 69695b4..a74c36e 100644 --- a/src/components/jobs/JobRow.tsx +++ b/src/components/jobs/JobRow.tsx @@ -131,6 +131,9 @@ export function JobRow({ }; const resultUrl = getResultUrl(); + // A "Check Price" row: the request went to the endpoint's /price path, so its + // figure is a quote — nothing was charged for it. + const isQuote = job.isPriceCheck === true; const estimate = getEstimate(); const finalPrice = getFinalPrice(); // Same figure quoted and charged → show it once. Floats come back from two @@ -185,14 +188,20 @@ export function JobRow({ )} {finalPrice.isEstimated ? '~' : ''}${formatCost(finalPrice.amount)} @@ -202,7 +211,11 @@ export function JobRow({ {boostFee !== undefined && ( +${formatCost(boostFee)} diff --git a/src/lib/endpoint-registry.ts b/src/lib/endpoint-registry.ts index c1f0a5a..9560242 100644 --- a/src/lib/endpoint-registry.ts +++ b/src/lib/endpoint-registry.ts @@ -1145,6 +1145,14 @@ export function getEndpointByApiPath(apiPath: string): EndpointDefinition | unde const normalized = apiPath.replace(/^\//, ''); const byPath = ENDPOINTS.find(e => e.path.replace(/^\//, '') === normalized); if (byPath) return byPath; + // Price-check jobs store the /price path they called — map them back to the + // endpoint they priced so the row can be duplicated into the same form. A few + // endpoints share one price path (the transcription variants), so this resolves + // to the first of them. + const byPricePath = ENDPOINTS.find( + e => e.priceCalcPath?.replace(/^\//, '') === normalized + ); + if (byPricePath) return byPricePath; // Fallback: some jobs may have been stored using the registry id directly return ENDPOINTS.find(e => e.id === normalized); } diff --git a/src/lib/types.ts b/src/lib/types.ts index f2cffd9..9e672f2 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -109,6 +109,11 @@ export interface Job { error?: string; createdAt: string; // ISO timestamp completedAt?: string; + // Price-only pre-calculation (the "Check Price" button): the request went to + // the endpoint's /price path, not the generation path. Logged like every other + // request so the price endpoints can be exercised from the jobs list — it has + // no request_id, is never polled, and nothing was charged for it. + isPriceCheck?: boolean; // Pre-request estimate (from the endpoint's /price call made at submit time). // Includes the inline prompt-boost fee when the request asked for a boost. costCredits?: number;