fix(bird_x): pass .env credentials to Node subprocesses for WSL2/headless auth
* chore: fix YAML error in argument-hint * add codex auth support to responses API * Use gpt-5.1-codex-mini as default model for Codex auth Add CODEX_FALLBACK_MODELS chain (gpt-5.1-codex-mini → gpt-5.2) for Codex endpoint which doesn't support standard OpenAI models like gpt-4o-mini. Adds model fallback retry on 400 errors in the Codex search path. Also adds test_codex_auth.py with 22 unit tests covering JWT decoding, auth resolution, SSE parsing, and payload building. * Pass .env credentials to Bird Node subprocesses for X auth On platforms without browser cookie access (e.g. WSL2), Bird's vendored Node.js module cannot read AUTH_TOKEN/CT0 from Firefox or Chrome cookie stores. The .env config file already supports these values, but they were only loaded into the Python config dict — never exported to the environment of Node subprocesses. - Add AUTH_TOKEN/CT0 to env.py config key loading - Add set_credentials()/\_subprocess_env() to bird_x.py to inject credentials into the env dict passed to subprocess.run/Popen - Call set_credentials() in main() before Bird auth detection --------- Co-authored-by: Justin Williams <jblwilliams@gmail.com>
This commit is contained in:
@@ -5,7 +5,7 @@ import re
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from . import http
|
||||
from . import http, env
|
||||
|
||||
# Fallback models when the selected model isn't accessible (e.g., org not verified for GPT-5)
|
||||
# Note: gpt-4o-mini does NOT support web_search with filters param, so exclude it
|
||||
@@ -42,6 +42,93 @@ def _is_model_access_error(error: http.HTTPError) -> bool:
|
||||
|
||||
|
||||
OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses"
|
||||
CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"
|
||||
CODEX_INSTRUCTIONS = (
|
||||
"You are a research assistant for a skill that summarizes what people are "
|
||||
"discussing in the last 30 days. Your goal is to find relevant Reddit threads "
|
||||
"about the topic and return ONLY the required JSON. Be inclusive (return more "
|
||||
"rather than fewer), but avoid irrelevant results. Prefer threads with discussion "
|
||||
"and comments. If you can infer a date, include it; otherwise use null. "
|
||||
"Do not include developers.reddit.com or business.reddit.com."
|
||||
)
|
||||
|
||||
|
||||
def _parse_sse_chunk(chunk: str) -> Optional[Dict[str, Any]]:
|
||||
"""Parse a single SSE chunk into a JSON object."""
|
||||
lines = chunk.split("\n")
|
||||
data_lines = []
|
||||
|
||||
for line in lines:
|
||||
if line.startswith("data:"):
|
||||
data_lines.append(line[5:].strip())
|
||||
|
||||
if not data_lines:
|
||||
return None
|
||||
|
||||
data = "\n".join(data_lines).strip()
|
||||
if not data or data == "[DONE]":
|
||||
return None
|
||||
|
||||
try:
|
||||
return json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_sse_stream_raw(raw: str) -> List[Dict[str, Any]]:
|
||||
"""Parse SSE stream from raw text and return JSON events."""
|
||||
events: List[Dict[str, Any]] = []
|
||||
buffer = ""
|
||||
for chunk in raw.splitlines(keepends=True):
|
||||
buffer += chunk
|
||||
while "\n\n" in buffer:
|
||||
event_chunk, buffer = buffer.split("\n\n", 1)
|
||||
event = _parse_sse_chunk(event_chunk)
|
||||
if event is not None:
|
||||
events.append(event)
|
||||
if buffer.strip():
|
||||
event = _parse_sse_chunk(buffer)
|
||||
if event is not None:
|
||||
events.append(event)
|
||||
return events
|
||||
|
||||
|
||||
def _parse_codex_stream(raw: str) -> Dict[str, Any]:
|
||||
"""Parse SSE stream from Codex responses into a response-like dict."""
|
||||
events = _parse_sse_stream_raw(raw)
|
||||
|
||||
# Prefer explicit completed response payload if present
|
||||
for evt in reversed(events):
|
||||
if isinstance(evt, dict):
|
||||
if evt.get("type") == "response.completed" and isinstance(evt.get("response"), dict):
|
||||
return evt["response"]
|
||||
if isinstance(evt.get("response"), dict):
|
||||
return evt["response"]
|
||||
|
||||
# Fallback: reconstruct output text from deltas
|
||||
output_text = ""
|
||||
for evt in events:
|
||||
if not isinstance(evt, dict):
|
||||
continue
|
||||
delta = evt.get("delta")
|
||||
if isinstance(delta, str):
|
||||
output_text += delta
|
||||
continue
|
||||
text = evt.get("text")
|
||||
if isinstance(text, str):
|
||||
output_text += text
|
||||
|
||||
if output_text:
|
||||
return {
|
||||
"output": [
|
||||
{
|
||||
"type": "message",
|
||||
"content": [{"type": "output_text", "text": output_text}],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return {}
|
||||
|
||||
# Depth configurations: (min, max) threads to request
|
||||
# Request MORE than needed since many get filtered by date
|
||||
@@ -116,6 +203,35 @@ def _build_subreddit_query(topic: str) -> str:
|
||||
return f"r/{sub_name} site:reddit.com"
|
||||
|
||||
|
||||
def _build_payload(model: str, instructions_text: str, input_text: str, auth_source: str) -> Dict[str, Any]:
|
||||
"""Build responses payload for OpenAI or Codex endpoints."""
|
||||
payload = {
|
||||
"model": model,
|
||||
"store": False,
|
||||
"tools": [
|
||||
{
|
||||
"type": "web_search",
|
||||
"filters": {
|
||||
"allowed_domains": ["reddit.com"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"include": ["web_search_call.action.sources"],
|
||||
"instructions": instructions_text,
|
||||
"input": input_text,
|
||||
}
|
||||
if auth_source == env.AUTH_SOURCE_CODEX:
|
||||
payload["input"] = [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": input_text}],
|
||||
}
|
||||
]
|
||||
payload["stream"] = True
|
||||
return payload
|
||||
|
||||
|
||||
def search_reddit(
|
||||
api_key: str,
|
||||
model: str,
|
||||
@@ -123,6 +239,8 @@ def search_reddit(
|
||||
from_date: str,
|
||||
to_date: str,
|
||||
depth: str = "default",
|
||||
auth_source: str = "api_key",
|
||||
account_id: Optional[str] = None,
|
||||
mock_response: Optional[Dict] = None,
|
||||
_retry: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
@@ -145,10 +263,23 @@ def search_reddit(
|
||||
|
||||
min_items, max_items = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if auth_source == env.AUTH_SOURCE_CODEX:
|
||||
if not account_id:
|
||||
raise ValueError("Missing chatgpt_account_id for Codex auth")
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"chatgpt-account-id": account_id,
|
||||
"OpenAI-Beta": "responses=experimental",
|
||||
"originator": "pi",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
url = CODEX_RESPONSES_URL
|
||||
else:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
url = OPENAI_RESPONSES_URL
|
||||
|
||||
# Adjust timeout based on depth (generous for OpenAI web_search which can be slow)
|
||||
timeout = 90 if depth == "quick" else 120 if depth == "default" else 180
|
||||
@@ -166,6 +297,28 @@ def search_reddit(
|
||||
max_items=max_items,
|
||||
)
|
||||
|
||||
if auth_source == env.AUTH_SOURCE_CODEX:
|
||||
# Codex auth: try model with fallback chain
|
||||
from . import models as models_mod
|
||||
codex_models_to_try = [model] + [m for m in models_mod.CODEX_FALLBACK_MODELS if m != model]
|
||||
instructions_text = CODEX_INSTRUCTIONS + "\n\n" + input_text
|
||||
last_error = None
|
||||
for current_model in codex_models_to_try:
|
||||
try:
|
||||
payload = _build_payload(current_model, instructions_text, topic, auth_source)
|
||||
raw = http.post_raw(url, payload, headers=headers, timeout=timeout)
|
||||
return _parse_codex_stream(raw or "")
|
||||
except http.HTTPError as e:
|
||||
last_error = e
|
||||
if e.status_code == 400:
|
||||
_log_info(f"Model {current_model} not supported on Codex, trying fallback...")
|
||||
continue
|
||||
raise
|
||||
if last_error:
|
||||
raise last_error
|
||||
raise http.HTTPError("No Codex-compatible models available")
|
||||
|
||||
# Standard API key auth: try model fallback chain
|
||||
last_error = None
|
||||
for current_model in models_to_try:
|
||||
payload = {
|
||||
@@ -183,7 +336,7 @@ def search_reddit(
|
||||
}
|
||||
|
||||
try:
|
||||
return http.post(OPENAI_RESPONSES_URL, payload, headers=headers, timeout=timeout)
|
||||
return http.post(url, payload, headers=headers, timeout=timeout)
|
||||
except http.HTTPError as e:
|
||||
last_error = e
|
||||
if _is_model_access_error(e):
|
||||
|
||||
Reference in New Issue
Block a user