Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion faigate/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""fusionAIze Gate package."""

__version__ = "2.1.3"
__version__ = "2.1.4"
26 changes: 24 additions & 2 deletions faigate/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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)
Expand Down
62 changes: 56 additions & 6 deletions faigate/oauth/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading