AI

GPT-6 Sol/Luna入門——AI機能のモデルルーティングをFirestoreで測る

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

2026年9月24日
OpenAIGPT-6Responses APIFirestoreNext.js
GPT-6 Sol/Luna入門——AI機能のモデルルーティングをFirestoreで測る

はじめに

AI機能を業務アプリに入れていると、モデル選びはだんだん「一番強いものを使う」だけでは回らなくなります。

  • 仕様レビューは品質を優先したい
  • 問い合わせ分類は件数が多いので費用を抑えたい
  • モデルを変えたあと、応答品質とレイテンシを後から比べたい

OpenAI APIのChangelogでは、2026年9月22日に gpt-6-solgpt-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で modelgpt-6-sol または gpt-6-luna に設定して使うこと、推論しながらツールを使う場合はResponses APIを使うことが案内されています。reasoning effortを none 以外にする場合は、temperaturetop_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を作ります。requireUserassertCanReadProject は、アプリ側で用意する認証・認可ヘルパーです。モデル呼び出しの前に、必ず業務データへのアクセス権を確認します。

// 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-solgpt-6-luna がOpenAI APIに追加された
  • Solは複雑なコーディングや判断を含む仕事、Lunaは高頻度な分類・要約に向く
  • Next.js側でモデルルーティングを関数化し、理由もログに残す
  • Firestoreには本文ではなく、モデル名、利用量、レイテンシ、評価に必要なメタデータを保存する
  • GitHub Actionsでモデル指定の増加を検知すると、運用ルールが崩れにくい

20〜30件のタスクを小さく流して比較すれば、「この処理はLunaで十分」「このレビューはSolに寄せる」といった判断がチームの知見になります。モデル選びを感覚ではなく、アプリの観測データで育てていきましょう。