@frockbot/plugin-provider-frock-ai 0.3.13 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-provider-frock-ai",
3
- "version": "0.3.13",
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.13",
24
- "@frockbot/connection-core": "0.3.13",
25
- "@frockbot/kernel-contracts": "0.3.13",
26
- "@frockbot/plugin-models": "0.3.13",
27
- "@frockbot/provider-openai-compatible": "0.3.13",
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": {
@@ -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 { createFrockAiRuntimePlugin } from "./runtime.js";
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(LlmEffectNotStartedError);
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(LlmEffectNotStartedError);
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(ModelRequestDeadlineError);
403
- expect((failure as ModelRequestDeadlineError).phase).toBe("first-byte");
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
- LlmEffectNotStartedError,
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 { FROCK_AI_PROVIDER_TYPE, gatewayModelForFrockIdV1 } from "./catalog.js";
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 LlmEffectNotStartedError(
67
- "Frock AI request has invalid Connection authority",
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
- yield* streamWithModelRequestDeadlinesV1(
91
- async (deadlineSignal) => {
92
- try {
93
- return await this.config.runChatCompletion(
94
- gatewayModel,
95
- body,
96
- deadlineSignal,
97
- );
98
- } catch (error) {
99
- deadlineSignal.throwIfAborted();
100
- throw new LlmEffectNotStartedError(
101
- error instanceof Error
102
- ? error.message
103
- : "Frock AI request did not reach the gateway",
104
- );
105
- }
106
- },
107
- signal,
108
- this.config.deadlines ?? {},
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
- ctx.llm.register(new FrockAiProvider(config));
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
  }