{"record":{"id":"d3f09001d80c794a","repo":"odysseus-dev/odysseus","slug":"model-returned-no-rewritten-text","errorCode":null,"errorMessage":"model returned no rewritten text","messagePattern":"model returned no rewritten text","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"warning","filePath":"static/js/chat.js","lineNumber":6479,"sourceCode":"      }\n\n      // Strip any thinking markup from the answer. A reasoning model may emit\n      // an inline <think>…</think> block, a bare </think> (no opener), or — when\n      // its reasoning came via reasoning_content — a stray leading <think> that\n      // never closes (so it would otherwise hide the whole answer). Peel all of\n      // those off so what's left is just the rewritten text.\n      const _stripThink = (t) => {\n        t = markdownModule.normalizeThinkingMarkup(t || '');\n        t = t.replace(/<(?:think(?:ing)?|thought)(?:\\s+[^>]*)?>[\\s\\S]*?<\\/(?:think(?:ing)?|thought)>/gi, '');   // complete blocks\n        if (/<\\/(?:think(?:ing)?|thought)>/i.test(t)) t = t.replace(/^[\\s\\S]*?<\\/(?:think(?:ing)?|thought)>/i, '');  // reasoning w/o opener\n        return t.replace(/<\\/?(?:think(?:ing)?|thought)(?:\\s+[^>]*)?>/gi, '').trim();        // any orphan tag\n      };\n      newText = _stripThink(newText);\n\n      // Nothing left after stripping (or an empty stream) → real failure, not a\n      // blank bubble.\n      if (!newText.trim()) {\n        throw new Error('model returned no rewritten text');\n      }\n\n      // Update the element's raw text\n      if (newText) {\n        aiMsgElement.dataset.raw = newText;\n        // Final render with proper markdown\n        if (bodyEl) {\n          bodyEl.innerHTML = markdownModule.processWithThinking(\n            markdownModule.squashOutsideCode(newText)\n          );\n        }\n\n        // Save the new response as a variant\n        variants.push({ raw: newText, html: bodyEl ? bodyEl.innerHTML : '', label: varLabel });\n        aiMsgElement.dataset.variants = JSON.stringify(variants);\n        aiMsgElement.dataset.variantIndex = String(variants.length - 1);\n\n        // Persist variant metadata to server","sourceCodeStart":6461,"sourceCodeEnd":6497,"githubUrl":"https://github.com/odysseus-dev/odysseus/blob/f9235ebbf13f693a6fd29ce70b097f6ec83705bf/static/js/chat.js#L6461-L6497","documentation":"HTTP 400 from POST /webhooks whose message is the raw ValueError text from validate_webhook_url (src/webhook_manager.py:115). That validator rejects: URLs over 2048 chars; schemes other than http/https; URLs without a hostname; and URLs whose host resolves to private/internal addresses (SSRF protection — localhost, 127.0.0.0/8, RFC1918 ranges, or a hostname whose DNS resolves private). The identical pattern catches validate_events failures on the next lines.","triggerScenarios":"POST /webhooks with url='ftp://...', url='', url missing (empty string fails scheme check), url='http://localhost:9000/hook', or 'http://10.0.0.5/hook'; also a public-looking hostname whose DNS A record includes a private IP, and (via the events twin) an events string containing unknown event names.","commonSituations":"Testing webhooks against a local dev server (localhost/127.0.0.1 is always rejected); pointing at an internal service in a private network; typos in the scheme; DNS rebinding-adjacent setups where a host resolves both public and private IPs; wrong event names in the events field.","solutions":["Read the response body — it is the validator's exact message ('URL must use http or https', 'URL must not point to private/internal addresses', etc.), which pinpoints the failing rule.","For local testing, use a tunnel (ngrok/cloudflared) or a genuinely public hostname instead of localhost/private IPs — or temporarily run the validator-off path only in isolated dev environments.","Verify the URL parses with a scheme, host, and total length ≤ 2048.","If the host should be public, check its DNS records for accidental private A/AAAA entries and fix them.","For the events variant, cross-check event names against the documented webhook event list."],"exampleFix":"# before\ncurl -X POST url/api/webhooks -F 'name=local' -F 'url=http://localhost:8000/hook'\n\n# after\ncurl -X POST url/api/webhooks -F 'name=staging' -F 'url=https://mytunnel.example.com/hook'","handlingStrategy":"validation","validationCode":"import ipaddress, socket\nfrom urllib.parse import urlparse\n\ndef webhook_url_ok(url: str) -> bool:\n    u = urlparse(url.strip())\n    if len(url) > 2048: return False\n    if u.scheme not in ('http', 'https') or not u.hostname: return False\n    try:\n        ips = [ipaddress.ip_address(x[4][0]) for x in socket.getaddrinfo(u.hostname, None)]\n    except OSError:\n        return False\n    return all(not (i.is_private or i.is_loopback or i.is_link_local or i.is_reserved) for i in ips)\n\nif not webhook_url_ok(url): reject('URL fails webhook policy (scheme/host/SSRF)');","typeGuard":"function isAcceptableWebhookUrl(url: string): boolean {\n  if (url.length > 2048) return false;\n  try {\n    const u = new URL(url.trim());\n    return (u.protocol === 'http:' || u.protocol === 'https:')\n      && !!u.hostname\n      && !/^(localhost|127\\.|10\\.|192\\.168\\.|172\\.(1[6-9]|2\\d|3[01])\\.|\\[?::1)/i.test(u.hostname);\n  } catch { return false; }\n}","tryCatchPattern":"const r = await fetch('/api/webhooks', { method: 'POST', body: fd });\nif (r.status === 400) {\n  const msg = await r.text(); // raw ValueError text names the exact rule that failed\n  showFieldError(msg.includes('private') ? 'url' : msg.includes('event') ? 'events' : 'name', msg);\n}","preventionTips":["Never register localhost/private-network URLs — use a tunnel for local testing","Check the scheme is http/https and length ≤ 2048 before submitting","Verify the host's DNS resolves only to public IPs (the SSRF check resolves at submit time)","Use only documented event names in the events field"],"tags":["webhook","http-400","ssrf","url-validation","admin"],"backgroundTag":null,"analyzedSha":"f9235ebbf13f693a6fd29ce70b097f6ec83705bf","analyzedAt":"2026-08-14T21:47:48.359Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}