Skip to content

Commit 02f01c2

Browse files
committed
feat: model api calls refactoring
1 parent a8d1113 commit 02f01c2

File tree

5 files changed

+133
-68
lines changed

5 files changed

+133
-68
lines changed

app/src/components/model-tabs.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export class ModelTabs {
4040
// Swatch acts as an enable/disable switch
4141
const swatch = document.createElement('span');
4242
swatch.className = 'swatch';
43+
swatch.dataset.modelId = cfg.id;
4344
swatch.setAttribute('role', 'switch');
4445
swatch.setAttribute('aria-checked', String(cfg.enabled));
4546
swatch.title = cfg.enabled ? 'Enabled — click to disable' : 'Disabled — click to enable';
@@ -420,4 +421,12 @@ export class ModelTabs {
420421

421422
return card;
422423
}
424+
425+
// Toggle blinking state of a model tab's color chip while a call is in progress
426+
setModelRunning(modelId, running) {
427+
const btn = this.header.querySelector(`.tab-btn[data-model-id="${modelId}"]`);
428+
const sw = btn?.querySelector('.swatch');
429+
if (!sw) return;
430+
sw.classList.toggle('running', !!running);
431+
}
423432
}

app/src/core/api-client.js

Lines changed: 23 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { blobToDataURL, truncate } from './utils.js';
2+
import { buildRequestBody } from './providers/builder.js';
23

34
/**
45
* ApiClient
@@ -41,54 +42,18 @@ export class ApiClient {
4142
max_tokens: (endpointType === 'responses') ? undefined : maxTokens
4243
});
4344

44-
// Some providers (notably via OpenRouter) have subtle differences in multimodal payloads.
45-
// Normalize a few common variants for maximum compatibility.
46-
const isOpenRouter = /openrouter\.ai/i.test(String(baseURL || ''));
47-
const modelSlug = String(model || '').toLowerCase();
48-
const isQwenVL = /qwen/.test(modelSlug) && /vl/.test(modelSlug);
49-
50-
// For Chat API (OpenAI-style), image_url can be either object {url} or string for some providers.
51-
const imagePartChat = (isOpenRouter && isQwenVL)
52-
? { type: 'image_url', image_url: b64 }
53-
: { type: 'image_url', image_url: { url: b64 } };
54-
55-
// For Responses API (new OpenAI Responses), types should be input_text / input_image
56-
// and image_url is commonly a direct string.
57-
const textPartResponses = { type: 'input_text', text: prompt };
58-
const sysTextPartResponses = { type: 'input_text', text: sysPrompt };
59-
const imagePartResponses = { type: 'input_image', image_url: b64 };
60-
61-
let body;
62-
if (endpointType === 'responses') {
63-
body = {
64-
model,
65-
input: [
66-
{ role:'system', content:[ sysTextPartResponses ]},
67-
{ role:'user', content:[ textPartResponses, imagePartResponses ]}
68-
],
69-
// JSON-only response formatting
70-
text: { format: { type: 'json_object' } },
71-
// Azure GPT-5 compatible: use top-level max_output_tokens; omit temperature entirely
72-
max_output_tokens: maxTokens,
73-
...(reasoningEffort ? { reasoning: { effort: reasoningEffort } } : {})
74-
};
75-
} else {
76-
// chat
77-
body = {
78-
model, temperature, max_tokens: maxTokens,
79-
messages: [
80-
// Some providers expect system as a plain string; use string for Qwen via OpenRouter.
81-
isOpenRouter && isQwenVL
82-
? { role:'system', content: sysPrompt }
83-
: { role:'system', content:[{ type:'text', text: sysPrompt }]},
84-
{ role:'user', content:[
85-
{ type:'text', text: prompt },
86-
imagePartChat
87-
]}
88-
],
89-
response_format: { type:'json_object' }
90-
};
91-
}
45+
// Build provider/mode-specific body using the new builder
46+
const body = buildRequestBody({
47+
endpointType,
48+
baseURL,
49+
model,
50+
temperature,
51+
maxTokens,
52+
prompt,
53+
sysPrompt,
54+
imageB64: b64,
55+
reasoningEffort
56+
});
9257

9358
const controller = new AbortController();
9459
const to = setTimeout(() => controller.abort('timeout'), timeoutMs);
@@ -115,16 +80,17 @@ export class ApiClient {
11580
// Auto-retry for Responses when stopped by max_output_tokens
11681
if (endpointType === 'responses' && j && j.status === 'incomplete' && j.incomplete_details?.reason === 'max_output_tokens') {
11782
const increased = Math.min(Math.max(Number(maxTokens) || 300, 300) * 2, 4096);
118-
const retryBody = {
83+
const retryBody = buildRequestBody({
84+
endpointType,
85+
baseURL,
11986
model,
120-
input: [
121-
{ role:'system', content:[ sysTextPartResponses ]},
122-
{ role:'user', content:[ textPartResponses, imagePartResponses ]}
123-
],
124-
text: { format: { type: 'json_object' } },
125-
max_output_tokens: increased,
126-
...(reasoningEffort ? { reasoning: { effort: reasoningEffort } } : {})
127-
};
87+
temperature,
88+
maxTokens: increased,
89+
prompt,
90+
sysPrompt,
91+
imageB64: b64,
92+
reasoningEffort
93+
});
12894
const controller2 = new AbortController();
12995
const to2 = setTimeout(() => controller2.abort('timeout'), timeoutMs);
13096
res = await fetch(url, { method:'POST', headers, body: JSON.stringify(retryBody), signal: controller2.signal });

app/src/core/batch-runner.js

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,28 @@ export class BatchRunner {
4040
// Notify UI that a new run started so it can show a partial row immediately
4141
onRunStart?.({ batchId: batchMeta.id, runId: runMeta.id, runMeta });
4242

43+
// Set image on overlay immediately so progressive detections draw on it
44+
const ctxImage = await createImageBitmap(imageBlob, { imageOrientation: 'from-image' });
45+
this.overlay.setImage(ctxImage, imageW, imageH, imageName);
46+
4347
// Kick off parallel calls
4448
const sysTpl = this.storage?.getSystemPromptTemplate?.() || '';
49+
const partialResults = [];
50+
const updateUI = async () => {
51+
try {
52+
// Draw whatever we have so far
53+
const items = partialResults.filter(r => r.status === 'ok' && r.parsed?.primary).map(r => ({
54+
color: r.color, model: r.modelDisplayName, det: r.parsed.primary
55+
}));
56+
this.overlay.drawDetections(items);
57+
// Show partial results in the table without waiting for all
58+
this.resultsTable.showRun(runMeta, { id: runMeta.id, results: partialResults, logs: {} });
59+
} catch { /* noop */ }
60+
};
61+
4562
const promises = enabledModels.map(async m => {
63+
// mark model as running (blink tab chip)
64+
this.modelTabs?.setModelRunning?.(m.id, true);
4665
let status = 'ok', latencyMs = null, rawText = '', rawFull = undefined, parsed = null, errorMessage = undefined;
4766
const onLog = (log) => this._appendLog(runMeta.id, m.id, log);
4867
try {
@@ -69,20 +88,14 @@ export class BatchRunner {
6988
errorMessage
7089
};
7190
await this._appendResult(runMeta.id, result);
91+
partialResults.push(result);
92+
await updateUI();
93+
// clear running state for this model
94+
this.modelTabs?.setModelRunning?.(m.id, false);
7295
return result;
7396
});
74-
7597
const settled = await Promise.all(promises);
7698

77-
// Update overlay + results table
78-
const items = settled.filter(r => r.status === 'ok' && r.parsed?.primary).map(r => ({
79-
color: r.color, model: r.modelDisplayName, det: r.parsed.primary
80-
}));
81-
// Update canvas
82-
const ctxImage = await createImageBitmap(imageBlob, { imageOrientation: 'from-image' });
83-
this.overlay.setImage(ctxImage, imageW, imageH, imageName);
84-
this.overlay.drawDetections(items);
85-
8699
// Update summaries
87100
const okCount = settled.filter(r => r.status === 'ok').length;
88101
const errCount = settled.length - okCount;
@@ -100,7 +113,7 @@ export class BatchRunner {
100113
batchMeta.summary.avgLatencyMs = avgLatency != null ? (prevAvg == null ? avgLatency : Math.round((prevAvg + avgLatency)/2)) : prevAvg;
101114
await this.history.updateBatchMeta(batchMeta);
102115

103-
// Results panel reflects the current run
116+
// Final refresh of the results table using stored run data
104117
const runData = await this.history.getRunData(runMeta.id);
105118
this.resultsTable.showRun(runMeta, runData);
106119

app/src/core/providers/builder.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Provider/mode-specific request builder for model calls.
2+
// Keeps ApiClient slim by encapsulating payload differences.
3+
4+
function detectProvider(baseURL = '') {
5+
const u = String(baseURL || '').toLowerCase();
6+
if (u.includes('openrouter.ai')) return 'openrouter';
7+
try {
8+
const host = new URL(baseURL).host.toLowerCase();
9+
if (host.endsWith('.azure.com')) return 'azure';
10+
} catch {}
11+
return 'generic';
12+
}
13+
14+
function isQwenVLModel(model = '') {
15+
const m = String(model || '').toLowerCase();
16+
return /qwen/.test(m) && /vl/.test(m);
17+
}
18+
19+
function buildChatPayload(ctx) {
20+
const { model, temperature = 0, maxTokens = 2048, prompt, sysPrompt, baseURL } = ctx;
21+
const provider = detectProvider(baseURL);
22+
23+
// Some providers expect image_url as a string (OpenRouter + Qwen VL)
24+
const useStringChatImageUrl = provider === 'openrouter' && isQwenVLModel(model);
25+
const imagePartChat = useStringChatImageUrl
26+
? { type: 'image_url', image_url: ctx.imageB64 }
27+
: { type: 'image_url', image_url: { url: ctx.imageB64 } };
28+
29+
// Some providers expect system content as a plain string (OpenRouter + Qwen VL)
30+
const systemMessage = (provider === 'openrouter' && isQwenVLModel(model))
31+
? { role: 'system', content: sysPrompt }
32+
: { role: 'system', content: [{ type: 'text', text: sysPrompt }] };
33+
34+
return {
35+
model,
36+
temperature,
37+
max_tokens: maxTokens,
38+
messages: [
39+
systemMessage,
40+
{ role: 'user', content: [ { type: 'text', text: prompt }, imagePartChat ] }
41+
],
42+
response_format: { type: 'json_object' }
43+
};
44+
}
45+
46+
function buildResponsesPayload(ctx) {
47+
const { model, maxTokens = 2048, prompt, sysPrompt, reasoningEffort } = ctx;
48+
return {
49+
model,
50+
input: [
51+
{ role: 'system', content: [ { type: 'input_text', text: sysPrompt } ] },
52+
{ role: 'user', content: [ { type: 'input_text', text: prompt }, { type: 'input_image', image_url: ctx.imageB64 } ] }
53+
],
54+
// JSON-only response formatting
55+
text: { format: { type: 'json_object' } },
56+
// Azure GPT-5 compatible: top-level max_output_tokens; omit temperature entirely
57+
max_output_tokens: maxTokens,
58+
...(reasoningEffort ? { reasoning: { effort: reasoningEffort } } : {})
59+
};
60+
}
61+
62+
export function buildRequestBody({ endpointType, baseURL, model, temperature, maxTokens, prompt, sysPrompt, imageB64, reasoningEffort }) {
63+
const ctx = { endpointType, baseURL, model, temperature, maxTokens, prompt, sysPrompt, imageB64, reasoningEffort };
64+
if (endpointType === 'responses') return buildResponsesPayload(ctx);
65+
return buildChatPayload(ctx);
66+
}
67+
68+
export function detectProviderKind(baseURL) {
69+
return detectProvider(baseURL);
70+
}
71+

app/styles.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,12 @@ h2 { font-size:16px; margin:0; }
131131
box-shadow: 0 0 0 1px var(--accent) inset;
132132
}
133133
.tab-btn .swatch { width:12px; height:12px; border-radius:3px; border:2px solid #000; display:inline-block; margin-right:6px; vertical-align:middle; cursor:pointer; }
134+
.tab-btn .swatch.running { animation: chipBlink 1.1s ease-in-out infinite; box-shadow: 0 0 0 2px rgba(255,255,255,0.08) inset; }
135+
@keyframes chipBlink {
136+
0% { filter: brightness(0.8) saturate(0.9); opacity: 0.6; }
137+
50% { filter: brightness(1.3) saturate(1.1); opacity: 1; }
138+
100% { filter: brightness(0.8) saturate(0.9); opacity: 0.6; }
139+
}
134140
.tabs-body { padding: 10px; }
135141
.tab-pane { display:none; }
136142
.tab-pane.active { display:block; }

0 commit comments

Comments
 (0)