📝

LLM のモデル廃止に耐える ~TypeScript で型安全なモデルカタログを作ってみた~

に公開

はじめに

こんにちは、株式会社AI Shift の yoshi です。
私たちはエンタープライズ向けの AI エージェントプラットフォーム AI Worker Platform を開発しています。

2026年に入ってから、LLM のモデル廃止対応を行いました。DALL-E 3、Gemini 1.0 / 1.5 など...。

最初の DALL-E 3 の廃止対応では、「モデルの参照を差し替えるだけだし、すぐ終わるだろう」と思っていました。ところがいざ着手してみると、影響範囲が想定以上に広かったので、「次の廃止でも同じことをやるのか?」と考えると、これは設計で解決すべき問題でした。そこでモデルカタログ基盤を構築したので、その設計と運用について共有します。

この記事で話すこと / 話さないこと

話すこと

  • LLM プロバイダー(Azure OpenAI / Vertex AI / Anthropic)のモデルを一元管理する内部パッケージの設計と実装
  • マルチテナント環境でテナントごとに異なる Azure デプロイ名を型安全に扱う方法

話さないこと

  • LLM の選定基準やモデルの性能比較
  • Vercel AI SDK / LangChain 自体の使い方チュートリアル
  • Azure OpenAI / Vertex AI のセットアップ手順やインフラ構築

設計方針 — 何を解決するパッケージにするか

モデルカタログを設計するにあたって、3つの問いを立てました。

Q1. モデルが廃止されたとき、1箇所の変更で全サービスに反映できるか?

これが最も重要な要件でした。DALL-E 3 の廃止対応では、バックエンドのロジック部分から、フロントエンドの UI コンポーネント、locale ファイルなど、あらゆる場所に変更が必要でした。「retired とマークしたら、あとは自動で fallback する」という運用が自分の理想でした。

Q2. テナントごとに異なる Azure デプロイ名をどう抽象化するか?

Azure OpenAI では、同じ gpt-4o モデルでもテナントごとにデプロイ名が異なる可能性があります。あるテナントは gpt-4o そのまま、別のテナントは acme-chat4o_s、また別のテナントは corp-gpt-4o。これらが switch/case で管理されていたのを、型安全な lookup に置き換える必要がありました。

Q3. Vercel AI SDK との結合度をどう保つか?

私たちは Vercel AI SDK を使って LLM を呼び出していますが、モデルカタログ自体は AI SDK に依存すべきではないと考えました。理由はフロントエンドのモデル選択 UI でも同じカタログを参照したいためとAI SDK のバージョンアップにカタログが引きずられるのを避けたいためです。

この3つの問いを行ったり来たりしながら、最終的に4つの方針に落ち着きました。

  • @ai-sdk/* に依存しない純粋なデータ + ロジックパッケージとする
  • as const satisfies でリテラル型を保ちつつ型安全にする
  • Fallback は replacementModelId チェインで自動解決する
  • テナント設定は型パラメータで分離する

パッケージ設計 — model-catalog の全体像

@pkg/model-catalogの全体図

パッケージの構造は以下のとおりです。

packages/model-catalog/
├── src/
│   ├── types/       # Model, DeploymentConfig, FallbackResult 等の型定義
│   ├── catalog/     # プロバイダー別モデル定義 (azure.ts, vertex.ts, anthropic.ts)
│   │   └── fallback.ts  # Fallback ロジック
│   ├── tenants/     # テナント別デプロイ設定
│   ├── utils/       # isReasoningModel, supportsParallelToolCalls 等
│   └── index.ts     # Public API

Model 型の設計

モデルは ChatModel | ImageModel | EmbeddingModel の Discriminated Union として定義しています。type フィールドの値で型を判別する union 型です。

type ModelType = 'chat' | 'image' | 'embedding'

interface BaseModel {
  id: string
  type: ModelType
  provider: ProviderType
  status: 'active' | 'deprecated' | 'retired'
  replacementModelId?: string
}

interface ChatModel extends BaseModel {
  type: 'chat'
  series: 'gpt' | 'claude' | 'gemini' | 'o'
  capabilities: {
    temperatureSupport: boolean
    parallelToolCalls: boolean
    vision: boolean
  }
}

type Model = ChatModel | ImageModel | EmbeddingModel

status フィールドが、このパッケージの核になる概念です。

ステータス 説明文
active 利用可能
deprecated 非推奨。新規選択は不可だが、既存の利用は継続できる。
retired 廃止済み。replacementModelId で指定された後継モデルに自動 fallback する。

as const satisfies パターン

モデル定義には TypeScript 4.9 で導入された satisfies 演算子as const を組み合わせています。

export const AZURE_CHAT_MODELS = [
  {
    id: 'gpt-4o',
    type: 'chat',
    provider: 'azure',
    status: 'active',
    series: 'gpt',
    capabilities: {
      temperatureSupport: true,
      parallelToolCalls: true,
      vision: true,
    },
  },
  {
    id: 'gpt-35-turbo',
    type: 'chat',
    provider: 'azure',
    status: 'retired',
    replacementModelId: 'gpt-4o-mini',
    series: 'gpt',
    capabilities: {
      temperatureSupport: true,
      parallelToolCalls: false,
      vision: false,
    },
  },
  // ...
] as const satisfies readonly ChatModel[]

このパターンのメリットは2つあります。

  1. as const により、id フィールドが string ではなく 'gpt-4o' | 'gpt-35-turbo' のリテラル型として推論される
  2. satisfies ChatModel[] により、各エントリが ChatModel の構造を満たしているかコンパイル時にチェックされる

つまり、リテラル型の精度と構造の型安全性を両立できます。as ChatModel[] と書くとリテラル型が消えてしまいますし、as const だけだと typo した時に気づくことが難しいです。
この組み合わせが「型を広げず、構造は検証する」を実現してくれます!

Fallback システム — 廃止モデルを自動で置き換える

全体像

やりたいことはシンプルです。
廃止されたモデル ID を渡しても、後継モデルが自動的に返ってくる。呼び出し側のコードを変える必要はありません。

Fallback チェインの解決過程の矢印図

gpt-35-turbo (retired) → gpt-4o-mini (active)  ✅ 自動解決
gemini-1.5-flash-001 (retired) → gemini-2.0-flash-001 (active)  ✅ 自動解決
o1-mini (retired) → o4-mini (active)  ✅ 自動解決

呼び出し側の変更は最小限で、getChatModel()getChatModelWithFallback() に差し替えるだけです。

// Before
const model = getChatModel(modelId)
if (!model) {
  throw new Error(`Unknown model: ${modelId}`)
}

// After
const result = getChatModelWithFallback(modelId)
if (!result.ok) {
  throw new Error(`Model resolution failed: ${result.error.reason}`)
}

if (result.fallbackApplied) {
  logger.warn(`Model ${result.originalModelId} is retired, falling back to ${result.value.id}`)
}
const model = result.value

「fallback 先のモデルは性能やコストが違うのでは?」という疑問はもっともです。
例えば gpt-35-turbogpt-4o-mini は性能が上がる一方、トークン単価も変わります。fallback はサイレントに行われますが、ログには記録されるように設計しています。
fallback を設定する際は事前にいつから fallback されるのか、をテナントに通知し反映させています。

仕組みの詳細

status: 'retired' のモデルに対して replacementModelId が設定されていれば、自動的に後継モデルを解決します。後継モデルがさらに retired であればチェインを辿ります。

FallbackResult<T> は Go の (value, error) や Rust の Result<T, E> に近い型です。throw だと呼び出し側で型情報が消えてしまいますし、fallback が適用されたかどうかや元のモデル ID といった付加情報も返したかったため、この形にしました。

type FallbackResult<T> =
  | { ok: true; value: T; fallbackApplied: false }
  | { ok: true; value: T; fallbackApplied: true; originalModelId: string }
  | { ok: false; error: FallbackError }
// チェインを辿る関数例
function getChatModelWithFallback(modelId: string): FallbackResult<ChatModel> {
  const model = getChatModel(modelId)
  if (!model) return { ok: false, error: new FallbackError('MODEL_NOT_FOUND') }
  if (model.status !== 'retired') return { ok: true, value: model, fallbackApplied: false }

  // Fallback チェインを辿る
  const visited = new Set<string>()
  let current = model
  while (current.status === 'retired') {
    if (visited.has(current.id)) {
      return { ok: false, error: new FallbackError('CIRCULAR_REFERENCE') }
    }
    visited.add(current.id)
    if (visited.size > 10) {
      return { ok: false, error: new FallbackError('MAX_DEPTH_EXCEEDED') }
    }
    if (!current.replacementModelId) {
      return { ok: false, error: new FallbackError('NO_REPLACEMENT') }
    }
    const next = getChatModel(current.replacementModelId)
    if (!next) {
      return { ok: false, error: new FallbackError('REPLACEMENT_NOT_FOUND') }
    }
    current = next
  }
  return { ok: true, value: current, fallbackApplied: true, originalModelId: modelId }
}

安全装置

Fallback チェインの解決には、設定ミスを早期に検出するための安全装置を4つ設けています。

エラー理由 意味
NO_REPLACEMENT retired だが後継が未指定 設定漏れの検出
REPLACEMENT_NOT_FOUND 後継の modelId がカタログに存在しない typo の検出
CIRCULAR_REFERENCE A→B→A のループ 設定ミスの検出
MAX_DEPTH_EXCEEDED チェインが10段を超えた 異常な設定の検出

これらはランタイムのチェックですが、テストで担保しています。

describe('getChatModelWithFallback', () => {
  it('retired モデルを渡すと active な後継モデルが返る', () => {
    const result = getChatModelWithFallback('gpt-35-turbo')

    expect(result.ok).toBe(true)
    expect(result.value.id).toBe('gpt-4o-mini')
    expect(result.value.status).toBe('active')
    expect(result.fallbackApplied).toBeTruthy()
    expect(result.originalModelId).toBe('gpt-35-turbo')
  })

  it('循環参照を検出してエラーを返す', () => {
    const result = getChatModelWithFallback('circular-model-a')

    expect(result.ok).toBeFalsy()
    expect(result.error.reason).toBe('CIRCULAR_REFERENCE')
  })
})

マルチテナント対応 — テナント別デプロイ名の型安全な管理

課題

Azure OpenAI では、モデルをデプロイする際に任意の名前をつけられる可能性があります。そのため、同じ gpt-4o モデルでもテナントごとにデプロイ名が異なります。

テナント gpt-4o のデプロイ名
デフォルト gpt-4o
テナント A acme-chat4o_s
テナント B corp-4o
テナント C example-gpt-4o

Before の実装では、これが巨大な switch/case として各サービスにコピーされていました。

// Before: 各サービスに散在していた switch/case
function getDeploymentName(tenantId: string, modelId: string): string {
  switch (tenantId) {
    case TENANT_A_ID:
      switch (modelId) {
        case 'gpt-4o': return 'acme-chat4o_s'
        case 'gpt-4o-mini': return 'acme-chat4omini_s'
        // ... 10行以上続く
      }
    case TENANT_B_ID:
      switch (modelId) {
        case 'gpt-4o': return 'corp-4o'
        // ... さらに続く
      }
    // ... テナントが増えるたびに膨張
  }
}

解決策: テナント別デプロイ設定

model-catalog では、テナントごとにデプロイ設定ファイルを用意し、型安全な lookup 関数を提供しています。

interface DeploymentConfig {
  modelId: string
  deploymentId: string
}
// tenants/acme.ts
export const ACME_CHAT_DEPLOYMENTS = [
  { modelId: 'gpt-4o', deploymentId: 'acme-chat4o_s' },
  { modelId: 'gpt-4o-mini', deploymentId: 'acme-chat4omini_s' },
  { modelId: 'gpt-4', deploymentId: 'acme-chat4_s' },
] as const satisfies readonly DeploymentConfig[]

export function getAcmeDeployment(modelId: string) {
  return ACME_CHAT_DEPLOYMENTS.find(d => d.modelId === modelId)
}

TypedAzureProvider による型制約

Vercel AI SDK の Azure Provider の chat() メソッドに渡せるデプロイ名を、テナントごとのリテラル型で制約しています。

type AcmeChatDeploymentId = typeof ACME_CHAT_DEPLOYMENTS[number]['deploymentId']
// => 'acme-chat4o_s' | 'acme-chat4omini_s' | 'acme-chat4_s'

// LanguageModelV1, EmbeddingModelV1 は Vercel AI SDK が提供するモデルのインターフェース
interface TypedAzureProvider<ChatId extends string, EmbeddingId extends string> {
  chat(deploymentId: ChatId): LanguageModelV1
  embeddingModel(deploymentId: EmbeddingId): EmbeddingModelV1<string>
}

存在しないデプロイ名を渡すとコンパイル時にエラーになります。

// OK
provider.chat('acme-chat4o_s')

// コンパイルエラー: 'acme-chat4o_typo' は型に含まれない
provider.chat('acme-chat4o_typo')

Before / After

// Before: マジックナンバーとswitch文の塊
if (tenantId === TENANT_A_ID) {
  switch (modelName) {
    case 'gpt-4o': return azure.chat('acme-chat4o_s')
    case 'gpt-4o-mini': return azure.chat('acme-chat4omini_s')
    // ...
  }
}

// After: カタログ lookup の1行
const deployment = getAcmeDeployment(modelId)
if (deployment) return azure.chat(deployment.deploymentId)

運用して分かったこと — 学びと失敗

良かった点

Before/After の工数比較の棒グラフ

廃止対応の工数: 約30ファイル → 2ファイル

DALL-E 3 の廃止対応は約30ファイル・丸1日以上かかりました。カタログ導入後の o1-minio4-mini は 2ファイル・30分以内。カタログの status'retired' に変えて replacementModelId: 'o4-mini' を追加するだけで終わりました。

Frontend / Backend のモデル定義のズレ解消

モデル選択 UI のフィルタリング(deprecated モデルを非表示にする等)にも同じカタログを使えるので、「Backend では廃止済みなのに Frontend のドロップダウンにまだ表示されている」というズレがなくなりました。(マイグレーションしろ、という意見はごもっともです...)

typo のコンパイル時検出

provider.chat('gpt-4o-mni') のような typo は、以前はランタイムエラーで初めて気づいていましたが、今はエディタの赤線や Type Check で即座にわかります。

新モデル追加の手軽さ

gpt-5 が使えるようになったら、catalog/azure.ts にエントリを1つ追加し、必要なテナントの deployments に追加するだけです。カタログの型定義が合っていなければコンパイルが通らないので、設定漏れにも気づけます。

課題

現在はテナントごとに TypeScript ファイルを用意していますが、テナント数が増えるとコード量が膨張します。将来的にはテナント設定を DB または環境変数から読み込む外部化が必要になるでしょう。ただ、現時点ではテナント数が限定的なため、型安全性のメリットのほうが大きいと判断しています。

まとめ

LLM プロバイダーのモデル廃止は、AI プラットフォームを運用する上で避けて通れません。2026年だけでも4回の廃止対応を経験し、手作業では追いつかなくなっていました。

@pkg/model-catalog という内部パッケージを作って、以下を実現しました。

  • モデルの定義、ステータス、fallback 設定を1箇所に集約
  • retired マーク + replacementModelId で、チェインを自動解決
  • as const satisfies と Discriminated Union で、リテラル型の精度と構造の安全性を両立
  • TypedAzureProvider ジェネリクスで、テナント別デプロイ名をコンパイル時に検証

正直に言えば、この model-catalog が完璧だとは思っていません。テナント設定の外部化も積み残しています。次の廃止ラッシュが来たとき「余裕だった」と言えるかはわかりません。

ただ、モデルが変わってもコードは変えなくていいですし、その状態に一歩近づけたことは確かで、そのぶん本来やるべきプロダクト開発に集中できるようになりました。

同じ苦労をしている方の参考になれば幸いです。

最後に

AI Shiftではエンジニアの採用に力を入れています。少しでも興味を持っていただけましたら、カジュアル面談でお話しませんか(オンライン・19時以降の面談も可能です)。

【面談フォームはこちら】
https://hrmos.co/pages/cyberagent-group/jobs/1826557091831955459

AI Shift Tech Blog

Discussion