301 lines
10 KiB
JavaScript
301 lines
10 KiB
JavaScript
/**
|
|
* Non-production focused-call diagnostics harness.
|
|
*
|
|
* Makes an independent request to the configured Ollama server with the
|
|
* same prompt, captures raw response text and completion metadata before
|
|
* delegating recovery/parse to an inline implementation (recoverJson) that
|
|
* mirrors the production helper's behavior.
|
|
*
|
|
* Callers must handle the returned diagnostics object or any thrown
|
|
* DiagnosticError to capture what the focused path would have seen.
|
|
*/
|
|
|
|
const DETECTION_CACHE = new Map();
|
|
|
|
/**
|
|
* Inline JSON recovery — mirrors the production recoverJson in lib/llm/provider.js
|
|
* without importing it (recoverJson is not exported).
|
|
*/
|
|
function recoverJson(raw) {
|
|
if (typeof raw !== "string") return raw;
|
|
const trimmed = raw.trim();
|
|
|
|
if (trimmed.length === 0) throw new SyntaxError("Model produced empty output");
|
|
|
|
try {
|
|
return JSON.parse(trimmed);
|
|
} catch { /* Not directly parseable */ }
|
|
|
|
let result = trimmed;
|
|
|
|
let braceDepth = 0;
|
|
let bracketDepth = 0;
|
|
let inString = false;
|
|
let escaped = false;
|
|
|
|
for (let i = 0; i < result.length; i++) {
|
|
const ch = result[i];
|
|
|
|
if (escaped) { escaped = false; continue; }
|
|
if (ch === '\\') { escaped = true; continue; }
|
|
if (ch === '"') { inString = !inString; continue; }
|
|
if (inString) continue;
|
|
if (ch === '{') braceDepth++;
|
|
else if (ch === '}') braceDepth--;
|
|
else if (ch === '[') bracketDepth++;
|
|
else if (ch === ']') bracketDepth--;
|
|
}
|
|
|
|
const closingBrackets = [];
|
|
for (let i = 0; i < bracketDepth; i++) closingBrackets.push(']');
|
|
for (let i = 0; i < braceDepth; i++) closingBrackets.push('}');
|
|
|
|
if (closingBrackets.length > 0) {
|
|
const closed = result + closingBrackets.reverse().join('');
|
|
try { return JSON.parse(closed); } catch { /* still broken */ }
|
|
}
|
|
|
|
const lastOpen = Math.max(result.lastIndexOf('{'), result.lastIndexOf('['));
|
|
if (lastOpen >= 0) {
|
|
try { return JSON.parse(result.slice(lastOpen)); } catch { /* nothing works */ }
|
|
}
|
|
|
|
throw new SyntaxError("Model output could not be parsed as JSON: " + result.slice(0, 300) + "...");
|
|
}
|
|
|
|
/**
|
|
* Throw a tagged error that preserves captured diagnostics for failure
|
|
* artifact writing without any production code modification.
|
|
*/
|
|
class DiagnosticError extends Error {
|
|
constructor(message, cause) {
|
|
super(message, { cause });
|
|
this.name = "DiagnosticError";
|
|
this.focusedDiagnostics = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detect whether /api/chat is available for a given baseUrl.
|
|
*/
|
|
async function detectChatSupport(baseUrl) {
|
|
if (DETECTION_CACHE.has(baseUrl)) return DETECTION_CACHE.get(baseUrl);
|
|
|
|
try {
|
|
const res = await fetch(`${baseUrl}/api/chat`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
model: "dummy-check",
|
|
messages: [{ role: "user", content: "test" }],
|
|
stream: false,
|
|
}),
|
|
});
|
|
|
|
let chatSupported;
|
|
if (res.ok) { await res.body?.consume(); chatSupported = true; }
|
|
else if (res.status === 405 || res.status === 501) { await res.body?.consume(); chatSupported = false; }
|
|
else { await res.body?.consume(); chatSupported = false; }
|
|
|
|
DETECTION_CACHE.set(baseUrl, chatSupported);
|
|
return chatSupported;
|
|
} catch {
|
|
DETECTION_CACHE.set(baseUrl, false);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Make one focused request that captures all observable diagnostics
|
|
* before delegating recovery/parse back to the caller.
|
|
*
|
|
* @param {object} params
|
|
* @param {string} params.baseUrl
|
|
* @param {string} params.modelName
|
|
* @param {string} params.prompt
|
|
* @returns {{ diagnostics: object, error?: DiagnosticError | null, parsedResult: unknown }}
|
|
*/
|
|
export async function runFocusedDiagnostic({ baseUrl, modelName, prompt }) {
|
|
const diagnostics = {
|
|
rawResponseText: null,
|
|
rawResponseCharacterCount: null,
|
|
done: null,
|
|
doneReason: null,
|
|
promptEvalCount: null,
|
|
evalCount: null,
|
|
modelElapsedMs: null,
|
|
apiUsed: null,
|
|
};
|
|
|
|
const startedAt = Date.now();
|
|
|
|
let chatSupported = false;
|
|
try { chatSupported = await detectChatSupport(baseUrl); } catch { /* use default */ }
|
|
|
|
let rawResponse = null;
|
|
let fullResponseData = null;
|
|
let apiUsed = null;
|
|
|
|
// ---- Try /api/chat (same path as production) ----
|
|
if (chatSupported) {
|
|
try {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 60000);
|
|
|
|
const res = await fetch(`${baseUrl}/api/chat`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
model: modelName,
|
|
messages: [{ role: "user", content: prompt }],
|
|
stream: false,
|
|
format: "json",
|
|
}),
|
|
signal: controller.signal,
|
|
});
|
|
|
|
clearTimeout(timeout);
|
|
|
|
if (res.ok) {
|
|
fullResponseData = await res.json();
|
|
rawResponse = typeof fullResponseData.message?.content === "string"
|
|
? fullResponseData.message.content
|
|
: JSON.stringify(fullResponseData.message?.content ?? null);
|
|
apiUsed = "/api/chat";
|
|
} else {
|
|
await res.body?.consume();
|
|
}
|
|
} catch (e) {
|
|
if (!e.message?.includes("abort")) { /* non-fatal */ }
|
|
}
|
|
}
|
|
|
|
// ---- Fallback to /api/generate (production-compatible path) ----
|
|
if (rawResponse == null || rawResponse === "") {
|
|
try {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 300000);
|
|
|
|
apiUsed = "/api/generate";
|
|
|
|
const res = await fetch(`${baseUrl}/api/generate`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
model: modelName,
|
|
prompt,
|
|
stream: false,
|
|
}),
|
|
signal: controller.signal,
|
|
});
|
|
|
|
clearTimeout(timeout);
|
|
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => "");
|
|
throw new Error(`/api/generate returned ${res.status}: ${text.slice(0, 500)}`);
|
|
}
|
|
|
|
fullResponseData = await res.json();
|
|
|
|
rawResponse = typeof fullResponseData.response === "string"
|
|
? fullResponseData.response
|
|
: JSON.stringify(fullResponseData);
|
|
} catch (e) {
|
|
// Preserve abort/timeout — these are legitimate signal-based failures
|
|
if (e.name === "AbortError" || e.message?.includes("aborted")) {
|
|
throw e;
|
|
}
|
|
if (apiUsed === "/api/generate") {
|
|
throw new Error(
|
|
`Ollama /api/generate request timed out after 5 minutes.\n\n` +
|
|
`This usually means:\n` +
|
|
`1. The model is loading into memory for the first time (cold start)\n` +
|
|
`2. Your hardware is slow for this model size\n` +
|
|
`3. Ollama server is overloaded\n\n` +
|
|
`Try:\n` +
|
|
`- Run the request again after ~1 minute (model may be cached now)\n` +
|
|
`- Use a smaller model (e.g., llama3.1 instead of llama3.1:70b)\n` +
|
|
`- Check Ollama logs: \`ollama serve\``
|
|
);
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
diagnostics.rawResponseText = rawResponse;
|
|
diagnostics.rawResponseCharacterCount = rawResponse?.length ?? 0;
|
|
diagnostics.apiUsed = apiUsed || "none";
|
|
diagnostics.modelElapsedMs = Date.now() - startedAt;
|
|
|
|
// Completion metadata (available in fullResponseData for both /api/chat and /api/generate)
|
|
if (fullResponseData != null) {
|
|
diagnostics.done = fullResponseData.done ?? null;
|
|
diagnostics.doneReason = fullResponseData.done_reason ?? null;
|
|
diagnostics.promptEvalCount = fullResponseData.prompt_eval_count ?? null;
|
|
diagnostics.evalCount = fullResponseData.eval_count ?? null;
|
|
}
|
|
|
|
// ---- Error path: signal parse failure ----
|
|
if (rawResponse == null || rawResponse === "") {
|
|
let diagInfo = "";
|
|
if (fullResponseData) {
|
|
const keys = Object.keys(fullResponseData);
|
|
diagInfo += "Keys in response: " + keys.join(", ") + "\n";
|
|
for (const key of keys) {
|
|
const val = fullResponseData[key];
|
|
if (typeof val === "string") {
|
|
diagInfo += ` ${key}: "${val.slice(0, 200)}"\n`;
|
|
} else if (typeof val === "object" && val != null) {
|
|
try { diagInfo += ` ${key}: ${JSON.stringify(val).slice(0, 300)}\n`; } catch { diagInfo += ` ${key}: [object]\n`; }
|
|
} else {
|
|
diagInfo += ` ${key}: ${String(val)}\n`;
|
|
}
|
|
}
|
|
}
|
|
|
|
const err = new DiagnosticError(
|
|
"Model produced empty output.\n\n" +
|
|
"API used: " + (apiUsed || "none") + "\n" +
|
|
"/api/chat supported: " + chatSupported + "\n" +
|
|
"Full server response:\n" + (diagInfo || "(none)\n") +
|
|
"\nPossible causes:\n" +
|
|
"- Check 'ollama list' — make sure the model name matches exactly\n" +
|
|
"- The model may be corrupted. Try: ollama pull " + modelName + "\n" +
|
|
"- This Ollama version does not support format:json — relying on prompt instructions only\n" +
|
|
"- If your model is very small (e.g., tinyllama, phi), try a larger one like llama3.1"
|
|
);
|
|
err.focusedDiagnostics = structuredClone(diagnostics);
|
|
throw err;
|
|
}
|
|
|
|
// ---- Recovery / parse delegation to inline implementation ----
|
|
let parsedResult = null;
|
|
let recoveryError = null;
|
|
try {
|
|
parsedResult = recoverJson(rawResponse);
|
|
} catch (e) {
|
|
if (e instanceof SyntaxError) {
|
|
recoveryError = new DiagnosticError(
|
|
"Model returned output that could not be parsed as valid JSON.\n\n" +
|
|
"API used: " + (apiUsed || "none") + "\n" +
|
|
"/api/chat supported: " + chatSupported + "\n" +
|
|
"Raw model output:\n" + rawResponse.slice(0, 1000) + (rawResponse.length > 1000 ? "\n...(truncated)" : "") +
|
|
"\n\nPossible causes:\n" +
|
|
"- This Ollama version does not support format:json\n" +
|
|
"- Try a larger model (llama3.1, mistral-large) which follows JSON instructions better\n" +
|
|
"- Shorten your scenario to under 500 words\n" +
|
|
"- Consider upgrading Ollama: https://ollama.com/download"
|
|
);
|
|
recoveryError.focusedDiagnostics = structuredClone(diagnostics);
|
|
}
|
|
// Propagate non-SyntaxError without wrapping
|
|
if (!(e instanceof SyntaxError)) throw e;
|
|
}
|
|
|
|
return { diagnostics, parsedResult, error: recoveryError };
|
|
}
|
|
|
|
// ---- Named export for static analysis verification ----
|
|
export function _getDetectionCache() { return DETECTION_CACHE; }
|