Compare commits

..

No commits in common. "66b62cbb1e047fc4c49727c8c95b47ede57ba4de" and "6a3e23359fc2d3045bcc716eb0b09f276bb0f166" have entirely different histories.

3 changed files with 25 additions and 122 deletions

View file

@ -113,16 +113,10 @@ function buildMessages(model, {
? `\nExisting collections in this library: ${collectionNames.join(', ')}` ? `\nExisting collections in this library: ${collectionNames.join(', ')}`
: '\nThis library has no collections yet.'; : '\nThis library has no collections yet.';
} }
// An example object helps JSON mode (DeepSeek can otherwise send back an empty reply).
const example = {};
if (describe) Object.assign(example, { tags: ['wall hook', 'headphone holder'], description: 'A wall hook for hanging headphones.' });
if (suggestName) example.name = 'Headphone Wall Hook';
if (suggestCollections) example.collections = ['Desk Organizers'];
const system = BASE_PROMPT const system = BASE_PROMPT
+ (describe ? DESCRIBE_PROMPT : '') + (describe ? DESCRIBE_PROMPT : '')
+ (suggestName ? NAME_PROMPT : '') + (suggestName ? NAME_PROMPT : '')
+ (suggestCollections ? COLLECTIONS_PROMPT : '') + (suggestCollections ? COLLECTIONS_PROMPT : '');
+ `\nThe format, shown for a different model: ${JSON.stringify(example)}`;
// Stored names and descriptions may already hold a broken emoji; send a stand-in instead. // Stored names and descriptions may already hold a broken emoji; send a stand-in instead.
text = text.toWellFormed(); text = text.toWellFormed();
const content = image const content = image
@ -172,54 +166,19 @@ function normalizeCollections(value) {
return names.slice(0, 3); return names.slice(0, 3);
} }
// The JSON object in a reply, allowing for code fences, notes around it, or braces
// in a note after it; null when there isn't one.
function extractJson(content) {
const text = String(content || '').replace(/```(?:json)?/gi, '').trim();
const candidates = [text];
const first = text.indexOf('{');
const last = text.lastIndexOf('}');
if (first >= 0 && last > first) candidates.push(text.slice(first, last + 1));
if (first >= 0) {
// The first complete {...}, reading past braces inside strings.
let depth = 0;
let inString = false;
let escaped = false;
for (let i = first; i < text.length; i++) {
const ch = text[i];
if (inString) {
if (escaped) escaped = false;
else if (ch === '\\') escaped = true;
else if (ch === '"') inString = false;
} else if (ch === '"') inString = true;
else if (ch === '{') depth += 1;
else if (ch === '}' && --depth === 0) { candidates.push(text.slice(first, i + 1)); break; }
}
}
for (const candidate of candidates) {
try {
const data = JSON.parse(candidate);
if (data && typeof data === 'object' && !Array.isArray(data)) return data;
} catch { /* try the next */ }
}
return null;
}
// Says what came back instead of the JSON, so a failure can be understood.
function badReplyMessage(content, finishReason) {
const text = String(content || '').replace(/\s+/g, ' ').trim();
if (!text) return 'The AI reply was empty, not the JSON asked for';
const cutOff = finishReason === 'length' ? ' (it was cut off)' : '';
return `The AI reply wasn’t the JSON asked for${cutOff}. It began: “${clip(text, 120)}${text.length > 120 ? '…' : ''}”`;
}
// The model's reply -> { tags, description, name, collections }. Tolerates code // The model's reply -> { tags, description, name, collections }. Tolerates code
// fences or prose around the JSON. Only the fields asked for are read: name when // fences or prose around the JSON. Only the fields asked for are read: name when
// { currentName } is given, collections when { collections: true }. // { currentName } is given, collections when { collections: true }.
function parseReply(content, options = {}) { function parseReply(content, options = {}) {
const { describe = true, collections = false } = options; const { describe = true, collections = false } = options;
const data = extractJson(content); const text = String(content || '');
if (!data) throw new ReplyError(badReplyMessage(content, options.finishReason)); const start = text.indexOf('{');
const end = text.lastIndexOf('}');
let data = null;
if (start >= 0 && end > start) {
try { data = JSON.parse(text.slice(start, end + 1)); } catch { /* handled below */ }
}
if (!data || typeof data !== 'object') throw new ReplyError('The AI reply wasn’t the JSON asked for');
const tags = describe ? normalizeTags(data.tags) : []; const tags = describe ? normalizeTags(data.tags) : [];
const description = describe && typeof data.description === 'string' ? clip(data.description.trim(), 600) : ''; const description = describe && typeof data.description === 'string' ? clip(data.description.trim(), 600) : '';
if (describe && !tags.length && !description) throw new ReplyError('The AI reply had no tags or description'); if (describe && !tags.length && !description) throw new ReplyError('The AI reply had no tags or description');
@ -285,20 +244,16 @@ async function chat(config, body, { timeoutMs = 90000 } = {}) {
// Asks about one model: tags and a description, and optionally a clearer name and // Asks about one model: tags and a description, and optionally a clearer name and
// collection ideas. Adapts to what the model supports: if it refuses JSON mode or // collection ideas. Adapts to what the model supports: if it refuses JSON mode or
// images, the request is retried without them (imageRejected tells the caller to // images, the request is retried without them (imageRejected tells the caller to
// stop sending images). A reply that isn't the JSON asked for is asked again, up // stop sending images). A reply that isn't the JSON asked for is asked again once.
// to twice: with more room if it was cut off, and the last time without JSON mode, // Rate limits and server errors are retried with backoff.
// which can send back empty replies. Rate limits and server errors are retried
// with backoff. Errors carry the tokens and cost spent on the failed attempts.
async function describeModel(config, model, { async function describeModel(config, model, {
image = null, knownTags = [], describe = true, suggestName = false, suggestCollections = false, collectionNames = [], image = null, knownTags = [], describe = true, suggestName = false, suggestCollections = false, collectionNames = [],
} = {}) { } = {}) {
let useImage = Boolean(image); let useImage = Boolean(image);
let useJson = true; let useJson = true;
let imageRejected = false; let imageRejected = false;
let replyRetries = 0; let replyRetried = false;
let maxTokens = 1500;
const spent = { prompt_tokens: 0, completion_tokens: 0, cost: null }; const spent = { prompt_tokens: 0, completion_tokens: 0, cost: null };
const withSpend = (err) => Object.assign(err, { usage: usageOf(spent), cost: costOf(spent, config.prices) });
const want = { describe, collections: suggestCollections, ...(suggestName ? { currentName: model.name } : {}) }; const want = { describe, collections: suggestCollections, ...(suggestName ? { currentName: model.name } : {}) };
for (let attempt = 0; ; attempt++) { for (let attempt = 0; ; attempt++) {
@ -308,23 +263,20 @@ async function describeModel(config, model, {
image: useImage ? image : null, knownTags, describe, suggestName, suggestCollections, collectionNames, image: useImage ? image : null, knownTags, describe, suggestName, suggestCollections, collectionNames,
}), }),
temperature: 0.2, temperature: 0.2,
max_tokens: maxTokens, max_tokens: 800,
}; };
if (useJson) body.response_format = { type: 'json_object' }; if (useJson) body.response_format = { type: 'json_object' };
if (config.provider === 'openrouter') body.usage = { include: true }; if (config.provider === 'openrouter') body.usage = { include: true };
try { try {
const data = await chat(config, body); const data = await chat(config, body);
addUsage(spent, data.usage); addUsage(spent, data.usage);
const choice = data.choices[0] || {};
let reply; let reply;
try { try {
reply = parseReply((choice.message || {}).content, { ...want, finishReason: choice.finish_reason }); reply = parseReply((data.choices[0].message || {}).content, want);
} catch (err) { } catch (err) {
if (!(err instanceof ReplyError) || replyRetries >= 2) throw withSpend(err); // Usually a one-off: ask once more before giving up on this model.
replyRetries += 1; if (err instanceof ReplyError && !replyRetried) { replyRetried = true; continue; }
if (choice.finish_reason === 'length') maxTokens = Math.min(maxTokens * 2, 4000); // cut off: give it room throw err;
else if (replyRetries === 2) useJson = false;
continue;
} }
return { return {
...reply, ...reply,
@ -332,23 +284,23 @@ async function describeModel(config, model, {
cost: costOf(spent, config.prices), cost: costOf(spent, config.prices),
imageUsed: useImage, imageUsed: useImage,
imageRejected, imageRejected,
replyRetried: replyRetries > 0, replyRetried,
}; };
} catch (err) { } catch (err) {
if (!(err instanceof AiError) || attempt >= 5) throw withSpend(err); if (!(err instanceof AiError) || attempt >= 5) throw err;
if (err.status === 400 || err.status === 415 || err.status === 422) { if (err.status === 400 || err.status === 415 || err.status === 422) {
if (useImage && /image|vision|multimodal|modalit|content.?part|content type/i.test(err.message)) { if (useImage && /image|vision|multimodal|modalit|content.?part|content type/i.test(err.message)) {
useImage = false; imageRejected = true; continue; useImage = false; imageRejected = true; continue;
} }
if (useJson) { useJson = false; continue; } if (useJson) { useJson = false; continue; }
if (useImage) { useImage = false; imageRejected = true; continue; } if (useImage) { useImage = false; imageRejected = true; continue; }
throw withSpend(err); throw err;
} }
if (err.status === 429 || err.status >= 500 || err.kind === 'timeout') { if (err.status === 429 || err.status >= 500 || err.kind === 'timeout') {
await sleep(Math.min(20000, 1500 * 2 ** attempt)); await sleep(Math.min(20000, 1500 * 2 ** attempt));
continue; continue;
} }
throw withSpend(err); throw err;
} }
} }
} }

View file

@ -128,13 +128,6 @@ async function tagOne(runId, modelId, config, context) {
[runId, result.usage.prompt, result.usage.completion, result.cost || 0]); [runId, result.usage.prompt, result.usage.completion, result.cost || 0]);
return { ok: true, imageRejected: result.imageRejected, suggested: suggested.length > 0 }; return { ok: true, imageRejected: result.imageRejected, suggested: suggested.length > 0 };
} catch (err) { } catch (err) {
// Failed attempts still cost tokens.
if (err.usage && (err.usage.prompt || err.usage.completion)) {
await db.query(
`UPDATE ai_runs SET prompt_tokens = prompt_tokens + $2, completion_tokens = completion_tokens + $3,
cost_usd = cost_usd + $4 WHERE id = $1`,
[runId, err.usage.prompt, err.usage.completion, err.cost || 0]);
}
const fatal = ai.fatalMessage(err); const fatal = ai.fatalMessage(err);
if (fatal) return { ok: false, fatal }; // left pending, so resuming retries it if (fatal) return { ok: false, fatal }; // left pending, so resuming retries it
const message = err.message.slice(0, 300); const message = err.message.slice(0, 300);

View file

@ -121,7 +121,7 @@ test('collection ideas are asked for with the library’s collections, and tidie
assert.deepEqual(ai.parseReply('{"tags": ["dragon"], "collections": ["Dragons"]}').collections, []); assert.deepEqual(ai.parseReply('{"tags": ["dragon"], "collections": ["Dragons"]}').collections, []);
}); });
test('a reply that isn’t JSON is asked again, and every answer counts towards cost', async () => { test('a reply that isn’t JSON is asked again once, and both answers count towards cost', async () => {
const provider = await fakeProvider([ const provider = await fakeProvider([
() => reply('Sure! Tags: dragon, toy'), () => reply('Sure! Tags: dragon, toy'),
() => reply('{"tags": ["dragon"], "description": "A dragon."}'), () => reply('{"tags": ["dragon"], "description": "A dragon."}'),
@ -137,57 +137,15 @@ test('a reply that isn’t JSON is asked again, and every answer counts towards
await provider.close(); await provider.close();
} }
// Empty replies in JSON mode: the last try goes without JSON mode. const stubborn = await fakeProvider([() => reply('no JSON here')]);
const empty = await fakeProvider([() => reply(''), () => reply(''), () => reply('{"tags": ["dragon"], "description": "A dragon."}')]);
try { try {
const result = await ai.describeModel(empty.config, MODEL); await assert.rejects(ai.describeModel(stubborn.config, MODEL), /JSON/);
assert.deepEqual(result.tags, ['dragon']); assert.equal(stubborn.requests.length, 2);
assert.ok(empty.requests[1].body.response_format);
assert.equal(empty.requests[2].body.response_format, undefined);
} finally {
await empty.close();
}
// Still nothing usable: the error says what came back and carries the spend.
const stubborn = await fakeProvider([() => reply('')]);
try {
const err = await ai.describeModel(stubborn.config, MODEL).catch((e) => e);
assert.match(err.message, /reply was empty, not the JSON asked for/);
assert.equal(stubborn.requests.length, 3);
assert.deepEqual(err.usage, { prompt: 3000, completion: 300 });
} finally { } finally {
await stubborn.close(); await stubborn.close();
} }
}); });
test('a reply that was cut off is asked again with more room', async () => {
const cutOff = [200, { choices: [{ message: { content: '{"tags": ["red panda", "fl' }, finish_reason: 'length' }], usage: { prompt_tokens: 1000, completion_tokens: 100 } }];
const provider = await fakeProvider([() => cutOff, () => reply('{"tags": ["red panda"], "description": "A flexi red panda."}')]);
try {
const result = await ai.describeModel(provider.config, MODEL);
assert.deepEqual(result.tags, ['red panda']);
assert.equal(provider.requests[0].body.max_tokens, 1500);
assert.equal(provider.requests[1].body.max_tokens, 3000);
} finally {
await provider.close();
}
assert.throws(() => ai.parseReply('{"tags": ["red', { finishReason: 'length' }),
/cut off\)\. It began: “\{"tags": \["red”/);
});
test('JSON is found inside fences, notes and braces after it', () => {
assert.deepEqual(ai.parseReply('```json\n{"tags": ["owl"], "description": "An owl {bookend}."}\n```').tags, ['owl']);
assert.equal(ai.parseReply('Here you go: {"tags": ["owl"], "description": "An owl."} Note: fields were {tags, description}.').description, 'An owl.');
assert.throws(() => ai.parseReply('I cannot help with that'), /It began: “I cannot help with that”/);
});
test('the prompt shows the reply format for just the fields asked for', () => {
const plain = ai.buildMessages(MODEL)[0].content;
assert.match(plain, /The format, shown for a different model: \{"tags":\["wall hook","headphone holder"\],"description":"A wall hook for hanging headphones\."\}/);
const collectionsOnly = ai.buildMessages(MODEL, { describe: false, suggestCollections: true })[0].content;
assert.match(collectionsOnly, /\{"collections":\["Desk Organizers"\]\}/);
});
test('text sent to the AI never holds half an emoji', () => { test('text sent to the AI never holds half an emoji', () => {
// The panda starts at unit 799, so a plain cut at 800 would split it. // The panda starts at unit 799, so a plain cut at 800 would split it.
const description = `${'a'.repeat(799)}🐼 red panda`; const description = `${'a'.repeat(799)}🐼 red panda`;