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
56 changes: 42 additions & 14 deletions src/app/api/models/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ import { loadConfig } from '@/lib/config';
// Force dynamic to prevent caching - config can change
export const dynamic = 'force-dynamic';

// deAPI paginates /models: default 25 per page, `limit` is capped at 50 server-side.
// Fetch every page so the models cache is always complete.
const PAGE_LIMIT = 50;
const MAX_PAGES = 20;

interface ModelsPage {
data?: unknown[];
meta?: { current_page?: number; last_page?: number; total?: number };
}

// GET /api/models - Proxy to deAPI /models endpoint
export async function GET() {
try {
Expand All @@ -16,24 +26,42 @@ export async function GET() {
);
}

const url = `${config.apiUrl.replace(/\/$/, '')}/models?per_page=100`;
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${config.apiToken}`,
'Accept': 'application/json',
},
});
const baseUrl = `${config.apiUrl.replace(/\/$/, '')}/models`;
const models: unknown[] = [];
let lastMeta: ModelsPage['meta'];
let page = 1;
let lastPage = 1;

const data = await response.json();
while (page <= lastPage && page <= MAX_PAGES) {
const url = `${baseUrl}?limit=${PAGE_LIMIT}&page=${page}`;
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${config.apiToken}`,
'Accept': 'application/json',
},
});

if (!response.ok) {
return NextResponse.json(
{ error: data.error || data.message || `HTTP ${response.status}` },
{ status: response.status }
);
const data = await response.json();

if (!response.ok) {
return NextResponse.json(
{ error: data.error || data.message || `HTTP ${response.status}` },
{ status: response.status }
);
}

const pageData = data as ModelsPage;
models.push(...(pageData.data ?? []));
lastMeta = pageData.meta;
lastPage = pageData.meta?.last_page ?? 1;
page += 1;
}

if (page > MAX_PAGES && page <= lastPage) {
console.warn(`[deapi-tester] /models pagination stopped at ${MAX_PAGES} pages (last_page=${lastPage})`);
}

return NextResponse.json(data, {
return NextResponse.json({ data: models, meta: lastMeta }, {
headers: {
'Cache-Control': 'no-store, no-cache, must-revalidate',
},
Expand Down
57 changes: 57 additions & 0 deletions src/app/api/result/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { NextResponse } from 'next/server';

// Transcription results are text/JSON files on (often internal or presigned)
// storage that the browser cannot fetch directly, so the preview goes through
// the server like every other deAPI call. Capped so a huge transcript can never
// blow up the UI.
const MAX_BYTES = 4 * 1024 * 1024;

// GET /api/result?url=... — fetch a text/JSON result file for in-app preview.
export async function GET(request: Request) {
const url = new URL(request.url).searchParams.get('url');

if (!url) {
return NextResponse.json({ error: 'Missing url parameter' }, { status: 400 });
}

let target: URL;
try {
target = new URL(url);
} catch {
return NextResponse.json({ error: 'Invalid url parameter' }, { status: 400 });
}
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
return NextResponse.json({ error: 'Only http(s) URLs are supported' }, { status: 400 });
}

try {
const response = await fetch(target, { cache: 'no-store' });
if (!response.ok) {
return NextResponse.json(
{ error: `Failed to fetch result: HTTP ${response.status}` },
{ status: 502 }
);
}

const buffer = Buffer.from(await response.arrayBuffer());
const truncated = buffer.length > MAX_BYTES;

return NextResponse.json({
contentType: response.headers.get('content-type') || '',
size: buffer.length,
truncated,
text: buffer.subarray(0, MAX_BYTES).toString('utf-8'),
});
} catch (error) {
console.error('[deapi-tester] GET /api/result error:', error);
// fetch() reports connection problems as a bare "fetch failed"; name the host
// so an unreachable result URL (e.g. an internal storage hostname that only
// resolves inside the API's network) is obvious rather than mysterious.
const cause = error instanceof Error && error.cause instanceof Error ? error.cause.message : null;
const message = error instanceof Error ? error.message : 'Unknown error';
return NextResponse.json(
{ error: `${message} (${target.host})${cause ? ` — ${cause}` : ''}` },
{ status: 502 }
);
}
}
15 changes: 9 additions & 6 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ interface ProxyResponse {

export default function Home() {
const { showError, showSuccess } = useToast();
const { balance } = useBalance();
const { balance, refreshBalance, isLoading: balanceLoading } = useBalance();
const { resolvedTheme, toggleTheme } = useTheme();
const { refreshModels, isLoading: modelsLoading } = useModelsContext();
const jobsPanelRef = useRef<JobsPanelRef>(null);
Expand Down Expand Up @@ -143,14 +143,17 @@ export default function Home() {
</div>
)}

{/* Refresh models */}
{/* Refresh models + balance */}
<button
onClick={() => refreshModels()}
disabled={modelsLoading}
onClick={() => {
refreshModels();
refreshBalance();
}}
disabled={modelsLoading || balanceLoading}
className="p-1.5 text-[var(--muted)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-2)] rounded transition-colors"
title="Refresh models"
title="Refresh models & balance"
>
<RefreshCw className={`w-4 h-4 ${modelsLoading ? 'animate-spin' : ''}`} />
<RefreshCw className={`w-4 h-4 ${modelsLoading || balanceLoading ? 'animate-spin' : ''}`} />
</button>

{/* Theme toggle */}
Expand Down
129 changes: 99 additions & 30 deletions src/components/EndpointForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,30 @@ export function EndpointForm({ endpoint, prefill, onSubmit, onPriceCheck, isSubm
});
}, [selectedModelSlug, selectedModel, resolveLangSlug, resolveVoiceSlug]);

// Keep model-driven selects (e.g. ts_level) on a value the selected model
// actually offers — switching to a model with a shorter list would otherwise
// keep a value the API rejects.
useEffect(() => {
const limits = (modelLimits ?? {}) as Record<string, unknown>;
const modelDriven = endpoint.params.filter((p) => p.optionsFromModel);
if (modelDriven.length === 0) return;

setValues((prev) => {
let changed = false;
const next = { ...prev };
modelDriven.forEach((param) => {
const list = limits[param.optionsFromModel as string];
if (!Array.isArray(list) || list.length === 0) return;
const allowed = list.map((v) => String(v));
if (!allowed.includes(String(next[param.name] ?? ''))) {
next[param.name] = allowed[0];
changed = true;
}
});
return changed ? next : prev;
});
}, [modelLimits, endpoint.params]);

// Apply a "duplicate request" prefill — load params from a history job into the form.
// Declared after the init / auto-select / auto-default effects so it runs last and its
// values win. Keyed on a one-shot nonce so it never re-applies on later re-renders.
Expand Down Expand Up @@ -567,6 +591,20 @@ export function EndpointForm({ endpoint, prefill, onSubmit, onPriceCheck, isSubm
return selectedModel.languages.map((l) => ({ value: l.slug, label: l.name }));
}

// Options published by the model itself, e.g. `info.limits.timestamp_levels`.
const fromModel = endpoint.params.find((p) => p.name === paramName)?.optionsFromModel;
if (fromModel) {
const limits = (modelLimits ?? {}) as Record<string, unknown>;
const list = limits[fromModel];
if (Array.isArray(list) && list.length > 0) {
return list.map((v) => {
const value = String(v);
return { value, label: value.charAt(0).toUpperCase() + value.slice(1) };
});
}
return undefined;
}

if (paramName === 'voice' && selectedModel.languages) {
const selectedLang = values['lang'] as string;
// Also try matching by name (API defaults may use name instead of slug)
Expand All @@ -584,24 +622,48 @@ export function EndpointForm({ endpoint, prefill, onSubmit, onPriceCheck, isSubm
}

return undefined;
}, [endpoint.id, endpoint.inferenceType, models, selectedModel, values, enhancementInferenceType]);
}, [endpoint.id, endpoint.inferenceType, endpoint.params, models, selectedModel, modelLimits, values, enhancementInferenceType]);

// Whether the selected model publishes a given capability. The key is looked up
// in `info.features` first, then `info.limits`; an empty array counts as "not
// supported" (the API validates the field against the published list).
const modelPublishesCapability = useCallback((key: string): boolean => {
const features = (modelFeatures ?? {}) as Record<string, unknown>;
if (key in features) return features[key] === true;
const limits = (modelLimits ?? {}) as Record<string, unknown>;
if (key in limits) {
const value = limits[key];
return Array.isArray(value) ? value.length > 0 : Boolean(value);
}
return false;
}, [modelFeatures, modelLimits]);

// Check if a field should be visible based on visibleWhen condition
// Check if a field should be visible: model capability gate first, then the
// value-based visibleWhen condition (an array of conditions means OR).
const isFieldVisible = useCallback((param: EndpointParam): boolean => {
if (!param.visibleWhen) return true;
const currentValue = values[param.visibleWhen.field];
if (param.visibleWhen.matchEmpty && (currentValue === null || currentValue === undefined || currentValue === '')) {
return true;
if (param.visibleFromModel && !modelPublishesCapability(param.visibleFromModel)) {
return false;
}
return param.visibleWhen.values.includes(currentValue as string);
}, [values]);
if (!param.visibleWhen) return true;

const conditions = Array.isArray(param.visibleWhen) ? param.visibleWhen : [param.visibleWhen];
return conditions.some((condition) => {
const currentValue = values[condition.field];
if (condition.matchEmpty && (currentValue === null || currentValue === undefined || currentValue === '')) {
return true;
}
// Booleans are stored as real booleans; compare on their string form so a
// condition can be written as values: ['true'].
return condition.values.includes(String(currentValue));
});
}, [values, modelPublishesCapability]);

const buildFilteredValues = useCallback((): Record<string, JsonValue> => {
const filteredValues: Record<string, JsonValue> = {};
Object.entries(values).forEach(([key, value]) => {
// Exclude hidden fields from payload
// Exclude hidden fields from payload (value-gated or model-capability-gated)
const param = endpoint.params.find(p => p.name === key);
if (param?.visibleWhen && !isFieldVisible(param)) return;
if (param && !isFieldVisible(param)) return;

if (value !== null) {
// Split into array if arrayMode is on for this field
Expand Down Expand Up @@ -734,7 +796,7 @@ export function EndpointForm({ endpoint, prefill, onSubmit, onPriceCheck, isSubm

Object.entries(files).forEach(([key, fileOrFiles]) => {
const param = endpoint.params.find((p) => p.name === key);
if (param?.visibleWhen && !isFieldVisible(param)) return;
if (param && !isFieldVisible(param)) return;
if (Array.isArray(fileOrFiles)) {
fileOrFiles.forEach((file) => formData.append(key, file));
} else {
Expand Down Expand Up @@ -791,7 +853,7 @@ export function EndpointForm({ endpoint, prefill, onSubmit, onPriceCheck, isSubm
Object.entries(files).forEach(([key, fileOrFiles]) => {
const param = endpoint.params.find((p) => p.name === key);
// Skip hidden file fields
if (param?.visibleWhen && !isFieldVisible(param)) return;
if (param && !isFieldVisible(param)) return;
const isMultiMode = param?.multiFieldName && multiFileMode[key];
const fieldName = isMultiMode && param?.multiFieldName ? param.multiFieldName : key;

Expand Down Expand Up @@ -982,8 +1044,8 @@ export function EndpointForm({ endpoint, prefill, onSubmit, onPriceCheck, isSubm
)}
</div>

{/* Center: Params with defaults/limits */}
{compactParams.length > 0 && (
{/* Center: Params with defaults/limits, plus toggles */}
{(compactParams.length > 0 || booleanParams.length > 0) && (
<div className="w-44 flex-shrink-0 space-y-2 overflow-y-auto border-l border-r border-[var(--border)] px-3">
<div className="space-y-2">
{compactParams.map((param) => {
Expand Down Expand Up @@ -1019,6 +1081,29 @@ export function EndpointForm({ endpoint, prefill, onSubmit, onPriceCheck, isSubm
);
})}
</div>

{booleanParams.length > 0 && (
<div className="space-y-2 pt-1">
{booleanParams.map((param) => (
<div key={param.name}>
<label
className="block text-[10px] text-[var(--muted)] mb-0.5"
title={param.description}
>
{param.label}
</label>
<FormField
param={param}
value={values[param.name]}
compact
isNullableDisabled={nullableDisabled[param.name]}
onValueChange={handleChange}
onNullableToggle={toggleNullable}
/>
</div>
))}
</div>
)}
</div>
)}

Expand Down Expand Up @@ -1059,22 +1144,6 @@ export function EndpointForm({ endpoint, prefill, onSubmit, onPriceCheck, isSubm
</div>
))}

{booleanParams.map((param) => (
<div key={param.name}>
<label className="flex items-baseline gap-1 text-[10px] text-[var(--muted)] mb-1">
{param.label}
</label>
<FormField
param={getEffectiveParam(param)}
value={values[param.name]}
compact
isNullableDisabled={nullableDisabled[param.name]}
onValueChange={handleChange}
onNullableToggle={toggleNullable}
/>
</div>
))}

{otherParams.length > 0 && (
<details className="group">
<summary className="flex items-center gap-1 text-[10px] text-[var(--muted)] cursor-pointer hover:text-[var(--text-secondary)] py-1">
Expand Down
5 changes: 5 additions & 0 deletions src/components/jobs/JobRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Job, JsonValue } from '@/lib/types';
import { STATUS_BG_COLORS } from '@/lib/constants';
import { formatTime, formatCost, formatProgress, getResultType, getResultText } from '@/lib/format-utils';
import { useSettings } from '@/components/SettingsContext';
import { JobTranscription } from '@/components/jobs/JobTranscription';

interface PollUpdate {
timestamp: number;
Expand Down Expand Up @@ -328,6 +329,10 @@ export function JobRow({
</div>
</div>

{/* Transcription result — renders only for jobs deAPI reported a
`transcription` block for (self-hiding otherwise). */}
<JobTranscription job={job} resultUrl={resultUrl} />

{/* Expanded Raw Request & Response */}
{isRawExpanded && (job.rawRequest || job.rawResponse || (showResponseHeaders && hasResponseHeaders)) && (
<div className="px-4 pb-3 space-y-2">
Expand Down
Loading
Loading