From 8f1f8e07c349d1dde59efb5ef0b72af5d465e135 Mon Sep 17 00:00:00 2001 From: robgruen Date: Tue, 2 Jun 2026 09:32:07 -0700 Subject: [PATCH 1/7] removed embedding specific endpoint --- src/typechat/OpenAIConfig.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/typechat/OpenAIConfig.cs b/src/typechat/OpenAIConfig.cs index bde1f88..2ef4bcd 100644 --- a/src/typechat/OpenAIConfig.cs +++ b/src/typechat/OpenAIConfig.cs @@ -149,11 +149,6 @@ public static OpenAIConfig FromEnvironment(bool isEmbedding = false) } if (isEmbedding) { - if (Environment.GetEnvironmentVariable(VariableNames.OPENAI_EMBEDDING_ENDPOINT) != null) - { - config.Endpoint = Environment.GetEnvironmentVariable(VariableNames.OPENAI_EMBEDDING_ENDPOINT); - } - config.Model = Environment.GetEnvironmentVariable(VariableNames.OPENAI_EMBEDDINGMODEL); } else From 1a445d833055e73e73bccae6c6c9e265e1de971b Mon Sep 17 00:00:00 2001 From: robgruen Date: Tue, 2 Jun 2026 10:03:32 -0700 Subject: [PATCH 2/7] removed embedding specific endpoint --- src/typechat/OpenAIConfig.cs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/typechat/OpenAIConfig.cs b/src/typechat/OpenAIConfig.cs index 2ef4bcd..6c4128b 100644 --- a/src/typechat/OpenAIConfig.cs +++ b/src/typechat/OpenAIConfig.cs @@ -39,11 +39,6 @@ public static class VariableNames /// public const string OPENAI_EMBEDDINGMODEL = "OPENAI_EMBEDDINGMODEL"; - /// - /// The embedding endpoint to use - /// - public const string OPENAI_EMBEDDING_ENDPOINT = "OPENAI_EMBEDDING_ENDPOINT"; - /// /// Api key to use for Azure OpenAI service /// From 9792c08374401676fa5f255827739593e64ecbbd Mon Sep 17 00:00:00 2001 From: robgruen Date: Tue, 2 Jun 2026 10:14:29 -0700 Subject: [PATCH 3/7] removed embedding endpoint --- .github/workflows/integration_tests.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index 07058d5..da26e28 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -99,6 +99,5 @@ jobs: AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_API_KEY }} OPENAI_MODEL: ${{ secrets.AZURE_COMPLETION_MODEL }} OPENAI_EMBEDDINGMODEL: ${{ secrets.AZURE_EMBEDDING_MODEL }} - OPENAI_EMBEDDING_ENDPOINT: ${{ secrets.OPENAI_EMBEDDING_ENDPOINT }} run: | dotnet test -c ${{ matrix.configuration }} tests/TypeChat.IntegrationTests/TypeChat.IntegrationTests.csproj --no-build -v Normal --logger trx From ae7ff93ea8e840fa402243f2ccc8d15b0513273d Mon Sep 17 00:00:00 2001 From: robgruen Date: Tue, 2 Jun 2026 10:40:44 -0700 Subject: [PATCH 4/7] removed development environment --- .github/workflows/integration_tests.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index da26e28..7c715b0 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -25,7 +25,6 @@ permissions: jobs: build-integrationtest: - environment: development strategy: fail-fast: false matrix: From c764199fccc6483c35ad51c8a9fea03e520aa685 Mon Sep 17 00:00:00 2001 From: robgruen Date: Tue, 2 Jun 2026 10:46:38 -0700 Subject: [PATCH 5/7] Add environment setting to integration tests workflow --- .github/workflows/integration_tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index 7c715b0..da26e28 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -25,6 +25,7 @@ permissions: jobs: build-integrationtest: + environment: development strategy: fail-fast: false matrix: From 6d5d104cf8a38bc589df4c32d3489280f9c3a97f Mon Sep 17 00:00:00 2001 From: Robert Gruen Date: Mon, 6 Jul 2026 10:52:10 -0700 Subject: [PATCH 6/7] Added support for IEnumerable (arraylike excluding string and dictionaries). --- src/typechat.schema/TypeEx.cs | 107 ++++++++++++++++++++++ src/typechat.schema/TypescriptExporter.cs | 77 ++++++++++++---- tests/TypeChat.TestLib/Schemas.cs | 39 ++++++++ tests/TypeChat.UnitTests/TestSchema.cs | 69 ++++++++++++++ 4 files changed, 275 insertions(+), 17 deletions(-) diff --git a/src/typechat.schema/TypeEx.cs b/src/typechat.schema/TypeEx.cs index c5fe980..04eafa5 100644 --- a/src/typechat.schema/TypeEx.cs +++ b/src/typechat.schema/TypeEx.cs @@ -149,6 +149,113 @@ internal static bool IsTask(this Type type) return (args.IsNullOrEmpty()) ? null : args[0]; } + /// + /// Is the given type one that serializes to a JSON array? This includes arrays and any type + /// that implements IEnumerable{T} (List{T}, IList{T}, ICollection{T}, HashSet{T}...). + /// Strings (which are IEnumerable{char}) and dictionaries are deliberately excluded: they do + /// not serialize to JSON arrays. + /// + /// type to inspect + /// the type of the items in the array + /// true if the type is array-like + internal static bool IsArrayLike(this Type type, out Type elementType) + { + elementType = null; + + // Strings are IEnumerable but must be exported as scalar strings, not char arrays + if (type.IsString()) + { + return false; + } + // Dictionaries are IEnumerable> but must be exported as maps, not arrays. + // Use a structural check so that even a self-mapping dictionary is excluded here. + if (type.IsDictionaryShaped()) + { + return false; + } + if (type.IsArray) + { + elementType = type.GetElementType(); + return true; + } + + Type? enumerableType = FindGenericInterface(type, typeof(IEnumerable<>)); + if (enumerableType is not null) + { + Type genericArg = enumerableType.GetGenericArguments()[0]; + // Guard against a type that enumerates itself (e.g. class Node : IEnumerable): + // there is no finite element type to unwrap to, so treat it as a plain object instead. + if (genericArg != type) + { + elementType = genericArg; + return true; + } + return false; + } + // Non-generic IEnumerable (e.g. ArrayList): the element type is unknown, so treat it as 'any' + if (typeof(IEnumerable).IsAssignableFrom(type)) + { + elementType = typeof(object); + return true; + } + return false; + } + + /// + /// Is the given type one that serializes to a JSON object/map? This includes any type that + /// implements IDictionary{K,V} or IReadOnlyDictionary{K,V}, as well as the non-generic IDictionary. + /// + /// type to inspect + /// the type of the map's keys + /// the type of the map's values + /// true if the type is a dictionary + internal static bool IsDictionary(this Type type, out Type keyType, out Type valueType) + { + keyType = typeof(string); + valueType = typeof(object); + + Type? dictionaryType = FindGenericInterface(type, typeof(IDictionary<,>)) ?? + FindGenericInterface(type, typeof(IReadOnlyDictionary<,>)); + if (dictionaryType is not null) + { + Type[] args = dictionaryType.GetGenericArguments(); + // Guard against a type that maps to itself (e.g. class D : IDictionary): + // there is no finite value type to unwrap to, so treat it as a plain object instead. + if (args[1] != type) + { + keyType = args[0]; + valueType = args[1]; + return true; + } + return false; + } + // Non-generic IDictionary (e.g. Hashtable): keys and values are untyped + return typeof(IDictionary).IsAssignableFrom(type); + } + + private static Type? FindGenericInterface(Type type, Type genericInterface) + { + if (type.IsGenericType && type.GetGenericTypeDefinition() == genericInterface) + { + return type; + } + foreach (Type iface in type.GetInterfaces()) + { + if (iface.IsGenericType && iface.GetGenericTypeDefinition() == genericInterface) + { + return iface; + } + } + return null; + } + + private static bool IsDictionaryShaped(this Type type) + { + return FindGenericInterface(type, typeof(IDictionary<,>)) is not null || + FindGenericInterface(type, typeof(IReadOnlyDictionary<,>)) is not null || + typeof(IDictionary).IsAssignableFrom(type); + } + internal static IEnumerable Subclasses(this Type type) { if (!type.IsClass) diff --git a/src/typechat.schema/TypescriptExporter.cs b/src/typechat.schema/TypescriptExporter.cs index 2d06173..3f6382d 100644 --- a/src/typechat.schema/TypescriptExporter.cs +++ b/src/typechat.schema/TypescriptExporter.cs @@ -175,20 +175,21 @@ public override void ExportPending() public override void ExportType(Type type) { - if (type.IsArray) + if (type.IsDictionary(out _, out Type valueType)) { - AddPending(type.GetElementType()); + AddPending(valueType); + } + else if (type.IsArrayLike(out Type elementType)) + { + AddPending(elementType); + } + else if (type.IsEnum) + { + ExportEnum(type); } else { - if (type.IsEnum) - { - ExportEnum(type); - } - else - { - ExportClass(type); - } + ExportClass(type); } } @@ -505,7 +506,7 @@ private TypescriptExporter ExportVariable(MemberInfo member, Type type) _writer.Variable( member.PropertyName(), DataType(actualType), - type.IsArray, + actualType.IsArrayLike(out _), isNullable ); } @@ -534,7 +535,7 @@ private TypescriptExporter ExportParameter(ParameterInfo parameter, int i, int c DataType(type), i, count, - type.IsArray, + type.IsArrayLike(out _), isNullable ); AddPending(type); @@ -658,9 +659,20 @@ private string DataType(Type type) type = type.GetGenericType() ?? typeof(void); } - if (type.IsArray) + Type? nullableValueType = type.GetNullableValueType(); + if (nullableValueType is not null) + { + type = nullableValueType; + } + + if (type.IsDictionary(out Type keyType, out Type valueType)) + { + return DataTypeMap(keyType, valueType); + } + + if (type.IsArrayLike(out Type elementType)) { - return DataType(type.GetElementType()); + return DataType(elementType); } string? typeName = null; @@ -680,6 +692,23 @@ private string DataType(Type type) return typeName; } + private string DataTypeMap(Type keyType, Type valueType) + { + string valueDataType = DataType(valueType); + if (valueType.IsArrayLike(out _)) + { + valueDataType += Typescript.Punctuation.Array; + } + return $"Record<{MapKeyType(keyType)}, {valueDataType}>"; + } + + private static string MapKeyType(Type keyType) + { + // JSON object keys are always strings; only numeric keys are represented as 'number' + string? primitive = Typescript.Types.ToPrimitive(keyType); + return primitive == Typescript.Types.Number ? Typescript.Types.Number : Typescript.Types.String; + } + private string InterfaceName(Type type, bool useMapper = true) { string? typeName = null; @@ -756,12 +785,26 @@ protected override bool ShouldExport(Type type, out Type typeToExport) return false; } - if (type.IsArray) + // Unwrap arrays, collections and dictionaries down to the underlying element/value type + while (true) { - type = type.GetElementType(); + if (type.IsDictionary(out _, out Type valueType)) + { + type = valueType; + } + else if (type.IsArrayLike(out Type elementType)) + { + type = elementType; + } + else + { + break; + } } typeToExport = type; - return !type.IsPrimitive && !_nonExportTypes.Contains(type); + return !type.IsPrimitive && + !type.IsNullableValueType() && + !_nonExportTypes.Contains(type); } } diff --git a/tests/TypeChat.TestLib/Schemas.cs b/tests/TypeChat.TestLib/Schemas.cs index f49236e..08599c5 100644 --- a/tests/TypeChat.TestLib/Schemas.cs +++ b/tests/TypeChat.TestLib/Schemas.cs @@ -336,6 +336,45 @@ public class FriendsOfPerson public Name[] FriendNames { get; set; } } +// For testing that IEnumerable exports as a JSON array (string[]) and not as a +// leaky List_String interface. See https://github.com/microsoft/typechat.net/issues/218 +public sealed class Pizza +{ + public IEnumerable Toppings { get; set; } + public string Size { get; set; } +} + +// For testing that the various collection and dictionary types all export as JSON arrays/maps +public class CollectionsObj +{ + public IEnumerable Tags { get; set; } + public List Names { get; set; } + public IList Scores { get; set; } + public ICollection Locations { get; set; } + public IReadOnlyList Ratings { get; set; } + public HashSet UniqueTags { get; set; } + public string[] Aliases { get; set; } + public Dictionary Counts { get; set; } + public IDictionary LocationsByCity { get; set; } + public Dictionary NamesById { get; set; } + public Dictionary> Buckets { get; set; } +} + +// A type that enumerates itself (composite pattern): it implements IEnumerable, +// so it has no finite "array of..." representation. Schema generation must still terminate. +public class Composite : IEnumerable +{ + public string Name { get; set; } + public IEnumerator GetEnumerator() => Enumerable.Empty().GetEnumerator(); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); +} + +public class CompositeHolder +{ + public List Nodes { get; set; } + public Composite Root { get; set; } +} + // For testing Generics public class Child { diff --git a/tests/TypeChat.UnitTests/TestSchema.cs b/tests/TypeChat.UnitTests/TestSchema.cs index ecd36d4..2e04b02 100644 --- a/tests/TypeChat.UnitTests/TestSchema.cs +++ b/tests/TypeChat.UnitTests/TestSchema.cs @@ -95,5 +95,74 @@ public void ExportGenerics() Assert.True(lines.ContainsSubstring("Child_Name[]")); Assert.True(lines.ContainsSubstring("Child_Location[]")); } + + [Fact] + public void ExportEnumerable_Issue218() + { + var schema = TypescriptExporter.GenerateSchema(typeof(Pizza)); + var lines = schema.Schema.Text.Lines(); + + // IEnumerable must be exported as a JSON array of strings... + Assert.True(lines.ContainsSubstring("Toppings", "string[]")); + Assert.True(lines.ContainsSubstring("Size", "string")); + // ...and NOT as a leaky List_String interface exposing Capacity/Count/Item + Assert.False(lines.ContainsSubstring("List_String")); + Assert.False(lines.ContainsSubstring("Capacity")); + } + + [Fact] + public void ExportCollections() + { + var schema = TypescriptExporter.GenerateSchema(typeof(CollectionsObj)); + var lines = schema.Schema.Text.Lines(); + + // IEnumerable, List, IList, ICollection, IReadOnlyList, HashSet and arrays => T[] + Assert.True(lines.ContainsSubstring("Tags", "string[]")); + Assert.True(lines.ContainsSubstring("Names", "Name[]")); + Assert.True(lines.ContainsSubstring("Scores", "number[]")); + Assert.True(lines.ContainsSubstring("Locations", "Location[]")); + Assert.True(lines.ContainsSubstring("Ratings", "number[]")); + Assert.True(lines.ContainsSubstring("UniqueTags", "string[]")); + Assert.True(lines.ContainsSubstring("Aliases", "string[]")); + + // Dictionaries => Record, including a dictionary whose value is itself a collection + Assert.True(lines.ContainsSubstring("Counts", "Record")); + Assert.True(lines.ContainsSubstring("LocationsByCity", "Record")); + Assert.True(lines.ContainsSubstring("NamesById", "Record")); + Assert.True(lines.ContainsSubstring("Buckets", "Record")); + + // Element/value types are still exported as interfaces + Assert.True(lines.ContainsSubstring("interface", "Name")); + Assert.True(lines.ContainsSubstring("interface", "Location")); + + // The internals of List/Dictionary<> must NOT leak into the schema + Assert.False(lines.ContainsSubstring("Capacity")); + Assert.False(lines.ContainsSubstring("KeyValuePair")); + Assert.False(lines.ContainsSubstring("interface", "List_")); + } + + [Fact] + public void SelfReferentialEnumerable_Terminates() + { + // Composite : IEnumerable has no finite "array of..." representation. + // Schema generation must terminate rather than loop forever while unwrapping it. + TypescriptSchema schema = null; + var worker = new System.Threading.Thread( + () => schema = TypescriptExporter.GenerateSchema(typeof(CompositeHolder))) + { + IsBackground = true + }; + worker.Start(); + bool finished = worker.Join(System.TimeSpan.FromSeconds(5)); + + Assert.True(finished, "Schema generation did not terminate for a self-referential IEnumerable type"); + + var lines = schema.Schema.Text.Lines(); + // A List is still a normal array... + Assert.True(lines.ContainsSubstring("Nodes", "Composite[]")); + // ...but the self-enumerating Composite itself falls back to a plain object interface + Assert.True(lines.ContainsSubstring("interface", "Composite")); + Assert.True(lines.ContainsSubstring("Name", "string")); + } } From 7f467c13bb0d6d1e157f1a826016e52e101d4a30 Mon Sep 17 00:00:00 2001 From: Robert Gruen Date: Mon, 6 Jul 2026 11:00:47 -0700 Subject: [PATCH 7/7] Added support for struct serialization. --- src/typechat.schema/TypeEx.cs | 16 +++++++++++++- src/typechat.schema/Typescript.cs | 10 ++++++++- src/typechat.schema/TypescriptExporter.cs | 12 ++++++++--- tests/TypeChat.TestLib/Schemas.cs | 24 +++++++++++++++++++++ tests/TypeChat.UnitTests/TestSchema.cs | 26 +++++++++++++++++++++++ 5 files changed, 83 insertions(+), 5 deletions(-) diff --git a/src/typechat.schema/TypeEx.cs b/src/typechat.schema/TypeEx.cs index 04eafa5..5785077 100644 --- a/src/typechat.schema/TypeEx.cs +++ b/src/typechat.schema/TypeEx.cs @@ -40,7 +40,21 @@ public static bool IsBoolean(this Type type) public static bool IsDateTime(this Type type) { - return type == typeof(DateTime) || type == typeof(TimeSpan); + // These are value types that System.Text.Json serializes to/from a JSON *string* rather than + // to an object. They must be treated as the 'string' primitive by the exporter (see + // Typescript.Types.ToPrimitive); otherwise they would be exported as (mostly empty) interfaces. + if (type == typeof(DateTime) || type == typeof(TimeSpan) || type == typeof(DateTimeOffset)) + { + return true; + } +#if NET6_0_OR_GREATER + // DateOnly/TimeOnly only exist on net6.0+, but Json serializes them as strings too. + if (type == typeof(DateOnly) || type == typeof(TimeOnly)) + { + return true; + } +#endif + return false; } public static bool IsNumber(this Type type) diff --git a/src/typechat.schema/Typescript.cs b/src/typechat.schema/Typescript.cs index 57b6cbd..dc8df58 100644 --- a/src/typechat.schema/Typescript.cs +++ b/src/typechat.schema/Typescript.cs @@ -70,7 +70,15 @@ public static class Types } else if (type.IsDateTime()) { - return String; // Json does not have a primitive DateTime + return String; // Json has no DateTime primitive: these value types serialize as strings + } + else if (type == typeof(Guid)) + { + // Guid is a value type, but System.Text.Json writes it as a JSON string (e.g. + // "3f2504e0-4f89-11d3-9a0c-0305e82c3301") and it exposes no public fields/properties. + // Without this mapping it would fall through to the object path and be exported as an + // empty "interface Guid {}", which a model could never populate correctly. + return String; } else if (type.IsObject()) { diff --git a/src/typechat.schema/TypescriptExporter.cs b/src/typechat.schema/TypescriptExporter.cs index 3f6382d..3295374 100644 --- a/src/typechat.schema/TypescriptExporter.cs +++ b/src/typechat.schema/TypescriptExporter.cs @@ -196,9 +196,15 @@ public override void ExportType(Type type) public TypescriptExporter ExportClass(Type type) { ArgumentVerify.ThrowIfNull(type, nameof(type)); - if (!type.IsClass) - { - ArgumentVerify.Throw($"{type.Name} must be a class"); + // Export both reference types (classes) and non-enum value types (structs) as interfaces: + // System.Text.Json serializes a struct's public fields/properties into a JSON object exactly + // like a class (e.g. KeyValuePair => { Key, Value }, a custom Money => { Amount, Currency }). + // Enums are routed to ExportEnum before they reach here, and scalar value types that Json writes + // as a single value (Guid, DateTime, DateTimeOffset, ...) are mapped to primitives in + // Typescript.Types.ToPrimitive, so only genuine object-like structs arrive at this point. + if (!type.IsClass && (!type.IsValueType || type.IsPrimitive || type.IsEnum)) + { + ArgumentVerify.Throw($"{type.Name} must be a class or struct"); } if (IsExported(type)) diff --git a/tests/TypeChat.TestLib/Schemas.cs b/tests/TypeChat.TestLib/Schemas.cs index 08599c5..d50c366 100644 --- a/tests/TypeChat.TestLib/Schemas.cs +++ b/tests/TypeChat.TestLib/Schemas.cs @@ -375,6 +375,30 @@ public class CompositeHolder public Composite Root { get; set; } } +// For testing that structs export as interfaces: Json serializes a struct's public members as an +// object, just like a class. +public struct Money +{ + public decimal Amount { get; set; } + public string Currency { get; set; } +} + +public struct Coordinates +{ + public double Latitude { get; set; } + public double Longitude { get; set; } +} + +public class StructHolder +{ + public Money Price { get; set; } + public Coordinates Location { get; set; } + public KeyValuePair Pair { get; set; } + // Scalar value types that Json writes as a single string - must NOT become empty interfaces + public Guid Id { get; set; } + public DateTimeOffset When { get; set; } +} + // For testing Generics public class Child { diff --git a/tests/TypeChat.UnitTests/TestSchema.cs b/tests/TypeChat.UnitTests/TestSchema.cs index 2e04b02..db81aed 100644 --- a/tests/TypeChat.UnitTests/TestSchema.cs +++ b/tests/TypeChat.UnitTests/TestSchema.cs @@ -164,5 +164,31 @@ public void SelfReferentialEnumerable_Terminates() Assert.True(lines.ContainsSubstring("interface", "Composite")); Assert.True(lines.ContainsSubstring("Name", "string")); } + + [Fact] + public void ExportStructs() + { + var schema = TypescriptExporter.GenerateSchema(typeof(StructHolder)); + var lines = schema.Schema.Text.Lines(); + + // Structs export as interfaces, because Json serializes their public members as an object + Assert.True(lines.ContainsSubstring("Price", "Money")); + Assert.True(lines.ContainsSubstring("interface", "Money")); + Assert.True(lines.ContainsSubstring("Amount", "number")); + Assert.True(lines.ContainsSubstring("Currency", "string")); + + Assert.True(lines.ContainsSubstring("Location", "Coordinates")); + Assert.True(lines.ContainsSubstring("interface", "Coordinates")); + + Assert.True(lines.ContainsSubstring("Pair", "KeyValuePair")); + Assert.True(lines.ContainsSubstring("Key", "string")); + Assert.True(lines.ContainsSubstring("Value", "number")); + + // Scalar value types map to string, NOT to empty interfaces + Assert.True(lines.ContainsSubstring("Id", "string")); + Assert.True(lines.ContainsSubstring("When", "string")); + Assert.False(lines.ContainsSubstring("interface", "Guid")); + Assert.False(lines.ContainsSubstring("interface", "DateTimeOffset")); + } }