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:
@@ -111,7 +111,6 @@ from lib import (
|
||||
schema,
|
||||
score,
|
||||
ui,
|
||||
websearch,
|
||||
xai_x,
|
||||
youtube_yt,
|
||||
)
|
||||
@@ -154,6 +153,8 @@ def _search_reddit(
|
||||
from_date,
|
||||
to_date,
|
||||
depth=depth,
|
||||
auth_source=config.get("OPENAI_AUTH_SOURCE", "api_key"),
|
||||
account_id=config.get("OPENAI_CHATGPT_ACCOUNT_ID"),
|
||||
)
|
||||
except http.HTTPError as e:
|
||||
raw_openai = {"error": str(e)}
|
||||
@@ -176,6 +177,8 @@ def _search_reddit(
|
||||
core,
|
||||
from_date, to_date,
|
||||
depth=depth,
|
||||
auth_source=config.get("OPENAI_AUTH_SOURCE", "api_key"),
|
||||
account_id=config.get("OPENAI_CHATGPT_ACCOUNT_ID"),
|
||||
)
|
||||
retry_items = openai_reddit.parse_reddit_response(retry_raw)
|
||||
# Add items not already found (by URL)
|
||||
@@ -1032,6 +1035,9 @@ def main():
|
||||
# Load config
|
||||
config = env.get_config()
|
||||
|
||||
# Inject .env credentials into Bird module before auth check
|
||||
bird_x.set_credentials(config.get('AUTH_TOKEN'), config.get('CT0'))
|
||||
|
||||
# Auto-detect Bird (no prompts - just use it if available)
|
||||
x_source_status = env.get_x_source_status(config)
|
||||
x_source = x_source_status["source"] # 'bird', 'xai', or None
|
||||
|
||||
@@ -24,6 +24,24 @@ DEPTH_CONFIG = {
|
||||
"deep": 60,
|
||||
}
|
||||
|
||||
# Module-level credentials injected from .env config
|
||||
_credentials: Dict[str, str] = {}
|
||||
|
||||
|
||||
def set_credentials(auth_token: Optional[str], ct0: Optional[str]):
|
||||
"""Inject AUTH_TOKEN/CT0 from .env config so Node subprocesses can use them."""
|
||||
if auth_token:
|
||||
_credentials['AUTH_TOKEN'] = auth_token
|
||||
if ct0:
|
||||
_credentials['CT0'] = ct0
|
||||
|
||||
|
||||
def _subprocess_env() -> Dict[str, str]:
|
||||
"""Build env dict for Node subprocesses, merging injected credentials."""
|
||||
env = os.environ.copy()
|
||||
env.update(_credentials)
|
||||
return env
|
||||
|
||||
|
||||
def _log(msg: str):
|
||||
"""Log to stderr."""
|
||||
@@ -112,6 +130,7 @@ def is_bird_authenticated() -> Optional[str]:
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
env=_subprocess_env(),
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip().split('\n')[0]
|
||||
@@ -187,6 +206,7 @@ def _run_bird_search(query: str, count: int, timeout: int) -> Dict[str, Any]:
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
preexec_fn=preexec,
|
||||
env=_subprocess_env(),
|
||||
)
|
||||
|
||||
# Register for cleanup tracking (if available)
|
||||
|
||||
+151
-7
@@ -1,9 +1,12 @@
|
||||
"""Environment and API key management for last30days skill."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
from typing import Optional, Dict, Any, Literal
|
||||
|
||||
# Allow override via environment variable for testing
|
||||
# Set LAST30DAYS_CONFIG_DIR="" for clean/no-config mode
|
||||
@@ -20,6 +23,29 @@ else:
|
||||
CONFIG_DIR = Path.home() / ".config" / "last30days"
|
||||
CONFIG_FILE = CONFIG_DIR / ".env"
|
||||
|
||||
CODEX_AUTH_FILE = Path(os.environ.get("CODEX_AUTH_FILE", str(Path.home() / ".codex" / "auth.json")))
|
||||
|
||||
AuthSource = Literal["api_key", "codex", "none"]
|
||||
AuthStatus = Literal["ok", "missing", "expired", "missing_account_id"]
|
||||
|
||||
AUTH_SOURCE_API_KEY: AuthSource = "api_key"
|
||||
AUTH_SOURCE_CODEX: AuthSource = "codex"
|
||||
AUTH_SOURCE_NONE: AuthSource = "none"
|
||||
|
||||
AUTH_STATUS_OK: AuthStatus = "ok"
|
||||
AUTH_STATUS_MISSING: AuthStatus = "missing"
|
||||
AUTH_STATUS_EXPIRED: AuthStatus = "expired"
|
||||
AUTH_STATUS_MISSING_ACCOUNT_ID: AuthStatus = "missing_account_id"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpenAIAuth:
|
||||
token: Optional[str]
|
||||
source: AuthSource
|
||||
status: AuthStatus
|
||||
account_id: Optional[str]
|
||||
codex_auth_file: str
|
||||
|
||||
|
||||
def load_env_file(path: Path) -> Dict[str, str]:
|
||||
"""Load environment variables from a file."""
|
||||
@@ -44,14 +70,131 @@ def load_env_file(path: Path) -> Dict[str, str]:
|
||||
return env
|
||||
|
||||
|
||||
def _decode_jwt_payload(token: str) -> Optional[Dict[str, Any]]:
|
||||
"""Decode JWT payload without verification."""
|
||||
try:
|
||||
parts = token.split(".")
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
payload_b64 = parts[1]
|
||||
pad = "=" * (-len(payload_b64) % 4)
|
||||
decoded = base64.urlsafe_b64decode(payload_b64 + pad)
|
||||
return json.loads(decoded.decode("utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _token_expired(token: str, leeway_seconds: int = 60) -> bool:
|
||||
"""Check if JWT token is expired."""
|
||||
payload = _decode_jwt_payload(token)
|
||||
if not payload:
|
||||
return False
|
||||
exp = payload.get("exp")
|
||||
if not exp:
|
||||
return False
|
||||
return exp <= (time.time() + leeway_seconds)
|
||||
|
||||
|
||||
def extract_chatgpt_account_id(access_token: str) -> Optional[str]:
|
||||
"""Extract chatgpt_account_id from JWT token."""
|
||||
payload = _decode_jwt_payload(access_token)
|
||||
if not payload:
|
||||
return None
|
||||
auth_claim = payload.get("https://api.openai.com/auth", {})
|
||||
if isinstance(auth_claim, dict):
|
||||
return auth_claim.get("chatgpt_account_id")
|
||||
return None
|
||||
|
||||
|
||||
def load_codex_auth(path: Path = CODEX_AUTH_FILE) -> Dict[str, Any]:
|
||||
"""Load Codex auth JSON."""
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
with open(path, "r") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def get_codex_access_token() -> tuple[Optional[str], str]:
|
||||
"""Get Codex access token from auth.json.
|
||||
|
||||
Returns:
|
||||
(token, status) where status is 'ok', 'missing', or 'expired'
|
||||
"""
|
||||
auth = load_codex_auth()
|
||||
token = None
|
||||
if isinstance(auth, dict):
|
||||
tokens = auth.get("tokens") or {}
|
||||
if isinstance(tokens, dict):
|
||||
token = tokens.get("access_token")
|
||||
if not token:
|
||||
token = auth.get("access_token")
|
||||
if not token:
|
||||
return None, AUTH_STATUS_MISSING
|
||||
if _token_expired(token):
|
||||
return None, AUTH_STATUS_EXPIRED
|
||||
return token, AUTH_STATUS_OK
|
||||
|
||||
|
||||
def get_openai_auth(file_env: Dict[str, str]) -> OpenAIAuth:
|
||||
"""Resolve OpenAI auth from API key or Codex login."""
|
||||
api_key = os.environ.get('OPENAI_API_KEY') or file_env.get('OPENAI_API_KEY')
|
||||
if api_key:
|
||||
return OpenAIAuth(
|
||||
token=api_key,
|
||||
source=AUTH_SOURCE_API_KEY,
|
||||
status=AUTH_STATUS_OK,
|
||||
account_id=None,
|
||||
codex_auth_file=str(CODEX_AUTH_FILE),
|
||||
)
|
||||
|
||||
codex_token, codex_status = get_codex_access_token()
|
||||
if codex_token:
|
||||
account_id = extract_chatgpt_account_id(codex_token)
|
||||
if account_id:
|
||||
return OpenAIAuth(
|
||||
token=codex_token,
|
||||
source=AUTH_SOURCE_CODEX,
|
||||
status=AUTH_STATUS_OK,
|
||||
account_id=account_id,
|
||||
codex_auth_file=str(CODEX_AUTH_FILE),
|
||||
)
|
||||
return OpenAIAuth(
|
||||
token=None,
|
||||
source=AUTH_SOURCE_CODEX,
|
||||
status=AUTH_STATUS_MISSING_ACCOUNT_ID,
|
||||
account_id=None,
|
||||
codex_auth_file=str(CODEX_AUTH_FILE),
|
||||
)
|
||||
|
||||
return OpenAIAuth(
|
||||
token=None,
|
||||
source=AUTH_SOURCE_NONE,
|
||||
status=codex_status,
|
||||
account_id=None,
|
||||
codex_auth_file=str(CODEX_AUTH_FILE),
|
||||
)
|
||||
|
||||
|
||||
def get_config() -> Dict[str, Any]:
|
||||
"""Load configuration from ~/.config/last30days/.env and environment."""
|
||||
# Load from config file first (if configured)
|
||||
file_env = load_env_file(CONFIG_FILE) if CONFIG_FILE else {}
|
||||
|
||||
# Build config: process.env > .env file
|
||||
openai_auth = get_openai_auth(file_env)
|
||||
|
||||
# Build config: Codex/OpenAI auth + process.env > .env file
|
||||
config = {
|
||||
'OPENAI_API_KEY': openai_auth.token,
|
||||
'OPENAI_AUTH_SOURCE': openai_auth.source,
|
||||
'OPENAI_AUTH_STATUS': openai_auth.status,
|
||||
'OPENAI_CHATGPT_ACCOUNT_ID': openai_auth.account_id,
|
||||
'CODEX_AUTH_FILE': openai_auth.codex_auth_file,
|
||||
}
|
||||
|
||||
keys = [
|
||||
('OPENAI_API_KEY', None),
|
||||
('XAI_API_KEY', None),
|
||||
('OPENROUTER_API_KEY', None),
|
||||
('PARALLEL_API_KEY', None),
|
||||
@@ -60,9 +203,10 @@ def get_config() -> Dict[str, Any]:
|
||||
('OPENAI_MODEL_PIN', None),
|
||||
('XAI_MODEL_POLICY', 'latest'),
|
||||
('XAI_MODEL_PIN', None),
|
||||
('AUTH_TOKEN', None),
|
||||
('CT0', None),
|
||||
]
|
||||
|
||||
config = {}
|
||||
for key, default in keys:
|
||||
config[key] = os.environ.get(key) or file_env.get(key, default)
|
||||
|
||||
@@ -79,7 +223,7 @@ def get_available_sources(config: Dict[str, Any]) -> str:
|
||||
|
||||
Returns: 'all', 'both', 'reddit', 'reddit-web', 'x', 'x-web', 'web', or 'none'
|
||||
"""
|
||||
has_openai = bool(config.get('OPENAI_API_KEY'))
|
||||
has_openai = bool(config.get('OPENAI_API_KEY')) and config.get('OPENAI_AUTH_STATUS') == AUTH_STATUS_OK
|
||||
has_xai = bool(config.get('XAI_API_KEY'))
|
||||
has_web = has_web_search_keys(config)
|
||||
|
||||
@@ -121,7 +265,7 @@ def get_missing_keys(config: Dict[str, Any]) -> str:
|
||||
|
||||
Returns: 'all', 'both', 'reddit', 'x', 'web', or 'none'
|
||||
"""
|
||||
has_openai = bool(config.get('OPENAI_API_KEY'))
|
||||
has_openai = bool(config.get('OPENAI_API_KEY')) and config.get('OPENAI_AUTH_STATUS') == AUTH_STATUS_OK
|
||||
has_xai = bool(config.get('XAI_API_KEY'))
|
||||
has_web = has_web_search_keys(config)
|
||||
|
||||
@@ -170,7 +314,7 @@ def validate_sources(requested: str, available: str, include_web: bool = False)
|
||||
elif requested == 'web':
|
||||
return 'web', None
|
||||
else:
|
||||
return 'web', f"Only web search keys configured. Add OPENAI_API_KEY for Reddit, XAI_API_KEY for X."
|
||||
return 'web', "Only web search keys configured. Add OPENAI_API_KEY (or run codex login) for Reddit, XAI_API_KEY for X."
|
||||
|
||||
if requested == 'auto':
|
||||
# Add web to sources if include_web is set
|
||||
|
||||
+11
-4
@@ -38,6 +38,7 @@ def request(
|
||||
json_data: Optional[Dict[str, Any]] = None,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
retries: int = MAX_RETRIES,
|
||||
raw: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Make an HTTP request and return JSON response.
|
||||
|
||||
@@ -50,7 +51,7 @@ def request(
|
||||
retries: Number of retries on failure
|
||||
|
||||
Returns:
|
||||
Parsed JSON response
|
||||
Parsed JSON response (or raw text if raw=True)
|
||||
|
||||
Raises:
|
||||
HTTPError: On request failure
|
||||
@@ -66,8 +67,6 @@ def request(
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
|
||||
log(f"{method} {url}")
|
||||
if json_data:
|
||||
log(f"Payload keys: {list(json_data.keys())}")
|
||||
|
||||
last_error = None
|
||||
for attempt in range(retries):
|
||||
@@ -75,6 +74,8 @@ def request(
|
||||
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||||
body = response.read().decode('utf-8')
|
||||
log(f"Response: {response.status} ({len(body)} bytes)")
|
||||
if raw:
|
||||
return body
|
||||
return json.loads(body) if body else {}
|
||||
except urllib.error.HTTPError as e:
|
||||
body = None
|
||||
@@ -84,7 +85,8 @@ def request(
|
||||
pass
|
||||
log(f"HTTP Error {e.code}: {e.reason}")
|
||||
if body:
|
||||
log(f"Error body: {body[:500]}")
|
||||
snippet = " ".join(body.split())
|
||||
log(f"Error body: {snippet[:200]}")
|
||||
last_error = HTTPError(f"HTTP {e.code}: {e.reason}", e.code, body)
|
||||
|
||||
# Don't retry client errors (4xx) except rate limits
|
||||
@@ -137,6 +139,11 @@ def post(url: str, json_data: Dict[str, Any], headers: Optional[Dict[str, str]]
|
||||
return request("POST", url, headers=headers, json_data=json_data, **kwargs)
|
||||
|
||||
|
||||
def post_raw(url: str, json_data: Dict[str, Any], headers: Optional[Dict[str, str]] = None, **kwargs) -> str:
|
||||
"""Make a POST request with JSON body and return raw text."""
|
||||
return request("POST", url, headers=headers, json_data=json_data, raw=True, **kwargs)
|
||||
|
||||
|
||||
def get_reddit_json(path: str, timeout: int = DEFAULT_TIMEOUT, retries: int = MAX_RETRIES) -> Dict[str, Any]:
|
||||
"""Fetch Reddit thread JSON.
|
||||
|
||||
|
||||
+17
-7
@@ -3,11 +3,12 @@
|
||||
import re
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from . import cache, http
|
||||
from . import cache, http, env
|
||||
|
||||
# OpenAI API
|
||||
OPENAI_MODELS_URL = "https://api.openai.com/v1/models"
|
||||
OPENAI_FALLBACK_MODELS = ["gpt-5.2", "gpt-5.1", "gpt-5", "gpt-4.1", "gpt-4o"]
|
||||
CODEX_FALLBACK_MODELS = ["gpt-5.1-codex-mini", "gpt-5.2"]
|
||||
|
||||
# xAI API - Agent Tools API requires grok-4 family
|
||||
XAI_MODELS_URL = "https://api.x.ai/v1/models"
|
||||
@@ -157,12 +158,21 @@ def get_models(
|
||||
result = {"openai": None, "xai": None}
|
||||
|
||||
if config.get("OPENAI_API_KEY"):
|
||||
result["openai"] = select_openai_model(
|
||||
config["OPENAI_API_KEY"],
|
||||
config.get("OPENAI_MODEL_POLICY", "auto"),
|
||||
config.get("OPENAI_MODEL_PIN"),
|
||||
mock_openai_models,
|
||||
)
|
||||
if config.get("OPENAI_AUTH_SOURCE") == env.AUTH_SOURCE_CODEX:
|
||||
# Codex auth doesn't use the OpenAI models list endpoint
|
||||
policy = config.get("OPENAI_MODEL_POLICY", "auto")
|
||||
pin = config.get("OPENAI_MODEL_PIN")
|
||||
if policy == "pinned" and pin:
|
||||
result["openai"] = pin
|
||||
else:
|
||||
result["openai"] = CODEX_FALLBACK_MODELS[0]
|
||||
else:
|
||||
result["openai"] = select_openai_model(
|
||||
config["OPENAI_API_KEY"],
|
||||
config.get("OPENAI_MODEL_POLICY", "auto"),
|
||||
config.get("OPENAI_MODEL_PIN"),
|
||||
mock_openai_models,
|
||||
)
|
||||
|
||||
if config.get("XAI_API_KEY"):
|
||||
result["xai"] = select_xai_model(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -4,7 +4,7 @@ import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from typing import Optional
|
||||
|
||||
from . import schema
|
||||
|
||||
@@ -101,10 +101,11 @@ def render_compact(report: schema.Report, limit: int = 15, missing_keys: str = "
|
||||
lines.append("**🌐 WEB SEARCH MODE** - assistant will search blogs, docs & news")
|
||||
lines.append("")
|
||||
lines.append("---")
|
||||
lines.append("**⚡ Want better results?** Add API keys to unlock Reddit & X data:")
|
||||
lines.append("- `OPENAI_API_KEY` → Reddit threads with real upvotes & comments")
|
||||
lines.append("**⚡ Want better results?** Add API keys or sign in to Codex to unlock Reddit & X data:")
|
||||
lines.append("- `OPENAI_API_KEY` or `codex login` → Reddit threads with real upvotes & comments")
|
||||
lines.append("- `XAI_API_KEY` → X posts with real likes & reposts")
|
||||
lines.append("- Edit `~/.config/last30days/.env` to add keys")
|
||||
lines.append("- If already signed in but still seeing this, re-run `codex login`")
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
|
||||
@@ -129,7 +130,7 @@ def render_compact(report: schema.Report, limit: int = 15, missing_keys: str = "
|
||||
lines.append("*💡 Tip: Add XAI_API_KEY for X/Twitter data and better triangulation.*")
|
||||
lines.append("")
|
||||
elif report.mode == "x-only" and missing_keys == "reddit":
|
||||
lines.append("*💡 Tip: Add OPENAI_API_KEY for Reddit data and better triangulation.*")
|
||||
lines.append("*💡 Tip: Add OPENAI_API_KEY or run `codex login` for Reddit data and better triangulation. If already signed in, re-run `codex login`.*")
|
||||
lines.append("")
|
||||
|
||||
# Reddit items
|
||||
@@ -168,7 +169,7 @@ def render_compact(report: schema.Report, limit: int = 15, missing_keys: str = "
|
||||
|
||||
# Top comment insights
|
||||
if item.comment_insights:
|
||||
lines.append(f" Insights:")
|
||||
lines.append(" Insights:")
|
||||
for insight in item.comment_insights[:3]:
|
||||
lines.append(f" - {insight}")
|
||||
|
||||
@@ -630,6 +631,8 @@ def render_full_report(report: schema.Report) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
|
||||
|
||||
def write_outputs(
|
||||
report: schema.Report,
|
||||
raw_openai: Optional[dict] = None,
|
||||
|
||||
+2
-3
@@ -1,6 +1,5 @@
|
||||
"""Terminal UI utilities for last30days skill."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
@@ -117,7 +116,7 @@ I just researched that for you. Here's what I've got right now:
|
||||
|
||||
{status_line}
|
||||
|
||||
You can unlock more sources with API keys — just ask me how and I'll walk you through it. More sources means better research, but it works fine as-is.
|
||||
You can unlock more sources with API keys or by signing in to Codex — just ask me how and I'll walk you through it. More sources means better research, but it works fine as-is.
|
||||
|
||||
Some examples of what you can do:
|
||||
- "last30 what are people saying about Figma"
|
||||
@@ -131,7 +130,7 @@ Just start with "last30" and talk to me like normal.
|
||||
|
||||
# Shorter promo for single missing key
|
||||
PROMO_SINGLE_KEY = {
|
||||
"reddit": "\n💡 You can unlock Reddit with an OpenAI API key — just ask me how.\n",
|
||||
"reddit": "\n💡 You can unlock Reddit with an OpenAI API key or by running `codex login` — just ask me how.\n",
|
||||
"x": "\n💡 You can unlock X with an xAI API key — just ask me how.\n",
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user