Keeps the Gemini keys and tier of every Gemini mod, and each mod's model and thinking level, in one place: adds $.gemini, which builds their Gemini requests…

Several mods ask Gemini. When each keeps its own key, tier and model, a new key means one change per mod. This mod keeps the Gemini settings of every Gemini mod in one place: the key and the tier for all of them, and the model and the thinking level for each. It adds $.gemini to the engine interface; gemini-compact, gemini-advisor, gemini-review and gemini-plan-review depend on it and build their Gemini requests through it. council uses it when it is installed, without depending on it.
$.gemini.request builds the generateContent request from the mod's body: the key in the x-goog-api-key header, never in the URL, the mod's model, and its thinking level in generationConfig.thinkingConfig.thinkingLevel. Without a level the body is sent as it is, so the model uses its own default. A mod may name another model for one request (model); the mod's thinking level still applies to it.$.http.fetch, and $.gemini.read reads the answer: the text and the token counts, Gemini's error message, or a wait before the same request goes again after an HTTP 503 (1 s, 2 s, 3 s, at most four attempts, no attempt once the mod's deadline has passed). After an HTTP 429 or a key error it answers the same request with the next key, which the mod sends at once.A method on a plugin's noun must answer within 10 seconds (measured on 2.1.278: a 14-second fetch inside one was refused with did not answer within 10000ms). A Gemini request can take longer, so the request is sent by the mod and not inside $.gemini.
minimal, low, medium and high. Which levels a model takes differs per model, and the mod keeps no table of them: an unsupported level is Gemini's HTTP 400, which the mod that asked shows. Measured on 2.1.278:
| Model | minimal | low | medium | high |
|---|---|---|---|---|
gemini-3.8-flash | HTTP 400 "Thinking level MINIMAL is not supported for this model" | yes | yes | yes |
gemini-3.5-flash-lite | yes | yes | yes | yes |
The Gemini docs say Gemini 3.1 Pro takes no minimal either.
/gemini-core [status] tier, how many keys are set, each enrolled mod's model and thinking level /gemini-core free | paid the tier of every Gemini mod; free prints the warning below /gemini-core models [refresh] the Gemini text models the key lists /gemini-core model <mod> a pane to pick the mod's model from that list /gemini-core model <mod> <id> for example: model review gemini-3.7-flash; an id the list lacks is refused /gemini-core thinking <mod> <level|default> for example: thinking compact low; default drops the level /gemini-core reset the tier from the plugin option, each mod its default model and no level
A mod is named in full (gemini-review) or without gemini- (review). The settings are kept across sessions and take effect at the next request. A mod may hook gemini.configure to follow a change.
A mod that enrolls with ownModels names the model of each request itself, as council does with its members. The status shows it as council: models set by the mod · thinking model default, and model <mod> is refused for it without asking Google. Its thinking level is set here as for any mod.
The list comes from Google's models.list (GET /v1beta/models, the key in the x-goog-api-key header), asked the first time a command needs it and kept in memory for the session, again with models refresh. When a key fails, the next key is asked. It keeps the models that take generateContent and whose id starts with gemini-, and leaves out ids with tts or image, which answer with speech or pictures. On the checked key Google listed 58 models and 21 stayed (measured on 2.1.278). The filter reads names only, so a new model of another kind whose name does not say so stays in the list; Gemini's own error then reaches the mod that asks.
/gemini-core model <mod> opens a pane with a Select of the list, the mod's current model selected; Enter sets the pick, shows it in a toast and closes the pane, and Esc closes it with no change. In the terminal the Select draws a scrolling list of ten rows. A surface without a Select (mobile) shows the list and the command that sets one. An id given with the command is refused when the list lacks it, with the three closest ids:
gemini-9-flash is not a Gemini text model this key lists; closest: gemini-2.5-flash, gemini-3.5-flash, gemini-3.6-flash.
The apiKey option and GEMINI_API_KEY take a comma-separated list; the option wins when it is set. Blanks and repeated keys are dropped. The keys are tried in order:
all 34 keys failed: Gemini HTTP 429: quota (keys 1-4, 6-34); Gemini HTTP 400: API key not valid. (key 5).In a live check on 2.1.278 with an invalid key first and a working key second, the first review got HTTP 400 in 0.4 s and HTTP 200 from the second key; the next review asked the second key only.
Google applies the rate limits per project, not per key ("Rate limits are applied per project, not per API key", Gemini API rate limits), so two keys of one project share one quota. Keys of several projects used to add up free quota go against the Google APIs Terms of Service: "You agree to, and will not attempt to circumvent, such limitations documented with each API." A second key is for a key that stops working, or a paid key beside a free one. All keys share the one tier setting.
The Gemini mods send the conversation, tool outputs and diffs. The Gemini API Additional Terms say about the free tier: "Google uses the content you submit to the Services and any generated responses to provide, improve, and develop Google products and services", "human reviewers may read, annotate, and process your API input and output", and "Do not submit sensitive, confidential, or personal information to the Unpaid Services." On a project you would not show to Google, use a key with billing enabled and set /gemini-core paid. The mod cannot tell which tier a key is on; the tier only chooses the warning the mods show.
claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install gemini-core@kilimcininkoroglu-mods
A Gemini mod lists gemini-core in its dependencies, so installing one installs this one. Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.
no Gemini key: gemini-compact runs the built-in summary, gemini-review lets the commit run unreviewed, gemini-plan-review lets the plan reach you unreviewed, and the advisor call fails.~/.zshrc, ~/.bashrc), open a new terminal, then start Claude Code from it:export GEMINI_API_KEY="key1" export GEMINI_API_KEY="key1,key2" # several keys, tried in turn
/plugin configure flow, or at install time with claude plugin install gemini-core@kilimcininkoroglu-mods --config apiKey=..., which also leaves the key in your shell history./gemini-core. The first line names the tier and the number of keys, for example free tier · 2 keys, tried in turn. no key: set GEMINI_API_KEY ... means Claude Code did not get the key.free, and every Gemini mod then shows a free-tier warning. With a billing-enabled key run /gemini-core paid. When you mix a paid and a free key, put the paid key first, because one tier covers all keys./gemini-review on, /gemini-plan-review on, /gemini-advisor on, /gemini-compact on. Each is off after an install and sends nothing to Gemini until then; on is refused while this mod has no key.gemini-3.8-flash, the default of gemini-review, gemini-plan-review and gemini-advisor, and HTTP 200 for gemini-3.5-flash (measured). Pick another model with /gemini-core models and /gemini-core model <mod>.After an update from a Gemini mod that kept its own settings (gemini-review and gemini-advisor 0.1.x, gemini-compact 0.2.x): claude plugin update does not add gemini-core, so run claude plugin install gemini-core@kilimcininkoroglu-mods once. The mod's old apiKey, tier and model options and its stored free, paid and model settings are not read, so do steps 1 to 5 again.
| Option | Default | What it sets | |
|---|---|---|---|
apiKey | GEMINI_API_KEY | The Gemini API key of every Gemini mod, or several separated by commas, stored as a secret | |
tier | free | free or paid; `/gemini-core free\ | paid` overrides it |
The contract is types/index.d.ts. A mod that uses it:
const prepared = await $.gemini.request({ consumer: 'my-mod', body })
if ('error' in prepared) return fail(prepared.error)
const started = await $.clock.now()
let http = prepared.http
for (let attempt = 1; ; attempt++) {
const r = await $.http.fetch(http.url, http.init)
const read = await $.gemini.read({ http, status: r.status, ok: r.ok, text: r.text, attempt, elapsedMs: (await $.clock.now()) - started })
if ('answer' in read || 'error' in read) return read
if ('next' in read) http = read.next
else await $.clock.sleep(read.retryInMs)
}
request takes model to send one request to another model than the mod's. A mod that always does so enrolls with ownModels: true: $.gemini.enroll({ consumer: 'my-mod', defaultModel, ownModels: true }).
A hook's budget is 10 seconds, and $.clock.sleep counts against it while the wait of $.http.fetch does not (measured on 2.1.283). A mod that asks several models at once in one hook can resend after retryInMs without the wait, because the waits of parallel requests add up.
Validated with claude plugin validate on Claude Code 2.1.283:
❯ types ./types/index.d.ts declares on $: $.gemini ❯ ./register.ts hooks: engine.create, session.start, command.run{command=gemini-core}, ui.render{component=Pane}, ui.close ❯ ./register.ts calls: $.command.register, $.env.get, $.gemini.configure (via applyChange, pickModel), $.gemini.settings (via openPicker, statusOf), $.http.fetch (via listModels), $.store.delete, $.store.get, $.store.set, $.ui.close (via pickModel), $.ui.open (via openPicker), $.ui.resolve, $.ui.toast ❯ ./register.ts env writes: nothing ❯ ./register.ts env reads: GEMINI_API_KEY
Reach L3, reaches the network: /gemini-core models and a model change ask Google for the model list. The generateContent requests it builds carry the key to the mod that sends them.
$.gemini.request.tier and model) are not read.engine.create, so the tests build $.gemini on a store in memory, and the mods' tests answer gemini.* themselves.make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test
hooks/register.ts 270 lines1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2import type { Gemini, GeminiChange, GeminiEnroll, GeminiPrepared, GeminiRequest, GeminiSettings, GeminiTier } from '../types/index.d.ts'
3import { buildHttp, MODEL_ID, withThinking } from './api.ts'
4import { parseKeys, readWithKeys, type KeyState } from './keys.ts'
5import { modelsRequest, modelsText, parseModels, unknownModel, type ModelInfo } from './models.ts'
6import { listTree, PANE_ID, paneRows, pickerTree, type Pick } from './picker.tsx'
7import { consumersOf, FREE_WARNING, isThinking, isTier, KEYS, ownModelsText, parseCommand, resolveConsumer, statusText, type ConsumerLine } from './settings.ts'
8
9/**
10 * The store and environment the methods of `$.gemini` run on. The validator
11 * refuses `next(e)` at `engine.create` passed as an argument, so the hook wraps
12 * the calls it needs.
13 */
14export type Host = {
15 get(key: string): Promise<unknown>
16 set(key: string, value: unknown): Promise<void>
17 del(key: string): Promise<void>
18 envKey(): Promise<string | undefined>
19}
20
21/** `apiKeys` from the option, tried in order; empty when the option is unset. */
22export type Config = { apiKeys: string[]; tier: GeminiTier }
23
24const NO_KEY = 'no Gemini key: set GEMINI_API_KEY or the gemini-core apiKey option'
25
26export function configFrom(options: PluginOptions): Config {
27 return { apiKeys: parseKeys(typeof options.apiKey === 'string' ? options.apiKey : undefined), tier: isTier(options.tier) ? options.tier : 'free' }
28}
29
30function errorText(err: unknown): string {
31 return err instanceof Error ? err.message : String(err)
32}
33
34/** The option's keys, or else the keys of GEMINI_API_KEY; both take a comma-separated list. */
35async function apiKeys(host: Host, config: Config): Promise<string[]> {
36 return config.apiKeys.length > 0 ? config.apiKeys : parseKeys(await host.envKey())
37}
38
39async function tierOf(host: Host, config: Config): Promise<GeminiTier> {
40 const stored = await host.get(KEYS.tier)
41 return isTier(stored) ? stored : config.tier
42}
43
44async function enrolled(host: Host): Promise<Record<string, string>> {
45 return consumersOf(await host.get(KEYS.consumers))
46}
47
48async function settingsFor(host: Host, config: Config, consumer: string): Promise<GeminiSettings> {
49 const defaultModel = (await enrolled(host))[consumer]
50 if (defaultModel === undefined) throw new Error(`${consumer} has not enrolled with gemini-core`)
51 const model = await host.get(KEYS.model(consumer))
52 const thinking = await host.get(KEYS.thinking(consumer))
53 const own = (await host.get(KEYS.own(consumer))) === true
54 const keys = (await apiKeys(host, config)).length
55 return {
56 hasKey: keys > 0,
57 keys,
58 tier: await tierOf(host, config),
59 model: typeof model === 'string' && MODEL_ID.test(model) ? model : defaultModel,
60 ...(isThinking(thinking) ? { thinking } : {}),
61 ...(own ? { ownModels: true as const } : {}),
62 }
63}
64
65/** Keeps whether a mod names the model of each request itself, as its latest enrollment says. */
66async function markOwnModels(host: Host, consumer: string, own: boolean): Promise<void> {
67 if (((await host.get(KEYS.own(consumer))) === true) === own) return
68 if (own) await host.set(KEYS.own(consumer), true)
69 else await host.del(KEYS.own(consumer))
70}
71
72async function enroll(host: Host, input: GeminiEnroll): Promise<void> {
73 if (!/^[a-z0-9][a-z0-9-]*$/.test(input.consumer)) throw new Error(`enroll takes a plugin name, not ${JSON.stringify(input.consumer)}`)
74 if (!MODEL_ID.test(input.defaultModel)) throw new Error(`enroll takes a Gemini model id, not ${JSON.stringify(input.defaultModel)}`)
75 const mods = await enrolled(host)
76 if (mods[input.consumer] !== input.defaultModel) await host.set(KEYS.consumers, { ...mods, [input.consumer]: input.defaultModel })
77 await markOwnModels(host, input.consumer, input.ownModels === true)
78}
79
80/** The request for a mod, with the key the last request succeeded or moved on with, and the model the input names, if any. */
81async function prepareRequest(host: Host, config: Config, state: KeyState, input: GeminiRequest): Promise<GeminiPrepared> {
82 if (input.model !== undefined && !MODEL_ID.test(input.model)) return { error: `${input.model} is not a Gemini model id` }
83 const keys = await apiKeys(host, config)
84 const key = keys[state.preferred] ?? keys[0]
85 if (key === undefined) return { error: NO_KEY }
86 try {
87 const s = await settingsFor(host, config, input.consumer)
88 const model = input.model ?? s.model
89 return { http: buildHttp(model, key, withThinking(input.body, s.thinking)), model, tier: s.tier }
90 } catch (err) {
91 return { error: errorText(err) }
92 }
93}
94
95async function reset(host: Host): Promise<string> {
96 await host.del(KEYS.tier)
97 for (const consumer of Object.keys(await enrolled(host))) {
98 await host.del(KEYS.model(consumer))
99 await host.del(KEYS.thinking(consumer))
100 }
101 return 'settings reset: the plugin option tier, and each mod its default model and the model default thinking'
102}
103
104/** A model or thinking change for one mod, named in full or without `gemini-`. */
105async function configureMod(host: Host, change: Extract<GeminiChange, { consumer: string }>): Promise<string> {
106 const names = Object.keys(await enrolled(host))
107 const consumer = resolveConsumer(change.consumer, names)
108 if (consumer === undefined) throw new Error(`no Gemini mod named ${change.consumer}; enrolled: ${names.join(', ') || 'none'}`)
109 if ('model' in change) {
110 if ((await host.get(KEYS.own(consumer))) === true) throw new Error(ownModelsText(consumer))
111 if (!MODEL_ID.test(change.model)) throw new Error(`${change.model} is not a Gemini model id`)
112 await host.set(KEYS.model(consumer), change.model)
113 return `${consumer}: model ${change.model}`
114 }
115 if (change.thinking === null) await host.del(KEYS.thinking(consumer))
116 else await host.set(KEYS.thinking(consumer), change.thinking)
117 return `${consumer}: thinking ${change.thinking ?? 'model default'}`
118}
119
120async function configure(host: Host, change: GeminiChange): Promise<string> {
121 if ('reset' in change) return reset(host)
122 if ('tier' in change) {
123 await host.set(KEYS.tier, change.tier)
124 return change.tier === 'free' ? FREE_WARNING : 'paid tier'
125 }
126 return configureMod(host, change)
127}
128
129/** `$.gemini`, on the nouns beneath it. */
130export function createGemini(host: Host, config: Config): Gemini {
131 const state: KeyState = { preferred: 0 }
132 return {
133 enroll: input => enroll(host, input),
134 settings: ({ consumer }) => settingsFor(host, config, consumer),
135 request: input => prepareRequest(host, config, state, input),
136 read: async input => readWithKeys(input, await apiKeys(host, config), state),
137 configure: change => configure(host, change),
138 }
139}
140
141async function statusOf($: EngineInterface, config: Config): Promise<string> {
142 const mods = Object.keys(consumersOf(await $.store.get(KEYS.consumers))).sort()
143 const lines: ConsumerLine[] = []
144 for (const consumer of mods) {
145 const s = await $.gemini.settings({ consumer })
146 lines.push({ consumer, model: s.model, ...(s.thinking === undefined ? {} : { thinking: s.thinking }), ...(s.ownModels === true ? { ownModels: true as const } : {}) })
147 }
148 const tier = await $.store.get(KEYS.tier)
149 const keys = config.apiKeys.length > 0 ? config.apiKeys : parseKeys(await $.env.get('GEMINI_API_KEY'))
150 return statusText(isTier(tier) ? tier : config.tier, keys.length, lines)
151}
152
153/** What the command keeps for the session: the model list, fetched once, and the pick the pane is open for. */
154type Session = { models?: ModelInfo[]; pick?: Pick }
155
156/** The text models the keys list, from memory unless `refresh`; each key is asked in turn until one answers. */
157async function listModels($: EngineInterface, config: Config, session: Session, refresh: boolean): Promise<ModelInfo[]> {
158 if (session.models !== undefined && !refresh) return session.models
159 const keys = config.apiKeys.length > 0 ? config.apiKeys : parseKeys(await $.env.get('GEMINI_API_KEY'))
160 if (keys.length === 0) throw new Error(NO_KEY)
161 const failures: string[] = []
162 for (const [i, key] of keys.entries()) {
163 const request = modelsRequest(key)
164 const r = await $.http.fetch(request.url, request.init)
165 try {
166 session.models = parseModels(r.status, r.ok, r.text)
167 return session.models
168 } catch (err) {
169 failures.push(`key ${i + 1}: ${errorText(err)}`)
170 }
171 }
172 throw new Error(failures.join('; '))
173}
174
175/** Opens the pane that lists the models for one mod. */
176async function openPicker($: EngineInterface, config: Config, session: Session, name: string): Promise<string> {
177 const names = Object.keys(consumersOf(await $.store.get(KEYS.consumers)))
178 const consumer = resolveConsumer(name, names)
179 if (consumer === undefined) return `no Gemini mod named ${name}; enrolled: ${names.join(', ') || 'none'}`
180 if ((await $.store.get(KEYS.own(consumer))) === true) return ownModelsText(consumer)
181 const models = await listModels($, config, session, false)
182 if (models.length === 0) return modelsText(models)
183 session.pick = { consumer, models, current: (await $.gemini.settings({ consumer })).model }
184 await $.ui.open({ id: PANE_ID, title: `Gemini model for ${consumer}`, focus: true, closeOnEscape: true, rows: paneRows(models) })
185 return `pick the model of ${consumer} in the pane; Esc closes it`
186}
187
188/** Sets the model picked in the pane and closes it. */
189async function pickModel($: EngineInterface, session: Session, model: string): Promise<void> {
190 const pick = session.pick
191 if (pick === undefined) return
192 session.pick = undefined
193 await $.ui.close({ id: PANE_ID })
194 $.ui.toast(await $.gemini.configure({ consumer: pick.consumer, model }))
195}
196
197/** Whether the mod named in a command names the model of each request itself. */
198async function ownsModels($: EngineInterface, name: string): Promise<boolean> {
199 const consumer = resolveConsumer(name, Object.keys(consumersOf(await $.store.get(KEYS.consumers))))
200 return consumer !== undefined && (await $.store.get(KEYS.own(consumer))) === true
201}
202
203/**
204 * A model id set by argument is checked against the list the keys give; a mod that names its own models
205 * is refused by `configure` before the list is asked for.
206 */
207async function applyChange($: EngineInterface, config: Config, session: Session, change: GeminiChange): Promise<string> {
208 if ('model' in change && !(await ownsModels($, change.consumer))) {
209 const refused = unknownModel(change.model, await listModels($, config, session, false))
210 if (refused !== undefined) return refused
211 }
212 return $.gemini.configure(change)
213}
214
215async function runCommand($: EngineInterface, config: Config, session: Session, args: string): Promise<string> {
216 const command = parseCommand(args)
217 try {
218 switch (command.kind) {
219 case 'error': return command.text
220 case 'status': return await statusOf($, config)
221 case 'models': return modelsText(await listModels($, config, session, command.refresh))
222 case 'pick': return await openPicker($, config, session, command.consumer)
223 case 'change': return await applyChange($, config, session, command.change)
224 }
225 } catch (err) {
226 return errorText(err)
227 }
228}
229
230export const register: Register = (on, options) => {
231 const config = configFrom(options)
232 const session: Session = {}
233
234 on('engine.create', async (_, e, next) => {
235 const below = await next(e)
236 const host: Host = {
237 get: key => below.store.get(key),
238 set: (key, value) => below.store.set(key, value),
239 del: key => below.store.delete(key),
240 envKey: () => below.env.get('GEMINI_API_KEY'),
241 }
242 return { ...below, gemini: createGemini(host, config) }
243 })
244
245 on('session.start', async ($, e, next) => {
246 const r = await next(e)
247 await $.command.register({
248 name: 'gemini-core',
249 description: 'Gemini settings of every Gemini mod: status, free, paid, models, model <mod> [id], thinking <mod> <level|default>, reset (gemini-core)',
250 argumentHint: '[free | paid | models [refresh] | model <mod> [id] | thinking <mod> <level|default> | reset]',
251 })
252 return r
253 })
254
255 on('command.run', { command: 'gemini-core' }, async ($, e) => ({ text: await runCommand($, config, session, String(e.args ?? '')) }))
256
257 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
258 const pick = session.pick
259 if (e.requestId !== PANE_ID || pick === undefined) return next(e)
260 if (e.surface === 'mobile') return listTree($.ui.resolve(e), pick)
261 return pickerTree($.ui.resolve(e), pick, model => void pickModel($, session, model).catch((err: unknown) => $.ui.toast(errorText(err))))
262 })
263
264 // Esc or the person closing the pane drops the pick.
265 on('ui.close', async (_, e, next) => {
266 if (e.id === PANE_ID) session.pick = undefined
267 return next(e)
268 })
269}
270types/index.d.ts 95 lines1/**
2 * `$.gemini`, added by the gemini-core plugin: the Gemini key, the tier, and
3 * each Gemini mod's model and thinking level, in one place.
4 *
5 * A method must answer within 10 s (measured on 2.1.278), so the request is
6 * not sent here: `request` builds it, the caller sends it with
7 * `$.http.fetch`, and `read` reads the answer and says whether to ask again.
8 */
9
10/** A Gemini thinking level. Which ones a model takes differs per model; an unsupported one is Gemini's HTTP 400. */
11export type GeminiThinking = 'minimal' | 'low' | 'medium' | 'high'
12
13export type GeminiTier = 'free' | 'paid'
14
15/**
16 * A Gemini mod: its plugin name, and the model it uses until /gemini-core sets another. `ownModels` says
17 * the mod names the model of each request itself (`GeminiRequest`'s `model`), so /gemini-core sets none
18 * for it and shows it so.
19 */
20export type GeminiEnroll = { consumer: string; defaultModel: string; ownModels?: boolean }
21
22/**
23 * What a Gemini mod runs with; `keys` counts the keys tried in turn, `thinking` absent is the model's own
24 * default, and `ownModels` is set when the mod enrolled so.
25 */
26export type GeminiSettings = { hasKey: boolean; keys: number; tier: GeminiTier; model: string; thinking?: GeminiThinking; ownModels?: true }
27
28/**
29 * `request`'s input: the mod, the generateContent body, and a model for this request alone. `model`
30 * absent is the mod's model; a present one must be a plain model id, and the mod's thinking level applies
31 * to it as well.
32 */
33export type GeminiRequest = { consumer: string; body: Record<string, unknown>; model?: string }
34
35/**
36 * A generateContent POST with the key in a header, never in the URL. `tried`
37 * holds why the keys before this one failed for the same request; send the
38 * request with `url` and `init` and give the whole object back to `read`.
39 */
40export type GeminiHttp = { url: string; init: { method: 'POST'; headers: Record<string, string>; body: string }; tried?: readonly string[] }
41
42/** `request`'s answer: the request with what it runs with, or why there is none. */
43export type GeminiPrepared = { http: GeminiHttp; model: string; tier: GeminiTier } | { error: string }
44
45/** The answer text and its token counts; `finishReason` is Gemini's, such as `STOP` or `MAX_TOKENS`. */
46export type GeminiAnswer = { text: string; inputTokens: number; outputTokens: number; finishReason?: string }
47
48/** One HTTP response, the request that got it, and where the caller is in its attempts. */
49export type GeminiResponse = {
50 /** The request as sent; its key says which key the next attempt moves on from. */
51 http: GeminiHttp
52 status: number
53 ok: boolean
54 text: string
55 /** 1 for the first request. */
56 attempt: number
57 /** Milliseconds since the first request started. */
58 elapsedMs: number
59 /** No new attempt starts once this much has passed; 60000 when absent. */
60 deadlineMs?: number
61}
62
63/**
64 * `read`'s answer: the answer, why there is none, how long to wait before
65 * sending the same request again, or the same request with the next key, to
66 * send at once (after an HTTP 429 or a key error).
67 */
68export type GeminiRead = { answer: GeminiAnswer } | { error: string } | { retryInMs: number } | { next: GeminiHttp }
69
70/** A change /gemini-core makes; `consumer` names the mod a model or thinking change is for. */
71export type GeminiChange =
72 | { tier: GeminiTier }
73 | { consumer: string; model: string }
74 | { consumer: string; thinking: GeminiThinking | null }
75 | { reset: true }
76
77export type Gemini = {
78 /** Records a Gemini mod and its default model; call it at session start. */
79 enroll(input: GeminiEnroll): Promise<void>
80 /** What a mod runs with now. */
81 settings(input: { consumer: string }): Promise<GeminiSettings>
82 /** The request for a generateContent body, with the mod's model, or the one the input names, and the mod's thinking level. */
83 request(input: GeminiRequest): Promise<GeminiPrepared>
84 /** Reads a response; asks for another attempt after an HTTP 503 while the deadline allows, and moves to the next key after a 429 or a key error. */
85 read(input: GeminiResponse): Promise<GeminiRead>
86 /** Stores a change and answers the line /gemini-core prints. A mod may hook `gemini.configure` to follow its own changes. */
87 configure(input: GeminiChange): Promise<string>
88}
89
90declare module 'claude-code' {
91 interface EngineInterface {
92 gemini: Gemini
93 }
94}
95hooks/api.ts 98 lines1/** The Gemini generateContent request and the reading of its response. */
2import type { GeminiAnswer, GeminiHttp, GeminiRead, GeminiResponse, GeminiThinking } from '../types/index.d.ts'
3
4const API = 'https://generativelanguage.googleapis.com/v1beta/models'
5
6/** A model id is placed in the URL path, so only a plain id is taken. */
7export const MODEL_ID = /^[a-z0-9][a-z0-9.-]{0,79}$/
8
9function isRecord(value: unknown): value is Record<string, unknown> {
10 return typeof value === 'object' && value !== null && !Array.isArray(value)
11}
12
13/** The body with the thinking level in its generationConfig; without a level the body is sent as it is. */
14export function withThinking(body: Record<string, unknown>, thinking: GeminiThinking | undefined): Record<string, unknown> {
15 if (thinking === undefined) return body
16 const config = isRecord(body.generationConfig) ? body.generationConfig : {}
17 return { ...body, generationConfig: { ...config, thinkingConfig: { thinkingLevel: thinking } } }
18}
19
20/** A POST to the model with the key in a header, never in the URL. */
21export function buildHttp(model: string, apiKey: string, body: Record<string, unknown>): GeminiHttp {
22 return {
23 url: `${API}/${model}:generateContent`,
24 init: { method: 'POST', headers: { 'content-type': 'application/json', 'x-goog-api-key': apiKey }, body: JSON.stringify(body) },
25 }
26}
27
28/**
29 * Gemini answers 503 ("high demand") now and then, often after 10 s or more,
30 * and the next request often works (measured). The caller's hook budget of
31 * 10 s counts its `$.clock` waits, so the waits stay short.
32 */
33export const RETRY = { delaysMs: [1000, 2000, 3000], deadlineMs: 60_000 }
34
35/** The wait before the next attempt, or undefined when the answer stands. */
36export function retryDelay(status: number, attempt: number, elapsedMs: number, deadlineMs = RETRY.deadlineMs): number | undefined {
37 const delay = RETRY.delaysMs[attempt - 1]
38 if (status !== 503 || delay === undefined) return undefined
39 return elapsedMs + delay < deadlineMs ? delay : undefined
40}
41
42function errorText(value: unknown, fallback: string): string {
43 const error = isRecord(value) ? value.error : undefined
44 const message = isRecord(error) && typeof error.message === 'string' ? error.message : fallback
45 return message.replace(/\s+/g, ' ').slice(0, 200)
46}
47
48function firstCandidate(value: Record<string, unknown>): Record<string, unknown> | undefined {
49 const candidate = Array.isArray(value.candidates) ? value.candidates[0] : undefined
50 return isRecord(candidate) ? candidate : undefined
51}
52
53/** The answer text: every part that is text and not a thought, joined. */
54function answerText(candidate: Record<string, unknown> | undefined): string | undefined {
55 const content = candidate?.content
56 const parts = isRecord(content) && Array.isArray(content.parts) ? content.parts : []
57 const texts = parts.filter(p => isRecord(p) && typeof p.text === 'string' && p.thought !== true).map(p => (p as { text: string }).text)
58 return texts.length === 0 ? undefined : texts.join('')
59}
60
61function count(usage: unknown, key: string): number {
62 const n = isRecord(usage) ? usage[key] : undefined
63 return typeof n === 'number' ? n : 0
64}
65
66/** Reads a generateContent response; an HTTP error, a blocked or empty answer throws. */
67export function parseResponse(status: number, ok: boolean, text: string): GeminiAnswer {
68 let value: unknown
69 try {
70 value = JSON.parse(text)
71 } catch {
72 throw new Error(ok ? 'Gemini answered with no JSON' : `Gemini HTTP ${status}`)
73 }
74 if (!ok) throw new Error(`Gemini HTTP ${status}: ${errorText(value, 'no message')}`)
75 if (!isRecord(value)) throw new Error('Gemini answered with no object')
76 const candidate = firstCandidate(value)
77 const answer = answerText(candidate)
78 if (answer === undefined) throw new Error(`Gemini gave no answer (${errorText(value, 'blocked or empty')})`)
79 const reason = candidate?.finishReason
80 return {
81 text: answer,
82 inputTokens: count(value.usageMetadata, 'promptTokenCount'),
83 outputTokens: count(value.usageMetadata, 'candidatesTokenCount') + count(value.usageMetadata, 'thoughtsTokenCount'),
84 ...(typeof reason === 'string' ? { finishReason: reason } : {}),
85 }
86}
87
88/** The answer, the error, or the wait before the same request is sent again. */
89export function readResponse(r: Omit<GeminiResponse, 'http'>): GeminiRead {
90 const delay = retryDelay(r.status, r.attempt, r.elapsedMs, r.deadlineMs)
91 if (delay !== undefined) return { retryInMs: delay }
92 try {
93 return { answer: parseResponse(r.status, r.ok, r.text) }
94 } catch (err) {
95 return { error: err instanceof Error ? err.message : String(err) }
96 }
97}
98hooks/keys.ts 96 lines1/** Several Gemini keys, tried in turn: which failures move to the next key, and the reading that does it. */
2import type { GeminiHttp, GeminiRead, GeminiResponse } from '../types/index.d.ts'
3import { readResponse, RETRY } from './api.ts'
4
5/** The keys of a comma-separated list, trimmed, without blanks or repeats, in order. */
6export function parseKeys(text: string | undefined): string[] {
7 const keys = (text ?? '').split(',').map(k => k.trim()).filter(k => k !== '')
8 return [...new Set(keys)]
9}
10
11/**
12 * A failure another key can fix: a quota (429) or a key Google refuses
13 * (401, 403, or 400 "API key not valid"). A 503 is the model's load, the same
14 * for every key, so it is not one.
15 */
16export function isKeyFailure(status: number, text: string): boolean {
17 if (status === 429 || status === 401 || status === 403) return true
18 return status === 400 && /API_KEY_INVALID|API key not valid|API key expired/.test(text)
19}
20
21/** The index of the key the request was sent with, or -1 when it is not in the list. */
22export function keyIndex(http: GeminiHttp, keys: readonly string[]): number {
23 return keys.indexOf(http.init.headers['x-goog-api-key'] ?? '')
24}
25
26/** The same request with another key, carrying why the earlier ones failed. */
27export function withKey(http: GeminiHttp, key: string, tried: readonly string[]): GeminiHttp {
28 return { url: http.url, init: { ...http.init, headers: { ...http.init.headers, 'x-goog-api-key': key } }, tried }
29}
30
31/** The error text of a failed response, as `read` words it. */
32function failureText(r: GeminiResponse): string {
33 const read = readResponse({ ...r, attempt: Number.MAX_SAFE_INTEGER })
34 return 'error' in read ? read.error : `Gemini HTTP ${r.status}`
35}
36
37/** In memory only: the key the last request succeeded or moved on with, where the next request starts. */
38export type KeyState = { preferred: number }
39
40/** A `tried` entry: the key's place and its failure. */
41const TRIED = /^key (\d+): (.*)$/s
42
43/**
44 * The key failures of one request, each distinct failure once with the keys
45 * that got it, so a long key list gives a short error:
46 * `Gemini HTTP 429: quota (keys 1-3, 5); Gemini HTTP 400: API key not valid (key 4)`.
47 */
48export function failuresText(tried: readonly string[]): string {
49 const byText = new Map<string, number[]>()
50 for (const entry of tried) {
51 const [, place, text] = TRIED.exec(entry) ?? [entry, '0', entry]
52 byText.set(text ?? entry, [...(byText.get(text ?? entry) ?? []), Number(place)])
53 }
54 return [...byText].map(([text, places]) => `${text} (${placesText(places)})`).join('; ')
55}
56
57/** `key 4`, or `keys 1-3, 5`. */
58function placesText(places: readonly number[]): string {
59 const sorted = [...places].sort((a, b) => a - b)
60 const runs: string[] = []
61 for (let i = 0; i < sorted.length; ) {
62 let j = i
63 while (sorted[j + 1] === (sorted[j] ?? 0) + 1) j++
64 runs.push(i === j ? `${sorted[i]}` : `${sorted[i]}-${sorted[j]}`)
65 i = j + 1
66 }
67 return `${sorted.length === 1 ? 'key' : 'keys'} ${runs.join(', ')}`
68}
69
70/**
71 * Reads a response knowing the keys: a key failure moves to the next key in
72 * turn (wrapping, each key once per request, none once the deadline passed);
73 * when no key is left, the error names the failures by the keys' places in
74 * the list, never the keys.
75 */
76export function readWithKeys(r: GeminiResponse, keys: readonly string[], state: KeyState): GeminiRead {
77 const index = keyIndex(r.http, keys)
78 if (index >= 0 && isKeyFailure(r.status, r.text)) return afterKeyFailure(r, keys, state, index)
79 const read = readResponse(r)
80 if ('answer' in read && index >= 0) state.preferred = index
81 return read
82}
83
84/** The next key for the same request, or the error when none is left or the deadline passed. */
85function afterKeyFailure(r: GeminiResponse, keys: readonly string[], state: KeyState, index: number): GeminiRead {
86 const tried = [...(r.http.tried ?? []), `key ${index + 1}: ${failureText(r)}`]
87 if (keys.length === 1) return { error: failureText(r) }
88 if (tried.length >= keys.length) return { error: `all ${keys.length} keys failed: ${failuresText(tried)}` }
89 if (r.elapsedMs >= (r.deadlineMs ?? RETRY.deadlineMs)) return { error: `${tried.length} of ${keys.length} keys failed before the deadline: ${failuresText(tried)}` }
90 const next = (index + 1) % keys.length
91 const key = keys[next]
92 if (key === undefined) return { error: `no key at place ${next + 1} of ${keys.length}` }
93 state.preferred = next
94 return { next: withKey(r.http, key, tried) }
95}
96hooks/models.ts 74 lines1/** The Gemini models a key can use, as Google lists them, and the text /gemini-core prints of them. */
2import { MODEL_ID } from './api.ts'
3
4/** One model a mod can be set to. */
5export type ModelInfo = { id: string; displayName: string; thinking: boolean; inputTokenLimit: number }
6
7/** The model list request: every model on one page (58 on the checked key), the key in a header. */
8export function modelsRequest(key: string): { url: string; init: { method: 'GET'; headers: Record<string, string> } } {
9 return { url: 'https://generativelanguage.googleapis.com/v1beta/models?pageSize=1000', init: { method: 'GET', headers: { 'x-goog-api-key': key } } }
10}
11
12function isRecord(value: unknown): value is Record<string, unknown> {
13 return typeof value === 'object' && value !== null && !Array.isArray(value)
14}
15
16/** A text model: it takes generateContent, its id is a Gemini id, and its name marks no speech or image output. */
17function modelOf(entry: unknown): ModelInfo | undefined {
18 if (!isRecord(entry) || typeof entry.name !== 'string') return undefined
19 const id = entry.name.replace(/^models\//, '')
20 const methods = Array.isArray(entry.supportedGenerationMethods) ? entry.supportedGenerationMethods : []
21 if (!methods.includes('generateContent') || !id.startsWith('gemini-') || !MODEL_ID.test(id) || /tts|image/.test(id)) return undefined
22 return {
23 id,
24 displayName: typeof entry.displayName === 'string' ? entry.displayName : id,
25 thinking: entry.thinking === true,
26 inputTokenLimit: typeof entry.inputTokenLimit === 'number' ? entry.inputTokenLimit : 0,
27 }
28}
29
30/** The text models of a models.list response, by id; an HTTP error or a body that is not the list throws. */
31export function parseModels(status: number, ok: boolean, text: string): ModelInfo[] {
32 let value: unknown
33 try {
34 value = JSON.parse(text)
35 } catch {
36 throw new Error(`the model list came back as no JSON (HTTP ${status})`)
37 }
38 if (!ok) {
39 const error = isRecord(value) && isRecord(value.error) && typeof value.error.message === 'string' ? value.error.message : 'no message'
40 throw new Error(`the model list failed: Gemini HTTP ${status}: ${error.slice(0, 200)}`)
41 }
42 const models = isRecord(value) && Array.isArray(value.models) ? value.models : undefined
43 if (models === undefined) throw new Error('the model list came back without models')
44 return models.map(modelOf).filter((m): m is ModelInfo => m !== undefined).sort((a, b) => a.id.localeCompare(b.id))
45}
46
47function tokens(n: number): string {
48 return n >= 1_000_000 ? `${Math.round(n / 1_048_576)}M` : `${Math.round(n / 1024)}k`
49}
50
51/** One line per model: `gemini-3.8-flash · thinking · 1M in`. */
52export function modelsText(models: readonly ModelInfo[]): string {
53 if (models.length === 0) return 'the key lists no Gemini text model'
54 return models.map(m => `${m.id} · ${m.thinking ? 'thinking' : 'no thinking'} · ${tokens(m.inputTokenLimit)} in`).join('\n')
55}
56
57/** The number of single-character edits between two ids. */
58function distance(a: string, b: string): number {
59 let row = Array.from({ length: b.length + 1 }, (_, j) => j)
60 for (let i = 1; i <= a.length; i++) {
61 const next = [i]
62 for (let j = 1; j <= b.length; j++) next.push(Math.min((row[j] ?? 0) + 1, (next[j - 1] ?? 0) + 1, (row[j - 1] ?? 0) + (a[i - 1] === b[j - 1] ? 0 : 1)))
63 row = next
64 }
65 return row[b.length] ?? 0
66}
67
68/** Why an id is not taken, with the closest ids the key lists; undefined when it is listed. */
69export function unknownModel(id: string, models: readonly ModelInfo[]): string | undefined {
70 if (models.some(m => m.id === id)) return undefined
71 const near = [...models].sort((a, b) => distance(id, a.id) - distance(id, b.id)).slice(0, 3).map(m => m.id)
72 return `${id} is not a Gemini text model this key lists${near.length === 0 ? '' : `; closest: ${near.join(', ')}`}. /gemini-core models lists them.`
73}
74hooks/picker.tsx 42 lines1/** The pane /gemini-core model <mod> opens: the models the key lists, to pick one for a mod. */
2import type { Elements } from 'claude-code'
3import type { ModelInfo } from './models.ts'
4
5/** The pick the pane is open for. */
6export type Pick = { consumer: string; models: readonly ModelInfo[]; current: string }
7
8export const PANE_ID = 'gemini-core-model'
9
10/** The pane's rows: the hint, and the list the picker draws one model per row. */
11export function paneRows(models: readonly ModelInfo[]): number {
12 return Math.min(models.length, 20) + 2
13}
14
15function label(m: ModelInfo): string {
16 return `${m.id}${m.thinking ? '' : ' (no thinking)'}`
17}
18
19/** A Select over the models, the mod's current model selected. */
20export function pickerTree(els: Elements['terminal' | 'desktop' | 'vscode'], pick: Pick, onPick: (model: string) => void) {
21 const { Box, Select, Text } = els
22 const options = pick.models.map(m => ({ value: m.id, label: label(m) }))
23 const value = pick.models.some(m => m.id === pick.current) ? pick.current : undefined
24 return (
25 <Box flexDirection="column">
26 <Text dimColor>{`Enter sets the model of ${pick.consumer}; Esc closes without a change.`}</Text>
27 <Select key="model" label="model" options={options} {...(value === undefined ? {} : { value })} autoFocus onSelect={onPick} />
28 </Box>
29 )
30}
31
32/** Where a surface has no Select: the list, and the command that sets one. */
33export function listTree(els: Elements['mobile'], pick: Pick) {
34 const { Box, Text } = els
35 return (
36 <Box flexDirection="column">
37 <Text dimColor>{`/gemini-core model ${pick.consumer} <id> sets one of:`}</Text>
38 {pick.models.map(m => <Text key={m.id}>{label(m)}</Text>)}
39 </Box>
40 )
41}
42hooks/settings.ts 99 lines1/** The settings /gemini-core changes, their store keys, and the lines it prints. */
2import type { GeminiChange, GeminiThinking, GeminiTier } from '../types/index.d.ts'
3import { MODEL_ID } from './api.ts'
4
5export const THINKING: readonly GeminiThinking[] = ['minimal', 'low', 'medium', 'high']
6
7export function isThinking(value: unknown): value is GeminiThinking {
8 return THINKING.some(t => t === value)
9}
10
11export function isTier(value: unknown): value is GeminiTier {
12 return value === 'free' || value === 'paid'
13}
14
15/** Store keys: the tier, the enrolled mods, each mod's model and thinking level, and the mods that name their own models. */
16export const KEYS = {
17 tier: 'tier',
18 consumers: 'consumers',
19 model: (consumer: string) => `model:${consumer}`,
20 thinking: (consumer: string) => `thinking:${consumer}`,
21 own: (consumer: string) => `own:${consumer}`,
22}
23
24/** Why /gemini-core sets no model for a mod that names the model of each request itself. */
25export function ownModelsText(consumer: string): string {
26 return `${consumer} names the model of each request itself, so /gemini-core sets no model for it; its thinking level is set here`
27}
28
29/** The enrolled mods and their default models, from the store. */
30export function consumersOf(value: unknown): Record<string, string> {
31 if (typeof value !== 'object' || value === null || Array.isArray(value)) return {}
32 return Object.fromEntries(Object.entries(value).filter((e): e is [string, string] => typeof e[1] === 'string' && MODEL_ID.test(e[1])))
33}
34
35/** A mod named in full (`gemini-review`) or without the `gemini-` prefix (`review`). */
36export function resolveConsumer(name: string, enrolled: readonly string[]): string | undefined {
37 return enrolled.find(c => c === name || c === `gemini-${name}`)
38}
39
40export type Command =
41 | { kind: 'status' }
42 | { kind: 'change'; change: GeminiChange }
43 | { kind: 'models'; refresh: boolean }
44 | { kind: 'pick'; consumer: string }
45 | { kind: 'error'; text: string }
46
47export const USAGE = 'expects free, paid, models [refresh], model <mod> [id], thinking <mod> <minimal|low|medium|high|default>, or reset'
48
49/** `model <mod>` opens the picker; `model <mod> <id>` sets the id, checked against the list before it is stored. */
50function modelCommand(consumer: string | undefined, id: string | undefined): Command {
51 if (consumer === undefined) return { kind: 'error', text: 'model takes a mod, and a model id or nothing to pick from the list, for example: model review' }
52 if (id === undefined) return { kind: 'pick', consumer }
53 if (!MODEL_ID.test(id)) return { kind: 'error', text: `${id} is not a Gemini model id` }
54 return { kind: 'change', change: { consumer, model: id } }
55}
56
57function modelsCommand(word: string | undefined, rest: number): Command {
58 if (rest > 0 || (word !== undefined && word !== 'refresh')) return { kind: 'error', text: 'models takes nothing, or refresh to ask Google again' }
59 return { kind: 'models', refresh: word === 'refresh' }
60}
61
62function thinkingCommand(consumer: string | undefined, level: string | undefined): Command {
63 if (consumer === undefined || level === undefined) return { kind: 'error', text: 'thinking takes a mod and a level, for example: thinking review low' }
64 if (level === 'default') return { kind: 'change', change: { consumer, thinking: null } }
65 return isThinking(level) ? { kind: 'change', change: { consumer, thinking: level } } : { kind: 'error', text: `thinking level ${level} is not one of ${THINKING.join(', ')}, default` }
66}
67
68const WORDS: Record<string, Command> = {
69 '': { kind: 'status' },
70 status: { kind: 'status' },
71 free: { kind: 'change', change: { tier: 'free' } },
72 paid: { kind: 'change', change: { tier: 'paid' } },
73 reset: { kind: 'change', change: { reset: true } },
74}
75
76/** Reads the argument of /gemini-core; the mod name is taken as typed and resolved by `configure`. */
77export function parseCommand(args: string): Command {
78 const [first = '', second, third, ...rest] = args.trim().split(/\s+/).filter(Boolean)
79 if (first === 'models') return modelsCommand(second, rest.length + (third === undefined ? 0 : 1))
80 if (rest.length > 0) return { kind: 'error', text: USAGE }
81 if (first === 'model') return modelCommand(second, third)
82 if (first === 'thinking') return thinkingCommand(second, third)
83 const word = WORDS[first]
84 return word !== undefined && second === undefined ? word : { kind: 'error', text: USAGE }
85}
86
87export const FREE_WARNING =
88 'free tier: Google may use what the Gemini mods send (the conversation, tool outputs, diffs) to improve its products, and human reviewers may read it (Gemini API Additional Terms). Use paid with a billing-enabled key to avoid this.'
89
90/** One mod's line in the status. */
91export type ConsumerLine = { consumer: string; model: string; thinking?: GeminiThinking; ownModels?: true }
92
93/** The status of /gemini-core. */
94export function statusText(tier: GeminiTier, keys: number, lines: readonly ConsumerLine[]): string {
95 const key = keys === 0 ? 'no key: set GEMINI_API_KEY or the gemini-core apiKey option' : keys === 1 ? 'key set' : `${keys} keys, tried in turn`
96 const mods = lines.map(l => `${l.consumer}: ${l.ownModels === true ? 'models set by the mod' : l.model} · thinking ${l.thinking ?? 'model default'}`)
97 return [`${tier} tier · ${key}`, ...(mods.length === 0 ? ['no Gemini mod has enrolled yet'] : mods)].join('\n')
98}
99