diff --git a/CHANGELOG.md b/CHANGELOG.md index 5638e76..5e5c5fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # fusionAIze Gate Changelog +## v2.1.4 - 2026-04-08 + +### Fixed + +- **Codex OAuth provider startup gating**: OAuth-backed Codex providers no longer get skipped during startup just because they do not use a static `api_key` +- **Codex helper resolution in Brew/libexec installs**: `faigate-auth` is now resolved robustly from the packaged runtime so Codex OAuth refresh works in Homebrew-managed installs +- **Codex responses endpoint handling**: explicit empty `chat_path` values are preserved, so Codex requests stay on `chatgpt.com/backend-api/codex/responses` instead of drifting back to `/chat/completions` +- **Codex streaming compatibility**: `stream=true` now uses the same responses adapter as non-stream requests and returns OpenAI-compatible SSE chunks without falling back to another provider + ## v2.1.3 - 2026-04-08 ### Added diff --git a/faigate/__init__.py b/faigate/__init__.py index 6c739a4..91b81b4 100644 --- a/faigate/__init__.py +++ b/faigate/__init__.py @@ -1,3 +1,3 @@ """fusionAIze Gate package.""" -__version__ = "2.1.3" +__version__ = "2.1.4" diff --git a/faigate/main.py b/faigate/main.py index 3bfeabc..53e63f0 100644 --- a/faigate/main.py +++ b/faigate/main.py @@ -50,7 +50,11 @@ get_community_hooks_loaded, get_virtual_providers, ) -from .lane_registry import get_provider_lane_binding, get_route_add_recommendations +from .lane_registry import ( + get_provider_lane_binding, + get_provider_transport_binding, + get_route_add_recommendations, +) from .metrics import MetricsStore, calc_cost from .provider_availability import ( record_availability_from_config, @@ -97,6 +101,24 @@ def _provider_catalog_config_path() -> str: return str(os.environ.get("FAIGATE_CONFIG_FILE") or "config.yaml") +def _provider_requires_static_api_key(name: str, cfg: dict[str, Any]) -> bool: + """Return whether one configured provider needs a static API key at startup. + + OAuth-backed routes inject credentials at request time via helper flows, so + they must not be filtered out just because ``api_key`` is empty in + ``config.yaml``. + """ + + transport = cfg.get("transport") + if not isinstance(transport, dict) or "requires_api_key" not in transport: + transport = get_provider_transport_binding( + name, + backend=str(cfg.get("backend", "openai-compat") or "openai-compat"), + contract=str(cfg.get("contract", "generic") or "generic"), + ) + return bool(transport.get("requires_api_key", True)) + + class PayloadTooLargeError(ValueError): """Raised when one request or upload exceeds configured size limits.""" @@ -2249,7 +2271,7 @@ async def lifespan(app: FastAPI): # Initialize provider backends for name, pcfg in _config.providers.items(): - if not pcfg.get("api_key"): + if _provider_requires_static_api_key(name, pcfg) and not pcfg.get("api_key"): logger.warning("Provider %s has no API key, skipping", name) continue _providers[name] = create_provider_backend(name, pcfg) diff --git a/faigate/oauth/backend.py b/faigate/oauth/backend.py index 896e0a2..1f9d5d0 100644 --- a/faigate/oauth/backend.py +++ b/faigate/oauth/backend.py @@ -8,7 +8,12 @@ import asyncio import json import logging +import os +import shlex +import shutil +import sys import time +from pathlib import Path from typing import Any import httpx @@ -77,6 +82,43 @@ def _create_wrapped_backend(self) -> ProviderBackend: wrapped_cfg["auth_optional"] = True return ProviderBackend(self.name, wrapped_cfg) + def _resolve_helper_argv(self) -> list[str] | None: + """Return an executable argv for helper commands when possible. + + Brew installs the real helper in ``libexec/bin``. Service and temporary + runtime PATHs do not always include that directory, so a bare + ``faigate-auth ...`` command can fail even when the helper is installed. + """ + + try: + argv = shlex.split(self.helper_cmd) + except ValueError: + return None + if not argv: + return None + + helper = argv[0] + if os.path.isabs(helper) or "/" in helper: + return argv + + resolved = shutil.which(helper) + if not resolved: + python_bin = Path(sys.executable).parent + prefix_bin = Path(sys.prefix) / "bin" + candidates = ( + python_bin / helper, + prefix_bin / helper, + python_bin.parent / "bin" / helper, + ) + for candidate in candidates: + if candidate.exists() and os.access(candidate, os.X_OK): + resolved = str(candidate) + break + if not resolved: + return None + argv[0] = resolved + return argv + async def _ensure_token(self) -> str: """Ensure a valid access token exists, refreshing or logging in if needed. @@ -128,12 +170,20 @@ async def _run_helper(self) -> dict[str, Any]: logger.info("Running OAuth helper: %s", self.helper_cmd) try: - # Run helper command - proc = await asyncio.create_subprocess_shell( - self.helper_cmd, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) + argv = self._resolve_helper_argv() + if argv is not None: + proc = await asyncio.create_subprocess_exec( + *argv, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + else: + # Fall back to shell execution for intentionally shell-based helpers. + proc = await asyncio.create_subprocess_shell( + self.helper_cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) stdout, stderr = await proc.communicate() if proc.returncode != 0: stderr_text = stderr.decode("utf-8", errors="replace").strip() diff --git a/faigate/providers.py b/faigate/providers.py index e4cf9e3..ce49ced 100644 --- a/faigate/providers.py +++ b/faigate/providers.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import logging import re import time @@ -156,8 +157,9 @@ async def close(self) -> None: await self._client.aclose() def _transport_path(self, key: str, default: str = "") -> str: - value = str(self.transport.get(key, default) or default).strip() - return value + if key in self.transport: + return str(self.transport.get(key, "") or "").strip() + return str(default).strip() def _transport_url(self, path: str) -> str: cleaned = str(path or "").strip() @@ -179,6 +181,283 @@ def _authorization_headers(self, *, content_type: str | None = None) -> dict[str headers.update(self.default_extra_headers) return headers + def _uses_codex_responses_api(self) -> bool: + """Return whether this provider targets the ChatGPT Codex responses API.""" + return str(self.transport.get("profile", "") or "").strip().lower() == "oauth-codex" + + def _flatten_text_content(self, content: Any) -> str: + """Collapse OpenAI-style message content into plain text for upstreams.""" + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for item in content: + if isinstance(item, dict): + if item.get("type") == "text": + parts.append(str(item.get("text") or "")) + elif item.get("type") == "input_text": + parts.append(str(item.get("text") or "")) + return " ".join(part for part in parts if part).strip() + return str(content or "") + + def _codex_effective_model(self, model: str) -> str: + """Map Gate-facing Codex aliases onto currently accepted upstream models.""" + normalized = str(model or "").strip() + if normalized == "gpt-5.4": + return "gpt-5-codex" + if normalized in {"gpt-5.3-codex-high", "gpt-5.3-codex-xhigh", "gpt-5.3-codex-low"}: + return "gpt-5.3-codex" + return normalized + + def _codex_reasoning_config( + self, + *, + extra_body: dict[str, Any] | None = None, + ) -> dict[str, Any] | None: + """Translate Gate reasoning hints into the Codex responses schema.""" + source = {**self.default_extra_body, **dict(extra_body or {})} + effort = str(source.get("reasoning_effort", "") or "").strip().lower() + if not effort: + return None + effort_map = { + "extra_high": "high", + "xhigh": "high", + "high": "high", + "medium": "medium", + "low": "low", + "minimal": "low", + "none": "low", + } + mapped = effort_map.get(effort) + if not mapped: + return None + return {"effort": mapped} + + def _build_codex_request_body( + self, + messages: list[dict[str, Any]], + *, + model: str, + stream: bool, + extra_body: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Build a ChatGPT Codex responses payload from OpenAI-style messages.""" + instructions_parts: list[str] = [] + input_messages: list[dict[str, Any]] = [] + for msg in messages: + role = str(msg.get("role", "user") or "user") + text = self._flatten_text_content(msg.get("content")) + if role == "system": + if text: + instructions_parts.append(text) + continue + if not text: + continue + input_messages.append({"role": role, "content": text}) + + body: dict[str, Any] = { + "model": self._codex_effective_model(model), + "instructions": "\n\n".join(instructions_parts), + "input": input_messages, + "store": False, + "stream": True, + } + reasoning = self._codex_reasoning_config(extra_body=extra_body) + if reasoning: + body["reasoning"] = reasoning + return body + + def _iter_sse_events(self, payload: str) -> list[dict[str, Any]]: + """Parse one completed SSE payload into JSON event objects.""" + events: list[dict[str, Any]] = [] + data_lines: list[str] = [] + for raw_line in payload.splitlines(): + line = raw_line.strip() + if line.startswith("data:"): + data_lines.append(line[5:].strip()) + continue + if line: + continue + if not data_lines: + continue + data_blob = "\n".join(data_lines) + data_lines = [] + try: + event = json.loads(data_blob) + except json.JSONDecodeError: + continue + if isinstance(event, dict): + events.append(event) + if data_lines: + try: + event = json.loads("\n".join(data_lines)) + except json.JSONDecodeError: + event = None + if isinstance(event, dict): + events.append(event) + return events + + def _codex_completion_from_sse( + self, + payload: str, + *, + requested_model: str, + latency_ms: float, + ) -> dict[str, Any]: + """Translate a completed Codex SSE transcript into one chat completion.""" + events = self._iter_sse_events(payload) + response_meta: dict[str, Any] = {} + text_parts: list[str] = [] + for event in events: + event_type = str(event.get("type", "") or "") + if event_type == "response.output_text.delta": + text_parts.append(str(event.get("delta") or "")) + elif event_type == "response.output_text.done" and not text_parts: + text_parts.append(str(event.get("text") or "")) + elif event_type == "response.completed": + response_meta = dict(event.get("response") or {}) + + usage = dict(response_meta.get("usage") or {}) + prompt_tokens = int(usage.get("input_tokens") or 0) + completion_tokens = int(usage.get("output_tokens") or 0) + total_tokens = int(usage.get("total_tokens") or (prompt_tokens + completion_tokens)) + content = "".join(text_parts).strip() + + return { + "id": response_meta.get("id") or f"chatcmpl-{int(time.time() * 1000)}", + "object": "chat.completion", + "created": int(response_meta.get("created_at") or time.time()), + "model": response_meta.get("model") or requested_model, + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": content}, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": total_tokens, + }, + "_faigate": { + "provider": self.name, + "model": response_meta.get("model") or requested_model, + "latency_ms": round(latency_ms, 1), + "cache_hit_tokens": 0, + "cache_miss_tokens": 0, + }, + } + + def _openai_sse_chunk(self, payload: dict[str, Any]) -> bytes: + """Encode one OpenAI-style SSE chunk.""" + return f"data: {json.dumps(payload, separators=(',', ':'))}\n\n".encode() + + async def _stream_codex_response( + self, + url: str, + headers: dict[str, str], + body: dict[str, Any], + *, + requested_model: str, + t0: float, + ) -> AsyncIterator[bytes]: + """Translate Codex responses SSE into OpenAI-compatible chat chunks.""" + async with self._client.stream("POST", url, json=body, headers=headers) as resp: + if resp.status_code >= 400: + error_text = await resp.aread() + self.health.record_failure(f"HTTP {resp.status_code}") + raise ProviderError(self.name, resp.status_code, error_text.decode()[:500]) + + completion_id = f"chatcmpl-{int(time.time() * 1000)}" + created = int(time.time()) + model_name = requested_model + emitted_role = False + data_lines: list[str] = [] + saw_content = False + + async for raw_line in resp.aiter_lines(): + line = raw_line.strip() + if line.startswith("data:"): + data_lines.append(line[5:].strip()) + continue + if line: + continue + if not data_lines: + continue + payload_blob = "\n".join(data_lines) + data_lines = [] + try: + event = json.loads(payload_blob) + except json.JSONDecodeError: + continue + + event_type = str(event.get("type", "") or "") + if event_type == "response.created": + response = dict(event.get("response") or {}) + completion_id = str(response.get("id") or completion_id) + created = int(response.get("created_at") or created) + model_name = str(response.get("model") or model_name) + continue + + if event_type == "response.output_text.delta": + delta = str(event.get("delta") or "") + if not delta: + continue + if not emitted_role: + yield self._openai_sse_chunk( + { + "id": completion_id, + "object": "chat.completion.chunk", + "created": created, + "model": model_name, + "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}], + } + ) + emitted_role = True + saw_content = True + self.health.record_success((time.time() - t0) * 1000) + yield self._openai_sse_chunk( + { + "id": completion_id, + "object": "chat.completion.chunk", + "created": created, + "model": model_name, + "choices": [{"index": 0, "delta": {"content": delta}, "finish_reason": None}], + } + ) + continue + + if event_type == "response.completed": + response = dict(event.get("response") or {}) + completion_id = str(response.get("id") or completion_id) + model_name = str(response.get("model") or model_name) + if not saw_content: + self.health.record_success((time.time() - t0) * 1000) + yield self._openai_sse_chunk( + { + "id": completion_id, + "object": "chat.completion.chunk", + "created": int(response.get("created_at") or created), + "model": model_name, + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], + } + ) + yield b"data: [DONE]\n\n" + return + + self.health.record_success((time.time() - t0) * 1000) + yield self._openai_sse_chunk( + { + "id": completion_id, + "object": "chat.completion.chunk", + "created": created, + "model": model_name, + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], + } + ) + yield b"data: [DONE]\n\n" + def _classify_request_readiness_issue(self, detail: str) -> tuple[str, str]: lowered = str(detail or "").lower() if not lowered: @@ -649,6 +928,45 @@ async def complete( max_tokens=max_tokens, ) + if self._uses_codex_responses_api(): + body = self._build_codex_request_body( + messages, + model=model, + stream=stream, + extra_body=extra_body, + ) + headers = self._authorization_headers(content_type="application/json") + url = self._transport_url(self._transport_path("chat_path", "/chat/completions")) + try: + if stream: + return self._stream_codex_response( + url, + headers, + body, + requested_model=model, + t0=t0, + ) + + resp = await self._client.post(url, json=body, headers=headers) + latency = (time.time() - t0) * 1000 + if resp.status_code >= 400: + error_text = resp.text[:500] + self.health.record_failure(f"HTTP {resp.status_code}: {error_text}") + raise ProviderError(self.name, resp.status_code, error_text) + + self.health.record_success(latency) + return self._codex_completion_from_sse( + resp.text, + requested_model=model, + latency_ms=latency, + ) + except httpx.TimeoutException as e: + self.health.record_failure(f"Timeout: {e}") + raise ProviderError(self.name, 0, f"Timeout: {e}") from e + except httpx.ConnectError as e: + self.health.record_failure(f"Connection error: {e}") + raise ProviderError(self.name, 0, f"Connection error: {e}") from e + # OpenAI-compatible path (DeepSeek, OpenRouter, etc.) body: dict[str, Any] = { "model": model, diff --git a/pyproject.toml b/pyproject.toml index 43ed8d9..3c26e6c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "faigate" -version = "2.1.3" +version = "2.1.4" description = "Local OpenAI-compatible routing gateway for OpenClaw and other AI-native clients." readme = "README.md" license = "Apache-2.0" diff --git a/tests/test_client_profiles.py b/tests/test_client_profiles.py index f47c869..f416a35 100644 --- a/tests/test_client_profiles.py +++ b/tests/test_client_profiles.py @@ -38,7 +38,11 @@ async def aclose(self): sys.modules["httpx"] = _httpx from faigate.config import ConfigError, load_config -from faigate.main import _resolve_client_profile, _resolve_requested_model +from faigate.main import ( + _provider_requires_static_api_key, + _resolve_client_profile, + _resolve_requested_model, +) from faigate.router import Router @@ -81,6 +85,65 @@ def test_resolve_requested_model_strips_faigate_namespace(self, tmp_path): assert shortcut_name is None assert hints == {} + def test_oauth_codex_provider_does_not_require_static_api_key(self, tmp_path): + cfg = load_config( + _write_config( + tmp_path, + """ +server: + host: "127.0.0.1" + port: 8090 +providers: + openai-codex-5.4-medium: + backend: oauth + base_url: "https://chatgpt.com/backend-api/codex/responses" + model: "openai-codex/gpt-5.4" + underlying_backend: openai-compat + oauth: + helper: "faigate-auth openai-codex" +fallback_chain: [] +metrics: + enabled: false +""", + ) + ) + + assert ( + _provider_requires_static_api_key( + "openai-codex-5.4-medium", + cfg.providers["openai-codex-5.4-medium"], + ) + is False + ) + + def test_standard_openai_provider_still_requires_static_api_key(self, tmp_path): + cfg = load_config( + _write_config( + tmp_path, + """ +server: + host: "127.0.0.1" + port: 8090 +providers: + openai-gpt4o: + backend: openai-compat + base_url: "https://api.openai.com/v1" + model: "gpt-4o" +fallback_chain: [] +metrics: + enabled: false +""", + ) + ) + + assert ( + _provider_requires_static_api_key( + "openai-gpt4o", + cfg.providers["openai-gpt4o"], + ) + is True + ) + def test_resolve_n8n_profile_from_headers(self, tmp_path): cfg = load_config( _write_config( diff --git a/tests/test_providers.py b/tests/test_providers.py index 2cc5d75..9740401 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -9,6 +9,7 @@ import sys import types +from pathlib import Path import pytest @@ -37,10 +38,13 @@ async def aclose(self): _httpx.Timeout = _Timeout _httpx.Limits = _Limits _httpx.AsyncClient = _AsyncClient +_httpx.Request = object +_httpx.Response = object _httpx.TimeoutException = Exception _httpx.ConnectError = Exception sys.modules["httpx"] = _httpx +from faigate.oauth.backend import OAuthBackend # noqa: E402 from faigate.providers import ProviderBackend # noqa: E402 @@ -345,6 +349,167 @@ async def test_assistant_none_content_converted_to_empty_string(self): for part in item.get("parts", []): assert isinstance(part.get("text"), str), f"Non-string text in part: {part}" + +def test_transport_path_preserves_explicit_empty_chat_path(): + backend = ProviderBackend( + "openai-codex-5.4-medium", + { + "backend": "openai-compat", + "base_url": "https://chatgpt.com/backend-api/codex/responses", + "api_key": "secret", + "model": "openai-codex/gpt-5.4", + "transport": {"chat_path": ""}, + }, + ) + + path = backend._transport_path("chat_path", "/chat/completions") + + assert path == "" + assert backend._transport_url(path) == "https://chatgpt.com/backend-api/codex/responses" + + +@pytest.mark.asyncio +async def test_codex_responses_payload_maps_to_supported_model_and_openai_output(): + backend = ProviderBackend( + "openai-codex-5.4-medium", + { + "backend": "openai-compat", + "base_url": "https://chatgpt.com/backend-api/codex/responses", + "api_key": "secret", + "model": "gpt-5.4", + "transport": {"profile": "oauth-codex", "chat_path": ""}, + "extra_body": {"reasoning_effort": "medium"}, + }, + ) + captured: dict = {} + + class _FakeResp: + status_code = 200 + text = ( + "event: response.output_text.delta\n" + 'data: {"type":"response.output_text.delta","delta":"ok"}\n\n' + "event: response.completed\n" + 'data: {"type":"response.completed","response":{"id":"resp_test","created_at":1775616020,' + '"model":"gpt-5-codex","usage":{"input_tokens":19,"output_tokens":5,"total_tokens":24}}}\n\n' + ) + + async def _fake_post(url, json=None, headers=None, **_kw): + captured["url"] = url + captured["json"] = json or {} + captured["headers"] = headers or {} + return _FakeResp() + + backend._client.post = _fake_post # type: ignore[attr-defined] + + result = await backend.complete([{"role": "user", "content": "Reply with exactly ok."}]) + + assert captured["url"] == "https://chatgpt.com/backend-api/codex/responses" + assert captured["json"]["model"] == "gpt-5-codex" + assert captured["json"]["stream"] is True + assert captured["json"]["store"] is False + assert captured["json"]["instructions"] == "" + assert captured["json"]["input"] == [{"role": "user", "content": "Reply with exactly ok."}] + assert captured["json"]["reasoning"] == {"effort": "medium"} + assert result["choices"][0]["message"]["content"] == "ok" + assert result["model"] == "gpt-5-codex" + assert result["usage"]["total_tokens"] == 24 + + +@pytest.mark.asyncio +async def test_codex_responses_stream_maps_to_openai_chunks(): + backend = ProviderBackend( + "openai-codex-5.4-medium", + { + "backend": "openai-compat", + "base_url": "https://chatgpt.com/backend-api/codex/responses", + "api_key": "secret", + "model": "gpt-5.4", + "transport": {"profile": "oauth-codex", "chat_path": ""}, + "extra_body": {"reasoning_effort": "medium"}, + }, + ) + captured: dict = {} + + class _FakeStreamResp: + status_code = 200 + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def aiter_lines(self): + for line in [ + ( + 'data: {"type":"response.created","response":{"id":"resp_stream",' + '"created_at":1775616020,"model":"gpt-5-codex"}}' + ), + "", + 'data: {"type":"response.output_text.delta","delta":"ok"}', + "", + ( + 'data: {"type":"response.completed","response":{"id":"resp_stream",' + '"created_at":1775616020,"model":"gpt-5-codex","usage":{"input_tokens":19,' + '"output_tokens":5,"total_tokens":24}}}' + ), + "", + ]: + yield line + + def _fake_stream(method, url, json=None, headers=None, **_kw): + captured["method"] = method + captured["url"] = url + captured["json"] = json or {} + captured["headers"] = headers or {} + return _FakeStreamResp() + + backend._client.stream = _fake_stream # type: ignore[attr-defined] + + stream_iter = await backend.complete( + [{"role": "user", "content": "Reply with exactly ok."}], + stream=True, + ) + chunks = [chunk async for chunk in stream_iter] + payload = b"".join(chunks).decode("utf-8") + + assert captured["method"] == "POST" + assert captured["url"] == "https://chatgpt.com/backend-api/codex/responses" + assert captured["json"]["model"] == "gpt-5-codex" + assert captured["json"]["stream"] is True + assert 'data: {"id":"resp_stream","object":"chat.completion.chunk"' in payload + assert '"role":"assistant"' in payload + assert '"content":"ok"' in payload + assert "data: [DONE]" in payload + + +def test_oauth_backend_resolves_brew_libexec_helper(monkeypatch, tmp_path: Path): + libexec_bin = tmp_path / "libexec" / "bin" + libexec_bin.mkdir(parents=True) + helper_path = libexec_bin / "faigate-auth" + helper_path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + helper_path.chmod(0o755) + + monkeypatch.setattr("faigate.oauth.backend.shutil.which", lambda _name: None) + monkeypatch.setattr("faigate.oauth.backend.sys.executable", str(libexec_bin / "python")) + + backend = OAuthBackend( + "openai-codex-5.4-medium", + { + "backend": "oauth", + "base_url": "https://chatgpt.com/backend-api/codex/responses", + "model": "openai-codex/gpt-5.4", + "underlying_backend": "openai-compat", + "oauth": {"helper": "faigate-auth openai-codex"}, + }, + ) + + argv = backend._resolve_helper_argv() + + assert argv is not None + assert argv[0] == str(helper_path) + assert argv[1:] == ["openai-codex"] + @pytest.mark.asyncio async def test_multimodal_content_array_flattened(self): """Multimodal content list must be flattened to a plain string for Gemini."""