GPT-6 Sol/Luna入門——AI機能のモデルルーティングをFirestoreで測る
OpenAI APIに追加されたGPT-6 SolとGPT-6 Lunaを題材に、Next.js + Firestoreでタスク別モデルルーティングと評価ログを作る実践手順を解説します。

はじめに
AI機能を業務アプリに入れていると、モデル選びはだんだん「一番強いものを使う」だけでは回らなくなります。
- 仕様レビューは品質を優先したい
- 問い合わせ分類は件数が多いので費用を抑えたい
- モデルを変えたあと、応答品質とレイテンシを後から比べたい
OpenAI APIのChangelogでは、2026年9月22日に gpt-6-sol と gpt-6-luna がResponses APIとChat Completions APIへ追加されたと案内されています。どちらもテキスト・画像入力からテキストを生成する推論モデルです。公式モデルカタログでは、Solは複雑なコーディングやエージェント的なワークフロー向け、Lunaは焦点の絞られた高頻度タスク向け、と位置づけられています。
本記事では、架空の社内問い合わせ管理SaaS「HelpDeskFlow」を題材に、Next.js App Router + Firestore + TypeScriptで「タスクごとにSol/Lunaを振り分け、結果をFirestoreへ保存する」仕組みを作ります。
参考にした一次情報は次の通りです。
何が変わったのか
今回の実務上のポイントは、GPT-6系を「単一の置き換え先」ではなく、用途別の選択肢として扱えることです。
| モデル | 向いている使い方 | 標準価格の目安 |
|---|---|---|
gpt-6-sol | 複雑な仕様レビュー、コード生成、複数条件の判断 | 入力$2、キャッシュ入力$0.20、出力$10 / 100万トークン |
gpt-6-luna | 問い合わせ分類、要約、定型チェック、バッチ処理 | 入力$0.10、キャッシュ入力$0.01、出力$0.50 / 100万トークン |
OpenAIのモデルカタログでは、どちらもコンテキストウィンドウは1.05M、最大出力は128K tokensとされています。ただし、長く入るからといってFirestore上の履歴を丸ごと投げる設計にはしません。まずは必要なチケット、直近コメント、受け入れ条件を選び、モデル選択の根拠と利用結果を残すのが安全です。
また、GPT-6のモデルガイダンスでは、Responses APIで model を gpt-6-sol または gpt-6-luna に設定して使うこと、推論しながらツールを使う場合はResponses APIを使うことが案内されています。reasoning effortを none 以外にする場合は、temperature や top_p などのパラメータを外す点も移行時の注意点です。
事前準備
HelpDeskFlowでは、Route HandlerからOpenAI APIを呼び、Firebase Admin SDKでFirestoreへ保存します。
yarn add openai firebase-admin
環境変数はNext.jsの実行環境に設定します。GitHub ActionsやVercelなどで動かす場合も、APIキーをリポジトリに置かない方針は変わりません。
OPENAI_API_KEY=sk-...
ハンズオン1: モデル選択ルールを作る
まず、アプリのどこから呼んでも同じ判断になるよう、モデルルーティングを関数化します。ここでは「高頻度で構造が安定している仕事はLuna」「判断の重い仕事はSol」という保守的な分け方にします。
// src/lib/ai/openaiModelRouter.ts
export type AiTaskKind =
| "ticket_classification"
| "ticket_summary"
| "implementation_plan"
| "risk_review";
type ModelRoute = {
model: "gpt-6-sol" | "gpt-6-luna";
reasoningEffort: "none" | "low" | "medium";
reason: string;
};
export function routeOpenAiModel(taskKind: AiTaskKind): ModelRoute {
if (taskKind === "ticket_classification" || taskKind === "ticket_summary") {
return {
model: "gpt-6-luna",
reasoningEffort: "none",
reason: "高頻度な分類・要約なので、低コストで反復しやすいLunaを使う",
};
}
return {
model: "gpt-6-sol",
reasoningEffort: "medium",
reason: "仕様やリスクの判断を含むため、Solで推論品質を優先する",
};
}
reason を残しておくと、あとで「なぜこのモデルを使ったのか」をレビューできます。AI利用量が増えるほど、この小さな説明が効いてきます。
ハンズオン2: Responses APIを呼ぶRoute Handler
次に、Next.jsのRoute Handlerを作ります。requireUser と assertCanReadProject は、アプリ側で用意する認証・認可ヘルパーです。モデル呼び出しの前に、必ず業務データへのアクセス権を確認します。
// src/app/api/projects/[projectId]/ai-assist/route.ts
import OpenAI from "openai";
import { FieldValue } from "firebase-admin/firestore";
import { adminDb } from "@/lib/firebase/admin";
import { requireUser } from "@/lib/auth/requireUser";
import { assertCanReadProject } from "@/lib/projects/permissions";
import { routeOpenAiModel, type AiTaskKind } from "@/lib/ai/openaiModelRouter";
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
type Params = {
params: Promise<{ projectId: string }>;
};
type RequestBody = {
taskKind: AiTaskKind;
ticketIds: string[];
question: string;
};
export async function POST(request: Request, { params }: Params) {
const user = await requireUser(request);
const { projectId } = await params;
const body = (await request.json()) as RequestBody;
await assertCanReadProject(user.uid, projectId);
const ticketIds = body.ticketIds.slice(0, 30);
const question = body.question.trim().slice(0, 1000);
const route = routeOpenAiModel(body.taskKind);
const startedAt = Date.now();
const ticketsSnapshot = await adminDb
.collection("helpdeskTickets")
.where("projectId", "==", projectId)
.where("ticketId", "in", ticketIds)
.get();
const ticketContext = ticketsSnapshot.docs
.map((doc) => {
const data = doc.data() as {
ticketId: string;
title: string;
status: string;
priority: "low" | "medium" | "high";
};
return `${data.ticketId}: ${data.title} / ${data.status} / ${data.priority}`;
})
.join("\n");
const response = await openai.responses.create({
model: route.model,
reasoning: { effort: route.reasoningEffort },
instructions:
"あなたはWeb系SaaSのシニアエンジニアです。日本語で、結論、根拠、次のアクションを簡潔に返してください。",
input: [
`タスク種別: ${body.taskKind}`,
`質問: ${question}`,
"対象チケット:",
ticketContext,
].join("\n\n"),
});
const latencyMs = Date.now() - startedAt;
await adminDb.collection("aiModelRoutingRuns").add({
provider: "openai",
projectId,
requestedBy: user.uid,
taskKind: body.taskKind,
ticketIds,
model: route.model,
reasoningEffort: route.reasoningEffort,
routingReason: route.reason,
responseId: response.id,
responseModel: response.model,
usage: response.usage ?? null,
latencyMs,
outputTextLength: response.output_text.length,
createdAt: FieldValue.serverTimestamp(),
});
return Response.json({
model: route.model,
reasoningEffort: route.reasoningEffort,
answer: response.output_text,
});
}
ポイントは、回答本文をFirestoreへ丸ごと保存しないことです。問い合わせ本文には個人情報や契約情報が混ざることがあります。ログにはモデル名、タスク種別、利用量、レイテンシ、出力文字数を残し、必要な場合だけアプリの監査ポリシーに沿って本文保存を検討します。
ハンズオン3: Firestoreで週次比較する
モデルを切り替える判断は、1回の体感ではなく、同じタスク群で比較するのが大切です。まずは30件単位で回し、Lunaで足りる仕事とSolに任せる仕事を分けます。
// src/lib/ai/modelRoutingReport.ts
import { adminDb } from "@/lib/firebase/admin";
type ModelRoutingSummary = {
model: string;
runCount: number;
averageLatencyMs: number;
averageOutputTextLength: number;
};
export async function summarizeModelRoutingRuns(input: {
projectId: string;
since: Date;
}): Promise<ModelRoutingSummary[]> {
const snapshot = await adminDb
.collection("aiModelRoutingRuns")
.where("projectId", "==", input.projectId)
.where("createdAt", ">=", input.since)
.get();
const groups = new Map<string, { count: number; latency: number; output: number }>();
for (const doc of snapshot.docs) {
const data = doc.data() as {
model: string;
latencyMs: number;
outputTextLength: number;
};
const current = groups.get(data.model) ?? { count: 0, latency: 0, output: 0 };
groups.set(data.model, {
count: current.count + 1,
latency: current.latency + data.latencyMs,
output: current.output + data.outputTextLength,
});
}
return [...groups.entries()].map(([model, value]) => ({
model,
runCount: value.count,
averageLatencyMs: Math.round(value.latency / value.count),
averageOutputTextLength: Math.round(value.output / value.count),
}));
}
最初の運用では、以下のような基準で十分です。
| 観点 | 見るもの | 判断 |
|---|---|---|
| 分類精度 | 人手修正された割合 | 修正が少なければLunaを継続 |
| 仕様レビュー | 見落とし指摘の有用性 | 重要な抜け漏れを拾うならSolを継続 |
| レイテンシ | latencyMs | 画面同期か非同期処理かを決める |
| 費用 | usage とモデル単価 | 高頻度タスクをLunaへ寄せる |
GitHub Actionsでモデル指定を見張る
運用が進むと、試験的に入れたモデル名がコードに残りがちです。PRごとに許可モデルを検査しておくと、意図しないモデル利用を防げます。
name: OpenAI model routing guard
on:
pull_request:
paths:
- "src/**"
- ".github/**"
jobs:
guard:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check approved OpenAI model IDs
run: |
if rg 'gpt-6-(astra|sol|luna)|gpt-5\\.6' src .github; then
echo "OpenAI model reference found. Review routing policy before merging."
fi
ここでは検出で即失敗にはしていません。移行期は、まず「モデル指定が増えたことに気づける」状態を作るほうが現実的です。失敗させる場合は、許可リストを別ファイルにして、レビュー済みのモデルだけ通す運用にします。
注意点
GPT-6 Sol/LunaはResponses APIとChat Completions APIの両方で使えますが、ツール利用を含む推論ワークフローはResponses APIに寄せるのが無難です。公式ガイダンスでも、推論しながらツールを使う場合はResponses APIを使うよう案内されています。
また、EUデータレジデンシーについては、GPT-6 Sol/LunaではStandard processingのみ利用可能とOpenAIのデータコントロール資料に記載されています。リージョン要件がある案件では、モデル選択だけでなく処理リージョンとサービスティアもセットで確認してください。
まとめ
GPT-6 SolとGPT-6 Lunaの追加で、OpenAI APIのモデル選択は「高性能モデルへ一括移行」ではなく、「タスクごとに品質・費用・速度を測って振り分ける」段階に入りました。
- 2026年9月22日に
gpt-6-solとgpt-6-lunaがOpenAI APIに追加された - Solは複雑なコーディングや判断を含む仕事、Lunaは高頻度な分類・要約に向く
- Next.js側でモデルルーティングを関数化し、理由もログに残す
- Firestoreには本文ではなく、モデル名、利用量、レイテンシ、評価に必要なメタデータを保存する
- GitHub Actionsでモデル指定の増加を検知すると、運用ルールが崩れにくい
20〜30件のタスクを小さく流して比較すれば、「この処理はLunaで十分」「このレビューはSolに寄せる」といった判断がチームの知見になります。モデル選びを感覚ではなく、アプリの観測データで育てていきましょう。