odysseus-dev/odysseus · warning · Error
model returned no rewritten text
Error message
model returned no rewritten text
What it means
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.
Source
Thrown at static/js/chat.js:6479
}
// Strip any thinking markup from the answer. A reasoning model may emit
// an inline <think>…</think> block, a bare </think> (no opener), or — when
// its reasoning came via reasoning_content — a stray leading <think> that
// never closes (so it would otherwise hide the whole answer). Peel all of
// those off so what's left is just the rewritten text.
const _stripThink = (t) => {
t = markdownModule.normalizeThinkingMarkup(t || '');
t = t.replace(/<(?:think(?:ing)?|thought)(?:\s+[^>]*)?>[\s\S]*?<\/(?:think(?:ing)?|thought)>/gi, ''); // complete blocks
if (/<\/(?:think(?:ing)?|thought)>/i.test(t)) t = t.replace(/^[\s\S]*?<\/(?:think(?:ing)?|thought)>/i, ''); // reasoning w/o opener
return t.replace(/<\/?(?:think(?:ing)?|thought)(?:\s+[^>]*)?>/gi, '').trim(); // any orphan tag
};
newText = _stripThink(newText);
// Nothing left after stripping (or an empty stream) → real failure, not a
// blank bubble.
if (!newText.trim()) {
throw new Error('model returned no rewritten text');
}
// Update the element's raw text
if (newText) {
aiMsgElement.dataset.raw = newText;
// Final render with proper markdown
if (bodyEl) {
bodyEl.innerHTML = markdownModule.processWithThinking(
markdownModule.squashOutsideCode(newText)
);
}
// Save the new response as a variant
variants.push({ raw: newText, html: bodyEl ? bodyEl.innerHTML : '', label: varLabel });
aiMsgElement.dataset.variants = JSON.stringify(variants);
aiMsgElement.dataset.variantIndex = String(variants.length - 1);
// Persist variant metadata to serverView on GitHub (pinned to f9235ebbf1)
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.
Example fix
# before curl -X POST url/api/webhooks -F 'name=local' -F 'url=http://localhost:8000/hook' # after curl -X POST url/api/webhooks -F 'name=staging' -F 'url=https://mytunnel.example.com/hook'
Defensive patterns
Strategy: validation
Validate before calling
import ipaddress, socket
from urllib.parse import urlparse
def webhook_url_ok(url: str) -> bool:
u = urlparse(url.strip())
if len(url) > 2048: return False
if u.scheme not in ('http', 'https') or not u.hostname: return False
try:
ips = [ipaddress.ip_address(x[4][0]) for x in socket.getaddrinfo(u.hostname, None)]
except OSError:
return False
return all(not (i.is_private or i.is_loopback or i.is_link_local or i.is_reserved) for i in ips)
if not webhook_url_ok(url): reject('URL fails webhook policy (scheme/host/SSRF)'); Type guard
function isAcceptableWebhookUrl(url: string): boolean {
if (url.length > 2048) return false;
try {
const u = new URL(url.trim());
return (u.protocol === 'http:' || u.protocol === 'https:')
&& !!u.hostname
&& !/^(localhost|127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|\[?::1)/i.test(u.hostname);
} catch { return false; }
} Try / catch
const r = await fetch('/api/webhooks', { method: 'POST', body: fd });
if (r.status === 400) {
const msg = await r.text(); // raw ValueError text names the exact rule that failed
showFieldError(msg.includes('private') ? 'url' : msg.includes('event') ? 'events' : 'name', msg);
} Prevention
- 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
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- data.error || ('HTTP ' + (data.status || 500))
- str(e)
- Action '{task.action}' requires admin privileges
- detail
- {context} must stay on GitHub (got {host or 'unknown host'})
AI-assisted analysis of odysseus-dev/odysseus@f9235ebbf1 (2026-08-14).
Data as JSON: /api/errors/d3f09001d80c794a.
Report an issue: GitHub.