@frockbot/plugin-models 0.3.14 → 0.3.17

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-models",
3
- "version": "0.3.14",
3
+ "version": "0.3.17",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,7 +17,7 @@
17
17
  "typecheck": "tsc --noEmit -p tsconfig.json"
18
18
  },
19
19
  "dependencies": {
20
- "@frockbot/kernel-contracts": "0.3.14",
20
+ "@frockbot/kernel-contracts": "0.3.17",
21
21
  "cordis": "4.0.0-rc.8"
22
22
  },
23
23
  "devDependencies": {
package/src/llm.ts CHANGED
@@ -6,6 +6,10 @@ import {
6
6
  type LlmStreamEvent,
7
7
  type ModelInvocation,
8
8
  type NormalizedModelRequest,
9
+ type JsonSchemaResponseFormatV1,
10
+ parseStructuredOutputJsonV1,
11
+ type StructuredModelResultV1,
12
+ validateStructuredOutputV1,
9
13
  } from "@frockbot/kernel-contracts";
10
14
 
11
15
  export class LlmRegistry extends Service implements ModelInvocation {
@@ -44,9 +48,72 @@ export class LlmRegistry extends Service implements ModelInvocation {
44
48
  throw new LlmEffectNotStartedError(
45
49
  `LLM provider "${request.provider}" is unavailable`,
46
50
  );
47
- return this.ctx.waterfall("llm/stream", request, signal, () =>
51
+ const events = this.ctx.waterfall("llm/stream", request, signal, () =>
48
52
  provider.stream(request, signal),
49
53
  );
54
+ return this.validatedStream(request, events);
55
+ }
56
+
57
+ private async *validatedStream(
58
+ request: NormalizedModelRequest,
59
+ events: AsyncIterable<LlmStreamEvent>,
60
+ ): AsyncIterable<LlmStreamEvent> {
61
+ let text = "";
62
+ let sawStructuredFailure = false;
63
+ let validatedResponseFormat = false;
64
+ for await (const event of events) {
65
+ if (event.type === "text-delta") text += event.text;
66
+ if (event.type === "structured-output-failure") {
67
+ sawStructuredFailure = true;
68
+ }
69
+ if (
70
+ event.type === "finish" &&
71
+ request.responseFormat &&
72
+ !sawStructuredFailure
73
+ ) {
74
+ validatedResponseFormat = true;
75
+ const failure = responseFormatFailureV1(request, text);
76
+ if (failure) yield { type: "structured-output-failure", failure };
77
+ }
78
+ yield event;
79
+ }
80
+ if (
81
+ request.responseFormat &&
82
+ !sawStructuredFailure &&
83
+ !validatedResponseFormat
84
+ ) {
85
+ const failure = responseFormatFailureV1(request, text);
86
+ if (failure) yield { type: "structured-output-failure", failure };
87
+ }
88
+ }
89
+
90
+ async structured<T>(
91
+ request: NormalizedModelRequest,
92
+ format: Omit<JsonSchemaResponseFormatV1, "type">,
93
+ signal: AbortSignal,
94
+ ): Promise<StructuredModelResultV1<T>> {
95
+ const structuredRequest: NormalizedModelRequest = {
96
+ ...request,
97
+ responseFormat: { type: "json_schema", ...format },
98
+ };
99
+ let raw = "";
100
+ let failure:
101
+ | Extract<
102
+ Awaited<ReturnType<typeof validateStructuredOutputV1>>,
103
+ { status: "failed" }
104
+ >["failure"]
105
+ | undefined;
106
+ for await (const event of this.stream(structuredRequest, signal)) {
107
+ if (event.type === "text-delta") raw += event.text;
108
+ if (event.type === "structured-output-failure") {
109
+ failure = event.failure;
110
+ }
111
+ }
112
+ if (failure) return { status: "failed", failure, raw };
113
+ const validated = validateStructuredOutputV1(raw, format.schema);
114
+ if (validated.status === "failed") return validated;
115
+ // SAFETY: callers choose T alongside the schema that was just validated.
116
+ return { status: "completed", value: validated.value as T, raw };
50
117
  }
51
118
 
52
119
  async reconcile(
@@ -69,10 +136,31 @@ export class LlmRegistry extends Service implements ModelInvocation {
69
136
  };
70
137
  }
71
138
  try {
72
- return await provider.reconciliation.retrieve(
139
+ const outcome = await provider.reconciliation.retrieve(
73
140
  { providerEffectId: request.requestId, request },
74
141
  signal,
75
142
  );
143
+ if (outcome.status !== "recovered" || !request.responseFormat) {
144
+ return outcome;
145
+ }
146
+ const raw = outcome.events
147
+ .filter(
148
+ (event): event is Extract<LlmStreamEvent, { type: "text-delta" }> =>
149
+ event.type === "text-delta",
150
+ )
151
+ .map((event) => event.text)
152
+ .join("");
153
+ const failure = responseFormatFailureV1(request, raw);
154
+ if (!failure) return outcome;
155
+ const finish = outcome.events.findIndex(
156
+ (event) => event.type === "finish",
157
+ );
158
+ const events = [...outcome.events];
159
+ events.splice(finish < 0 ? events.length : finish, 0, {
160
+ type: "structured-output-failure",
161
+ failure,
162
+ });
163
+ return { status: "recovered", events };
76
164
  } catch (error) {
77
165
  signal.throwIfAborted();
78
166
  return {
@@ -85,3 +173,18 @@ export class LlmRegistry extends Service implements ModelInvocation {
85
173
  }
86
174
  }
87
175
  }
176
+
177
+ function responseFormatFailureV1(
178
+ request: NormalizedModelRequest,
179
+ text: string,
180
+ ):
181
+ | Extract<StructuredModelResultV1<unknown>, { status: "failed" }>["failure"]
182
+ | undefined {
183
+ const format = request.responseFormat;
184
+ if (!format) return undefined;
185
+ const result =
186
+ format.type === "json_schema"
187
+ ? validateStructuredOutputV1(text, format.schema)
188
+ : parseStructuredOutputJsonV1(text);
189
+ return result.status === "failed" ? result.failure : undefined;
190
+ }
@@ -0,0 +1,134 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ type LlmProvider,
4
+ type NormalizedModelRequest,
5
+ } from "@frockbot/kernel-contracts";
6
+ import { Context } from "cordis";
7
+ import { LlmRegistry } from "./llm.js";
8
+
9
+ const schema = {
10
+ type: "object" as const,
11
+ properties: { answer: { type: "string" as const } },
12
+ required: ["answer"],
13
+ additionalProperties: false,
14
+ };
15
+
16
+ function request(): NormalizedModelRequest {
17
+ return {
18
+ requestId: "request-1",
19
+ provider: "fake-structured",
20
+ model: "fake-model",
21
+ system: "Answer briefly.",
22
+ messages: [{ role: "user", content: "Is this typed?" }],
23
+ tools: [],
24
+ };
25
+ }
26
+
27
+ function fakeProvider(content: string): LlmProvider {
28
+ return {
29
+ id: "fake-structured",
30
+ supports: { structuredOutput: "json_schema" },
31
+ async *stream(modelRequest) {
32
+ expect(modelRequest.responseFormat).toEqual({
33
+ type: "json_schema",
34
+ name: "answer",
35
+ schema,
36
+ });
37
+ yield { type: "text-delta", text: content };
38
+ yield { type: "finish", reason: "completed" };
39
+ },
40
+ };
41
+ }
42
+
43
+ async function mounted(content: string): Promise<Context> {
44
+ const root = new Context();
45
+ await root.plugin(LlmRegistry);
46
+ root.llm.register(fakeProvider(content));
47
+ return root;
48
+ }
49
+
50
+ describe("structured output through a fake provider", () => {
51
+ test("round-trips a validated typed value", async () => {
52
+ const root = await mounted('{"answer":"yes"}');
53
+ const result = await root.llm.structured<{ answer: string }>(
54
+ request(),
55
+ { name: "answer", schema },
56
+ new AbortController().signal,
57
+ );
58
+ expect(result).toEqual({
59
+ status: "completed",
60
+ value: { answer: "yes" },
61
+ raw: '{"answer":"yes"}',
62
+ });
63
+ await root.fiber.dispose();
64
+ });
65
+
66
+ test("emits and returns a typed validation failure", async () => {
67
+ const root = await mounted('{"answer":4}');
68
+ const events = [];
69
+ for await (const event of root.llm.stream(
70
+ {
71
+ ...request(),
72
+ responseFormat: { type: "json_schema", name: "answer", schema },
73
+ },
74
+ new AbortController().signal,
75
+ )) {
76
+ events.push(event);
77
+ }
78
+ expect(events.at(-2)).toEqual({
79
+ type: "structured-output-failure",
80
+ failure: {
81
+ code: "schema-mismatch",
82
+ message: "The model response did not match the requested schema",
83
+ issues: [
84
+ {
85
+ path: "$.answer",
86
+ code: "type",
87
+ message: "$.answer must be a string",
88
+ },
89
+ ],
90
+ },
91
+ });
92
+
93
+ const result = await root.llm.structured<{ answer: string }>(
94
+ { ...request(), requestId: "request-2" },
95
+ { name: "answer", schema },
96
+ new AbortController().signal,
97
+ );
98
+ expect(result.status).toBe("failed");
99
+ if (result.status === "failed") {
100
+ expect(result.failure.code).toBe("schema-mismatch");
101
+ }
102
+ await root.fiber.dispose();
103
+ });
104
+
105
+ test("emits a typed invalid-JSON failure for schema-free JSON mode", async () => {
106
+ const root = new Context();
107
+ await root.plugin(LlmRegistry);
108
+ root.llm.register({
109
+ id: "fake-json",
110
+ supports: { structuredOutput: "json" },
111
+ async *stream(modelRequest) {
112
+ expect(modelRequest.responseFormat).toEqual({ type: "json" });
113
+ yield { type: "text-delta", text: "not json" };
114
+ yield { type: "finish", reason: "completed" };
115
+ },
116
+ });
117
+ const events = [];
118
+ for await (const event of root.llm.stream(
119
+ {
120
+ ...request(),
121
+ provider: "fake-json",
122
+ responseFormat: { type: "json" },
123
+ },
124
+ new AbortController().signal,
125
+ )) {
126
+ events.push(event);
127
+ }
128
+ expect(events.at(-2)).toMatchObject({
129
+ type: "structured-output-failure",
130
+ failure: { code: "invalid-json" },
131
+ });
132
+ await root.fiber.dispose();
133
+ });
134
+ });