thedotmack/claude-mem · error · ServerClassifiedProviderError

parse_error

parse_error

Error message

OpenRouter returned invalid JSON

What it means

Thrown by OpenRouterObservationProvider.generate() when response.json() rejects after a successful HTTP status. Classified as parse_error with providerLabel 'OpenRouter', it means the OpenRouter endpoint returned a non-JSON body. The underlying parse error is attached as cause.

Source

Thrown at src/server/generation/providers/OpenRouterObservationProvider.ts:108

    }

    if (!response.ok) {
      const bodyText = await safeReadBody(response);
      throw classifyHttpProviderError({
        status: response.status,
        bodyText,
        headers: response.headers,
        cause: new Error(`OpenRouter API error: ${response.status} - ${bodyText}`),
        providerLabel: 'OpenRouter',
      });
    }

    let data: OpenRouterResponse;
    try {
      data = (await response.json()) as OpenRouterResponse;
    } catch (parseError) {
      const err = parseError instanceof Error ? parseError : new Error(String(parseError));
      throw new ServerClassifiedProviderError('OpenRouter returned invalid JSON', {
        kind: 'parse_error',
        cause: err,
      });
    }

    if (data.error) {
      throw classifyHttpProviderError({
        status: response.status,
        bodyText: `${data.error.code ?? ''} ${data.error.message ?? ''}`,
        headers: response.headers,
        cause: new Error(`OpenRouter API error: ${data.error.code} - ${data.error.message}`),
        providerLabel: 'OpenRouter',
      });
    }

    const rawText = data.choices?.[0]?.message?.content?.trim() ?? '';
    if (!rawText) {
      logger.warn('SDK', 'OpenRouter returned empty content', {

View on GitHub (pinned to d768ba3643)

Solutions

  1. Inspect the cause and the status/bodyText captured before parsing.
  2. Retry once; transient interception often clears.
  3. Bypass/allowlist the proxy for the OpenRouter host.
  4. Verify the resolved chat-completions URL is correct.
  5. Capture the raw body to confirm HTML/empty, then escalate.

Example fix

// before: parse without inspecting body shape
// after: guard non-JSON before parsing
const bodyText = await response.text();
if (!bodyText.trim().startsWith('{')) {
  throw new Error('OpenRouter returned non-JSON body');
}
const data = JSON.parse(bodyText) as OpenRouterResponse;
Defensive patterns

Strategy: retry

Validate before calling

async function fetchOpenRouterJsonOrThrow(response: Response): Promise<unknown> {
  const bodyText = await response.text();
  if (!response.ok) {
    throw new Error(`OpenRouter API error: ${response.status} - ${bodyText}`);
  }
  if (!bodyText || !bodyText.trim().startsWith('{')) {
    throw new Error('OpenRouter returned a non-JSON body (possible proxy interception)');
  }
  return JSON.parse(bodyText);
}

Type guard

function isOpenRouterResponse(v: unknown): v is OpenRouterResponse {
  return typeof v === 'object' && v !== null
    && 'choices' in v;
}

Try / catch

for (let attempt = 0; attempt < 3; attempt++) {
  try {
    return await provider.generate(context);
  } catch (error) {
    const isParse = error instanceof ServerClassifiedProviderError && error.kind === 'parse_error';
    if (!isParse || attempt === 2) throw error;
    await new Promise(r => setTimeout(r, 2 ** attempt * 500));
  }
}

Prevention

When it happens

Trigger: A proxy/WAF returns HTML instead of JSON; OpenRouter returns 200 with an empty body; a gateway truncates the stream; the configured apiUrl (resolved via resolveOpenRouterChatCompletionsUrl) points at the wrong host; a transient status page is served.

Common situations: Corporate proxy interception. OpenRouter serves an interim HTML page during an incident. A custom baseUrl hits a CDN returning HTML. Network instability truncates the response.

Understand the failure class

Related errors


AI-assisted analysis of thedotmack/claude-mem@d768ba3643 (2026-08-12). Data as JSON: /api/errors/7d0bdab3ce1868e2. Report an issue: GitHub.