@ai-sdk/openai 4.0.109 → 4.0.110

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.
@@ -2005,6 +2005,47 @@ message into `result.output`. This prevents commentary or repeated final answers
2005
2005
  from being joined into invalid JSON. The returned `text`, `content`, response
2006
2006
  messages, and token usage still include the complete response as before.
2007
2007
 
2008
+ #### Refusals
2009
+
2010
+ The Responses API can answer a request with a `refusal` content part instead of
2011
+ `output_text`. The provider surfaces the refusal explanation as text, and
2012
+ reports a `content-filter` finish reason in both `generateText` and
2013
+ `streamText`. When the same response also contains a client-side function call,
2014
+ the finish reason is `tool-calls` instead, so the tools are still executed; the
2015
+ refusal text remains part of the content.
2016
+
2017
+ Refusal content parts mainly appear with structured outputs (`Output.object` or
2018
+ a JSON schema passed to the model). With structured output, a refusal means no
2019
+ object can be generated: `generateText` throws `NoObjectGeneratedError` with the
2020
+ refusal text on `error.text` and `error.finishReason` set to `'content-filter'`.
2021
+
2022
+ ```ts
2023
+ import { NoObjectGeneratedError, Output, generateText } from 'ai';
2024
+ import { openai } from '@ai-sdk/openai';
2025
+ import { z } from 'zod';
2026
+
2027
+ try {
2028
+ await generateText({
2029
+ model: openai.responses('gpt-4o'),
2030
+ output: Output.object({
2031
+ schema: z.object({ steps: z.array(z.string()) }),
2032
+ }),
2033
+ prompt: '...',
2034
+ });
2035
+ } catch (error) {
2036
+ if (NoObjectGeneratedError.isInstance(error)) {
2037
+ // the model's refusal explanation
2038
+ console.log(error.text);
2039
+ // 'content-filter'
2040
+ console.log(error.finishReason);
2041
+ }
2042
+ }
2043
+ ```
2044
+
2045
+ Without structured output, the model may still decline in plain text. Such a
2046
+ response arrives as ordinary text with a `stop` finish reason, so check the text
2047
+ in your application if you need to detect declines.
2048
+
2008
2049
  #### Typed providerMetadata in Text Parts
2009
2050
 
2010
2051
  When using the OpenAI Responses API, the SDK attaches OpenAI-specific metadata to output parts via `providerMetadata`.
@@ -2705,6 +2746,33 @@ You need to change Zod `.nullish()` and `.optional()` to `.nullable()`.
2705
2746
 
2706
2747
  </Note>
2707
2748
 
2749
+ #### Refusals
2750
+
2751
+ Refusals mainly happen when you request structured outputs. When OpenAI declines
2752
+ a request, the provider surfaces the refusal explanation as text and reports a
2753
+ `content-filter` finish reason in both `generateText` and `streamText`:
2754
+
2755
+ ```ts
2756
+ const { text, finishReason } = await generateText({
2757
+ model: openai.chat('gpt-4o'),
2758
+ prompt: '...',
2759
+ });
2760
+
2761
+ if (finishReason === 'content-filter') {
2762
+ // `text` contains the model's refusal explanation
2763
+ }
2764
+ ```
2765
+
2766
+ When you request structured output, a refusal means that no object can be
2767
+ generated: `generateText` throws `NoObjectGeneratedError` with the refusal text
2768
+ on `error.text`.
2769
+
2770
+ When you request logprobs, `providerMetadata.openai.logprobs` contains the
2771
+ logprobs of all emitted tokens: the content logprobs first, followed by the
2772
+ refusal logprobs. This is the same for `generateText` and `streamText`; with
2773
+ `streamText`, the logprobs of all streamed tokens are reported when the stream
2774
+ finishes.
2775
+
2708
2776
  #### Logprobs
2709
2777
 
2710
2778
  OpenAI provides logprobs information for completion/chat models.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-sdk/openai",
3
- "version": "4.0.109",
3
+ "version": "4.0.110",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "sideEffects": false,
@@ -1,11 +1,18 @@
1
1
  import type { LanguageModelV4FinishReason } from '@ai-sdk/provider';
2
2
 
3
- export function mapOpenAIFinishReason(
4
- finishReason: string | null | undefined,
5
- ): LanguageModelV4FinishReason['unified'] {
3
+ export function mapOpenAIFinishReason({
4
+ finishReason,
5
+ hasRefusal = false,
6
+ }: {
7
+ finishReason: string | null | undefined;
8
+ // flag that checks if the model refused to answer:
9
+ hasRefusal?: boolean;
10
+ }): LanguageModelV4FinishReason['unified'] {
6
11
  switch (finishReason) {
7
12
  case 'stop':
8
- return 'stop';
13
+ // OpenAI reports a completed response for refusals, so the refusal is
14
+ // the reason the response ended:
15
+ return hasRefusal ? 'content-filter' : 'stop';
9
16
  case 'length':
10
17
  return 'length';
11
18
  case 'content_filter':
@@ -36,6 +36,8 @@ export const openaiChatResponseSchema = lazySchema(() =>
36
36
  message: z.object({
37
37
  role: z.literal('assistant').nullish(),
38
38
  content: z.string().nullish(),
39
+ // set instead of `content` when the model refuses to answer:
40
+ refusal: z.string().nullish(),
39
41
  audio: z
40
42
  .object({
41
43
  transcript: z.string().nullish(),
@@ -84,6 +86,22 @@ export const openaiChatResponseSchema = lazySchema(() =>
84
86
  }),
85
87
  )
86
88
  .nullish(),
89
+ // logprobs of the refusal tokens (set instead of `content` when
90
+ // the model refuses to answer):
91
+ refusal: z
92
+ .array(
93
+ z.object({
94
+ token: z.string(),
95
+ logprob: z.number(),
96
+ top_logprobs: z.array(
97
+ z.object({
98
+ token: z.string(),
99
+ logprob: z.number(),
100
+ }),
101
+ ),
102
+ }),
103
+ )
104
+ .nullish(),
87
105
  })
88
106
  .nullish(),
89
107
  finish_reason: z.string().nullish(),
@@ -128,6 +146,8 @@ export const openaiChatChunkSchema = lazySchema(() =>
128
146
  .object({
129
147
  role: z.enum(['assistant']).nullish(),
130
148
  content: z.string().nullish(),
149
+ // set instead of `content` when the model refuses to answer:
150
+ refusal: z.string().nullish(),
131
151
  tool_calls: z
132
152
  .array(
133
153
  z.object({
@@ -172,6 +192,22 @@ export const openaiChatChunkSchema = lazySchema(() =>
172
192
  }),
173
193
  )
174
194
  .nullish(),
195
+ // logprobs of the refusal tokens (set instead of `content` when
196
+ // the model refuses to answer):
197
+ refusal: z
198
+ .array(
199
+ z.object({
200
+ token: z.string(),
201
+ logprob: z.number(),
202
+ top_logprobs: z.array(
203
+ z.object({
204
+ token: z.string(),
205
+ logprob: z.number(),
206
+ }),
207
+ ),
208
+ }),
209
+ )
210
+ .nullish(),
175
211
  })
176
212
  .nullish(),
177
213
  finish_reason: z.string().nullish(),
@@ -53,6 +53,15 @@ import {
53
53
  import { prepareChatTools } from './openai-chat-prepare-tools';
54
54
  import { normalizeOpenAIJsonSchema } from '../normalize-openai-json-schema';
55
55
 
56
+ type OpenAIChatLogprob = NonNullable<
57
+ NonNullable<
58
+ Extract<
59
+ OpenAIChatChunk,
60
+ { choices: unknown }
61
+ >['choices'][number]['logprobs']
62
+ >['content']
63
+ >[number];
64
+
56
65
  type OpenAIChatConfig = {
57
66
  provider: string;
58
67
  /** Model ID sent to the API. Defaults to the model's reported ID. */
@@ -447,7 +456,10 @@ export class OpenAIChatLanguageModel implements LanguageModelV4 {
447
456
 
448
457
  const content: Array<LanguageModelV4Content> = [];
449
458
 
450
- // text content:
459
+ // text content. When the model refuses to answer, the API returns the
460
+ // refusal in `refusal` and sets `content` to null, so surface it as text
461
+ // instead of dropping it. Content and refusal are both returned when the
462
+ // model refuses after answering, so keep both:
451
463
  const text =
452
464
  choice.message.content != null && choice.message.content.length > 0
453
465
  ? choice.message.content
@@ -456,6 +468,12 @@ export class OpenAIChatLanguageModel implements LanguageModelV4 {
456
468
  content.push({ type: 'text', text });
457
469
  }
458
470
 
471
+ const refusal = choice.message.refusal;
472
+ const hasRefusal = refusal != null && refusal.length > 0;
473
+ if (hasRefusal) {
474
+ content.push({ type: 'text', text: refusal });
475
+ }
476
+
459
477
  // tool calls:
460
478
  for (const toolCall of choice.message.tool_calls ?? []) {
461
479
  content.push({
@@ -488,14 +506,25 @@ export class OpenAIChatLanguageModel implements LanguageModelV4 {
488
506
  providerMetadata.openai.rejectedPredictionTokens =
489
507
  completionTokenDetails?.rejected_prediction_tokens;
490
508
  }
491
- if (choice.logprobs?.content != null) {
492
- providerMetadata.openai.logprobs = choice.logprobs.content;
509
+ // logprobs either follow the text content (content) or the refusal, so
510
+ // combine them in the order the text is emitted above. Explicitly empty
511
+ // logprobs are reported as an empty array, as before:
512
+ const contentLogprobs = choice.logprobs?.content;
513
+ const refusalLogprobs = choice.logprobs?.refusal;
514
+ if (contentLogprobs != null || refusalLogprobs != null) {
515
+ providerMetadata.openai.logprobs = [
516
+ ...(contentLogprobs ?? []),
517
+ ...(refusalLogprobs ?? []),
518
+ ];
493
519
  }
494
520
 
495
521
  return {
496
522
  content,
497
523
  finishReason: {
498
- unified: mapOpenAIFinishReason(choice.finish_reason),
524
+ unified: mapOpenAIFinishReason({
525
+ finishReason: choice.finish_reason,
526
+ hasRefusal,
527
+ }),
499
528
  raw: choice.finish_reason ?? undefined,
500
529
  },
501
530
  usage: convertOpenAIChatUsage(response.usage),
@@ -562,6 +591,13 @@ export class OpenAIChatLanguageModel implements LanguageModelV4 {
562
591
  let usage: OpenAIChatUsage | undefined = undefined;
563
592
  let metadataExtracted = false;
564
593
  let isActiveText = false;
594
+ let hasRefusal = false;
595
+ let rawFinishReason: string | undefined = undefined;
596
+ let hasLogprobs = false;
597
+
598
+ // logprobs of all streamed chunks (content token logprobs followed by
599
+ // refusal token logprobs), collected across chunks:
600
+ const streamedLogprobs: OpenAIChatLogprob[] = [];
565
601
 
566
602
  const providerMetadata: SharedV4ProviderMetadata = { openai: {} };
567
603
 
@@ -587,6 +623,7 @@ export class OpenAIChatLanguageModel implements LanguageModelV4 {
587
623
  // handle failed chunk parsing / validation:
588
624
  if (!chunk.success) {
589
625
  finishReason = { unified: 'error', raw: undefined };
626
+ rawFinishReason = undefined;
590
627
  controller.enqueue({ type: 'error', error: chunk.error });
591
628
  return;
592
629
  }
@@ -596,6 +633,7 @@ export class OpenAIChatLanguageModel implements LanguageModelV4 {
596
633
  // handle error chunks:
597
634
  if ('error' in value) {
598
635
  finishReason = { unified: 'error', raw: undefined };
636
+ rawFinishReason = undefined;
599
637
  controller.enqueue({
600
638
  type: 'error',
601
639
  error:
@@ -639,15 +677,29 @@ export class OpenAIChatLanguageModel implements LanguageModelV4 {
639
677
 
640
678
  const choice = value.choices[0];
641
679
 
680
+ // store the raw finish reason and map it in `flush`, after all
681
+ // chunks have been read: some servers send the last delta (which
682
+ // can contain a refusal) in the same chunk as the finish reason,
683
+ // and a refusal turns `stop` into `content-filter`:
642
684
  if (choice?.finish_reason != null) {
643
- finishReason = {
644
- unified: mapOpenAIFinishReason(choice.finish_reason),
645
- raw: choice.finish_reason,
646
- };
685
+ rawFinishReason = choice.finish_reason;
647
686
  }
648
687
 
649
- if (choice?.logprobs?.content != null) {
650
- providerMetadata.openai.logprobs = choice.logprobs.content;
688
+ // logprobs either follow the text content (content) or the refusal.
689
+ // They are streamed token by token, so collect the logprobs of all
690
+ // chunks (content logprobs followed by refusal logprobs) and report
691
+ // the complete list at the end of the stream:
692
+ if (choice?.logprobs != null) {
693
+ const contentLogprobs = choice.logprobs.content;
694
+ const refusalLogprobs = choice.logprobs.refusal;
695
+
696
+ if (contentLogprobs != null || refusalLogprobs != null) {
697
+ hasLogprobs = true;
698
+ streamedLogprobs.push(
699
+ ...(contentLogprobs ?? []),
700
+ ...(refusalLogprobs ?? []),
701
+ );
702
+ }
651
703
  }
652
704
 
653
705
  if (choice?.delta == null) {
@@ -656,7 +708,24 @@ export class OpenAIChatLanguageModel implements LanguageModelV4 {
656
708
 
657
709
  const delta = choice.delta;
658
710
 
659
- if (delta.content != null) {
711
+ // When the model refuses to answer, the API streams the refusal in
712
+ // `refusal` instead of `content`. Content and refusal deltas can
713
+ // both occur, so append each to the same text part instead of
714
+ // dropping one:
715
+ const textDeltas: string[] = [];
716
+ if (delta.content != null && delta.content.length > 0) {
717
+ textDeltas.push(delta.content);
718
+ }
719
+ if (delta.refusal != null && delta.refusal.length > 0) {
720
+ hasRefusal = true;
721
+ textDeltas.push(delta.refusal);
722
+ }
723
+ // empty `content: ""` deltas keep their previous behavior:
724
+ if (textDeltas.length === 0 && delta.content === '') {
725
+ textDeltas.push(delta.content);
726
+ }
727
+
728
+ for (const textDelta of textDeltas) {
660
729
  if (!isActiveText) {
661
730
  controller.enqueue({ type: 'text-start', id: '0' });
662
731
  isActiveText = true;
@@ -665,7 +734,7 @@ export class OpenAIChatLanguageModel implements LanguageModelV4 {
665
734
  controller.enqueue({
666
735
  type: 'text-delta',
667
736
  id: '0',
668
- delta: delta.content,
737
+ delta: textDelta,
669
738
  });
670
739
  }
671
740
 
@@ -696,6 +765,25 @@ export class OpenAIChatLanguageModel implements LanguageModelV4 {
696
765
 
697
766
  toolCallTracker.flush();
698
767
 
768
+ // map the stored finish reason now that all deltas (including
769
+ // refusals that arrived in the same chunk as the finish reason)
770
+ // have been read:
771
+ if (rawFinishReason != null) {
772
+ finishReason = {
773
+ unified: mapOpenAIFinishReason({
774
+ finishReason: rawFinishReason,
775
+ hasRefusal,
776
+ }),
777
+ raw: rawFinishReason,
778
+ };
779
+ }
780
+
781
+ // report the logprobs of all streamed chunks (content logprobs
782
+ // followed by refusal logprobs), not only the last chunk:
783
+ if (hasLogprobs) {
784
+ providerMetadata.openai.logprobs = streamedLogprobs;
785
+ }
786
+
699
787
  controller.enqueue({
700
788
  type: 'finish',
701
789
  finishReason,
@@ -723,6 +811,7 @@ function isOpenAIChatOutputChunk(chunk: OpenAIChatChunk): boolean {
723
811
 
724
812
  return (
725
813
  (delta?.content != null && delta.content.length > 0) ||
814
+ (delta?.refusal != null && delta.refusal.length > 0) ||
726
815
  (delta?.tool_calls != null && delta.tool_calls.length > 0) ||
727
816
  (delta?.annotations != null && delta.annotations.length > 0)
728
817
  );
@@ -22,3 +22,5 @@ export * from '../tool/shell';
22
22
  export * from '../tool/tool-search';
23
23
  export * from '../tool/web-search';
24
24
  export * from '../tool/web-search-preview';
25
+
26
+ export { openaiModelCatalog } from '../openai-model-catalog';
@@ -748,6 +748,7 @@ async function convertOpenAIBatchResult(
748
748
  const content: LanguageModelV4GenerateResult['content'] = [];
749
749
  const logprobs: Array<NonNullable<OpenAIResponsesLogprobs>> = [];
750
750
  let hasFunctionCall = false;
751
+ let hasRefusal = false;
751
752
 
752
753
  for (const part of response.output) {
753
754
  switch (part.type) {
@@ -774,6 +775,12 @@ async function convertOpenAIBatchResult(
774
775
 
775
776
  case 'message': {
776
777
  for (const contentPart of part.content) {
778
+ if (contentPart.type === 'refusal') {
779
+ hasRefusal = true;
780
+ content.push({ type: 'text', text: contentPart.refusal });
781
+ continue;
782
+ }
783
+
777
784
  content.push({ type: 'text', text: contentPart.text });
778
785
  if (contentPart.logprobs != null) {
779
786
  logprobs.push(contentPart.logprobs);
@@ -916,6 +923,7 @@ async function convertOpenAIBatchResult(
916
923
  unified: mapOpenAIResponseFinishReason({
917
924
  finishReason: response.incomplete_details?.reason,
918
925
  hasFunctionCall,
926
+ hasRefusal,
919
927
  }),
920
928
  raw: response.incomplete_details?.reason ?? undefined,
921
929
  },
@@ -3,15 +3,21 @@ import type { LanguageModelV4FinishReason } from '@ai-sdk/provider';
3
3
  export function mapOpenAIResponseFinishReason({
4
4
  finishReason,
5
5
  hasFunctionCall,
6
+ hasRefusal = false,
6
7
  }: {
7
8
  finishReason: string | null | undefined;
8
9
  // flag that checks if there have been client-side tool calls (not executed by openai)
9
10
  hasFunctionCall: boolean;
11
+ // flag that checks if the model refused to answer:
12
+ hasRefusal?: boolean;
10
13
  }): LanguageModelV4FinishReason['unified'] {
11
14
  switch (finishReason) {
12
15
  case undefined:
13
16
  case null:
14
- return hasFunctionCall ? 'tool-calls' : 'stop';
17
+ // A client-side function call must win over a refusal so that the tools
18
+ // are still executed. The refusal text remains part of the content.
19
+ if (hasFunctionCall) return 'tool-calls';
20
+ return hasRefusal ? 'content-filter' : 'stop';
15
21
  case 'max_output_tokens':
16
22
  return 'length';
17
23
  case 'content_filter':
@@ -869,6 +869,8 @@ const openaiResponsesModeledChunkTypes = new Set([
869
869
  'response.output_item.done',
870
870
  'response.output_text.annotation.added',
871
871
  'response.output_text.delta',
872
+ 'response.refusal.delta',
873
+ 'response.refusal.done',
872
874
  'response.reasoning_summary_part.added',
873
875
  'response.reasoning_summary_part.done',
874
876
  'response.reasoning_summary_text.delta',
@@ -948,6 +950,20 @@ export const openaiResponsesChunkSchema = lazySchema(() =>
948
950
  )
949
951
  .nullish(),
950
952
  }),
953
+ z.object({
954
+ type: z.literal('response.refusal.delta'),
955
+ item_id: z.string(),
956
+ output_index: z.number().nullish(),
957
+ content_index: z.number().nullish(),
958
+ delta: z.string(),
959
+ }),
960
+ z.object({
961
+ type: z.literal('response.refusal.done'),
962
+ item_id: z.string(),
963
+ output_index: z.number().nullish(),
964
+ content_index: z.number().nullish(),
965
+ refusal: z.string(),
966
+ }),
951
967
  z.object({
952
968
  type: z.enum(['response.completed', 'response.incomplete']),
953
969
  response: z.object({
@@ -1548,55 +1564,62 @@ export const openaiResponsesResponseSchema = lazySchema(() =>
1548
1564
  role: z.literal('assistant'),
1549
1565
  id: z.string(),
1550
1566
  phase: z.enum(['commentary', 'final_answer']).nullish(),
1567
+ // the API returns either text or a refusal as content parts:
1551
1568
  content: z.array(
1552
- z.object({
1553
- type: z.literal('output_text'),
1554
- text: z.string(),
1555
- logprobs: z
1556
- .array(
1557
- z.object({
1558
- token: z.string(),
1559
- logprob: z.number(),
1560
- top_logprobs: z.array(
1561
- z.object({
1562
- token: z.string(),
1563
- logprob: z.number(),
1564
- }),
1565
- ),
1566
- }),
1567
- )
1568
- .nullish(),
1569
- annotations: z.array(
1570
- z.discriminatedUnion('type', [
1571
- z.object({
1572
- type: z.literal('url_citation'),
1573
- start_index: z.number(),
1574
- end_index: z.number(),
1575
- url: z.string(),
1576
- title: z.string(),
1577
- }),
1578
- z.object({
1579
- type: z.literal('file_citation'),
1580
- file_id: z.string(),
1581
- filename: z.string(),
1582
- index: z.number(),
1583
- }),
1584
- z.object({
1585
- type: z.literal('container_file_citation'),
1586
- container_id: z.string(),
1587
- file_id: z.string(),
1588
- filename: z.string(),
1589
- start_index: z.number(),
1590
- end_index: z.number(),
1591
- }),
1592
- z.object({
1593
- type: z.literal('file_path'),
1594
- file_id: z.string(),
1595
- index: z.number(),
1596
- }),
1597
- ]),
1598
- ),
1599
- }),
1569
+ z.discriminatedUnion('type', [
1570
+ z.object({
1571
+ type: z.literal('output_text'),
1572
+ text: z.string(),
1573
+ logprobs: z
1574
+ .array(
1575
+ z.object({
1576
+ token: z.string(),
1577
+ logprob: z.number(),
1578
+ top_logprobs: z.array(
1579
+ z.object({
1580
+ token: z.string(),
1581
+ logprob: z.number(),
1582
+ }),
1583
+ ),
1584
+ }),
1585
+ )
1586
+ .nullish(),
1587
+ annotations: z.array(
1588
+ z.discriminatedUnion('type', [
1589
+ z.object({
1590
+ type: z.literal('url_citation'),
1591
+ start_index: z.number(),
1592
+ end_index: z.number(),
1593
+ url: z.string(),
1594
+ title: z.string(),
1595
+ }),
1596
+ z.object({
1597
+ type: z.literal('file_citation'),
1598
+ file_id: z.string(),
1599
+ filename: z.string(),
1600
+ index: z.number(),
1601
+ }),
1602
+ z.object({
1603
+ type: z.literal('container_file_citation'),
1604
+ container_id: z.string(),
1605
+ file_id: z.string(),
1606
+ filename: z.string(),
1607
+ start_index: z.number(),
1608
+ end_index: z.number(),
1609
+ }),
1610
+ z.object({
1611
+ type: z.literal('file_path'),
1612
+ file_id: z.string(),
1613
+ index: z.number(),
1614
+ }),
1615
+ ]),
1616
+ ),
1617
+ }),
1618
+ z.object({
1619
+ type: z.literal('refusal'),
1620
+ refusal: z.string(),
1621
+ }),
1622
+ ]),
1600
1623
  ),
1601
1624
  }),
1602
1625
  z.object({