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 server

View on GitHub (pinned to f9235ebbf1)

Solutions

  1. 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.
  2. 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.
  3. Verify the URL parses with a scheme, host, and total length ≤ 2048.
  4. If the host should be public, check its DNS records for accidental private A/AAAA entries and fix them.
  5. 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

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


AI-assisted analysis of odysseus-dev/odysseus@f9235ebbf1 (2026-08-14). Data as JSON: /api/errors/d3f09001d80c794a. Report an issue: GitHub.