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…

Claude Code mods: tweaked copies of community mods, as a plugin marketplace.
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:
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.
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.
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.
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).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:
| Command | What it does |
|---|---|
/listen setup | One time: a Python environment in ~/.claude-live-voice (with engine local, also faster-whisper and the Whisper model, about 600 MB) |
/listen | Start or stop listening |
/listen engine local | Whisper and a Windows voice instead of OpenAI; engine openai switches back |
/listen language en | The language you speak and hear (nl by default, auto to detect) |
/listen voice cedar | Another voice: an OpenAI voice (marin, cedar, coral, sage, …), or a Windows voice with engine local |
/listen speak off | Stop reading replies aloud |
/listen device jabra | Use another microphone, by part of its name or its number |
/listen model medium | With 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.
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
| Command | What it does |
|---|---|
/gpt login | Opens your browser: sign in and allow plan usage. The tokens stay in ~/.config/claude-openai-models/credentials.json |
/gpt models | The models your account can use |
/gpt picker | Adds 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 off | Claude answers again (/gpt on brings back the last model) |
/gpt agents follow · /gpt agents claude | Subagents answer with whoever answers the loop that starts them (the default), or always run on Claude |
/gpt usage | GPT's requests and tokens in this session, and the last error from OpenAI |
/gpt schemas | Reads Claude Code's tool schemas again (it also does at every session start) |
/gpt status · /gpt logout | Who is signed in and who answers · forget the tokens |
How it works:
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./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.$.model.complete naming a ChatGPT model, and its $.model.fork while GPT answers, go to OpenAI too./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.Where it stops:
/gpt python <path> for another); scripts/gpt.py uses only its standard library.MIT. cache-tax is © 2026 Karan Bansal; see plugins/cache-tax/LICENSE. live-voice is © 2026 Guido Vermeulen.
hooks/register.tsx 627 lines1import { 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}
627hooks/tool-schemas.ts 2274 lines1// 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 lines1// 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}
471types/index.d.ts 11 lines1/** 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