Skip to content

Commit c79b9ee

Browse files
fix: refactor and add structured output support (#85)
1 parent 3dd4c62 commit c79b9ee

26 files changed

Lines changed: 2700 additions & 1283 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Changelog
22

3+
## [Unreleased]
4+
5+
### Breaking Changes
6+
* `ResilientLLM.chat()` now always returns a consistent envelope object: `{ content, toolCalls?, metadata }` (metadata is no longer gated by `returnOperationMetadata`).
7+
38
## [1.7.1](https://github.com/gitcommitshow/resilient-llm/compare/v1.7.0...v1.7.1) (2026-03-16)
49

510

README.md

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ const conversationHistory = [
5353

5454
(async () => {
5555
try {
56-
const response = await llm.chat(conversationHistory);
57-
console.log('LLM response:', response);
56+
const { content, toolCalls, metadata } = await llm.chat(conversationHistory);
57+
console.log('LLM response:', content);
5858
} catch (err) {
5959
console.error('Error:', err);
6060
}
@@ -92,6 +92,37 @@ import { ProviderRegistry } from 'resilient-llm';
9292

9393
See the [full API reference](./docs/reference.md) for complete documentation.
9494

95+
## Structured output (JSON + schema)
96+
97+
Use `llm.chat(..., { responseFormat })` when you need the assistant to return **machine-readable JSON**, optionally matching a **specific JSON Schema**.
98+
99+
```javascript
100+
// JSON mode (single JSON object)
101+
const { content: obj } = await llm.chat(messages, { responseFormat: { type: 'json_object' } });
102+
103+
// Schema mode (validate required keys/types)
104+
const { content: result } = await llm.chat(messages, {
105+
responseFormat: {
106+
type: 'json_schema',
107+
json_schema: {
108+
name: 'answer_payload',
109+
schema: {
110+
type: 'object',
111+
additionalProperties: false,
112+
properties: { answer: { type: 'string' } },
113+
required: ['answer']
114+
}
115+
}
116+
}
117+
});
118+
119+
// `result` is a parsed JS object (not a string).
120+
// If the model returns invalid JSON or fails schema validation,
121+
// `llm.chat(...)` throws a StructuredOutputError with `code` and `validation` details.
122+
```
123+
124+
For all supported shapes (including plain schema objects) and parsing/validation behavior, see [`responseFormat` docs](./docs/reference.md#responseformat-json-mode--schema-mode).
125+
95126
## Supported LLM Providers
96127

97128
ResilientLLM comes with built-in support for all text models provided by **OpenAI**, **Anthropic**, **Google/Gemini**, **Ollama** API, etc.

docs/reference.md

Lines changed: 167 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ new ResilientLLM(options?: ResilientLLMOptions)
5252
| `backoffFactor` | `number` | No | `2` | Exponential backoff multiplier between retries |
5353
| `onRateLimitUpdate` | `Function` | No | `undefined` | Callback function called when rate limit information is updated |
5454
| `onError` | `Function` | No | `undefined` | Currently not used (reserved for future use) |
55-
| `returnOperationMetadata` | `boolean` | No | `false` | When `true`, `chat()` returns a [ChatResponse](#chatresponse) with `metadata` populated instead of a plain string (see [OperationMetadata](#operationmetadata)) |
5655

5756
**RateLimitConfig:**
5857

@@ -79,12 +78,6 @@ const llm = new ResilientLLM({
7978

8079
---
8180

82-
### ResilientLLM Static Properties
83-
84-
_No static properties currently available. Use `ProviderRegistry.getDefaultModels()` to get default models for all providers._
85-
86-
---
87-
8881
### ResilientLLM Instance Methods
8982

9083
#### `chat(conversationHistory, llmOptions?)`
@@ -93,7 +86,7 @@ Sends a chat completion request to the configured LLM provider.
9386

9487
**Signature:**
9588
```typescript
96-
chat(conversationHistory: Message[], llmOptions?: ChatOptions): Promise<string | ChatResponse>
89+
chat(conversationHistory: Message[], llmOptions?: ChatOptions): Promise<ChatResponse>
9790
```
9891

9992
**Parameters:**
@@ -124,8 +117,15 @@ chat(conversationHistory: Message[], llmOptions?: ChatOptions): Promise<string |
124117
| `reasoningEffort` | `string` | Reasoning effort level: `"low"`, `"medium"`, or `"high"` (for reasoning models) |
125118
| `apiKey` | `string` | Override API key for this request (takes precedence over ProviderRegistry) |
126119
| `tools` | `Tool[]` | Array of tool definitions for function calling |
127-
| `responseFormat` | `Object` | Response format specification (e.g., `{ type: "json_object" }`) |
128-
| `returnOperationMetadata` | `boolean` | When `true`, this request returns a [ChatResponse](#chatresponse) with `metadata`; overrides the constructor default for this call only |
120+
| `responseFormat` | `Object \| string` | Response format specification (`json_object`/`json_schema` object shapes, plain schema-like object, or JSON aliases: `"json"`, `"object"`, `"json_object"`) |
121+
| `outputConfig` | `Object` | **Legacy/migration support**. Anthropic-style alternative structured-output input shape, normalized internally via `responseFormat`. _Prefer `responseFormat` for all new usage._ |
122+
| `response_format` | `Object \| string` | **Legacy/migration support**. Snake_case alias for `responseFormat`; passthrough-friendly for provider-native payloads. _Prefer `responseFormat` for all new usage._ |
123+
| `output_config` | `Object` | **Legacy/migration support**. Snake_case alias for `outputConfig`; passed through as-is when provided. _Prefer `responseFormat` for all new usage._ |
124+
125+
Use one naming style per field to avoid ambiguity:
126+
- Prefer camelCase (`responseFormat` or its alias `outputConfig`) in app code.
127+
- Prefer snake_case (`response_format`, `output_config`) when reusing raw provider payload snippets.
128+
- Do not send both aliases for the same field in one request; conflicting info may result in error.
129129

130130
**Tool:**
131131

@@ -138,33 +138,37 @@ chat(conversationHistory: Message[], llmOptions?: ChatOptions): Promise<string |
138138
| `function.parameters` | `Object` | Function parameters schema (OpenAI format) |
139139
| `function.input_schema` | `Object` | Function input schema (Anthropic format) |
140140

141-
**Returns:** `Promise<string | ChatResponse>`
141+
**Returns:** `Promise<ChatResponse>`
142142

143-
- If `tools` are provided, returns `ChatResponse` with `content` and `toolCalls`; otherwise returns a `string` (the assistant's reply).
144-
- If `returnOperationMetadata` is set to `true` (constructor or `llmOptions`): Returns a `ChatResponse` with `content` and `metadata` ([OperationMetadata](#operationmetadata))
145-
- Otherwise: Returns `string` containing
146-
the assistant's response.
143+
- Always returns a predictable envelope:
144+
- `response.content` is the assistant output (string in text mode, parsed object in JSON/schema mode)
145+
- `response.toolCalls` is included when tool calls are returned
146+
- `response.metadata` is always included
147147

148148
**ChatResponse:**
149149

150150
| Property | Type | Description |
151151
|----------|------|-------------|
152-
| `content` | `string \| null` | The text content of the response |
152+
| `content` | `string \| Object \| null` | The assistant content (text by default, normalized JSON object in JSON modes) |
153153
| `toolCalls` | `Array` | Array of tool call objects (if tools were used) |
154-
| `metadata` | `OperationMetadata` | Present when `returnOperationMetadata` is `true` (request id, config, timing, retries, rate limiting, usage, etc.) |
154+
| `metadata` | `OperationMetadata` | Always included (request id, config, timing, retries, rate limiting, usage, etc.) |
155155

156156
**Throws:**
157157

158158
- `Error` - If input tokens exceed `maxInputTokens`
159159
- `Error` - If API key is not set for the selected service (unless auth is optional)
160160
- `Error` - If the AI service/provider is invalid
161161
- `Error` - If API request fails
162+
- `Error` - If JSON mode parsing fails (`code: "JSON_PARSE_ERROR"`, `rawResponse`)
163+
- `Error` - If JSON response is not an object (`code: "JSON_MODE_FAILURE"`, `rawResponse`)
164+
- `Error` - If schema validation fails (`code: "SCHEMA_MISMATCH"`, `rawResponse`, `validation: SchemaValidationIssue`)
162165

163166
**Notes:**
164167

165168
- API keys can be provided via `llmOptions.apiKey`, `ProviderRegistry.configure()`, or environment variables
166169
- The implementation uses `ProviderRegistry` to manage providers and their configurations
167170
- Response parsing is handled generically using provider-specific `chatConfig` settings
171+
- For schema mode, validation checks top-level required fields and primitive types (`string`, `number`, `boolean`, `integer`). Schema mismatch errors include a `validation` object with `missingFields`, `extraFields`, and `typeMismatches` arrays
168172

169173
**Example:**
170174
```javascript
@@ -173,8 +177,8 @@ const conversationHistory = [
173177
{ role: 'user', content: 'What is the capital of France?' }
174178
];
175179

176-
const response = await llm.chat(conversationHistory);
177-
console.log(response); // "The capital of France is Paris."
180+
const { content } = await llm.chat(conversationHistory);
181+
console.log(content); // "The capital of France is Paris."
178182
```
179183

180184
**Example with tools:**
@@ -212,14 +216,13 @@ const response = await llm.chat(conversationHistory, {
212216
const llm = new ResilientLLM({
213217
aiService: 'openai',
214218
model: 'gpt-4o-mini',
215-
returnOperationMetadata: true
216219
});
217220

218221
const { content, metadata } = await llm.chat(conversationHistory);
219222
console.log(content); // Assistant reply text
220-
console.log(metadata.requestId);
221-
console.log(metadata.timing.totalTimeMs);
222-
console.log(metadata.usage); // prompt_tokens, completion_tokens, total_tokens
223+
console.log(metadata?.requestId);
224+
console.log(metadata?.timing?.totalTimeMs);
225+
console.log(metadata?.usage); // prompt_tokens, completion_tokens, total_tokens
223226
```
224227
225228
---
@@ -430,7 +433,7 @@ Retries the chat request with an alternate AI service when the current service r
430433
431434
**Signature:**
432435
```typescript
433-
retryChatWithAlternateService(conversationHistory: Message[], llmOptions?: ChatOptions): Promise<string | ChatResponse>
436+
retryChatWithAlternateService(conversationHistory: Message[], llmOptions?: ChatOptions): Promise<ChatResponse>
434437
```
435438
436439
**Parameters:**
@@ -440,7 +443,7 @@ retryChatWithAlternateService(conversationHistory: Message[], llmOptions?: ChatO
440443
| `conversationHistory` | `Message[]` | Yes | Array of message objects |
441444
| `llmOptions` | `ChatOptions` | No | LLM options for the request |
442445
443-
**Returns:** `Promise<string | ChatResponse>` - Response from the alternate service
446+
**Returns:** `Promise<ChatResponse>` - Response from the alternate service
444447
445448
**Throws:**
446449
@@ -507,25 +510,32 @@ interface Message {
507510
508511
### ChatResponse
509512
510-
Response object returned by `chat()` when tools are used and/or `returnOperationMetadata` is `true`. Otherwise `chat()` returns a plain `string`.
513+
Response envelope returned by `chat()` on every call.
514+
515+
- `content` is the assistant output:
516+
- text mode -> `string`
517+
- JSON/schema mode -> parsed JS object
518+
- `toolCalls` is present when tool calls were returned
519+
- `metadata` is always included
511520
512521
```typescript
513522
interface ChatResponse {
514-
content: string | null;
523+
content: string | Object | null;
515524
toolCalls?: Array<any>;
516-
metadata?: OperationMetadata; // present when returnOperationMetadata is true
525+
metadata: OperationMetadata;
517526
}
518527
```
519528
520529
### OperationMetadata
521530
522-
Operation metadata attached to `ChatResponse.metadata` when `returnOperationMetadata` is `true` (constructor or per-call). Used for observability, logging, and debugging.
531+
Operation metadata attached to `ChatResponse.metadata` on every call. Used for observability, logging, and debugging.
523532
524533
```typescript
525534
interface OperationMetadata {
526535
requestId: string;
527536
operationId: string;
528537
startTime: number;
538+
finishReason?: string | null;
529539
config: {
530540
aiService: string;
531541
model: string;
@@ -594,7 +604,6 @@ interface ResilientLLMOptions {
594604
backoffFactor?: number;
595605
onRateLimitUpdate?: (info: RateLimitInfo) => void;
596606
onError?: (error: Error) => void;
597-
returnOperationMetadata?: boolean;
598607
}
599608
```
600609
@@ -615,10 +624,137 @@ interface ChatOptions {
615624
apiKey?: string;
616625
tools?: Tool[];
617626
responseFormat?: Object;
618-
returnOperationMetadata?: boolean;
627+
outputConfig?: Object;
628+
}
629+
```
630+
631+
#### `responseFormat` (JSON mode + schema mode)
632+
633+
Use `responseFormat` when you need the assistant response as **JSON**, optionally matching a **particular schema**.
634+
635+
- **JSON mode (no schema)**: ensures the reply is a single JSON object (library parses it for you).
636+
- **Schema mode**: provides a JSON Schema so the library can validate the parsed object and throw `SCHEMA_MISMATCH` when required keys/types don’t match.
637+
638+
**Supplying a schema**
639+
640+
You can supply a schema in any of these equivalent shapes (pick one and stick to it):
641+
642+
- **OpenAI-style wrapper** (recommended when you want to be explicit):
643+
644+
```typescript
645+
responseFormat: {
646+
type: 'json_schema',
647+
json_schema: {
648+
name: 'my_payload',
649+
schema: {
650+
type: 'object',
651+
properties: {
652+
answer: { type: 'string' },
653+
citations: { type: 'array', items: { type: 'string' } }
654+
},
655+
required: ['answer']
656+
}
657+
}
658+
}
659+
```
660+
661+
- **Short wrapper** (schema directly on the object):
662+
663+
```typescript
664+
responseFormat: {
665+
type: 'json_schema',
666+
schema: {
667+
type: 'object',
668+
properties: { answer: { type: 'string' } },
669+
required: ['answer']
670+
}
671+
}
672+
```
673+
674+
- **Plain schema-like object** (auto-detected as a schema):
675+
676+
```typescript
677+
responseFormat: {
678+
type: 'object',
679+
properties: { answer: { type: 'string' } },
680+
required: ['answer']
619681
}
620682
```
621683
684+
**End-to-end example (schema mode)**
685+
686+
```typescript
687+
const llm = new ResilientLLM({ aiService: 'openai', model: 'gpt-4o-mini' });
688+
689+
const result = await llm.chat(
690+
[{ role: 'user', content: 'Return an answer and citations.' }],
691+
{
692+
responseFormat: {
693+
type: 'json_schema',
694+
json_schema: {
695+
name: 'answer_payload',
696+
schema: {
697+
type: 'object',
698+
properties: {
699+
answer: { type: 'string' },
700+
citations: { type: 'array', items: { type: 'string' } }
701+
},
702+
required: ['answer']
703+
}
704+
}
705+
}
706+
}
707+
);
708+
709+
// `result.content` is a parsed JS object when `responseFormat` requests JSON/schema mode.
710+
```
711+
712+
**Validation scope (important)**
713+
714+
The built-in validator is intentionally lightweight: it checks **required keys**, **extra keys**, and **primitive types** at the top level (`string`, `number`, `boolean`, `integer`).
715+
716+
- Extra keys are enforced only when your schema sets `additionalProperties: false` (and the schema has `properties`).
717+
- For deeper validation needs (nested objects, enums, regex, oneOf/anyOf, etc.), run your own schema validator after the call.
718+
719+
**Example: `additionalProperties: false` + `required`**
720+
721+
```typescript
722+
const result = await llm.chat(messages, {
723+
responseFormat: {
724+
type: 'json_schema',
725+
json_schema: {
726+
name: 'answer_payload',
727+
schema: {
728+
type: 'object',
729+
additionalProperties: false,
730+
properties: {
731+
answer: { type: 'string' }
732+
},
733+
required: ['answer']
734+
}
735+
}
736+
}
737+
});
738+
739+
// `result.content` is { answer: string } when the model output matches the schema.
740+
// If the model returns invalid JSON or extra keys, `llm.chat(...)` throws StructuredOutputError (e.g. `SCHEMA_MISMATCH`).
741+
```
742+
743+
#### `responseFormat` examples (quick)
744+
745+
```typescript
746+
// JSON alias strings (equivalent to { type: 'json_object' })
747+
'json'
748+
'object'
749+
'json_object'
750+
751+
// OpenAI-compatible JSON mode
752+
{ type: 'json_object' }
753+
754+
// When `responseFormat` requests JSON, `llm.chat(...)` resolves to a response envelope
755+
// where `.content` is the parsed JS object.
756+
```
757+
622758
### Tool
623759
624760
Tool definition for function calling.

0 commit comments

Comments
 (0)