@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.
- package/dist/cjs/core/ToolLoopExecutor.d.ts +11 -0
- package/dist/cjs/core/ToolLoopExecutor.d.ts.map +1 -1
- package/dist/cjs/core/ToolLoopExecutor.js +75 -51
- package/dist/cjs/core/ToolLoopExecutor.js.map +1 -1
- package/dist/cjs/index.d.ts +3 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +4 -2
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/providers/AnthropicProvider.d.ts.map +1 -1
- package/dist/cjs/providers/AnthropicProvider.js +18 -35
- package/dist/cjs/providers/AnthropicProvider.js.map +1 -1
- package/dist/cjs/providers/DeepSeekProvider.d.ts +1 -12
- package/dist/cjs/providers/DeepSeekProvider.d.ts.map +1 -1
- package/dist/cjs/providers/DeepSeekProvider.js +3 -20
- package/dist/cjs/providers/DeepSeekProvider.js.map +1 -1
- package/dist/cjs/providers/GeminiProvider.d.ts.map +1 -1
- package/dist/cjs/providers/GeminiProvider.js +19 -36
- package/dist/cjs/providers/GeminiProvider.js.map +1 -1
- package/dist/cjs/providers/GenericOpenAICompatibleProvider.d.ts +69 -0
- package/dist/cjs/providers/GenericOpenAICompatibleProvider.d.ts.map +1 -0
- package/dist/cjs/providers/GenericOpenAICompatibleProvider.js +93 -0
- package/dist/cjs/providers/GenericOpenAICompatibleProvider.js.map +1 -0
- package/dist/cjs/providers/OpenAICompatibleProvider.d.ts +23 -5
- package/dist/cjs/providers/OpenAICompatibleProvider.d.ts.map +1 -1
- package/dist/cjs/providers/OpenAICompatibleProvider.js +46 -44
- package/dist/cjs/providers/OpenAICompatibleProvider.js.map +1 -1
- package/dist/cjs/providers/index.d.ts +3 -1
- package/dist/cjs/providers/index.d.ts.map +1 -1
- package/dist/cjs/providers/index.js +3 -1
- package/dist/cjs/providers/index.js.map +1 -1
- package/dist/cjs/utils/completion.d.ts +20 -0
- package/dist/cjs/utils/completion.d.ts.map +1 -0
- package/dist/cjs/utils/completion.js +33 -0
- package/dist/cjs/utils/completion.js.map +1 -0
- package/dist/cjs/utils/index.d.ts +3 -2
- package/dist/cjs/utils/index.d.ts.map +1 -1
- package/dist/cjs/utils/index.js +7 -1
- package/dist/cjs/utils/index.js.map +1 -1
- package/dist/cjs/utils/retry.d.ts +43 -7
- package/dist/cjs/utils/retry.d.ts.map +1 -1
- package/dist/cjs/utils/retry.js +100 -12
- package/dist/cjs/utils/retry.js.map +1 -1
- package/dist/core/ToolLoopExecutor.d.ts +11 -0
- package/dist/core/ToolLoopExecutor.d.ts.map +1 -1
- package/dist/core/ToolLoopExecutor.js +76 -52
- package/dist/core/ToolLoopExecutor.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/providers/AnthropicProvider.d.ts.map +1 -1
- package/dist/providers/AnthropicProvider.js +19 -36
- package/dist/providers/AnthropicProvider.js.map +1 -1
- package/dist/providers/DeepSeekProvider.d.ts +1 -12
- package/dist/providers/DeepSeekProvider.d.ts.map +1 -1
- package/dist/providers/DeepSeekProvider.js +3 -20
- package/dist/providers/DeepSeekProvider.js.map +1 -1
- package/dist/providers/GeminiProvider.d.ts.map +1 -1
- package/dist/providers/GeminiProvider.js +20 -37
- package/dist/providers/GeminiProvider.js.map +1 -1
- package/dist/providers/GenericOpenAICompatibleProvider.d.ts +69 -0
- package/dist/providers/GenericOpenAICompatibleProvider.d.ts.map +1 -0
- package/dist/providers/GenericOpenAICompatibleProvider.js +87 -0
- package/dist/providers/GenericOpenAICompatibleProvider.js.map +1 -0
- package/dist/providers/OpenAICompatibleProvider.d.ts +23 -5
- package/dist/providers/OpenAICompatibleProvider.d.ts.map +1 -1
- package/dist/providers/OpenAICompatibleProvider.js +47 -45
- package/dist/providers/OpenAICompatibleProvider.js.map +1 -1
- package/dist/providers/index.d.ts +3 -1
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js +1 -0
- package/dist/providers/index.js.map +1 -1
- package/dist/utils/completion.d.ts +20 -0
- package/dist/utils/completion.d.ts.map +1 -0
- package/dist/utils/completion.js +29 -0
- package/dist/utils/completion.js.map +1 -0
- package/dist/utils/index.d.ts +3 -2
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +3 -1
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/retry.d.ts +43 -7
- package/dist/utils/retry.d.ts.map +1 -1
- package/dist/utils/retry.js +98 -12
- package/dist/utils/retry.js.map +1 -1
- package/package.json +1 -1
- package/src/core/ToolLoopExecutor.ts +108 -52
- package/src/index.ts +3 -0
- package/src/providers/AnthropicProvider.ts +22 -43
- package/src/providers/DeepSeekProvider.ts +3 -35
- package/src/providers/GeminiProvider.ts +23 -44
- package/src/providers/GenericOpenAICompatibleProvider.ts +138 -0
- package/src/providers/OpenAICompatibleProvider.ts +87 -67
- package/src/providers/index.ts +4 -0
- package/src/utils/completion.ts +38 -0
- package/src/utils/index.ts +5 -2
- 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
|
-
...(
|
|
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
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
|
|
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
|
-
...(
|
|
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 (
|
|
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 —
|
|
772
|
-
//
|
|
773
|
-
|
|
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
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
*
|
|
188
|
-
*
|
|
189
|
-
* completions
|
|
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
|
-
|
|
193
|
-
|
|
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 (
|
|
518
|
+
if (reqInput.parameters?.jsonSchema) {
|
|
489
519
|
return this.executeStructuredGenerate(
|
|
490
520
|
model,
|
|
491
|
-
|
|
492
|
-
|
|
521
|
+
reqInput,
|
|
522
|
+
reqInput.parameters.jsonSchema
|
|
493
523
|
);
|
|
494
524
|
}
|
|
495
|
-
return this.executeChatCompletion(model,
|
|
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
|
|
588
|
-
//
|
|
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
|
-
|
|
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 —
|
|
739
|
-
//
|
|
740
|
-
|
|
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 {
|
package/src/providers/index.ts
CHANGED
|
@@ -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
|
+
}
|