Skip to content

Commit c6d7d85

Browse files
committed
fix: parse Gemini CLI stream output
1 parent 81c9bd3 commit c6d7d85

15 files changed

Lines changed: 923 additions & 37 deletions

docs/concepts/model-providers.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,9 @@ Gemini CLI OAuth is shipped as part of the bundled `google` plugin.
258258
</Step>
259259
</Steps>
260260

261-
Gemini CLI JSON replies are parsed from `response`; usage falls back to `stats`, with `stats.cached` normalized into OpenClaw `cacheRead`.
261+
Gemini CLI uses `stream-json` by default. OpenClaw reads assistant stream
262+
messages and normalizes `stats.cached` into `cacheRead`; legacy
263+
`--output-format json` overrides still read reply text from `response`.
262264

263265
### Z.AI (GLM)
264266

docs/gateway/cli-backends.md

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -287,8 +287,10 @@ load local files from plain paths.
287287
## Inputs / outputs
288288

289289
- `output: "json"` (default) tries to parse JSON and extract text + session id.
290-
- For Gemini CLI JSON output, OpenClaw reads reply text from `response` and
291-
usage from `stats` when `usage` is missing or empty.
290+
- For Gemini CLI JSON output, OpenClaw reads reply text from `response` and usage
291+
from `stats` when `usage` is missing or empty. The bundled Gemini CLI default
292+
uses `stream-json`, but old `--output-format json` overrides still use the
293+
JSON parser.
292294
- `output: "jsonl"` parses JSONL streams and extracts the final agent message plus session
293295
identifiers when present.
294296
- `output: "text"` treats stdout as the final response.
@@ -318,8 +320,11 @@ The bundled Anthropic plugin registers a default for `claude-cli`:
318320
The bundled Google plugin also registers a default for `google-gemini-cli`:
319321

320322
- `command: "gemini"`
321-
- `args: ["--output-format", "json", "--prompt", "{prompt}"]`
322-
- `resumeArgs: ["--resume", "{sessionId}", "--output-format", "json", "--prompt", "{prompt}"]`
323+
- `args: ["--skip-trust", "--output-format", "stream-json", "--prompt", "{prompt}"]`
324+
- `resumeArgs: ["--skip-trust", "--resume", "{sessionId}", "--output-format", "stream-json", "--prompt", "{prompt}"]`
325+
- `output: "jsonl"`
326+
- `resumeOutput: "jsonl"`
327+
- `jsonlDialect: "gemini-stream-json"`
323328
- `imageArg: "@"`
324329
- `imagePathScope: "workspace"`
325330
- `modelArg: "--model"`
@@ -330,9 +335,13 @@ Prerequisite: the local Gemini CLI must be installed and available as
330335
`gemini` on `PATH` (`brew install gemini-cli` or
331336
`npm install -g @google/gemini-cli`).
332337

333-
Gemini CLI JSON notes:
338+
Gemini CLI output notes:
334339

335-
- Reply text is read from the JSON `response` field.
340+
- The default `stream-json` parser reads assistant `message` events, tool events,
341+
final `result` usage, and fatal Gemini error events.
342+
- If you override Gemini args to `--output-format json`, OpenClaw normalizes that
343+
backend back to `output: "json"` and reads reply text from the JSON `response`
344+
field.
336345
- Usage falls back to `stats` when `usage` is absent or empty.
337346
- `stats.cached` is normalized into OpenClaw `cacheRead`.
338347
- If `stats.input` is missing, OpenClaw derives input tokens from
@@ -372,8 +381,10 @@ api.registerTextTransforms({
372381
rewrites streamed assistant deltas and parsed final text before OpenClaw handles
373382
its own control markers and channel delivery.
374383

375-
For CLIs that emit Claude Code stream-json compatible JSONL, set
376-
`jsonlDialect: "claude-stream-json"` on that backend's config.
384+
For CLIs that emit provider-specific JSONL events, set `jsonlDialect` on that
385+
backend's config. Supported dialects are `claude-stream-json` for Claude
386+
Code-compatible streams and `gemini-stream-json` for Gemini CLI `stream-json`
387+
events.
377388

378389
## Native compaction ownership
379390

docs/providers/google.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -435,11 +435,14 @@ WebSocket endpoint, sends the initial setup payload, and waits for
435435

436436
</Accordion>
437437

438-
<Accordion title="Gemini CLI JSON usage notes">
439-
When using the `google-gemini-cli` OAuth provider, OpenClaw normalizes
440-
the CLI JSON output as follows:
441-
442-
- Reply text comes from the CLI JSON `response` field.
438+
<Accordion title="Gemini CLI usage notes">
439+
When using the `google-gemini-cli` OAuth provider, OpenClaw uses Gemini
440+
CLI `stream-json` output by default and normalizes usage from the final
441+
`stats` payload. Legacy `--output-format json` overrides still use the
442+
JSON parser.
443+
444+
- Streamed reply text comes from assistant `message` events.
445+
- For legacy JSON output, reply text comes from the CLI JSON `response` field.
443446
- Usage falls back to `stats` when the CLI leaves `usage` empty.
444447
- `stats.cached` is normalized into OpenClaw `cacheRead`.
445448
- If `stats.input` is missing, OpenClaw derives input tokens from

docs/reference/api-usage-costs.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,9 @@ OpenClaw features that can generate provider usage or paid API calls.
3131
- `/usage tokens` shows tokens only; subscription-style OAuth/token and CLI flows
3232
still show tokens only unless that runtime supplies compatible usage metadata
3333
and an explicit local price is configured.
34-
- Gemini CLI note: when the CLI returns JSON output, OpenClaw reads usage from
35-
`stats`, normalizes `stats.cached` into `cacheRead`, and derives input tokens
36-
from `stats.input_tokens - stats.cached` when needed.
34+
- Gemini CLI note: the default `stream-json` output and legacy JSON overrides
35+
both read usage from `stats`, normalize `stats.cached` into `cacheRead`, and
36+
derive input tokens from `stats.input_tokens - stats.cached` when needed.
3737

3838
Anthropic note: Anthropic staff told us OpenClaw-style Claude CLI usage is
3939
allowed again, so OpenClaw treats Claude CLI reuse and `claude -p` usage as

docs/reference/prompt-caching.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -163,10 +163,11 @@ If the provider does not support this cache mode, `cacheRetention` has no effect
163163
OpenClaw manages a provider-native `cachedContents` resource rather than
164164
injecting cache markers into the request.
165165

166-
### Gemini CLI JSON usage
166+
### Gemini CLI usage
167167

168-
- Gemini CLI JSON output can also surface cache hits through `stats.cached`;
169-
OpenClaw maps that to `cacheRead`.
168+
- Gemini CLI `stream-json` output can surface cache hits through `stats.cached`;
169+
OpenClaw maps that to `cacheRead`. Legacy `--output-format json` overrides use
170+
the same usage normalization.
170171
- If the CLI omits a direct `stats.input` value, OpenClaw derives input tokens
171172
from `stats.input_tokens - stats.cached`.
172173
- This is usage normalization only. It does not mean OpenClaw is creating

docs/reference/token-use.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,11 @@ Usage surfaces normalize common provider-native field aliases before display.
9292
For OpenAI-family Responses traffic, that includes both `input_tokens` /
9393
`output_tokens` and `prompt_tokens` / `completion_tokens`, so transport-specific
9494
field names do not change `/status`, `/usage`, or session summaries.
95-
Gemini CLI JSON usage is normalized too: reply text comes from `response`, and
96-
`stats.cached` maps to `cacheRead` with `stats.input_tokens - stats.cached`
97-
used when the CLI omits an explicit `stats.input` field.
95+
Gemini CLI usage is normalized too: the default `stream-json` parser reads
96+
assistant `message` events, and `stats.cached` maps to `cacheRead` with
97+
`stats.input_tokens - stats.cached` used when the CLI omits an explicit
98+
`stats.input` field. Legacy JSON overrides still read reply text from
99+
`response`.
98100
For native OpenAI-family Responses traffic, WebSocket/SSE usage aliases are
99101
normalized the same way, and totals fall back to normalized input + output when
100102
`total_tokens` is missing or `0`.

extensions/google/cli-backend.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ type PreparedGeminiCliExecution = {
4949
cleanup?: () => Promise<void>;
5050
};
5151

52+
type GeminiCliBackendConfig = CliBackendPlugin["config"];
53+
type GeminiCliOutputMode = NonNullable<GeminiCliBackendConfig["output"]>;
54+
5255
function normalizeString(value: string | undefined): string | undefined {
5356
const trimmed = value?.trim();
5457
return trimmed ? trimmed : undefined;
@@ -403,6 +406,47 @@ async function prepareGeminiCliAuthHome(
403406
return null;
404407
}
405408

409+
function mapGeminiCliOutputFormat(value: string | undefined): GeminiCliOutputMode | undefined {
410+
if (value === "stream-json") {
411+
return "jsonl";
412+
}
413+
if (value === "json" || value === "text") {
414+
return value;
415+
}
416+
return undefined;
417+
}
418+
419+
function readGeminiCliOutputFormat(args: readonly string[] | undefined): GeminiCliOutputMode {
420+
for (let index = 0; index < (args?.length ?? 0); index += 1) {
421+
const arg = args?.[index];
422+
if (arg === "--output-format" || arg === "-o") {
423+
return mapGeminiCliOutputFormat(args?.[index + 1]) ?? "text";
424+
}
425+
const inline = arg?.startsWith("--output-format=")
426+
? arg.slice("--output-format=".length)
427+
: arg?.startsWith("-o=")
428+
? arg.slice("-o=".length)
429+
: undefined;
430+
const mapped = mapGeminiCliOutputFormat(inline);
431+
if (mapped) {
432+
return mapped;
433+
}
434+
}
435+
return "text";
436+
}
437+
438+
function normalizeGeminiCliBackendConfig(config: GeminiCliBackendConfig): GeminiCliBackendConfig {
439+
const output = readGeminiCliOutputFormat(config.args);
440+
const resumeOutput = readGeminiCliOutputFormat(config.resumeArgs ?? config.args);
441+
const usesStreamJson = output === "jsonl" || resumeOutput === "jsonl";
442+
return {
443+
...config,
444+
output,
445+
resumeOutput,
446+
jsonlDialect: usesStreamJson ? "gemini-stream-json" : undefined,
447+
};
448+
}
449+
406450
export function buildGoogleGeminiCliBackend(): CliBackendPlugin {
407451
return {
408452
id: "google-gemini-cli",
@@ -420,6 +464,7 @@ export function buildGoogleGeminiCliBackend(): CliBackendPlugin {
420464
bundleMcpMode: "gemini-system-settings",
421465
nativeToolMode: "always-on",
422466
authEpochMode: "profile-only",
467+
normalizeConfig: normalizeGeminiCliBackendConfig,
423468
prepareExecution: async (ctx) =>
424469
await prepareGeminiCliAuthHome(
425470
{
@@ -433,18 +478,19 @@ export function buildGoogleGeminiCliBackend(): CliBackendPlugin {
433478
),
434479
config: {
435480
command: "gemini",
436-
args: ["--skip-trust", "--output-format", "json", "--prompt", "{prompt}"],
481+
args: ["--skip-trust", "--output-format", "stream-json", "--prompt", "{prompt}"],
437482
resumeArgs: [
438483
"--skip-trust",
439484
"--resume",
440485
"{sessionId}",
441486
"--output-format",
442-
"json",
487+
"stream-json",
443488
"--prompt",
444489
"{prompt}",
445490
],
446-
output: "json",
491+
output: "jsonl",
447492
input: "arg",
493+
jsonlDialect: "gemini-stream-json",
448494
imageArg: "@",
449495
imagePathScope: "workspace",
450496
modelArg: "--model",

extensions/google/setup-api.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,48 @@ describe("google setup entry", () => {
9191
});
9292
});
9393

94+
describe("google gemini cli backend config", () => {
95+
it("keeps legacy json output overrides on the json parser", () => {
96+
const backend = buildGoogleGeminiCliBackend();
97+
const normalized = backend.normalizeConfig?.({
98+
...backend.config,
99+
args: ["--skip-trust", "--output-format", "json", "--prompt", "{prompt}"],
100+
resumeArgs: [
101+
"--skip-trust",
102+
"--resume",
103+
"{sessionId}",
104+
"--output-format=json",
105+
"--prompt",
106+
"{prompt}",
107+
],
108+
});
109+
110+
expect(normalized?.output).toBe("json");
111+
expect(normalized?.resumeOutput).toBe("json");
112+
expect(normalized?.jsonlDialect).toBeUndefined();
113+
});
114+
115+
it("keeps short stream-json output overrides on the jsonl parser", () => {
116+
const backend = buildGoogleGeminiCliBackend();
117+
const normalized = backend.normalizeConfig?.({
118+
...backend.config,
119+
args: ["--skip-trust", "-o", "stream-json", "--prompt", "{prompt}"],
120+
resumeArgs: [
121+
"--skip-trust",
122+
"--resume",
123+
"{sessionId}",
124+
"-o=stream-json",
125+
"--prompt",
126+
"{prompt}",
127+
],
128+
});
129+
130+
expect(normalized?.output).toBe("jsonl");
131+
expect(normalized?.resumeOutput).toBe("jsonl");
132+
expect(normalized?.jsonlDialect).toBe("gemini-stream-json");
133+
});
134+
});
135+
94136
describe("google gemini cli backend auth bridge", () => {
95137
it("materializes selected OpenClaw OAuth credentials into a persistent profile-scoped Gemini CLI home", async () => {
96138
const backend = buildGoogleGeminiCliBackend();

0 commit comments

Comments
 (0)