はじめに
画像生成APIを試す段階では、SDKを直接呼び出すだけでも十分です。しかし複数のモデルを比較したり、障害時に別プロバイダーへ切り替えたりする段階になると、アプリケーション全体が特定SDKの型に依存していることが問題になります。
この記事では、画像生成部分を小さなAdapterに閉じ込めるTypeScriptの設計を紹介します。ポイントは次の3つです。
- アプリケーション側で共通の入力・出力型を定義する
- プロバイダー固有の変換をAdapter内に閉じ込める
- リトライ対象と恒久エラーを区別する
共通の型を作る
まず、アプリケーションが必要とする最小限の型だけを定義します。
type ImageSize = "1024x1024" | "1536x1024" | "1024x1536";
export type GenerateImageInput = {
prompt: string;
negativePrompt?: string;
size: ImageSize;
seed?: number;
};
export type GeneratedImage = {
bytes: Uint8Array;
mediaType: "image/png" | "image/jpeg" | "image/webp";
providerRequestId?: string;
};
export interface ImageGenerator {
readonly name: string;
generate(
input: GenerateImageInput,
signal?: AbortSignal,
): Promise<GeneratedImage>;
}
URLだけを返すAPIもありますが、共通出力はバイト列にそろえました。期限付きURLを永続データとして保存してしまう事故を避け、呼び出し側でストレージへ保存できるためです。
プロバイダーAdapterを実装する
次は、HTTP APIを呼ぶAdapterの最小例です。実際のエンドポイントやレスポンス形式は利用するサービスに合わせて置き換えます。
class ProviderError extends Error {
constructor(
message: string,
readonly retryable: boolean,
readonly status?: number,
) {
super(message);
}
}
export class HttpImageGenerator implements ImageGenerator {
readonly name = "http-provider";
constructor(
private readonly endpoint: URL,
private readonly apiKey: string,
) {}
async generate(
input: GenerateImageInput,
signal?: AbortSignal,
): Promise<GeneratedImage> {
const response = await fetch(this.endpoint, {
method: "POST",
headers: {
authorization: `Bearer ${this.apiKey}`,
"content-type": "application/json",
},
body: JSON.stringify({
prompt: input.prompt,
negative_prompt: input.negativePrompt,
size: input.size,
seed: input.seed,
}),
signal,
});
if (!response.ok) {
const retryable = response.status === 429 || response.status >= 500;
throw new ProviderError(
`image generation failed: ${response.status}`,
retryable,
response.status,
);
}
const mediaType = response.headers.get("content-type");
if (
mediaType !== "image/png" &&
mediaType !== "image/jpeg" &&
mediaType !== "image/webp"
) {
throw new ProviderError(`unexpected media type: ${mediaType}`, false);
}
return {
bytes: new Uint8Array(await response.arrayBuffer()),
mediaType,
providerRequestId: response.headers.get("x-request-id") ?? undefined,
};
}
}
認証情報はログに出さず、環境変数やシークレット管理サービスからコンストラクタへ渡します。また、AbortSignalをそのままfetchへ渡すことで、上位レイヤーからタイムアウトやキャンセルを制御できます。
リトライ処理を分離する
429や一時的な5xxは再試行する価値がありますが、認証エラーや入力エラーを繰り返しても改善しません。Adapterがretryableを返し、共通処理が待機時間を管理する形にします。
const sleep = (ms: number, signal?: AbortSignal) =>
new Promise<void>((resolve, reject) => {
const id = setTimeout(resolve, ms);
signal?.addEventListener(
"abort",
() => {
clearTimeout(id);
reject(signal.reason);
},
{ once: true },
);
});
export async function generateWithRetry(
generator: ImageGenerator,
input: GenerateImageInput,
signal?: AbortSignal,
): Promise<GeneratedImage> {
const delays = [500, 1_000, 2_000];
for (let attempt = 0; ; attempt++) {
try {
return await generator.generate(input, signal);
} catch (error) {
const canRetry =
error instanceof ProviderError &&
error.retryable &&
attempt < delays.length;
if (!canRetry) throw error;
await sleep(delays[attempt], signal);
}
}
}
本番環境では、複数プロセスから同じジョブを実行しないためのidempotency keyや、ジョブキュー側の再試行回数も合わせて設計します。
モデル比較時に保存する情報
モデルを比較するときは、出力画像だけでなく次の情報も保存すると原因を追いやすくなります。
- 正規化前の入力値
- 実際に送信したプロバイダー固有payload
- モデル名とバージョン
- seed、画像サイズ、処理時間
- request IDと課金情報
- エラー種別と再試行回数
比較対象を探す際には、ChinaAIのような画像・動画生成ツールをまとめたページも候補整理に使えます。ただし、検証時はプロンプト、seed、サイズを固定し、一度に変える条件を一つにすることが重要です。
テストしやすくする
呼び出し側がImageGeneratorだけに依存していれば、テストではネットワークを使わないFakeを渡せます。
class FakeImageGenerator implements ImageGenerator {
readonly name = "fake";
calls: GenerateImageInput[] = [];
async generate(input: GenerateImageInput): Promise<GeneratedImage> {
this.calls.push(input);
return {
bytes: new Uint8Array([137, 80, 78, 71]),
mediaType: "image/png",
};
}
}
これにより、プロンプト組み立て、保存処理、ジョブ状態遷移をSDKや外部APIの稼働状況から切り離してテストできます。
まとめ
生成AIのプロバイダーを切り替えやすくする鍵は、大きな抽象化ではなく境界を明確にすることです。共通入力・出力、Adapter、エラー分類、キャンセル、保存すべきメタデータを先に決めておけば、新しいモデルを追加してもアプリケーション本体の変更範囲を小さくできます。