Reasoning-aware JSON extractor for LLM responses: strips thinking tags, unwraps markdown fences, and scans prose before
JSON.parseever sees the text. Zero dependencies.
Security posture is tracked in docs/security-posture.md, including CodeQL, OpenSSF Scorecard, Dependabot and branch rules.
You asked for JSON. The model gave you:
<think>
Let me reason about this. The score should reflect... maybe {draft: 6}?
</think>
Sure! Here's the result:
```json
{"score": 8, "reason": "clear"}
```
Hope that helps!
JSON.parse throws on all of that. json-from-llm returns { score: 8, reason: "clear" }.
import { extractJson } from 'json-from-llm';
const data = extractJson<{ score: number }>(modelOutput);- Reasoning-model aware. Strips
<think>/<thinking>blocks first, including unclosed reasoning prefixes, so brace-laden reasoning (a real cause ofNo object generatedfailures with DeepSeek R1, Gemini 2.5 thinking, prompted Claude) never gets mistaken for the payload. - Handles the real wrappers. Markdown fences (
jsonand bare ```), conversational prose before/after, and the JSON sitting bare in the text. - String-aware, delimiter-aware, never corrupts. The scanner and the trailing-comma repair both respect string contents: a
}or,inside"a string value"is left alone, and mismatched or truncated JSON-looking drafts are skipped. - Conservative repair. Removes trailing commas (the most common malformation); it will never rewrite your data.
- Fixture-backed edge cases. Public fixtures cover reasoning tags, fenced JSON, prose wrappers, trailing commas, top-level type expectations and no-JSON failures.
- Two library entry points + CLI.
extractJsonthrows on failure;tryExtractJsonreturns{ found };json-from-llmreads stdin for shell pipelines. - Zero dependencies, ESM + CJS, fully typed.
npm install json-from-llmPipe model output directly into the binary:
cat response.txt | npx json-from-llmExample:
printf '%s\n' '<think>{draft: true}</think>```json
{"score":8,"reason":"clear"}
```' | npx json-from-llm
# {"score":8,"reason":"clear"}Useful flags:
# Skip an earlier array and require the first object that parses
cat response.txt | npx json-from-llm --expect object
# Disable trailing-comma repair when you want strict parsing
cat response.txt | npx json-from-llm --no-repairExit codes:
0: JSON extracted and printed to stdout.1: no matching JSON value found.2: invalid CLI options.
Returns the extracted JSON value, or throws JsonExtractionError if none can be recovered.
The non-throwing variant.
interface ExtractOptions {
repair?: boolean; // remove trailing commas (default true)
expect?: 'object' | 'array' | 'any'; // restrict the top-level type (default 'any')
}expect is handy when prose contains a stray array but you want the object:
extractJson('[1,2] then the answer {"a":1}', { expect: 'object' }); // { a: 1 }These are the exact shapes returned by each SDK. Pass the raw string directly to extractJson.
OpenAI
import OpenAI from 'openai';
import { extractJson } from 'json-from-llm';
const client = new OpenAI();
const res = await client.chat.completions.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: 'Return {"score":8} as JSON.' }],
});
const raw = res.choices[0].message.content ?? '';
const data = extractJson<{ score: number }>(raw);Anthropic
import Anthropic from '@anthropic-ai/sdk';
import { extractJson } from 'json-from-llm';
const client = new Anthropic();
const msg = await client.messages.create({
model: 'claude-opus-4-8',
max_tokens: 256,
messages: [{ role: 'user', content: 'Return {"score":8} as JSON.' }],
});
const raw = msg.content.find((b) => b.type === 'text')?.text ?? '';
const data = extractJson<{ score: number }>(raw);Vercel AI SDK
import { generateText } from 'ai';
import { openai } from '@ai-sdk/openai';
import { extractJson } from 'json-from-llm';
const { text } = await generateText({
model: openai('gpt-4o'),
prompt: 'Return {"score":8} as JSON.',
});
const data = extractJson<{ score: number }>(text);Streaming (OpenAI)
import OpenAI from 'openai';
import { extractJson } from 'json-from-llm';
const client = new OpenAI();
const stream = await client.chat.completions.create({
model: 'gpt-4o',
stream: true,
messages: [{ role: 'user', content: 'Return {"score":8} as JSON.' }],
});
let raw = '';
for await (const chunk of stream) {
raw += chunk.choices[0]?.delta?.content ?? '';
}
const data = extractJson<{ score: number }>(raw);Streaming (Vercel AI SDK)
import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
import { extractJson } from 'json-from-llm';
const result = streamText({
model: openai('gpt-4o'),
prompt: 'Return {"score":8} as JSON.',
});
const { text } = await result;
const data = extractJson<{ score: number }>(text);- Strip
<think>/<thinking>/<reasoning>blocks. If a reasoning tag is opened and never closed, treat the rest as reasoning. - Prefer complete contents of fenced
json(or bare) code blocks. - If a fence contains prose, scan inside those fences for balanced JSON after complete fence payloads have been tried.
- Otherwise scan for the first balanced
{…}/[…]that parses, string-aware and delimiter-aware. - If parsing fails, apply conservative repair (trailing commas) and retry.
The low-level pieces (stripReasoning, fencedBlocks, balancedSpans, removeTrailingCommas) are exported too.
- TypeScript generics do not validate runtime shape. Pair this with your schema validator when fields matter.
- Repair is intentionally narrow: trailing commas only. It will not convert JSON5, comments, single quotes or unquoted keys.
- Candidate order is deterministic: JSON-ish fences first, then balanced objects/arrays in document order, filtered by
expect. - Unclosed reasoning tags return no JSON from that suffix instead of risking a draft extraction.
import { z } from 'zod';
import { extractJson } from 'json-from-llm';
const Schema = z.object({ score: z.number(), reason: z.string() });
const raw = modelOutput; // the raw LLM response string
const parsed = Schema.parse(extractJson(raw)); // throws ZodError if shape is wrongTypeScript generics pass through without runtime checking. Zod (or any schema validator) is the right layer for field-level validation.
import { JsonExtractionError, tryExtractJson } from 'json-from-llm';
async function extractWithRetry(
call: () => Promise<string>,
maxAttempts = 3,
): Promise<unknown> {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const raw = await call();
const result = tryExtractJson(raw);
if (result.found) return result.value;
if (attempt === maxAttempts) {
throw new JsonExtractionError(
`No JSON found after ${maxAttempts} attempts`,
);
}
// Re-ask the model on failure
}
throw new JsonExtractionError('Unreachable');
}jsonrepair: repairs malformed JSON syntax (unclosed brackets, unquoted keys, comments). It expects the input to already be JSON-shaped. json-from-llm solves the step before that: locating which substring is the intended JSON payload inside a mixed reasoning/prose/fenced response. The two are complementary: extract first, then repair if needed.
JSON.parse with a prompt asking for pure JSON: works often, but reasoning models emit <think> blocks unconditionally, and conversational models wrap answers in prose even when instructed not to. json-from-llm handles those cases without prompt engineering.
structured_outputs / forced JSON mode: not all providers support it, not all tasks are compatible with it (e.g., chain-of-thought), and it requires schema registration. json-from-llm works on any plain-text completion.
The package includes a small public corpus under fixtures/:
deepseek-thinking-object.txtgemini-reasoning-array.txtopenai-fenced-object.txtmultiple-fenced-final.txtanthropic-prose-object.txtprose-trailing-commas.txtmalformed-draft-valid-final.txtexpect-object-skips-array.txttruncated-stream-no-json.txtunclosed-thinking-no-json.txtno-json.txt- expected
tryExtractJsonoutputs underfixtures/expected/
The tests read these files directly, so parser changes are checked against stable, reusable examples. The fixtures are synthetic and safe for public CI: they contain no prompts, secrets, user data or live provider responses.
tool-schema: convert a JSON Schema into a provider tool / function-calling schema for OpenAI, Anthropic, Gemini and MCPllm-sse: parse streaming SSE from LLM providers into typed, provider-agnostic eventsllm-messages: convert chat messages between OpenAI, Anthropic and Gemini formatsllm-errors: normalize provider errors (rate limits, retries, status) into one shape
MIT © Sebastian Legarraga