@falai/agent 2.4.3 → 2.5.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.
Files changed (96) hide show
  1. package/dist/cjs/core/ToolLoopExecutor.d.ts +11 -0
  2. package/dist/cjs/core/ToolLoopExecutor.d.ts.map +1 -1
  3. package/dist/cjs/core/ToolLoopExecutor.js +75 -51
  4. package/dist/cjs/core/ToolLoopExecutor.js.map +1 -1
  5. package/dist/cjs/index.d.ts +3 -0
  6. package/dist/cjs/index.d.ts.map +1 -1
  7. package/dist/cjs/index.js +4 -2
  8. package/dist/cjs/index.js.map +1 -1
  9. package/dist/cjs/providers/AnthropicProvider.d.ts.map +1 -1
  10. package/dist/cjs/providers/AnthropicProvider.js +18 -35
  11. package/dist/cjs/providers/AnthropicProvider.js.map +1 -1
  12. package/dist/cjs/providers/DeepSeekProvider.d.ts +1 -12
  13. package/dist/cjs/providers/DeepSeekProvider.d.ts.map +1 -1
  14. package/dist/cjs/providers/DeepSeekProvider.js +3 -20
  15. package/dist/cjs/providers/DeepSeekProvider.js.map +1 -1
  16. package/dist/cjs/providers/GeminiProvider.d.ts.map +1 -1
  17. package/dist/cjs/providers/GeminiProvider.js +19 -36
  18. package/dist/cjs/providers/GeminiProvider.js.map +1 -1
  19. package/dist/cjs/providers/GenericOpenAICompatibleProvider.d.ts +69 -0
  20. package/dist/cjs/providers/GenericOpenAICompatibleProvider.d.ts.map +1 -0
  21. package/dist/cjs/providers/GenericOpenAICompatibleProvider.js +93 -0
  22. package/dist/cjs/providers/GenericOpenAICompatibleProvider.js.map +1 -0
  23. package/dist/cjs/providers/OpenAICompatibleProvider.d.ts +23 -5
  24. package/dist/cjs/providers/OpenAICompatibleProvider.d.ts.map +1 -1
  25. package/dist/cjs/providers/OpenAICompatibleProvider.js +46 -44
  26. package/dist/cjs/providers/OpenAICompatibleProvider.js.map +1 -1
  27. package/dist/cjs/providers/index.d.ts +3 -1
  28. package/dist/cjs/providers/index.d.ts.map +1 -1
  29. package/dist/cjs/providers/index.js +3 -1
  30. package/dist/cjs/providers/index.js.map +1 -1
  31. package/dist/cjs/utils/completion.d.ts +20 -0
  32. package/dist/cjs/utils/completion.d.ts.map +1 -0
  33. package/dist/cjs/utils/completion.js +33 -0
  34. package/dist/cjs/utils/completion.js.map +1 -0
  35. package/dist/cjs/utils/index.d.ts +3 -2
  36. package/dist/cjs/utils/index.d.ts.map +1 -1
  37. package/dist/cjs/utils/index.js +7 -1
  38. package/dist/cjs/utils/index.js.map +1 -1
  39. package/dist/cjs/utils/retry.d.ts +43 -7
  40. package/dist/cjs/utils/retry.d.ts.map +1 -1
  41. package/dist/cjs/utils/retry.js +100 -12
  42. package/dist/cjs/utils/retry.js.map +1 -1
  43. package/dist/core/ToolLoopExecutor.d.ts +11 -0
  44. package/dist/core/ToolLoopExecutor.d.ts.map +1 -1
  45. package/dist/core/ToolLoopExecutor.js +76 -52
  46. package/dist/core/ToolLoopExecutor.js.map +1 -1
  47. package/dist/index.d.ts +3 -0
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +1 -0
  50. package/dist/index.js.map +1 -1
  51. package/dist/providers/AnthropicProvider.d.ts.map +1 -1
  52. package/dist/providers/AnthropicProvider.js +19 -36
  53. package/dist/providers/AnthropicProvider.js.map +1 -1
  54. package/dist/providers/DeepSeekProvider.d.ts +1 -12
  55. package/dist/providers/DeepSeekProvider.d.ts.map +1 -1
  56. package/dist/providers/DeepSeekProvider.js +3 -20
  57. package/dist/providers/DeepSeekProvider.js.map +1 -1
  58. package/dist/providers/GeminiProvider.d.ts.map +1 -1
  59. package/dist/providers/GeminiProvider.js +20 -37
  60. package/dist/providers/GeminiProvider.js.map +1 -1
  61. package/dist/providers/GenericOpenAICompatibleProvider.d.ts +69 -0
  62. package/dist/providers/GenericOpenAICompatibleProvider.d.ts.map +1 -0
  63. package/dist/providers/GenericOpenAICompatibleProvider.js +87 -0
  64. package/dist/providers/GenericOpenAICompatibleProvider.js.map +1 -0
  65. package/dist/providers/OpenAICompatibleProvider.d.ts +23 -5
  66. package/dist/providers/OpenAICompatibleProvider.d.ts.map +1 -1
  67. package/dist/providers/OpenAICompatibleProvider.js +47 -45
  68. package/dist/providers/OpenAICompatibleProvider.js.map +1 -1
  69. package/dist/providers/index.d.ts +3 -1
  70. package/dist/providers/index.d.ts.map +1 -1
  71. package/dist/providers/index.js +1 -0
  72. package/dist/providers/index.js.map +1 -1
  73. package/dist/utils/completion.d.ts +20 -0
  74. package/dist/utils/completion.d.ts.map +1 -0
  75. package/dist/utils/completion.js +29 -0
  76. package/dist/utils/completion.js.map +1 -0
  77. package/dist/utils/index.d.ts +3 -2
  78. package/dist/utils/index.d.ts.map +1 -1
  79. package/dist/utils/index.js +3 -1
  80. package/dist/utils/index.js.map +1 -1
  81. package/dist/utils/retry.d.ts +43 -7
  82. package/dist/utils/retry.d.ts.map +1 -1
  83. package/dist/utils/retry.js +98 -12
  84. package/dist/utils/retry.js.map +1 -1
  85. package/package.json +1 -1
  86. package/src/core/ToolLoopExecutor.ts +108 -52
  87. package/src/index.ts +3 -0
  88. package/src/providers/AnthropicProvider.ts +22 -43
  89. package/src/providers/DeepSeekProvider.ts +3 -35
  90. package/src/providers/GeminiProvider.ts +23 -44
  91. package/src/providers/GenericOpenAICompatibleProvider.ts +138 -0
  92. package/src/providers/OpenAICompatibleProvider.ts +87 -67
  93. package/src/providers/index.ts +4 -0
  94. package/src/utils/completion.ts +38 -0
  95. package/src/utils/index.ts +5 -2
  96. package/src/utils/retry.ts +136 -14
@@ -7,15 +7,9 @@
7
7
  import OpenAI from "openai";
8
8
  import type {
9
9
  ChatCompletionChunk,
10
- ChatCompletionCreateParamsNonStreaming,
11
10
  ChatCompletionCreateParamsStreaming,
12
11
  } from "openai/resources/chat/completions";
13
12
 
14
- import type {
15
- GenerateMessageInput,
16
- GenerateMessageOutput,
17
- StructuredSchema,
18
- } from "../types";
19
13
  import type { ProviderCapabilities } from "../types/ai";
20
14
  import { logger } from "../utils";
21
15
  import {
@@ -96,38 +90,12 @@ export class DeepSeekProvider extends OpenAICompatibleProvider {
96
90
  backupModels,
97
91
  config,
98
92
  retryConfig,
93
+ // DeepSeek has no responses.parse API; structured output goes through
94
+ // chat completions with a native json_schema response_format.
95
+ structuredOutput: "json_schema",
99
96
  });
100
97
  }
101
98
 
102
- /**
103
- * DeepSeek has no responses.parse API; structured output goes through
104
- * chat completions with a json_schema response_format.
105
- */
106
- protected override async executeStructuredGenerate(
107
- model: string,
108
- input: GenerateMessageInput<unknown>,
109
- _jsonSchema: StructuredSchema
110
- ): Promise<GenerateMessageOutput> {
111
- return this.executeChatCompletion(model, input);
112
- }
113
-
114
- /**
115
- * DeepSeek supports native json_schema enforcement in chat completions
116
- * (both streaming and non-streaming).
117
- */
118
- protected override structuredResponseFormat(
119
- jsonSchema: StructuredSchema,
120
- schemaName: string | undefined
121
- ): ChatCompletionCreateParamsNonStreaming["response_format"] {
122
- return {
123
- type: "json_schema" as const,
124
- json_schema: {
125
- name: schemaName || "structured_output",
126
- schema: this.adaptSchema(jsonSchema),
127
- },
128
- };
129
- }
130
-
131
99
  /**
132
100
  * DeepSeek reports usage in streaming chunks when explicitly requested.
133
101
  */
@@ -21,7 +21,8 @@ import type {
21
21
  } from "../types";
22
22
  import type { ProviderCapabilities } from "../types/ai";
23
23
  import type { HistoryItem } from "../types/history";
24
- import { withTimeoutAndRetry, withStreamRetry } from "../utils/retry";
24
+ import { withTimeoutAndRetry, withStreamRetry, resolveRetryConfig, combineAbortSignals } from "../utils/retry";
25
+ import { assertUsableCompletion } from "../utils/completion";
25
26
  import { tryParseJSONResponse } from "../utils/json";
26
27
  import { logger } from "../utils/logger";
27
28
  import {
@@ -32,11 +33,6 @@ import {
32
33
  type ErrorClassificationOptions,
33
34
  } from "./errorClassification";
34
35
 
35
- const DEFAULT_RETRY_CONFIG = {
36
- timeout: 60000,
37
- retries: 3,
38
- };
39
-
40
36
  /**
41
37
  * Configuration options for Gemini provider
42
38
  * Uses types from @google/genai package
@@ -106,14 +102,7 @@ export class GeminiProvider implements AiProvider {
106
102
  this.primaryModel = model;
107
103
  this.backupModels = backupModels;
108
104
  this.config = config;
109
- this.retryConfig = {
110
- // `||` is intentional: a 0ms timeout is degenerate (aborts every call
111
- // immediately), so fall back to the default.
112
- timeout: retryConfig?.timeout || DEFAULT_RETRY_CONFIG.timeout,
113
- // `??` so an explicit `retries: 0` (disable retries) is honored instead of
114
- // being clobbered to the default by a falsy-zero check.
115
- retries: retryConfig?.retries ?? DEFAULT_RETRY_CONFIG.retries,
116
- };
105
+ this.retryConfig = resolveRetryConfig(retryConfig);
117
106
  }
118
107
 
119
108
  /**
@@ -423,7 +412,7 @@ export class GeminiProvider implements AiProvider {
423
412
  model: string,
424
413
  input: GenerateMessageInput<TContext>
425
414
  ): Promise<GenerateMessageOutput<TStructured>> {
426
- const operation = async (): Promise<GenerateMessageOutput> => {
415
+ const operation = async (signal: AbortSignal): Promise<GenerateMessageOutput> => {
427
416
  // Schema-required: configure response schema
428
417
  const configOverride: Partial<GenerateContentConfig> = { ...this.config };
429
418
 
@@ -468,12 +457,13 @@ export class GeminiProvider implements AiProvider {
468
457
  }
469
458
  }
470
459
 
460
+ const reqSignal = combineAbortSignals(input.signal, signal);
471
461
  response = await this.genAI.models.generateContent({
472
462
  model,
473
463
  contents: historyContents,
474
464
  config: {
475
465
  ...configOverride,
476
- ...(input.signal ? { abortSignal: input.signal } : {}),
466
+ ...(reqSignal ? { abortSignal: reqSignal } : {}),
477
467
  },
478
468
  });
479
469
  } catch (error: unknown) {
@@ -542,16 +532,10 @@ export class GeminiProvider implements AiProvider {
542
532
  } as AgentStructuredResponse;
543
533
  }
544
534
 
545
- // A parsed-but-blank structured message with no tool calls is just as
546
- // empty as no text at all — throw so withTimeoutAndRetry retries instead
547
- // of returning {"message":""}.
548
- if (
549
- toolCalls.length === 0 &&
550
- typeof structured?.message === "string" &&
551
- !structured.message.trim()
552
- ) {
553
- throw new Error("No response from Gemini");
554
- }
535
+ // A parsed-but-blank message with no tool calls is as empty as no text;
536
+ // the shared guard throws so withTimeoutAndRetry retries instead of
537
+ // returning {"message":""}.
538
+ assertUsableCompletion(structured, message, toolCalls.length, "Gemini");
555
539
 
556
540
  return {
557
541
  message,
@@ -582,8 +566,8 @@ export class GeminiProvider implements AiProvider {
582
566
  // Try primary model first
583
567
  try {
584
568
  yield* withStreamRetry(
585
- () => this.generateStreamWithModel(this.primaryModel, input),
586
- { maxRetries: this.retryConfig.retries, operationName: `Gemini ${this.primaryModel} stream` }
569
+ (signal) => this.generateStreamWithModel(this.primaryModel, input, signal),
570
+ { maxRetries: this.retryConfig.retries, firstChunkTimeoutMs: this.retryConfig.timeout, operationName: `Gemini ${this.primaryModel} stream` }
587
571
  );
588
572
  } catch (primaryError: unknown) {
589
573
  const primaryErrMsg = getErrorMessage(primaryError);
@@ -608,8 +592,8 @@ export class GeminiProvider implements AiProvider {
608
592
 
609
593
  try {
610
594
  yield* withStreamRetry(
611
- () => this.generateStreamWithModel(backupModel, input),
612
- { maxRetries: this.retryConfig.retries, operationName: `Gemini ${backupModel} stream` }
595
+ (signal) => this.generateStreamWithModel(backupModel, input, signal),
596
+ { maxRetries: this.retryConfig.retries, firstChunkTimeoutMs: this.retryConfig.timeout, operationName: `Gemini ${backupModel} stream` }
613
597
  );
614
598
  logger.debug(`[GEMINI] Backup model ${backupModel} succeeded`);
615
599
  return;
@@ -645,8 +629,11 @@ export class GeminiProvider implements AiProvider {
645
629
  TStructured = AgentStructuredResponse
646
630
  >(
647
631
  model: string,
648
- input: GenerateMessageInput<TContext>
632
+ input: GenerateMessageInput<TContext>,
633
+ attemptSignal?: AbortSignal
649
634
  ): AsyncGenerator<GenerateMessageStreamChunk<TStructured>> {
635
+ // Caller cancellation + the retry helper's per-attempt deadline, as one.
636
+ const reqSignal = combineAbortSignals(input.signal, attemptSignal);
650
637
  // Streaming: request JSON if schema provided
651
638
  const configOverride: Partial<GenerateContentConfig> = { ...this.config };
652
639
 
@@ -696,7 +683,7 @@ export class GeminiProvider implements AiProvider {
696
683
  contents: historyContents,
697
684
  config: {
698
685
  ...configOverride,
699
- ...(input.signal ? { abortSignal: input.signal } : {}),
686
+ ...(reqSignal ? { abortSignal: reqSignal } : {}),
700
687
  },
701
688
  });
702
689
  } catch (error: unknown) {
@@ -714,7 +701,7 @@ export class GeminiProvider implements AiProvider {
714
701
  }> = [];
715
702
 
716
703
  for await (const chunk of stream) {
717
- if (input.signal?.aborted) break;
704
+ if (reqSignal?.aborted) break;
718
705
 
719
706
  // Safely extract text — chunk.text can throw when chunk has only function calls
720
707
  const delta = this.safeExtractText(chunk);
@@ -768,17 +755,9 @@ export class GeminiProvider implements AiProvider {
768
755
  } as AgentStructuredResponse;
769
756
  }
770
757
 
771
- // Empty-completion guard — mirror of the non-streaming path. The effective
772
- // message is the parsed structured message when a schema is used, else the
773
- // accumulated text. A blank message with no tool calls means the model
774
- // produced nothing usable; throw so withStreamRetry/generateStreamWithBackup
775
- // retry instead of silently emitting an empty message.
776
- const messageText = (
777
- typeof structured?.message === "string" ? structured.message : accumulated
778
- ).trim();
779
- if (!messageText && toolCalls.length === 0) {
780
- throw new Error("No response from Gemini");
781
- }
758
+ // Empty-completion guard — same definition as the non-streaming path, so the
759
+ // stream retries / falls back to backup instead of emitting an empty message.
760
+ assertUsableCompletion(structured, accumulated, toolCalls.length, "Gemini");
782
761
 
783
762
  // Yield final chunk
784
763
  yield {
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Generic OpenAI-compatible provider + factory.
3
+ *
4
+ * Most "OpenAI-compatible" endpoints (Azure OpenAI, Groq, Together, Fireworks,
5
+ * vLLM, LM Studio, Ollama, a self-hosted gateway…) differ from OpenAI only in
6
+ * base URL, headers, and how structured output is requested. `createOpenAICompatibleProvider`
7
+ * builds a working provider from that config alone — no subclass required — so
8
+ * adding one ("fake" provider, same wire protocol, different base URL) is a few lines:
9
+ *
10
+ * const ollama = createOpenAICompatibleProvider({
11
+ * name: "ollama",
12
+ * baseURL: "http://localhost:11434/v1",
13
+ * apiKey: "ollama", // local servers ignore it; pass any non-empty string
14
+ * model: "llama3.3",
15
+ * });
16
+ *
17
+ * const azure = createOpenAICompatibleProvider({
18
+ * name: "azure",
19
+ * baseURL: `https://${resource}.openai.azure.com/openai/deployments/${deployment}`,
20
+ * apiKey: process.env.AZURE_OPENAI_KEY!,
21
+ * model: deployment,
22
+ * defaultHeaders: { "api-key": process.env.AZURE_OPENAI_KEY! },
23
+ * });
24
+ *
25
+ * For OpenAI/OpenRouter (native `responses.parse`) and Anthropic/Gemini, prefer
26
+ * their dedicated provider classes.
27
+ */
28
+
29
+ import OpenAI from "openai";
30
+
31
+ import type { ProviderCapabilities } from "../types/ai";
32
+ import {
33
+ OpenAICompatibleProvider,
34
+ type OpenAICompatibleRequestConfig,
35
+ type StructuredOutputMode,
36
+ } from "./OpenAICompatibleProvider";
37
+
38
+ export interface OpenAICompatibleOptions {
39
+ /** Provider identifier, e.g. "azure", "ollama", "groq". */
40
+ name: string;
41
+ /** Base URL of the OpenAI-compatible endpoint. */
42
+ baseURL: string;
43
+ /** API key. Local servers often ignore it — pass any non-empty string. */
44
+ apiKey: string;
45
+ /** Primary model / deployment name. */
46
+ model: string;
47
+ /** Backup models to try if the primary fails. */
48
+ backupModels?: string[];
49
+ /** Human-readable name for error messages. Defaults to `name`. */
50
+ displayName?: string;
51
+ /** Uppercase tag for log lines. Defaults to `name.toUpperCase()`. */
52
+ logLabel?: string;
53
+ /** Capability overrides, merged over the defaults (all true except caching). */
54
+ capabilities?: Partial<ProviderCapabilities>;
55
+ /** Extra request headers (e.g. Azure's `api-key`, a gateway's auth header). */
56
+ defaultHeaders?: Record<string, string>;
57
+ /**
58
+ * How structured output is requested. Defaults to `"json_schema"` — the
59
+ * broadest enforced mode for arbitrary compatible endpoints (`responses.parse`
60
+ * is OpenAI-only). See {@link StructuredOutputMode}.
61
+ */
62
+ structuredOutput?: StructuredOutputMode;
63
+ /** Default request parameters merged into every call. */
64
+ config?: OpenAICompatibleRequestConfig;
65
+ /** Per-call timeout (ms) and retry count. */
66
+ retryConfig?: { timeout?: number; retries?: number };
67
+ }
68
+
69
+ /** Sensible defaults for a modern OpenAI-compatible endpoint. */
70
+ const DEFAULT_CAPABILITIES: ProviderCapabilities = {
71
+ supportsTools: true,
72
+ supportsNativeJsonSchema: true,
73
+ supportsStreaming: true,
74
+ supportsStreamingToolCalls: true,
75
+ supportsPromptCaching: false,
76
+ };
77
+
78
+ /**
79
+ * Concrete OpenAI-compatible provider configured entirely from
80
+ * {@link OpenAICompatibleOptions}. Kept private — construct via
81
+ * {@link createOpenAICompatibleProvider}; subclass {@link OpenAICompatibleProvider}
82
+ * directly if you need behavior beyond these knobs (e.g. provider-specific
83
+ * error classification or streaming hooks).
84
+ */
85
+ class GenericOpenAICompatibleProvider extends OpenAICompatibleProvider {
86
+ public readonly name: string;
87
+ public readonly capabilities: ProviderCapabilities;
88
+ protected readonly logLabel: string;
89
+ protected readonly displayName: string;
90
+
91
+ constructor(options: OpenAICompatibleOptions) {
92
+ if (!options.name) {
93
+ throw new Error("An OpenAI-compatible provider needs a `name`.");
94
+ }
95
+ if (!options.baseURL) {
96
+ throw new Error(`[${options.name}] A \`baseURL\` is required.`);
97
+ }
98
+ if (!options.apiKey) {
99
+ throw new Error(
100
+ `[${options.name}] An \`apiKey\` is required — use any non-empty string for servers that ignore it.`
101
+ );
102
+ }
103
+ if (!options.model) {
104
+ throw new Error(`[${options.name}] A \`model\` is required.`);
105
+ }
106
+
107
+ super({
108
+ client: new OpenAI({
109
+ apiKey: options.apiKey,
110
+ baseURL: options.baseURL,
111
+ ...(options.defaultHeaders ? { defaultHeaders: options.defaultHeaders } : {}),
112
+ }),
113
+ model: options.model,
114
+ backupModels: options.backupModels,
115
+ config: options.config,
116
+ retryConfig: options.retryConfig,
117
+ // Arbitrary compatible endpoints rarely have responses.parse; default to
118
+ // the broadly-supported chat json_schema strategy.
119
+ structuredOutput: options.structuredOutput ?? "json_schema",
120
+ });
121
+
122
+ this.name = options.name;
123
+ this.displayName = options.displayName ?? options.name;
124
+ this.logLabel = options.logLabel ?? options.name.toUpperCase();
125
+ this.capabilities = { ...DEFAULT_CAPABILITIES, ...options.capabilities };
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Build an `AiProvider` for any OpenAI-compatible endpoint from config alone.
131
+ * See {@link OpenAICompatibleOptions} for the knobs and the file header for
132
+ * Azure/Ollama examples.
133
+ */
134
+ export function createOpenAICompatibleProvider(
135
+ options: OpenAICompatibleOptions
136
+ ): OpenAICompatibleProvider {
137
+ return new GenericOpenAICompatibleProvider(options);
138
+ }
@@ -27,7 +27,7 @@ import type {
27
27
  } from "../types";
28
28
  import type { ProviderCapabilities } from "../types/ai";
29
29
  import type { HistoryItem } from "../types/history";
30
- import { withTimeoutAndRetry, withStreamRetry, logger } from "../utils";
30
+ import { withTimeoutAndRetry, withStreamRetry, resolveRetryConfig, logger, assertUsableCompletion, combineAbortSignals } from "../utils";
31
31
  import {
32
32
  classifyProviderError,
33
33
  getErrorMessage,
@@ -36,11 +36,6 @@ import {
36
36
  type ErrorClassificationOptions,
37
37
  } from "./errorClassification";
38
38
 
39
- const DEFAULT_RETRY_CONFIG = {
40
- timeout: 60000,
41
- retries: 3,
42
- };
43
-
44
39
  /**
45
40
  * Default request parameters shared by OpenAI-compatible providers
46
41
  */
@@ -48,6 +43,17 @@ export type OpenAICompatibleRequestConfig = Partial<
48
43
  Omit<ChatCompletionCreateParamsNonStreaming, "model" | "messages">
49
44
  >;
50
45
 
46
+ /**
47
+ * How structured (JSON-schema) output is requested from the endpoint:
48
+ * - `"responses_parse"` (default) — OpenAI's native `responses.parse` API
49
+ * (OpenAI, OpenRouter). Most compatible endpoints do not implement it.
50
+ * - `"json_schema"` — chat completions with a `json_schema` response_format.
51
+ * The broadest enforced mode (DeepSeek, Groq, Together, Fireworks, vLLM, Azure…).
52
+ * - `"json_object"` — chat completions with a `json_object` response_format,
53
+ * for servers that lack json_schema enforcement (parsed at the end).
54
+ */
55
+ export type StructuredOutputMode = "responses_parse" | "json_schema" | "json_object";
56
+
51
57
  /**
52
58
  * Initialization values supplied by subclasses
53
59
  */
@@ -65,6 +71,11 @@ export interface OpenAICompatibleProviderInit {
65
71
  timeout?: number;
66
72
  retries?: number;
67
73
  };
74
+ /**
75
+ * How structured output is requested (default `"responses_parse"`). Lifts the
76
+ * strategy that used to be a per-subclass override into shared config.
77
+ */
78
+ structuredOutput?: StructuredOutputMode;
68
79
  }
69
80
 
70
81
  /**
@@ -85,6 +96,8 @@ export abstract class OpenAICompatibleProvider implements AiProvider {
85
96
  protected readonly backupModels: string[];
86
97
  protected readonly config?: OpenAICompatibleRequestConfig;
87
98
  protected readonly retryConfig: { timeout: number; retries: number };
99
+ /** Structured-output strategy; see {@link StructuredOutputMode}. */
100
+ protected readonly structuredOutput: StructuredOutputMode;
88
101
 
89
102
  /**
90
103
  * Provider-specific error classification signals.
@@ -99,14 +112,8 @@ export abstract class OpenAICompatibleProvider implements AiProvider {
99
112
  this.primaryModel = init.model;
100
113
  this.backupModels = init.backupModels ?? [];
101
114
  this.config = init.config;
102
- this.retryConfig = {
103
- // `||` is intentional: a 0ms timeout is degenerate (aborts every call
104
- // immediately), so fall back to the default.
105
- timeout: init.retryConfig?.timeout || DEFAULT_RETRY_CONFIG.timeout,
106
- // `??` so an explicit `retries: 0` (disable retries) is honored instead of
107
- // being clobbered to the default by a falsy-zero check.
108
- retries: init.retryConfig?.retries ?? DEFAULT_RETRY_CONFIG.retries,
109
- };
115
+ this.retryConfig = resolveRetryConfig(init.retryConfig);
116
+ this.structuredOutput = init.structuredOutput ?? "responses_parse";
110
117
  }
111
118
 
112
119
  // ---------------------------------------------------------------------
@@ -146,22 +153,31 @@ export abstract class OpenAICompatibleProvider implements AiProvider {
146
153
  input: GenerateMessageInput<unknown>,
147
154
  jsonSchema: StructuredSchema
148
155
  ): Promise<GenerateMessageOutput> {
149
- const response = await this.client.responses.parse({
150
- model,
151
- instructions: input.prompt,
152
- input: "",
153
- reasoning: {
154
- effort: input.parameters?.reasoning?.effort || "low",
155
- },
156
- text: {
157
- format: {
158
- type: "json_schema",
159
- name: input.parameters?.schemaName || "structured_output",
160
- // Adapt common schema format to the provider's format
161
- schema: this.adaptSchema(jsonSchema),
156
+ // Non-native modes go through chat completions, where
157
+ // `structuredResponseFormat` selects the response_format.
158
+ if (this.structuredOutput !== "responses_parse") {
159
+ return this.executeChatCompletion(model, input);
160
+ }
161
+
162
+ const response = await this.client.responses.parse(
163
+ {
164
+ model,
165
+ instructions: input.prompt,
166
+ input: "",
167
+ reasoning: {
168
+ effort: input.parameters?.reasoning?.effort || "low",
169
+ },
170
+ text: {
171
+ format: {
172
+ type: "json_schema",
173
+ name: input.parameters?.schemaName || "structured_output",
174
+ // Adapt common schema format to the provider's format
175
+ schema: this.adaptSchema(jsonSchema),
176
+ },
162
177
  },
163
178
  },
164
- });
179
+ { signal: input.signal }
180
+ );
165
181
 
166
182
  if (!response.output_parsed) {
167
183
  throw new Error(`No parsed output returned from ${this.displayName}`);
@@ -183,15 +199,25 @@ export abstract class OpenAICompatibleProvider implements AiProvider {
183
199
  }
184
200
 
185
201
  /**
186
- * response_format applied to chat-completions requests when a JSON
187
- * schema is present. Default requests a generic JSON object (parsed at
188
- * the end); subclasses with native json_schema support in chat
189
- * completions (e.g. DeepSeek) override this.
202
+ * response_format applied to chat-completions requests when a JSON schema is
203
+ * present, selected by {@link StructuredOutputMode}: `"json_schema"` for native
204
+ * enforcement, else a generic JSON object parsed at the end. (`"responses_parse"`
205
+ * never reaches chat completions for non-streaming, but streaming has no parse
206
+ * API, so it falls back to `json_object` here.)
190
207
  */
191
208
  protected structuredResponseFormat(
192
- _jsonSchema: StructuredSchema,
193
- _schemaName: string | undefined
209
+ jsonSchema: StructuredSchema,
210
+ schemaName: string | undefined
194
211
  ): ChatCompletionCreateParamsNonStreaming["response_format"] {
212
+ if (this.structuredOutput === "json_schema") {
213
+ return {
214
+ type: "json_schema" as const,
215
+ json_schema: {
216
+ name: schemaName || "structured_output",
217
+ schema: this.adaptSchema(jsonSchema),
218
+ },
219
+ };
220
+ }
195
221
  return { type: "json_object" };
196
222
  }
197
223
 
@@ -413,8 +439,8 @@ export abstract class OpenAICompatibleProvider implements AiProvider {
413
439
  // Try primary model first
414
440
  try {
415
441
  yield* withStreamRetry(
416
- () => this.generateStreamWithModel<TContext, TStructured>(this.primaryModel, input),
417
- { maxRetries: this.retryConfig.retries, operationName: `${this.logLabel} ${this.primaryModel} stream` }
442
+ (signal) => this.generateStreamWithModel<TContext, TStructured>(this.primaryModel, input, signal),
443
+ { maxRetries: this.retryConfig.retries, firstChunkTimeoutMs: this.retryConfig.timeout, operationName: `${this.logLabel} ${this.primaryModel} stream` }
418
444
  );
419
445
  } catch (primaryError: unknown) {
420
446
  const primaryErrMsg = getErrorMessage(primaryError);
@@ -440,8 +466,8 @@ export abstract class OpenAICompatibleProvider implements AiProvider {
440
466
 
441
467
  try {
442
468
  yield* withStreamRetry(
443
- () => this.generateStreamWithModel<TContext, TStructured>(backupModel, input),
444
- { maxRetries: this.retryConfig.retries, operationName: `${this.logLabel} ${backupModel} stream` }
469
+ (signal) => this.generateStreamWithModel<TContext, TStructured>(backupModel, input, signal),
470
+ { maxRetries: this.retryConfig.retries, firstChunkTimeoutMs: this.retryConfig.timeout, operationName: `${this.logLabel} ${backupModel} stream` }
445
471
  );
446
472
  logger.debug(`[${this.logLabel}] Backup model ${backupModel} succeeded`);
447
473
  return;
@@ -483,16 +509,20 @@ export abstract class OpenAICompatibleProvider implements AiProvider {
483
509
  model: string,
484
510
  input: GenerateMessageInput<TContext>
485
511
  ): Promise<GenerateMessageOutput<TStructured>> {
486
- const operation = async (): Promise<GenerateMessageOutput> => {
512
+ const operation = async (signal: AbortSignal): Promise<GenerateMessageOutput> => {
513
+ // Thread the per-attempt timeout signal through input.signal so whichever
514
+ // path runs — responses.parse (structured) or chat.completions — cancels
515
+ // its upstream call when the deadline fires.
516
+ const reqInput = { ...input, signal: combineAbortSignals(input.signal, signal) };
487
517
  // Use structured output path if JSON schema is provided
488
- if (input.parameters?.jsonSchema) {
518
+ if (reqInput.parameters?.jsonSchema) {
489
519
  return this.executeStructuredGenerate(
490
520
  model,
491
- input,
492
- input.parameters.jsonSchema
521
+ reqInput,
522
+ reqInput.parameters.jsonSchema
493
523
  );
494
524
  }
495
- return this.executeChatCompletion(model, input);
525
+ return this.executeChatCompletion(model, reqInput);
496
526
  };
497
527
 
498
528
  return withTimeoutAndRetry(
@@ -540,7 +570,9 @@ export abstract class OpenAICompatibleProvider implements AiProvider {
540
570
  );
541
571
  }
542
572
 
543
- const response = await this.client.chat.completions.create(params);
573
+ const response = await this.client.chat.completions.create(params, {
574
+ signal: input.signal,
575
+ });
544
576
 
545
577
  const message = response.choices[0]?.message?.content || "";
546
578
 
@@ -584,16 +616,10 @@ export abstract class OpenAICompatibleProvider implements AiProvider {
584
616
  } as AgentStructuredResponse;
585
617
  }
586
618
 
587
- // A parsed-but-blank structured message with no tool calls is just as empty
588
- // as no text at all — throw so withTimeoutAndRetry retries instead of
619
+ // A parsed-but-blank message with no tool calls is as empty as no text;
620
+ // the shared guard throws so withTimeoutAndRetry retries instead of
589
621
  // returning {"message":""}.
590
- if (
591
- toolCalls.length === 0 &&
592
- typeof structured?.message === "string" &&
593
- !structured.message.trim()
594
- ) {
595
- throw new Error(`No response from ${this.displayName}`);
596
- }
622
+ assertUsableCompletion(structured, message, toolCalls.length, this.displayName);
597
623
 
598
624
  return {
599
625
  message,
@@ -617,7 +643,8 @@ export abstract class OpenAICompatibleProvider implements AiProvider {
617
643
  TStructured = AgentStructuredResponse
618
644
  >(
619
645
  model: string,
620
- input: GenerateMessageInput<TContext>
646
+ input: GenerateMessageInput<TContext>,
647
+ attemptSignal?: AbortSignal
621
648
  ): AsyncGenerator<GenerateMessageStreamChunk<TStructured>> {
622
649
  // Build messages from history and append prompt as final user message
623
650
  const historyMessages = this.buildChatMessages(input.history);
@@ -652,7 +679,9 @@ export abstract class OpenAICompatibleProvider implements AiProvider {
652
679
  );
653
680
  }
654
681
 
655
- const stream = await this.client.chat.completions.create(params);
682
+ const stream = await this.client.chat.completions.create(params, {
683
+ signal: combineAbortSignals(input.signal, attemptSignal),
684
+ });
656
685
 
657
686
  let accumulated = "";
658
687
  let currentModel = model;
@@ -735,18 +764,9 @@ export abstract class OpenAICompatibleProvider implements AiProvider {
735
764
  } as TStructured;
736
765
  }
737
766
 
738
- // Empty-completion guard — mirror of the non-streaming path. A blank
739
- // effective message (structured message under a schema, else accumulated
740
- // text) with no tool calls means the model produced nothing usable; throw
741
- // so withStreamRetry/generateStreamWithBackup retry instead of silently
742
- // emitting an empty message.
743
- const structuredMessage = (structured as AgentStructuredResponse | undefined)?.message;
744
- const messageText = (
745
- typeof structuredMessage === "string" ? structuredMessage : accumulated
746
- ).trim();
747
- if (!messageText && toolCalls.length === 0) {
748
- throw new Error(`No response from ${this.displayName}`);
749
- }
767
+ // Empty-completion guard — same definition as the non-streaming path, so the
768
+ // stream retries / falls back to backup instead of emitting an empty message.
769
+ assertUsableCompletion(structured, accumulated, toolCalls.length, this.displayName);
750
770
 
751
771
  // Yield final chunk
752
772
  yield {
@@ -22,8 +22,12 @@ export { OpenAICompatibleProvider } from "./OpenAICompatibleProvider";
22
22
  export type {
23
23
  OpenAICompatibleProviderInit,
24
24
  OpenAICompatibleRequestConfig,
25
+ StructuredOutputMode,
25
26
  } from "./OpenAICompatibleProvider";
26
27
 
28
+ export { createOpenAICompatibleProvider } from "./GenericOpenAICompatibleProvider";
29
+ export type { OpenAICompatibleOptions } from "./GenericOpenAICompatibleProvider";
30
+
27
31
  export {
28
32
  classifyProviderError,
29
33
  isBackupEligible,
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The user-facing text of a (possibly structured) model completion, trimmed.
3
+ *
4
+ * Under a JSON schema the real message lives in `structured.message`; otherwise
5
+ * it's the raw/accumulated text passed as `fallbackText`. A blank result with no
6
+ * tool calls means the model produced nothing usable — providers throw on that
7
+ * so the retry/backup path runs instead of emitting an empty message. Shared by
8
+ * the streaming and non-streaming empty-completion guards across all providers.
9
+ */
10
+ export function effectiveMessageText(
11
+ structured: unknown,
12
+ fallbackText: string
13
+ ): string {
14
+ const message =
15
+ typeof structured === "object" && structured !== null && "message" in structured
16
+ ? structured.message
17
+ : undefined;
18
+ return (typeof message === "string" ? message : fallbackText).trim();
19
+ }
20
+
21
+ /**
22
+ * The single definition of "the model produced nothing usable": a blank
23
+ * effective message ({@link effectiveMessageText}) and no tool calls. When that
24
+ * holds, throw `No response from <provider>` so the caller's retry/backup path
25
+ * runs instead of surfacing an empty turn. Lifting this above the providers
26
+ * keeps the streaming and non-streaming guards — six call sites across three
27
+ * providers — from drifting on what counts as empty.
28
+ */
29
+ export function assertUsableCompletion(
30
+ structured: unknown,
31
+ fallbackText: string,
32
+ toolCallCount: number,
33
+ providerLabel: string
34
+ ): void {
35
+ if (toolCallCount === 0 && !effectiveMessageText(structured, fallbackText)) {
36
+ throw new Error(`No response from ${providerLabel}`);
37
+ }
38
+ }