Skip to content

Commit 2e08424

Browse files
authored
feat: normalize OpenAI Responses API output (#9)
1 parent d9bbebf commit 2e08424

6 files changed

Lines changed: 154 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ All notable changes to this project are documented here. The format is based on
44
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres
55
to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [Unreleased]
8+
9+
### Added
10+
11+
- Added OpenAI Responses API response normalization via
12+
`responseFromOpenAIResponses(...)` and `normalizeResponse(body, { from:
13+
'openai-responses' })`, mapping `output_text` to assistant content,
14+
`function_call` to Chat Completions-compatible `tool_calls`, and Responses
15+
usage/status fields to neutral `usage` and `finishReason` values.
16+
717
## [0.4.9] - 2026-06-04
818

919
### Added

README.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,20 +110,25 @@ The same idea applies to the read side. Normalize a provider's response body int
110110
a canonical OpenAI assistant message, plus a neutral finish reason and token usage:
111111

112112
```ts
113-
import { responseFromAnthropic, normalizeResponse } from 'llm-messages';
113+
import { responseFromAnthropic, responseFromOpenAIResponses, normalizeResponse } from 'llm-messages';
114114

115115
const { message, finishReason, usage } = responseFromAnthropic(anthropicResponseBody);
116116
// message -> { role: 'assistant', content, tool_calls? } (tool input re-serialized to a JSON string)
117117
// finishReason -> 'stop' | 'tool_calls' | 'length' | 'content_filter' | 'unknown'
118118
// usage -> { inputTokens, outputTokens }
119119

120+
const responses = responseFromOpenAIResponses(openaiResponsesBody);
121+
// OpenAI Responses API `output_text` items become assistant `content`.
122+
// `function_call` items become Chat Completions-compatible `tool_calls`.
123+
120124
// Or dispatch by provider:
121125
normalizeResponse(geminiResponseBody, { from: 'gemini' });
126+
normalizeResponse(openaiResponsesBody, { from: 'openai-responses' });
122127
```
123128

124129
`finishReason` is normalized to `tool_calls` whenever the model called a tool, even
125-
for Gemini (which reports `STOP`). Gemini tool calls without an id get a
126-
deterministic one.
130+
for Gemini (which reports `STOP`) and Responses API bodies with `function_call`
131+
items. Gemini tool calls without an id get a deterministic one.
127132

128133
## Format cheatsheet
129134

ROADMAP.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ fallback behavior matter.
99

1010
1. **OpenAI Responses API coverage**
1111

12-
Track and test how Responses API text, multimodal and tool-call payloads map
13-
to the current OpenAI Chat Completions-compatible hub shape.
12+
Initial response normalization now maps Responses API `output_text` and
13+
`function_call` items back into the current OpenAI Chat Completions-compatible
14+
hub shape. Next: expand multimodal and streaming conformance fixtures.
1415

1516
Public issue: https://github.com/slegarraga/llm-messages/issues/6
1617

src/index.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,14 @@ export type { ConversationOf } from './convert.js';
55
export { parseDataUrl, toDataUrl } from './image.js';
66
export type { NormalizedImage } from './image.js';
77
export type { MediaPart, MediaModality, MediaSource } from './media.js';
8-
export { responseFromOpenAI, responseFromAnthropic, responseFromGemini, normalizeResponse } from './response.js';
9-
export type { NormalizedResponse, FinishReason, Usage } from './response.js';
8+
export {
9+
responseFromOpenAI,
10+
responseFromOpenAIResponses,
11+
responseFromAnthropic,
12+
responseFromGemini,
13+
normalizeResponse,
14+
} from './response.js';
15+
export type { NormalizedResponse, FinishReason, Usage, ResponseProvider } from './response.js';
1016

1117
export type {
1218
Provider,

src/response.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,63 @@ export function responseFromOpenAI(body: unknown): NormalizedResponse {
5858
};
5959
}
6060

61+
const OPENAI_RESPONSES_INCOMPLETE: Record<string, FinishReason> = {
62+
max_output_tokens: 'length',
63+
content_filter: 'content_filter',
64+
};
65+
66+
function responseApiFinishReason(root: Record<string, unknown>): FinishReason {
67+
if (root.status === 'completed') return 'stop';
68+
if (root.status !== 'incomplete') return 'unknown';
69+
70+
const details = isRecord(root.incomplete_details) ? root.incomplete_details : {};
71+
return OPENAI_RESPONSES_INCOMPLETE[String(details.reason)] ?? 'unknown';
72+
}
73+
74+
/** Normalizes an OpenAI Responses API response body. */
75+
export function responseFromOpenAIResponses(body: unknown): NormalizedResponse {
76+
const root = isRecord(body) ? body : {};
77+
const output = Array.isArray(root.output) ? root.output : [];
78+
const textPieces: string[] = [];
79+
const toolCalls: OpenAIToolCall[] = [];
80+
let counter = 0;
81+
82+
for (const item of output) {
83+
if (!isRecord(item)) continue;
84+
85+
if (item.type === 'message') {
86+
const content = Array.isArray(item.content) ? item.content : [];
87+
for (const part of content) {
88+
if (!isRecord(part)) continue;
89+
if (typeof part.text === 'string' && (part.type === 'output_text' || part.type === 'text')) {
90+
textPieces.push(part.text);
91+
} else if (part.type === 'refusal' && typeof part.refusal === 'string') {
92+
textPieces.push(part.refusal);
93+
}
94+
}
95+
} else if (item.type === 'function_call' && typeof item.name === 'string') {
96+
const name = item.name;
97+
const id =
98+
typeof item.call_id === 'string'
99+
? item.call_id
100+
: typeof item.id === 'string'
101+
? item.id
102+
: `call_${name.replace(/[^a-zA-Z0-9_-]/g, '_')}_${counter++}`;
103+
const args = typeof item.arguments === 'string' ? item.arguments : JSON.stringify(item.arguments ?? {});
104+
toolCalls.push({ id, type: 'function', function: { name, arguments: args } });
105+
}
106+
}
107+
108+
const usage = isRecord(root.usage) ? root.usage : {};
109+
return {
110+
message: buildMessage(textPieces.join(''), toolCalls),
111+
finishReason: finalReason(responseApiFinishReason(root), toolCalls),
112+
usage: { inputTokens: num(usage.input_tokens), outputTokens: num(usage.output_tokens) },
113+
};
114+
}
115+
116+
export type ResponseProvider = Provider | 'openai-responses';
117+
61118
/* ------------------------------- Anthropic ----------------------------- */
62119

63120
const ANTHROPIC_FINISH: Record<string, FinishReason> = {
@@ -147,12 +204,14 @@ export function responseFromGemini(body: unknown, options: ConvertOptions = {}):
147204
/** Normalizes a provider response body into the canonical shape. */
148205
export function normalizeResponse(
149206
body: unknown,
150-
route: { from: Provider },
207+
route: { from: ResponseProvider },
151208
options: ConvertOptions = {},
152209
): NormalizedResponse {
153210
switch (route.from) {
154211
case 'openai':
155212
return responseFromOpenAI(body);
213+
case 'openai-responses':
214+
return responseFromOpenAIResponses(body);
156215
case 'anthropic':
157216
return responseFromAnthropic(body);
158217
case 'gemini':

test/response.test.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { describe, it, expect } from 'vitest';
2-
import { responseFromOpenAI, responseFromAnthropic, responseFromGemini, normalizeResponse } from '../src/index.ts';
2+
import {
3+
responseFromOpenAI,
4+
responseFromOpenAIResponses,
5+
responseFromAnthropic,
6+
responseFromGemini,
7+
normalizeResponse,
8+
} from '../src/index.ts';
39
import type { Warning } from '../src/index.ts';
410

511
describe('responseFromOpenAI', () => {
@@ -28,6 +34,55 @@ describe('responseFromOpenAI', () => {
2834
});
2935
});
3036

37+
describe('responseFromOpenAIResponses', () => {
38+
it('collects output_text items, function calls, finish reason and usage', () => {
39+
const r = responseFromOpenAIResponses({
40+
object: 'response',
41+
status: 'completed',
42+
output: [
43+
{
44+
type: 'message',
45+
role: 'assistant',
46+
content: [{ type: 'output_text', text: 'Checking.' }],
47+
},
48+
{
49+
type: 'function_call',
50+
call_id: 'call_weather',
51+
name: 'get_weather',
52+
arguments: '{"location":"Paris"}',
53+
},
54+
],
55+
usage: { input_tokens: 12, output_tokens: 6, total_tokens: 18 },
56+
});
57+
58+
expect(r.message).toEqual({
59+
role: 'assistant',
60+
content: 'Checking.',
61+
tool_calls: [
62+
{
63+
id: 'call_weather',
64+
type: 'function',
65+
function: { name: 'get_weather', arguments: '{"location":"Paris"}' },
66+
},
67+
],
68+
});
69+
expect(r.finishReason).toBe('tool_calls');
70+
expect(r.usage).toEqual({ inputTokens: 12, outputTokens: 6 });
71+
});
72+
73+
it('maps incomplete max output token responses to length', () => {
74+
const r = responseFromOpenAIResponses({
75+
object: 'response',
76+
status: 'incomplete',
77+
incomplete_details: { reason: 'max_output_tokens' },
78+
output: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'partial' }] }],
79+
});
80+
81+
expect(r.message).toEqual({ role: 'assistant', content: 'partial' });
82+
expect(r.finishReason).toBe('length');
83+
});
84+
});
85+
3186
describe('responseFromAnthropic', () => {
3287
it('collects text, serializes tool input, maps stop_reason and usage', () => {
3388
const r = responseFromAnthropic({
@@ -100,4 +155,13 @@ describe('normalizeResponse', () => {
100155
expect(r.message.content).toBe('hi');
101156
expect(r.finishReason).toBe('stop');
102157
});
158+
159+
it('dispatches OpenAI Responses API bodies explicitly', () => {
160+
const r = normalizeResponse(
161+
{ object: 'response', status: 'completed', output: [{ type: 'message', content: [{ type: 'output_text', text: 'hi' }] }] },
162+
{ from: 'openai-responses' },
163+
);
164+
expect(r.message.content).toBe('hi');
165+
expect(r.finishReason).toBe('stop');
166+
});
103167
});

0 commit comments

Comments
 (0)