Skip to content

feat(language-model): add OAuthLanguageModel for claude/codex/gemini OAuth subprocess auth#52

Open
lelouar wants to merge 2 commits into
SynaLinks:mainfrom
lelouar:feat/oauth-cli-language-model
Open

feat(language-model): add OAuthLanguageModel for claude/codex/gemini OAuth subprocess auth#52
lelouar wants to merge 2 commits into
SynaLinks:mainfrom
lelouar:feat/oauth-cli-language-model

Conversation

@lelouar

@lelouar lelouar commented Apr 28, 2026

Copy link
Copy Markdown

TL;DR

Adds synalinks.OAuthCLILanguageModel — a drop-in LanguageModel subclass that bridges to the locally-installed claude, codex, and gemini interactive CLIs as a subprocess. This unlocks an auth modality the framework cannot currently reach: OAuth-only subscriptions (Claude Max, ChatGPT Plus/Pro, Google account) where the user has no usable API key and therefore no path to litellm.

  • 1 new module (~660 lines, stdlib-only, no new deps)
  • 1 colocated test file (5 tests, run without any CLI binary installed)
  • 1 line in language_models/__init__.py to register the class for serialization

The base LanguageModel is not modified.


Motivation — why this belongs in synalinks, not in user code

Today, every flagship hosted model in 2026 ships through OAuth subscription as the dominant or sole distribution channel for individual developers:

Provider API key path Subscription path
Anthropic Claude requires Pro/Build credits, separate billing Claude Max — included, no API key
OpenAI / Codex requires usage-based billing on top ChatGPT Plus/Pro — included, no API key
Google Gemini requires Vertex / AI Studio key OAuth via google account — included

A user on a flat-rate subscription cannot today plug their preferred model into a synalinks Generator, ChainOfThought, or FunctionCallingAgent, because litellm requires an API key. They have to either:

  1. Maintain a parallel synalinks adapter in their own project (which is what motivated this PR — we wrote it twice already), or
  2. Pay separately for API access on top of their already-paid subscription.

The interactive CLIs (claude, codex, gemini) already authenticate locally via OAuth and already accept structured input/output flags. Wrapping them as a LanguageModel is a generic, framework-level concern: the subclass is the same regardless of who uses it, and the non-obvious bits (gemini HOME isolation, codex --output-schema strict-mode) are the kind of hard-won knowledge that belongs in the framework rather than rediscovered downstream.


What this adds

import synalinks

# Drop-in: any module that accepts a LanguageModel accepts this.
lm = synalinks.OAuthCLILanguageModel(provider=\"codex\", model=\"gpt-5.2\")
generator = synalinks.Generator(language_model=lm, data_model=MySchema)

# Or via env vars:
#   SYNALINKS_CLI_PROVIDER=claude
#   SYNALINKS_CLI_MODEL=claude-sonnet-4-6
lm = synalinks.language_models.get_oauth_cli_language_model()

Public surface:

  • synalinks.OAuthCLILanguageModel(provider, model, timeout=180, retry=2, reasoning=\"medium\", effort=\"low\", caching=True, fallback=None)
  • synalinks.language_models.get_oauth_cli_language_model() — env-driven factory
  • Env vars: SYNALINKS_CLI_{PROVIDER,MODEL,TIMEOUT,REASONING,EFFORT}, SYNALINKS_GEMINI_HOME

Internal helpers (kept private, exported only for testing):

  • ask_llm_via_cli(provider, prompt, …) — the low-level async call
  • _make_strict_schema(schema) — codex strict-mode schema rewrite
  • _extract_json(text) — JSON extraction fallback for claude/gemini
  • ensure_minimal_gemini_home() — idempotent isolated HOME setup

Architecture

The class is a thin subclass of LanguageModel:

  • __init__ calls super().__init__(model=f\"oauth/{provider}/{cli_model or 'default'}\", …) so the canonical model string round-trips through the existing serialization machinery and the oauth/ prefix is unambiguous.
  • __call__ is overridden — it does not go through litellm. It formats the messages into a single prompt, dispatches to the right per-provider command builder, awaits asyncio.subprocess, and returns either the parsed JSON dict (when schema is given) or the standard {role, content, tool_call_id, tool_calls, created_at, usage} shape produced by the parent class.
  • streaming=True raises explicitly — subprocess stdout buffering would make token-by-token streaming brittle and inconsistent with the litellm path.
  • get_config / from_config round-trip every field including fallback.

The subprocess environment is built by stripping the API-key vars (ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, GEMINI_API_KEY, XAI_API_KEY) before exec — without this, the CLIs auto-detect them and silently bypass OAuth, which on a subscription-only account either fails loudly (wrong auth) or silently routes to the wrong workspace.


Per-provider design notes (latency-critical)

These flag combinations were measured empirically. Each one is in there for a reason; please don't simplify them away without benchmarking.

codex (gpt-5.2 / gpt-5.3-codex / gpt-5.4)

codex exec --ephemeral --skip-git-repo-check
  -c personality=\"none\"
  -c model_reasoning_effort=\"low\"
  -o <tempfile>
  [-m <model>]
  [--output-schema <schema_tempfile>]
  -                          # prompt from stdin
  • --ephemeral skips conversation persistence.
  • --skip-git-repo-check saves a parent-dir scan we never need.
  • personality=\"none\" disables system-prompt injection.
  • --output-schema FILE is the big one: when a JSON schema is provided, codex forwards it to the OpenAI Responses API with strict=true, so the server enforces the schema and we never have to retry on parse failures. Empirically this is faster than asking for JSON in the prompt.
  • reasoning=low is the floor — minimal is rejected by the Responses API whenever web_search is attached, which it always is for ChatGPT accounts.
  • Output is read from a tempfile rather than stdout to avoid mixing logs.

The strict-mode requirement of the Responses API is non-trivial: every object node must declare additionalProperties: false and every property key must appear in required. Synalinks-emitted schemas frequently omit optional fields from required and sometimes set additionalProperties: true for open param bags. _make_strict_schema() rewrites the schema recursively (including inside $defs) without mutating the input, so codex strict mode accepts it. There's a unit test for this.

claude (claude-sonnet-4-6, etc.)

claude --print --no-session-persistence
  --tools \"\"
  --disable-slash-commands
  --effort low
  --output-format text
  [--model <model>]
  • --bare is not used because it requires ANTHROPIC_API_KEY, which is exactly what an OAuth-subscription user does not have.
  • --tools \"\" and --disable-slash-commands cut tool-discovery overhead.
  • --effort low is exposed as a constructor knob; a synalinks Trainer/Optimizer can dial it up per call.
  • No --json-schema: contrary to codex, claude's structured-output mode adds latency on this path. We fall back to prompt-based JSON extraction (instructive system prompt + balanced-brace + fenced-code parser).

gemini

gemini -p \"\" -o text --approval-mode yolo -e \"\"
  [-m <model>]

with HOME rewritten to a minimal isolated directory:

  • The user's real ~/.gemini/ may load hooks, skills, agents, and enableAgents=true, adding tens of seconds of cold-start overhead per invocation. A baseline gemini call against a real user HOME measured ~63s; with the isolated HOME it drops to 8–16s.
  • ensure_minimal_gemini_home() creates ~/.synalinks_gemini_home/.gemini/ (or \$SYNALINKS_GEMINI_HOME if set), symlinks just the OAuth credential files (oauth_creds.json, google_accounts.json, installation_id), and writes a minimal settings.json with enableAgents=false and ide.enabled=false. Idempotent and side-effect-free across calls.
  • -m is mandatory — without it, gemini falls back to a default that triples latency.
  • -e \"\" disables extensions; --approval-mode yolo bypasses the interactive approval prompt.

Why this shape, not a single "CLI runner"

The three CLIs differ enough that a unified abstraction would either lose the optimizations (slow) or expose ten knobs that only apply to one provider. Keeping three small command builders + one async dispatcher is clearer and lets reviewers verify each branch in isolation.


Trade-offs / honest caveats

These are documented in the module docstring; flagging them here too:

  • Process-spawn cost per call. Each LLM call forks a subprocess. For high-QPS workloads, the existing litellm-backed LanguageModel (HTTP keep-alive) is preferable. This adapter is positioned as the OAuth fallback path, not the primary one.
  • No streaming. Subprocess stdout buffering makes token-by-token streaming brittle; streaming=True raises explicitly. If you need streaming on OAuth, the right answer is to fix it inside the CLI vendors, not here.
  • Cost reporting is 0.0. OAuth subscriptions are flat-rate; per-call cost is genuinely unknown. We do not fake a number — last_call_cost and cumulated_cost stay at zero, so Trainer/Optimizer paths that sum cost will see zero. Documented in the docstring.
  • Auth-file refresh is delegated to the CLIs themselves. An earlier draft pre-checked the OAuth token expiry on every call and ran <cli> auth refresh proactively; we removed that — it added 1 file-read+JSON-parse to every call to save 1 retry per ~hour when a token expires. The CLIs handle their own refresh internally; net latency win is on the side of removal.
  • Platform PATH coupling. The previous draft prepended a hard-coded nvm Node path; that was removed before opening this PR — it's a developer-machine artifact, not framework-level concern. Users who need Node on PATH should manage that themselves.

Pros / Cons / Value

Value (why this PR specifically)

  • Unlocks an auth modality LanguageModel cannot reach today.
  • Drop-in: every existing Generator, ChainOfThought, FunctionCallingAgent accepts it without modification — we exercised this downstream before opening the PR.
  • Schema-aware where possible: codex gets server-enforced strict JSON; claude/gemini get a robust prompt-based extractor with three cascading parsers (raw → fenced → balanced-brace).
  • Concentrates the gemini HOME-isolation trick into the framework so future users don't pay the 63s cold-start tax.

Pros

  • Additive only. Zero changes to base LanguageModel. No behavior change for current users.
  • Zero new dependencies. stdlib only (asyncio, subprocess, tempfile, pathlib, re, json, time, logging).
  • Tests don't need the CLIs installed (helpers are pure-Python; the subprocess test asserts the unknown-provider error path).
  • Symmetrical with the existing LanguageModel API surface: same __call__(messages, schema, streaming), same get_config/from_config, same register_synalinks_serializable decorator.

Cons / honest caveats

(See Trade-offs above.)

Alternatives considered

  • Keep it in user code. Rejected: we wrote essentially this same module twice already in two different downstream projects. The abstraction is generic.
  • Plugin-style external package. Rejected: synalinks already exposes LanguageModel as the documented extension point (see ALL_OBJECTS in language_models/__init__.py).
  • Push prompt-based JSON extraction into the base LanguageModel. Rejected as out of scope for this PR. If maintainers want, the _extract_json / _make_strict_schema helpers are lift-and-shift candidates for a follow-up refactor.

Testing

Colocated test file: synalinks/src/language_models/oauth_cli_language_model_test.py

  • test_unknown_provider_returns_error_stringask_llm_via_cli(provider=\"bogus\") returns the error sentinel, no subprocess spawned.
  • test_make_strict_schema_recursive — feeds a nested object schema with additionalProperties=True and verifies the output sets it to False everywhere (including inside \$defs) and adds every property to required. Also verifies the input is not mutated.
  • test_extract_json_balanced_and_fenced — parses three text variants (raw JSON, ```json fence, leading prose + balanced braces) all to the same dict; verifies the no-JSON case returns None.
  • test_serialization_roundtrip — instantiates with all knobs, calls get_config() then from_config(config), and verifies every field round-trips. Also asserts the canonical model string is oauth/{provider}/{model}.
  • test_default_model_string_when_empty — empty model → oauth/{provider}/default.

The full synalinks/src/language_models/ suite (10 tests, including the 5 pre-existing LanguageModel tests) passes locally:

$ uv run pytest synalinks/src/language_models/ -v
============================== 10 passed in 14.91s ==============================

uvx ruff check and uvx ruff format --check clean.


Compatibility

  • No breaking changes. Base LanguageModel is untouched.
  • No new runtime dependencies. stdlib only.
  • No new optional dependencies. The CLIs are runtime requirements only when the user instantiates OAuthCLILanguageModel — they are not imported.
  • Existing imports continue to work. synalinks.LanguageModel is unchanged; the new class is additive at synalinks.OAuthCLILanguageModel.

Files

  • synalinks/src/language_models/oauth_cli_language_model.py (new, ~660 lines)
  • synalinks/src/language_models/oauth_cli_language_model_test.py (new, ~95 lines)
  • synalinks/src/language_models/__init__.py (+3 lines: import + add to ALL_OBJECTS)

No regenerated synalinks/api/ stubs in this commit — happy to add them in a follow-up commit if maintainers want them landed in the same PR. The @synalinks_export decorators dispatch at import time so the public path works without the regenerated stubs.


Naming / API bikeshed

Open to feedback on:

  • Class name: OAuthCLILanguageModel vs CLILanguageModel vs SubprocessLanguageModel. Picked the OAuth-explicit name because that's the actual differentiator vs litellm.
  • Env-var prefix: chose SYNALINKS_CLI_* to stay namespaced and avoid clashes. Open to SYNALINKS_OAUTH_CLI_* if maintainers prefer.
  • Factory location: currently synalinks.language_models.get_oauth_cli_language_model. Could go on the class as OAuthCLILanguageModel.from_env() instead.

Happy to iterate on any of these.

@YoanSallami

Copy link
Copy Markdown
Contributor

Ok for the concept, I agree that it would be a nice addition, but the implementation is breaking plenty of style rules, first the model parameter should be "provider/model", then why using an additional method to connect, can't that be done already at the class construction. remove get_oauth_cli_language_model, rename OAuthLanguageModel and keep the parameters consistent with LanguageModel so its an in-place replacement

@lelouar lelouar force-pushed the feat/oauth-cli-language-model branch from a6a8698 to 12db86b Compare April 28, 2026 13:00
@lelouar

lelouar commented Apr 28, 2026

Copy link
Copy Markdown
Author

Thanks @lelouar — fully agree on all four points. Force-pushed an updated commit (12db86b) addressing each one:

Changes vs the original commit

  1. model="provider/model" — replaces the separate provider/model kwargs. Construction now goes:

    synalinks.OAuthLanguageModel(model="codex/gpt-5.2")
    synalinks.OAuthLanguageModel(model="claude/claude-sonnet-4-6")
    synalinks.OAuthLanguageModel(model="gemini/gemini-2.0-flash")

    Anything outside {claude, codex, gemini} raises ValueError at construction (including missing / and unsupported providers like openai/...). Test added: test_invalid_model_string_raises.

  2. Removed get_oauth_cli_language_model — no env-driven factory; users construct directly. The SYNALINKS_CLI_* env vars are gone too. Only SYNALINKS_GEMINI_HOME remains because it's used at subprocess-exec time to point at the isolated gemini HOME.

  3. Renamed OAuthCLILanguageModelOAuthLanguageModel — class, file (oauth_language_model.py), test file, and serialization label all updated.

  4. In-place replacement, signature aligned with LanguageModel — constructor is now (model, api_base, timeout=600, retry=5, fallback=None, caching=False), byte-for-byte the same as the parent. reasoning_effort moved to __call__ kwargs (matches the existing parent kwarg, which already does kwargs.pop("reasoning_effort", "none")); we just route it to codex's model_reasoning_effort or claude's --effort internally. get_config/from_config overrides are dropped — the parent's versions work as-is now that the signature matches. Verified end-to-end:

    >>> lm = synalinks.OAuthLanguageModel(model="codex/gpt-5.2")
    >>> cfg = synalinks.language_models.serialize(lm)
    >>> rebuilt = synalinks.language_models.deserialize(cfg)
    >>> isinstance(rebuilt, synalinks.LanguageModel)
    True

Other adjustments

  • Regenerated synalinks/__init__.py + synalinks/api/__init__.py + synalinks/api/language_models/__init__.py via shell/api_gen.sh so synalinks.OAuthLanguageModel works without the user having to import from synalinks.src.*.
  • Tests rewritten to match the new shape (11 passing, still no CLI binaries required).
  • Lint + format clean (shell/lint.sh).

Diffstat shrunk to +738 / 0 across 6 files.

Let me know if there's anything else to tighten.

@lelouar lelouar changed the title feat(language-model): add OAuthCLILanguageModel for claude/codex/gemini OAuth subprocess auth feat(language-model): add OAuthLanguageModel for claude/codex/gemini OAuth subprocess auth Apr 28, 2026
@lelouar

lelouar commented Apr 30, 2026

Copy link
Copy Markdown
Author

Self-review: two regressions to fix before merge

While re-reading the PR against the previous internal version of the same
adapter, two empirically-validated findings did not survive the cleanup
and should be restored:

1. gemini hangs on stdin-piped prompt for gemini-2.5-flash

The current command builder is:

def _build_gemini_cmd(model: str) -> list[str]:
    cmd = ["gemini", "-p", "", "-o", "text", "--approval-mode", "yolo", "-e", ""]
    ...

with the prompt piped on stdin in ask_llm_via_cli. Empirically this hangs
on gemini-2.5-flash (the default on most accounts). The fix is to pass
the prompt through -p and close stdin:

cmd = ["gemini", "-p", prompt, "-o", "text", "--yolo", "-e", ""]
if model:
    cmd += ["-m", model]
# ...
proc = await asyncio.create_subprocess_exec(
    *cmd, stdin=asyncio.subprocess.DEVNULL, ...,
)

Suggested change: route the prompt through `-p` for `provider == "gemini"`
and set `stdin=DEVNULL` for that branch only (codex still needs stdin,
claude still needs stdin).

2. Missing GEMINI_CLI_TRUST_WORKSPACE=true

Gemini CLI v0.37+ refuses to run in a directory it considers "untrusted"
without an interactive trust prompt — there is no headless escape hatch
besides this env var. For a subprocess invocation that is by definition
non-interactive, we have to opt in:

env = {k: v for k, v in os.environ.items() if k not in _API_KEY_VARS}
if provider == "gemini":
    env["HOME"] = ensure_minimal_gemini_home()
    env["GEMINI_CLI_TRUST_WORKSPACE"] = "true"   # add this

Without it, gemini exits with a trust-prompt error on most users' working
directories.

Test coverage gap

The current test suite only exercises `provider="bogus"` for the subprocess
path. A pure-Python assertion that the gemini command builder emits
`-p ` (not `-p ""`) when a prompt is provided would have caught
regression #1. Happy to add it in this PR or as a follow-up.


Both fixes are small and additive (no changes to codex/claude paths). I'll
push a fixup commit unless reviewers prefer a separate PR.

lelouar pushed a commit to lelouar/synalinks that referenced this pull request May 1, 2026
Two regressions identified during self-review of PR SynaLinks#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.
@lelouar

lelouar commented May 1, 2026

Copy link
Copy Markdown
Author

Pushed d97bc9d — both self-identified regressions fixed:

1. gemini stdin hang_build_gemini_cmd(model, prompt) now routes the prompt through -p <prompt> (was -p ""); for the gemini branch only, the subprocess is invoked with stdin=asyncio.subprocess.DEVNULL instead of piping. codex/claude paths are untouched.

2. workspace trustenv["GEMINI_CLI_TRUST_WORKSPACE"] = "true" added next to the isolated HOME setup, so headless calls don't trip Gemini CLI v0.37+'s interactive trust prompt.

Test coverage — added test_gemini_cmd_routes_prompt_through_p_flag asserting the builder emits -p <prompt> (not -p "") when a prompt is provided. 7/7 tests pass, lint + format clean.

lelouar pushed a commit to lelouar/synalinks that referenced this pull request May 5, 2026
Two regressions identified during self-review of PR SynaLinks#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.
@lelouar lelouar force-pushed the feat/oauth-cli-language-model branch from d97bc9d to c90349f Compare May 5, 2026 16:00
@lelouar

lelouar commented May 5, 2026

Copy link
Copy Markdown
Author

Rebased onto latest main (eb62b67) — all conflicts resolved ✅

Changes during rebase:

  1. Location refactor: Moved oauth_language_model.py/oauth_language_model_test.py to new path synalinks/src/modules/language_models/ (upstream reorganized language_models into modules)
  2. Import paths: Updated all imports to use new location
  3. Parent-compat kwargs: Added **kwargs to init to handle name, description, and other parent-class parameters passed during deserialization
  4. Export files: Resolved auto-generated api/init.py / api/language_models/init.py to include OAuthLanguageModel exports

All 7 tests pass. Mergeable ✅

@YoanSallami

Copy link
Copy Markdown
Contributor

How structured output is handled when using the OAuthLanguageModel ?

@lelouar

lelouar commented May 12, 2026

Copy link
Copy Markdown
Author

Structured output follows a per-provider strategy because only codex exposes a server-side schema endpoint over OAuth:

codex — server-side enforcement via --output-schema
The schema is written to a temp file, hardened with _make_strict_schema() (recursively sets additionalProperties: false + fills required for every object node, as the OpenAI Responses API requires), and passed to codex exec --output-schema <path>. The model's output is read back from a separate temp file (-o <path>), bypassing stdout. No JSON extraction heuristic needed; the Responses API guarantees a conforming object.

claude / gemini — prompt-based extraction
Neither CLI exposes a --output-schema / --json-schema flag over OAuth. When schema is provided, the prompt is prepended with:

Return ONLY one valid JSON object matching this schema.
No markdown fences, no explanations.

Schema:
<schema as JSON>

Conversation:
<original prompt>

The raw stdout is then passed through _extract_json() which tries, in order: (1) direct json.loads, (2) markdown-fenced block regex, (3) balanced-brace scan. If none of these yields a dict, the call is retried (up to retry times, default 5).

When no schema is provided, all three providers return a plain-text ChatMessage dict with role=assistant and content=raw_output.

The tradeoff is intentional: codex is the only OAuth-accessible model that supports structured output reliably server-side. For claude/gemini the prompt-injection approach is best-effort — it works well for well-prompted schemas but has no API-level guarantee. An explicit note could be added to the docstring if you'd like.

Raoul RAFFEL added 2 commits June 12, 2026 14:33
…OAuth subprocess auth

In-place subclass of `LanguageModel` that bridges to the locally-installed
`claude`, `codex`, and `gemini` CLIs as a subprocess. Unlocks an auth
modality the framework cannot reach today: OAuth-only subscriptions
(Claude Max, ChatGPT Plus/Pro, Google account) where the user has no
usable API key.

Constructor signature is identical to `LanguageModel` (model, api_base,
timeout, retry, fallback, caching). Provider is encoded in the `model`
string as `provider/model`, e.g.

  synalinks.OAuthLanguageModel(model="codex/gpt-5.2")
  synalinks.OAuthLanguageModel(model="claude/claude-sonnet-4-6")
  synalinks.OAuthLanguageModel(model="gemini/gemini-2.0-flash")

Only `claude`, `codex`, `gemini` are accepted as providers (any other
prefix raises ValueError). `reasoning_effort` is consumed at call time
as the same kwarg the parent already accepts, mapped to codex
`model_reasoning_effort` and claude `--effort`. `streaming=True` raises.

Latency-critical decisions (retained from the prior implementation):
  * codex passes the JSON schema via `--output-schema` so the Responses
    API enforces strict mode server-side (zero parse retries).
  * claude does NOT use `--bare` (requires API key) nor `--json-schema`
    (measurably slower); fall back to prompt-based JSON extraction.
  * gemini runs against an isolated minimal HOME at
    `~/.synalinks_gemini_home` to bypass user hooks/skills/agents
    (~63s -> 8-16s on cold start). Override via `SYNALINKS_GEMINI_HOME`.

Cost reporting is `0.0` (subscriptions are flat-rate; no fake numbers).

Additive only: no changes to base `LanguageModel`, no new dependencies
(stdlib only), 11 tests pass without claude/codex/gemini binaries
installed.

Files:
  * new: synalinks/src/language_models/oauth_language_model.py
  * new: synalinks/src/language_models/oauth_language_model_test.py
  * mod: synalinks/src/language_models/__init__.py (registers in
    ALL_OBJECTS for serialize/deserialize round-trip)
  * mod: synalinks/api/__init__.py + synalinks/api/language_models/__init__.py
    + synalinks/__init__.py (regenerated by shell/api_gen.sh)
Two regressions identified during self-review of PR SynaLinks#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.
@lelouar lelouar force-pushed the feat/oauth-cli-language-model branch from c90349f to 7296041 Compare June 12, 2026 12:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants