@frockbot/plugin-provider-frock-ai 0.3.14 → 0.3.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -6
- package/src/runtime.test.ts +142 -6
- package/src/runtime.ts +153 -27
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-provider-frock-ai",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.15",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -20,11 +20,12 @@
|
|
|
20
20
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@frockbot/configuration-core": "0.3.
|
|
24
|
-
"@frockbot/connection-core": "0.3.
|
|
25
|
-
"@frockbot/kernel-
|
|
26
|
-
"@frockbot/
|
|
27
|
-
"@frockbot/
|
|
23
|
+
"@frockbot/configuration-core": "0.3.15",
|
|
24
|
+
"@frockbot/connection-core": "0.3.15",
|
|
25
|
+
"@frockbot/kernel-agent-loop": "0.3.15",
|
|
26
|
+
"@frockbot/kernel-contracts": "0.3.15",
|
|
27
|
+
"@frockbot/plugin-models": "0.3.15",
|
|
28
|
+
"@frockbot/provider-openai-compatible": "0.3.15",
|
|
28
29
|
"cordis": "4.0.0-rc.8"
|
|
29
30
|
},
|
|
30
31
|
"devDependencies": {
|
package/src/runtime.test.ts
CHANGED
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import {
|
|
3
|
-
LlmEffectNotStartedError,
|
|
4
3
|
MODEL_FIRST_BYTE_DEADLINE_MS_V1,
|
|
5
4
|
MODEL_FIRST_BYTE_DEADLINE_REASON_V1,
|
|
6
5
|
MODEL_IDLE_DEADLINE_MS_V1,
|
|
7
6
|
MODEL_IDLE_DEADLINE_REASON_V1,
|
|
8
7
|
ModelRequestDeadlineError,
|
|
8
|
+
ModelProviderFailureError,
|
|
9
9
|
type NormalizedModelRequest,
|
|
10
10
|
} from "@frockbot/kernel-contracts";
|
|
11
11
|
import { LlmRegistry } from "@frockbot/plugin-models";
|
|
12
|
+
import type { Agent } from "@frockbot/kernel-agent-loop/agent";
|
|
12
13
|
import { Context } from "cordis";
|
|
13
14
|
import {
|
|
14
15
|
FROCK_AI_CONNECTION_GENERATION,
|
|
15
16
|
FROCK_AI_CONNECTION_ID,
|
|
16
17
|
FROCK_AI_DEFAULT_MODEL,
|
|
17
18
|
} from "./catalog.js";
|
|
18
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
classifyFrockAiFailureV1,
|
|
21
|
+
createFrockAiRuntimePlugin,
|
|
22
|
+
FrockAiTransportErrorV1,
|
|
23
|
+
} from "./runtime.js";
|
|
19
24
|
|
|
20
25
|
const request: NormalizedModelRequest = {
|
|
21
26
|
requestId: "effect-1",
|
|
@@ -105,6 +110,135 @@ function pushableSse(): {
|
|
|
105
110
|
}
|
|
106
111
|
|
|
107
112
|
describe("Frock AI runtime Contribution", () => {
|
|
113
|
+
test("maps AI Gateway and Workers AI error envelopes", () => {
|
|
114
|
+
expect(
|
|
115
|
+
classifyFrockAiFailureV1(new FrockAiTransportErrorV1("busy", 503, 2_000)),
|
|
116
|
+
).toMatchObject({
|
|
117
|
+
classification: "transient",
|
|
118
|
+
providerReason: "busy",
|
|
119
|
+
retryAfterMs: 2_000,
|
|
120
|
+
});
|
|
121
|
+
expect(
|
|
122
|
+
classifyFrockAiFailureV1({
|
|
123
|
+
error: { message: "model not found", code: "invalid_model" },
|
|
124
|
+
}),
|
|
125
|
+
).toMatchObject({
|
|
126
|
+
classification: "permanent",
|
|
127
|
+
providerReason: "model not found",
|
|
128
|
+
});
|
|
129
|
+
expect(
|
|
130
|
+
classifyFrockAiFailureV1({ error: { message: "opaque", code: 7999 } }),
|
|
131
|
+
).toMatchObject({ classification: "unknown", providerReason: "opaque" });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("falls from a permanently rejected manual model to Auto immediately", async () => {
|
|
135
|
+
const calls: string[] = [];
|
|
136
|
+
const root = new Context();
|
|
137
|
+
await root.plugin(LlmRegistry);
|
|
138
|
+
await root.plugin(
|
|
139
|
+
createFrockAiRuntimePlugin(
|
|
140
|
+
runtimeConfig((gatewayModel) => {
|
|
141
|
+
calls.push(gatewayModel);
|
|
142
|
+
return gatewayModel.startsWith("workers-ai/")
|
|
143
|
+
? Promise.reject(new FrockAiTransportErrorV1("model missing", 404))
|
|
144
|
+
: Promise.resolve(
|
|
145
|
+
sse(
|
|
146
|
+
'data: {"choices":[{"delta":{"content":"auto"},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n',
|
|
147
|
+
),
|
|
148
|
+
);
|
|
149
|
+
}),
|
|
150
|
+
),
|
|
151
|
+
);
|
|
152
|
+
const agent = {} as Agent;
|
|
153
|
+
const manual = {
|
|
154
|
+
...request,
|
|
155
|
+
model: "@frock/deepseek-ai/deepseek-v4-flash-0731",
|
|
156
|
+
};
|
|
157
|
+
let failure: unknown;
|
|
158
|
+
try {
|
|
159
|
+
for await (const event of root.llm.stream(
|
|
160
|
+
manual,
|
|
161
|
+
new AbortController().signal,
|
|
162
|
+
)) {
|
|
163
|
+
void event;
|
|
164
|
+
}
|
|
165
|
+
} catch (error) {
|
|
166
|
+
failure = error;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const action = await root.waterfall(
|
|
170
|
+
"agent/request-error",
|
|
171
|
+
agent,
|
|
172
|
+
failure,
|
|
173
|
+
new AbortController().signal,
|
|
174
|
+
() => Promise.resolve({ kind: "fail" as const }),
|
|
175
|
+
);
|
|
176
|
+
expect(action).toEqual({ kind: "fallback" });
|
|
177
|
+
const fallback = await root.waterfall(
|
|
178
|
+
"agent/request",
|
|
179
|
+
agent,
|
|
180
|
+
manual,
|
|
181
|
+
new AbortController().signal,
|
|
182
|
+
() => Promise.resolve(manual),
|
|
183
|
+
);
|
|
184
|
+
expect(fallback.model).toBe(FROCK_AI_DEFAULT_MODEL);
|
|
185
|
+
for await (const event of root.llm.stream(
|
|
186
|
+
fallback,
|
|
187
|
+
new AbortController().signal,
|
|
188
|
+
)) {
|
|
189
|
+
void event;
|
|
190
|
+
}
|
|
191
|
+
expect(calls).toEqual([
|
|
192
|
+
"workers-ai/@cf/deepseek-ai/deepseek-v4-flash-0731",
|
|
193
|
+
"dynamic/configured-auto",
|
|
194
|
+
]);
|
|
195
|
+
await root.fiber.dispose();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("leaves a transient manual-model failure on the retry path", async () => {
|
|
199
|
+
const root = new Context();
|
|
200
|
+
await root.plugin(LlmRegistry);
|
|
201
|
+
await root.plugin(
|
|
202
|
+
createFrockAiRuntimePlugin(
|
|
203
|
+
runtimeConfig(() =>
|
|
204
|
+
Promise.reject(new FrockAiTransportErrorV1("busy", 503)),
|
|
205
|
+
),
|
|
206
|
+
),
|
|
207
|
+
);
|
|
208
|
+
const agent = {} as Agent;
|
|
209
|
+
const manual = {
|
|
210
|
+
...request,
|
|
211
|
+
model: "@frock/deepseek-ai/deepseek-v4-flash-0731",
|
|
212
|
+
};
|
|
213
|
+
let failure: unknown;
|
|
214
|
+
try {
|
|
215
|
+
for await (const event of root.llm.stream(
|
|
216
|
+
manual,
|
|
217
|
+
new AbortController().signal,
|
|
218
|
+
)) {
|
|
219
|
+
void event;
|
|
220
|
+
}
|
|
221
|
+
} catch (error) {
|
|
222
|
+
failure = error;
|
|
223
|
+
}
|
|
224
|
+
const action = await root.waterfall(
|
|
225
|
+
"agent/request-error",
|
|
226
|
+
agent,
|
|
227
|
+
failure,
|
|
228
|
+
new AbortController().signal,
|
|
229
|
+
() => Promise.resolve({ kind: "retry" as const }),
|
|
230
|
+
);
|
|
231
|
+
expect(action).toEqual({ kind: "retry" });
|
|
232
|
+
const retried = await root.waterfall(
|
|
233
|
+
"agent/request",
|
|
234
|
+
agent,
|
|
235
|
+
manual,
|
|
236
|
+
new AbortController().signal,
|
|
237
|
+
() => Promise.resolve(manual),
|
|
238
|
+
);
|
|
239
|
+
expect(retried.model).toBe(manual.model);
|
|
240
|
+
await root.fiber.dispose();
|
|
241
|
+
});
|
|
108
242
|
test.each([
|
|
109
243
|
[FROCK_AI_DEFAULT_MODEL, "dynamic/configured-auto"],
|
|
110
244
|
[
|
|
@@ -241,7 +375,7 @@ describe("Frock AI runtime Contribution", () => {
|
|
|
241
375
|
void event;
|
|
242
376
|
}
|
|
243
377
|
})(),
|
|
244
|
-
).rejects.toBeInstanceOf(
|
|
378
|
+
).rejects.toBeInstanceOf(ModelProviderFailureError);
|
|
245
379
|
expect(calls).toBe(0);
|
|
246
380
|
await root.fiber.dispose();
|
|
247
381
|
});
|
|
@@ -273,7 +407,7 @@ describe("Frock AI runtime Contribution", () => {
|
|
|
273
407
|
|
|
274
408
|
// Uncertain here would park the run on a reconciliation this Package
|
|
275
409
|
// cannot perform, wedging the Bot on a transient gateway error.
|
|
276
|
-
expect(failure).toBeInstanceOf(
|
|
410
|
+
expect(failure).toBeInstanceOf(ModelProviderFailureError);
|
|
277
411
|
expect((failure as Error).message).toBe(
|
|
278
412
|
"AI Gateway rejected the request (429): slow down",
|
|
279
413
|
);
|
|
@@ -399,8 +533,10 @@ describe("Frock AI deadlines", () => {
|
|
|
399
533
|
() => undefined,
|
|
400
534
|
(error: unknown) => error,
|
|
401
535
|
);
|
|
402
|
-
expect(failure).toBeInstanceOf(
|
|
403
|
-
expect((failure as
|
|
536
|
+
expect(failure).toBeInstanceOf(ModelProviderFailureError);
|
|
537
|
+
expect((failure as ModelProviderFailureError).classification).toBe(
|
|
538
|
+
"transient",
|
|
539
|
+
);
|
|
404
540
|
expect((failure as Error).message).toBe(
|
|
405
541
|
MODEL_FIRST_BYTE_DEADLINE_REASON_V1,
|
|
406
542
|
);
|
package/src/runtime.ts
CHANGED
|
@@ -1,19 +1,104 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
2
|
+
boundedModelProviderReasonV1,
|
|
3
3
|
type LlmProvider,
|
|
4
4
|
type LlmReconciliationCapability,
|
|
5
|
+
ModelProviderFailureError,
|
|
6
|
+
type ModelProviderFailureClassV1,
|
|
7
|
+
ModelRequestDeadlineError,
|
|
5
8
|
type NormalizedModelRequest,
|
|
6
9
|
} from "@frockbot/kernel-contracts";
|
|
7
10
|
import {
|
|
11
|
+
classifyOpenAICompatibleFailureV1,
|
|
8
12
|
type ModelRequestDeadlineOptionsV1,
|
|
9
13
|
requestToWire,
|
|
10
14
|
streamWithModelRequestDeadlinesV1,
|
|
11
15
|
} from "@frockbot/provider-openai-compatible";
|
|
16
|
+
import type { Agent } from "@frockbot/kernel-agent-loop/agent";
|
|
12
17
|
import type { Plugin } from "cordis";
|
|
13
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
FROCK_AI_DEFAULT_MODEL,
|
|
20
|
+
FROCK_AI_PROVIDER_TYPE,
|
|
21
|
+
gatewayModelForFrockIdV1,
|
|
22
|
+
} from "./catalog.js";
|
|
14
23
|
|
|
15
24
|
export type OpenAICompatibleChatCompletionBodyV1 = Record<string, unknown>;
|
|
16
25
|
|
|
26
|
+
/** The small error shape the Cloudflare host carries across this seam. */
|
|
27
|
+
export class FrockAiTransportErrorV1 extends Error {
|
|
28
|
+
constructor(
|
|
29
|
+
message: string,
|
|
30
|
+
readonly status?: number,
|
|
31
|
+
readonly retryAfterMs?: number,
|
|
32
|
+
readonly code?: string | number,
|
|
33
|
+
) {
|
|
34
|
+
super(boundedModelProviderReasonV1(message));
|
|
35
|
+
this.name = "FrockAiTransportErrorV1";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function errorRecordV1(value: unknown): Record<string, unknown> | undefined {
|
|
40
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
41
|
+
? (value as Record<string, unknown>)
|
|
42
|
+
: undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Map both AI Gateway and Workers AI error envelopes to the model contract. */
|
|
46
|
+
export function classifyFrockAiFailureV1(
|
|
47
|
+
error: unknown,
|
|
48
|
+
): ModelProviderFailureError {
|
|
49
|
+
if (error instanceof ModelProviderFailureError) return error;
|
|
50
|
+
const outer = errorRecordV1(error);
|
|
51
|
+
const nested = errorRecordV1(outer?.error);
|
|
52
|
+
const status =
|
|
53
|
+
error instanceof FrockAiTransportErrorV1
|
|
54
|
+
? error.status
|
|
55
|
+
: typeof outer?.status === "number"
|
|
56
|
+
? outer.status
|
|
57
|
+
: undefined;
|
|
58
|
+
const code =
|
|
59
|
+
error instanceof FrockAiTransportErrorV1
|
|
60
|
+
? error.code
|
|
61
|
+
: (nested?.code ?? outer?.code);
|
|
62
|
+
const reason =
|
|
63
|
+
error instanceof Error
|
|
64
|
+
? error.message
|
|
65
|
+
: typeof nested?.message === "string"
|
|
66
|
+
? nested.message
|
|
67
|
+
: typeof outer?.message === "string"
|
|
68
|
+
? outer.message
|
|
69
|
+
: "Frock AI request did not reach the provider";
|
|
70
|
+
const words = `${String(code ?? "")} ${reason}`;
|
|
71
|
+
let classification: ModelProviderFailureClassV1;
|
|
72
|
+
if (status !== undefined) {
|
|
73
|
+
classification = classifyOpenAICompatibleFailureV1(
|
|
74
|
+
status,
|
|
75
|
+
typeof code === "string" ? code : undefined,
|
|
76
|
+
);
|
|
77
|
+
} else if (
|
|
78
|
+
/rate|overload|temporar|unavailable|timeout|timed out|gateway|reset/i.test(
|
|
79
|
+
words,
|
|
80
|
+
)
|
|
81
|
+
) {
|
|
82
|
+
classification = "transient";
|
|
83
|
+
} else if (
|
|
84
|
+
/invalid|unauthor|forbidden|credential|not found|unknown model|content|safety|policy/i.test(
|
|
85
|
+
words,
|
|
86
|
+
)
|
|
87
|
+
) {
|
|
88
|
+
classification = "permanent";
|
|
89
|
+
} else {
|
|
90
|
+
classification = "unknown";
|
|
91
|
+
}
|
|
92
|
+
return new ModelProviderFailureError({
|
|
93
|
+
classification,
|
|
94
|
+
reason,
|
|
95
|
+
...(error instanceof FrockAiTransportErrorV1 &&
|
|
96
|
+
error.retryAfterMs !== undefined
|
|
97
|
+
? { retryAfterMs: error.retryAfterMs }
|
|
98
|
+
: {}),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
17
102
|
/**
|
|
18
103
|
* The narrow native host seam. Cloudflare's generated `Ai` type remains in
|
|
19
104
|
* apps/cloudflare; the Package consumes one streaming gateway operation.
|
|
@@ -40,6 +125,7 @@ export interface FrockAiRuntimeConfig {
|
|
|
40
125
|
|
|
41
126
|
class FrockAiProvider implements LlmProvider {
|
|
42
127
|
readonly id = FROCK_AI_PROVIDER_TYPE;
|
|
128
|
+
readonly autoFallbackFailures = new WeakSet<ModelProviderFailureError>();
|
|
43
129
|
|
|
44
130
|
/**
|
|
45
131
|
* The Gateway keeps no addressable copy of a completion, so an interrupted
|
|
@@ -63,9 +149,10 @@ class FrockAiProvider implements LlmProvider {
|
|
|
63
149
|
binding?.connectionId !== this.config.connectionId ||
|
|
64
150
|
binding.connectionGeneration !== this.config.connectionGeneration
|
|
65
151
|
) {
|
|
66
|
-
throw new
|
|
67
|
-
|
|
68
|
-
|
|
152
|
+
throw new ModelProviderFailureError({
|
|
153
|
+
classification: "permanent",
|
|
154
|
+
reason: "Frock AI request has invalid Connection authority",
|
|
155
|
+
});
|
|
69
156
|
}
|
|
70
157
|
signal.throwIfAborted();
|
|
71
158
|
const wire = requestToWire(request);
|
|
@@ -87,34 +174,73 @@ class FrockAiProvider implements LlmProvider {
|
|
|
87
174
|
// otherwise bounded by nothing short of the fifteen-minute Turn deadline.
|
|
88
175
|
// The seam's signal is derived from the caller's, so passing it down keeps
|
|
89
176
|
// the cancellation and adds the deadline.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
177
|
+
try {
|
|
178
|
+
yield* streamWithModelRequestDeadlinesV1(
|
|
179
|
+
(deadlineSignal) =>
|
|
180
|
+
this.config.runChatCompletion(gatewayModel, body, deadlineSignal),
|
|
181
|
+
signal,
|
|
182
|
+
this.config.deadlines ?? {},
|
|
183
|
+
);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
if (signal.aborted) throw error;
|
|
186
|
+
if (error instanceof ModelRequestDeadlineError) {
|
|
187
|
+
if (error.phase === "idle") throw error;
|
|
188
|
+
throw new ModelProviderFailureError({
|
|
189
|
+
classification: "transient",
|
|
190
|
+
reason: error.message,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
const failure = classifyFrockAiFailureV1(error);
|
|
194
|
+
if (
|
|
195
|
+
failure.classification === "permanent" &&
|
|
196
|
+
request.model !== FROCK_AI_DEFAULT_MODEL
|
|
197
|
+
) {
|
|
198
|
+
this.autoFallbackFailures.add(failure);
|
|
199
|
+
}
|
|
200
|
+
throw failure;
|
|
201
|
+
}
|
|
110
202
|
}
|
|
111
203
|
}
|
|
112
204
|
|
|
113
205
|
export function createFrockAiRuntimePlugin(
|
|
114
206
|
config: FrockAiRuntimeConfig,
|
|
115
207
|
): Plugin.Function {
|
|
116
|
-
const plugin: Plugin.Function = (ctx) =>
|
|
117
|
-
|
|
208
|
+
const plugin: Plugin.Function = (ctx) => {
|
|
209
|
+
const provider = new FrockAiProvider(config);
|
|
210
|
+
const fallbackAgents = new WeakSet<Agent>();
|
|
211
|
+
const disposeProvider = ctx.llm.register(provider);
|
|
212
|
+
const disposeFailure = ctx.on(
|
|
213
|
+
"agent/request-error",
|
|
214
|
+
async (agent, error, _signal, next) => {
|
|
215
|
+
if (
|
|
216
|
+
!(error instanceof ModelProviderFailureError) ||
|
|
217
|
+
!provider.autoFallbackFailures.has(error)
|
|
218
|
+
) {
|
|
219
|
+
return next();
|
|
220
|
+
}
|
|
221
|
+
fallbackAgents.add(agent);
|
|
222
|
+
return { kind: "fallback" } as const;
|
|
223
|
+
},
|
|
224
|
+
);
|
|
225
|
+
const disposeRequest = ctx.on(
|
|
226
|
+
"agent/request",
|
|
227
|
+
async (agent, _request, _signal, next) => {
|
|
228
|
+
const request = await next();
|
|
229
|
+
return fallbackAgents.has(agent)
|
|
230
|
+
? { ...request, model: FROCK_AI_DEFAULT_MODEL }
|
|
231
|
+
: request;
|
|
232
|
+
},
|
|
233
|
+
);
|
|
234
|
+
const disposeTurn = ctx.on("agent/turn-stopping", async (agent) => {
|
|
235
|
+
fallbackAgents.delete(agent);
|
|
236
|
+
});
|
|
237
|
+
return () => {
|
|
238
|
+
disposeTurn();
|
|
239
|
+
disposeRequest();
|
|
240
|
+
disposeFailure();
|
|
241
|
+
disposeProvider();
|
|
242
|
+
};
|
|
243
|
+
};
|
|
118
244
|
plugin.inject = ["llm"];
|
|
119
245
|
return plugin;
|
|
120
246
|
}
|