JavaScript 用 Azure OpenAI クライアント ライブラリの型が使いにくい件の調査
どう型が使いにくいのか
Function Callingしたときのレスポンスが絞り込めない
Azure OpenAI Service (プレビュー) で関数呼び出しを使用する方法 はPythonでの説明だが、それを見ながらTypeScriptでFunction callingを使う場合のコードを起こしてみる。
const response = await client.getChatCompletions(deploymentName, messages, {
tools: tools,
});
const responseMessage = response.choices[0].message;
const toolCalls = responseMessage?.toolCalls;
if (toolCalls) {
for (const toolCall of toolCalls) {
if (toolCall.type === "function") {
const functionName = toolCall.function.name;
// プロパティ 'function' は型 'ChatCompletionsToolCallUnion' に存在しません。
const functionArgs = JSON.parse(toolCall.function.arguments);
// プロパティ 'function' は型 'ChatCompletionsToolCallUnion' に存在しません。
}
}
}
だが実際には上記のように型エラーが発生する。
エラーがないように書こうとするとこうなる
if (toolCalls) {
for (const toolCall of toolCalls) {
if ("function" in toolCall) {
const functionName = toolCall.function.name;
const functionArgs = JSON.parse(toolCall.function.arguments);
}
}
}
type
はdiscriminated unionかと思ったら実はそうなっていない。
Messageも絞り込めない
getChatCompletions
に与えるmessages
の型はChatRequestMessageUnion[]
である。
メモリ上に履歴を持とうとして以下のような実装をしたとする
const messages: ChatRequestMessageUnion[] = [
{
role: "user",
content: "What's the weather like in San Francisco, Tokyo, and Paris?",
},
];
これを画面に表示しようとして以下のようなコードを書くと
for (const message of messages) {
if (message.role === "assistant") {
console.log(`Assistant: ${message.content}`);
// プロパティ 'content' は型 'ChatRequestMessage | ChatRequestAssistantMessage' に存在しません。
}
if (message.role === "user") {
console.log(`User: ${message.content}`);
// プロパティ 'content' は型 'ChatRequestMessage | ChatRequestUserMessage' に存在しません。
}
}
かなりアホっぽい。
OpenAIのクライアントではどうなるのか?
// エラーにはならない
if (toolCalls) {
for (const toolCall of toolCalls) {
const functionName = toolCall.function.name;
const functionArgs = JSON.parse(toolCall.function.arguments);
}
}
また、Messagesもエラーにならない。
const messages: ChatCompletionMessageParam[] = [
{
role: "user",
content: "What's the weather like in San Francisco, Tokyo, and Paris?",
},
];
for (const message of messages) {
if (message.role === "assistant") {
console.log(`Assistant: ${message.content}`);
}
if (message.role === "user") {
console.log(`User: ${message.content}`);
}
}
こうあって欲しい。
なぜうまく絞り込めないのか
toolCall
の型はChatCompletionsToolCallUnion
で定義は
export type ChatCompletionsToolCallUnion = ChatCompletionsFunctionToolCall | ChatCompletionsToolCall;
export interface ChatCompletionsToolCall {
type: string;
id: string;
index?: number;
}
export interface ChatCompletionsFunctionToolCall extends ChatCompletionsToolCall {
type: "function";
function: FunctionCall;
}
2つのinterfaceの関係は親子になっており
ChatCompletionsToolCallUnion
には親子が両方含まれている。
親のChatCompletionsToolCall
のtype
はstring
であるため、type
で絞り込んでも親の可能性が常に残ってしまう。
誰が変な型を作っているのか
SDKのソースコードは Azure/azure-sdk-for-js にあるが、SDKはスキーマからクライアントを自動生成している。
スキーマは Azure/azure-rest-api-specs にあって、TypeSpecで記述されている。TypeSpecはTypeScriptのような文法でスキーマを定義するとOpenAPIのスキーマを書き出すことのできるツールだ。
このTypeSpec定義から内部ツールであるtsp-clientを使ってTypeScriptのクライアントが生成されている。
どんな型が定義されているのか
ChatCompletionsToolCallUnion
の元になっているスキーマの定義は OpenAI.Inference/models/completions/tools.tspに以下のように定義されている。
@discriminator("type")
@doc("""
An abstract representation of a tool call that must be resolved in a subsequent request to perform the requested
chat completion.
""")
@added(ServiceApiVersions.v2024_02_15_Preview)
model ChatCompletionsToolCall {
#suppress "@azure-tools/typespec-azure-core/no-string-discriminator" "Existing"
@doc("The object type.")
type: string;
@doc("The ID of the tool call.")
id: string;
}
@added(ServiceApiVersions.v2024_02_15_Preview)
@doc("""
A tool call to a function tool, issued by the model in evaluation of a configured function tool, that represents
a function invocation needed for a subsequent chat completions request to resolve.
""")
model ChatCompletionsFunctionToolCall extends ChatCompletionsToolCall {
@doc("The type of tool call, in this case always 'function'.")
type: "function";
@doc("The details of the function invocation requested by the tool call.")
function: FunctionCall;
}
ModelにはExtendsが書けるが、これを書くと継承をサポートする言語では継承を使ってクライアントを生成するヒントになるらしい。
もう一つ大事なのが@discriminator
でこれはDiscriminated Types
という機能らしい。これはつまりTypeScriptのクライアントを作るときにはDiscriminated Union
になるよと言っているように読める。
OpenAPIのスキーマはどう表現されているか?
TypeSpecからコンパイルされたOpenAPIのスキーマ定義も公開されていて、これを書いているときの最新のスキーマ定義を見ると、
{
"chatCompletionMessageToolCall": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "The ID of the tool call."
},
"type": {
"$ref": "#/components/schemas/toolCallType"
},
"function": {
"type": "object",
"description": "The function that the model called.",
"properties": {
"name": {
"type": "string",
"description": "The name of the function to call."
},
"arguments": {
"type": "string",
"description": "The arguments to call the function with, as generated by the model in JSON format. Note that the model does not always generate valid JSON, and may hallucinate parameters not defined by your function schema. Validate the arguments in your code before calling your function."
}
},
"required": [
"name",
"arguments"
]
}
},
"required": [
"id",
"type",
"function"
]
}
}
あんま変じゃない気がする。ChatRequestMessageUnion
の方はどうなっているかというと
{
"chatCompletionRequestMessage": {
"type": "object",
"properties": {
"role": {
"$ref": "#/components/schemas/chatCompletionRequestMessageRole"
}
},
"discriminator": {
"propertyName": "role",
"mapping": {
"system": "#/components/schemas/chatCompletionRequestMessageSystem",
"user": "#/components/schemas/chatCompletionRequestMessageUser",
"assistant": "#/components/schemas/chatCompletionRequestMessageAssistant",
"tool": "#/components/schemas/chatCompletionRequestMessageTool",
"function": "#/components/schemas/chatCompletionRequestMessageFunction"
}
},
"required": [
"role"
]
}
}
こちらは OpenAPIのDiscriminator で書かれている。
誰がTypeScriptのコードを生成しているのか?
TypeScriptのコード生成にはtsp-clientが使われている。これはただの便利スクリプトでConfigを作ってTypeSpecのcompileコマンドを打っているだけ。Configを作るときにemitter-package.jsonを読んでいる。
TypeSpecにはTypeScriptのソースコードを生成する機能はなく、Azure/typespec-azureで機能拡張されている。TypeScriptのコードは@azure-tools/typespec-autorestと@azure-tools/typespec-tsで生成されているっぽい。
実際にTypeScriptのコードを生成してみる
typespec-tsの説明を見るとemitterを設定するとTypeScriptのコードを生成できるっぽいので、奇妙な型が実際に生成されるのかを見てみる。
import "@typespec/http";
import "@typespec/rest";
import "@typespec/openapi3";
using TypeSpec.Http;
using TypeSpec.Rest;
@service({
title: "Pet Store Service",
})
namespace PetStore;
@route("/pets")
interface Pets {
list(): Pet[];
}
@discriminator("type")
model Pet {
name: string;
type: string;
}
model Dog extends Pet {
type: "dog";
bowwow: string;
}
model Cat extends Pet {
type: "cat";
meow: string;
}
そのままの設定だとAzure SDKと出力されるコードが違うのでazure-rest-api-specsの設定を参考に以下のように設定する。
emit:
- "@typespec/openapi3"
- "@azure-tools/typespec-ts"
options:
"@azure-tools/typespec-ts":
isModularLibrary: true
packageDetails:
name: "@azure-rest/confidential-ledger"
description: "Confidential Ledger Service"
実際にコンパイルしてみる。
export interface Pet {
name: string;
/** the discriminator possible values: dog, cat */
type: string;
}
export interface Dog extends Pet {
type: "dog";
bowwow: string;
}
export interface Cat extends Pet {
type: "cat";
meow: string;
}
/** Alias for PetUnion */
export type PetUnion = Dog | Cat | Pet;
PetUnion
にPet
が含まれる型が生成されることが確認できた。
Javaだともっとめんどくさいのではないか?
Tool Callsの説明を見るとキャストしている。
ChatChoice choice = chatCompletions.getChoices().get(0);
if (choice.getFinishReason() == CompletionsFinishReason.TOOL_CALLS) {
ChatCompletionsFunctionToolCall toolCall = (ChatCompletionsFunctionToolCall) choice.getMessage().getToolCalls().get(0);
String functionArguments = toolCall.getFunction().getArguments();
}
場合分けする場合でもJavaだと instanceof するしかない。この場合はなんでもかんでも返ってくるAPIを恨むしかなさそう。
モデル生成コードはどうなっているか?
TypeScriptのソースコードを生成しているのはAzure/autorest.typescript
このリポジトリを見ていたら Ser deser for discriminated union and polymorphic base #2169
というPRを発見した。Discriminated Unionはここで実装されたっぽい。4ヶ月前の実装なのでかなりできたてである。
ここのコメントを読むと
Put a pin here, we have to use Union as the suffix so that we are consistent with hlc.
Unionに親クラスを含めるのは意図した実装のようだ。ただHLCとの互換性を保つためであると書かれている。
HLCとはなにか?
AzureのSDKにはRLCとHLCという概念があるらしい。RLCがRest Level Clientであるとの記載は見つかるが、HLCの記載を見つけることはできなかった。おそらくMS社内用語っぽい?
RLCの解説を読もうとしてリンクを踏むとMS社内サイトに飛ばされたりして判然としない。
過去に話されたことを探してみる
なんとなく事情が見えてきたので過去ログをあさってみる
同じ話が書かれている。"previously discussed in Teams"とか言われてしまうとお手上げである。
これも似た話な気がする
Unionに親クラスを追加するべきかどうか話されているIssueがあった
https://github.com/Azure/autorest.typescript/issues/2143#issuecomment-1831800320 を見ると実装方法を迷っているようだった。
https://github.com/Azure/autorest.typescript/pull/2028 で実装されたらしい。
ただどちらにするのか議論された形跡がない。たぶんMS社内で行われたと思う。
@azure/openai
v2がリリースされ、openai
の利用が推奨されるようになって解決した。