openrouter
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -20,3 +20,4 @@ credentials/
|
|||||||
.claude/
|
.claude/
|
||||||
*.pid
|
*.pid
|
||||||
memory.bak/
|
memory.bak/
|
||||||
|
.use_openrouter
|
||||||
|
|||||||
56
cli.py
56
cli.py
@@ -580,6 +580,51 @@ def cmd_whatsapp(args):
|
|||||||
_whatsapp_qr()
|
_whatsapp_qr()
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_openrouter(args):
|
||||||
|
"""Handle openrouter subcommand."""
|
||||||
|
semaphore = PROJECT_ROOT / ".use_openrouter"
|
||||||
|
|
||||||
|
if args.openrouter_action == "on":
|
||||||
|
env_file = Path.home() / ".claude-env.sh"
|
||||||
|
if not env_file.exists():
|
||||||
|
print(f"Error: {env_file} not found")
|
||||||
|
sys.exit(1)
|
||||||
|
# Verify required vars exist in file
|
||||||
|
text = env_file.read_text()
|
||||||
|
if "ANTHROPIC_BASE_URL" not in text or "OPENROUTER_API_KEY" not in text:
|
||||||
|
print(f"Warning: {env_file} may be missing ANTHROPIC_BASE_URL or OPENROUTER_API_KEY")
|
||||||
|
|
||||||
|
semaphore.write_text("# OpenRouter mode enabled\n")
|
||||||
|
print("OpenRouter mode: ENABLED")
|
||||||
|
print("Restart echo-core for changes to take effect:")
|
||||||
|
print(" systemctl --user restart echo-core")
|
||||||
|
|
||||||
|
elif args.openrouter_action == "off":
|
||||||
|
if semaphore.exists():
|
||||||
|
semaphore.unlink()
|
||||||
|
print("OpenRouter mode: DISABLED")
|
||||||
|
print("Restart echo-core to use Anthropic API:")
|
||||||
|
print(" systemctl --user restart echo-core")
|
||||||
|
else:
|
||||||
|
print("OpenRouter mode: already disabled")
|
||||||
|
|
||||||
|
elif args.openrouter_action == "status":
|
||||||
|
status = "ENABLED" if semaphore.exists() else "DISABLED"
|
||||||
|
print(f"OpenRouter mode: {status}")
|
||||||
|
if semaphore.exists():
|
||||||
|
print(f"Semafor: {semaphore}")
|
||||||
|
env_file = Path.home() / ".claude-env.sh"
|
||||||
|
if env_file.exists():
|
||||||
|
# Show which vars will be loaded
|
||||||
|
print(f"Env file: {env_file}")
|
||||||
|
text = env_file.read_text()
|
||||||
|
for line in text.splitlines():
|
||||||
|
if line.strip().startswith("export ") and not line.strip().startswith("#"):
|
||||||
|
var_name = line.strip()[7:].split("=")[0] if "=" in line else "?"
|
||||||
|
if any(x in var_name for x in ["ANTHROPIC", "OPENROUTER"]):
|
||||||
|
print(f" {var_name}=***")
|
||||||
|
|
||||||
|
|
||||||
def _whatsapp_status():
|
def _whatsapp_status():
|
||||||
"""Check WhatsApp bridge connection status."""
|
"""Check WhatsApp bridge connection status."""
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@@ -806,6 +851,14 @@ def main():
|
|||||||
whatsapp_sub.add_parser("status", help="Check bridge connection status")
|
whatsapp_sub.add_parser("status", help="Check bridge connection status")
|
||||||
whatsapp_sub.add_parser("qr", help="Show QR code instructions")
|
whatsapp_sub.add_parser("qr", help="Show QR code instructions")
|
||||||
|
|
||||||
|
# openrouter
|
||||||
|
openrouter_parser = sub.add_parser("openrouter", help="Toggle OpenRouter mode")
|
||||||
|
openrouter_sub = openrouter_parser.add_subparsers(dest="openrouter_action")
|
||||||
|
|
||||||
|
openrouter_sub.add_parser("on", help="Enable OpenRouter mode")
|
||||||
|
openrouter_sub.add_parser("off", help="Disable OpenRouter mode")
|
||||||
|
openrouter_sub.add_parser("status", help="Check OpenRouter status")
|
||||||
|
|
||||||
# Parse and dispatch
|
# Parse and dispatch
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
@@ -839,6 +892,9 @@ def main():
|
|||||||
"whatsapp": lambda a: (
|
"whatsapp": lambda a: (
|
||||||
cmd_whatsapp(a) if a.whatsapp_action else (whatsapp_parser.print_help() or sys.exit(0))
|
cmd_whatsapp(a) if a.whatsapp_action else (whatsapp_parser.print_help() or sys.exit(0))
|
||||||
),
|
),
|
||||||
|
"openrouter": lambda a: (
|
||||||
|
cmd_openrouter(a) if a.openrouter_action else (openrouter_parser.print_help() or sys.exit(0))
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
handler = dispatch.get(args.command)
|
handler = dispatch.get(args.command)
|
||||||
|
|||||||
@@ -107,12 +107,89 @@ if not shutil.which(CLAUDE_BIN):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_env_vars(value: str, env: dict[str, str]) -> str:
|
||||||
|
"""Expand $VAR and ${VAR} patterns using values from env dict."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
def replacer(match: re.Match) -> str:
|
||||||
|
var_name = match.group(1) or match.group(2)
|
||||||
|
return env.get(var_name, match.group(0))
|
||||||
|
|
||||||
|
# Match ${VAR} or $VAR
|
||||||
|
return re.sub(r'\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)', replacer, value)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_env_file(path: Path, target_env: dict[str, str]) -> None:
|
||||||
|
"""Parse shell export statements from env file and update target_env."""
|
||||||
|
try:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
for line in text.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith("export "):
|
||||||
|
# Parse: export VAR="value" or export VAR=value
|
||||||
|
rest = line[7:] # remove "export "
|
||||||
|
if "=" in rest:
|
||||||
|
key, val = rest.split("=", 1)
|
||||||
|
key = key.strip()
|
||||||
|
# Remove inline comments (everything after unquoted #)
|
||||||
|
val = _strip_shell_comments(val.strip())
|
||||||
|
# Remove quotes if present
|
||||||
|
val = val.strip('"').strip("'")
|
||||||
|
if key and not key.startswith("#"):
|
||||||
|
# Expand shell variables like $OPENROUTER_API_KEY
|
||||||
|
val = _expand_env_vars(val, target_env)
|
||||||
|
target_env[key] = val
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_shell_comments(value: str) -> str:
|
||||||
|
"""Remove shell-style comments from a value string.
|
||||||
|
|
||||||
|
Handles quoted # characters correctly.
|
||||||
|
"""
|
||||||
|
in_single = False
|
||||||
|
in_double = False
|
||||||
|
escaped = False
|
||||||
|
|
||||||
|
for i, char in enumerate(value):
|
||||||
|
if escaped:
|
||||||
|
escaped = False
|
||||||
|
continue
|
||||||
|
if char == '\\':
|
||||||
|
escaped = True
|
||||||
|
continue
|
||||||
|
if char == '"' and not in_single:
|
||||||
|
in_double = not in_double
|
||||||
|
elif char == "'" and not in_double:
|
||||||
|
in_single = not in_single
|
||||||
|
elif char == '#' and not in_single and not in_double:
|
||||||
|
# Comment starts here
|
||||||
|
return value[:i].rstrip()
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _safe_env() -> dict[str, str]:
|
def _safe_env() -> dict[str, str]:
|
||||||
"""Return os.environ minus sensitive/problematic variables."""
|
"""Return os.environ minus sensitive/problematic variables.
|
||||||
stripped = {k for k in _ENV_STRIP if k in os.environ}
|
|
||||||
|
Daca exista fisierul .use_openrouter, incarca variabilele din ~/.claude-env.sh
|
||||||
|
"""
|
||||||
|
env = dict(os.environ)
|
||||||
|
|
||||||
|
# Check for OpenRouter toggle
|
||||||
|
semaphore = PROJECT_ROOT / ".use_openrouter"
|
||||||
|
env_file = Path.home() / ".claude-env.sh"
|
||||||
|
if semaphore.exists() and env_file.exists():
|
||||||
|
# Parse and load environment variables from .claude-env.sh
|
||||||
|
_load_env_file(env_file, env)
|
||||||
|
# Keep ANTHROPIC_DEFAULT_*_MODEL vars - Claude CLI uses them to translate
|
||||||
|
# haiku/sonnet/opus aliases to OpenRouter model names
|
||||||
|
|
||||||
|
stripped = {k for k in _ENV_STRIP if k in env}
|
||||||
if stripped:
|
if stripped:
|
||||||
_security_log.debug("Stripped env vars from subprocess: %s", stripped)
|
_security_log.debug("Stripped env vars from subprocess: %s", stripped)
|
||||||
return {k: v for k, v in os.environ.items() if k not in _ENV_STRIP}
|
return {k: v for k, v in env.items() if k not in _ENV_STRIP}
|
||||||
|
|
||||||
|
|
||||||
def _load_sessions() -> dict:
|
def _load_sessions() -> dict:
|
||||||
@@ -242,8 +319,13 @@ def _run_claude(
|
|||||||
|
|
||||||
if proc.returncode != 0:
|
if proc.returncode != 0:
|
||||||
stdout_tail = "\n".join(text_blocks[-3:]) if text_blocks else ""
|
stdout_tail = "\n".join(text_blocks[-3:]) if text_blocks else ""
|
||||||
detail = stderr_output[:500] or stdout_tail[:500]
|
# Check if result_obj has an error
|
||||||
|
result_error = ""
|
||||||
|
if result_obj and result_obj.get("is_error"):
|
||||||
|
result_error = result_obj.get("result", "") or result_obj.get("error", "")
|
||||||
|
detail = stderr_output[:500] or result_error[:500] or stdout_tail[:500]
|
||||||
logger.error("Claude CLI stderr: %s", stderr_output[:1000])
|
logger.error("Claude CLI stderr: %s", stderr_output[:1000])
|
||||||
|
logger.error("Claude CLI result_obj: %s", result_obj)
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Claude CLI error (exit {proc.returncode}): {detail}"
|
f"Claude CLI error (exit {proc.returncode}): {detail}"
|
||||||
)
|
)
|
||||||
@@ -385,14 +467,14 @@ def resume_session(
|
|||||||
If *on_text* is provided, each intermediate Claude text block is passed
|
If *on_text* is provided, each intermediate Claude text block is passed
|
||||||
to the callback as soon as it arrives.
|
to the callback as soon as it arrives.
|
||||||
"""
|
"""
|
||||||
# Find channel/model for logging
|
# Find channel/model for logging and model selection
|
||||||
sessions = _load_sessions()
|
sessions = _load_sessions()
|
||||||
_log_channel = "?"
|
_log_channel = "?"
|
||||||
_log_model = "?"
|
_log_model = DEFAULT_MODEL
|
||||||
for cid, sess in sessions.items():
|
for cid, sess in sessions.items():
|
||||||
if sess.get("session_id") == session_id:
|
if sess.get("session_id") == session_id:
|
||||||
_log_channel = cid
|
_log_channel = cid
|
||||||
_log_model = sess.get("model", "?")
|
_log_model = sess.get("model", DEFAULT_MODEL)
|
||||||
break
|
break
|
||||||
|
|
||||||
# Wrap external user message with injection protection markers
|
# Wrap external user message with injection protection markers
|
||||||
@@ -401,6 +483,7 @@ def resume_session(
|
|||||||
cmd = [
|
cmd = [
|
||||||
CLAUDE_BIN, "-p", wrapped_message,
|
CLAUDE_BIN, "-p", wrapped_message,
|
||||||
"--resume", session_id,
|
"--resume", session_id,
|
||||||
|
"--model", _log_model,
|
||||||
"--output-format", "stream-json", "--verbose",
|
"--output-format", "stream-json", "--verbose",
|
||||||
"--allowedTools", *ALLOWED_TOOLS,
|
"--allowedTools", *ALLOWED_TOOLS,
|
||||||
]
|
]
|
||||||
@@ -452,10 +535,15 @@ def send_message(
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""High-level convenience: auto start or resume based on channel state."""
|
"""High-level convenience: auto start or resume based on channel state."""
|
||||||
session = get_active_session(channel_id)
|
session = get_active_session(channel_id)
|
||||||
if session is not None:
|
# Only resume if session has a valid session_id (not a pre-set model placeholder)
|
||||||
|
if session is not None and session.get("session_id"):
|
||||||
return resume_session(session["session_id"], message, timeout, on_text=on_text)
|
return resume_session(session["session_id"], message, timeout, on_text=on_text)
|
||||||
|
# Use model from pre-set session if available, otherwise use provided model
|
||||||
|
effective_model = model
|
||||||
|
if session is not None and session.get("model"):
|
||||||
|
effective_model = session["model"]
|
||||||
response_text, _session_id = start_session(
|
response_text, _session_id = start_session(
|
||||||
channel_id, message, model, timeout, on_text=on_text
|
channel_id, message, effective_model, timeout, on_text=on_text
|
||||||
)
|
)
|
||||||
return response_text
|
return response_text
|
||||||
|
|
||||||
|
|||||||
@@ -49,18 +49,21 @@ def get_subtitles(url, lang='en'):
|
|||||||
f.unlink()
|
f.unlink()
|
||||||
|
|
||||||
# First, get video info
|
# First, get video info
|
||||||
info_cmd = [yt_dlp, '--dump-json', '--no-download', url]
|
title = "Unknown"
|
||||||
try:
|
info_cmd = [yt_dlp, '--js-runtimes', 'node', '--dump-json', '--no-download', url]
|
||||||
result = subprocess.run(info_cmd, capture_output=True, text=True, timeout=30)
|
result = subprocess.run(info_cmd, capture_output=True, text=True, timeout=30)
|
||||||
if result.returncode == 0:
|
print(f"INFO: returncode={result.returncode}, stderr={result.stderr[:200]}", file=sys.stderr)
|
||||||
|
if result.returncode == 0:
|
||||||
|
try:
|
||||||
info = json.loads(result.stdout)
|
info = json.loads(result.stdout)
|
||||||
title = info.get('title', 'Unknown')
|
title = info.get('title', 'Unknown')
|
||||||
duration = info.get('duration', 0)
|
duration = info.get('duration', 0)
|
||||||
print(f"Title: {title}", file=sys.stderr)
|
print(f"Title: {title}", file=sys.stderr)
|
||||||
print(f"Duration: {duration//60}:{duration%60:02d}", file=sys.stderr)
|
print(f"Duration: {duration//60}:{duration%60:02d}", file=sys.stderr)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
title = "Unknown"
|
print(f"JSON parse error: {e}", file=sys.stderr)
|
||||||
print(f"Could not get video info: {e}", file=sys.stderr)
|
else:
|
||||||
|
print(f"yt-dlp failed: {result.stderr[:500]}", file=sys.stderr)
|
||||||
|
|
||||||
# Try to get subtitles in order of preference
|
# Try to get subtitles in order of preference
|
||||||
lang_preferences = [lang, 'ro', 'en', 'en-US', 'en-GB']
|
lang_preferences = [lang, 'ro', 'en', 'en-US', 'en-GB']
|
||||||
@@ -69,6 +72,7 @@ def get_subtitles(url, lang='en'):
|
|||||||
# Try manual subtitles first
|
# Try manual subtitles first
|
||||||
cmd = [
|
cmd = [
|
||||||
yt_dlp,
|
yt_dlp,
|
||||||
|
'--js-runtimes', 'node',
|
||||||
'--write-subs',
|
'--write-subs',
|
||||||
'--sub-langs', try_lang,
|
'--sub-langs', try_lang,
|
||||||
'--skip-download',
|
'--skip-download',
|
||||||
@@ -88,6 +92,7 @@ def get_subtitles(url, lang='en'):
|
|||||||
for try_lang in lang_preferences:
|
for try_lang in lang_preferences:
|
||||||
cmd = [
|
cmd = [
|
||||||
yt_dlp,
|
yt_dlp,
|
||||||
|
'--js-runtimes', 'node',
|
||||||
'--write-auto-subs',
|
'--write-auto-subs',
|
||||||
'--sub-langs', try_lang,
|
'--sub-langs', try_lang,
|
||||||
'--skip-download',
|
'--skip-download',
|
||||||
@@ -104,7 +109,7 @@ def get_subtitles(url, lang='en'):
|
|||||||
if text:
|
if text:
|
||||||
return title, text
|
return title, text
|
||||||
|
|
||||||
return title, None
|
return title or "Unknown", None
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
if len(sys.argv) < 2:
|
if len(sys.argv) < 2:
|
||||||
|
|||||||
Reference in New Issue
Block a user