Skip to content

Commit a43366e

Browse files
authored
Merge pull request #18 from deapi-ai/fix/queue-status-and-config-ux
fix: queued status, price-calc history, config UX + connectivity check
2 parents 7ecbe2d + 2ecaa78 commit a43366e

9 files changed

Lines changed: 414 additions & 50 deletions

File tree

‎src/app/api/config/route.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
setActiveProfile,
66
addProfile,
77
updateProfile,
8+
duplicateProfile,
89
deleteProfile,
910
} from '@/lib/config';
1011
import { AppConfigFull, ConfigProfile } from '@/lib/types';
@@ -128,6 +129,28 @@ export async function PUT(request: Request) {
128129
});
129130
}
130131

132+
case 'duplicateProfile': {
133+
if (!body.profileId || typeof body.profileId !== 'string') {
134+
return NextResponse.json(
135+
{ error: 'profileId is required' },
136+
{ status: 400 }
137+
);
138+
}
139+
const newProfile = duplicateProfile(body.profileId);
140+
const config = loadFullConfig();
141+
return NextResponse.json({
142+
profile: {
143+
...newProfile,
144+
apiToken: maskToken(newProfile.apiToken),
145+
hasToken: !!newProfile.apiToken,
146+
},
147+
config: {
148+
...config,
149+
profiles: maskProfiles(config.profiles),
150+
},
151+
});
152+
}
153+
131154
case 'deleteProfile': {
132155
if (!body.profileId || typeof body.profileId !== 'string') {
133156
return NextResponse.json(

‎src/app/api/jobs/[id]/route.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,18 @@ export async function GET(
7171
completedAt: new Date().toISOString(),
7272
});
7373
} else {
74+
// A queued job (pending/in_queue/waiting) stays 'pending' (waiting) — it
75+
// only becomes 'processing' once a worker actually starts. This keeps the
76+
// fallback poll consistent with the WebSocket path.
77+
const jobStatus =
78+
status === 'pending' ||
79+
status === 'queued' ||
80+
status === 'in_queue' ||
81+
status === 'waiting'
82+
? 'pending'
83+
: 'processing';
7484
updateJob(job.id, {
75-
status: 'processing',
85+
status: jobStatus,
7686
rawResponse: data,
7787
});
7888
}

‎src/app/api/proxy/route.ts‎

Lines changed: 81 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import { Job, JsonValue, UploadedFile } from '@/lib/types';
77

88
// POST /api/proxy - Proxy request to deAPI
99
export async function POST(request: Request) {
10+
// Track a persisted job so the outer catch can mark it failed instead of
11+
// leaving a pre-created 'sending' stub stuck forever on an unexpected error.
12+
let persistedJobId: string | undefined;
1013
try {
1114
const contentType = request.headers.get('content-type') || '';
1215
let endpointId: string;
@@ -187,8 +190,20 @@ export async function POST(request: Request) {
187190
});
188191
}
189192

190-
const priceData = await priceResponse.json();
191-
console.log('[deapi-tester] Price response:', priceResponse.status, priceData);
193+
// Parse defensively — a non-JSON price response (e.g. HTML error page)
194+
// should not crash the whole request, just skip the estimate.
195+
const priceText = await priceResponse.text();
196+
let priceData;
197+
try {
198+
priceData = JSON.parse(priceText);
199+
} catch {
200+
priceData = null;
201+
}
202+
console.log(
203+
'[deapi-tester] Price response:',
204+
priceResponse.status,
205+
priceData ?? priceText.slice(0, 300)
206+
);
192207
// Most price endpoints return { data: { price } }; prompt-enhancement
193208
// returns a top-level { price }.
194209
const price = priceData?.data?.price ?? priceData?.price;
@@ -214,7 +229,9 @@ export async function POST(request: Request) {
214229

215230
// Create or reuse the job entry before making the request. When the client
216231
// pre-created a 'sending' stub (providedJobId), update it in place so the row
217-
// transitions sending -> pending without duplicating.
232+
// transitions sending -> pending without duplicating. Price-only checks are a
233+
// throwaway pre-calculation — there is no request to restore/duplicate to —
234+
// so they are NOT persisted to history (all job writes are skipped below).
218235
const jobId = providedJobId || generateJobId();
219236
// Store the actual API path (without leading slash) as endpointId
220237
const jobEndpointId = targetPath.replace(/^\//, '');
@@ -234,15 +251,18 @@ export async function POST(request: Request) {
234251
createdAt: new Date().toISOString(),
235252
costCredits: estimatedPrice,
236253
};
237-
if (providedJobId) {
238-
// Preserve the stub's original createdAt (set when the user clicked Execute).
239-
const jobUpdate: Partial<Job> = { ...job };
240-
delete jobUpdate.createdAt;
241-
const updated = updateJob(jobId, jobUpdate);
242-
// Stub missing (e.g. cleared before the proxy ran) — fall back to creating it.
243-
if (!updated) addJob(job);
244-
} else {
245-
addJob(job);
254+
if (!isPriceCalc) {
255+
if (providedJobId) {
256+
// Preserve the stub's original createdAt (set when the user clicked Execute).
257+
const jobUpdate: Partial<Job> = { ...job };
258+
delete jobUpdate.createdAt;
259+
const updated = updateJob(jobId, jobUpdate);
260+
// Stub missing (e.g. cleared before the proxy ran) — fall back to creating it.
261+
if (!updated) addJob(job);
262+
} else {
263+
addJob(job);
264+
}
265+
persistedJobId = jobId;
246266
}
247267

248268
// Make request to deAPI
@@ -256,7 +276,21 @@ export async function POST(request: Request) {
256276

257277
clearTimeout(timeoutId);
258278

259-
const rawResponse = await response.json();
279+
// deAPI (or a gateway/proxy in front of it) can return a non-JSON body on
280+
// errors — an HTML 500/502 page, a plain string. response.json() would throw
281+
// a cryptic "Unexpected token '<'". Read the body as text and parse
282+
// defensively so we surface the real HTTP status + a body snippet instead.
283+
const responseText = await response.text();
284+
let rawResponse;
285+
try {
286+
rawResponse = JSON.parse(responseText);
287+
} catch {
288+
rawResponse = {
289+
error: `Non-JSON response from API (HTTP ${response.status})`,
290+
status: response.status,
291+
body: responseText.slice(0, 4000),
292+
};
293+
}
260294

261295
// Capture response headers so the UI can optionally display them
262296
const rawResponseHeaders: Record<string, string> = {};
@@ -266,13 +300,15 @@ export async function POST(request: Request) {
266300

267301
// Update job with response
268302
if (!response.ok) {
269-
updateJob(jobId, {
270-
rawResponse,
271-
rawResponseHeaders,
272-
status: 'failed',
273-
error: rawResponse.error || rawResponse.message || `HTTP ${response.status}`,
274-
completedAt: new Date().toISOString(),
275-
});
303+
if (!isPriceCalc) {
304+
updateJob(jobId, {
305+
rawResponse,
306+
rawResponseHeaders,
307+
status: 'failed',
308+
error: rawResponse.error || rawResponse.message || `HTTP ${response.status}`,
309+
completedAt: new Date().toISOString(),
310+
});
311+
}
276312

277313
return NextResponse.json({
278314
success: false,
@@ -285,12 +321,20 @@ export async function POST(request: Request) {
285321

286322
// For async endpoints, extract request_id
287323
if (endpoint.isAsync && rawResponse.data?.request_id) {
288-
updateJob(jobId, {
289-
requestId: rawResponse.data.request_id,
290-
rawResponse,
291-
rawResponseHeaders,
292-
status: 'processing',
293-
});
324+
// A request_id means the job was accepted and QUEUED — not yet being
325+
// computed. Mark it 'pending' (waiting in queue), not 'processing'; the
326+
// WebSocket / reconciliation poll flips it to 'processing' once a worker
327+
// actually starts, then to completed/failed. This avoids the misleading
328+
// "processing" flash (and the processing -> pending regression) right after
329+
// submit.
330+
if (!isPriceCalc) {
331+
updateJob(jobId, {
332+
requestId: rawResponse.data.request_id,
333+
rawResponse,
334+
rawResponseHeaders,
335+
status: 'pending',
336+
});
337+
}
294338

295339
return NextResponse.json({
296340
success: true,
@@ -319,7 +363,7 @@ export async function POST(request: Request) {
319363
} else if (rawResponse.data?.balance !== undefined) {
320364
syncUpdateData.costCredits = rawResponse.data.balance;
321365
}
322-
updateJob(jobId, syncUpdateData);
366+
if (!isPriceCalc) updateJob(jobId, syncUpdateData);
323367

324368
return NextResponse.json({
325369
success: true,
@@ -332,6 +376,16 @@ export async function POST(request: Request) {
332376
} catch (error) {
333377
console.error('[deapi-tester] POST /api/proxy error:', error);
334378
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
379+
// Don't leave a pre-created job stuck (e.g. in 'sending'/'pending') when the
380+
// request throws before a response was recorded — mark it failed so the UI
381+
// reflects the error instead of spinning forever.
382+
if (persistedJobId) {
383+
updateJob(persistedJobId, {
384+
status: 'failed',
385+
error: errorMessage,
386+
completedAt: new Date().toISOString(),
387+
});
388+
}
335389
return NextResponse.json(
336390
{ error: errorMessage },
337391
{ status: 500 }
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { NextResponse } from 'next/server';
2+
import { loadFullConfig } from '@/lib/config';
3+
4+
// POST /api/test-connection - Verify a profile's API URL + token reach a live
5+
// deAPI by hitting /models. Used by the settings drawer BEFORE saving a profile,
6+
// so a typo'd URL or a bad/missing token is caught (and can be confirmed) up
7+
// front instead of only surfacing later as a failed models/balance load.
8+
//
9+
// Body: { apiUrl, apiToken?, profileId? }. The token is resolved server-side:
10+
// an explicit non-empty apiToken wins; otherwise the stored token for profileId
11+
// is used — the browser only ever sees a masked token, so when editing an
12+
// existing profile without retyping the token it must fall back to the stored
13+
// one. The token is never echoed back.
14+
//
15+
// Always responds HTTP 200; the connection result is the JSON body's `ok` field.
16+
export async function POST(request: Request) {
17+
try {
18+
const body = await request.json();
19+
const apiUrl: string = typeof body.apiUrl === 'string' ? body.apiUrl.trim() : '';
20+
const providedToken: string = typeof body.apiToken === 'string' ? body.apiToken : '';
21+
const profileId: string | undefined =
22+
typeof body.profileId === 'string' ? body.profileId : undefined;
23+
24+
if (!apiUrl) {
25+
return NextResponse.json({ ok: false, status: 0, error: 'API URL is required' });
26+
}
27+
28+
// Resolve the token to test with (explicit token wins, else stored profile token).
29+
let token = providedToken.trim();
30+
if (!token && profileId) {
31+
const cfg = loadFullConfig();
32+
token = cfg.profiles.find((p) => p.id === profileId)?.apiToken || '';
33+
}
34+
if (!token) {
35+
return NextResponse.json({ ok: false, status: 0, error: 'No API token to test with' });
36+
}
37+
38+
const url = apiUrl.replace(/\/$/, '') + '/models';
39+
const controller = new AbortController();
40+
const timeoutId = setTimeout(() => controller.abort(), 15000);
41+
42+
let res: Response;
43+
try {
44+
res = await fetch(url, {
45+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
46+
signal: controller.signal,
47+
});
48+
} catch (err) {
49+
clearTimeout(timeoutId);
50+
const message = err instanceof Error ? err.message : 'Request failed';
51+
return NextResponse.json({ ok: false, status: 0, error: `Cannot reach API: ${message}` });
52+
}
53+
clearTimeout(timeoutId);
54+
55+
if (!res.ok) {
56+
// The body may be non-JSON (e.g. an HTML 404 page); extract a message best-effort.
57+
const text = await res.text();
58+
let apiError: string | undefined;
59+
try {
60+
const json = JSON.parse(text);
61+
apiError = json?.error || json?.message;
62+
} catch {
63+
apiError = undefined;
64+
}
65+
return NextResponse.json({
66+
ok: false,
67+
status: res.status,
68+
error: apiError || `HTTP ${res.status}`,
69+
});
70+
}
71+
72+
return NextResponse.json({ ok: true, status: res.status });
73+
} catch (error) {
74+
const message = error instanceof Error ? error.message : 'Unknown error';
75+
return NextResponse.json({ ok: false, status: 0, error: message });
76+
}
77+
}

0 commit comments

Comments
 (0)