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:
@@ -24,15 +24,17 @@
|
|||||||
# Clone the repo
|
# Clone the repo
|
||||||
git clone https://github.com/mvanhorn/last30days-skill.git ~/.claude/skills/last30days
|
git clone https://github.com/mvanhorn/last30days-skill.git ~/.claude/skills/last30days
|
||||||
|
|
||||||
# Add your API keys
|
# Add your API keys (optional if signed in to Codex)
|
||||||
mkdir -p ~/.config/last30days
|
mkdir -p ~/.config/last30days
|
||||||
cat > ~/.config/last30days/.env << 'EOF'
|
cat > ~/.config/last30days/.env << 'EOF'
|
||||||
OPENAI_API_KEY=sk-...
|
OPENAI_API_KEY=sk-... # optional if using `codex login`
|
||||||
XAI_API_KEY=xai-... # optional - cookie auth is default for X search
|
XAI_API_KEY=xai-... # optional - cookie auth is default for X search
|
||||||
EOF
|
EOF
|
||||||
chmod 600 ~/.config/last30days/.env
|
chmod 600 ~/.config/last30days/.env
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If you're signed in to Codex (`codex login`), the skill will use your Codex credentials for the OpenAI Responses API and you can omit `OPENAI_API_KEY`. If you're not signed in, run `codex login` first.
|
||||||
|
|
||||||
### X Search Authentication
|
### X Search Authentication
|
||||||
|
|
||||||
X search reads your existing browser cookies - no API keys or login commands needed.
|
X search reads your existing browser cookies - no API keys or login commands needed.
|
||||||
|
|||||||
@@ -146,6 +146,8 @@ Generated: {date} | Sources: Reddit, X, YouTube, HN, Polymarket, Web
|
|||||||
|
|
||||||
**CRITICAL: Run this command in the FOREGROUND with a 5-minute timeout. Do NOT use run_in_background. The full output contains Reddit, X, AND YouTube data that you need to read completely.**
|
**CRITICAL: Run this command in the FOREGROUND with a 5-minute timeout. Do NOT use run_in_background. The full output contains Reddit, X, AND YouTube data that you need to read completely.**
|
||||||
|
|
||||||
|
**IMPORTANT: The script handles API key/Codex auth detection automatically.** Run it and check the output to determine mode.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Find skill root — works in repo checkout, Claude Code, or Codex install
|
# Find skill root — works in repo checkout, Claude Code, or Codex install
|
||||||
for dir in \
|
for dir in \
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
`last30days` is a Claude Code skill that researches a given topic across Reddit and X (Twitter) using the OpenAI Responses API and xAI Responses API respectively. It enforces a strict 30-day recency window, popularity-aware ranking, and produces actionable outputs including best practices, a prompt pack, and a reusable context snippet.
|
`last30days` is a Claude Code skill that researches a given topic across Reddit and X (Twitter) using the OpenAI Responses API and xAI Responses API respectively. It enforces a strict 30-day recency window, popularity-aware ranking, and produces actionable outputs including best practices, a prompt pack, and a reusable context snippet. OpenAI auth can come from `OPENAI_API_KEY` or Codex login credentials.
|
||||||
|
|
||||||
The skill operates in three modes depending on available API keys: **reddit-only** (OpenAI key), **x-only** (xAI key), or **both** (full cross-validation). It uses automatic model selection to stay current with the latest models from both providers, with optional pinning for stability.
|
The skill operates in three modes depending on available API keys: **reddit-only** (OpenAI key), **x-only** (xAI key), or **both** (full cross-validation). It uses automatic model selection to stay current with the latest models from both providers, with optional pinning for stability.
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ The skill operates in three modes depending on available API keys: **reddit-only
|
|||||||
|
|
||||||
The orchestrator (`last30days.py`) coordinates discovery, enrichment, normalization, scoring, deduplication, and rendering. Each concern is isolated in `scripts/lib/`:
|
The orchestrator (`last30days.py`) coordinates discovery, enrichment, normalization, scoring, deduplication, and rendering. Each concern is isolated in `scripts/lib/`:
|
||||||
|
|
||||||
- **env.py**: Load and validate API keys from `~/.config/last30days/.env`
|
- **env.py**: Load API keys from `~/.config/last30days/.env` and Codex auth from `~/.codex/auth.json`
|
||||||
- **dates.py**: Date range calculation and confidence scoring
|
- **dates.py**: Date range calculation and confidence scoring
|
||||||
- **cache.py**: 24-hour TTL caching keyed by topic + date range
|
- **cache.py**: 24-hour TTL caching keyed by topic + date range
|
||||||
- **http.py**: stdlib-only HTTP client with retry logic
|
- **http.py**: stdlib-only HTTP client with retry logic
|
||||||
|
|||||||
@@ -111,7 +111,6 @@ from lib import (
|
|||||||
schema,
|
schema,
|
||||||
score,
|
score,
|
||||||
ui,
|
ui,
|
||||||
websearch,
|
|
||||||
xai_x,
|
xai_x,
|
||||||
youtube_yt,
|
youtube_yt,
|
||||||
)
|
)
|
||||||
@@ -154,6 +153,8 @@ def _search_reddit(
|
|||||||
from_date,
|
from_date,
|
||||||
to_date,
|
to_date,
|
||||||
depth=depth,
|
depth=depth,
|
||||||
|
auth_source=config.get("OPENAI_AUTH_SOURCE", "api_key"),
|
||||||
|
account_id=config.get("OPENAI_CHATGPT_ACCOUNT_ID"),
|
||||||
)
|
)
|
||||||
except http.HTTPError as e:
|
except http.HTTPError as e:
|
||||||
raw_openai = {"error": str(e)}
|
raw_openai = {"error": str(e)}
|
||||||
@@ -176,6 +177,8 @@ def _search_reddit(
|
|||||||
core,
|
core,
|
||||||
from_date, to_date,
|
from_date, to_date,
|
||||||
depth=depth,
|
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)
|
retry_items = openai_reddit.parse_reddit_response(retry_raw)
|
||||||
# Add items not already found (by URL)
|
# Add items not already found (by URL)
|
||||||
@@ -1032,6 +1035,9 @@ def main():
|
|||||||
# Load config
|
# Load config
|
||||||
config = env.get_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)
|
# Auto-detect Bird (no prompts - just use it if available)
|
||||||
x_source_status = env.get_x_source_status(config)
|
x_source_status = env.get_x_source_status(config)
|
||||||
x_source = x_source_status["source"] # 'bird', 'xai', or None
|
x_source = x_source_status["source"] # 'bird', 'xai', or None
|
||||||
|
|||||||
@@ -24,6 +24,24 @@ DEPTH_CONFIG = {
|
|||||||
"deep": 60,
|
"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):
|
def _log(msg: str):
|
||||||
"""Log to stderr."""
|
"""Log to stderr."""
|
||||||
@@ -112,6 +130,7 @@ def is_bird_authenticated() -> Optional[str]:
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=15,
|
timeout=15,
|
||||||
|
env=_subprocess_env(),
|
||||||
)
|
)
|
||||||
if result.returncode == 0 and result.stdout.strip():
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
return result.stdout.strip().split('\n')[0]
|
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,
|
stderr=subprocess.PIPE,
|
||||||
text=True,
|
text=True,
|
||||||
preexec_fn=preexec,
|
preexec_fn=preexec,
|
||||||
|
env=_subprocess_env(),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Register for cleanup tracking (if available)
|
# Register for cleanup tracking (if available)
|
||||||
|
|||||||
+151
-7
@@ -1,9 +1,12 @@
|
|||||||
"""Environment and API key management for last30days skill."""
|
"""Environment and API key management for last30days skill."""
|
||||||
|
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any, Literal
|
||||||
|
|
||||||
# Allow override via environment variable for testing
|
# Allow override via environment variable for testing
|
||||||
# Set LAST30DAYS_CONFIG_DIR="" for clean/no-config mode
|
# Set LAST30DAYS_CONFIG_DIR="" for clean/no-config mode
|
||||||
@@ -20,6 +23,29 @@ else:
|
|||||||
CONFIG_DIR = Path.home() / ".config" / "last30days"
|
CONFIG_DIR = Path.home() / ".config" / "last30days"
|
||||||
CONFIG_FILE = CONFIG_DIR / ".env"
|
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]:
|
def load_env_file(path: Path) -> Dict[str, str]:
|
||||||
"""Load environment variables from a file."""
|
"""Load environment variables from a file."""
|
||||||
@@ -44,14 +70,131 @@ def load_env_file(path: Path) -> Dict[str, str]:
|
|||||||
return env
|
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]:
|
def get_config() -> Dict[str, Any]:
|
||||||
"""Load configuration from ~/.config/last30days/.env and environment."""
|
"""Load configuration from ~/.config/last30days/.env and environment."""
|
||||||
# Load from config file first (if configured)
|
# Load from config file first (if configured)
|
||||||
file_env = load_env_file(CONFIG_FILE) if CONFIG_FILE else {}
|
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 = [
|
keys = [
|
||||||
('OPENAI_API_KEY', None),
|
|
||||||
('XAI_API_KEY', None),
|
('XAI_API_KEY', None),
|
||||||
('OPENROUTER_API_KEY', None),
|
('OPENROUTER_API_KEY', None),
|
||||||
('PARALLEL_API_KEY', None),
|
('PARALLEL_API_KEY', None),
|
||||||
@@ -60,9 +203,10 @@ def get_config() -> Dict[str, Any]:
|
|||||||
('OPENAI_MODEL_PIN', None),
|
('OPENAI_MODEL_PIN', None),
|
||||||
('XAI_MODEL_POLICY', 'latest'),
|
('XAI_MODEL_POLICY', 'latest'),
|
||||||
('XAI_MODEL_PIN', None),
|
('XAI_MODEL_PIN', None),
|
||||||
|
('AUTH_TOKEN', None),
|
||||||
|
('CT0', None),
|
||||||
]
|
]
|
||||||
|
|
||||||
config = {}
|
|
||||||
for key, default in keys:
|
for key, default in keys:
|
||||||
config[key] = os.environ.get(key) or file_env.get(key, default)
|
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'
|
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_xai = bool(config.get('XAI_API_KEY'))
|
||||||
has_web = has_web_search_keys(config)
|
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'
|
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_xai = bool(config.get('XAI_API_KEY'))
|
||||||
has_web = has_web_search_keys(config)
|
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':
|
elif requested == 'web':
|
||||||
return 'web', None
|
return 'web', None
|
||||||
else:
|
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':
|
if requested == 'auto':
|
||||||
# Add web to sources if include_web is set
|
# Add web to sources if include_web is set
|
||||||
|
|||||||
+11
-4
@@ -38,6 +38,7 @@ def request(
|
|||||||
json_data: Optional[Dict[str, Any]] = None,
|
json_data: Optional[Dict[str, Any]] = None,
|
||||||
timeout: int = DEFAULT_TIMEOUT,
|
timeout: int = DEFAULT_TIMEOUT,
|
||||||
retries: int = MAX_RETRIES,
|
retries: int = MAX_RETRIES,
|
||||||
|
raw: bool = False,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Make an HTTP request and return JSON response.
|
"""Make an HTTP request and return JSON response.
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ def request(
|
|||||||
retries: Number of retries on failure
|
retries: Number of retries on failure
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Parsed JSON response
|
Parsed JSON response (or raw text if raw=True)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPError: On request failure
|
HTTPError: On request failure
|
||||||
@@ -66,8 +67,6 @@ def request(
|
|||||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
|
||||||
log(f"{method} {url}")
|
log(f"{method} {url}")
|
||||||
if json_data:
|
|
||||||
log(f"Payload keys: {list(json_data.keys())}")
|
|
||||||
|
|
||||||
last_error = None
|
last_error = None
|
||||||
for attempt in range(retries):
|
for attempt in range(retries):
|
||||||
@@ -75,6 +74,8 @@ def request(
|
|||||||
with urllib.request.urlopen(req, timeout=timeout) as response:
|
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||||||
body = response.read().decode('utf-8')
|
body = response.read().decode('utf-8')
|
||||||
log(f"Response: {response.status} ({len(body)} bytes)")
|
log(f"Response: {response.status} ({len(body)} bytes)")
|
||||||
|
if raw:
|
||||||
|
return body
|
||||||
return json.loads(body) if body else {}
|
return json.loads(body) if body else {}
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
body = None
|
body = None
|
||||||
@@ -84,7 +85,8 @@ def request(
|
|||||||
pass
|
pass
|
||||||
log(f"HTTP Error {e.code}: {e.reason}")
|
log(f"HTTP Error {e.code}: {e.reason}")
|
||||||
if body:
|
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)
|
last_error = HTTPError(f"HTTP {e.code}: {e.reason}", e.code, body)
|
||||||
|
|
||||||
# Don't retry client errors (4xx) except rate limits
|
# 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)
|
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]:
|
def get_reddit_json(path: str, timeout: int = DEFAULT_TIMEOUT, retries: int = MAX_RETRIES) -> Dict[str, Any]:
|
||||||
"""Fetch Reddit thread JSON.
|
"""Fetch Reddit thread JSON.
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -3,11 +3,12 @@
|
|||||||
import re
|
import re
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from . import cache, http
|
from . import cache, http, env
|
||||||
|
|
||||||
# OpenAI API
|
# OpenAI API
|
||||||
OPENAI_MODELS_URL = "https://api.openai.com/v1/models"
|
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"]
|
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 API - Agent Tools API requires grok-4 family
|
||||||
XAI_MODELS_URL = "https://api.x.ai/v1/models"
|
XAI_MODELS_URL = "https://api.x.ai/v1/models"
|
||||||
@@ -157,6 +158,15 @@ def get_models(
|
|||||||
result = {"openai": None, "xai": None}
|
result = {"openai": None, "xai": None}
|
||||||
|
|
||||||
if config.get("OPENAI_API_KEY"):
|
if config.get("OPENAI_API_KEY"):
|
||||||
|
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(
|
result["openai"] = select_openai_model(
|
||||||
config["OPENAI_API_KEY"],
|
config["OPENAI_API_KEY"],
|
||||||
config.get("OPENAI_MODEL_POLICY", "auto"),
|
config.get("OPENAI_MODEL_POLICY", "auto"),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import re
|
|||||||
import sys
|
import sys
|
||||||
from typing import Any, Dict, List, Optional
|
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)
|
# 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
|
# 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"
|
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
|
# Depth configurations: (min, max) threads to request
|
||||||
# Request MORE than needed since many get filtered by date
|
# 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"
|
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(
|
def search_reddit(
|
||||||
api_key: str,
|
api_key: str,
|
||||||
model: str,
|
model: str,
|
||||||
@@ -123,6 +239,8 @@ def search_reddit(
|
|||||||
from_date: str,
|
from_date: str,
|
||||||
to_date: str,
|
to_date: str,
|
||||||
depth: str = "default",
|
depth: str = "default",
|
||||||
|
auth_source: str = "api_key",
|
||||||
|
account_id: Optional[str] = None,
|
||||||
mock_response: Optional[Dict] = None,
|
mock_response: Optional[Dict] = None,
|
||||||
_retry: bool = False,
|
_retry: bool = False,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
@@ -145,10 +263,23 @@ def search_reddit(
|
|||||||
|
|
||||||
min_items, max_items = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
|
min_items, max_items = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
|
||||||
|
|
||||||
|
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 = {
|
headers = {
|
||||||
"Authorization": f"Bearer {api_key}",
|
"Authorization": f"Bearer {api_key}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
|
url = OPENAI_RESPONSES_URL
|
||||||
|
|
||||||
# Adjust timeout based on depth (generous for OpenAI web_search which can be slow)
|
# 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
|
timeout = 90 if depth == "quick" else 120 if depth == "default" else 180
|
||||||
@@ -166,6 +297,28 @@ def search_reddit(
|
|||||||
max_items=max_items,
|
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
|
last_error = None
|
||||||
for current_model in models_to_try:
|
for current_model in models_to_try:
|
||||||
payload = {
|
payload = {
|
||||||
@@ -183,7 +336,7 @@ def search_reddit(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
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:
|
except http.HTTPError as e:
|
||||||
last_error = e
|
last_error = e
|
||||||
if _is_model_access_error(e):
|
if _is_model_access_error(e):
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Optional
|
from typing import Optional
|
||||||
|
|
||||||
from . import schema
|
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("**🌐 WEB SEARCH MODE** - assistant will search blogs, docs & news")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("---")
|
lines.append("---")
|
||||||
lines.append("**⚡ Want better results?** Add API keys to unlock Reddit & X data:")
|
lines.append("**⚡ Want better results?** Add API keys or sign in to Codex to unlock Reddit & X data:")
|
||||||
lines.append("- `OPENAI_API_KEY` → Reddit threads with real upvotes & comments")
|
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("- `XAI_API_KEY` → X posts with real likes & reposts")
|
||||||
lines.append("- Edit `~/.config/last30days/.env` to add keys")
|
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("---")
|
||||||
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("*💡 Tip: Add XAI_API_KEY for X/Twitter data and better triangulation.*")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
elif report.mode == "x-only" and missing_keys == "reddit":
|
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("")
|
lines.append("")
|
||||||
|
|
||||||
# Reddit items
|
# Reddit items
|
||||||
@@ -168,7 +169,7 @@ def render_compact(report: schema.Report, limit: int = 15, missing_keys: str = "
|
|||||||
|
|
||||||
# Top comment insights
|
# Top comment insights
|
||||||
if item.comment_insights:
|
if item.comment_insights:
|
||||||
lines.append(f" Insights:")
|
lines.append(" Insights:")
|
||||||
for insight in item.comment_insights[:3]:
|
for insight in item.comment_insights[:3]:
|
||||||
lines.append(f" - {insight}")
|
lines.append(f" - {insight}")
|
||||||
|
|
||||||
@@ -630,6 +631,8 @@ def render_full_report(report: schema.Report) -> str:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def write_outputs(
|
def write_outputs(
|
||||||
report: schema.Report,
|
report: schema.Report,
|
||||||
raw_openai: Optional[dict] = None,
|
raw_openai: Optional[dict] = None,
|
||||||
|
|||||||
+2
-3
@@ -1,6 +1,5 @@
|
|||||||
"""Terminal UI utilities for last30days skill."""
|
"""Terminal UI utilities for last30days skill."""
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
@@ -117,7 +116,7 @@ I just researched that for you. Here's what I've got right now:
|
|||||||
|
|
||||||
{status_line}
|
{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:
|
Some examples of what you can do:
|
||||||
- "last30 what are people saying about Figma"
|
- "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
|
# Shorter promo for single missing key
|
||||||
PROMO_SINGLE_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",
|
"x": "\n💡 You can unlock X with an xAI API key — just ask me how.\n",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""Tests for Codex auth integration (env.py + openai_reddit.py)."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
# Add scripts directory to path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
|
||||||
|
|
||||||
|
from lib import env, openai_reddit
|
||||||
|
|
||||||
|
|
||||||
|
def _make_jwt(payload: dict) -> str:
|
||||||
|
"""Build a fake JWT with the given payload (no signature verification)."""
|
||||||
|
header = base64.urlsafe_b64encode(json.dumps({"alg": "none"}).encode()).rstrip(b"=")
|
||||||
|
body = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b"=")
|
||||||
|
return f"{header.decode()}.{body.decode()}.fakesig"
|
||||||
|
|
||||||
|
|
||||||
|
class TestDecodeJwtPayload(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_valid_jwt(self):
|
||||||
|
token = _make_jwt({"sub": "user123", "exp": 9999999999})
|
||||||
|
result = env._decode_jwt_payload(token)
|
||||||
|
self.assertEqual(result["sub"], "user123")
|
||||||
|
|
||||||
|
def test_invalid_jwt(self):
|
||||||
|
self.assertIsNone(env._decode_jwt_payload("not-a-jwt"))
|
||||||
|
|
||||||
|
def test_empty_string(self):
|
||||||
|
self.assertIsNone(env._decode_jwt_payload(""))
|
||||||
|
|
||||||
|
|
||||||
|
class TestTokenExpired(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_not_expired(self):
|
||||||
|
token = _make_jwt({"exp": int(time.time()) + 3600})
|
||||||
|
self.assertFalse(env._token_expired(token))
|
||||||
|
|
||||||
|
def test_expired(self):
|
||||||
|
token = _make_jwt({"exp": int(time.time()) - 100})
|
||||||
|
self.assertTrue(env._token_expired(token))
|
||||||
|
|
||||||
|
def test_no_exp_claim(self):
|
||||||
|
token = _make_jwt({"sub": "user"})
|
||||||
|
self.assertFalse(env._token_expired(token))
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractChatgptAccountId(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_extracts_account_id(self):
|
||||||
|
token = _make_jwt({
|
||||||
|
"https://api.openai.com/auth": {
|
||||||
|
"chatgpt_account_id": "acct_abc123"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
self.assertEqual(env.extract_chatgpt_account_id(token), "acct_abc123")
|
||||||
|
|
||||||
|
def test_missing_auth_claim(self):
|
||||||
|
token = _make_jwt({"sub": "user"})
|
||||||
|
self.assertIsNone(env.extract_chatgpt_account_id(token))
|
||||||
|
|
||||||
|
def test_missing_account_id_in_claim(self):
|
||||||
|
token = _make_jwt({
|
||||||
|
"https://api.openai.com/auth": {"other_field": "value"}
|
||||||
|
})
|
||||||
|
self.assertIsNone(env.extract_chatgpt_account_id(token))
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetOpenaiAuth(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_api_key_takes_priority(self):
|
||||||
|
"""OPENAI_API_KEY in env file should be preferred over Codex."""
|
||||||
|
file_env = {"OPENAI_API_KEY": "sk-test123"}
|
||||||
|
auth = env.get_openai_auth(file_env)
|
||||||
|
self.assertEqual(auth.source, "api_key")
|
||||||
|
self.assertEqual(auth.status, "ok")
|
||||||
|
self.assertEqual(auth.token, "sk-test123")
|
||||||
|
self.assertIsNone(auth.account_id)
|
||||||
|
|
||||||
|
@patch.dict(os.environ, {"OPENAI_API_KEY": "sk-from-env"}, clear=False)
|
||||||
|
def test_env_var_takes_priority(self):
|
||||||
|
"""OPENAI_API_KEY env var should be preferred over file."""
|
||||||
|
file_env = {}
|
||||||
|
auth = env.get_openai_auth(file_env)
|
||||||
|
self.assertEqual(auth.source, "api_key")
|
||||||
|
self.assertEqual(auth.token, "sk-from-env")
|
||||||
|
|
||||||
|
def test_no_keys_returns_none_source(self):
|
||||||
|
"""No API key and no Codex auth → source=none."""
|
||||||
|
fake_path = Path("/tmp/nonexistent_codex_auth_test.json")
|
||||||
|
with patch.object(env, 'CODEX_AUTH_FILE', fake_path):
|
||||||
|
# Also patch get_codex_access_token to avoid reading real auth file
|
||||||
|
with patch.object(env, 'get_codex_access_token', return_value=(None, "missing")):
|
||||||
|
environ_copy = {k: v for k, v in os.environ.items() if k != "OPENAI_API_KEY"}
|
||||||
|
with patch.dict(os.environ, environ_copy, clear=True):
|
||||||
|
auth = env.get_openai_auth({})
|
||||||
|
self.assertEqual(auth.source, "none")
|
||||||
|
self.assertIsNone(auth.token)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadCodexAuth(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_nonexistent_file(self):
|
||||||
|
result = env.load_codex_auth(Path("/tmp/nonexistent_codex_auth.json"))
|
||||||
|
self.assertEqual(result, {})
|
||||||
|
|
||||||
|
def test_valid_json(self):
|
||||||
|
import tempfile
|
||||||
|
data = {"tokens": {"access_token": "tok123"}}
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||||||
|
json.dump(data, f)
|
||||||
|
f.flush()
|
||||||
|
result = env.load_codex_auth(Path(f.name))
|
||||||
|
os.unlink(f.name)
|
||||||
|
self.assertEqual(result["tokens"]["access_token"], "tok123")
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetAvailableSourcesWithAuth(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_codex_auth_ok_counts_as_openai(self):
|
||||||
|
config = {
|
||||||
|
"OPENAI_API_KEY": "codex-token",
|
||||||
|
"OPENAI_AUTH_STATUS": "ok",
|
||||||
|
"XAI_API_KEY": None,
|
||||||
|
}
|
||||||
|
result = env.get_available_sources(config)
|
||||||
|
self.assertIn("reddit", result)
|
||||||
|
|
||||||
|
def test_codex_auth_expired_not_counted(self):
|
||||||
|
config = {
|
||||||
|
"OPENAI_API_KEY": None,
|
||||||
|
"OPENAI_AUTH_STATUS": "expired",
|
||||||
|
"XAI_API_KEY": None,
|
||||||
|
}
|
||||||
|
result = env.get_available_sources(config)
|
||||||
|
self.assertEqual(result, "web")
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseCodexStream(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_response_completed_event(self):
|
||||||
|
"""Should extract response from response.completed SSE event."""
|
||||||
|
sse = (
|
||||||
|
'data: {"type":"response.created","response":{"id":"r1"}}\n\n'
|
||||||
|
'data: {"type":"response.completed","response":{"id":"r1","output":[{"type":"message","content":[{"type":"output_text","text":"hello"}]}]}}\n\n'
|
||||||
|
)
|
||||||
|
result = openai_reddit._parse_codex_stream(sse)
|
||||||
|
self.assertIn("output", result)
|
||||||
|
|
||||||
|
def test_delta_fallback(self):
|
||||||
|
"""Should reconstruct text from delta events."""
|
||||||
|
sse = (
|
||||||
|
'data: {"delta":"hel"}\n\n'
|
||||||
|
'data: {"delta":"lo"}\n\n'
|
||||||
|
)
|
||||||
|
result = openai_reddit._parse_codex_stream(sse)
|
||||||
|
self.assertIn("output", result)
|
||||||
|
text = result["output"][0]["content"][0]["text"]
|
||||||
|
self.assertEqual(text, "hello")
|
||||||
|
|
||||||
|
def test_empty_stream(self):
|
||||||
|
result = openai_reddit._parse_codex_stream("")
|
||||||
|
self.assertEqual(result, {})
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildPayload(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_api_key_payload(self):
|
||||||
|
payload = openai_reddit._build_payload(
|
||||||
|
"gpt-4o", "instructions", "input text", "api_key"
|
||||||
|
)
|
||||||
|
self.assertEqual(payload["model"], "gpt-4o")
|
||||||
|
self.assertEqual(payload["input"], "input text")
|
||||||
|
self.assertNotIn("stream", payload)
|
||||||
|
|
||||||
|
def test_codex_payload_has_stream(self):
|
||||||
|
payload = openai_reddit._build_payload(
|
||||||
|
"gpt-4o", "instructions", "input text", env.AUTH_SOURCE_CODEX
|
||||||
|
)
|
||||||
|
self.assertTrue(payload["stream"])
|
||||||
|
# Input should be structured message format for Codex
|
||||||
|
self.assertIsInstance(payload["input"], list)
|
||||||
|
self.assertEqual(payload["input"][0]["role"], "user")
|
||||||
|
|
||||||
|
def test_codex_payload_has_store_false(self):
|
||||||
|
payload = openai_reddit._build_payload(
|
||||||
|
"gpt-4o", "inst", "text", env.AUTH_SOURCE_CODEX
|
||||||
|
)
|
||||||
|
self.assertFalse(payload["store"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user