@kernl-sdk/ai 0.2.10 → 0.3.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.
@@ -1,4 +1,4 @@
1
1
 
2
- > @kernl-sdk/ai@0.2.9 build /Users/andjones/Documents/projects/kernl/packages/providers/ai
2
+ > @kernl-sdk/ai@0.2.10 build /Users/andjones/Documents/projects/kernl/packages/providers/ai
3
3
  > tsc && tsc-alias --resolve-full-paths
4
4
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # @kernl/ai
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 3fe8682: Add native structured output support for agents
8
+
9
+ **kernl**
10
+ - Add `output` field to Agent config (Zod schema for structured responses)
11
+ - Rename type params: `TResponse` → `TOutput`, `AgentResponseType` → `AgentOutputType`
12
+ - Wire `agent.output` through Thread to protocol's `responseType`
13
+
14
+ **@kernl-sdk/ai**
15
+ - Add `RESPONSE_FORMAT` codec for AI SDK's `responseFormat` parameter
16
+ - Add structured output integration tests for OpenAI, Anthropic, and Google
17
+
3
18
  ## 0.2.10
4
19
 
5
20
  ### Patch Changes
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect, beforeAll } from "vitest";
2
2
  import { openai } from "@ai-sdk/openai";
3
3
  import { anthropic } from "@ai-sdk/anthropic";
4
+ import { google } from "@ai-sdk/google";
4
5
  import { IN_PROGRESS } from "@kernl-sdk/protocol";
5
6
  import { AISDKLanguageModel } from "../language-model.js";
6
7
  /**
@@ -9,11 +10,26 @@ import { AISDKLanguageModel } from "../language-model.js";
9
10
  * These tests require API keys to be set:
10
11
  * - OPENAI_API_KEY for OpenAI tests
11
12
  * - ANTHROPIC_API_KEY for Anthropic tests
13
+ * - GOOGLE_GENERATIVE_AI_API_KEY for Google tests
12
14
  *
13
- * Run with: OPENAI_API_KEY=your-key ANTHROPIC_API_KEY=your-key pnpm test:run
15
+ * Run with: OPENAI_API_KEY=your-key ANTHROPIC_API_KEY=your-key GOOGLE_GENERATIVE_AI_API_KEY=your-key pnpm test:run
14
16
  */
15
17
  const SKIP_OPENAI_TESTS = !process.env.OPENAI_API_KEY;
16
18
  const SKIP_ANTHROPIC_TESTS = !process.env.ANTHROPIC_API_KEY;
19
+ const SKIP_GOOGLE_TESTS = !process.env.GOOGLE_GENERATIVE_AI_API_KEY;
20
+ /**
21
+ * Shared JSON schema for structured output tests.
22
+ * Extracts a person's name and age from text.
23
+ */
24
+ const PERSON_SCHEMA = {
25
+ type: "object",
26
+ properties: {
27
+ name: { type: "string", description: "The person's name" },
28
+ age: { type: "number", description: "The person's age in years" },
29
+ },
30
+ required: ["name", "age"],
31
+ additionalProperties: false,
32
+ };
17
33
  describe.skipIf(SKIP_OPENAI_TESTS)("AISDKLanguageModel - OpenAI", () => {
18
34
  let gpt41;
19
35
  beforeAll(() => {
@@ -645,6 +661,94 @@ describe.skipIf(SKIP_OPENAI_TESTS)("AISDKLanguageModel - OpenAI", () => {
645
661
  })).rejects.toThrow(/max_output_tokens/);
646
662
  });
647
663
  });
664
+ describe("structured output", () => {
665
+ it("should generate structured JSON output with responseType", async () => {
666
+ const response = await gpt41.generate({
667
+ input: [
668
+ {
669
+ kind: "message",
670
+ role: "user",
671
+ id: "msg-1",
672
+ content: [
673
+ {
674
+ kind: "text",
675
+ text: "Extract the person info: John Smith is 42 years old.",
676
+ },
677
+ ],
678
+ },
679
+ ],
680
+ responseType: {
681
+ kind: "json",
682
+ schema: PERSON_SCHEMA,
683
+ name: "person",
684
+ description: "A person with name and age",
685
+ },
686
+ settings: {
687
+ maxTokens: 100,
688
+ temperature: 0,
689
+ },
690
+ });
691
+ expect(response.content).toBeDefined();
692
+ expect(response.content.length).toBeGreaterThan(0);
693
+ // Find the assistant message with JSON output
694
+ const messages = response.content.filter((item) => item.kind === "message" && item.role === "assistant");
695
+ expect(messages.length).toBeGreaterThan(0);
696
+ const msg = messages[0];
697
+ const textContent = msg.content.find((c) => c.kind === "text");
698
+ expect(textContent).toBeDefined();
699
+ // Parse and validate the JSON output
700
+ const parsed = JSON.parse(textContent.text);
701
+ expect(parsed.name).toBe("John Smith");
702
+ expect(parsed.age).toBe(42);
703
+ });
704
+ it("should stream structured JSON output with responseType", async () => {
705
+ const events = [];
706
+ for await (const event of gpt41.stream({
707
+ input: [
708
+ {
709
+ kind: "message",
710
+ role: "user",
711
+ id: "msg-1",
712
+ content: [
713
+ {
714
+ kind: "text",
715
+ text: "Extract the person info: Alice Wong is 28 years old.",
716
+ },
717
+ ],
718
+ },
719
+ ],
720
+ responseType: {
721
+ kind: "json",
722
+ schema: PERSON_SCHEMA,
723
+ name: "person",
724
+ description: "A person with name and age",
725
+ },
726
+ settings: {
727
+ maxTokens: 100,
728
+ temperature: 0,
729
+ },
730
+ })) {
731
+ events.push(event);
732
+ }
733
+ expect(events.length).toBeGreaterThan(0);
734
+ // Should have text-delta events for streaming JSON
735
+ const textDeltas = events.filter((e) => e.kind === "text-delta");
736
+ expect(textDeltas.length).toBeGreaterThan(0);
737
+ // Should have a complete message with the JSON
738
+ const messages = events.filter((e) => e.kind === "message");
739
+ expect(messages.length).toBeGreaterThan(0);
740
+ const msg = messages[0];
741
+ const textContent = msg.content.find((c) => c.kind === "text");
742
+ expect(textContent).toBeDefined();
743
+ // Parse and validate the JSON output
744
+ const parsed = JSON.parse(textContent.text);
745
+ expect(parsed.name).toBe("Alice Wong");
746
+ expect(parsed.age).toBe(28);
747
+ // Should have finish event
748
+ const finishEvents = events.filter((e) => e.kind === "finish");
749
+ expect(finishEvents.length).toBe(1);
750
+ });
751
+ });
648
752
  });
649
753
  describe.skipIf(SKIP_ANTHROPIC_TESTS)("AISDKLanguageModel - Anthropic", () => {
650
754
  let claude;
@@ -773,4 +877,241 @@ describe.skipIf(SKIP_ANTHROPIC_TESTS)("AISDKLanguageModel - Anthropic", () => {
773
877
  expect(finishEvents.length).toBe(1);
774
878
  });
775
879
  });
880
+ describe("structured output", () => {
881
+ it("should generate structured JSON output with responseType", async () => {
882
+ const response = await claude.generate({
883
+ input: [
884
+ {
885
+ kind: "message",
886
+ role: "user",
887
+ id: "msg-1",
888
+ content: [
889
+ {
890
+ kind: "text",
891
+ text: "Extract the person info: Maria Garcia is 35 years old.",
892
+ },
893
+ ],
894
+ },
895
+ ],
896
+ responseType: {
897
+ kind: "json",
898
+ schema: PERSON_SCHEMA,
899
+ name: "person",
900
+ description: "A person with name and age",
901
+ },
902
+ settings: {
903
+ maxTokens: 100,
904
+ temperature: 0,
905
+ },
906
+ });
907
+ expect(response.content).toBeDefined();
908
+ expect(response.content.length).toBeGreaterThan(0);
909
+ // Find the assistant message with JSON output
910
+ const messages = response.content.filter((item) => item.kind === "message" && item.role === "assistant");
911
+ expect(messages.length).toBeGreaterThan(0);
912
+ const msg = messages[0];
913
+ const textContent = msg.content.find((c) => c.kind === "text");
914
+ expect(textContent).toBeDefined();
915
+ // Parse and validate the JSON output
916
+ const parsed = JSON.parse(textContent.text);
917
+ expect(parsed.name).toBe("Maria Garcia");
918
+ expect(parsed.age).toBe(35);
919
+ });
920
+ it("should stream structured JSON output with responseType", async () => {
921
+ const events = [];
922
+ for await (const event of claude.stream({
923
+ input: [
924
+ {
925
+ kind: "message",
926
+ role: "user",
927
+ id: "msg-1",
928
+ content: [
929
+ {
930
+ kind: "text",
931
+ text: "Extract the person info: David Chen is 55 years old.",
932
+ },
933
+ ],
934
+ },
935
+ ],
936
+ responseType: {
937
+ kind: "json",
938
+ schema: PERSON_SCHEMA,
939
+ name: "person",
940
+ description: "A person with name and age",
941
+ },
942
+ settings: {
943
+ maxTokens: 100,
944
+ temperature: 0,
945
+ },
946
+ })) {
947
+ events.push(event);
948
+ }
949
+ expect(events.length).toBeGreaterThan(0);
950
+ // Should have text-delta events for streaming JSON
951
+ const textDeltas = events.filter((e) => e.kind === "text-delta");
952
+ expect(textDeltas.length).toBeGreaterThan(0);
953
+ // Should have a complete message with the JSON
954
+ const messages = events.filter((e) => e.kind === "message");
955
+ expect(messages.length).toBeGreaterThan(0);
956
+ const msg = messages[0];
957
+ const textContent = msg.content.find((c) => c.kind === "text");
958
+ expect(textContent).toBeDefined();
959
+ // Parse and validate the JSON output
960
+ const parsed = JSON.parse(textContent.text);
961
+ expect(parsed.name).toBe("David Chen");
962
+ expect(parsed.age).toBe(55);
963
+ // Should have finish event
964
+ const finishEvents = events.filter((e) => e.kind === "finish");
965
+ expect(finishEvents.length).toBe(1);
966
+ });
967
+ });
968
+ });
969
+ describe.skipIf(SKIP_GOOGLE_TESTS)("AISDKLanguageModel - Google", () => {
970
+ let gemini;
971
+ beforeAll(() => {
972
+ gemini = new AISDKLanguageModel(google("gemini-2.0-flash"));
973
+ });
974
+ describe("generate", () => {
975
+ it("should generate a simple text response", async () => {
976
+ const response = await gemini.generate({
977
+ input: [
978
+ {
979
+ kind: "message",
980
+ role: "user",
981
+ id: "msg-1",
982
+ content: [
983
+ { kind: "text", text: "Say 'Hello, World!' and nothing else." },
984
+ ],
985
+ },
986
+ ],
987
+ settings: {
988
+ maxTokens: 50,
989
+ temperature: 0,
990
+ },
991
+ });
992
+ expect(response.content).toBeDefined();
993
+ expect(response.content.length).toBeGreaterThan(0);
994
+ expect(response.usage).toBeDefined();
995
+ expect(response.usage.totalTokens).toBeGreaterThan(0);
996
+ const messages = response.content.filter((item) => item.kind === "message");
997
+ expect(messages.length).toBeGreaterThan(0);
998
+ });
999
+ });
1000
+ describe("stream", () => {
1001
+ it("should stream text responses", async () => {
1002
+ const events = [];
1003
+ for await (const event of gemini.stream({
1004
+ input: [
1005
+ {
1006
+ kind: "message",
1007
+ role: "user",
1008
+ id: "msg-1",
1009
+ content: [{ kind: "text", text: "Count to 5" }],
1010
+ },
1011
+ ],
1012
+ settings: {
1013
+ maxTokens: 50,
1014
+ temperature: 0,
1015
+ },
1016
+ })) {
1017
+ events.push(event);
1018
+ }
1019
+ expect(events.length).toBeGreaterThan(0);
1020
+ // Should have at least one finish event
1021
+ const finishEvents = events.filter((e) => e.kind === "finish");
1022
+ expect(finishEvents.length).toBe(1);
1023
+ // Should have usage information
1024
+ const finishEvent = finishEvents[0];
1025
+ expect(finishEvent.usage).toBeDefined();
1026
+ expect(finishEvent.usage.totalTokens).toBeGreaterThan(0);
1027
+ });
1028
+ });
1029
+ describe("structured output", () => {
1030
+ it("should generate structured JSON output with responseType", async () => {
1031
+ const response = await gemini.generate({
1032
+ input: [
1033
+ {
1034
+ kind: "message",
1035
+ role: "user",
1036
+ id: "msg-1",
1037
+ content: [
1038
+ {
1039
+ kind: "text",
1040
+ text: "Extract the person info: Kenji Tanaka is 29 years old.",
1041
+ },
1042
+ ],
1043
+ },
1044
+ ],
1045
+ responseType: {
1046
+ kind: "json",
1047
+ schema: PERSON_SCHEMA,
1048
+ name: "person",
1049
+ description: "A person with name and age",
1050
+ },
1051
+ settings: {
1052
+ maxTokens: 100,
1053
+ temperature: 0,
1054
+ },
1055
+ });
1056
+ expect(response.content).toBeDefined();
1057
+ expect(response.content.length).toBeGreaterThan(0);
1058
+ // Find the assistant message with JSON output
1059
+ const messages = response.content.filter((item) => item.kind === "message" && item.role === "assistant");
1060
+ expect(messages.length).toBeGreaterThan(0);
1061
+ const msg = messages[0];
1062
+ const textContent = msg.content.find((c) => c.kind === "text");
1063
+ expect(textContent).toBeDefined();
1064
+ // Parse and validate the JSON output
1065
+ const parsed = JSON.parse(textContent.text);
1066
+ expect(parsed.name).toBe("Kenji Tanaka");
1067
+ expect(parsed.age).toBe(29);
1068
+ });
1069
+ it("should stream structured JSON output with responseType", async () => {
1070
+ const events = [];
1071
+ for await (const event of gemini.stream({
1072
+ input: [
1073
+ {
1074
+ kind: "message",
1075
+ role: "user",
1076
+ id: "msg-1",
1077
+ content: [
1078
+ {
1079
+ kind: "text",
1080
+ text: "Extract the person info: Sarah Johnson is 41 years old.",
1081
+ },
1082
+ ],
1083
+ },
1084
+ ],
1085
+ responseType: {
1086
+ kind: "json",
1087
+ schema: PERSON_SCHEMA,
1088
+ name: "person",
1089
+ description: "A person with name and age",
1090
+ },
1091
+ settings: {
1092
+ maxTokens: 100,
1093
+ temperature: 0,
1094
+ },
1095
+ })) {
1096
+ events.push(event);
1097
+ }
1098
+ expect(events.length).toBeGreaterThan(0);
1099
+ // Should have text-delta events for streaming JSON
1100
+ const textDeltas = events.filter((e) => e.kind === "text-delta");
1101
+ expect(textDeltas.length).toBeGreaterThan(0);
1102
+ // Should have a complete message with the JSON
1103
+ const messages = events.filter((e) => e.kind === "message");
1104
+ expect(messages.length).toBeGreaterThan(0);
1105
+ const msg = messages[0];
1106
+ const textContent = msg.content.find((c) => c.kind === "text");
1107
+ expect(textContent).toBeDefined();
1108
+ // Parse and validate the JSON output
1109
+ const parsed = JSON.parse(textContent.text);
1110
+ expect(parsed.name).toBe("Sarah Johnson");
1111
+ expect(parsed.age).toBe(41);
1112
+ // Should have finish event
1113
+ const finishEvents = events.filter((e) => e.kind === "finish");
1114
+ expect(finishEvents.length).toBe(1);
1115
+ });
1116
+ });
776
1117
  });
@@ -1,6 +1,6 @@
1
1
  import type { Codec } from "@kernl-sdk/shared/lib";
2
- import { type LanguageModelResponse, type LanguageModelWarning } from "@kernl-sdk/protocol";
3
- import type { LanguageModelV3Content, LanguageModelV3FinishReason, LanguageModelV3Usage, LanguageModelV3CallWarning } from "@ai-sdk/provider";
2
+ import { type LanguageModelResponse, type LanguageModelResponseType, type LanguageModelWarning } from "@kernl-sdk/protocol";
3
+ import type { LanguageModelV3Content, LanguageModelV3FinishReason, LanguageModelV3Usage, LanguageModelV3CallWarning, JSONSchema7 } from "@ai-sdk/provider";
4
4
  /**
5
5
  * AI SDK generate result structure
6
6
  */
@@ -13,4 +13,22 @@ export interface AISdkGenerateResult {
13
13
  }
14
14
  export declare const MODEL_RESPONSE: Codec<LanguageModelResponse, AISdkGenerateResult>;
15
15
  export declare const WARNING: Codec<LanguageModelWarning, LanguageModelV3CallWarning>;
16
+ /**
17
+ * AI SDK response format type.
18
+ *
19
+ * Maps to the `responseFormat` parameter in AI SDK's doGenerate/doStream.
20
+ */
21
+ export interface AISdkResponseFormat {
22
+ type: "json";
23
+ schema?: JSONSchema7;
24
+ name?: string;
25
+ description?: string;
26
+ }
27
+ /**
28
+ * Codec for converting protocol responseType to AI SDK responseFormat.
29
+ *
30
+ * - `kind: "text"` or undefined → undefined (AI SDK defaults to text)
31
+ * - `kind: "json"` → `{ type: "json", schema, name, description }`
32
+ */
33
+ export declare const RESPONSE_FORMAT: Codec<LanguageModelResponseType | undefined, AISdkResponseFormat | undefined>;
16
34
  //# sourceMappingURL=response.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"response.d.ts","sourceRoot":"","sources":["../../src/convert/response.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,uBAAuB,CAAC;AACnD,OAAO,EAIL,KAAK,qBAAqB,EAI1B,KAAK,oBAAoB,EAE1B,MAAM,qBAAqB,CAAC;AAE7B,OAAO,KAAK,EACV,sBAAsB,EACtB,2BAA2B,EAC3B,oBAAoB,EACpB,0BAA0B,EAC3B,MAAM,kBAAkB,CAAC;AAE1B;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,KAAK,CAAC,sBAAsB,CAAC,CAAC;IACvC,YAAY,EAAE,2BAA2B,CAAC;IAC1C,KAAK,EAAE,oBAAoB,CAAC;IAC5B,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC3C,QAAQ,EAAE,KAAK,CAAC,0BAA0B,CAAC,CAAC;CAC7C;AAED,eAAO,MAAM,cAAc,EAAE,KAAK,CAAC,qBAAqB,EAAE,mBAAmB,CAiG1E,CAAC;AAmBJ,eAAO,MAAM,OAAO,EAAE,KAAK,CAAC,oBAAoB,EAAE,0BAA0B,CA0BzE,CAAC"}
1
+ {"version":3,"file":"response.d.ts","sourceRoot":"","sources":["../../src/convert/response.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,uBAAuB,CAAC;AACnD,OAAO,EAIL,KAAK,qBAAqB,EAE1B,KAAK,yBAAyB,EAG9B,KAAK,oBAAoB,EAE1B,MAAM,qBAAqB,CAAC;AAE7B,OAAO,KAAK,EACV,sBAAsB,EACtB,2BAA2B,EAC3B,oBAAoB,EACpB,0BAA0B,EAC1B,WAAW,EACZ,MAAM,kBAAkB,CAAC;AAE1B;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,KAAK,CAAC,sBAAsB,CAAC,CAAC;IACvC,YAAY,EAAE,2BAA2B,CAAC;IAC1C,KAAK,EAAE,oBAAoB,CAAC;IAC5B,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC3C,QAAQ,EAAE,KAAK,CAAC,0BAA0B,CAAC,CAAC;CAC7C;AAED,eAAO,MAAM,cAAc,EAAE,KAAK,CAAC,qBAAqB,EAAE,mBAAmB,CAiG1E,CAAC;AAmBJ,eAAO,MAAM,OAAO,EAAE,KAAK,CAAC,oBAAoB,EAAE,0BAA0B,CA0BzE,CAAC;AAEJ;;;;GAIG;AACH,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;;;GAKG;AACH,eAAO,MAAM,eAAe,EAAE,KAAK,CACjC,yBAAyB,GAAG,SAAS,EACrC,mBAAmB,GAAG,SAAS,CAiBhC,CAAC"}
@@ -123,3 +123,25 @@ export const WARNING = {
123
123
  }
124
124
  },
125
125
  };
126
+ /**
127
+ * Codec for converting protocol responseType to AI SDK responseFormat.
128
+ *
129
+ * - `kind: "text"` or undefined → undefined (AI SDK defaults to text)
130
+ * - `kind: "json"` → `{ type: "json", schema, name, description }`
131
+ */
132
+ export const RESPONSE_FORMAT = {
133
+ encode: (responseType) => {
134
+ if (!responseType || responseType.kind === "text") {
135
+ return undefined;
136
+ }
137
+ return {
138
+ type: "json",
139
+ schema: responseType.schema,
140
+ name: responseType.name,
141
+ description: responseType.description,
142
+ };
143
+ },
144
+ decode: () => {
145
+ throw new Error("codec:unimplemented");
146
+ },
147
+ };
@@ -1 +1 @@
1
- {"version":3,"file":"language-model.d.ts","sourceRoot":"","sources":["../src/language-model.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAExD,OAAO,KAAK,EACV,aAAa,EACb,oBAAoB,EACpB,qBAAqB,EACrB,wBAAwB,EACzB,MAAM,qBAAqB,CAAC;AAS7B;;GAEG;AACH,qBAAa,kBAAmB,YAAW,aAAa;IAK1C,OAAO,CAAC,KAAK;IAJzB,QAAQ,CAAC,IAAI,EAAG,KAAK,CAAU;IAC/B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;gBAEL,KAAK,EAAE,eAAe;IAK1C;;OAEG;IACG,QAAQ,CACZ,OAAO,EAAE,oBAAoB,GAC5B,OAAO,CAAC,qBAAqB,CAAC;IAejC;;OAEG;IACI,MAAM,CACX,OAAO,EAAE,oBAAoB,GAC5B,aAAa,CAAC,wBAAwB,CAAC;CAgF3C"}
1
+ {"version":3,"file":"language-model.d.ts","sourceRoot":"","sources":["../src/language-model.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAExD,OAAO,KAAK,EACV,aAAa,EACb,oBAAoB,EACpB,qBAAqB,EACrB,wBAAwB,EACzB,MAAM,qBAAqB,CAAC;AAS7B;;GAEG;AACH,qBAAa,kBAAmB,YAAW,aAAa;IAK1C,OAAO,CAAC,KAAK;IAJzB,QAAQ,CAAC,IAAI,EAAG,KAAK,CAAU;IAC/B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;gBAEL,KAAK,EAAE,eAAe;IAK1C;;OAEG;IACG,QAAQ,CACZ,OAAO,EAAE,oBAAoB,GAC5B,OAAO,CAAC,qBAAqB,CAAC;IAiBjC;;OAEG;IACI,MAAM,CACX,OAAO,EAAE,oBAAoB,GAC5B,aAAa,CAAC,wBAAwB,CAAC;CAkF3C"}
@@ -2,7 +2,7 @@ import { message, reasoning } from "@kernl-sdk/protocol";
2
2
  import { MESSAGE } from "./convert/message.js";
3
3
  import { TOOL } from "./convert/tools.js";
4
4
  import { MODEL_SETTINGS } from "./convert/settings.js";
5
- import { MODEL_RESPONSE } from "./convert/response.js";
5
+ import { MODEL_RESPONSE, RESPONSE_FORMAT } from "./convert/response.js";
6
6
  import { convertStream } from "./convert/stream.js";
7
7
  /**
8
8
  * LanguageModel adapter for the AI SDK LanguageModelV3.
@@ -24,10 +24,12 @@ export class AISDKLanguageModel {
24
24
  const messages = request.input.map(MESSAGE.encode);
25
25
  const tools = request.tools ? request.tools.map(TOOL.encode) : undefined;
26
26
  const settings = MODEL_SETTINGS.encode(request.settings);
27
+ const responseFormat = RESPONSE_FORMAT.encode(request.responseType);
27
28
  const result = await this.model.doGenerate({
28
29
  prompt: messages,
29
30
  tools,
30
31
  ...settings,
32
+ responseFormat,
31
33
  abortSignal: request.abort,
32
34
  });
33
35
  return MODEL_RESPONSE.decode(result);
@@ -39,10 +41,12 @@ export class AISDKLanguageModel {
39
41
  const messages = request.input.map(MESSAGE.encode);
40
42
  const tools = request.tools ? request.tools.map(TOOL.encode) : undefined;
41
43
  const settings = MODEL_SETTINGS.encode(request.settings);
44
+ const responseFormat = RESPONSE_FORMAT.encode(request.responseType);
42
45
  const stream = await this.model.doStream({
43
46
  prompt: messages,
44
47
  tools,
45
48
  ...settings,
49
+ responseFormat,
46
50
  abortSignal: request.abort,
47
51
  });
48
52
  // text + reasoning buffers for delta accumulation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kernl-sdk/ai",
3
- "version": "0.2.10",
3
+ "version": "0.3.0",
4
4
  "description": "Vercel AI SDK adapter for kernl",
5
5
  "keywords": [
6
6
  "kernl",
@@ -69,8 +69,8 @@
69
69
  },
70
70
  "dependencies": {
71
71
  "@kernl-sdk/protocol": "0.2.8",
72
- "@kernl-sdk/shared": "^0.3.0",
73
- "@kernl-sdk/retrieval": "0.1.3"
72
+ "@kernl-sdk/retrieval": "0.1.3",
73
+ "@kernl-sdk/shared": "^0.3.0"
74
74
  },
75
75
  "scripts": {
76
76
  "build": "tsc && tsc-alias --resolve-full-paths",
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect, beforeAll } from "vitest";
2
2
  import { openai } from "@ai-sdk/openai";
3
3
  import { anthropic } from "@ai-sdk/anthropic";
4
+ import { google } from "@ai-sdk/google";
4
5
  import { IN_PROGRESS } from "@kernl-sdk/protocol";
5
6
 
6
7
  import { AISDKLanguageModel } from "../language-model";
@@ -11,12 +12,28 @@ import { AISDKLanguageModel } from "../language-model";
11
12
  * These tests require API keys to be set:
12
13
  * - OPENAI_API_KEY for OpenAI tests
13
14
  * - ANTHROPIC_API_KEY for Anthropic tests
15
+ * - GOOGLE_GENERATIVE_AI_API_KEY for Google tests
14
16
  *
15
- * Run with: OPENAI_API_KEY=your-key ANTHROPIC_API_KEY=your-key pnpm test:run
17
+ * Run with: OPENAI_API_KEY=your-key ANTHROPIC_API_KEY=your-key GOOGLE_GENERATIVE_AI_API_KEY=your-key pnpm test:run
16
18
  */
17
19
 
18
20
  const SKIP_OPENAI_TESTS = !process.env.OPENAI_API_KEY;
19
21
  const SKIP_ANTHROPIC_TESTS = !process.env.ANTHROPIC_API_KEY;
22
+ const SKIP_GOOGLE_TESTS = !process.env.GOOGLE_GENERATIVE_AI_API_KEY;
23
+
24
+ /**
25
+ * Shared JSON schema for structured output tests.
26
+ * Extracts a person's name and age from text.
27
+ */
28
+ const PERSON_SCHEMA = {
29
+ type: "object" as const,
30
+ properties: {
31
+ name: { type: "string" as const, description: "The person's name" },
32
+ age: { type: "number" as const, description: "The person's age in years" },
33
+ },
34
+ required: ["name", "age"],
35
+ additionalProperties: false,
36
+ };
20
37
 
21
38
  describe.skipIf(SKIP_OPENAI_TESTS)("AISDKLanguageModel - OpenAI", () => {
22
39
  let gpt41: AISDKLanguageModel;
@@ -741,6 +758,109 @@ describe.skipIf(SKIP_OPENAI_TESTS)("AISDKLanguageModel - OpenAI", () => {
741
758
  ).rejects.toThrow(/max_output_tokens/);
742
759
  });
743
760
  });
761
+
762
+ describe("structured output", () => {
763
+ it("should generate structured JSON output with responseType", async () => {
764
+ const response = await gpt41.generate({
765
+ input: [
766
+ {
767
+ kind: "message",
768
+ role: "user",
769
+ id: "msg-1",
770
+ content: [
771
+ {
772
+ kind: "text",
773
+ text: "Extract the person info: John Smith is 42 years old.",
774
+ },
775
+ ],
776
+ },
777
+ ],
778
+ responseType: {
779
+ kind: "json",
780
+ schema: PERSON_SCHEMA,
781
+ name: "person",
782
+ description: "A person with name and age",
783
+ },
784
+ settings: {
785
+ maxTokens: 100,
786
+ temperature: 0,
787
+ },
788
+ });
789
+
790
+ expect(response.content).toBeDefined();
791
+ expect(response.content.length).toBeGreaterThan(0);
792
+
793
+ // Find the assistant message with JSON output
794
+ const messages = response.content.filter(
795
+ (item) => item.kind === "message" && item.role === "assistant",
796
+ );
797
+ expect(messages.length).toBeGreaterThan(0);
798
+
799
+ const msg = messages[0] as any;
800
+ const textContent = msg.content.find((c: any) => c.kind === "text");
801
+ expect(textContent).toBeDefined();
802
+
803
+ // Parse and validate the JSON output
804
+ const parsed = JSON.parse(textContent.text);
805
+ expect(parsed.name).toBe("John Smith");
806
+ expect(parsed.age).toBe(42);
807
+ });
808
+
809
+ it("should stream structured JSON output with responseType", async () => {
810
+ const events = [];
811
+
812
+ for await (const event of gpt41.stream({
813
+ input: [
814
+ {
815
+ kind: "message",
816
+ role: "user",
817
+ id: "msg-1",
818
+ content: [
819
+ {
820
+ kind: "text",
821
+ text: "Extract the person info: Alice Wong is 28 years old.",
822
+ },
823
+ ],
824
+ },
825
+ ],
826
+ responseType: {
827
+ kind: "json",
828
+ schema: PERSON_SCHEMA,
829
+ name: "person",
830
+ description: "A person with name and age",
831
+ },
832
+ settings: {
833
+ maxTokens: 100,
834
+ temperature: 0,
835
+ },
836
+ })) {
837
+ events.push(event);
838
+ }
839
+
840
+ expect(events.length).toBeGreaterThan(0);
841
+
842
+ // Should have text-delta events for streaming JSON
843
+ const textDeltas = events.filter((e) => e.kind === "text-delta");
844
+ expect(textDeltas.length).toBeGreaterThan(0);
845
+
846
+ // Should have a complete message with the JSON
847
+ const messages = events.filter((e) => e.kind === "message");
848
+ expect(messages.length).toBeGreaterThan(0);
849
+
850
+ const msg = messages[0] as any;
851
+ const textContent = msg.content.find((c: any) => c.kind === "text");
852
+ expect(textContent).toBeDefined();
853
+
854
+ // Parse and validate the JSON output
855
+ const parsed = JSON.parse(textContent.text);
856
+ expect(parsed.name).toBe("Alice Wong");
857
+ expect(parsed.age).toBe(28);
858
+
859
+ // Should have finish event
860
+ const finishEvents = events.filter((e) => e.kind === "finish");
861
+ expect(finishEvents.length).toBe(1);
862
+ });
863
+ });
744
864
  });
745
865
 
746
866
  describe.skipIf(SKIP_ANTHROPIC_TESTS)("AISDKLanguageModel - Anthropic", () => {
@@ -887,4 +1007,283 @@ describe.skipIf(SKIP_ANTHROPIC_TESTS)("AISDKLanguageModel - Anthropic", () => {
887
1007
  expect(finishEvents.length).toBe(1);
888
1008
  });
889
1009
  });
1010
+
1011
+ describe("structured output", () => {
1012
+ it("should generate structured JSON output with responseType", async () => {
1013
+ const response = await claude.generate({
1014
+ input: [
1015
+ {
1016
+ kind: "message",
1017
+ role: "user",
1018
+ id: "msg-1",
1019
+ content: [
1020
+ {
1021
+ kind: "text",
1022
+ text: "Extract the person info: Maria Garcia is 35 years old.",
1023
+ },
1024
+ ],
1025
+ },
1026
+ ],
1027
+ responseType: {
1028
+ kind: "json",
1029
+ schema: PERSON_SCHEMA,
1030
+ name: "person",
1031
+ description: "A person with name and age",
1032
+ },
1033
+ settings: {
1034
+ maxTokens: 100,
1035
+ temperature: 0,
1036
+ },
1037
+ });
1038
+
1039
+ expect(response.content).toBeDefined();
1040
+ expect(response.content.length).toBeGreaterThan(0);
1041
+
1042
+ // Find the assistant message with JSON output
1043
+ const messages = response.content.filter(
1044
+ (item) => item.kind === "message" && item.role === "assistant",
1045
+ );
1046
+ expect(messages.length).toBeGreaterThan(0);
1047
+
1048
+ const msg = messages[0] as any;
1049
+ const textContent = msg.content.find((c: any) => c.kind === "text");
1050
+ expect(textContent).toBeDefined();
1051
+
1052
+ // Parse and validate the JSON output
1053
+ const parsed = JSON.parse(textContent.text);
1054
+ expect(parsed.name).toBe("Maria Garcia");
1055
+ expect(parsed.age).toBe(35);
1056
+ });
1057
+
1058
+ it("should stream structured JSON output with responseType", async () => {
1059
+ const events = [];
1060
+
1061
+ for await (const event of claude.stream({
1062
+ input: [
1063
+ {
1064
+ kind: "message",
1065
+ role: "user",
1066
+ id: "msg-1",
1067
+ content: [
1068
+ {
1069
+ kind: "text",
1070
+ text: "Extract the person info: David Chen is 55 years old.",
1071
+ },
1072
+ ],
1073
+ },
1074
+ ],
1075
+ responseType: {
1076
+ kind: "json",
1077
+ schema: PERSON_SCHEMA,
1078
+ name: "person",
1079
+ description: "A person with name and age",
1080
+ },
1081
+ settings: {
1082
+ maxTokens: 100,
1083
+ temperature: 0,
1084
+ },
1085
+ })) {
1086
+ events.push(event);
1087
+ }
1088
+
1089
+ expect(events.length).toBeGreaterThan(0);
1090
+
1091
+ // Should have text-delta events for streaming JSON
1092
+ const textDeltas = events.filter((e) => e.kind === "text-delta");
1093
+ expect(textDeltas.length).toBeGreaterThan(0);
1094
+
1095
+ // Should have a complete message with the JSON
1096
+ const messages = events.filter((e) => e.kind === "message");
1097
+ expect(messages.length).toBeGreaterThan(0);
1098
+
1099
+ const msg = messages[0] as any;
1100
+ const textContent = msg.content.find((c: any) => c.kind === "text");
1101
+ expect(textContent).toBeDefined();
1102
+
1103
+ // Parse and validate the JSON output
1104
+ const parsed = JSON.parse(textContent.text);
1105
+ expect(parsed.name).toBe("David Chen");
1106
+ expect(parsed.age).toBe(55);
1107
+
1108
+ // Should have finish event
1109
+ const finishEvents = events.filter((e) => e.kind === "finish");
1110
+ expect(finishEvents.length).toBe(1);
1111
+ });
1112
+ });
1113
+ });
1114
+
1115
+ describe.skipIf(SKIP_GOOGLE_TESTS)("AISDKLanguageModel - Google", () => {
1116
+ let gemini: AISDKLanguageModel;
1117
+
1118
+ beforeAll(() => {
1119
+ gemini = new AISDKLanguageModel(google("gemini-2.5-flash-lite"));
1120
+ });
1121
+
1122
+ describe("generate", () => {
1123
+ it("should generate a simple text response", async () => {
1124
+ const response = await gemini.generate({
1125
+ input: [
1126
+ {
1127
+ kind: "message",
1128
+ role: "user",
1129
+ id: "msg-1",
1130
+ content: [
1131
+ { kind: "text", text: "Say 'Hello, World!' and nothing else." },
1132
+ ],
1133
+ },
1134
+ ],
1135
+ settings: {
1136
+ maxTokens: 50,
1137
+ temperature: 0,
1138
+ },
1139
+ });
1140
+
1141
+ expect(response.content).toBeDefined();
1142
+ expect(response.content.length).toBeGreaterThan(0);
1143
+ expect(response.usage).toBeDefined();
1144
+ expect(response.usage.totalTokens).toBeGreaterThan(0);
1145
+
1146
+ const messages = response.content.filter(
1147
+ (item) => item.kind === "message",
1148
+ );
1149
+ expect(messages.length).toBeGreaterThan(0);
1150
+ });
1151
+ });
1152
+
1153
+ describe("stream", () => {
1154
+ it("should stream text responses", async () => {
1155
+ const events = [];
1156
+
1157
+ for await (const event of gemini.stream({
1158
+ input: [
1159
+ {
1160
+ kind: "message",
1161
+ role: "user",
1162
+ id: "msg-1",
1163
+ content: [{ kind: "text", text: "Count to 5" }],
1164
+ },
1165
+ ],
1166
+ settings: {
1167
+ maxTokens: 50,
1168
+ temperature: 0,
1169
+ },
1170
+ })) {
1171
+ events.push(event);
1172
+ }
1173
+
1174
+ expect(events.length).toBeGreaterThan(0);
1175
+
1176
+ // Should have at least one finish event
1177
+ const finishEvents = events.filter((e) => e.kind === "finish");
1178
+ expect(finishEvents.length).toBe(1);
1179
+
1180
+ // Should have usage information
1181
+ const finishEvent = finishEvents[0] as any;
1182
+ expect(finishEvent.usage).toBeDefined();
1183
+ expect(finishEvent.usage.totalTokens).toBeGreaterThan(0);
1184
+ });
1185
+ });
1186
+
1187
+ describe("structured output", () => {
1188
+ it("should generate structured JSON output with responseType", async () => {
1189
+ const response = await gemini.generate({
1190
+ input: [
1191
+ {
1192
+ kind: "message",
1193
+ role: "user",
1194
+ id: "msg-1",
1195
+ content: [
1196
+ {
1197
+ kind: "text",
1198
+ text: "Extract the person info: Kenji Tanaka is 29 years old.",
1199
+ },
1200
+ ],
1201
+ },
1202
+ ],
1203
+ responseType: {
1204
+ kind: "json",
1205
+ schema: PERSON_SCHEMA,
1206
+ name: "person",
1207
+ description: "A person with name and age",
1208
+ },
1209
+ settings: {
1210
+ maxTokens: 100,
1211
+ temperature: 0,
1212
+ },
1213
+ });
1214
+
1215
+ expect(response.content).toBeDefined();
1216
+ expect(response.content.length).toBeGreaterThan(0);
1217
+
1218
+ // Find the assistant message with JSON output
1219
+ const messages = response.content.filter(
1220
+ (item) => item.kind === "message" && item.role === "assistant",
1221
+ );
1222
+ expect(messages.length).toBeGreaterThan(0);
1223
+
1224
+ const msg = messages[0] as any;
1225
+ const textContent = msg.content.find((c: any) => c.kind === "text");
1226
+ expect(textContent).toBeDefined();
1227
+
1228
+ // Parse and validate the JSON output
1229
+ const parsed = JSON.parse(textContent.text);
1230
+ expect(parsed.name).toBe("Kenji Tanaka");
1231
+ expect(parsed.age).toBe(29);
1232
+ });
1233
+
1234
+ it("should stream structured JSON output with responseType", async () => {
1235
+ const events = [];
1236
+
1237
+ for await (const event of gemini.stream({
1238
+ input: [
1239
+ {
1240
+ kind: "message",
1241
+ role: "user",
1242
+ id: "msg-1",
1243
+ content: [
1244
+ {
1245
+ kind: "text",
1246
+ text: "Extract the person info: Sarah Johnson is 41 years old.",
1247
+ },
1248
+ ],
1249
+ },
1250
+ ],
1251
+ responseType: {
1252
+ kind: "json",
1253
+ schema: PERSON_SCHEMA,
1254
+ name: "person",
1255
+ description: "A person with name and age",
1256
+ },
1257
+ settings: {
1258
+ maxTokens: 100,
1259
+ temperature: 0,
1260
+ },
1261
+ })) {
1262
+ events.push(event);
1263
+ }
1264
+
1265
+ expect(events.length).toBeGreaterThan(0);
1266
+
1267
+ // Should have text-delta events for streaming JSON
1268
+ const textDeltas = events.filter((e) => e.kind === "text-delta");
1269
+ expect(textDeltas.length).toBeGreaterThan(0);
1270
+
1271
+ // Should have a complete message with the JSON
1272
+ const messages = events.filter((e) => e.kind === "message");
1273
+ expect(messages.length).toBeGreaterThan(0);
1274
+
1275
+ const msg = messages[0] as any;
1276
+ const textContent = msg.content.find((c: any) => c.kind === "text");
1277
+ expect(textContent).toBeDefined();
1278
+
1279
+ // Parse and validate the JSON output
1280
+ const parsed = JSON.parse(textContent.text);
1281
+ expect(parsed.name).toBe("Sarah Johnson");
1282
+ expect(parsed.age).toBe(41);
1283
+
1284
+ // Should have finish event
1285
+ const finishEvents = events.filter((e) => e.kind === "finish");
1286
+ expect(finishEvents.length).toBe(1);
1287
+ });
1288
+ });
890
1289
  });
@@ -5,6 +5,7 @@ import {
5
5
  FAILED,
6
6
  type LanguageModelResponse,
7
7
  type LanguageModelResponseItem,
8
+ type LanguageModelResponseType,
8
9
  type LanguageModelFinishReason,
9
10
  type LanguageModelUsage,
10
11
  type LanguageModelWarning,
@@ -16,6 +17,7 @@ import type {
16
17
  LanguageModelV3FinishReason,
17
18
  LanguageModelV3Usage,
18
19
  LanguageModelV3CallWarning,
20
+ JSONSchema7,
19
21
  } from "@ai-sdk/provider";
20
22
 
21
23
  /**
@@ -172,3 +174,42 @@ export const WARNING: Codec<LanguageModelWarning, LanguageModelV3CallWarning> =
172
174
  }
173
175
  },
174
176
  };
177
+
178
+ /**
179
+ * AI SDK response format type.
180
+ *
181
+ * Maps to the `responseFormat` parameter in AI SDK's doGenerate/doStream.
182
+ */
183
+ export interface AISdkResponseFormat {
184
+ type: "json";
185
+ schema?: JSONSchema7;
186
+ name?: string;
187
+ description?: string;
188
+ }
189
+
190
+ /**
191
+ * Codec for converting protocol responseType to AI SDK responseFormat.
192
+ *
193
+ * - `kind: "text"` or undefined → undefined (AI SDK defaults to text)
194
+ * - `kind: "json"` → `{ type: "json", schema, name, description }`
195
+ */
196
+ export const RESPONSE_FORMAT: Codec<
197
+ LanguageModelResponseType | undefined,
198
+ AISdkResponseFormat | undefined
199
+ > = {
200
+ encode: (responseType) => {
201
+ if (!responseType || responseType.kind === "text") {
202
+ return undefined;
203
+ }
204
+
205
+ return {
206
+ type: "json",
207
+ schema: responseType.schema,
208
+ name: responseType.name,
209
+ description: responseType.description,
210
+ };
211
+ },
212
+ decode: () => {
213
+ throw new Error("codec:unimplemented");
214
+ },
215
+ };
@@ -11,7 +11,7 @@ import { message, reasoning } from "@kernl-sdk/protocol";
11
11
  import { MESSAGE } from "./convert/message";
12
12
  import { TOOL } from "./convert/tools";
13
13
  import { MODEL_SETTINGS } from "./convert/settings";
14
- import { MODEL_RESPONSE } from "./convert/response";
14
+ import { MODEL_RESPONSE, RESPONSE_FORMAT } from "./convert/response";
15
15
  import { convertStream } from "./convert/stream";
16
16
 
17
17
  /**
@@ -36,11 +36,13 @@ export class AISDKLanguageModel implements LanguageModel {
36
36
  const messages = request.input.map(MESSAGE.encode);
37
37
  const tools = request.tools ? request.tools.map(TOOL.encode) : undefined;
38
38
  const settings = MODEL_SETTINGS.encode(request.settings);
39
+ const responseFormat = RESPONSE_FORMAT.encode(request.responseType);
39
40
 
40
41
  const result = await this.model.doGenerate({
41
42
  prompt: messages,
42
43
  tools,
43
44
  ...settings,
45
+ responseFormat,
44
46
  abortSignal: request.abort,
45
47
  });
46
48
 
@@ -56,11 +58,13 @@ export class AISDKLanguageModel implements LanguageModel {
56
58
  const messages = request.input.map(MESSAGE.encode);
57
59
  const tools = request.tools ? request.tools.map(TOOL.encode) : undefined;
58
60
  const settings = MODEL_SETTINGS.encode(request.settings);
61
+ const responseFormat = RESPONSE_FORMAT.encode(request.responseType);
59
62
 
60
63
  const stream = await this.model.doStream({
61
64
  prompt: messages,
62
65
  tools,
63
66
  ...settings,
67
+ responseFormat,
64
68
  abortSignal: request.abort,
65
69
  });
66
70