SLOPSHOPPER

gemini-core

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…

newpanecommandtoastnetwork
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · gemini-core
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /gemini-core ⎿ gemini-core: free tier · no key: set GEMINI_API_KEY or the gemini-core apiKey option ⎿ gemini-core: no Gemini mod has enrolled yet ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

gemini-core

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.

What it does

  1. Each Gemini mod enrolls at session start with its plugin name and its default model.
  2. When a mod asks Gemini, $.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.
  3. The mod sends the request with its own $.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.

Thinking levels

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:

Modelminimallowmediumhigh
gemini-3.8-flashHTTP 400 "Thinking level MINIMAL is not supported for this model"yesyesyes
gemini-3.5-flash-liteyesyesyesyes

The Gemini docs say Gemini 3.1 Pro takes no minimal either.

Command

/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.

Model list

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.

Several keys

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:

  • An HTTP 429 (quota), 401, 403, or 400 "API key not valid" or "API key expired" sends the same request with the next key at once. The last key wraps to the first, and each key is tried once per request. No key is tried once the mod's deadline has passed (60 s for review and compact, 50 s for plan-review, 40 s for the advisor). With one key, the error is that key's failure alone.
  • When no key is left, the error names each distinct failure once with the places of the keys that got it, never a key: all 34 keys failed: Gemini HTTP 429: quota (keys 1-4, 6-34); Gemini HTTP 400: API key not valid. (key 5).
  • The next request starts at the key the last one succeeded or moved on with, so a key whose daily quota is used up is not asked first every time. This place is kept in memory and starts over when the module reloads.
  • An HTTP 503 is the model's load, the same for every key, so it stays on the same key and waits as above.

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.

Free tier or paid tier

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.

Install

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.

After installing

  1. Give it a key from Google AI Studio, in one of two places. Without a key every Gemini mod reports 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.
  2. The environment. Add the line to your shell profile (~/.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

  • The plugin option, stored as a secret. It wins over the environment. Set it in the /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.
  • Restart Claude Code and run /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.
  • Choose the tier. The default is 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.
  • Turn on each Gemini mod you want: /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.
  • Check that each mod's model answers on your key. A free key can have no quota for a model: the free key of the live checks answered HTTP 429 for 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.

Options

OptionDefaultWhat it sets
apiKeyGEMINI_API_KEYThe Gemini API key of every Gemini mod, or several separated by commas, stored as a secret
tierfreefree or paid; `/gemini-core free\paid` overrides it

For a mod author

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.

What it can reach

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.

  1. Reads: GEMINI_API_KEY or the apiKey option; its own $.store
  2. Runs: no process; it opens one pane for a model pick
  3. Sends: the model list request (GET generativelanguage.googleapis.com/v1beta/models, no conversation), once per session and on refresh, with the key in the x-goog-api-key header; the Gemini mods send the requests it builds
  4. Persists: in $.store, the tier, the enrolled mods with their default models, which of them name their own models, and each mod's model and thinking level
  5. Hostile input: a mod that calls $.gemini.request receives the key, so only install Gemini mods you trust; a model id, also one a request names, must match [a-z0-9.-] because it goes into the URL path; the response text is read as JSON and never run

Limits

  • The key reaches every plugin that calls $.gemini.request.
  • Settings kept by the Gemini mods before they used gemini-core (their tier and model) are not read.
  • The test engine of 2.1.278 runs no engine.create, so the tests build $.gemini on a store in memory, and the mods' tests answer gemini.* themselves.
  • Plugin nouns are early access; the 10-second limit and the rest were measured on 2.1.278.

Development

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

Source 7 files
hooks/register.ts 270 lines
1import 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}
270
types/index.d.ts 95 lines
1/**
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}
95
hooks/api.ts 98 lines
1/** 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}
98
hooks/keys.ts 96 lines
1/** 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}
96
hooks/models.ts 74 lines
1/** 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}
74
hooks/picker.tsx 42 lines
1/** 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}
42
hooks/settings.ts 99 lines
1/** 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