@frockbot/plugin-models 0.3.16 → 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 +2 -2
- package/src/llm.ts +105 -2
- package/src/structured-output.integration.test.ts +134 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-models",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|