Claude Media
Claude Code modのmodel.completeで別の質問を投げる

Claude Code modのmodel.completeで別の質問を投げる

Claude Code modの$.model.completeは、会話とは別に1回だけモデルへ質問し、セッションの認証で答えを受け取るAPIです。maxTokens、isAnswered、v2.1.292のキャッシュまでまとめます。

$.model.completeは、Claude Codeのmodから会話の外で1回だけモデルに質問するための呼び出しです。ログイン中のプランやAPIキーがそのまま使われるので、modの側にキーを持たせる必要はありません。履歴を持たない単発の問いなので、ラベル付けや要約のような小さな仕事に向きます。v2.1.292からは、promptとsystemにテキストのブロックを渡し、ブロックのcache: trueでそこまでをキャッシュできます。

この記事では、呼び出しの形、戻り値の読み方、止まりやすい条件、v2.1.292のキャッシュ、テストでの差し替えを順に扱います。modの作り方そのものはClaude Codeのmodを80行で作る手順にあります。

$.model.completeは何を送り、何を送らないか

modのフックが受け取る$には、$.modelという名前空間があります。$.model.completeはそのうち、1つのプロンプトをモデルに送って返信を受け取るメソッドです。

送られるのは、modが渡したpromptとsystemだけです。Claude Codeで進行中の会話は含まれません。同じ質問を会話の続きとして投げたいときは別のメソッドで、$.model.fork({ prompt })が現在の会話をそのまま土台にします。モデルとシステムプロンプトは会話と同じものを使うため、Claude APIがプロンプトキャッシュから大部分を返す、という説明です。

呼び出し会話の履歴向く用途
$.model.complete会話の履歴含まれない向く用途渡した文字列だけで完結する分類・要約・整形
$.model.fork会話の履歴現在の会話を土台にする向く用途今の文脈を踏まえた1問

どちらもユーザーのプランまたはAPIキーを使います。modを配布する側は、利用者の課金に乗る呼び出しだと理解して書くことになります。管理者向けのページでも、$.model.completeは「ユーザーのプランまたはAPIキーでモデルを呼ぶ」呼び出しとして、ファイルの読み書きやプロセス起動と並ぶ権限の一覧に載っています。

completeとforkの使い分け

$.model.forkは、書き方はcompleteに近く、promptを渡します。

await $.model.fork({ prompt: 'ここまでの作業で、まだ残っていることを1つ挙げて' })

「ここまでの作業」を指せるのは、会話を土台にするforkだけです。completeに同じ文を渡しても、モデルは会話を知らないので答えようがありません。逆に、/triageのようにコマンド引数の文字列だけで決まる仕事は、会話を連れていく必要がないのでcompleteが向きます。forkはモデルとシステムプロンプトが会話と同じで、大部分がプロンプトキャッシュから返ります。戻り値の形は、使っているバージョンの型定義で確かめてください。

最小の使い方 — /triageコマンド

ドキュメントの例は、/triageというコマンドの後ろに書かれたテキストを小さなモデルでラベル付けするものです。コマンドの登録は、session.startフックで$.command.registerを呼ぶ形です。その上で、command.runフックから$.model.completeを呼びます。

on('command.run', { command: 'triage' }, async ($, e) => {
  const r = await $.model.complete({
    model: 'haiku',
    // systemで役割を決め、promptに判定したい文章を渡す
    system: 'Reply with one word: bug, feature, or question.',
    prompt: e.args,
    // 1語で済むので出力は少なく、15秒で諦める
    maxTokens: 20,
    timeoutMs: 15000,
  })
  // r.textは答えが返ったときだけ存在するので、先にr.isAnsweredを見る
  const label = r.isAnswered ? r.text.trim() : 'unknown'
  return { text: 'Label: ' + label }
})

/triage the export button does nothingと打つと、modはその文字列だけをモデルに送り、Label: bugのような答えを表示します。Claudeの会話は送られません。答えが返らなければLabel: unknownです。

渡しているオプションは5つです。

オプション役割
model役割使うモデル。例ではhaiku
system役割役割や出力形式を決めるシステムプロンプト
prompt役割モデルに答えさせる本文
maxTokens役割返信の最大トークン数
timeoutMs役割呼び出しを諦めるまでの時間(ミリ秒)

このほかにeffortなどのオプションがあります。全項目は、modを--plugin-dirで読み込んだときに.claude-plugin/types/へ書き出される型定義ファイルに載っているので、使っているバージョンのものを開くのが確実です。

戻り値は「答えた」と「答えなかった」の2通り

$.model.completeは、Claude APIの側で失敗しても例外を投げません。代わりに、返ってきたオブジェクトのisAnsweredがfalseになり、理由がreasonに入ります。textが存在するのはisAnsweredがtrueのときだけです。

つまり、次の書き方はisAnsweredがfalseのときtextがundefinedになり、trim()の呼び出しで落ちます。

// 悪い例: 失敗時にr.textがundefinedになる
return { text: (await $.model.complete({ prompt: e.args })).text.trim() }

一方で、呼び出しそのものがrejectされる場合があります。ドキュメントの例は、組織がブロックしているモデルを指定したときのような、Claude Codeが送らないと決めている要求です。まとめると、失敗は2つの経路に分かれます。

  • 応答しなかった: 呼び出しは解決し、isAnsweredがfalseでreasonが付く
  • 要求自体が送れない: 呼び出しがrejectされる。tryとcatchで受ける

フックは例外を投げると読み飛ばされるため、rejectを放置すると、その後の処理も動きません。コマンドの返答はunknownのような既定値に落とし、原因は$.ui.logや$.ui.toastで見せる、という分け方が扱いやすいです。

2つの経路を両方受ける形にすると、/triageは次のようになります。

on('command.run', { command: 'triage' }, async ($, e) => {
  let r
  try {
    r = await $.model.complete({
      model: 'haiku',
      system: 'Reply with one word: bug, feature, or question.',
      prompt: e.args,
      maxTokens: 20,
      timeoutMs: 15000,
    })
  } catch (err) {
    // 要求自体が送れなかった経路(組織がブロックしたモデルなど)
    return { text: 'Label: unknown (request not sent)' }
  }
  // 応答しなかった経路は、rejectではなくisAnsweredで分かる
  if (!r.isAnswered) return { text: 'Label: unknown (' + r.reason + ')' }
  return { text: 'Label: ' + r.text.trim() }
})

tryで囲むのは呼び出しだけにして、isAnsweredの判定はcatchの外に置いています。reasonの値は型定義で確かめられるので、画面に出す文言は自分のmodに合わせて決めます。

返信が途中で切れるときはmaxTokensを疑う

maxTokensの既定は1024、上限は64,000か、そのモデルの出力上限のどちらか低いほうです。短いラベルなら既定で足りますが、長めの要約や整形を頼むと、1024トークンで打ち切られます。

返信が尻切れになるときは、まずmaxTokensを引き上げます。逆に、1語の分類のように出力が小さいと分かっている呼び出しでは、例のように20程度まで絞れます。上限を絞る側の利点は、想定外の長文が返ってきても課金が膨らまないことです。

もう1つ、時間の数え方にも注意が要ります。フックには1イベントあたり10秒の実行時間制限があります。ただし、nextや、$.clock.sleepを除くmods APIの呼び出しの待ち時間は、この10秒に数えません。モデルの返事を待つ時間でフックが読み飛ばされる、ということは通常起きません。待ちを区切りたいときは、timeoutMsで呼び出しごとに決めます。

手元のコマンドと組み合わせる — 差分の要約

$.model.completeは、$.process.runと組み合わせると使い道が広がります。例えば、作業ツリーの差分の概要をモデルに1行へ要約させるコマンドは、次のような形になります(ドキュメントの呼び出しを組み合わせた例で、公式のサンプルそのものではありません)。

on('command.run', { command: 'diffsum' }, async ($, e) => {
  // 引数リストで渡す。シェルは使われない
  const diff = await $.process.run(['git', 'diff', '--stat'])
  if (!diff.stdout.trim()) return { text: 'No changes' }
 
  const r = await $.model.complete({
    model: 'haiku',
    system: 'Summarize this git diff --stat in one short sentence.',
    prompt: diff.stdout,
    maxTokens: 120,
    timeoutMs: 20000,
  })
  return { text: r.isAnswered ? r.text.trim() : 'Summary unavailable: ' + r.reason }
})

$.process.runはプログラムが非ゼロで終了しても解決します。起動できなかったときと、タイムアウト(既定30秒)に達したときだけrejectされます。そのため、実際のmodではこの呼び出しもtryとcatchで囲みます。

/diffsumは、会話の外で差分の概要だけをモデルに渡すので、Claude本体の会話にはトークンを足しません。modが担うのは、会話の外の小さな補助作業です。ただし$.model.completeを呼ぶ以上、この要約の分は利用者のプランまたはAPIキーの使用量に乗ります。

使用量はusageで読める

isAnsweredがtrueの戻り値には、textのほかにusageが付きます。テスト用スタブの例では、input_tokens、output_tokens、cache_read_input_tokens、cache_creation_input_tokensの4つが並んでいます。modが何度もモデルを呼ぶ作りなら、このusageを足し合わせておくと、1回のコマンドでどれだけ使ったかを自分で把握できます。呼び出しは利用者のプランやAPIキーに乗るため、見えない課金を作らない意味でも役に立ちます。

v2.1.292のプロンプトキャッシュ

v2.1.292で、$.model.completeにプロンプトキャッシュが加わりました。変更点は2つです。

  • promptとsystemが、文字列だけでなくテキストのブロックを受け取れるようになった
  • ブロックにcache: trueを付けると、リクエストのそのブロックまでがキャッシュされる

効くのは、同じ長い前置きを何度も送る呼び出しです。例えば、systemに長い判定基準を入れ、promptだけを毎回変えて複数回呼ぶmodです。前置きの部分をキャッシュ対象にすれば、2回目以降はその分が読み取りになります。短い一回きりの呼び出しでは、効果はほとんどありません。

ブロックの具体的な書式は、modのAPIを説明するページにも変更履歴にも載っていません。書式は、使っているバージョンの型定義ファイルで確かめてください。

キャッシュの読み取りと書き込みのトークン数は、会話のリクエストならturn.stepフックのresult.usageにcache_read_input_tokensとcache_creation_input_tokensとして出ます。$.model.completeのテスト用スタブの例にも、同じ名前のフィールドを持つusageが含まれています。

テストでは固定の返信に差し替える

$.model.completeの呼び出しを含むmodは、claude plugin testでモデルを動かさずに検証できます。on('model.complete', …)のスタブが、呼び出しの代わりに答えます。スタブが返すのはvalueを持つオブジェクトで、成功なら{ isAnswered: true, text, usage }の形です。

スタブが返す値は、必ずvalueの下に入れます。valueもdenyも持たない裸の値を返すと、returned neither { value } nor { deny }というエラーでテストが落ちます。modが呼ぶ$.model.completeに対応するスタブが無い場合は、no implementation forで始まるエラーになります。スタブの登録は、テストの最初の$の呼び出しより前に済ませます。

isAnswered: falseを返すスタブを足せば、「答えが返らなかったときのunknown」の分岐も同じ方法で試せます。reasonの値はmodが画面に出すだけなので、スタブには型定義にある値を入れます。

test('an unanswered call falls back to unknown', async ($, on) => {
  // モデルが答えなかった場合を再現する
  on('model.complete', () => ({
    value: { isAnswered: false, reason: 'timeout' },
  }))
 
  const answer = await $.command.run({ command: 'triage', args: 'anything' })
  expect(answer.text).toContain('unknown')
})

要求自体が送れない経路は、同じスタブで() => ({ deny: 'the reason' })を返すと、modの$.model.completeがrejectされます。上のtryとcatchの側は、こちらで確かめます。テストの書き方の全体はmodのtestを書く記事にまとめてあります。

管理者に見える範囲と、割り込みの経路

$.model.completeは、$.fs.readや$.http.fetchと同じく、それ自体がイベントです。model.completeという名前のフックが、後ろで動くmodの呼び出しを横取りし、next(e)で通すか、{ deny: reason }で拒否できます。組織が利用可能なモデルを絞っていれば、それに合わない指定は先に述べたrejectになりえます。

組織側からは、modがどの呼び出しを使うかを導入前に確かめられます。

claude plugin validate ./some-mod

出力にはhooks:行とcalls:行が出て、modのコードが何をするかが読めます。

  ❯ ./register.js hooks: session.start, tool.call, ui.render{component=Pane}
  ❯ ./register.js calls: $.fs.read, $.http.fetch, $.store.set, $.ui.open

hooks:行はmodが受け取るイベント、calls:行はmodのコードが呼ぶmods APIです。上の出力は管理者向けページの例で、$.fs.readのように$.付きで並びます。$.model.completeを使うmodならcalls:行にそれが出るので、利用者の課金に乗るかどうかは動かす前に読み取れます。管理者向けの権限一覧では、同じ呼び出しが「ユーザーのプランまたはAPIキーでモデルを呼ぶ」権限として載り、$.fs.readや$.process.run、$.http.fetchと並んで確認の対象です。管理側の設定の話は、allowManagedModsOnlyの記事にまとめています。

使い始める前に決めておくこと

modへ組み込む前に決めておくと迷いにくい点は、次の5つです。

  1. モデル: 分類や整形は小さいモデルで足りるかを、まず試す
  2. maxTokens: 想定する返信の長さに合わせて、既定の1024のままでよいかを決める
  3. 失敗時の既定値: isAnsweredがfalseのときに何を返すか
  4. timeoutMs: コマンドの待たせ方に合う値。この記事の例では、1語を返す/triageが15秒、1文を返す/diffsumが20秒
  5. 繰り返し送る前置き: あるなら、キャッシュの対象にする

会話の文脈が要る問いなら$.model.fork、文字列だけで完結する問いなら$.model.complete、という振り分けを最初に決めておくと、後でmodを読み返すときにも意図が伝わります。v2.1.292でほかに何が入ったかは、v2.1.292のリリースノートで確かめられます。

この記事を共有:XはてブLinkedIn