# LLM and Reduce

> Get validated JSON from a language model with client.llm, and choose the best of many results, vote, join or merge them with client.reduce.

Source: https://nodaro.ai/docs/developers/sdk/llm-and-reduce

**`client.llm`** asks a language model for **structured output**: you send a system prompt, an input and a JSON Schema, and you get back an object that matches the schema. **`client.reduce`** runs the Reduce fan-in step on its own: it picks the best of many results, counts them, votes, joins text or merges JSON, the job the [Choose Best](https://nodaro.ai/docs/nodes/automate/choose-best) node does in a workflow. Both cost credits by model tier.

## Methods

| Method | What it does |
| --- | --- |
| [`llm.structured(input)`](#llmstructuredinput) | Get a validated object from a language model, in one request |
| [`llm.structuredJob(input)`](#llmstructuredjobinput) | The same call as a job you poll |
| [`reduce.run(input)`](#reduceruninput) | Pick, count, vote, join or merge many inputs into one |

## client.llm

Structured output: your system prompt and JSON Schema in, a validated object out. The platform chooses the model lane, forces JSON output, validates it against your schema, and feeds invalid answers back to the model before it gives up. It is billed as `llm-structured`, by model tier.

### llm.structured(input)

Asks the model and waits for the answer (`POST /v1/llm/structured`). A call can take several minutes, which is longer than the client's default 60-second timeout. Create the client with a larger `timeoutMs` for it, or use `structuredJob()`.

```ts
structured<T>(input: LlmStructuredInput): Promise<{
jobId: string
output: T
usage: { inputTokens: number; outputTokens: number }
}>
```

<TypeTable
type={{
system: { type: 'string', required: true, description: "The system prompt, up to 100,000 characters." },
input: { type: 'string', required: true, description: "The user message, 1 to 100,000 characters." },
jsonSchema: { type: 'Record<string, unknown>', required: true, description: "A JSON Schema whose type is object, up to 64 KB and 20 levels deep." },
schemaName: { type: 'string', description: "A name for the schema that the model sees, up to 64 characters." },
llmModel: { type: 'string', description: "The model id. Omit it for the default." },
reasoningEffort: { type: 'string', description: "none, low, medium, high, xhigh or max, depending on the model. xhigh and max bill one tier higher." },
maxRetries: { type: 'number', default: '2', description: "How many invalid answers are fed back to the model before the call fails, 0 to 3." },
origin: { type: 'string', description: "Your app's name, stored on the job. client.jobs.list({ origin }) finds your runs." },
advancedMode: { type: 'boolean', description: "Gemini models only: run on the maker's own API, where temperature and maxTokens take full effect. Bills one tier higher." },
temperature: { type: 'number', description: "The sampling temperature." },
maxTokens: { type: 'number', description: "The longest answer, in tokens." },
}}
/>

```ts
type Plan = { title: string; scenes: string[] }

const { output } = await client.llm.structured<Plan>({
system: "You write production plans for short films.",
input: "A rainy chase through Rome, 60 seconds.",
jsonSchema: {
type: "object",
properties: {
title: { type: "string" },
scenes: { type: "array", items: { type: "string" } },
},
required: ["title", "scenes"],
},
schemaName: "production_plan",
})
console.log(output.title, output.scenes.length)
```

### llm.structuredJob(input)

The same call as a job (`POST /v1/llm/structured/jobs`). It returns a `jobId` at once; poll it with [`client.jobs.getStatus()`](https://nodaro.ai/docs/developers/sdk/jobs-and-executions). A job can also draft from a video: the platform analyzes the video first, then adds the analysis to your input.

```ts
structuredJob(input: LlmStructuredJobInput): Promise<{ jobId: string }>
```

<TypeTable
type={{
'...': { type: 'LlmStructuredInput', description: "Every field of structured()." },
label: { type: 'string', description: "A title for the run, up to 120 characters, shown in run lists." },
videoUrl: { type: 'string', description: "A video to draft from. It is analyzed first, as a separate job you own." },
videoAnalysis: { type: '{ llmModel?: string; selectionMode?: "choose" | "combine" }', description: "Options for that analysis." },
analysisJobId: { type: 'string', description: "A finished video analysis job of yours to draft from, instead of analyzing the video again. It saves a second analysis charge. Not combinable with videoAnalysis." },
}}
/>

```ts
const { jobId } = await client.llm.structuredJob({
system: "You write production plans.",
input: "A rainy chase through Rome.",
jsonSchema: { type: "object", properties: { title: { type: "string" } }, required: ["title"] },
origin: "my-app",
label: "Rome chase",
})

// later, even from another session
const { data } = await client.jobs.getStatus(jobId)
if (data.status === "completed") {
console.log((data.output_data as { output: { title: string } }).output.title)
}
const { data: runs } = await client.jobs.list({ type: "llm-structured", origin: "my-app" })
```

While the job runs, its `output_data` holds `stage`: `analyzing` (for video drafts) or `drafting`. When it completes, `output_data` holds `output`, `inputTokens` and `outputTokens`, plus `analysisJobId` and `analysisCredits` for a video draft.

- `analysisJobId` fails with a 422 when the job is not yours, does not exist, or is not a finished video analysis. The codes are `analysis_not_found`, `not_analysis`, `analysis_failed`, `analysis_not_ready` and `invalid_analysis`.
- A platform without this route throws `NotFoundError`.
- A self-hosted instance that sends its language-model calls to Nodaro Cloud answers `503 provider_unavailable`. Treat it as unavailable on that instance, not as a passing error.

## client.reduce

### reduce.run(input)

Reduces many inputs to one. It mirrors the MCP `reduce` tool and the [Choose Best](https://nodaro.ai/docs/nodes/automate/choose-best) node.

```ts
run(input: ReduceInput): Promise<ReduceResult>
```

<TypeTable
type={{
strategyId: { type: '"pick-best-llm" | "concat" | "first-non-empty" | "count" | "vote" | "merge-json"', required: true, description: "How to reduce the inputs." },
inputs: { type: 'string[]', required: true, description: "Up to 1,000 inputs, as text or URLs." },
strategyConfig: { type: 'Record<string, unknown>', default: '{}', description: "Settings of the strategy. See the table below." },
workflowId: { type: 'string', description: "A workflow to list this run under, in its run history." },
}}
/>

| Strategy | `strategyConfig` | What it returns |
| --- | --- | --- |
| `pick-best-llm` | `{ criteria, inputKind?, llmModel? }`. `inputKind` is `"text"` or `"image-url"`. `llmModel` chooses the judge, and its credit tier applies. | The input a language model judges best, with its index and reasoning |
| `concat` | `{ separator? }`, a blank line by default | Every input joined into one text |
| `first-non-empty` | none | The first input that is not empty |
| `count` | none | The number of inputs |
| `vote` | `{ caseSensitive? }`, `false` by default | The most frequent input. A tie goes to the first. |
| `merge-json` | `{ strategy? }`: `"deep"` (the default) or `"shallow"` | The JSON inputs merged into one object |

```ts
const result = await client.reduce.run({
strategyId: "pick-best-llm",
strategyConfig: { criteria: "The sharpest image with no artifacts", inputKind: "image-url" },
inputs: [url1, url2, url3, url4, url5],
})
console.log(result.output)             // the chosen URL
console.log(result.meta.selectedIndex) // 0 to 4
console.log(result.meta.reasoning)     // why the model chose it
```

The result is `{ jobId, output, meta }`. `output` is the chosen or combined value, as a string. `meta.summary` is always set. `pick-best-llm` and `vote` set `meta.selectedIndex`, and `pick-best-llm` also sets `meta.reasoning`.

```ts
// Majority vote
const winner = await client.reduce.run({ strategyId: "vote", inputs: ["red", "blue", "red"] })

// Deep-merge JSON fragments
const merged = await client.reduce.run({
strategyId: "merge-json",
inputs: [JSON.stringify({ a: 1, nested: { x: 1 } }), JSON.stringify({ b: 2, nested: { y: 2 } })],
})
JSON.parse(merged.output) // { a: 1, b: 2, nested: { x: 1, y: 2 } }
```

When every input is empty or only whitespace, the call fails with a `NodaroError` whose status is 400 and `code` is `no_valid_inputs`. Credits are reserved like every generation, so a short balance throws `InsufficientCreditsError`.

## Frequently asked questions

### How do I get JSON that matches my schema from a language model?

Call client.llm.structured with a system prompt, an input and a JSON Schema object. The platform forces the model to answer in that shape, validates it, retries invalid answers, and returns the parsed object.

### When should I use structuredJob instead of structured?

Use structuredJob for long calls. structured waits for the answer in one request, which can take minutes, longer than the default 60-second timeout. structuredJob returns a jobId at once.

### How do I pick the best of several generated images?

Call client.reduce.run with strategyId pick-best-llm, your criteria and inputKind image-url, and pass the image URLs as inputs. The result names the chosen URL, its index and the model's reasoning.

### Which Reduce strategies need no language model?

concat, first-non-empty, count, vote and merge-json run without a model. Only pick-best-llm asks a language model to judge.
