Skip to content

Commit 7296041

Browse files
author
Raoul RAFFEL
committed
fix(oauth-language-model): gemini stdin hang + workspace trust
Two regressions identified during self-review of PR #52, both restored from the previous internal version of this adapter: 1. `gemini-2.5-flash` hangs on stdin-piped prompts. Route the prompt through `-p <prompt>` (was `-p ""`) and close stdin with `DEVNULL` for the gemini branch only — codex/claude still read from stdin. 2. Gemini CLI v0.37+ refuses to run in an "untrusted" workspace without an interactive trust prompt. Subprocess invocations are non-interactive by definition, so set `GEMINI_CLI_TRUST_WORKSPACE=true` explicitly. Adds a regression test asserting the gemini command builder emits `-p <prompt>` (not `-p ""`) when a prompt is provided. codex/claude paths are untouched.
1 parent cee41d5 commit 7296041

2 files changed

Lines changed: 45 additions & 10 deletions

File tree

synalinks/src/modules/language_models/oauth_language_model.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454

5555
from synalinks.src.api_export import synalinks_export
5656
from synalinks.src.backend import ChatRole
57-
from synalinks.src.language_models.language_model import LanguageModel
57+
from synalinks.src.modules.language_models.language_model import LanguageModel
5858
from synalinks.src.saving.object_registration import register_synalinks_serializable
5959

6060
logger = logging.getLogger(__name__)
@@ -201,13 +201,15 @@ def _build_claude_cmd(
201201
return cmd
202202

203203

204-
def _build_gemini_cmd(model: str) -> list[str]:
204+
def _build_gemini_cmd(model: str, prompt: str) -> list[str]:
205205
# `-m` is mandatory — omitting it triples cold-start latency.
206206
# `-e ""` disables extensions; `--approval-mode yolo` skips prompts.
207+
# The prompt MUST go through `-p`; piping it on stdin hangs
208+
# `gemini-2.5-flash` (the default on most accounts).
207209
cmd = [
208210
"gemini",
209211
"-p",
210-
"",
212+
prompt,
211213
"-o",
212214
"text",
213215
"--approval-mode",
@@ -394,14 +396,14 @@ async def ask_llm_via_cli(
394396

395397
elif provider == "gemini":
396398
ensure_minimal_gemini_home()
397-
cmd = _build_gemini_cmd(model)
398399
if schema:
399400
prompt = (
400401
"Return ONLY one valid JSON object matching this schema. "
401402
"No markdown fences, no explanations.\n\n"
402403
f"Schema:\n{json.dumps(schema, indent=2)}\n\n"
403404
f"Conversation:\n{prompt}\n"
404405
)
406+
cmd = _build_gemini_cmd(model, prompt)
405407
else:
406408
return (
407409
f"❌ Unknown provider '{provider}' (expected: claude|codex|gemini)",
@@ -411,11 +413,25 @@ async def ask_llm_via_cli(
411413
env = {k: v for k, v in os.environ.items() if k not in _API_KEY_VARS}
412414
if provider == "gemini":
413415
env["HOME"] = ensure_minimal_gemini_home()
416+
# Gemini CLI v0.37+ refuses to run in an "untrusted" workspace
417+
# without an interactive trust prompt; subprocess invocations
418+
# are non-interactive by definition, so opt in explicitly.
419+
env["GEMINI_CLI_TRUST_WORKSPACE"] = "true"
420+
421+
# gemini receives the prompt via `-p`, not stdin: piping the prompt
422+
# in hangs `gemini-2.5-flash`. codex/claude still read prompt from
423+
# stdin.
424+
if provider == "gemini":
425+
stdin_mode = asyncio.subprocess.DEVNULL
426+
stdin_payload: bytes | None = None
427+
else:
428+
stdin_mode = asyncio.subprocess.PIPE
429+
stdin_payload = prompt.encode("utf-8")
414430

415431
try:
416432
proc = await asyncio.create_subprocess_exec(
417433
*cmd,
418-
stdin=asyncio.subprocess.PIPE,
434+
stdin=stdin_mode,
419435
stdout=asyncio.subprocess.PIPE,
420436
stderr=asyncio.subprocess.PIPE,
421437
env=env,
@@ -425,7 +441,7 @@ async def ask_llm_via_cli(
425441

426442
try:
427443
stdout_b, stderr_b = await asyncio.wait_for(
428-
proc.communicate(input=prompt.encode("utf-8")),
444+
proc.communicate(input=stdin_payload),
429445
timeout=float(timeout),
430446
)
431447
except asyncio.TimeoutError:
@@ -540,6 +556,7 @@ def __init__(
540556
retry=5,
541557
fallback=None,
542558
caching=False,
559+
**kwargs, # parent-compat (name, description, etc. ignored)
543560
):
544561
if model is None:
545562
raise ValueError("You need to set the `model` argument for any LanguageModel")

synalinks/src/modules/language_models/oauth_language_model_test.py

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,17 @@
33
import pytest
44

55
from synalinks.src import testing
6-
from synalinks.src.language_models.oauth_language_model import OAuthLanguageModel
7-
from synalinks.src.language_models.oauth_language_model import _extract_json
8-
from synalinks.src.language_models.oauth_language_model import _make_strict_schema
9-
from synalinks.src.language_models.oauth_language_model import ask_llm_via_cli
6+
from synalinks.src.modules.language_models.oauth_language_model import (
7+
OAuthLanguageModel,
8+
)
9+
from synalinks.src.modules.language_models.oauth_language_model import (
10+
_build_gemini_cmd,
11+
)
12+
from synalinks.src.modules.language_models.oauth_language_model import _extract_json
13+
from synalinks.src.modules.language_models.oauth_language_model import (
14+
_make_strict_schema,
15+
)
16+
from synalinks.src.modules.language_models.oauth_language_model import ask_llm_via_cli
1017

1118

1219
class OAuthLanguageModelTest(testing.TestCase):
@@ -96,6 +103,17 @@ def test_serialization_roundtrip(self):
96103
self.assertEqual(rebuilt.provider, "claude")
97104
self.assertEqual(rebuilt.cli_model, "claude-sonnet-4-6")
98105

106+
def test_gemini_cmd_routes_prompt_through_p_flag(self):
107+
# Regression guard: piping the prompt on stdin hangs
108+
# `gemini-2.5-flash`, so the prompt must appear inline after `-p`
109+
# (and never as an empty string).
110+
cmd = _build_gemini_cmd("gemini-2.5-flash", "hello world")
111+
self.assertIn("-p", cmd)
112+
idx = cmd.index("-p")
113+
self.assertEqual(cmd[idx + 1], "hello world")
114+
self.assertNotIn("", cmd[idx + 1 : idx + 2])
115+
self.assertEqual(cmd[-2:], ["-m", "gemini-2.5-flash"])
116+
99117
def test_invalid_model_string_raises(self):
100118
with pytest.raises(ValueError, match="expects model"):
101119
OAuthLanguageModel(model="just-a-name-no-slash")

0 commit comments

Comments
 (0)