Skip to content

Commit 8be39b8

Browse files
authored
Make AI Deployment support multiple types (#409)
1 parent b88335f commit 8be39b8

File tree

19 files changed

+353
-56
lines changed

19 files changed

+353
-56
lines changed

src/Abstractions/CrestApps.OrchardCore.AI.Abstractions/IAIDeploymentManager.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ public interface IAIDeploymentManager : INamedSourceCatalogManager<AIDeployment>
1717
ValueTask<IEnumerable<AIDeployment>> GetAllAsync(string clientName, string connectionName);
1818

1919
/// <summary>
20-
/// Asynchronously retrieves all deployments of the specified type.
20+
/// Asynchronously retrieves all deployments supporting the specified type.
2121
/// </summary>
2222
/// <param name="type">The deployment type to filter by.</param>
2323
/// <returns>
@@ -29,14 +29,14 @@ public interface IAIDeploymentManager : INamedSourceCatalogManager<AIDeployment>
2929
/// <summary>
3030
/// Resolves the default deployment of a given type for a specific connection.
3131
/// Returns the deployment marked as IsDefault for that type on the connection,
32-
/// or the first deployment of that type on the connection if none is marked as default.
32+
/// or the first deployment supporting that type on the connection if none is marked as default.
3333
/// </summary>
3434
ValueTask<AIDeployment> GetDefaultAsync(string clientName, string connectionName, AIDeploymentType type);
3535

3636
/// <summary>
3737
/// Resolves a deployment using the full fallback chain:
3838
/// 1. If deploymentId is provided, returns that specific deployment.
39-
/// 2. If connectionName is provided, returns the default deployment of the given type for that connection.
39+
/// 2. If connectionName is provided, returns the default deployment supporting the given type for that connection.
4040
/// 3. Falls back to the global default deployment for the given type (from DefaultAIDeploymentSettings).
4141
/// Returns <see langword="null"/> if no deployment can be resolved.
4242
/// </summary>

src/Abstractions/CrestApps.OrchardCore.AI.Abstractions/Models/AIDeployment.cs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,14 @@ private string _providerNameBackingField
4040
public string ConnectionNameAlias { get; set; }
4141

4242
/// <summary>
43-
/// Gets or sets the type of this deployment (Chat, Utility, Embedding, Image, SpeechToText, TextToSpeech).
44-
/// Determines what capability this deployment provides.
43+
/// Gets or sets the capability types of this deployment (Chat, Utility, Embedding, Image, SpeechToText, TextToSpeech).
44+
/// A deployment can support one or more capabilities.
4545
/// </summary>
4646
public AIDeploymentType Type { get; set; }
4747

4848
/// <summary>
49-
/// Gets or sets whether this deployment is the default for its <see cref="Type"/>
50-
/// within its connection. Each connection can have at most one default per type.
49+
/// Gets or sets whether this deployment is the default for its selected capability types
50+
/// within its connection.
5151
/// </summary>
5252
public bool IsDefault { get; set; }
5353

@@ -57,6 +57,9 @@ private string _providerNameBackingField
5757

5858
public string OwnerId { get; set; }
5959

60+
public bool SupportsType(AIDeploymentType type)
61+
=> Type.Supports(type);
62+
6063
public AIDeployment Clone()
6164
{
6265
return new AIDeployment
Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
namespace CrestApps.OrchardCore.AI.Models;
22

3+
[Flags]
34
public enum AIDeploymentType
45
{
5-
Chat,
6-
Utility,
7-
Embedding,
8-
Image,
9-
SpeechToText,
10-
TextToSpeech,
6+
None = 0,
7+
Chat = 1 << 0,
8+
Utility = 1 << 1,
9+
Embedding = 1 << 2,
10+
Image = 1 << 3,
11+
SpeechToText = 1 << 4,
12+
TextToSpeech = 1 << 5,
1113
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
namespace CrestApps.OrchardCore.AI.Models;
2+
3+
public static class AIDeploymentTypeExtensions
4+
{
5+
private static readonly AIDeploymentType _allSupportedTypes = Enum.GetValues<AIDeploymentType>()
6+
.Where(type => type != AIDeploymentType.None)
7+
.Aggregate(AIDeploymentType.None, static (current, type) => current | type);
8+
9+
public static bool Supports(this AIDeploymentType value, AIDeploymentType type)
10+
=> type != AIDeploymentType.None && (value & type) == type;
11+
12+
public static bool IsValidSelection(this AIDeploymentType value)
13+
=> value != AIDeploymentType.None && (value & ~_allSupportedTypes) == 0;
14+
15+
public static IEnumerable<AIDeploymentType> GetSupportedTypes(this AIDeploymentType value)
16+
=> Enum.GetValues<AIDeploymentType>().Where(type => value.Supports(type));
17+
}

src/Core/CrestApps.OrchardCore.AI.Core/Handlers/AIDeploymentHandler.cs

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ public override Task ValidatingAsync(ValidatingContext<AIDeployment> context)
4444
context.Result.Fail(new ValidationResult(S["Deployment Name is required."], [nameof(AIDeployment.Name)]));
4545
}
4646

47-
if (!Enum.IsDefined(context.Model.Type))
47+
if (!context.Model.Type.IsValidSelection())
4848
{
4949
context.Result.Fail(new ValidationResult(S["The deployment type '{0}' is not valid.", context.Model.Type], [nameof(AIDeployment.Type)]));
5050
}
@@ -124,9 +124,7 @@ private Task PopulateAsync(AIDeployment deployment, JsonNode data)
124124
deployment.ConnectionName = provider.DefaultConnectionName;
125125
}
126126

127-
var typeValue = data[nameof(AIDeployment.Type)]?.GetValue<string>();
128-
129-
if (!string.IsNullOrEmpty(typeValue) && Enum.TryParse<AIDeploymentType>(typeValue, ignoreCase: true, out var type))
127+
if (TryGetDeploymentType(data[nameof(AIDeployment.Type)], out var type))
130128
{
131129
deployment.Type = type;
132130
}
@@ -148,4 +146,38 @@ private Task PopulateAsync(AIDeployment deployment, JsonNode data)
148146

149147
return Task.CompletedTask;
150148
}
149+
150+
private static bool TryGetDeploymentType(JsonNode typeNode, out AIDeploymentType type)
151+
{
152+
type = AIDeploymentType.None;
153+
154+
if (typeNode is null)
155+
{
156+
return false;
157+
}
158+
159+
if (typeNode is JsonArray array)
160+
{
161+
foreach (var item in array)
162+
{
163+
if (item is null ||
164+
!Enum.TryParse<AIDeploymentType>(item.GetValue<string>(), ignoreCase: true, out var parsedType) ||
165+
parsedType == AIDeploymentType.None)
166+
{
167+
type = AIDeploymentType.None;
168+
return false;
169+
}
170+
171+
type |= parsedType;
172+
}
173+
174+
return type.IsValidSelection();
175+
}
176+
177+
var typeValue = typeNode.GetValue<string>();
178+
179+
return !string.IsNullOrEmpty(typeValue) &&
180+
Enum.TryParse(typeValue, ignoreCase: true, out type) &&
181+
type.IsValidSelection();
182+
}
151183
}

src/Core/CrestApps.OrchardCore.AI.Core/Models/AIDeploymentConfigurationEntry.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public sealed class AIDeploymentConfigurationEntry
2020
public string Name { get; set; }
2121

2222
/// <summary>
23-
/// Gets or sets the deployment type (Chat, Utility, Embedding, Image, SpeechToText, TextToSpeech).
23+
/// Gets or sets the deployment capability types (Chat, Utility, Embedding, Image, SpeechToText, TextToSpeech).
2424
/// </summary>
2525
public AIDeploymentType Type { get; set; }
2626

src/Core/CrestApps.OrchardCore.AI.Core/Services/ConfigurationAIDeploymentStore.cs

Lines changed: 93 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -351,9 +351,7 @@ private AIDeployment ParseConnectionDeploymentEntry(
351351
return null;
352352
}
353353

354-
var typeStr = element.TryGetProperty("Type", out var typeProp) ? typeProp.GetString() : null;
355-
356-
if (string.IsNullOrEmpty(typeStr) || !Enum.TryParse<AIDeploymentType>(typeStr, ignoreCase: true, out var type))
354+
if (!TryGetDeploymentType(element, out var type))
357355
{
358356
_logger.LogWarning("Deployment entry '{Name}' in connection '{ConnectionId}' of provider '{ProviderName}' has an invalid or missing Type. Skipping.", name, connectionId, providerName);
359357
return null;
@@ -387,10 +385,7 @@ private static AIDeploymentConfigurationEntry ParseStandaloneDeploymentEntry(Jso
387385
Properties = BuildStandaloneDeploymentProperties(deploymentObject),
388386
};
389387

390-
var typeName = GetStringValue(deploymentObject["Type"]);
391-
392-
if (!string.IsNullOrWhiteSpace(typeName) &&
393-
Enum.TryParse<AIDeploymentType>(typeName, ignoreCase: true, out var deploymentType))
388+
if (TryGetDeploymentType(deploymentObject["Type"], out var deploymentType))
394389
{
395390
entry.Type = deploymentType;
396391
}
@@ -424,7 +419,7 @@ private AIDeployment CreateStandaloneDeployment(AIDeploymentConfigurationEntry e
424419
return null;
425420
}
426421

427-
if (!Enum.IsDefined(entry.Type))
422+
if (!entry.Type.IsValidSelection())
428423
{
429424
_logger.LogWarning("Deployment entry '{Name}' for provider '{ProviderName}' has an invalid Type. Skipping.", entry.Name, entry.ProviderName);
430425
return null;
@@ -448,6 +443,96 @@ private AIDeployment CreateStandaloneDeployment(AIDeploymentConfigurationEntry e
448443
};
449444
}
450445

446+
private static bool TryGetDeploymentType(JsonElement element, out AIDeploymentType type)
447+
{
448+
type = AIDeploymentType.None;
449+
450+
if (!element.TryGetProperty("Type", out var typeElement))
451+
{
452+
return false;
453+
}
454+
455+
return TryParseDeploymentTypeElement(typeElement, out type);
456+
}
457+
458+
private static bool TryParseDeploymentTypeElement(JsonElement typeElement, out AIDeploymentType type)
459+
{
460+
type = AIDeploymentType.None;
461+
462+
if (typeElement.ValueKind == JsonValueKind.Array)
463+
{
464+
foreach (var item in typeElement.EnumerateArray())
465+
{
466+
var typeName = item.GetString();
467+
468+
if (string.IsNullOrWhiteSpace(typeName) ||
469+
!Enum.TryParse<AIDeploymentType>(typeName, ignoreCase: true, out var parsedType) ||
470+
parsedType == AIDeploymentType.None)
471+
{
472+
type = AIDeploymentType.None;
473+
return false;
474+
}
475+
476+
type |= parsedType;
477+
}
478+
479+
return type.IsValidSelection();
480+
}
481+
482+
if (typeElement.ValueKind != JsonValueKind.String)
483+
{
484+
return false;
485+
}
486+
487+
var typeNameValue = typeElement.GetString();
488+
489+
return !string.IsNullOrWhiteSpace(typeNameValue) &&
490+
Enum.TryParse(typeNameValue, ignoreCase: true, out type) &&
491+
type.IsValidSelection();
492+
}
493+
494+
private static bool TryGetDeploymentType(JsonNode typeNode, out AIDeploymentType type)
495+
{
496+
type = AIDeploymentType.None;
497+
498+
if (typeNode is null)
499+
{
500+
return false;
501+
}
502+
503+
if (typeNode is JsonArray array)
504+
{
505+
foreach (var item in array)
506+
{
507+
if (item is null)
508+
{
509+
type = AIDeploymentType.None;
510+
return false;
511+
}
512+
513+
var typeName = GetStringValue(item);
514+
515+
if (string.IsNullOrWhiteSpace(typeName) ||
516+
!Enum.TryParse<AIDeploymentType>(typeName, ignoreCase: true, out var parsedType) ||
517+
parsedType == AIDeploymentType.None)
518+
{
519+
type = AIDeploymentType.None;
520+
return false;
521+
}
522+
523+
type |= parsedType;
524+
}
525+
526+
return type.IsValidSelection();
527+
}
528+
529+
var singleTypeName = GetStringValue(typeNode);
530+
531+
return !string.IsNullOrWhiteSpace(singleTypeName) &&
532+
Enum.TryParse(singleTypeName, ignoreCase: true, out type) &&
533+
type.IsValidSelection();
534+
}
535+
451536
private static JsonObject BuildStandaloneDeploymentProperties(JsonObject deploymentObject)
452537
{
453538
JsonObject properties = null;

src/Core/CrestApps.OrchardCore.AI.Core/Services/DefaultAIDeploymentManager.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ public async ValueTask<IEnumerable<AIDeployment>> GetAllAsync(string clientName,
3838
public async ValueTask<IEnumerable<AIDeployment>> GetByTypeAsync(AIDeploymentType type)
3939
{
4040
var deployments = (await Catalog.GetAllAsync())
41-
.Where(x => x.Type == type);
41+
.Where(x => x.SupportsType(type));
4242

4343
foreach (var deployment in deployments)
4444
{
@@ -52,7 +52,7 @@ public async ValueTask<AIDeployment> GetDefaultAsync(string clientName, string c
5252
{
5353
var deployments = await GetAllAsync(clientName, connectionName);
5454

55-
var candidates = deployments.Where(d => d.Type == type);
55+
var candidates = deployments.Where(d => d.SupportsType(type));
5656

5757
return candidates.FirstOrDefault(d => d.IsDefault)
5858
?? candidates.FirstOrDefault();
@@ -67,7 +67,7 @@ public async ValueTask<IEnumerable<AIDeployment>> GetAllByTypeAsync(AIDeployment
6767
{
6868
var allDeployments = await GetAllAsync();
6969

70-
var filtered = allDeployments.Where(d => d.Type == type);
70+
var filtered = allDeployments.Where(d => d.SupportsType(type));
7171

7272
if (!string.IsNullOrEmpty(clientName))
7373
{

src/Core/CrestApps.OrchardCore.Recipes.Core/Schemas/AIDeploymentRecipeStep.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ private static JsonSchema CreateSchema()
2626
("Name", new JsonSchemaBuilder().Type(SchemaValueType.String).Description("Deployment name as specified by the vendor.")),
2727
("ProviderName", new JsonSchemaBuilder().Type(SchemaValueType.String).Description("Provider name (e.g., OpenAI, DeepSeek).")),
2828
("ConnectionName", new JsonSchemaBuilder().Type(SchemaValueType.String).Description("Connection name used to configure the provider.")),
29-
("Type", new JsonSchemaBuilder().Type(SchemaValueType.String).Enum("Chat", "Utility", "Embedding", "Image", "SpeechToText").Description("The deployment type. Defaults to Chat when not specified.")),
29+
("Type", new JsonSchemaBuilder().AnyOf(
30+
new JsonSchemaBuilder().Type(SchemaValueType.String).Description("The deployment type, or a comma-separated flag value such as 'Chat, Utility'. Defaults to Chat when not specified."),
31+
new JsonSchemaBuilder().Type(SchemaValueType.Array).Items(
32+
new JsonSchemaBuilder().Type(SchemaValueType.String).Enum("Chat", "Utility", "Embedding", "Image", "SpeechToText", "TextToSpeech")).MinItems(1).UniqueItems(true).Description("The deployment types."))),
3033
("IsDefault", new JsonSchemaBuilder().Type(SchemaValueType.Boolean).Description("Whether this deployment is the default for its type and connection.")))
3134
.Required("Name")
3235
.AdditionalProperties(true);

src/CrestApps.OrchardCore.Documentations/docs/ai/migration-typed-deployments.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ Previously, AI model deployments were configured as string properties on `AIProv
1313

1414
In the new architecture, **AIDeployment** is a first-class typed entity with:
1515

16-
- **`Type`**The deployment purpose: `Chat`, `Utility`, `Embedding`, `Image`, or `SpeechToText`
17-
- **`IsDefault`** — Whether this deployment is the default for its type within its connection
16+
- **`Type`**One or more deployment purposes: `Chat`, `Utility`, `Embedding`, `Image`, `SpeechToText`, or `TextToSpeech`
17+
- **`IsDefault`** — Whether this deployment is the default for each selected type within its connection
1818
- **Independent identity** — Each deployment has its own record and can be referenced by ID
1919

2020
AI Profiles and Chat Interactions now reference deployments by ID (`ChatDeploymentId`, `UtilityDeploymentId`) rather than relying on a connection name to resolve deployment names.
@@ -55,7 +55,7 @@ After migration, review the auto-created deployments at **Artificial Intelligenc
5555

5656
After the automatic migration runs:
5757

58-
1. **Review deployments** — Navigate to **Artificial Intelligence > Deployments** and verify the auto-created records have the correct types and default flags.
58+
1. **Review deployments** — Navigate to **Artificial Intelligence > Deployments** and verify the auto-created records have the correct type selections and default flags.
5959
2. **Set global defaults** — Go to **Settings > Artificial Intelligence > Default Deployments** and configure global defaults for Chat, Utility, Embedding, Image, and voice-related deployment types as needed. These serve as fallbacks when a profile or interaction doesn't specify a deployment.
6060
3. **Update profiles (optional)** — Existing profiles continue to work. However, you can now set separate `ChatDeploymentId` and `UtilityDeploymentId` on each profile for more granular control.
6161

@@ -103,8 +103,8 @@ After the automatic migration runs:
103103
"IsDefault": true
104104
},
105105
{
106-
"Name": "gpt-4o-mini",
107-
"Type": "Utility",
106+
"Name": "gpt-4.1-mini",
107+
"Type": ["Chat", "Utility"],
108108
"IsDefault": true
109109
},
110110
{
@@ -127,6 +127,8 @@ After the automatic migration runs:
127127
}
128128
```
129129

130+
If you prefer, the `Type` property can also be expressed as a comma-separated flags string such as `"Chat, Utility"`, but JSON arrays are easier to read and maintain.
131+
130132
:::info
131133
Both formats are supported simultaneously. If both are present, the `Deployments` array takes precedence. We recommend migrating to the new format when convenient.
132134
:::

0 commit comments

Comments
 (0)