Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 53 additions & 38 deletions src/app/api/proxy/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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(/^\//, '');
Expand All @@ -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> = { ...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> = { ...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();
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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);
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion src/components/EndpointForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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?.();
}
};

Expand Down
27 changes: 20 additions & 7 deletions src/components/jobs/JobRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -185,14 +188,20 @@ export function JobRow({
)}
<span
className={`text-[10px] font-mono ${
finalPrice.isEstimated ? 'text-yellow-500' : 'text-green-400'
isQuote
? 'text-blue-400'
: finalPrice.isEstimated
? 'text-yellow-500'
: 'text-green-400'
}`}
title={
finalPrice.isEstimated
? 'Charge still settling (partner model) — this figure can change'
: priceMatchesEstimate
? 'Final price — matches the estimate'
: 'Final price charged for this request'
isQuote
? 'Quoted price — this was a price check, nothing was charged'
: finalPrice.isEstimated
? 'Charge still settling (partner model) — this figure can change'
: priceMatchesEstimate
? 'Final price — matches the estimate'
: 'Final price charged for this request'
}
>
{finalPrice.isEstimated ? '~' : ''}${formatCost(finalPrice.amount)}
Expand All @@ -202,7 +211,11 @@ export function JobRow({
{boostFee !== undefined && (
<span
className="text-[10px] font-mono text-purple-300 flex-shrink-0"
title="Prompt boost fee (estimated, billed separately from the inference)"
title={
isQuote
? 'Prompt boost fee (quoted separately — the /price endpoints do not include it)'
: 'Prompt boost fee (estimated, billed separately from the inference)'
}
>
+${formatCost(boostFee)}
</span>
Expand Down
8 changes: 8 additions & 0 deletions src/lib/endpoint-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
5 changes: 5 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading