Skip to content
Merged
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
39 changes: 39 additions & 0 deletions src/app/api/jobs/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,30 @@
import { loadConfig } from '@/lib/config';
import { updateJob, getJobByRequestId } from '@/lib/storage';
import { Job } from '@/lib/types';

// deAPI reports what a request actually cost on the job status, but only once
// the request is terminal (`price` is null while it runs). For partner models
// the charge settles after inference, so `is_estimated` can still be true —
// keep the flag so the UI can mark the figure as not final.
function readFinalPrice(data: Record<string, unknown> | undefined): Job['finalPrice'] {
const price = data?.price as { amount?: unknown; is_estimated?: unknown } | null | undefined;
if (!price || typeof price.amount !== 'number') return undefined;
return { amount: price.amount, isEstimated: price.is_estimated === true };
}

// Inline prompt booster (`enhance_prompt`) outcome. `prompt_boost` is non-null
// only once the boost has actually run.
function readPromptBoost(data: Record<string, unknown> | undefined): Job['promptBoost'] {
const boost = data?.prompt_boost as Record<string, unknown> | null | undefined;
if (!boost) return undefined;
const str = (v: unknown) => (typeof v === 'string' ? v : null);
return {
prompt: str(boost.prompt),
promptOriginal: str(boost.prompt_original),
negativePrompt: str(boost.negative_prompt),
negativePromptOriginal: str(boost.negative_prompt_original),
};
}

// GET /api/jobs/[id] - One-shot job status fetch + persist.
//
Expand Down Expand Up @@ -47,12 +72,24 @@ export async function GET(
const job = getJobByRequestId(requestId);
if (job) {
const status = data.data?.status || data.status;
// Price and boost details ride along with every status payload; they are
// only populated once the request is terminal.
const finalPrice = readFinalPrice(data.data);
const promptBoost = readPromptBoost(data.data);
const boostFields: Partial<Job> = {};
if (finalPrice) boostFields.finalPrice = finalPrice;
if (data.data?.prompt_boosted !== undefined) {
boostFields.promptBoosted = data.data.prompt_boosted === true;
}
if (promptBoost) boostFields.promptBoost = promptBoost;

if (status === 'done') {
const updateData: Record<string, unknown> = {
status: 'completed',
rawResponse: data,
resultUrl: data.data?.result_url,
completedAt: new Date().toISOString(),
...boostFields,
};
if (data.data?.cost_credits !== undefined) {
updateData.costCredits = data.data.cost_credits;
Expand All @@ -69,6 +106,7 @@ export async function GET(
data.error ||
(data.data?.error_code ? `Error: ${data.data.error_code}` : undefined),
completedAt: new Date().toISOString(),
...boostFields,
});
} else {
// A queued job (pending/in_queue/waiting) stays 'pending' (waiting) — it
Expand All @@ -84,6 +122,7 @@ export async function GET(
updateJob(job.id, {
status: jobStatus,
rawResponse: data,
...boostFields,
});
}
}
Expand Down
121 changes: 120 additions & 1 deletion src/app/api/proxy/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,59 @@ import { addJob, generateJobId, updateJob } from '@/lib/storage';
import { saveUploadedFile } from '@/lib/upload-storage';
import { getEndpointById } from '@/lib/endpoint-registry';
import { fetchAllPages, PAGE_LIMIT } from '@/lib/pagination';
import {
INLINE_BOOST_FIELD,
enhancementTypeForPath,
supportsInlineBoost,
} from '@/lib/prompt-enhancement';
import { Job, JsonValue, UploadedFile } from '@/lib/types';

// Price of the inline prompt boost (`enhance_prompt`), quoted by the same
// endpoint the standalone booster uses. The generation /price endpoints do not
// accept `enhance_prompt` and the job's own `price` covers the inference only,
// so the boost fee is quoted here and reported alongside the estimate.
async function fetchInlineBoostPrice(
apiUrl: string,
apiToken: string,
args: {
type: string;
modelSlug: string;
prompt: string;
negativePrompt?: string;
image?: File;
}
): Promise<number | undefined> {
try {
const form = new FormData();
form.append('type', args.type);
form.append('model_slug', args.modelSlug);
form.append('prompt', args.prompt);
if (args.negativePrompt && args.negativePrompt.length >= 3) {
form.append('negative_prompt', args.negativePrompt);
}
if (args.image) form.append('image', args.image);

const res = await fetch(`${apiUrl.replace(/\/$/, '')}/prompts/enhancements/price`, {
method: 'POST',
headers: { Authorization: `Bearer ${apiToken}` },
body: form,
});
const text = await res.text();
let data: { price?: number; data?: { price?: number } } | null;
try {
data = JSON.parse(text);
} catch {
data = null;
}
console.log('[deapi-tester] Boost price response:', res.status, data ?? text.slice(0, 300));
const price = data?.price ?? data?.data?.price;
return res.ok && typeof price === 'number' ? price : undefined;
} catch (err) {
console.error('[deapi-tester] Boost price calculation failed:', err);
return undefined;
}
}

// POST /api/proxy - Proxy request to deAPI
export async function POST(request: Request) {
// Track a persisted job so the outer catch can mark it failed instead of
Expand Down Expand Up @@ -65,6 +116,18 @@ export async function POST(request: Request) {
);
}

// Inline prompt booster: `enhance_prompt` travels with the generation
// request itself. The /price endpoints do not accept it, so it is stripped
// from every price payload and the boost fee is quoted separately (below).
const boostFlag = params[INLINE_BOOST_FIELD];
const boostRequested =
supportsInlineBoost(endpoint.path) &&
(boostFlag === true || boostFlag === 'true' || boostFlag === '1' || boostFlag === 1);
if (isPriceCalc && boostFlag !== undefined) {
delete params[INLINE_BOOST_FIELD];
formData?.delete(INLINE_BOOST_FIELD);
}

// For price calculation, check if endpoint supports it
if (isPriceCalc && (!endpoint.hasPriceCalc || !endpoint.priceCalcPath)) {
return NextResponse.json(
Expand Down Expand Up @@ -180,6 +243,7 @@ export async function POST(request: Request) {

// Fetch estimated price if endpoint supports price calculation (skip if this IS a price calc request)
let estimatedPrice: number | undefined;
let estimateBreakdown: { base?: number; boost?: number } | undefined;
if (!isPriceCalc && endpoint.hasPriceCalc && endpoint.priceCalcPath) {
try {
const priceUrl = config.apiUrl.replace(/\/$/, '') + endpoint.priceCalcPath;
Expand All @@ -196,6 +260,7 @@ export async function POST(request: Request) {
const priceForm = new FormData();
for (const [key, value] of Object.entries(params)) {
if (fileEntries.some((f) => f.field === key)) continue; // skip file placeholders
if (key === INLINE_BOOST_FIELD) continue; // not part of the /price contract
if (value !== undefined && value !== null) {
priceForm.append(key, String(value));
}
Expand All @@ -209,13 +274,15 @@ export async function POST(request: Request) {
body: priceForm,
});
} else {
const priceParams = { ...params };
delete priceParams[INLINE_BOOST_FIELD]; // not part of the /price contract
priceResponse = await fetch(priceUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${config.apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(params),
body: JSON.stringify(priceParams),
});
}

Expand Down Expand Up @@ -244,6 +311,29 @@ export async function POST(request: Request) {
}
}

// The inline boost is charged separately from the inference: the job's
// reported `price` covers the inference only (verified against the live
// API). So quote the boost on the side and keep it out of the estimate the
// final price is compared against, rather than folding it in.
if (!isPriceCalc && boostRequested) {
const boostType = enhancementTypeForPath(endpoint.path);
const modelSlug = typeof params.model === 'string' ? params.model : undefined;
const promptText = typeof params.prompt === 'string' ? params.prompt : undefined;
if (boostType && modelSlug && promptText) {
const boostPrice = await fetchInlineBoostPrice(config.apiUrl, config.apiToken, {
type: boostType,
modelSlug,
prompt: promptText,
negativePrompt:
typeof params.negative_prompt === 'string' ? params.negative_prompt : undefined,
image: fileEntries.find((f) => f.file.type.startsWith('image/'))?.file,
});
if (boostPrice !== undefined) {
estimateBreakdown = { base: estimatedPrice, boost: boostPrice };
}
}
}

// 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.
Expand Down Expand Up @@ -279,6 +369,7 @@ export async function POST(request: Request) {
status: 'pending',
createdAt: new Date().toISOString(),
costCredits: estimatedPrice,
estimateBreakdown,
};
if (!isPriceCalc) {
if (providedJobId) {
Expand Down Expand Up @@ -412,6 +503,34 @@ 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.
if (isPriceCalc && boostRequested && responseOk) {
const basePrice = rawResponse?.data?.price ?? rawResponse?.price;
const boostType = enhancementTypeForPath(endpoint.path);
const modelSlug = typeof params.model === 'string' ? params.model : undefined;
const promptText = typeof params.prompt === 'string' ? params.prompt : undefined;
if (boostType && modelSlug && promptText) {
const boostPrice = await fetchInlineBoostPrice(config.apiUrl, config.apiToken, {
type: boostType,
modelSlug,
prompt: promptText,
negativePrompt:
typeof params.negative_prompt === 'string' ? params.negative_prompt : undefined,
image: fileEntries.find((f) => f.file.type.startsWith('image/'))?.file,
});
if (boostPrice !== undefined) {
rawResponse._tester = {
...(rawResponse._tester || {}),
base_price: basePrice,
boost_price: boostPrice,
total_price: (typeof basePrice === 'number' ? basePrice : 0) + boostPrice,
};
}
}
}

// For sync endpoints, mark as completed
// Only update costCredits if API returns it, otherwise keep the estimated price
const syncUpdateData: Record<string, unknown> = {
Expand Down
31 changes: 31 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,37 @@ body {
height: 48px;
}

/* Horizontal splitter — drag to resize the panel above it */
.resize-handle-row {
height: 6px;
background: transparent;
cursor: row-resize;
transition: background 0.15s;
position: relative;
}

.resize-handle-row::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
height: 2px;
width: 32px;
background: var(--border);
border-radius: 1px;
transition: background 0.15s, width 0.15s;
}

.resize-handle-row:hover {
background: var(--accent-dim);
}

.resize-handle-row:hover::after {
background: var(--accent);
width: 64px;
}

/* Drawer animation */
@keyframes slideIn {
from {
Expand Down
64 changes: 63 additions & 1 deletion src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ interface FormPrefill {
nonce: number;
}

// Request form panel sizing (dragged by the splitter below the form).
const FORM_HEIGHT_KEY = 'deapi-form-height';
const FORM_MIN_HEIGHT = 200;

interface ProxyResponse {
success: boolean;
jobId?: string;
Expand All @@ -39,6 +43,48 @@ export default function Home() {
const [prefill, setPrefill] = useState<FormPrefill | null>(null);
const [isConfigOpen, setIsConfigOpen] = useState(false);

// Height of the request form panel. Null = the original auto behaviour
// (min 200px, capped at 45vh); a number is a height the user dragged and is
// remembered across sessions, so a long prompt can get as much room as needed.
const [formHeight, setFormHeight] = useState<number | null>(null);
const formAreaRef = useRef<HTMLDivElement>(null);

useEffect(() => {
const stored = Number(localStorage.getItem(FORM_HEIGHT_KEY));
if (Number.isFinite(stored) && stored >= FORM_MIN_HEIGHT) setFormHeight(stored);
}, []);

const startFormResize = (e: React.PointerEvent) => {
e.preventDefault();
const startY = e.clientY;
const startHeight = formAreaRef.current?.getBoundingClientRect().height ?? FORM_MIN_HEIGHT;
let latest = startHeight;

const onMove = (ev: PointerEvent) => {
// Leave room for the jobs panel below, whatever the viewport height is.
const max = Math.max(FORM_MIN_HEIGHT, window.innerHeight - 200);
latest = Math.min(max, Math.max(FORM_MIN_HEIGHT, startHeight + ev.clientY - startY));
setFormHeight(latest);
};
const onUp = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
localStorage.setItem(FORM_HEIGHT_KEY, String(Math.round(latest)));
};

window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
document.body.style.cursor = 'row-resize';
document.body.style.userSelect = 'none';
};

const resetFormHeight = () => {
setFormHeight(null);
localStorage.removeItem(FORM_HEIGHT_KEY);
};

// Duplicate a request from history: select its endpoint and preload its params
// into the form so the user can tweak and re-run without rebuilding from scratch.
const handleDuplicate = (job: Job) => {
Expand Down Expand Up @@ -189,7 +235,15 @@ export default function Home() {
{/* Center: Form + Jobs stacked */}
<div className="flex-1 flex flex-col min-w-0">
{/* Form Area */}
<div className="flex-shrink-0 border-b border-[var(--border)] bg-[var(--surface)]" style={{ minHeight: '200px', maxHeight: '45vh' }}>
<div
ref={formAreaRef}
className="flex-shrink-0 border-b border-[var(--border)] bg-[var(--surface)] overflow-hidden"
style={
formHeight === null
? { minHeight: `${FORM_MIN_HEIGHT}px`, maxHeight: '45vh' }
: { height: `${formHeight}px` }
}
>
{selectedEndpoint ? (
<EndpointForm
endpoint={selectedEndpoint}
Expand All @@ -214,6 +268,14 @@ export default function Home() {
)}
</div>

{/* Splitter: drag to give the form (and its prompt fields) more room */}
<div
onPointerDown={startFormResize}
onDoubleClick={resetFormHeight}
className="resize-handle-row flex-shrink-0"
title="Drag to resize the request form — double-click to reset"
/>

{/* Jobs Panel - takes remaining space */}
<div className="flex-1 min-h-0 overflow-hidden">
<JobsPanel
Expand Down
Loading
Loading