Compare commits
No commits in common. "66b62cbb1e047fc4c49727c8c95b47ede57ba4de" and "6a3e23359fc2d3045bcc716eb0b09f276bb0f166" have entirely different histories.
66b62cbb1e
...
6a3e23359f
3 changed files with 25 additions and 122 deletions
90
lib/ai.js
90
lib/ai.js
|
|
@ -113,16 +113,10 @@ function buildMessages(model, {
|
|||
? `\nExisting collections in this library: ${collectionNames.join(', ')}`
|
||||
: '\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
|
||||
+ (describe ? DESCRIBE_PROMPT : '')
|
||||
+ (suggestName ? NAME_PROMPT : '')
|
||||
+ (suggestCollections ? COLLECTIONS_PROMPT : '')
|
||||
+ `\nThe format, shown for a different model: ${JSON.stringify(example)}`;
|
||||
+ (suggestCollections ? COLLECTIONS_PROMPT : '');
|
||||
// Stored names and descriptions may already hold a broken emoji; send a stand-in instead.
|
||||
text = text.toWellFormed();
|
||||
const content = image
|
||||
|
|
@ -172,54 +166,19 @@ function normalizeCollections(value) {
|
|||
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
|
||||
// fences or prose around the JSON. Only the fields asked for are read: name when
|
||||
// { currentName } is given, collections when { collections: true }.
|
||||
function parseReply(content, options = {}) {
|
||||
const { describe = true, collections = false } = options;
|
||||
const data = extractJson(content);
|
||||
if (!data) throw new ReplyError(badReplyMessage(content, options.finishReason));
|
||||
const text = String(content || '');
|
||||
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 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');
|
||||
|
|
@ -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
|
||||
// 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
|
||||
// stop sending images). A reply that isn't the JSON asked for is asked again, up
|
||||
// to twice: with more room if it was cut off, and the last time without JSON mode,
|
||||
// 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.
|
||||
// stop sending images). A reply that isn't the JSON asked for is asked again once.
|
||||
// Rate limits and server errors are retried with backoff.
|
||||
async function describeModel(config, model, {
|
||||
image = null, knownTags = [], describe = true, suggestName = false, suggestCollections = false, collectionNames = [],
|
||||
} = {}) {
|
||||
let useImage = Boolean(image);
|
||||
let useJson = true;
|
||||
let imageRejected = false;
|
||||
let replyRetries = 0;
|
||||
let maxTokens = 1500;
|
||||
let replyRetried = false;
|
||||
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 } : {}) };
|
||||
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
|
|
@ -308,23 +263,20 @@ async function describeModel(config, model, {
|
|||
image: useImage ? image : null, knownTags, describe, suggestName, suggestCollections, collectionNames,
|
||||
}),
|
||||
temperature: 0.2,
|
||||
max_tokens: maxTokens,
|
||||
max_tokens: 800,
|
||||
};
|
||||
if (useJson) body.response_format = { type: 'json_object' };
|
||||
if (config.provider === 'openrouter') body.usage = { include: true };
|
||||
try {
|
||||
const data = await chat(config, body);
|
||||
addUsage(spent, data.usage);
|
||||
const choice = data.choices[0] || {};
|
||||
let reply;
|
||||
try {
|
||||
reply = parseReply((choice.message || {}).content, { ...want, finishReason: choice.finish_reason });
|
||||
reply = parseReply((data.choices[0].message || {}).content, want);
|
||||
} catch (err) {
|
||||
if (!(err instanceof ReplyError) || replyRetries >= 2) throw withSpend(err);
|
||||
replyRetries += 1;
|
||||
if (choice.finish_reason === 'length') maxTokens = Math.min(maxTokens * 2, 4000); // cut off: give it room
|
||||
else if (replyRetries === 2) useJson = false;
|
||||
continue;
|
||||
// Usually a one-off: ask once more before giving up on this model.
|
||||
if (err instanceof ReplyError && !replyRetried) { replyRetried = true; continue; }
|
||||
throw err;
|
||||
}
|
||||
return {
|
||||
...reply,
|
||||
|
|
@ -332,23 +284,23 @@ async function describeModel(config, model, {
|
|||
cost: costOf(spent, config.prices),
|
||||
imageUsed: useImage,
|
||||
imageRejected,
|
||||
replyRetried: replyRetries > 0,
|
||||
replyRetried,
|
||||
};
|
||||
} 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 (useImage && /image|vision|multimodal|modalit|content.?part|content type/i.test(err.message)) {
|
||||
useImage = false; imageRejected = true; continue;
|
||||
}
|
||||
if (useJson) { useJson = false; continue; }
|
||||
if (useImage) { useImage = false; imageRejected = true; continue; }
|
||||
throw withSpend(err);
|
||||
throw err;
|
||||
}
|
||||
if (err.status === 429 || err.status >= 500 || err.kind === 'timeout') {
|
||||
await sleep(Math.min(20000, 1500 * 2 ** attempt));
|
||||
continue;
|
||||
}
|
||||
throw withSpend(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,13 +128,6 @@ async function tagOne(runId, modelId, config, context) {
|
|||
[runId, result.usage.prompt, result.usage.completion, result.cost || 0]);
|
||||
return { ok: true, imageRejected: result.imageRejected, suggested: suggested.length > 0 };
|
||||
} 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);
|
||||
if (fatal) return { ok: false, fatal }; // left pending, so resuming retries it
|
||||
const message = err.message.slice(0, 300);
|
||||
|
|
|
|||
|
|
@ -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, []);
|
||||
});
|
||||
|
||||
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([
|
||||
() => reply('Sure! Tags: dragon, toy'),
|
||||
() => 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();
|
||||
}
|
||||
|
||||
// Empty replies in JSON mode: the last try goes without JSON mode.
|
||||
const empty = await fakeProvider([() => reply(''), () => reply(''), () => reply('{"tags": ["dragon"], "description": "A dragon."}')]);
|
||||
const stubborn = await fakeProvider([() => reply('no JSON here')]);
|
||||
try {
|
||||
const result = await ai.describeModel(empty.config, MODEL);
|
||||
assert.deepEqual(result.tags, ['dragon']);
|
||||
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 });
|
||||
await assert.rejects(ai.describeModel(stubborn.config, MODEL), /JSON/);
|
||||
assert.equal(stubborn.requests.length, 2);
|
||||
} finally {
|
||||
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', () => {
|
||||
// The panda starts at unit 799, so a plain cut at 800 would split it.
|
||||
const description = `${'a'.repeat(799)}🐼 red panda`;
|
||||
|
|
|
|||
Loading…
Reference in a new issue