@effect-uai/responses 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@effect-uai/responses",
3
+ "version": "0.1.0",
4
+ "description": "OpenAI Responses API provider for @effect-uai/core.",
5
+ "keywords": [
6
+ "ai",
7
+ "effect",
8
+ "llm",
9
+ "openai",
10
+ "responses-api"
11
+ ],
12
+ "homepage": "https://github.com/betalyra/effect-uai/tree/main/packages/providers/responses",
13
+ "bugs": {
14
+ "url": "https://github.com/betalyra/effect-uai/issues"
15
+ },
16
+ "license": "MIT",
17
+ "author": "Betalyra",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/betalyra/effect-uai",
21
+ "directory": "packages/providers/responses"
22
+ },
23
+ "type": "module",
24
+ "main": "./dist/index.mjs",
25
+ "types": "./dist/index.d.mts",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.mts",
29
+ "import": "./dist/index.mjs"
30
+ }
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "src",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "peerDependencies": {
42
+ "effect": "4.0.0-beta.57",
43
+ "@effect-uai/core": "^0.1.0"
44
+ },
45
+ "devDependencies": {
46
+ "effect": "4.0.0-beta.57",
47
+ "typescript": "^6.0.3",
48
+ "@effect-uai/core": "0.1.0"
49
+ },
50
+ "scripts": {
51
+ "build": "tsdown",
52
+ "typecheck": "tsc --noEmit"
53
+ }
54
+ }
@@ -0,0 +1,316 @@
1
+ import { Context, Effect, Layer, Match, Option, Redacted, Schema, Stream } from "effect"
2
+ import { HttpClient, HttpClientRequest } from "effect/unstable/http"
3
+ import * as AiError from "@effect-uai/core/AiError"
4
+ import { matchType } from "@effect-uai/core/Match"
5
+ import * as StructuredFormat from "@effect-uai/core/StructuredFormat"
6
+ import {
7
+ type CommonRequest,
8
+ LanguageModel,
9
+ type LanguageModelService,
10
+ } from "@effect-uai/core/LanguageModel"
11
+ import * as SSE from "@effect-uai/core/SSE"
12
+ import type { TurnEvent } from "@effect-uai/core/Turn"
13
+ import { itemsToInput } from "./codec.js"
14
+ import type { OpenAIModel } from "./models.js"
15
+ import {
16
+ KnownProviderEvent,
17
+ ProviderEvent,
18
+ eventToDeltas,
19
+ makeCallIdLookup,
20
+ } from "./streamEvents.js"
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Public types
24
+ // ---------------------------------------------------------------------------
25
+
26
+ export interface ResponsesRequest extends Omit<CommonRequest, "model"> {
27
+ /**
28
+ * Narrows `CommonRequest.model` (`string`) to the typed `OpenAIModel`
29
+ * literal union for autocomplete.
30
+ */
31
+ readonly model: OpenAIModel
32
+ readonly reasoning?: { readonly effort: "low" | "medium" | "high" }
33
+ readonly store?: boolean
34
+ readonly previousResponseId?: string
35
+ readonly instructions?: string
36
+ readonly parallelToolCalls?: boolean
37
+ readonly metadata?: Readonly<Record<string, string>>
38
+ readonly user?: string
39
+ readonly safetyIdentifier?: string
40
+ readonly promptCacheKey?: string
41
+ readonly truncation?: "auto" | "disabled"
42
+ /**
43
+ * Free-form JSON output: model emits valid JSON without schema
44
+ * constraints. Mutually exclusive with `structured` (schema wins if
45
+ * both are set).
46
+ */
47
+ readonly responseFormat?: { readonly type: "json_object" }
48
+ /**
49
+ * Answer length / level-of-detail hint. GPT-5+ models honour it;
50
+ * earlier models ignore.
51
+ */
52
+ readonly verbosity?: "low" | "medium" | "high"
53
+ }
54
+
55
+ export interface ResponsesService {
56
+ /**
57
+ * Stream the provider's native event vocabulary (post-SSE-decode).
58
+ * Use this when you need full vendor fidelity. For provider-portable
59
+ * code, use `streamTurn` instead.
60
+ */
61
+ readonly streamNative: (
62
+ request: ResponsesRequest,
63
+ ) => Stream.Stream<ProviderEvent, AiError.AiError>
64
+ /**
65
+ * Stream canonical `TurnEvent`s. Implemented as
66
+ * `streamNative |> toCanonical`.
67
+ */
68
+ readonly streamTurn: (request: ResponsesRequest) => Stream.Stream<TurnEvent, AiError.AiError>
69
+ /**
70
+ * Project a stream of native `ProviderEvent`s into canonical `TurnEvent`s.
71
+ * Exposed for cases where consumers want to compose with `streamNative`
72
+ * directly (e.g. tap into natives + still get canonical downstream).
73
+ */
74
+ readonly toCanonical: <E, R>(
75
+ s: Stream.Stream<ProviderEvent, E, R>,
76
+ ) => Stream.Stream<TurnEvent, E, R>
77
+ }
78
+
79
+ /**
80
+ * Provider-typed service tag. Yield this when you want Responses-specific
81
+ * options (`reasoning.effort`, `store`, `previousResponseId`) at the call
82
+ * site. Yield the generic `LanguageModel` tag for provider-portable code -
83
+ * both are registered by `layer`.
84
+ */
85
+ export class Responses extends Context.Service<Responses, ResponsesService>()(
86
+ "@betalyra/effect-uai/providers/responses/Responses",
87
+ ) {}
88
+
89
+ export interface Config {
90
+ readonly apiKey: Redacted.Redacted
91
+ readonly baseUrl?: string
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Request body
96
+ // ---------------------------------------------------------------------------
97
+
98
+ const jsonSchemaFormat = (
99
+ format: StructuredFormat.StructuredFormat<unknown>,
100
+ ): Record<string, unknown> => ({
101
+ type: "json_schema",
102
+ name: format.name,
103
+ schema: format.schema["~standard"].jsonSchema.input({ target: "draft-2020-12" }),
104
+ ...(format.description !== undefined && { description: format.description }),
105
+ ...(format.strict !== undefined && { strict: format.strict }),
106
+ })
107
+
108
+ const buildText = (request: ResponsesRequest): Record<string, unknown> | undefined => {
109
+ const format =
110
+ request.structured !== undefined ? jsonSchemaFormat(request.structured) : request.responseFormat
111
+ const text: Record<string, unknown> = {}
112
+ if (format !== undefined) text.format = format
113
+ if (request.verbosity !== undefined) text.verbosity = request.verbosity
114
+ return Object.keys(text).length === 0 ? undefined : text
115
+ }
116
+
117
+ const buildBody = (request: ResponsesRequest): Record<string, unknown> => {
118
+ const text = buildText(request)
119
+ return {
120
+ model: request.model,
121
+ input: itemsToInput(request.history),
122
+ stream: true,
123
+ ...(request.tools !== undefined &&
124
+ request.tools.length > 0 && {
125
+ tools: request.tools.map((t) => ({
126
+ type: "function",
127
+ name: t.name,
128
+ description: t.description,
129
+ parameters: t.inputSchema,
130
+ ...(t.strict !== undefined && { strict: t.strict }),
131
+ })),
132
+ }),
133
+ ...(request.toolChoice !== undefined && { tool_choice: request.toolChoice }),
134
+ ...(request.temperature !== undefined && { temperature: request.temperature }),
135
+ ...(request.maxOutputTokens !== undefined && {
136
+ max_output_tokens: request.maxOutputTokens,
137
+ }),
138
+ ...(request.reasoning !== undefined && { reasoning: request.reasoning }),
139
+ ...(request.store !== undefined && { store: request.store }),
140
+ ...(request.previousResponseId !== undefined && {
141
+ previous_response_id: request.previousResponseId,
142
+ }),
143
+ ...(request.instructions !== undefined && { instructions: request.instructions }),
144
+ ...(request.topP !== undefined && { top_p: request.topP }),
145
+ ...(request.parallelToolCalls !== undefined && {
146
+ parallel_tool_calls: request.parallelToolCalls,
147
+ }),
148
+ ...(request.metadata !== undefined && { metadata: request.metadata }),
149
+ ...(request.user !== undefined && { user: request.user }),
150
+ ...(request.safetyIdentifier !== undefined && {
151
+ safety_identifier: request.safetyIdentifier,
152
+ }),
153
+ ...(request.promptCacheKey !== undefined && {
154
+ prompt_cache_key: request.promptCacheKey,
155
+ }),
156
+ ...(request.truncation !== undefined && { truncation: request.truncation }),
157
+ ...(text !== undefined && { text }),
158
+ }
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // SSE event → provider event
163
+ // ---------------------------------------------------------------------------
164
+
165
+ const decodeKnown = Schema.decodeUnknownEffect(KnownProviderEvent)
166
+
167
+ const makeUnknown = (raw: unknown): ProviderEvent => ({ type: "_unknown", raw })
168
+
169
+ /**
170
+ * Lift events that carry a terminal failure signal (`error`,
171
+ * `response.failed`) to a typed `AiError`. Other events are not failures
172
+ * and produce `Option.none`.
173
+ */
174
+ const eventToError = Match.type<ProviderEvent>().pipe(
175
+ matchType(
176
+ "error",
177
+ (e): AiError.AiError => new AiError.Unavailable({ provider: "responses", raw: e }),
178
+ ),
179
+ matchType("response.failed", (e): AiError.AiError => {
180
+ const code = e.response.error?.code
181
+ const message = e.response.error?.message
182
+ return new AiError.GenerationFailed({
183
+ provider: "responses",
184
+ ...(code !== undefined && code !== null && { code }),
185
+ ...(message !== undefined && message !== null && { message }),
186
+ raw: e,
187
+ })
188
+ }),
189
+ Match.option,
190
+ )
191
+
192
+ const sseEventToProviderEvent = (ev: SSE.Event): Effect.Effect<ProviderEvent> =>
193
+ Effect.try({
194
+ try: () => JSON.parse(ev.data) as unknown,
195
+ catch: () => ev.data,
196
+ }).pipe(
197
+ Effect.flatMap((parsed) =>
198
+ decodeKnown(parsed).pipe(Effect.orElseSucceed(() => makeUnknown(parsed))),
199
+ ),
200
+ Effect.orElseSucceed(() => makeUnknown(ev.data)),
201
+ )
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // Service implementation
205
+ // ---------------------------------------------------------------------------
206
+
207
+ const httpStatusError = (status: number, body: string): AiError.AiError => {
208
+ const provider = "responses"
209
+ const raw = body
210
+ if (status === 429) return new AiError.RateLimited({ provider, raw })
211
+ if (status === 408 || status === 504) return new AiError.Timeout({ provider, raw })
212
+ if (status === 401) return new AiError.AuthFailed({ provider, subtype: "auth", raw })
213
+ if (status === 403) return new AiError.AuthFailed({ provider, subtype: "permission", raw })
214
+ if (status === 402) return new AiError.AuthFailed({ provider, subtype: "billing", raw })
215
+ if (status === 413) return new AiError.ContextLengthExceeded({ provider, raw })
216
+ if (status >= 500) return new AiError.Unavailable({ provider, status, raw })
217
+ return new AiError.InvalidRequest({ provider, raw })
218
+ }
219
+
220
+ const buildNativeStream = (cfg: Config) => {
221
+ const url = `${cfg.baseUrl ?? "https://api.openai.com/v1"}/responses`
222
+ return (
223
+ request: ResponsesRequest,
224
+ ): Stream.Stream<ProviderEvent, AiError.AiError, HttpClient.HttpClient> =>
225
+ Stream.unwrap(
226
+ Effect.gen(function* () {
227
+ const client = yield* HttpClient.HttpClient
228
+ const httpRequest = HttpClientRequest.post(url).pipe(
229
+ HttpClientRequest.bearerToken(cfg.apiKey),
230
+ HttpClientRequest.bodyJsonUnsafe(buildBody(request)),
231
+ HttpClientRequest.accept("text/event-stream"),
232
+ )
233
+ const response = yield* client
234
+ .execute(httpRequest)
235
+ .pipe(
236
+ Effect.mapError(
237
+ (cause) => new AiError.Unavailable({ provider: "responses", raw: cause }),
238
+ ),
239
+ )
240
+ if (response.status >= 400) {
241
+ const body = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
242
+ return Stream.fail(httpStatusError(response.status, body))
243
+ }
244
+
245
+ return response.stream.pipe(
246
+ Stream.mapError(
247
+ (cause) => new AiError.Unavailable({ provider: "responses", raw: cause }),
248
+ ),
249
+ SSE.fromBytes,
250
+ Stream.mapEffect(sseEventToProviderEvent),
251
+ Stream.flatMap((event) =>
252
+ Option.match(eventToError(event), {
253
+ onNone: () => Stream.succeed(event),
254
+ onSome: Stream.fail,
255
+ }),
256
+ ),
257
+ )
258
+ }),
259
+ )
260
+ }
261
+
262
+ /**
263
+ * Project a stream of native `ProviderEvent`s into canonical `TurnEvent`s.
264
+ * Threads a fresh `CallIdLookup` per stream so `function_call_arguments.delta`
265
+ * can be tagged with the right `call_id`.
266
+ */
267
+ export const toCanonical = <E, R>(
268
+ s: Stream.Stream<ProviderEvent, E, R>,
269
+ ): Stream.Stream<TurnEvent, E, R> =>
270
+ Stream.unwrap(
271
+ Effect.sync(() => {
272
+ const lookup = makeCallIdLookup()
273
+ return s.pipe(Stream.flatMap((event) => Stream.fromIterable(eventToDeltas(event, lookup))))
274
+ }),
275
+ )
276
+
277
+ // ---------------------------------------------------------------------------
278
+ // Constructors
279
+ // ---------------------------------------------------------------------------
280
+
281
+ /**
282
+ * Build a `ResponsesService` value. For Layer-based setup, prefer `layer`.
283
+ */
284
+ export const make = (cfg: Config): Effect.Effect<ResponsesService, never, HttpClient.HttpClient> =>
285
+ Effect.map(HttpClient.HttpClient.asEffect(), (client) => {
286
+ const streamNative: ResponsesService["streamNative"] = (request) =>
287
+ buildNativeStream(cfg)(request).pipe(Stream.provideService(HttpClient.HttpClient, client))
288
+ return {
289
+ streamNative,
290
+ streamTurn: (request) => toCanonical(streamNative(request)),
291
+ toCanonical,
292
+ }
293
+ })
294
+
295
+ /**
296
+ * Layer that registers both the provider-specific `Responses` tag and the
297
+ * generic `LanguageModel` tag, sharing one underlying implementation.
298
+ *
299
+ * The generic tag accepts `CommonRequest`; the typed tag accepts the full
300
+ * `ResponsesRequest` surface.
301
+ */
302
+ export const layer = (
303
+ cfg: Config,
304
+ ): Layer.Layer<Responses | LanguageModel, never, HttpClient.HttpClient> => {
305
+ const typed = Layer.effect(Responses, make(cfg))
306
+ const generic = Layer.effect(
307
+ LanguageModel,
308
+ Effect.map(
309
+ make(cfg),
310
+ (s): LanguageModelService => ({
311
+ streamTurn: (request) => s.streamTurn(request as ResponsesRequest),
312
+ }),
313
+ ),
314
+ )
315
+ return Layer.merge(typed, generic)
316
+ }
package/src/codec.ts ADDED
@@ -0,0 +1,298 @@
1
+ import { Match, Option, Schema } from "effect"
2
+ import type { ContentBlock, Item } from "@effect-uai/core/Items"
3
+ import { matchType } from "@effect-uai/core/Match"
4
+ import type { Turn } from "@effect-uai/core/Turn"
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Wire schemas - minimal subset of the Responses API output we consume.
8
+ // Reference: https://www.openresponses.org/specification
9
+ // ---------------------------------------------------------------------------
10
+
11
+ const WireUrlCitation = Schema.Struct({
12
+ type: Schema.Literal("url_citation"),
13
+ url: Schema.String,
14
+ start_index: Schema.Number,
15
+ end_index: Schema.Number,
16
+ title: Schema.String,
17
+ })
18
+
19
+ const WireFileCitation = Schema.Struct({
20
+ type: Schema.Literal("file_citation"),
21
+ file_id: Schema.String,
22
+ index: Schema.Number,
23
+ })
24
+
25
+ const WireContainerFileCitation = Schema.Struct({
26
+ type: Schema.Literal("container_file_citation"),
27
+ container_id: Schema.String,
28
+ file_id: Schema.String,
29
+ start_index: Schema.Number,
30
+ end_index: Schema.Number,
31
+ })
32
+
33
+ const WireFilePath = Schema.Struct({
34
+ type: Schema.Literal("file_path"),
35
+ file_id: Schema.String,
36
+ index: Schema.Number,
37
+ })
38
+
39
+ const WireAnnotation = Schema.Union([
40
+ WireUrlCitation,
41
+ WireFileCitation,
42
+ WireContainerFileCitation,
43
+ WireFilePath,
44
+ ])
45
+
46
+ const WireOutputTextContent = Schema.Struct({
47
+ type: Schema.Literal("output_text"),
48
+ text: Schema.String,
49
+ annotations: Schema.optional(Schema.NullOr(Schema.Array(WireAnnotation))),
50
+ })
51
+
52
+ const WireRefusalContent = Schema.Struct({
53
+ type: Schema.Literal("refusal"),
54
+ refusal: Schema.String,
55
+ })
56
+
57
+ const WireMessageContent = Schema.Union([WireOutputTextContent, WireRefusalContent])
58
+
59
+ const WireSummaryText = Schema.Struct({
60
+ type: Schema.Literal("summary_text"),
61
+ text: Schema.String,
62
+ })
63
+
64
+ const WireMessage = Schema.Struct({
65
+ type: Schema.Literal("message"),
66
+ id: Schema.optional(Schema.String),
67
+ role: Schema.Literal("assistant"),
68
+ content: Schema.Array(WireMessageContent),
69
+ })
70
+
71
+ const WireFunctionCall = Schema.Struct({
72
+ type: Schema.Literal("function_call"),
73
+ id: Schema.optional(Schema.String),
74
+ call_id: Schema.String,
75
+ name: Schema.String,
76
+ arguments: Schema.String,
77
+ })
78
+
79
+ const WireReasoning = Schema.Struct({
80
+ type: Schema.Literal("reasoning"),
81
+ id: Schema.optional(Schema.String),
82
+ summary: Schema.optional(Schema.Array(WireSummaryText)),
83
+ encrypted_content: Schema.optional(Schema.String),
84
+ })
85
+
86
+ export const WireOutputItem = Schema.Union([WireMessage, WireFunctionCall, WireReasoning])
87
+ export type WireOutputItem = typeof WireOutputItem.Type
88
+
89
+ const WireInputTokensDetails = Schema.Struct({
90
+ cached_tokens: Schema.optional(Schema.NullOr(Schema.Number)),
91
+ })
92
+
93
+ const WireOutputTokensDetails = Schema.Struct({
94
+ reasoning_tokens: Schema.optional(Schema.NullOr(Schema.Number)),
95
+ })
96
+
97
+ const WireUsage = Schema.Struct({
98
+ input_tokens: Schema.optional(Schema.Number),
99
+ output_tokens: Schema.optional(Schema.Number),
100
+ total_tokens: Schema.optional(Schema.Number),
101
+ input_tokens_details: Schema.optional(Schema.NullOr(WireInputTokensDetails)),
102
+ output_tokens_details: Schema.optional(Schema.NullOr(WireOutputTokensDetails)),
103
+ })
104
+
105
+ const WireResponseError = Schema.Struct({
106
+ code: Schema.optional(Schema.NullOr(Schema.String)),
107
+ message: Schema.optional(Schema.NullOr(Schema.String)),
108
+ })
109
+
110
+ // Many Responses-API fields are emitted as explicit `null` rather than
111
+ // missing - `Schema.optional` alone (T | undefined) doesn't cover that, so
112
+ // we lift each through `NullOr` first. Used by `response.completed`,
113
+ // `response.incomplete`, and `response.failed` events; not every field is
114
+ // populated on every event (e.g. `error` only on failed, `incomplete_details`
115
+ // only on incomplete).
116
+ export const WireResponseCompleted = Schema.Struct({
117
+ id: Schema.optional(Schema.NullOr(Schema.String)),
118
+ status: Schema.optional(Schema.NullOr(Schema.String)),
119
+ output: Schema.optional(Schema.NullOr(Schema.Array(WireOutputItem))),
120
+ usage: Schema.optional(Schema.NullOr(WireUsage)),
121
+ incomplete_details: Schema.optional(
122
+ Schema.NullOr(
123
+ Schema.Struct({
124
+ reason: Schema.optional(Schema.NullOr(Schema.String)),
125
+ }),
126
+ ),
127
+ ),
128
+ error: Schema.optional(Schema.NullOr(WireResponseError)),
129
+ })
130
+ export type WireResponseCompleted = typeof WireResponseCompleted.Type
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // History → request body input
134
+ // ---------------------------------------------------------------------------
135
+
136
+ /**
137
+ * If an item carries `providerData` from a prior OpenAI turn, re-emit it
138
+ * verbatim - preserves `encrypted_content`, item ids, and any field this
139
+ * codec doesn't model.
140
+ */
141
+ const passthrough = (item: Item): Record<string, unknown> | undefined =>
142
+ item.providerData !== undefined &&
143
+ typeof item.providerData === "object" &&
144
+ item.providerData !== null
145
+ ? (item.providerData as Record<string, unknown>)
146
+ : undefined
147
+
148
+ const contentBlockToInput = Match.type<ContentBlock>().pipe(
149
+ matchType("input_text", (b) => ({ type: "input_text", text: b.text })),
150
+ matchType("input_image", (b) => ({
151
+ type: "input_image",
152
+ image_url:
153
+ b.source._tag === "url"
154
+ ? b.source.url
155
+ : `data:${b.source.media_type};base64,${b.source.data}`,
156
+ })),
157
+ matchType("output_text", (b) => ({ type: "output_text", text: b.text })),
158
+ matchType("refusal", (b) => ({ type: "refusal", refusal: b.text })),
159
+ Match.exhaustive,
160
+ )
161
+
162
+ const itemToInput = (item: Item): Record<string, unknown> =>
163
+ passthrough(item) ??
164
+ Match.value(item).pipe(
165
+ matchType("message", (m) => ({
166
+ type: "message",
167
+ role: m.role,
168
+ content: m.content.map(contentBlockToInput),
169
+ })),
170
+ matchType("function_call", (f) => ({
171
+ type: "function_call",
172
+ call_id: f.call_id,
173
+ name: f.name,
174
+ arguments: f.arguments,
175
+ })),
176
+ matchType("function_call_output", (o) => ({
177
+ type: "function_call_output",
178
+ call_id: o.call_id,
179
+ output: o.output,
180
+ })),
181
+ matchType("reasoning", (r) => ({
182
+ type: "reasoning",
183
+ ...(r.id !== undefined && { id: r.id }),
184
+ ...(r.summary !== undefined && {
185
+ summary: [{ type: "summary_text", text: r.summary }],
186
+ }),
187
+ ...(r.signature !== undefined && { encrypted_content: r.signature }),
188
+ })),
189
+ Match.exhaustive,
190
+ )
191
+
192
+ /** Convert our `Item[]` history into the Responses API `input` array. */
193
+ export const itemsToInput = (items: ReadonlyArray<Item>): ReadonlyArray<Record<string, unknown>> =>
194
+ items.map(itemToInput)
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Wire output items → our Items
198
+ // ---------------------------------------------------------------------------
199
+
200
+ const wireMessageContentToBlock = Match.type<typeof WireMessageContent.Type>().pipe(
201
+ matchType(
202
+ "output_text",
203
+ (c): ContentBlock => ({
204
+ type: "output_text",
205
+ text: c.text,
206
+ ...(c.annotations !== undefined && c.annotations !== null && { annotations: c.annotations }),
207
+ }),
208
+ ),
209
+ matchType("refusal", (c): ContentBlock => ({ type: "refusal", text: c.refusal })),
210
+ Match.exhaustive,
211
+ )
212
+
213
+ export const wireItemToItem = (wire: WireOutputItem): Item =>
214
+ Match.value(wire).pipe(
215
+ matchType("message", (m) => ({
216
+ type: "message" as const,
217
+ role: m.role,
218
+ content: m.content.map(wireMessageContentToBlock),
219
+ providerData: m,
220
+ })),
221
+ matchType("function_call", (f) => ({
222
+ type: "function_call" as const,
223
+ call_id: f.call_id,
224
+ name: f.name,
225
+ arguments: f.arguments,
226
+ providerData: f,
227
+ })),
228
+ matchType("reasoning", (r) => ({
229
+ type: "reasoning" as const,
230
+ ...(r.id !== undefined && { id: r.id }),
231
+ ...(r.summary !== undefined && {
232
+ summary: r.summary.map((s) => s.text).join("\n"),
233
+ }),
234
+ ...(r.encrypted_content !== undefined && { signature: r.encrypted_content }),
235
+ providerData: r,
236
+ })),
237
+ Match.exhaustive,
238
+ )
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // response.completed → Turn
242
+ // ---------------------------------------------------------------------------
243
+
244
+ const reasonToStop = Match.type<string>().pipe(
245
+ Match.when("max_output_tokens", () => "max_tokens" as const),
246
+ Match.when("refusal", () => "refusal" as const),
247
+ Match.when("content_filter", () => "content_filter" as const),
248
+ Match.when("max_tool_calls", () => "max_tool_calls" as const),
249
+ Match.option,
250
+ )
251
+
252
+ const hasRefusalContent = (payload: WireResponseCompleted): boolean =>
253
+ (payload.output ?? []).some(
254
+ (i) => i.type === "message" && i.content.some((c) => c.type === "refusal"),
255
+ )
256
+
257
+ const stopReasonFromCompleted = (payload: WireResponseCompleted): Turn["stop_reason"] =>
258
+ Option.match(reasonToStop(payload.incomplete_details?.reason ?? ""), {
259
+ onSome: (s) => s,
260
+ onNone: () =>
261
+ hasRefusalContent(payload)
262
+ ? ("refusal" as const)
263
+ : (payload.output ?? []).some((i) => i.type === "function_call")
264
+ ? ("tool_calls" as const)
265
+ : ("stop" as const),
266
+ })
267
+
268
+ const cachedTokens = (payload: WireResponseCompleted): number | undefined =>
269
+ payload.usage?.input_tokens_details?.cached_tokens ?? undefined
270
+
271
+ const reasoningTokens = (payload: WireResponseCompleted): number | undefined =>
272
+ payload.usage?.output_tokens_details?.reasoning_tokens ?? undefined
273
+
274
+ export const turnFromCompleted = (payload: WireResponseCompleted): Turn => {
275
+ const cached = cachedTokens(payload)
276
+ const reasoning = reasoningTokens(payload)
277
+ return {
278
+ items: (payload.output ?? []).map(wireItemToItem),
279
+ usage: {
280
+ ...(payload.usage?.input_tokens !== undefined && {
281
+ input_tokens: payload.usage.input_tokens,
282
+ }),
283
+ ...(payload.usage?.output_tokens !== undefined && {
284
+ output_tokens: payload.usage.output_tokens,
285
+ }),
286
+ ...(payload.usage?.total_tokens !== undefined && {
287
+ total_tokens: payload.usage.total_tokens,
288
+ }),
289
+ ...(cached !== undefined && {
290
+ input_tokens_details: { cached_tokens: cached },
291
+ }),
292
+ ...(reasoning !== undefined && {
293
+ output_tokens_details: { reasoning_tokens: reasoning },
294
+ }),
295
+ },
296
+ stop_reason: stopReasonFromCompleted(payload),
297
+ }
298
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from "./Responses.js"
2
+ export * from "./models.js"
3
+ export * as codec from "./codec.js"
4
+ export * as streamEvents from "./streamEvents.js"
package/src/models.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Known OpenAI model identifiers usable via the Responses API (as of
3
+ * April 2026). The `(string & {})` tail keeps autocomplete on the literals
4
+ * while still accepting any string, so newly-released models work without
5
+ * an SDK update.
6
+ *
7
+ * Reference: https://developers.openai.com/api/docs/models/all
8
+ */
9
+ export type OpenAIModel =
10
+ | "gpt-5.5"
11
+ | "gpt-5.5-pro"
12
+ | "gpt-5.4"
13
+ | "gpt-5.4-pro"
14
+ | "gpt-5.4-mini"
15
+ | "gpt-5.4-nano"
16
+ | "gpt-5"
17
+ | "gpt-5-mini"
18
+ | "gpt-5-nano"
19
+ | "gpt-5.3-codex"
20
+ | "gpt-4.1"
21
+ | "gpt-4.1-mini"
22
+ | "gpt-4o-mini"
23
+ // eslint-disable-next-line @typescript-eslint/ban-types
24
+ | (string & {})