SLOPSHOPPER

openai-models

OpenAI models in Claude Code on your ChatGPT plan: Sign in with ChatGPT, /gpt picker lists them in the /model picker, and the one you pick answers the…

newbandguardcommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · openai-models
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /gpt ⎿ openai-models: status unknown: exit 0 · answering: Claude · subagents: follow · this session: 0 requests · 0 in (0 cached) · ⎿ openai-models: /gpt login · /gpt models · /gpt picker [off] · /gpt use <model> · /gpt off · /gpt agents follow|claude · /gpt ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Claude Code mods: tweaked copies of community mods, as a plugin marketplace.

cache-tax, with a Send anyway button

A copy of cache-tax 2.2.1 by Karan Bansal. The original drops a prompt sent on a cold prompt cache and asks you to send it again to pay. This copy asks first, in Claude Code's own dialog:

  • Send anyway ($x) lets the same prompt through as your own message. keepwarm then holds the cache for 3 hours.
  • Don't send drops it, nothing paid.

The question says what each answer saves. Closing the dialog without an answer falls back to the original behaviour: refused once, the resend pays.

/cache-tax guard test asks the question on your next prompt without a cold cache, priced at the session's context, so you can see it. Nothing is scored.

Everything else (/keepwarm, /cache-tax, the band above the prompt) works as in the original README.

Install

Needs Claude Code 2.1.287 or later.

claude plugin marketplace add grvermeulen/claude-mods
claude plugin install cache-tax@guido-mods

If the original is installed, turn it off, or a cold send is guarded twice:

claude plugin disable cache-tax@claude-code-mods

Restart Claude Code afterwards.

live-voice: talk to Claude, hands free

Claude Code's own voice mode is hold-to-talk or tap-to-talk. live-voice keeps listening instead: say what you want, and after a pause of 1.5 seconds it is sent as your prompt. When Claude answers, the reply is read aloud (code and tables left out, up to 700 characters), then it listens again.

  • By default OpenAI hears and speaks: each utterance goes to gpt-4o-transcribe, and the reply is spoken by gpt-4o-mini-tts with the voice marin, streamed so it starts at once. Claude stays the one answering.
  • /listen engine local keeps everything on your machine instead: faster-whisper hears, a Windows voice speaks (Microsoft Frank for Dutch).
  • The microphone is paused while Claude works and while it talks, so it does not hear itself.
  • A band above the prompt shows what it is doing, with Stop talking and Off buttons.

Windows only for now. Needs Python 3.10 or later, and for OpenAI an API key in the environment variable OPENAI_API_KEY (or CHATGPT_API_KEY), set before Claude starts. OpenAI bills transcription and speech per minute of audio.

claude plugin install live-voice@guido-mods

Then, in Claude Code:

CommandWhat it does
/listen setupOne time: a Python environment in ~/.claude-live-voice (with engine local, also faster-whisper and the Whisper model, about 600 MB)
/listenStart or stop listening
/listen engine localWhisper and a Windows voice instead of OpenAI; engine openai switches back
/listen language enThe language you speak and hear (nl by default, auto to detect)
/listen voice cedarAnother voice: an OpenAI voice (marin, cedar, coral, sage, …), or a Windows voice with engine local
/listen speak offStop reading replies aloud
/listen device jabraUse another microphone, by part of its name or its number
/listen model mediumWith engine local: a larger, slower, more accurate Whisper model; run /listen setup again to download it

Anything near the microphone that sounds like speech becomes a prompt, with the permissions your session has. Turn it off when you are not talking to Claude.

openai-models: GPT in Claude Code, on your ChatGPT plan (experimental)

Signs in with your ChatGPT account through OpenAI's Sign in with ChatGPT for open-source and local apps, then lets an OpenAI model answer the turns of a Claude Code session in Claude's place. Usage counts against your ChatGPT plan, not an API key; ChatGPT's settings show it per app and can cap it.

claude plugin install openai-models@guido-mods
CommandWhat it does
/gpt loginOpens your browser: sign in and allow plan usage. The tokens stay in ~/.config/claude-openai-models/credentials.json
/gpt modelsThe models your account can use
/gpt pickerAdds them to the /model picker, after Claude's, through the modelPicker setting in your user settings; pick one and it answers the session, subagents included. /gpt picker off takes them out again
/gpt use <model>Without the picker: that model answers this session's main loop. A part of the ID will do (astra, 6.1-sol) when it names one model. It stays picked for the session across a plugin reload
/gpt offClaude answers again (/gpt on brings back the last model)
/gpt agents follow · /gpt agents claudeSubagents answer with whoever answers the loop that starts them (the default), or always run on Claude
/gpt usageGPT's requests and tokens in this session, and the last error from OpenAI
/gpt schemasReads Claude Code's tool schemas again (it also does at every session start)
/gpt status · /gpt logoutWho is signed in and who answers · forget the tokens

How it works:

  • Tools. GPT gets every tool the session can call. Claude Code does not hand a mod the tools' schemas, so the mod reads them from the types file Claude Code's plugin tooling writes (all 79 built-in tools on 2.1.295), refreshes them at session start, and ships a copy for when it finds none. A deferred MCP tool gets its schema the way Claude gets it: once ToolSearch has loaded it into the conversation, the mod takes the schema from there. A tool with no schema at all is reachable through one invoke_tool function by its name; Claude Code checks its arguments against the real schema. Names OpenAI does not accept (over 64 characters) are shortened and mapped back.
  • Permissions. Claude Code still runs every tool, with the same permission checks it gives Claude.
  • Subagents started while GPT answers run on GPT too (/gpt agents claude keeps them on Claude; a model the Agent call names stands). A subagent on GPT gets its own conversation and a role naming its type and task; for a plugin's, project's or your own agent, its definition file's instructions too.
  • Compaction of a conversation GPT answers is written by GPT. Another plugin's $.model.complete naming a ChatGPT model, and its $.model.fork while GPT answers, go to OpenAI too.
  • Context. Claude's thinking goes to GPT as text; PDFs as files, text documents as text; images in tool results as images. GPT's own reasoning is carried from step to step (encrypted, as OpenAI returns it), and Claude Code's effort setting becomes GPT's reasoning effort. A model that refuses one of these gets the request again without it, and the band says so.
  • Usage. The band and /gpt usage count GPT's requests and tokens; Claude Code's own cost line does not price GPT. Cache Tax leaves GPT's turns out of Claude's cache clock, size and cost.
  • Sign-in. The ID token's signature is checked against OpenAI's published keys, with its issuer, audience, nonce and expiry. No token is put in a URL or a log line, and on Windows the token folder is cut to your account alone.

Where it stops:

  • Auto mode. Claude Code's auto mode approves actions with Anthropic's server-side classifier, which judges only Claude's own requests. An action GPT asks for that would need that approval is refused, with the reason; the band warns about it. While GPT answers, use the default or accept-edits mode (Shift+Tab).
  • Built-in subagent types (Explore, Plan, general-purpose) on GPT get their role but not Claude Code's internal prompt for that type, which a mod cannot read.
  • Calls with no hook. A call Claude Code makes for itself without a hook a mod can answer still goes to Anthropic with the picked model's name, if Claude Code makes one.
  • Needs Python 3 on the PATH (/gpt python <path> for another); scripts/gpt.py uses only its standard library.

License

MIT. cache-tax is © 2026 Karan Bansal; see plugins/cache-tax/LICENSE. live-voice is © 2026 Guido Vermeulen.

Source 4 files
hooks/register.tsx 627 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, TurnStepChunk, TurnStepInput, TurnStepResult, TurnUsage } from 'claude-code'
3
4import { SHIPPED_SCHEMAS } from './tool-schemas'
5import { LineReader, StepTranslator, buildToolset, explain, harvestSchemas, instructionsFor, parseInfer, toInput, usageFrom } from './translate'
6import type { ApiMessage, InputItem, Toolset } from './translate'
7
8/** The display name of the model answering the main loop, '' while Claude does. */
9const answering = atom({ plugin: 'openai-models', key: 'answering' } as const, '')
10/** The band's second part: usage, and a warning when there is one. */
11const detail = atom({ plugin: 'openai-models', key: 'detail' } as const, '')
12
13type Model = { slug: string; name: string }
14type Usage = { requests: number; input: number; cached: number; output: number; lastError: string }
15/** follow: a subagent answers with whoever answers its parent; claude: subagents always run on Claude. */
16type AgentsMode = 'follow' | 'claude'
17
18export type State = {
19  isWindows: boolean
20  /** The Python that runs scripts/gpt.py; stdlib only, so any Python 3. */
21  python: string
22  home: string
23  /** Claude Code's config folder: the user settings and the installed plugins' record. */
24  configDir: string
25  settingsPath: string
26  cwd: string
27  sessionId: string
28  /** The model /gpt use picked for the main loop, over whatever the session runs. */
29  active: Model | null
30  /** The account's models as last listed; a step whose model is one of them goes to OpenAI. */
31  models: Model[] | null
32  agents: AgentsMode
33  /** Claude Code's tool argument schemas: refreshed at session start, else the shipped ones. */
34  schemas: Record<string, object>
35  /** Which model answers each loop ('main' or an agent's id) while GPT does. */
36  loops: Map<string, string>
37  /** Tool calls GPT asked for, for the permission check. */
38  gptCalls: Set<string>
39  permissionMode: string | undefined
40  usage: Usage
41  /** GPT's reasoning items by turn key, so a later step can send them back. */
42  reasoning: Record<string, { model: string; items: InputItem[]; at: number }>
43  /** The last Claude model the main loop ran, for subagents kept on Claude. */
44  lastClaude: string | null
45  /** Agent definitions read so far, by agent type. */
46  definitions: Map<string, string | null>
47}
48
49export function freshState(): State {
50  return {
51    isWindows: false, python: 'python3', home: '.', configDir: '.claude', settingsPath: '', cwd: '.', sessionId: '',
52    active: null, models: null, agents: 'follow', schemas: SHIPPED_SCHEMAS, loops: new Map(), gptCalls: new Set(),
53    permissionMode: undefined, usage: freshUsage(), reasoning: {}, lastClaude: null, definitions: new Map(),
54  }
55}
56
57function freshUsage(): Usage {
58  return { requests: 0, input: 0, cached: 0, output: 0, lastError: '' }
59}
60
61const HELP = '/gpt login · /gpt models · /gpt picker [off] · /gpt use <model> · /gpt off · /gpt agents follow|claude · ' +
62  '/gpt usage · /gpt schemas · /gpt status · /gpt logout · /gpt python <path>'
63
64/** How many turns' reasoning is kept between steps; older turns go back without theirs. */
65const REASONING_KEEP = 60
66
67export const AUTO_MODE_REASON = 'Claude Code is in auto mode, where Anthropic\'s server-side classifier approves actions, ' +
68  'and it judges only Claude\'s own requests, so it cannot approve one GPT asked for. Ask the user to switch the permission ' +
69  'mode (Shift+Tab) to default or accept edits while GPT answers, then try again.'
70
71const COMPACT_INSTRUCTIONS = 'Write a summary of the conversation so far that lets the work continue without it: the user\'s ' +
72  'requests and intent, key decisions and technical details, the files and code touched (with paths), errors and how they ' +
73  'were fixed, what is done, what is pending, and the task in progress. Be specific; this summary replaces the conversation.'
74const COMPACT_HEADER = 'This session is being continued from a previous conversation that ran out of context. ' +
75  'The summary below covers the earlier portion of the conversation.'
76
77/** Marks the /model picker rows this mod wrote, so it can find and replace only its own. */
78const PICKER_TAG = '(openai-models)'
79
80type PickerRow = { model: string; label?: string; description?: string; behavesAs?: string }
81
82/** The settings with this mod's picker rows replaced by `models`' (none for `off`), other rows kept. */
83export function withPickerRows(settings: Record<string, unknown>, models: readonly Model[]): Record<string, unknown> {
84  const picker = (settings.modelPicker ?? {}) as { options?: PickerRow[]; replaceBuiltInOptions?: boolean }
85  const others = (picker.options ?? []).filter(row => !row.description?.endsWith(PICKER_TAG))
86  const ours = models.map(m => ({ model: m.slug, label: `${m.name} (ChatGPT)`, description: `OpenAI on your ChatGPT plan ${PICKER_TAG}` }))
87  const options = [...others, ...ours]
88  const next = { ...settings }
89  if (options.length) next.modelPicker = { ...picker, options }
90  else delete next.modelPicker
91  return next
92}
93
94/** Exact IDs and names win; otherwise a part of the ID (astra, 6.1-sol) that names one model. */
95export function resolveModel(models: readonly Model[], wanted: string): Model[] {
96  const query = wanted.trim().toLowerCase()
97  if (!query) return []
98  const exact = models.find(m => m.slug.toLowerCase() === query || m.name.toLowerCase() === query)
99  if (exact) return [exact]
100  const suffix = models.filter(m => m.slug.toLowerCase().endsWith(`-${query}`))
101  return suffix.length ? suffix : models.filter(m => m.slug.toLowerCase().includes(query))
102}
103
104export function gptModel(s: State, model: string | undefined): Model | null {
105  return model ? s.models?.find(m => m.slug === model) ?? null : null
106}
107
108/** Which OpenAI model answers this step, if any: the step's own model when it is one of the account's, else /gpt use's on main. */
109export function routeFor(s: State, e: TurnStepInput): Model | null {
110  return gptModel(s, e.model) ?? (e.agentId ? null : s.active)
111}
112
113/** Claude Code's effort as OpenAI's reasoning effort; a thinking budget in tokens by its size. */
114export function effortFor(effort: TurnStepInput['effort']): string | undefined {
115  if (effort === undefined) return undefined
116  if (typeof effort === 'number') return effort <= 4000 ? 'low' : effort <= 16000 ? 'medium' : 'high'
117  return effort === 'max' ? 'xhigh' : effort
118}
119
120function kilo(n: number): string {
121  return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
122}
123
124export function usageText(u: Usage): string {
125  return `${u.requests} request${u.requests === 1 ? '' : 's'} · ${kilo(u.input + u.cached)} in (${kilo(u.cached)} cached) · ${kilo(u.output)} out`
126}
127
128function script(root: string): string {
129  return `${root}/scripts/gpt.py`
130}
131
132/** Runs one gpt.py command to its end; its JSON lines, parsed. */
133async function gpt($: EngineInterface, s: State, command: string[], timeoutMs = 60_000): Promise<Array<Record<string, unknown>>> {
134  const r = await $.process.run([s.python, '-I', script($.plugin.root), ...command], { timeoutMs })
135  const parsed: Array<Record<string, unknown>> = []
136  for (const line of r.stdout.split(/\r?\n/).filter(l => l.trim())) {
137    try {
138      parsed.push(JSON.parse(line))
139    } catch {
140      // Not one of gpt.py's lines.
141    }
142  }
143  if (!parsed.length) parsed.push({ type: 'error', code: 'local', message: (r.stderr.trim().split(/\r?\n/).at(-1) || `exit ${r.exitCode}`) })
144  return parsed
145}
146
147function errorText(line: Record<string, unknown> | undefined): string {
148  return String(line?.message ?? 'no answer from gpt.py')
149}
150
151/** One request to OpenAI without tool calls, its text gathered: for summaries and other plugins' completions. */
152async function complete($: EngineInterface, s: State, slug: string, instructions: string, input: InputItem[], tools: readonly object[] = []):
153  Promise<{ ok: true; text: string; usage: TurnUsage | null } | { ok: false; error: string }> {
154  const body = { model: slug, instructions, input, ...(tools.length ? { tools, tool_choice: 'none' } : {}) }
155  const r = await $.process.run([s.python, '-I', script($.plugin.root), 'infer'], { stdin: JSON.stringify(body), timeoutMs: 300_000 })
156  let text = ''
157  let usage: TurnUsage | null = null
158  let error = ''
159  for (const line of r.stdout.split(/\r?\n/)) {
160    const event = parseInfer(line)
161    if (event?.type === 'text') text += event.delta
162    else if (event?.type === 'done') usage = usageFrom(event.usage, slug)
163    else if (event?.type === 'error') error = explain(event.code, event.message)
164  }
165  if (error || !text) return { ok: false, error: error || r.stderr.trim().split(/\r?\n/).at(-1) || 'no text came back' }
166  await count($, s, usage, '')
167  return { ok: true, text, usage }
168}
169
170const ZERO_USAGE = { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }
171
172function modelUsage(usage: TurnUsage | null) {
173  return usage
174    ? { input_tokens: usage.input_tokens, output_tokens: usage.output_tokens, cache_read_input_tokens: usage.cache_read_input_tokens, cache_creation_input_tokens: 0 }
175    : ZERO_USAGE
176}
177
178/** A completion's outcome as $.model.complete and $.model.fork answer it. */
179export function completionValue(r: { ok: true; text: string; usage: TurnUsage | null } | { ok: false; error: string }) {
180  return r.ok
181    ? { isAnswered: true as const, text: r.text, usage: modelUsage(r.usage) }
182    : { isAnswered: false as const, reason: 'api-error' as const, status: null, error: 'unknown' as const, usage: ZERO_USAGE }
183}
184
185async function count($: EngineInterface, s: State, usage: TurnUsage | null, error: string) {
186  s.usage.requests += 1
187  if (usage) {
188    s.usage.input += usage.input_tokens
189    s.usage.cached += usage.cache_read_input_tokens
190    s.usage.output += usage.output_tokens
191  }
192  s.usage.lastError = error
193  await $.store.set(`usage:${s.sessionId}`, s.usage)
194  await refreshBand($, s)
195}
196
197async function refreshBand($: EngineInterface, s: State) {
198  const slug = s.loops.get('main') ?? s.active?.slug
199  const name = slug ? gptModel(s, slug)?.name ?? s.active?.name ?? slug : ''
200  await update($, answering, () => name)
201  const notes = [usageText(s.usage)]
202  if (s.permissionMode === 'auto') notes.push('auto mode cannot approve GPT\'s edits or commands: switch the mode with Shift+Tab')
203  if (s.usage.lastError) notes.push(`last error: ${s.usage.lastError}`)
204  await update($, detail, () => notes.join(' · '))
205}
206
207/** The model answering a loop with GPT: an agent's from its last step, main's also from /gpt use or the session's model. */
208async function loopModel($: EngineInterface, s: State, agentId: string | undefined): Promise<string | null> {
209  if (agentId) return s.loops.get(agentId) ?? null
210  return s.loops.get('main') ?? s.active?.slug ?? gptModel(s, await $.session.model())?.slug ?? null
211}
212
213/** The body of an agent definition file, its frontmatter left out. */
214export function definitionBody(text: string): string {
215  return text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '').trim()
216}
217
218/** An agent type's own instructions: a plugin's agents/<name>.md, or a project's or the user's. Null for a built-in. */
219async function agentDefinition($: EngineInterface, s: State, type: string): Promise<string | null> {
220  if (s.definitions.has(type)) return s.definitions.get(type) ?? null
221  const [plugin, name] = type.includes(':') ? type.split(':', 2) : [null, type]
222  const candidates: string[] = []
223  if (plugin) {
224    try {
225      const installed = JSON.parse(await $.fs.read(`${s.configDir}/plugins/installed_plugins.json`)) as
226        { plugins?: Record<string, Array<{ installPath?: string }>> }
227      for (const [id, entries] of Object.entries(installed.plugins ?? {})) {
228        if (id.split('@')[0] === plugin) for (const entry of entries) if (entry.installPath) candidates.push(`${entry.installPath}/agents/${name}.md`)
229      }
230    } catch {
231      // No record of installed plugins.
232    }
233  } else {
234    candidates.push(`${s.cwd}/.claude/agents/${name}.md`, `${s.configDir}/agents/${name}.md`)
235  }
236  let found: string | null = null
237  for (const path of candidates) {
238    if (await $.fs.exists(path)) {
239      found = definitionBody(await $.fs.read(path))
240      break
241    }
242  }
243  s.definitions.set(type, found)
244  return found
245}
246
247/** What a subagent running on GPT is told about itself: its type, its task, and its definition's own instructions. */
248async function agentRole($: EngineInterface, s: State, agent: { type: string; description: string } | undefined): Promise<string> {
249  const type = agent?.type ?? 'general-purpose'
250  const base = `You are a subagent of type "${type}"${agent?.description ? `, started for: ${agent.description}` : ''}. ` +
251    'Your task is the first user message. The agent that started you sees only your final message, ' +
252    'so end with a complete, self-contained report of what you found or did.'
253  const definition = await agentDefinition($, s, type)
254  return definition ? `${base}\n\n## Your instructions\n${definition}` : base
255}
256
257/** GPT's reasoning for a turn, when the same model wrote it. */
258function reasoningFor(s: State, key: string, slug: string): InputItem[] | undefined {
259  const entry = s.reasoning[key]
260  return entry && entry.model === slug ? entry.items : undefined
261}
262
263async function keepReasoning($: EngineInterface, s: State, key: string, slug: string, items: InputItem[]) {
264  if (!items.length) return
265  s.reasoning[key] = { model: slug, items, at: await $.clock.now() }
266  const keys = Object.keys(s.reasoning).sort((a, b) => s.reasoning[b].at - s.reasoning[a].at)
267  for (const old of keys.slice(REASONING_KEEP)) delete s.reasoning[old]
268  await $.store.set('reasoning', s.reasoning)
269}
270
271async function login($: EngineInterface, s: State): Promise<string> {
272  const stream = $.process.spawn({ argv: [s.python, '-I', script($.plugin.root), 'login'] })
273  void (async () => {
274    const lines = new LineReader()
275    try {
276      for await (const { stream: pipe, text } of stream) {
277        if (pipe === 'stderr') continue
278        for (const line of lines.push(text)) {
279          const event = JSON.parse(line) as Record<string, unknown>
280          if (event.type === 'open') {
281            $.ui.toast('Sign in with ChatGPT in the browser window that opened')
282            $.ui.log(`openai-models: if no browser opened, open this to sign in: ${String(event.url)}`)
283          } else if (event.type === 'signed_in') {
284            s.models = null
285            $.ui.log(event.plan
286              ? `openai-models: signed in as ${String(event.email)}. /gpt picker or /gpt use <model>.`
287              : `openai-models: signed in as ${String(event.email)}, but using your ChatGPT plan was not allowed.`)
288          } else if (event.type === 'error') {
289            $.ui.log(`openai-models: sign-in failed: ${errorText(event)}`)
290          }
291        }
292      }
293    } catch (err) {
294      $.ui.log(`openai-models: sign-in failed: ${err instanceof Error ? err.message : String(err)}`)
295    }
296  })()
297  return 'Opening your browser to sign in with ChatGPT. You sign in and allow plan usage there; this waits up to five minutes.'
298}
299
300async function listModels($: EngineInterface, s: State): Promise<Model[] | string> {
301  const [line] = await gpt($, s, ['models'])
302  if (line?.type !== 'models') return errorText(line)
303  s.models = (line.models as Model[]) ?? []
304  await $.store.set('models', s.models)
305  return s.models
306}
307
308async function picker($: EngineInterface, s: State, isOff: boolean): Promise<string> {
309  const models = isOff ? [] : await listModels($, s)
310  if (typeof models === 'string') return `could not list your models: ${models}`
311  let settings: Record<string, unknown> = {}
312  if (await $.fs.exists(s.settingsPath)) {
313    try {
314      settings = JSON.parse(await $.fs.read(s.settingsPath)) as Record<string, unknown>
315    } catch {
316      return `${s.settingsPath} is not valid JSON; fix it first, nothing was changed`
317    }
318  }
319  await $.fs.write(s.settingsPath, JSON.stringify(withPickerRows(settings, models), null, 2) + '\n')
320  return isOff
321    ? 'The ChatGPT models are off the /model picker.'
322    : `${models.length} ChatGPT models added to the /model picker, after Claude's. Picking one makes it answer this session, ` +
323      'subagents included; pick a Claude model to go back. Restart Claude if the picker does not show them yet.'
324}
325
326function autoModeNote(s: State): string {
327  return s.permissionMode === 'auto' ? `\nNote: ${AUTO_MODE_REASON}` : ''
328}
329
330async function use($: EngineInterface, s: State, wanted: string): Promise<string> {
331  const models = s.models ?? (await listModels($, s))
332  if (typeof models === 'string') return `could not list your models: ${models}`
333  if (!models.length) return 'your ChatGPT account lists no models for this app'
334  let query = wanted
335  if (!query) {
336    if (models.length > 4) return `which one? /gpt use <model>, one of: ${models.map(m => m.slug).join(', ')}`
337    query = await $.ui.ask('Which OpenAI model should answer this session?', { header: 'Model', options: models.map(m => m.name) })
338  }
339  const matches = resolveModel(models, query)
340  if (matches.length > 1) return `"${query}" fits ${matches.map(m => m.slug).join(', ')}; name one of them`
341  const model = matches[0]
342  if (!model) return `not one of your models: ${query}. Yours: ${models.map(m => m.slug).join(', ')}`
343  s.active = model
344  await $.store.set('last', model.slug)
345  await $.store.set(`active:${s.sessionId}`, model.slug)
346  await refreshBand($, s)
347  return `${model.name} now answers this session's main loop, with Claude Code's tools` +
348    `${s.agents === 'follow' ? ' and the subagents it starts' : '; subagents stay on Claude'}. /gpt off goes back to Claude.${autoModeNote(s)}`
349}
350
351async function off($: EngineInterface, s: State): Promise<string> {
352  if (!s.active) return 'Claude is already answering (a ChatGPT model picked in /model is changed there)'
353  const name = s.active.name
354  s.active = null
355  s.loops.delete('main')
356  await $.store.delete(`active:${s.sessionId}`)
357  await refreshBand($, s)
358  return `Claude answers again (was ${name})`
359}
360
361async function status($: EngineInterface, s: State): Promise<string> {
362  const [line] = await gpt($, s, ['status'])
363  const who = line?.type === 'status'
364    ? line.signed_in ? `signed in as ${String(line.email)}${line.plan ? '' : ' (plan usage not allowed)'}` : 'not signed in (/gpt login)'
365    : `status unknown: ${errorText(line)}`
366  const main = await loopModel($, s, undefined)
367  return `${who} · answering: ${main ? gptModel(s, main)?.name ?? main : 'Claude'} · subagents: ${s.agents} · ` +
368    `this session: ${usageText(s.usage)}${autoModeNote(s)}`
369}
370
371async function refreshSchemas($: EngineInterface, s: State): Promise<string> {
372  const [line] = await gpt($, s, ['schemas', '--plugin', $.plugin.root], 30_000)
373  if (line?.type !== 'schemas') return `kept the ${Object.keys(s.schemas).length} tool schemas in use: ${errorText(line)}`
374  const record = JSON.parse(await $.fs.read(String(line.out))) as { schemas?: Record<string, object> }
375  if (record.schemas && Object.keys(record.schemas).length) s.schemas = record.schemas
376  return `${Object.keys(s.schemas).length} tool schemas read from ${String(line.source)}`
377}
378
379async function command($: EngineInterface, s: State, args: string): Promise<string> {
380  const [word, ...rest] = args.trim().split(/\s+/).filter(Boolean)
381  const arg = rest.join(' ')
382  switch (word) {
383    case 'login':
384      return login($, s)
385    case 'logout': {
386      await off($, s)
387      const [line] = await gpt($, s, ['logout'])
388      return line?.type === 'signed_out' ? 'signed out of ChatGPT' : `sign-out failed: ${errorText(line)}`
389    }
390    case 'models': {
391      const models = await listModels($, s)
392      if (typeof models === 'string') return `could not list your models: ${models}`
393      return models.length ? models.map(m => `${m.slug}  ${m.name}`).join('\n') : 'no models listed for this account'
394    }
395    case 'picker':
396      return picker($, s, arg === 'off')
397    case 'use':
398      return use($, s, arg)
399    case 'on':
400      return use($, s, String((await $.store.get('last')) ?? ''))
401    case 'off':
402      return off($, s)
403    case 'agents':
404      if (arg !== 'follow' && arg !== 'claude') return `subagents: ${s.agents}. /gpt agents follow (they answer with whoever answers their parent) or claude (always Claude)`
405      s.agents = arg
406      await $.store.set('agents', arg)
407      return arg === 'follow' ? 'Subagents now answer with whoever answers the loop that starts them.' : 'Subagents now always run on Claude.'
408    case 'usage':
409      return `GPT in this session: ${usageText(s.usage)}${s.usage.lastError ? ` · last error: ${s.usage.lastError}` : ''}. ` +
410        'It counts against your ChatGPT plan; ChatGPT settings → Usage shows the limit. Claude Code\'s own cost line does not price GPT.'
411    case 'schemas':
412      return refreshSchemas($, s)
413    case 'python':
414      if (!arg) return `gpt.py runs with ${s.python}; /gpt python <path> picks another`
415      s.python = arg
416      await $.store.set('python', arg)
417      return `gpt.py now runs with ${arg}`
418    case 'status':
419    case undefined:
420      return `${await status($, s)}\n${HELP}`
421    default:
422      return HELP
423  }
424}
425
426/** One step, of the main loop or a subagent's, answered by an OpenAI model instead of Claude. */
427async function* answer($: EngineInterface, s: State, model: Model, e: TurnStepInput): AsyncGenerator<TurnStepChunk, TurnStepResult> {
428  const [messages, composed, tools, agents] = await Promise.all([
429    e.agentId ? $.session.messages({ as: 'api', agentId: e.agentId }) : $.session.messages({ as: 'api' }),
430    $.prompt.compose(),
431    $.tool.list(),
432    e.agentId ? $.agent.list() : Promise.resolve([]),
433  ])
434  if (!Array.isArray(messages)) {
435    const failing = new StepTranslator(e.turnId, e.index, model.slug)
436    yield* failing.fail('this subagent\'s conversation cannot be read; give it a Claude model')
437    const { stop, result } = failing.finish()
438    yield stop
439    return result
440  }
441  const api = messages as unknown as ApiMessage[]
442  const toolset: Toolset = buildToolset(tools, s.schemas, harvestSchemas(api))
443  const role = e.agentId ? await agentRole($, s, agents.find(a => a.id === e.agentId)) : undefined
444  const effort = effortFor(e.effort)
445  const body = {
446    model: model.slug,
447    instructions: instructionsFor(composed.sections, toolset, model.name, role),
448    input: toInput(api, { toolset, reasoning: key => reasoningFor(s, key, model.slug) }),
449    tools: toolset.tools,
450    tool_choice: 'auto',
451    parallel_tool_calls: true,
452    include: ['reasoning.encrypted_content'],
453    ...(effort ? { reasoning: { effort } } : {}),
454  }
455  const step = new StepTranslator(e.turnId, e.index, model.slug, toolset)
456  const lines = new LineReader()
457  let errors = ''
458  try {
459    const child = $.process.spawn({ argv: [s.python, '-I', script($.plugin.root), 'infer'], input: JSON.stringify(body) })
460    for await (const { stream, text } of child) {
461      if (stream === 'stderr') {
462        errors += text
463        continue
464      }
465      for (const line of lines.push(text)) {
466        const event = parseInfer(line)
467        if (event) yield* step.push(event)
468      }
469    }
470  } catch (err) {
471    errors += err instanceof Error ? err.message : String(err)
472  }
473  if (!step.isDone) yield* step.fail(errors.trim().split(/\r?\n/).at(-1) || 'gpt.py ended without an answer')
474  const { stop, result } = step.finish()
475  for (const id of step.callIds()) s.gptCalls.add(id)
476  for (const notice of step.notices) $.ui.log(`openai-models: ${notice}`)
477  await keepReasoning($, s, step.key(), model.slug, step.reasoning)
478  await count($, s, result.usage, /\[OpenAI: (.*)\]$/.exec(result.answer)?.[1] ?? '')
479  yield stop
480  return result
481}
482
483export const register: Register = on => {
484  const s = freshState()
485
486  on('session.start', async ($, e, next) => {
487    const started = await next(e)
488    s.isWindows = (await $.env.get('OS')) === 'Windows_NT'
489    s.cwd = e.cwd
490    s.sessionId = await $.session.id()
491    const python = await $.store.get('python')
492    s.python = typeof python === 'string' && python ? python : s.isWindows ? 'python' : 'python3'
493    const models = await $.store.get('models')
494    s.models = Array.isArray(models) ? (models as Model[]) : null
495    const agents = await $.store.get('agents')
496    s.agents = agents === 'claude' ? 'claude' : 'follow'
497    const reasoning = await $.store.get('reasoning')
498    s.reasoning = reasoning && typeof reasoning === 'object' ? (reasoning as State['reasoning']) : {}
499    const usage = await $.store.get(`usage:${s.sessionId}`)
500    s.usage = usage && typeof usage === 'object' ? { ...freshUsage(), ...(usage as Usage) } : freshUsage()
501    // A reload starts the module afresh; the model /gpt use picked for this session comes back.
502    const active = await $.store.get(`active:${s.sessionId}`)
503    s.active = (typeof active === 'string' && gptModel(s, active)) || null
504    s.home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || '.'
505    s.configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) || `${s.home}/.claude`
506    s.settingsPath = `${s.configDir}/settings.json`
507    await $.command.register({
508      name: 'gpt',
509      description: 'OpenAI models on your ChatGPT plan: sign in, pick a model to answer this session, or go back to Claude (openai-models)',
510      argumentHint: '[login | models | picker [off] | use <model> | on | off | agents follow|claude | usage | schemas | status | logout]',
511      immediate: true,
512    })
513    await refreshBand($, s)
514    void refreshSchemas($, s).catch(() => undefined)
515    return started
516  })
517
518  on('command.run', { command: 'gpt' }, async ($, e) => ({ text: await command($, s, String(e.args ?? '')) }))
519
520  // A step whose model is one of the account's (picked in /model, or given to a subagent) goes to
521  // OpenAI; with /gpt use, so does every step of the main loop.
522  on('turn.step', async function* ($, e, next) {
523    const loop = e.agentId ?? 'main'
524    const model = routeFor(s, e)
525    if (!model) {
526      if (s.loops.delete(loop) && !e.agentId) await refreshBand($, s)
527      if (!e.agentId) s.lastClaude = e.model
528      return yield* next(e)
529    }
530    if (s.loops.get(loop) !== model.slug) {
531      s.loops.set(loop, model.slug)
532      if (!e.agentId) await refreshBand($, s)
533    }
534    return yield* answer($, s, model, e)
535  })
536
537  // A subagent follows whoever answers the loop that starts it, or stays on Claude, as /gpt agents says.
538  // An explicit model in the Agent call, and a fork (which always inherits), are left alone.
539  on('agent.spawn', async ($, e, next) => {
540    if (e.model || e.fork) return next(e)
541    const parent = await loopModel($, s, e.parentAgentId)
542    const parentIsGpt = Boolean(gptModel(s, e.parentModel))
543    if (s.agents === 'follow' && parent && !parentIsGpt) return next({ ...e, model: parent })
544    if (s.agents === 'claude' && parentIsGpt) return next({ ...e, model: s.lastClaude ?? 'sonnet' })
545    return next(e)
546  })
547
548  // A conversation GPT answers is summarized by GPT when it compacts.
549  on('session.compact', async ($, e, next) => {
550    const slug = await loopModel($, s, e.agentId)
551    if (!slug) return next(e)
552    if (e.trigger === 'precompute') return { skip: 'GPT answers this conversation; it is summarized when it compacts, not ahead of time' }
553    const messages = e.agentId ? await $.session.messages({ as: 'api', agentId: e.agentId }) : await $.session.messages({ as: 'api' })
554    if (!Array.isArray(messages)) return next(e)
555    const api = messages as unknown as ApiMessage[]
556    const toolset = buildToolset(await $.tool.list(), s.schemas, harvestSchemas(api))
557    const ask = `${COMPACT_INSTRUCTIONS}${e.instructions ? `\n\nAlso: ${e.instructions}` : ''}`
558    const r = await complete($, s, slug, 'You summarize conversations between a user and a coding agent.',
559      [...toInput(api, { toolset }), { role: 'user', content: ask }], toolset.tools)
560    if (!r.ok) return { skip: `GPT could not summarize the conversation: ${r.error}` }
561    return { messages: [{ role: 'user', text: `${COMPACT_HEADER}\n\n${r.text}`, toolUses: [] }] }
562  })
563
564  // Another plugin's $.model.complete naming one of the account's models goes to OpenAI.
565  on('model.complete', async ($, e, next) => {
566    const model = gptModel(s, e.model)
567    if (!model) return next(e)
568    return { value: completionValue(await complete($, s, model.slug, e.system ?? '', [{ role: 'user', content: e.prompt }])) }
569  })
570
571  // A fork of the main thread (another plugin's $.model.fork) goes to the model answering it.
572  on('model.fork', async ($, e, next) => {
573    const slug = await loopModel($, s, undefined)
574    if (!slug) return next(e)
575    const api = (await $.session.messages({ as: 'api' })) as unknown as ApiMessage[]
576    if (!api.length) return { value: { isAnswered: false as const, reason: 'nothing-to-fork' as const } }
577    const toolset = buildToolset(await $.tool.list(), s.schemas, harvestSchemas(api))
578    const composed = await $.prompt.compose()
579    return {
580      value: completionValue(await complete($, s, slug, instructionsFor(composed.sections, toolset, slug),
581        [...toInput(api, { toolset }), { role: 'user', content: e.prompt }], toolset.tools)),
582    }
583  })
584
585  // The permission mode, as the classic hook events carry it, for the auto-mode check and the band.
586  on('classic.UserPromptSubmit', async ($, e, next) => {
587    if (e.permission_mode && e.permission_mode !== s.permissionMode) {
588      s.permissionMode = e.permission_mode
589      await refreshBand($, s)
590    }
591    return next(e)
592  })
593
594  on('classic.PreToolUse', async ($, e, next) => {
595    if (e.permission_mode) s.permissionMode = e.permission_mode
596    return next(e)
597  })
598
599  // In auto mode the server-side classifier decides an ask, and it cannot judge a call GPT made:
600  // such a call is refused with the reason and the way out, instead of failing without one.
601  on('tool.check', async ($, e, next) => {
602    const verdict = await next(e)
603    if (!e.tool_use_id || !s.gptCalls.has(e.tool_use_id) || s.permissionMode !== 'auto' || verdict.decision !== 'ask') return verdict
604    return { decision: 'deny' as const, reason: AUTO_MODE_REASON }
605  })
606
607  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
608    const rest = await next(e)
609    const name = await read($, answering)
610    if (!name || e.props.hasSurvey) return rest
611    const note = await read($, detail)
612    const { Box, Button, Text } = $.ui.resolve(e)
613    return (
614      <Box flexDirection="column">
615        {rest}
616        <Box flexDirection="row">
617          <Text>
618            <Text bold color="success">● {name}</Text>
619            <Text dimColor>{` answers on your ChatGPT plan · ${note} `}</Text>
620          </Text>
621          {s.active ? <Button key="claude" label="Back to Claude" onPress={() => off($, s)} /> : null}
622        </Box>
623      </Box>
624    )
625  })
626}
627
hooks/tool-schemas.ts 2274 lines
1// Generated by `scripts/ship-schemas.py` from the types file of Claude Code 2.1.295:
2// the structure of each tool's arguments, without Claude Code's own descriptions. The mod
3// reads the full schemas from the types file on this machine at session start; this copy
4// is what it falls back to when it finds none.
5
6export const SHIPPED_VERSION = "2.1.295"
7
8export const SHIPPED_SCHEMAS: Record<string, object> = {
9 "Agent": {
10  "type": "object",
11  "properties": {
12   "description": {
13    "type": "string"
14   },
15   "prompt": {
16    "type": "string"
17   },
18   "subagent_type": {
19    "type": "string"
20   },
21   "model": {
22    "type": "string",
23    "enum": [
24     "sonnet",
25     "opus",
26     "haiku",
27     "fable"
28    ]
29   },
30   "effort": {
31    "type": "string",
32    "enum": [
33     "low",
34     "medium",
35     "high",
36     "xhigh",
37     "max"
38    ]
39   },
40   "run_in_background": {
41    "type": "boolean"
42   },
43   "name": {
44    "type": "string"
45   },
46   "team_name": {
47    "type": "string"
48   },
49   "mode": {
50    "type": "string",
51    "enum": [
52     "acceptEdits",
53     "auto",
54     "bypassPermissions",
55     "default",
56     "dontAsk",
57     "plan"
58    ]
59   },
60   "isolation": {
61    "type": "string",
62    "enum": [
63     "worktree",
64     "remote"
65    ]
66   }
67  },
68  "required": [
69   "description",
70   "prompt"
71  ]
72 },
73 "AppifactRepl": {
74  "type": "object",
75  "properties": {
76   "skill": {
77    "type": "string"
78   },
79   "artifact": {
80    "type": "string"
81   },
82   "code": {
83    "type": "string"
84   }
85  },
86  "required": [
87   "code"
88  ]
89 },
90 "Artifact": {
91  "type": "object",
92  "properties": {
93   "action": {
94    "type": "string",
95    "enum": [
96     "publish",
97     "list",
98     "read",
99     "delete",
100     "open",
101     "pin",
102     "unpin",
103     "quickstart"
104    ]
105   },
106   "file_path": {
107    "type": "string"
108   },
109   "asset": {
110    "type": "boolean"
111   },
112   "file_paths": {
113    "type": "array",
114    "items": {
115     "type": "string"
116    }
117   },
118   "from_url": {
119    "type": "string"
120   },
121   "asset_ids": {
122    "type": "array",
123    "items": {
124     "type": "string"
125    }
126   },
127   "favicon": {
128    "type": "string"
129   },
130   "icon": {
131    "type": "string"
132   },
133   "files": {
134    "anyOf": [
135     {
136      "type": "array",
137      "items": {
138       "type": "object",
139       "properties": {
140        "path": {
141         "type": "string"
142        },
143        "contentType": {
144         "type": "string"
145        }
146       },
147       "required": [
148        "path"
149       ]
150      }
151     },
152     {
153      "type": "object"
154     }
155    ]
156   },
157   "root": {
158    "type": "string"
159   },
160   "pin": {
161    "type": "boolean"
162   },
163   "limit": {
164    "type": "number"
165   },
166   "scope": {
167    "type": "string",
168    "enum": [
169     "mine",
170     "shared",
171     "all",
172     "types",
173     "files",
174     "assets"
175    ]
176   },
177   "type_query": {
178    "type": "string"
179   },
180   "type": {
181    "type": "string"
182   },
183   "intent": {
184    "type": "string",
185    "enum": [
186     "document",
187     "slides",
188     "design",
189     "other"
190    ]
191   },
192   "design_systems": {
193    "type": "boolean"
194   },
195   "title": {
196    "type": "string"
197   },
198   "description": {
199    "type": "string"
200   },
201   "label": {
202    "type": "string"
203   },
204   "overwrite_unread": {
205    "type": "array",
206    "items": {
207     "type": "string"
208    }
209   },
210   "url": {
211    "type": "string"
212   },
213   "type_url": {
214    "type": "string"
215   },
216   "auto_open": {
217    "type": "string",
218    "enum": [
219     "at_create",
220     "after_first_write"
221    ]
222   },
223   "prompt": {
224    "type": "string"
225   },
226   "force": {
227    "type": "boolean"
228   },
229   "out_dir": {
230    "type": "string"
231   },
232   "path": {
233    "type": "string"
234   },
235   "paths": {
236    "type": "array",
237    "items": {
238     "type": "string"
239    }
240   },
241   "after": {
242    "type": "string"
243   },
244   "page": {
245    "type": "boolean"
246   },
247   "capabilities": {
248    "type": "object"
249   },
250   "contract": {
251    "anyOf": [
252     {
253      "const": "latest"
254     },
255     {
256      "type": "string"
257     }
258    ]
259   }
260  },
261  "required": []
262 },
263 "ArtifactCheck": {
264  "type": "object",
265  "properties": {
266   "action": {
267    "const": "verify"
268   }
269  },
270  "required": [
271   "action"
272  ]
273 },
274 "ArtifactComments": {
275  "type": "object",
276  "properties": {
277   "action": {
278    "type": "string",
279    "enum": [
280     "read",
281     "reply",
282     "resolve",
283     "watch"
284    ]
285   },
286   "url": {
287    "type": "string"
288   },
289   "thread_id": {
290    "type": "string"
291   },
292   "text": {
293    "type": "string"
294   },
295   "cursor": {
296    "type": "string"
297   },
298   "acknowledge_duplicate": {
299    "type": "boolean"
300   },
301   "on": {
302    "type": "boolean"
303   },
304   "replies": {
305    "type": "boolean"
306   }
307  },
308  "required": [
309   "action"
310  ]
311 },
312 "ArtifactData": {
313  "type": "object",
314  "properties": {
315   "action": {
316    "type": "string",
317    "enum": [
318     "get",
319     "list",
320     "query",
321     "set",
322     "update",
323     "delete",
324     "str_replace",
325     "batch",
326     "profiles"
327    ]
328   },
329   "url": {
330    "type": "string"
331   },
332   "writes": {
333    "type": "array",
334    "items": {
335     "type": "object",
336     "properties": {
337      "op": {
338       "type": "string",
339       "enum": [
340        "set",
341        "update",
342        "delete"
343       ]
344      },
345      "collection": {
346       "type": "string"
347      },
348      "doc_id": {
349       "type": "string"
350      },
351      "data": {
352       "type": "object"
353      },
354      "file_path": {
355       "type": "string"
356      },
357      "if_version": {
358       "type": "number"
359      }
360     },
361     "required": [
362      "op",
363      "collection",
364      "doc_id"
365     ]
366    }
367   },
368   "collection": {
369    "type": "string"
370   },
371   "ids": {
372    "type": "array",
373    "items": {
374     "type": "string"
375    }
376   },
377   "doc_id": {
378    "type": "string"
379   },
380   "query": {
381    "type": "object",
382    "properties": {
383     "where": {
384      "type": "array",
385      "items": {
386       "type": "array",
387       "items": {}
388      }
389     },
390     "order_by": {
391      "type": "object",
392      "properties": {
393       "field": {
394        "type": "string"
395       },
396       "direction": {
397        "type": "string",
398        "enum": [
399         "asc",
400         "desc"
401        ]
402       }
403      },
404      "required": [
405       "field"
406      ]
407     },
408     "limit": {
409      "type": "number"
410     },
411     "cursor": {
412      "type": "string"
413     }
414    },
415    "required": []
416   },
417   "field": {
418    "type": "string"
419   },
420   "old_str": {
421    "type": "string"
422   },
423   "new_str": {
424    "type": "string"
425   },
426   "replace_all": {
427    "type": "boolean"
428   },
429   "if_version": {
430    "type": "number"
431   },
432   "data": {
433    "type": "object"
434   },
435   "file_path": {
436    "type": "string"
437   },
438   "out_dir": {
439    "type": "string"
440   },
441   "as_level": {
442    "type": "string",
443    "enum": [
444     "view",
445     "interact",
446     "admin"
447    ]
448   }
449  },
450  "required": [
451   "action"
452  ]
453 },
454 "AskUserQuestion": {
455  "type": "object",
456  "properties": {
457   "questions": {
458    "type": "array",
459    "items": {
460     "type": "object",
461     "properties": {
462      "question": {
463       "type": "string"
464      },
465      "header": {
466       "type": "string"
467      },
468      "options": {
469       "type": "array",
470       "items": {
471        "type": "object",
472        "properties": {
473         "label": {
474          "type": "string"
475         },
476         "description": {
477          "type": "string"
478         },
479         "preview": {
480          "type": "string"
481         }
482        },
483        "required": [
484         "label",
485         "description"
486        ]
487       }
488      },
489      "multiSelect": {
490       "type": "boolean"
491      }
492     },
493     "required": [
494      "question",
495      "header",
496      "options",
497      "multiSelect"
498     ]
499    }
500   },
501   "answers": {
502    "type": "object"
503   },
504   "annotations": {
505    "type": "object"
506   },
507   "metadata": {
508    "type": "object",
509    "properties": {
510     "source": {
511      "type": "string"
512     }
513    },
514    "required": []
515   }
516  },
517  "required": [
518   "questions"
519  ]
520 },
521 "Bash": {
522  "type": "object",
523  "properties": {
524   "command": {
525    "type": "string"
526   },
527   "timeout": {
528    "type": "number"
529   },
530   "description": {
531    "type": "string"
532   },
533   "run_in_background": {
534    "type": "boolean"
535   },
536   "dangerouslyDisableSandbox": {
537    "type": "boolean"
538   }
539  },
540  "required": [
541   "command"
542  ]
543 },
544 "ClaudeDesign": {
545  "type": "object",
546  "properties": {
547   "operation": {
548    "type": "string"
549   },
550   "arguments": {
551    "type": "object"
552   }
553  },
554  "required": [
555   "operation",
556   "arguments"
557  ]
558 },
559 "CronCreate": {
560  "type": "object",
561  "properties": {
562   "cron": {
563    "type": "string"
564   },
565   "prompt": {
566    "type": "string"
567   },
568   "recurring": {
569    "type": "boolean"
570   },
571   "durable": {
572    "type": "boolean"
573   }
574  },
575  "required": [
576   "cron",
577   "prompt"
578  ]
579 },
580 "CronDelete": {
581  "type": "object",
582  "properties": {
583   "id": {
584    "type": "string"
585   }
586  },
587  "required": [
588   "id"
589  ]
590 },
591 "CronList": {
592  "type": "object"
593 },
594 "DesignSync": {
595  "type": "object",
596  "properties": {
597   "method": {
598    "type": "string",
599    "enum": [
600     "list_projects",
601     "get_project",
602     "list_files",
603     "get_file",
604     "finalize_plan",
605     "write_files",
606     "delete_files",
607     "register_assets",
608     "unregister_assets",
609     "create_project",
610     "report_validate"
611    ]
612   },
613   "projectId": {
614    "type": "string"
615   },
616   "path": {
617    "type": "string"
618   },
619   "writes": {
620    "type": "array",
621    "items": {
622     "type": "string"
623    }
624   },
625   "deletes": {
626    "type": "array",
627    "items": {
628     "type": "string"
629    }
630   },
631   "planId": {
632    "type": "string"
633   },
634   "files": {
635    "type": "array",
636    "items": {
637     "type": "object",
638     "properties": {
639      "path": {
640       "type": "string"
641      },
642      "localPath": {
643       "type": "string"
644      },
645      "data": {
646       "type": "string"
647      },
648      "encoding": {
649       "const": "base64"
650      },
651      "mimeType": {
652       "type": "string"
653      }
654     },
655     "required": [
656      "path"
657     ]
658    }
659   },
660   "paths": {
661    "type": "array",
662    "items": {
663     "type": "string"
664    }
665   },
666   "name": {
667    "type": "string"
668   },
669   "assets": {
670    "type": "array",
671    "items": {
672     "type": "object",
673     "properties": {
674      "name": {
675       "type": "string"
676      },
677      "path": {
678       "type": "string"
679      },
680      "subtitle": {
681       "type": "string"
682      },
683      "viewport": {
684       "type": "object",
685       "properties": {
686        "width": {
687         "type": "number"
688        },
689        "height": {
690         "type": "number"
691        }
692       },
693       "required": [
694        "width"
695       ]
696      },
697      "group": {
698       "type": "string"
699      }
700     },
701     "required": [
702      "name",
703      "path"
704     ]
705    }
706   },
707   "localDir": {
708    "type": "string"
709   },
710   "counts": {
711    "type": "object",
712    "properties": {
713     "total": {
714      "type": "number"
715     },
716     "bad": {
717      "type": "number"
718     },
719     "thin": {
720      "type": "number"
721     },
722     "variantsIdentical": {
723      "type": "number"
724     },
725     "iterations": {
726      "type": "number"
727     }
728    },
729    "required": [
730     "total",
731     "bad",
732     "thin",
733     "variantsIdentical",
734     "iterations"
735    ]
736   }
737  },
738  "required": [
739   "method"
740  ]
741 },
742 "Edit": {
743  "type": "object",
744  "properties": {
745   "file_path": {
746    "type": "string"
747   },
748   "old_string": {
749    "type": "string"
750   },
751   "new_string": {
752    "type": "string"
753   },
754   "replace_all": {
755    "type": "boolean"
756   }
757  },
758  "required": [
759   "file_path",
760   "old_string",
761   "new_string"
762  ]
763 },
764 "enable__mcp__claude-in-chrome": {
765  "type": "object",
766  "properties": {
767   "task": {
768    "type": "string"
769   }
770  },
771  "required": []
772 },
773 "enable__mcp__remote-devices__Claude_Browser": {
774  "type": "object",
775  "properties": {
776   "task": {
777    "type": "string"
778   }
779  },
780  "required": []
781 },
782 "enable__mcp__remote-devices__computer": {
783  "type": "object",
784  "properties": {
785   "task": {
786    "type": "string"
787   }
788  },
789  "required": []
790 },
791 "EndConversation": {
792  "type": "object"
793 },
794 "EnterPlanMode": {
795  "type": "object"
796 },
797 "EnterWorktree": {
798  "type": "object",
799  "properties": {
800   "name": {
801    "type": "string"
802   },
803   "path": {
804    "type": "string"
805   }
806  },
807  "required": []
808 },
809 "ExitPlanMode": {
810  "type": "object",
811  "properties": {
812   "allowedPrompts": {
813    "type": "array",
814    "items": {
815     "type": "object",
816     "properties": {
817      "tool": {
818       "const": "Bash"
819      },
820      "prompt": {
821       "type": "string"
822      }
823     },
824     "required": [
825      "tool",
826      "prompt"
827     ]
828    }
829   }
830  },
831  "required": []
832 },
833 "ExitWorktree": {
834  "type": "object",
835  "properties": {
836   "action": {
837    "type": "string",
838    "enum": [
839     "keep",
840     "remove"
841    ]
842   },
843   "discard_changes": {
844    "type": "boolean"
845   }
846  },
847  "required": [
848   "action"
849  ]
850 },
851 "FetchInboxMessage": {
852  "type": "object",
853  "properties": {
854   "file_id": {
855    "type": "string"
856   }
857  },
858  "required": [
859   "file_id"
860  ]
861 },
862 "GetTask": {
863  "type": "object",
864  "properties": {
865   "taskId": {
866    "type": "string"
867   }
868  },
869  "required": [
870   "taskId"
871  ]
872 },
873 "Glob": {
874  "type": "object",
875  "properties": {
876   "pattern": {
877    "type": "string"
878   },
879   "path": {
880    "type": "string"
881   }
882  },
883  "required": [
884   "pattern"
885  ]
886 },
887 "Grep": {
888  "type": "object",
889  "properties": {
890   "pattern": {
891    "type": "string"
892   },
893   "path": {
894    "type": "string"
895   },
896   "glob": {
897    "type": "string"
898   },
899   "output_mode": {
900    "type": "string",
901    "enum": [
902     "content",
903     "files_with_matches",
904     "count"
905    ]
906   },
907   "-B": {
908    "type": "number"
909   },
910   "-A": {
911    "type": "number"
912   },
913   "-C": {
914    "type": "number"
915   },
916   "context": {
917    "type": "number"
918   },
919   "-n": {
920    "type": "boolean"
921   },
922   "-i": {
923    "type": "boolean"
924   },
925   "-o": {
926    "type": "boolean"
927   },
928   "type": {
929    "type": "string"
930   },
931   "head_limit": {
932    "type": "number"
933   },
934   "offset": {
935    "type": "number"
936   },
937   "multiline": {
938    "type": "boolean"
939   }
940  },
941  "required": [
942   "pattern"
943  ]
944 },
945 "ListAgents": {
946  "type": "object",
947  "properties": {
948   "channel": {
949    "type": "string"
950   },
951   "q": {
952    "type": "string"
953   }
954  },
955  "required": []
956 },
957 "ListConnectors": {
958  "type": "object",
959  "properties": {
960   "keywords": {
961    "type": "array",
962    "items": {
963     "type": "string"
964    }
965   }
966  },
967  "required": []
968 },
969 "ListMcpResourcesTool": {
970  "type": "object",
971  "properties": {
972   "server": {
973    "type": "string"
974   }
975  },
976  "required": []
977 },
978 "ListPlugins": {
979  "type": "object",
980  "properties": {
981   "keywords": {
982    "type": "array",
983    "items": {
984     "type": "string"
985    }
986   }
987  },
988  "required": []
989 },
990 "ListSkills": {
991  "type": "object",
992  "properties": {
993   "keywords": {
994    "type": "array",
995    "items": {
996     "type": "string"
997    }
998   }
999  },
1000  "required": []
1001 },
1002 "LSP": {
1003  "type": "object",
1004  "properties": {
1005   "operation": {
1006    "type": "string",
1007    "enum": [
1008     "goToDefinition",
1009     "findReferences",
1010     "hover",
1011     "documentSymbol",
1012     "workspaceSymbol",
1013     "goToImplementation",
1014     "prepareCallHierarchy",
1015     "incomingCalls",
1016     "outgoingCalls"
1017    ]
1018   },
1019   "filePath": {
1020    "type": "string"
1021   },
1022   "line": {
1023    "type": "number"
1024   },
1025   "character": {
1026    "type": "number"
1027   },
1028   "query": {
1029    "type": "string"
1030   }
1031  },
1032  "required": [
1033   "operation",
1034   "filePath",
1035   "line",
1036   "character"
1037  ]
1038 },
1039 "memory_list": {
1040  "type": "object",
1041  "properties": {
1042   "store": {
1043    "type": "string"
1044   },
1045   "path_prefix": {
1046    "type": "string"
1047   },
1048   "cursor": {
1049    "type": "string"
1050   }
1051  },
1052  "required": []
1053 },
1054 "memory_read": {
1055  "type": "object",
1056  "properties": {
1057   "store": {
1058    "type": "string"
1059   },
1060   "path": {
1061    "type": "string"
1062   }
1063  },
1064  "required": [
1065   "store",
1066   "path"
1067  ]
1068 },
1069 "memory_write": {
1070  "type": "object",
1071  "properties": {
1072   "store": {
1073    "type": "string"
1074   },
1075   "path": {
1076    "type": "string"
1077   },
1078   "content": {
1079    "type": "string"
1080   },
1081   "if_version": {
1082    "type": "string"
1083   }
1084  },
1085  "required": [
1086   "store",
1087   "path",
1088   "content",
1089   "if_version"
1090  ]
1091 },
1092 "Monitor": {
1093  "type": "object",
1094  "properties": {
1095   "description": {
1096    "type": "string"
1097   },
1098   "timeout_ms": {
1099    "type": "number"
1100   },
1101   "command": {
1102    "type": "string"
1103   },
1104   "ws": {
1105    "type": "object",
1106    "properties": {
1107     "url": {
1108      "type": "string"
1109     },
1110     "protocols": {
1111      "type": "array",
1112      "items": {
1113       "type": "string"
1114      }
1115     }
1116    },
1117    "required": [
1118     "url"
1119    ]
1120   }
1121  },
1122  "required": [
1123   "description",
1124   "timeout_ms"
1125  ]
1126 },
1127 "NotebookEdit": {
1128  "type": "object",
1129  "properties": {
1130   "notebook_path": {
1131    "type": "string"
1132   },
1133   "cell_id": {
1134    "type": "string"
1135   },
1136   "new_source": {
1137    "type": "string"
1138   },
1139   "cell_type": {
1140    "type": "string",
1141    "enum": [
1142     "code",
1143     "markdown"
1144    ]
1145   },
1146   "edit_mode": {
1147    "type": "string",
1148    "enum": [
1149     "replace",
1150     "insert",
1151     "delete"
1152    ]
1153   }
1154  },
1155  "required": [
1156   "notebook_path",
1157   "new_source"
1158  ]
1159 },
1160 "OfferChromeSetup": {
1161  "type": "object",
1162  "properties": {
1163   "reason": {
1164    "type": "string"
1165   }
1166  },
1167  "required": []
1168 },
1169 "Poll": {
1170  "type": "object"
1171 },
1172 "PowerShell": {
1173  "type": "object",
1174  "properties": {
1175   "command": {
1176    "type": "string"
1177   },
1178   "timeout": {
1179    "type": "number"
1180   },
1181   "description": {
1182    "type": "string"
1183   },
1184   "run_in_background": {
1185    "type": "boolean"
1186   },
1187   "dangerouslyDisableSandbox": {
1188    "type": "boolean"
1189   }
1190  },
1191  "required": [
1192   "command"
1193  ]
1194 },
1195 "Projects": {
1196  "type": "object",
1197  "properties": {
1198   "method": {
1199    "type": "string",
1200    "enum": [
hooks/translate.ts 471 lines
1// Claude Code's side in, OpenAI's Responses API out, and back: the conversation as
2// Responses input, Claude Code's tools as function schemas, and gpt.py's stream events as
3// the step chunks the engine draws and runs.
4
5import type { TurnStepChunk, TurnStepResult, TurnStopReason, TurnUsage } from 'claude-code'
6
7export type ApiBlock = { type: string } & Record<string, unknown>
8export type ApiMessage = { role: 'user' | 'assistant'; content: ApiBlock[] }
9export type InputItem = Record<string, unknown>
10export type FunctionTool = { type: 'function'; name: string; description: string; parameters: object; strict: false }
11export type ToolInfo = { name: string; description: string; mcp?: boolean }
12
13/** The function GPT calls a tool through when that tool is not offered as a function of its own. */
14export const INVOKE_TOOL = 'invoke_tool'
15const DESCRIPTION_CHARS = 1000
16const MAX_NATIVE_TOOLS = 120
17const CATALOGUE_CHARS = 8000
18const GUIDANCE_CHARS = 60_000
19const THINKING_CHARS = 4000
20
21/** FNV-1a, 32 bits, as 8 hex digits: a short, stable name for a long one. */
22export function fnv(text: string): string {
23  let hash = 0x811c9dc5
24  for (let i = 0; i < text.length; i++) {
25    hash ^= text.charCodeAt(i)
26    hash = Math.imul(hash, 0x01000193) >>> 0
27  }
28  return hash.toString(16).padStart(8, '0')
29}
30
31/** A name OpenAI takes for a function (letters, digits, _ and -, at most 64), the same every time. */
32export function safeName(name: string): string {
33  if (/^[A-Za-z0-9_-]{1,64}$/.test(name) && name !== INVOKE_TOOL) return name
34  return `t${fnv(name)}_${name.replace(/[^A-Za-z0-9_-]/g, '_').slice(-50)}`
35}
36
37function shorten(text: string, max: number): string {
38  if (text.length <= max) return text
39  const cut = text.slice(0, max)
40  const end = cut.lastIndexOf('. ')
41  return end > max / 2 ? cut.slice(0, end + 1) : cut
42}
43
44function firstSentence(text: string): string {
45  const line = text.split('\n').find(l => l.trim()) ?? ''
46  const end = line.search(/\.(\s|$)/)
47  return (end > 0 ? line.slice(0, end + 1) : line).slice(0, 160).trim()
48}
49
50function textOf(content: unknown): string {
51  if (typeof content === 'string') return content
52  if (!Array.isArray(content)) return ''
53  return content.map(block => (block?.type === 'text' ? String(block.text ?? '') : '')).filter(Boolean).join('\n')
54}
55
56export type Harvested = Map<string, { description: string; parameters: object }>
57
58/**
59 * The schemas ToolSearch has loaded into the conversation: its results carry each tool as
60 * `<function>{"name", "description", "parameters"}</function>`, which is how a deferred MCP
61 * tool's schema reaches the model at all.
62 */
63export function harvestSchemas(messages: readonly ApiMessage[]): Harvested {
64  const found: Harvested = new Map()
65  for (const message of messages) {
66    if (message.role !== 'user') continue
67    for (const block of message.content) {
68      if (block.type !== 'tool_result') continue
69      for (const match of textOf(block.content).matchAll(/<function>([\s\S]*?)<\/function>/g)) {
70        try {
71          const spec = JSON.parse(match[1]) as { name?: string; description?: string; parameters?: object }
72          if (spec.name && spec.parameters) found.set(spec.name, { description: spec.description ?? '', parameters: spec.parameters })
73        } catch {
74          // Not a schema.
75        }
76      }
77    }
78  }
79  return found
80}
81
82export type Toolset = {
83  tools: FunctionTool[]
84  /** The name GPT sees → the tool's own. */
85  real: Map<string, string>
86  /** The tool's own name → the name GPT sees. */
87  safe: Map<string, string>
88  /** Tools offered as functions of their own. */
89  native: Set<string>
90  /** Tools GPT reaches through invoke_tool. */
91  catalogue: Set<string>
92  /** Tools whose description was cut, for the full text in the instructions. */
93  shortened: ToolInfo[]
94}
95
96/**
97 * Every tool the model can call now, as GPT gets it: with a schema (Claude Code's own for a
98 * built-in, ToolSearch's for an MCP tool) as a function of its own; without one, by name
99 * through invoke_tool, whose arguments Claude Code checks against the real schema.
100 */
101export function buildToolset(available: readonly ToolInfo[], schemas: Record<string, object>, harvested: Harvested): Toolset {
102  const set: Toolset = { tools: [], real: new Map(), safe: new Map(), native: new Set(), catalogue: new Set(), shortened: [] }
103  for (const tool of available) {
104    const parameters = harvested.get(tool.name)?.parameters ?? schemas[tool.name]
105    if (!parameters || set.tools.length >= MAX_NATIVE_TOOLS) {
106      set.catalogue.add(tool.name)
107      continue
108    }
109    const name = safeName(tool.name)
110    const description = tool.description || harvested.get(tool.name)?.description || ''
111    set.real.set(name, tool.name)
112    set.safe.set(tool.name, name)
113    set.native.add(tool.name)
114    set.tools.push({ type: 'function', name, description: shorten(description, DESCRIPTION_CHARS), parameters, strict: false })
115    if (description.length > DESCRIPTION_CHARS) set.shortened.push({ name: tool.name, description })
116  }
117  if (set.catalogue.size) {
118    let list = ''
119    for (const name of set.catalogue) {
120      const line = `\n- ${name}: ${firstSentence(available.find(t => t.name === name)?.description ?? '')}`
121      if (list.length + line.length > CATALOGUE_CHARS) {
122        list += `\n- … and ${set.catalogue.size - list.split('\n- ').length + 1} more; ToolSearch loads any tool's schema by name`
123        break
124      }
125      list += line
126    }
127    set.tools.push({
128      type: 'function',
129      name: INVOKE_TOOL,
130      description: 'Calls one of Claude Code\'s tools that is not offered as a function of its own, by its exact name, ' +
131        'with its arguments as an object. Claude Code checks them against the tool\'s real schema and answers with an error ' +
132        'naming what is wrong; ToolSearch with "select:<name>" shows a tool\'s full schema first. Tools reachable this way:' + list,
133      parameters: {
134        type: 'object',
135        properties: {
136          tool: { type: 'string', enum: [...set.catalogue], description: 'The tool\'s exact name' },
137          input: { type: 'object', description: 'The tool\'s arguments' },
138        },
139        required: ['tool', 'input'],
140      },
141      strict: false,
142    })
143    set.real.set(INVOKE_TOOL, INVOKE_TOOL)
144  }
145  return set
146}
147
148/** The system prompt for GPT: its role (for a subagent), Claude Code's own prompt, who answers, and full tool guidance. */
149export function instructionsFor(sections: readonly { text: string }[], toolset: Toolset, model: string, role?: string): string {
150  const guidance: string[] = []
151  let size = 0
152  for (const tool of toolset.shortened) {
153    const text = `### ${tool.name}\n${tool.description}`
154    if (size + text.length > GUIDANCE_CHARS) break
155    guidance.push(text)
156    size += text.length
157  }
158  return [
159    ...(role ? [`# Your role\n${role}`] : []),
160    ...sections.map(section => section.text),
161    `# Who answers\nThis session runs on ${model}, an OpenAI model, in place of Claude. Where the text above says Claude, ` +
162    'it means you. Call the functions to use tools; Claude Code runs them, may ask the user first, and returns their results.',
163    ...(guidance.length ? ['# Full tool guidance', ...guidance] : []),
164  ].join('\n\n')
165}
166
167function imageUrl(block: ApiBlock): string | null {
168  const source = block.source as { type?: string; media_type?: string; data?: string; url?: string } | undefined
169  if (source?.type === 'base64' && source.data) return `data:${source.media_type ?? 'image/png'};base64,${source.data}`
170  if (source?.type === 'url' && source.url) return source.url
171  return null
172}
173
174/** A document block as Responses input: a PDF as a file, text as text, a URL as a file by URL. */
175export function documentPart(block: ApiBlock): InputItem {
176  const source = (block.source ?? {}) as { type?: string; media_type?: string; data?: unknown; url?: string; content?: unknown }
177  const title = typeof block.title === 'string' ? block.title : ''
178  if (source.type === 'base64' && typeof source.data === 'string') {
179    return { type: 'input_file', filename: title || 'document.pdf', file_data: `data:${source.media_type ?? 'application/pdf'};base64,${source.data}` }
180  }
181  if (source.type === 'text' && typeof source.data === 'string') return { type: 'input_text', text: `${title ? `Document: ${title}\n` : ''}${source.data}` }
182  if (source.type === 'url' && source.url) return { type: 'input_file', file_url: source.url }
183  if (source.type === 'content') return { type: 'input_text', text: `${title ? `Document: ${title}\n` : ''}${textOf(source.content)}` }
184  return { type: 'input_text', text: '[a document was attached here]' }
185}
186
187/** Content blocks as Responses content parts. */
188function parts(blocks: readonly ApiBlock[]): InputItem[] {
189  const out: InputItem[] = []
190  for (const block of blocks) {
191    if (block.type === 'text') out.push({ type: 'input_text', text: String(block.text ?? '') })
192    else if (block.type === 'image') {
193      const url = imageUrl(block)
194      out.push(url ? { type: 'input_image', image_url: url } : { type: 'input_text', text: '[image omitted]' })
195    } else if (block.type === 'document') out.push(documentPart(block))
196  }
197  return out
198}
199
200/** A tool result as a function output: a string, or parts when it holds images or files. */
201function output(block: ApiBlock): string | InputItem[] {
202  const error = block.is_error ? 'Error: ' : ''
203  if (typeof block.content === 'string') return error + block.content
204  const content = parts((block.content ?? []) as ApiBlock[])
205  if (content.every(p => p.type === 'input_text')) return error + content.map(p => String(p.text)).join('\n')
206  return error ? [{ type: 'input_text', text: 'Error:' }, ...content] : content
207}
208
209export type InputContext = {
210  toolset: Toolset
211  /** GPT's own reasoning items for an assistant turn, kept between steps, by turn key. */
212  reasoning?: (key: string) => InputItem[] | undefined
213}
214
215/** How an assistant turn's reasoning is filed: by its first tool call, else by its text. */
216export function turnKey(callIds: readonly string[], text: string): string {
217  return callIds.length ? `call:${callIds[0]}` : `text:${fnv(text.trim())}`
218}
219
220/**
221 * The conversation as Responses input items. Calls to tools GPT has as functions keep their
222 * native form, as do calls through invoke_tool; anything else becomes text. GPT's own
223 * reasoning items go back in front of the turn they led to; Claude's thinking goes in as text.
224 */
225export function toInput(messages: readonly ApiMessage[], ctx: InputContext): InputItem[] {
226  const { toolset } = ctx
227  const items: InputItem[] = []
228  const asText = new Map<string, string>()
229  for (const message of messages) {
230    if (message.role === 'assistant') {
231      const out: InputItem[] = []
232      const callIds: string[] = []
233      let text = ''
234      let said = ''
235      const flush = () => {
236        if (text.trim()) out.push({ role: 'assistant', content: text.trim() })
237        text = ''
238      }
239      for (const block of message.content) {
240        if (block.type === 'thinking' && typeof block.thinking === 'string' && block.thinking.trim()) {
241          text += `[earlier reasoning]\n${block.thinking.trim().slice(0, THINKING_CHARS)}\n[/earlier reasoning]\n`
242        } else if (block.type === 'text') {
243          text += String(block.text ?? '')
244          said += String(block.text ?? '')
245        } else if (block.type === 'tool_use') {
246          const id = String(block.id)
247          const name = String(block.name)
248          const args = block.input ?? {}
249          callIds.push(id)
250          if (toolset.native.has(name)) {
251            flush()
252            out.push({ type: 'function_call', call_id: id, name: toolset.safe.get(name) ?? name, arguments: JSON.stringify(args) })
253          } else if (toolset.catalogue.has(name)) {
254            flush()
255            out.push({ type: 'function_call', call_id: id, name: INVOKE_TOOL, arguments: JSON.stringify({ tool: name, input: args }) })
256          } else {
257            asText.set(id, name)
258            text += `\n[called the tool ${name} with ${JSON.stringify(args)}]\n`
259          }
260        }
261      }
262      flush()
263      items.push(...(ctx.reasoning?.(turnKey(callIds, said)) ?? []), ...out)
264      continue
265    }
266    const content: InputItem[] = []
267    for (const block of message.content) {
268      if (block.type === 'tool_result') {
269        const id = String(block.tool_use_id)
270        if (asText.has(id)) {
271          const result = output(block)
272          content.push({ type: 'input_text', text: `[result of the tool ${asText.get(id)}]\n${typeof result === 'string' ? result : textOf(block.content)}` })
273        } else {
274          items.push({ type: 'function_call_output', call_id: id, output: output(block) })
275        }
276      } else {
277        content.push(...parts([block]))
278      }
279    }
280    if (content.length) items.push({ role: 'user', content })
281  }
282  return items
283}
284
285/** One line of `gpt.py infer`, or null for anything else. */
286export type InferEvent =
287  | { type: 'text'; item: string; delta: string }
288  | { type: 'tool'; item: string; call_id: string; name: string }
289  | { type: 'args'; item: string; delta: string }
290  | { type: 'reasoning'; item: InputItem }
291  | { type: 'notice'; message: string }
292  | { type: 'done'; status: string; reason: string | null; usage: OpenAiUsage | null }
293  | { type: 'error'; code: string; message: string; relogin?: boolean }
294
295export type OpenAiUsage = { input_tokens?: number; output_tokens?: number; input_tokens_details?: { cached_tokens?: number } }
296
297export function parseInfer(line: string): InferEvent | null {
298  try {
299    const value = JSON.parse(line) as { type?: unknown }
300    return ['text', 'tool', 'args', 'reasoning', 'notice', 'done', 'error'].includes(String(value.type)) ? (value as InferEvent) : null
301  } catch {
302    return null
303  }
304}
305
306export function usageFrom(usage: OpenAiUsage | null, model: string): TurnUsage | null {
307  if (!usage) return null
308  const cached = usage.input_tokens_details?.cached_tokens ?? 0
309  return {
310    input_tokens: Math.max(0, (usage.input_tokens ?? 0) - cached),
311    output_tokens: usage.output_tokens ?? 0,
312    cache_read_input_tokens: cached,
313    cache_creation_input_tokens: 0,
314    model,
315  }
316}
317
318/** What an error code means for the person, by OpenAI's Errors and recovery page. */
319export function explain(code: string, message: string): string {
320  if (code === 'not_signed_in' || code === 'subscription_sharing_invalid_user' || code === 'http_401') return 'not signed in to ChatGPT: /gpt login'
321  if (code === 'subscription_sharing_usage_limit_exceeded') return 'your ChatGPT plan\'s usage limit for this app is reached (ChatGPT settings → Usage); /gpt off to go back to Claude'
322  if (code === 'subscription_sharing_usage_unavailable' || code === 'subscription_sharing_user_unavailable') return 'ChatGPT plan usage is unavailable right now; try again shortly, or /gpt off'
323  if (code === 'subscription_sharing_user_not_eligible') return 'this ChatGPT account cannot use its plan in other apps; /gpt off'
324  return `${message} (${code})`
325}
326
327/** Turns gpt.py's stream for one step into the engine's chunks and, at the end, the step's result. */
328export class StepTranslator {
329  private nextIndex = 0
330  private readonly indexOf = new Map<string, number>()
331  private readonly calls: Array<{ id: string; name: string; json: string }> = []
332  private readonly callByItem = new Map<string, { id: string; name: string; json: string }>()
333  /** invoke_tool calls, held until their arguments name the tool. */
334  private readonly invokes: Array<{ item: string; id: string; json: string }> = []
335  private answer = ''
336  private stopReason: TurnStopReason = 'end_turn'
337  private usage: TurnUsage | null = null
338  readonly reasoning: InputItem[] = []
339  readonly notices: string[] = []
340  isDone = false
341
342  constructor(private readonly turnId: string, private readonly index: number, private readonly model: string,
343    private readonly toolset?: Toolset) {}
344
345  private blockFor(item: string): number {
346    let index = this.indexOf.get(item)
347    if (index === undefined) {
348      index = this.nextIndex++
349      this.indexOf.set(item, index)
350    }
351    return index
352  }
353
354  push(event: InferEvent): TurnStepChunk[] {
355    switch (event.type) {
356      case 'text':
357        this.answer += event.delta
358        return [{ kind: 'text', index: this.blockFor(event.item), text: event.delta }]
359      case 'reasoning':
360        this.reasoning.push(event.item)
361        return []
362      case 'notice':
363        this.notices.push(event.message)
364        return []
365      case 'tool': {
366        if (event.name === INVOKE_TOOL) {
367          this.invokes.push({ item: event.item, id: event.call_id, json: '' })
368          return []
369        }
370        const call = { id: event.call_id, name: this.toolset?.real.get(event.name) ?? event.name, json: '' }
371        this.calls.push(call)
372        this.callByItem.set(event.item, call)
373        return [{ kind: 'tool', index: this.blockFor(event.item), id: call.id, name: call.name }]
374      }
375      case 'args': {
376        const held = this.invokes.find(c => c.item === event.item)
377        if (held) {
378          held.json += event.delta
379          return []
380        }
381        const call = this.callByItem.get(event.item)
382        if (call) call.json += event.delta
383        return [{ kind: 'input', index: this.blockFor(event.item), json: event.delta }]
384      }
385      case 'done': {
386        this.isDone = true
387        this.usage = usageFrom(event.usage, this.model)
388        const chunks = this.flushInvokes()
389        this.stopReason = this.calls.length ? 'tool_use'
390          : event.status === 'incomplete' && event.reason === 'max_output_tokens' ? 'max_tokens' : 'end_turn'
391        return chunks
392      }
393      case 'error':
394        return this.fail(explain(event.code, event.message))
395    }
396  }
397
398  /** The invoke_tool calls, now that their arguments are whole, as calls to the tools they name. */
399  private flushInvokes(): TurnStepChunk[] {
400    const chunks: TurnStepChunk[] = []
401    for (const held of this.invokes.splice(0)) {
402      let spec: { tool?: unknown; input?: unknown } = {}
403      try {
404        spec = JSON.parse(held.json || '{}')
405      } catch {
406        // Handled below as a call that names no tool.
407      }
408      if (typeof spec.tool !== 'string' || !spec.tool) {
409        const text = '\n[GPT called invoke_tool without naming a tool]'
410        this.answer += text
411        chunks.push({ kind: 'text', index: this.blockFor(held.item), text })
412        continue
413      }
414      const input = JSON.stringify(spec.input && typeof spec.input === 'object' ? spec.input : {})
415      const index = this.blockFor(held.item)
416      this.calls.push({ id: held.id, name: spec.tool, json: input })
417      chunks.push({ kind: 'tool', index, id: held.id, name: spec.tool }, { kind: 'input', index, json: input })
418    }
419    return chunks
420  }
421
422  /** Ends the step with the reason as text the person reads, and no tool calls. */
423  fail(reason: string): TurnStepChunk[] {
424    this.isDone = true
425    this.calls.length = 0
426    this.callByItem.clear()
427    this.invokes.length = 0
428    this.stopReason = 'end_turn'
429    const text = `${this.answer ? '\n\n' : ''}[OpenAI: ${reason}]`
430    this.answer += text
431    return [{ kind: 'text', index: this.blockFor('openai-models:error'), text }]
432  }
433
434  /** The tool calls GPT made, by id, for the permission check. */
435  callIds(): string[] {
436    return this.calls.map(c => c.id)
437  }
438
439  /** Where this step's reasoning is filed, matching turnKey on the transcript later. */
440  key(): string {
441    return turnKey(this.callIds(), this.answer)
442  }
443
444  finish(): { stop: TurnStepChunk; result: TurnStepResult } {
445    const toolUses = this.calls.map(call => {
446      let input: unknown = {}
447      try {
448        input = call.json ? JSON.parse(call.json) : {}
449      } catch {
450        // Broken JSON leaves the call without arguments, as a model's broken JSON would.
451      }
452      return { name: call.name, input }
453    })
454    return {
455      stop: { kind: 'stop', stopReason: this.stopReason, usage: this.usage },
456      result: { turnId: this.turnId, index: this.index, answer: this.answer, toolUses, stopReason: this.stopReason, usage: this.usage },
457    }
458  }
459}
460
461/** Cuts a stream of text pieces into whole lines; a piece may end mid-line. */
462export class LineReader {
463  private rest = ''
464
465  push(piece: string): string[] {
466    const lines = (this.rest + piece).split(/\r?\n/)
467    this.rest = lines.pop() ?? ''
468    return lines.filter(line => line.trim() !== '')
469  }
470}
471
types/index.d.ts 11 lines
1/** The display name of the OpenAI model answering the main loop, '' while Claude does. */
2export type Answering = string
3/** The band's second part: GPT's usage this session, and a warning when there is one. */
4export type Detail = string
5
6declare module 'claude-code' {
7  interface PluginState {
8    'openai-models': { answering: Answering; detail: Detail }
9  }
10}
11