Closed12

JavaScript 用 Azure OpenAI クライアント ライブラリの型が使いにくい件の調査

ashphyashphy

どう型が使いにくいのか

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);
    }
  }
}

typediscriminated 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' に存在しません。
  }
}

かなりアホっぽい。

ashphyashphy

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}`);
  }
}

こうあって欲しい。

ashphyashphy

なぜうまく絞り込めないのか

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には親子が両方含まれている。
親のChatCompletionsToolCalltypestringであるため、typeで絞り込んでも親の可能性が常に残ってしまう。

ashphyashphy

誰が変な型を作っているのか

SDKのソースコードは Azure/azure-sdk-for-js にあるが、SDKはスキーマからクライアントを自動生成している。

スキーマは Azure/azure-rest-api-specs にあって、TypeSpecで記述されている。TypeSpecはTypeScriptのような文法でスキーマを定義するとOpenAPIのスキーマを書き出すことのできるツールだ。

このTypeSpec定義から内部ツールであるtsp-clientを使ってTypeScriptのクライアントが生成されている。

ashphyashphy

どんな型が定義されているのか

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 で書かれている。

ashphyashphy

誰が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で生成されているっぽい。

ashphyashphy

実際にTypeScriptのコードを生成してみる

typespec-tsの説明を見るとemitterを設定するとTypeScriptのコードを生成できるっぽいので、奇妙な型が実際に生成されるのかを見てみる。

main.tsp
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の設定を参考に以下のように設定する。

tspconfig.yaml
emit:
  - "@typespec/openapi3"
  - "@azure-tools/typespec-ts"
options:
  "@azure-tools/typespec-ts":
    isModularLibrary: true
    packageDetails:
      name: "@azure-rest/confidential-ledger"
      description: "Confidential Ledger Service"

実際にコンパイルしてみる。

models.ts
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;

PetUnionPetが含まれる型が生成されることが確認できた。

ashphyashphy

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を恨むしかなさそう。

ashphyashphy

モデル生成コードはどうなっているか?

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との互換性を保つためであると書かれている。

ashphyashphy

HLCとはなにか?

AzureのSDKにはRLCとHLCという概念があるらしい。RLCがRest Level Clientであるとの記載は見つかるが、HLCの記載を見つけることはできなかった。おそらくMS社内用語っぽい?
RLCの解説を読もうとしてリンクを踏むとMS社内サイトに飛ばされたりして判然としない。

ashphyashphy

過去に話されたことを探してみる

なんとなく事情が見えてきたので過去ログをあさってみる

https://github.com/Azure/azure-sdk-for-node/issues/2960

同じ話が書かれている。"previously discussed in Teams"とか言われてしまうとお手上げである。
https://github.com/Azure/autorest.typescript/issues/2139#issuecomment-1831473074

これも似た話な気がする
https://github.com/Azure/autorest.typescript/issues/1921

Unionに親クラスを追加するべきかどうか話されているIssueがあった
https://github.com/Azure/autorest.typescript/issues/2143

https://github.com/Azure/autorest.typescript/issues/2143#issuecomment-1831800320 を見ると実装方法を迷っているようだった。

https://github.com/Azure/autorest.typescript/pull/2028 で実装されたらしい。
ただどちらにするのか議論された形跡がない。たぶんMS社内で行われたと思う。

このスクラップは1ヶ月前にクローズされました