@falai/agent 2.4.2 → 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 (142) hide show
  1. package/dist/cjs/core/PersistenceManager.d.ts +13 -0
  2. package/dist/cjs/core/PersistenceManager.d.ts.map +1 -1
  3. package/dist/cjs/core/PersistenceManager.js +29 -6
  4. package/dist/cjs/core/PersistenceManager.js.map +1 -1
  5. package/dist/cjs/core/ResponseModal.d.ts.map +1 -1
  6. package/dist/cjs/core/ResponseModal.js +15 -3
  7. package/dist/cjs/core/ResponseModal.js.map +1 -1
  8. package/dist/cjs/core/SessionManager.d.ts.map +1 -1
  9. package/dist/cjs/core/SessionManager.js +7 -0
  10. package/dist/cjs/core/SessionManager.js.map +1 -1
  11. package/dist/cjs/core/StreamingToolExecutor.d.ts.map +1 -1
  12. package/dist/cjs/core/StreamingToolExecutor.js +11 -0
  13. package/dist/cjs/core/StreamingToolExecutor.js.map +1 -1
  14. package/dist/cjs/core/ToolLoopExecutor.d.ts +27 -2
  15. package/dist/cjs/core/ToolLoopExecutor.d.ts.map +1 -1
  16. package/dist/cjs/core/ToolLoopExecutor.js +159 -74
  17. package/dist/cjs/core/ToolLoopExecutor.js.map +1 -1
  18. package/dist/cjs/core/ToolManager.d.ts.map +1 -1
  19. package/dist/cjs/core/ToolManager.js +8 -31
  20. package/dist/cjs/core/ToolManager.js.map +1 -1
  21. package/dist/cjs/core/toolGates.d.ts +24 -0
  22. package/dist/cjs/core/toolGates.d.ts.map +1 -0
  23. package/dist/cjs/core/toolGates.js +52 -0
  24. package/dist/cjs/core/toolGates.js.map +1 -0
  25. package/dist/cjs/index.d.ts +3 -0
  26. package/dist/cjs/index.d.ts.map +1 -1
  27. package/dist/cjs/index.js +4 -2
  28. package/dist/cjs/index.js.map +1 -1
  29. package/dist/cjs/providers/AnthropicProvider.d.ts.map +1 -1
  30. package/dist/cjs/providers/AnthropicProvider.js +18 -14
  31. package/dist/cjs/providers/AnthropicProvider.js.map +1 -1
  32. package/dist/cjs/providers/DeepSeekProvider.d.ts +1 -12
  33. package/dist/cjs/providers/DeepSeekProvider.d.ts.map +1 -1
  34. package/dist/cjs/providers/DeepSeekProvider.js +3 -20
  35. package/dist/cjs/providers/DeepSeekProvider.js.map +1 -1
  36. package/dist/cjs/providers/GeminiProvider.d.ts.map +1 -1
  37. package/dist/cjs/providers/GeminiProvider.js +19 -15
  38. package/dist/cjs/providers/GeminiProvider.js.map +1 -1
  39. package/dist/cjs/providers/GenericOpenAICompatibleProvider.d.ts +69 -0
  40. package/dist/cjs/providers/GenericOpenAICompatibleProvider.d.ts.map +1 -0
  41. package/dist/cjs/providers/GenericOpenAICompatibleProvider.js +93 -0
  42. package/dist/cjs/providers/GenericOpenAICompatibleProvider.js.map +1 -0
  43. package/dist/cjs/providers/OpenAICompatibleProvider.d.ts +23 -5
  44. package/dist/cjs/providers/OpenAICompatibleProvider.d.ts.map +1 -1
  45. package/dist/cjs/providers/OpenAICompatibleProvider.js +47 -23
  46. package/dist/cjs/providers/OpenAICompatibleProvider.js.map +1 -1
  47. package/dist/cjs/providers/index.d.ts +3 -1
  48. package/dist/cjs/providers/index.d.ts.map +1 -1
  49. package/dist/cjs/providers/index.js +3 -1
  50. package/dist/cjs/providers/index.js.map +1 -1
  51. package/dist/cjs/utils/completion.d.ts +20 -0
  52. package/dist/cjs/utils/completion.d.ts.map +1 -0
  53. package/dist/cjs/utils/completion.js +33 -0
  54. package/dist/cjs/utils/completion.js.map +1 -0
  55. package/dist/cjs/utils/index.d.ts +3 -2
  56. package/dist/cjs/utils/index.d.ts.map +1 -1
  57. package/dist/cjs/utils/index.js +8 -1
  58. package/dist/cjs/utils/index.js.map +1 -1
  59. package/dist/cjs/utils/retry.d.ts +58 -1
  60. package/dist/cjs/utils/retry.d.ts.map +1 -1
  61. package/dist/cjs/utils/retry.js +128 -3
  62. package/dist/cjs/utils/retry.js.map +1 -1
  63. package/dist/core/PersistenceManager.d.ts +13 -0
  64. package/dist/core/PersistenceManager.d.ts.map +1 -1
  65. package/dist/core/PersistenceManager.js +29 -6
  66. package/dist/core/PersistenceManager.js.map +1 -1
  67. package/dist/core/ResponseModal.d.ts.map +1 -1
  68. package/dist/core/ResponseModal.js +15 -3
  69. package/dist/core/ResponseModal.js.map +1 -1
  70. package/dist/core/SessionManager.d.ts.map +1 -1
  71. package/dist/core/SessionManager.js +7 -0
  72. package/dist/core/SessionManager.js.map +1 -1
  73. package/dist/core/StreamingToolExecutor.d.ts.map +1 -1
  74. package/dist/core/StreamingToolExecutor.js +11 -0
  75. package/dist/core/StreamingToolExecutor.js.map +1 -1
  76. package/dist/core/ToolLoopExecutor.d.ts +27 -2
  77. package/dist/core/ToolLoopExecutor.d.ts.map +1 -1
  78. package/dist/core/ToolLoopExecutor.js +160 -75
  79. package/dist/core/ToolLoopExecutor.js.map +1 -1
  80. package/dist/core/ToolManager.d.ts.map +1 -1
  81. package/dist/core/ToolManager.js +8 -31
  82. package/dist/core/ToolManager.js.map +1 -1
  83. package/dist/core/toolGates.d.ts +24 -0
  84. package/dist/core/toolGates.d.ts.map +1 -0
  85. package/dist/core/toolGates.js +49 -0
  86. package/dist/core/toolGates.js.map +1 -0
  87. package/dist/index.d.ts +3 -0
  88. package/dist/index.d.ts.map +1 -1
  89. package/dist/index.js +1 -0
  90. package/dist/index.js.map +1 -1
  91. package/dist/providers/AnthropicProvider.d.ts.map +1 -1
  92. package/dist/providers/AnthropicProvider.js +19 -15
  93. package/dist/providers/AnthropicProvider.js.map +1 -1
  94. package/dist/providers/DeepSeekProvider.d.ts +1 -12
  95. package/dist/providers/DeepSeekProvider.d.ts.map +1 -1
  96. package/dist/providers/DeepSeekProvider.js +3 -20
  97. package/dist/providers/DeepSeekProvider.js.map +1 -1
  98. package/dist/providers/GeminiProvider.d.ts.map +1 -1
  99. package/dist/providers/GeminiProvider.js +20 -16
  100. package/dist/providers/GeminiProvider.js.map +1 -1
  101. package/dist/providers/GenericOpenAICompatibleProvider.d.ts +69 -0
  102. package/dist/providers/GenericOpenAICompatibleProvider.d.ts.map +1 -0
  103. package/dist/providers/GenericOpenAICompatibleProvider.js +87 -0
  104. package/dist/providers/GenericOpenAICompatibleProvider.js.map +1 -0
  105. package/dist/providers/OpenAICompatibleProvider.d.ts +23 -5
  106. package/dist/providers/OpenAICompatibleProvider.d.ts.map +1 -1
  107. package/dist/providers/OpenAICompatibleProvider.js +48 -24
  108. package/dist/providers/OpenAICompatibleProvider.js.map +1 -1
  109. package/dist/providers/index.d.ts +3 -1
  110. package/dist/providers/index.d.ts.map +1 -1
  111. package/dist/providers/index.js +1 -0
  112. package/dist/providers/index.js.map +1 -1
  113. package/dist/utils/completion.d.ts +20 -0
  114. package/dist/utils/completion.d.ts.map +1 -0
  115. package/dist/utils/completion.js +29 -0
  116. package/dist/utils/completion.js.map +1 -0
  117. package/dist/utils/index.d.ts +3 -2
  118. package/dist/utils/index.d.ts.map +1 -1
  119. package/dist/utils/index.js +3 -1
  120. package/dist/utils/index.js.map +1 -1
  121. package/dist/utils/retry.d.ts +58 -1
  122. package/dist/utils/retry.d.ts.map +1 -1
  123. package/dist/utils/retry.js +125 -3
  124. package/dist/utils/retry.js.map +1 -1
  125. package/package.json +1 -1
  126. package/src/core/PersistenceManager.ts +30 -6
  127. package/src/core/ResponseModal.ts +16 -3
  128. package/src/core/SessionManager.ts +7 -0
  129. package/src/core/StreamingToolExecutor.ts +12 -0
  130. package/src/core/ToolLoopExecutor.ts +210 -79
  131. package/src/core/ToolManager.ts +8 -32
  132. package/src/core/toolGates.ts +72 -0
  133. package/src/index.ts +3 -0
  134. package/src/providers/AnthropicProvider.ts +26 -20
  135. package/src/providers/DeepSeekProvider.ts +3 -35
  136. package/src/providers/GeminiProvider.ts +29 -17
  137. package/src/providers/GenericOpenAICompatibleProvider.ts +138 -0
  138. package/src/providers/OpenAICompatibleProvider.ts +92 -44
  139. package/src/providers/index.ts +4 -0
  140. package/src/utils/completion.ts +38 -0
  141. package/src/utils/index.ts +5 -2
  142. package/src/utils/retry.ts +181 -4
@@ -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 } 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,10 +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
- timeout: retryConfig?.timeout || DEFAULT_RETRY_CONFIG.timeout,
111
- retries: retryConfig?.retries || DEFAULT_RETRY_CONFIG.retries,
112
- };
105
+ this.retryConfig = resolveRetryConfig(retryConfig);
113
106
  }
114
107
 
115
108
  /**
@@ -419,7 +412,7 @@ export class GeminiProvider implements AiProvider {
419
412
  model: string,
420
413
  input: GenerateMessageInput<TContext>
421
414
  ): Promise<GenerateMessageOutput<TStructured>> {
422
- const operation = async (): Promise<GenerateMessageOutput> => {
415
+ const operation = async (signal: AbortSignal): Promise<GenerateMessageOutput> => {
423
416
  // Schema-required: configure response schema
424
417
  const configOverride: Partial<GenerateContentConfig> = { ...this.config };
425
418
 
@@ -464,12 +457,13 @@ export class GeminiProvider implements AiProvider {
464
457
  }
465
458
  }
466
459
 
460
+ const reqSignal = combineAbortSignals(input.signal, signal);
467
461
  response = await this.genAI.models.generateContent({
468
462
  model,
469
463
  contents: historyContents,
470
464
  config: {
471
465
  ...configOverride,
472
- ...(input.signal ? { abortSignal: input.signal } : {}),
466
+ ...(reqSignal ? { abortSignal: reqSignal } : {}),
473
467
  },
474
468
  });
475
469
  } catch (error: unknown) {
@@ -538,6 +532,11 @@ export class GeminiProvider implements AiProvider {
538
532
  } as AgentStructuredResponse;
539
533
  }
540
534
 
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");
539
+
541
540
  return {
542
541
  message,
543
542
  metadata: {
@@ -566,7 +565,10 @@ export class GeminiProvider implements AiProvider {
566
565
  ): AsyncGenerator<GenerateMessageStreamChunk<TStructured>> {
567
566
  // Try primary model first
568
567
  try {
569
- yield* this.generateStreamWithModel(this.primaryModel, input);
568
+ yield* withStreamRetry(
569
+ (signal) => this.generateStreamWithModel(this.primaryModel, input, signal),
570
+ { maxRetries: this.retryConfig.retries, firstChunkTimeoutMs: this.retryConfig.timeout, operationName: `Gemini ${this.primaryModel} stream` }
571
+ );
570
572
  } catch (primaryError: unknown) {
571
573
  const primaryErrMsg = getErrorMessage(primaryError);
572
574
  logger.warn(
@@ -589,7 +591,10 @@ export class GeminiProvider implements AiProvider {
589
591
  );
590
592
 
591
593
  try {
592
- yield* this.generateStreamWithModel(backupModel, input);
594
+ yield* withStreamRetry(
595
+ (signal) => this.generateStreamWithModel(backupModel, input, signal),
596
+ { maxRetries: this.retryConfig.retries, firstChunkTimeoutMs: this.retryConfig.timeout, operationName: `Gemini ${backupModel} stream` }
597
+ );
593
598
  logger.debug(`[GEMINI] Backup model ${backupModel} succeeded`);
594
599
  return;
595
600
  } catch (backupError: unknown) {
@@ -624,8 +629,11 @@ export class GeminiProvider implements AiProvider {
624
629
  TStructured = AgentStructuredResponse
625
630
  >(
626
631
  model: string,
627
- input: GenerateMessageInput<TContext>
632
+ input: GenerateMessageInput<TContext>,
633
+ attemptSignal?: AbortSignal
628
634
  ): AsyncGenerator<GenerateMessageStreamChunk<TStructured>> {
635
+ // Caller cancellation + the retry helper's per-attempt deadline, as one.
636
+ const reqSignal = combineAbortSignals(input.signal, attemptSignal);
629
637
  // Streaming: request JSON if schema provided
630
638
  const configOverride: Partial<GenerateContentConfig> = { ...this.config };
631
639
 
@@ -675,7 +683,7 @@ export class GeminiProvider implements AiProvider {
675
683
  contents: historyContents,
676
684
  config: {
677
685
  ...configOverride,
678
- ...(input.signal ? { abortSignal: input.signal } : {}),
686
+ ...(reqSignal ? { abortSignal: reqSignal } : {}),
679
687
  },
680
688
  });
681
689
  } catch (error: unknown) {
@@ -693,7 +701,7 @@ export class GeminiProvider implements AiProvider {
693
701
  }> = [];
694
702
 
695
703
  for await (const chunk of stream) {
696
- if (input.signal?.aborted) break;
704
+ if (reqSignal?.aborted) break;
697
705
 
698
706
  // Safely extract text — chunk.text can throw when chunk has only function calls
699
707
  const delta = this.safeExtractText(chunk);
@@ -747,6 +755,10 @@ export class GeminiProvider implements AiProvider {
747
755
  } as AgentStructuredResponse;
748
756
  }
749
757
 
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");
761
+
750
762
  // Yield final chunk
751
763
  yield {
752
764
  delta: "",
@@ -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, 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,10 +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
- timeout: init.retryConfig?.timeout || DEFAULT_RETRY_CONFIG.timeout,
104
- retries: init.retryConfig?.retries || DEFAULT_RETRY_CONFIG.retries,
105
- };
115
+ this.retryConfig = resolveRetryConfig(init.retryConfig);
116
+ this.structuredOutput = init.structuredOutput ?? "responses_parse";
106
117
  }
107
118
 
108
119
  // ---------------------------------------------------------------------
@@ -142,22 +153,31 @@ export abstract class OpenAICompatibleProvider implements AiProvider {
142
153
  input: GenerateMessageInput<unknown>,
143
154
  jsonSchema: StructuredSchema
144
155
  ): Promise<GenerateMessageOutput> {
145
- const response = await this.client.responses.parse({
146
- model,
147
- instructions: input.prompt,
148
- input: "",
149
- reasoning: {
150
- effort: input.parameters?.reasoning?.effort || "low",
151
- },
152
- text: {
153
- format: {
154
- type: "json_schema",
155
- name: input.parameters?.schemaName || "structured_output",
156
- // Adapt common schema format to the provider's format
157
- 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
+ },
158
177
  },
159
178
  },
160
- });
179
+ { signal: input.signal }
180
+ );
161
181
 
162
182
  if (!response.output_parsed) {
163
183
  throw new Error(`No parsed output returned from ${this.displayName}`);
@@ -179,15 +199,25 @@ export abstract class OpenAICompatibleProvider implements AiProvider {
179
199
  }
180
200
 
181
201
  /**
182
- * response_format applied to chat-completions requests when a JSON
183
- * schema is present. Default requests a generic JSON object (parsed at
184
- * the end); subclasses with native json_schema support in chat
185
- * 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.)
186
207
  */
187
208
  protected structuredResponseFormat(
188
- _jsonSchema: StructuredSchema,
189
- _schemaName: string | undefined
209
+ jsonSchema: StructuredSchema,
210
+ schemaName: string | undefined
190
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
+ }
191
221
  return { type: "json_object" };
192
222
  }
193
223
 
@@ -408,9 +438,9 @@ export abstract class OpenAICompatibleProvider implements AiProvider {
408
438
  ): AsyncGenerator<GenerateMessageStreamChunk<TStructured>> {
409
439
  // Try primary model first
410
440
  try {
411
- yield* this.generateStreamWithModel<TContext, TStructured>(
412
- this.primaryModel,
413
- input
441
+ yield* withStreamRetry(
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` }
414
444
  );
415
445
  } catch (primaryError: unknown) {
416
446
  const primaryErrMsg = getErrorMessage(primaryError);
@@ -435,9 +465,9 @@ export abstract class OpenAICompatibleProvider implements AiProvider {
435
465
  );
436
466
 
437
467
  try {
438
- yield* this.generateStreamWithModel<TContext, TStructured>(
439
- backupModel,
440
- input
468
+ yield* withStreamRetry(
469
+ (signal) => this.generateStreamWithModel<TContext, TStructured>(backupModel, input, signal),
470
+ { maxRetries: this.retryConfig.retries, firstChunkTimeoutMs: this.retryConfig.timeout, operationName: `${this.logLabel} ${backupModel} stream` }
441
471
  );
442
472
  logger.debug(`[${this.logLabel}] Backup model ${backupModel} succeeded`);
443
473
  return;
@@ -479,16 +509,20 @@ export abstract class OpenAICompatibleProvider implements AiProvider {
479
509
  model: string,
480
510
  input: GenerateMessageInput<TContext>
481
511
  ): Promise<GenerateMessageOutput<TStructured>> {
482
- 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) };
483
517
  // Use structured output path if JSON schema is provided
484
- if (input.parameters?.jsonSchema) {
518
+ if (reqInput.parameters?.jsonSchema) {
485
519
  return this.executeStructuredGenerate(
486
520
  model,
487
- input,
488
- input.parameters.jsonSchema
521
+ reqInput,
522
+ reqInput.parameters.jsonSchema
489
523
  );
490
524
  }
491
- return this.executeChatCompletion(model, input);
525
+ return this.executeChatCompletion(model, reqInput);
492
526
  };
493
527
 
494
528
  return withTimeoutAndRetry(
@@ -536,7 +570,9 @@ export abstract class OpenAICompatibleProvider implements AiProvider {
536
570
  );
537
571
  }
538
572
 
539
- const response = await this.client.chat.completions.create(params);
573
+ const response = await this.client.chat.completions.create(params, {
574
+ signal: input.signal,
575
+ });
540
576
 
541
577
  const message = response.choices[0]?.message?.content || "";
542
578
 
@@ -580,6 +616,11 @@ export abstract class OpenAICompatibleProvider implements AiProvider {
580
616
  } as AgentStructuredResponse;
581
617
  }
582
618
 
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
621
+ // returning {"message":""}.
622
+ assertUsableCompletion(structured, message, toolCalls.length, this.displayName);
623
+
583
624
  return {
584
625
  message,
585
626
  metadata: {
@@ -602,7 +643,8 @@ export abstract class OpenAICompatibleProvider implements AiProvider {
602
643
  TStructured = AgentStructuredResponse
603
644
  >(
604
645
  model: string,
605
- input: GenerateMessageInput<TContext>
646
+ input: GenerateMessageInput<TContext>,
647
+ attemptSignal?: AbortSignal
606
648
  ): AsyncGenerator<GenerateMessageStreamChunk<TStructured>> {
607
649
  // Build messages from history and append prompt as final user message
608
650
  const historyMessages = this.buildChatMessages(input.history);
@@ -637,7 +679,9 @@ export abstract class OpenAICompatibleProvider implements AiProvider {
637
679
  );
638
680
  }
639
681
 
640
- 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
+ });
641
685
 
642
686
  let accumulated = "";
643
687
  let currentModel = model;
@@ -720,6 +764,10 @@ export abstract class OpenAICompatibleProvider implements AiProvider {
720
764
  } as TStructured;
721
765
  }
722
766
 
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);
770
+
723
771
  // Yield final chunk
724
772
  yield {
725
773
  delta: "",
@@ -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
+ }
@@ -55,8 +55,11 @@ export {
55
55
  export { LoggerLevel, logger } from "./logger";
56
56
 
57
57
  // Retry utilities
58
- export type { RetryOptions } from "./retry";
59
- export { retry, withTimeoutAndRetry } from "./retry";
58
+ export type { RetryOptions, StreamRetryOptions, RetryConfig } from "./retry";
59
+ export { retry, withTimeoutAndRetry, withStreamRetry, resolveRetryConfig, combineAbortSignals } from "./retry";
60
+
61
+ // Completion helpers
62
+ export { effectiveMessageText, assertUsableCompletion } from "./completion";
60
63
 
61
64
  // Condition utilities
62
65
  export {