Skip to content

Investigation: Azure and unbranded clients#5

Draft
annelo-msft wants to merge 26 commits intoazoai-generate-cleanfrom
azoai-client-inheritance
Draft

Investigation: Azure and unbranded clients#5
annelo-msft wants to merge 26 commits intoazoai-generate-cleanfrom
azoai-client-inheritance

Conversation

@annelo-msft
Copy link
Copy Markdown
Owner

@annelo-msft annelo-msft commented Apr 8, 2024

This PR looks at the model-inheritance approach.

using OpenAI.Models;
using System.ClientModel;

using AzureOpenAI.Models;
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once you add the namespace with the extension methods/properties, you can use an unbranded model like an Azure model. How do we make sure people instantiate the client as an Azure client and only send Azure properties to the Azure service?


namespace AzureOpenAI;

internal class PersistableModelList<TModel> : List<IPersistableModel<TModel>>, IPersistableModel<PersistableModelList<TModel>>
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a proposal for a new emitted type.

[PersistableModelProxy(typeof(UnknownAzureChatExtensionConfiguration))]
public partial class AzureChatExtensionConfiguration : IJsonModel<AzureChatExtensionConfiguration>
{
void IJsonModel<AzureChatExtensionConfiguration>.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options)
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per @KrzysztofCwalina, look at different options for "Wire" and "AzureWire"

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We decided to move away from this approach in favor of having separate models for each type.


namespace AzureOpenAI.Models;

internal class AzureCreateChatCompletionRequest : CreateChatCompletionRequest
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this goes away entirely.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, maybe this is better than keeping a Dictionary<string, object> ...

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed offline and for now we will continue to pursue the extension methods approach

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New approach is to keep these model subtypes types internal and use extension properties to retrieve values via internal properties on subtypes.


namespace AzureOpenAI.Models;

public partial class AzureSearchChatExtensionConfiguration : IJsonModel<AzureSearchChatExtensionConfiguration>
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note, some models like these are only in the Azure package. But we assign instances of them at runtime to objects in the unbranded package and reference them as IJsonModel. Think a bit more about this and the implications of it.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One angle: what does the generator do?
Side question on that - how does the Tsp represent the differences so the generator can do a different thing?

/// <param name="keyCredential"> The key credential to copy. </param>
/// <param name="endpoint"> OpenAI Endpoint. </param>
internal Chat(ClientPipeline pipeline, ApiKeyCredential keyCredential, Uri endpoint)
protected internal Chat(ClientPipeline pipeline, ApiKeyCredential keyCredential, Uri endpoint)
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The subclient constructor becomes protected internal.

private readonly ClientPipeline _pipeline;
private readonly Uri _endpoint;

protected Uri Endpoint => _endpoint;
Copy link
Copy Markdown
Owner Author

@annelo-msft annelo-msft Apr 9, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some client internal fields become protected properties so the derived type can use them.

}

internal PipelineMessage CreateCreateChatCompletionRequest(BinaryContent content, RequestOptions context)
protected virtual PipelineMessage CreateCreateChatCompletionRequest(BinaryContent content, RequestOptions context)
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request creation helpers become protected virtual.


// Note: we were holding strongly-typed values for added Azure properties
// We have to serialize them separately here.
// TODO: Does the format matter here? It seems like we want to write them
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Look at this in the context of different options for Wire-3rd party and Wire-Azure.


writer.WritePropertyName(property.Key);

// Note: what if it's just a primitive, i.e. a string or int, what do we do?
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO to solve.

/// </summary>
private IDictionary<string, BinaryData> _serializedAdditionalRawData;

private IDictionary<string, object> _additionalTypedProperties;
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO to solve.

public partial class OpenAIClient
{
protected ApiKeyCredential KeyCredential => _keyCredential;
protected Uri Endpoint => _endpoint;
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some client fields become protected properties so that they're accessible to derived clients. Consider this approach.

/// <summary> The OpenAI service client. </summary>
public partial class OpenAIClient
{
protected ApiKeyCredential KeyCredential => _keyCredential;
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One problem with this is that if the client doesn't use api key auth, what does this do?

{
}

// TODO: Show how this would differ for this case. Do we still need OperationName and the
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed we don't need OperationName for this approach.

// and stick it in AdditionalTypedProperties (are these "deserialized
// additional properties, e.g.?).

// TODO: what is our concurrency story for models in unbranded clients?
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

{
public static IList<AzureChatExtensionConfiguration> GetDataSources(this CreateChatCompletionRequest request)
{
// TODO: How can we validate that this is being called in the right context,
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How can we throw an exception when using the wrong client? Seems like MRW.Options is useful here.

// 2. The extension properties are actually quite simple -- they just get
// or set stuff in either the input or output properties dictionaries.
//
// TODO: What is the interplay of SerializedAdditionalRawData and
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO

Argument.AssertNotNull(createChatCompletionRequest, nameof(createChatCompletionRequest));

// Initialize Azure input model
AzureCreateChatCompletionRequest azureChatCompletionOptions = new(createChatCompletionRequest);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you just wrap and then call the base implementation? It would be good to not have to reimplement the convenience methods totally from scratch.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think when the protocol method signature is the same that will work. But since here we needed to add an overload for the protocol method because an input value has moved from the request body into a path parameter, we need to reimplement it to call the new protocol method overload.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, makes sense. But, where is the new protocol method? I have a hard time finding it. I was interested in seeing how you think we will make the new protocol method available to the callers.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I found the protocol method. But, the method needs to be an extension method, not just a public method on the internal type. People won't be able to call it, if it's an instance method

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point! That is my mistake.

string IPersistableModel<AzureCreateChatCompletionRequest>.GetFormatFromOptions(ModelReaderWriterOptions options)
=> "J";

void IJsonModel<AzureCreateChatCompletionRequest>.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

likewise, I wonder if we could reuse the serialization implementation for the things that are the same and only add/modify the parts that are different.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That seems likely. My question on that would be -- what is the value of doing it? I'm still wondering how we represent the added properties in the TypeSpec to indicate to the generator to generate just the diff from the base type. Once we solve that piece , it'll be easier to say which approach would be less work for the generator. If there's strong value to not duplicating generated serialization code, then we can prioritize figuring that part out.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, it's a good point that it will be easier on the generator to not have to understand such tricks. As far as the generator will be concerned is that there are two model types and two typespecs. It will generate serialization code as if there was no hierarchy,


public override Task<ClientResult> CreateChatCompletionAsync(BinaryContent content, RequestOptions context = null)
{
// Note, that we can later remap the values from the 3rd party client format to the
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it would be good to hide this one, but I don't think we can do it given we don't expose the subtype

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that makes sense. If we did expose the subtype, though, I think is a clear violation of the Liskov substitution principle -- I think you told me a few weeks ago that if we hide base type methods on the subtype, it makes the abstraction confusing ... why could I call this method on the base client, and now I don't see that method in the intellisense when I'm looking at a subtype? I dunno.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants