Skip to content

Supporting more APIs via more advanced serialization options #24

@bloeys

Description

@bloeys

Hello 😊

I am working on creating a Jai client for the OpenAI API, but it seems its currently not possible to represent the required format for them. I am happy to send a PR your way, but wanted to discuss the approach first.

The core of the OpenAI chat completion API can be represented as:

Msg :: struct {
    role: Role; // string
    content: []Msg_Content;
}

Msg_Content :: struct {
    type: Msg_Content_Type; // string

    union {

        text: string;

        image_url: struct{
            url: string;
            detail: Img_Detail = IMG_DETAIL_AUTO; // string
        };

        input_audio: struct{
            data: string;
            format: Audio_Format = AUDIO_FORMAT_MP3; // string
        };
    }
}

So (part of) the JSON sent to the API might look like:

"messages": [
  {
    "role": "developer",
    "content": "You are a helpful assistant."
  },
  {
    "role": "user",
    "content": [ { "type": "text", "text": "Hello there, ChatGPT" } ]
  }
]

Now the issue is that if you serialize this kind of struct the output JSON will include all the union fields, and we have no way of filtering them out at serialization time.

You can represent this another way, which is to have content: []*Msg_Content_Base and to have a struct per union field, but currently the serializer doesn't cast the pointer to the derived type (not sure if even possible) and so we only get the base.

A few solutions come to mind (but please feel free to suggest others):

  • Add a new note (or extend the ignore one) that accepts member info (like ignore) and the data of the parent struct being serialized. This way, we can ignore not only based on type, but based on the value of different fields in the struct. One can use this to makes both pointer and unions work here.

  • Ability to use custom serializers for certain types, probably by 'registering' a type serializer with Jaison. To avoid the user having to implement tons of code, we can make it a procedure that returns Any, which then gets serialized normally by Jaison. With this, we can implement the OpenAI API by taking the base pointer and returning the derived Msg_Content_* type, which allows Jaison to serialize the full info. Another thing this allows us to do is to say make all UUID :: [16]u8 members get serialized as strings by the custom serializers generating and returning a string to Jaison.

    • A similar thing is needed for parsing as well to ensure we support all kinds of API responses.

Obviously, those two are complementary and each gives an ability the doesn't. Either would allow us to support the OpenAI API, but we probably want both for a full backend (e.g. it would make it convenient to have response structs use Apollo_Time and UUID internally, but automatically convert into unix time and strings to the outside world at serialization time).

Looking forward to hearing your thoughts :)

Edit:

By modifying ignore to take the data pointer (2-3 line change), this now works to print the correct struct:

custom_ignore_by_note :: (member: *Type_Info_Struct_Member, data: *void) -> bool {

    UNION_OFFSET_IN_BYTES :: #run -> int {
        
        members := type_info(Msg_Content).members;
        for members {
            if it.name == "" && it.type.type == .STRUCT && it.flags == .USING return it.offset_in_bytes;
        }

        assert(false, "failed to find start of the union block of the OpenAI Msg_Content struct");
        return 0;
    }
    
	for note: member.notes {
		
        if note == "JsonIgnore"	return true;

        if note != "openai_msg_content_union" return false;

        msg := (data - UNION_OFFSET_IN_BYTES).(*Msg_Content);

        if msg.type == {
            case MSG_CONTENT_TYPE_TEXT;
                return member.name != "text";

            case MSG_CONTENT_TYPE_IMG_URL;
                return member.name != "image_url";

            case MSG_CONTENT_TYPE_INPUT_AUDIO;
                return member.name != "input_audio";

            case;
                log("unhandled OpenAI Msg_Content_Type '%' type in custom_ignore_by_note", msg.type, flags=.ERROR);
                return false;
        }      
	}
	return false;
}

Now its a bit dangerous and painful to get the proper parent offset (as union members have an offset of zero), so if we implement this its better to send both member and parent data pointers.

Using this for a bit, its clear this setup allows us to get very far, as I have now implemented the core of OpenAI and Anthropic APIs, although not very ergonomically (too many unions and custom ignores). I would also suggest we do the same (pass data pointer) to the renamer, as you do need similar flexibility there as well.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions