0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

複数のAI画像・動画モデルで「表示価格」と「実際の消費」をずらさない設計

0
Posted at

AI画像生成の料金が「1回10クレジット」のように固定されている間、実装は比較的単純です。

注: この記事のコード、型名、数値、フローは、説明用にゼロから作成した一般的な例です。SceneFlareの実装コード、内部API、価格式、プロバイダー設定は掲載していません。

しかし、複数の画像・動画モデルを扱い始めると、料金に影響する入力が増えていきます。

  • 解像度と品質
  • 出力する画像の枚数
  • 動画の出力時間
  • 音声を生成するか
  • 参照画像の枚数
  • 入力動画の長さ
  • テキストから生成するか、素材を編集するか

この状態で、フロントエンドには表示用の価格表、サーバーには課金用の条件分岐を別々に持つと、いずれ数字がずれます。

ユーザーには「24クレジット」と表示したのに、送信後に31クレジット消費されるような状態は避けなければなりません。

SceneFlare で複数のAI画像・動画ワークフローを扱う中では、価格計算をUIの補助機能ではなく、生成リクエストの契約として考えています。

この記事では、表示価格、サーバー側の確定価格、クレジット予約、履歴を同じルールで扱うための設計を整理します。

モデルIDだけでは価格を決められない

最初は次のような価格表でも動きます。

const exampleUnits = {
  imageModelA: 10,
  imageModelB: 20,
  videoModelA: 80,
}

ただし、同じモデルでも入力によって原価や処理量が変わる場合、この形では足りません。

たとえば動画モデルなら、次の二つは同じ価格とは限りません。

720p / 5秒 / 音声なし / テキストから生成
1080p / 10秒 / 音声あり / 8秒の動画を編集

価格計算に必要なのはモデルIDだけではなく、正規化済みの生成入力全体です。

type QuoteRequestExample = {
  engine: string
  operation: 'create-image' | 'edit-image' | 'create-video' | 'edit-video'
  options: Record<string, string>
  textSize: number
  inputs: Array<{
    kind: 'image' | 'video' | 'audio'
    lengthSeconds?: number
  }>
}

ここで重要なのは、画面から届いた値をそのまま価格関数に渡さないことです。

まずモデルの対応モード、選択肢、デフォルト値、参照素材の上限を使って入力を正規化し、その結果から価格を求めます。

raw form input
    |
    v
model capability validation
    |
    v
normalized pricing input
    |
    v
pricing resolver

存在しない解像度や、そのモードでは利用できない音声設定が価格計算に混ざらないようにします。

価格関数の戻り値を数字だけにしない

価格関数が number だけを返すと、後からその価格を説明しづらくなります。

実際の生成では、確定値か概算値か、どの価格ルールを使ったかも重要です。

type QuoteResultExample = {
  units: number
  ruleRevision: string
  provisional: boolean
  reasons: {
    operation: string
    resolution?: string
    outputSeconds?: number
    inputVideoSeconds?: number
    imageCount: number
  }
}

units はユーザーに表示して予約する値です。

ruleRevision は、どの料金ルールで計算したかを記録するために使います。

provisional は、入力トークン数やプロバイダー側の実使用量など、送信前に完全には確定できない要素がある場合に使えます。

reasons は、後から料金を調査するときの説明材料になります。プロバイダーの内部情報をそのままユーザーに見せる必要はありませんが、少なくともアプリケーション側では「何を根拠にこの価格になったか」を再現できる状態にしておきます。

UIとサーバーで同じresolverを使う

価格のずれを防ぐため、UIの見積もりとサーバーの確定処理は、同じ純粋関数を利用できる形にします。

export function calculateExampleQuote(
  input: QuoteRequestExample,
): QuoteResultExample {
  // モデルと入力条件から価格を決める
}

UIでは、解像度や出力時間を変更したときにこの関数を呼び、送信ボタンの近くに必要クレジットを表示します。

サーバーでは、リクエストを再検証して正規化した後、同じ関数をもう一度呼びます。

Browser: input -> normalize for preview -> show quote

Server:  request -> authenticate -> validate -> normalize -> quote again

同じresolverを共有しても、クライアントが送った価格を信頼してはいけません。

ブラウザ上のコードやリクエスト本文は変更できます。また、古いタブが古い価格ルールを持ったまま送信する可能性もあります。実際に予約するクレジットは、サーバーがその時点の入力から再計算した値にします。

クライアント計算はUXのため、サーバー計算は契約のため、という分け方です。

小数をそのままクレジットに変換しない

プロバイダー価格は、小数のドル単価、秒単価、トークン単価で提供されることがあります。

これをJavaScriptの number で何度も加算・乗算してから切り上げると、浮動小数点誤差がクレジット境界に影響する可能性があります。

// 境界値では意図しない切り上げが起こる可能性がある
const credits = Math.ceil((unitPrice * seconds) / usdPerCredit)

金額計算では、次のどちらかを選びます。

  • 最小通貨単位や十分に細かい内部単位へ整数化する
  • 分子と分母を整数で保持するdecimal / rational型を使う

最後にプロダクトのクレジットへ変換するときだけ切り上げます。

type FractionExample = {
  numerator: bigint
  denominator: bigint
}

また、最低消費クレジットがある場合は、この変換境界で適用します。各モデルの条件分岐に Math.max(minimum, ...) を散らすより、変換処理を一つにまとめた方が安全です。

消費ではなく、まず予約する

非同期生成では、リクエストを受け付けた時点と、結果が確定する時点が離れています。

生成開始時に即座にクレジットを消費すると、プロバイダー送信前の内部エラーでも返却処理が必要になります。一方、完了後にだけ消費すると、処理中に別の生成へ同じ残高を使える可能性があります。

そこで、生成タスクを作るときはクレジットを予約します。

validate and quote
    |
    v
create generation task
    |
    v
reserve credits
    |
    v
enqueue generation

タスクが成功したら予約を確定し、最終的に失敗したら予約を解除します。

reserved -> committed
         -> rolled back

以前、本番環境のAI画像生成で非同期処理が必要になる理由 でも書いたように、Webhook、polling、retryが同じ結果を複数回観測することがあります。

そのため、確定と解除は生成タスクIDを使って冪等にします。同じ成功通知が二回来ても二重消費せず、同じ失敗処理が再実行されても二重返却しないことが必要です。

生成タスクに価格スナップショットを保存する

料金ルールは変わります。

新しいモデルを追加するだけでなく、プロバイダー価格、対応解像度、最低料金、クレジット換算率が更新されることもあります。

現在の価格関数だけを使って過去タスクを再計算すると、履歴に表示される数字が後から変わってしまいます。

生成タスクには、少なくとも次の値を保存します。

type AcceptedQuoteExample = {
  units: number
  ruleRevision: string
  provisional: boolean
  inputSummary: Record<string, string | number | boolean>
}

このスナップショットは、送信時点でユーザーが確認したプロダクト価格です。

後からルールが更新されても、進行中タスクの予約額や完了済みタスクの履歴は変えません。

現在の料金ページと過去の生成履歴は、目的が違います。前者は現在利用できる条件を示し、後者はそのタスクが作成された時点の契約を示します。

条件の網羅性をテストする

TypeScriptの型だけでは、価格ルールの意味までは保証できません。

たとえば、型が正しくても次の問題は起こります。

  • 10秒・1080p・音声ありの条件だけ価格が未定義
  • より一般的な条件が先に一致し、詳細な料金が使われない
  • 入力動画の長さがあるのに静止画向け料金を選ぶ
  • UIのデフォルト値が価格テーブルの選択肢に存在しない
  • 小数の境界で1クレジット多く切り上がる

モデルごとに個別のテストを書くことに加え、対応する入力の組み合わせを一覧化したテーブルテストが役立ちます。

it.each([
  {
    name: '5秒・720p・音声なし',
    input: { duration: 5, resolution: '720p', withAudio: false },
    expectedCredits: 24,
  },
  {
    name: '10秒・1080p・音声あり',
    input: { duration: 10, resolution: '1080p', withAudio: true },
    expectedCredits: 72,
  },
])('$name', ({ input, expectedCredits }) => {
  expect(calculateExampleUnits(input)).toBe(expectedCredits)
})

上の数値は説明用ですが、考え方は同じです。

モデル一覧に新しいモデルを追加するときは、表示情報や入力フォームだけでなく、対応する価格条件がすべて決定的に解決できることも確認します。

まとめ

複数モデルを扱うAI生成サービスでは、価格はモデルに付いた一つの数字ではありません。モデル、モード、品質、出力時間、出力数、入力素材などを正規化した結果として決まります。

表示価格と実際の消費をずらさないためには、次の点が重要です。

  • 価格計算前に生成入力を正規化する
  • UIとサーバーで同じ純粋なresolverを使う
  • クライアントが送った価格は信頼せず、サーバーで再計算する
  • 小数の原価計算と整数クレジットへの変換を分離する
  • 非同期処理中はクレジットを予約し、成功・失敗時に冪等に確定する
  • 価格バージョンと入力要因を生成タスクへ保存する
  • 型だけでなく、条件の網羅性と境界値をテストする

モデルは生成処理を実行しますが、ユーザーに約束した価格を守る責任はアプリケーション側にあります。

価格計算を一つの明確な契約として設計すると、モデルや料金ルールが増えても、UI、残高、履歴を同じ意味のまま保ちやすくなります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?