relay-code 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
client/response.py ADDED
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass, field
3
+ from enum import Enum
4
+ import json
5
+ from typing import Any
6
+
7
+ @dataclass
8
+ class TextDelta:
9
+ content: str
10
+
11
+ def __str__(self):
12
+ return self.content
13
+
14
+ class StreamEventType(str, Enum):
15
+ TEXT_DELTA = "text_delta"
16
+ MESSAGE_COMPLETE = "message_complete"
17
+ ERROR = "error"
18
+
19
+ TOOL_CALL_START = "tool_call_start"
20
+ TOOL_CALL_DELTA = "tool_call_delta"
21
+ TOOL_CALL_COMPLETE = "tool_call_complete"
22
+
23
+ @dataclass
24
+ class TokenUsage:
25
+ prompt_tokens: int = 0
26
+ completion_tokens: int = 0
27
+ total_tokens: int = 0
28
+ cached_tokens: int = 0
29
+
30
+ def __add__(self, other: TokenUsage):
31
+ return TokenUsage(
32
+ prompt_tokens=self.prompt_tokens + other.prompt_tokens,
33
+ completion_tokens=self.completion_tokens + other.completion_tokens,
34
+ total_tokens=self.total_tokens + other.total_tokens,
35
+ cached_tokens=self.cached_tokens + other.cached_tokens,
36
+ )
37
+
38
+ @dataclass
39
+ class ToolCallDelta:
40
+ call_id: str
41
+ name: str | None = None
42
+ arguments_delta: str = ""
43
+
44
+ @dataclass
45
+ class ToolCall:
46
+ call_id: str
47
+ name: str | None = None
48
+ arguments: dict[str, Any] = field(default_factory=dict)
49
+
50
+ @dataclass
51
+ class StreamEvent:
52
+ type: StreamEventType
53
+ text_delta: TextDelta | None = None
54
+ tool_calls: list[ToolCall] = field(default_factory=list)
55
+ error: str | None = None
56
+ finish_reason: str | None = None
57
+ tool_call_delta: ToolCallDelta | None = None
58
+ tool_call: ToolCall | None = None
59
+ usage: TokenUsage | None = None
60
+
61
+ @dataclass
62
+ class ToolResultMessage:
63
+ tool_call_id: str
64
+ content: str
65
+ is_error: bool = False
66
+
67
+ def to_openai_message(self) -> dict[str, Any]:
68
+ return {
69
+ 'role': 'tool',
70
+ 'tool_call_id': self.tool_call_id,
71
+ 'content': self.content
72
+ }
73
+
74
+ def parse_tool_call_arguments(arguments_str: str) -> dict[str, Any]:
75
+ if not arguments_str:
76
+ return {}
77
+
78
+ try:
79
+ return json.loads(arguments_str)
80
+ except json.JSONDecodeError:
81
+ return {
82
+ 'raw_arguments': arguments_str
83
+ }
config/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Configuration module for Relay."""
2
+
3
+ from config.config import Config, ModelConfig
4
+ from config.loader import load_config
5
+
6
+ __all__ = ['Config', 'ModelConfig', 'load_config']
config/config.py ADDED
@@ -0,0 +1,76 @@
1
+ import os
2
+ from pathlib import Path
3
+ from pydantic import BaseModel, Field
4
+
5
+ from config.credentials import load_credentials
6
+
7
+ class ModelConfig(BaseModel):
8
+
9
+ name: str = "tencent/hy3:free"
10
+
11
+ temperature: float = Field(
12
+ default=1,
13
+ ge=0.0,
14
+ le=2.0
15
+ ) # clarity of the model
16
+
17
+ context_window: int = 256_000
18
+
19
+ class ShellEnvironmentConfig(BaseModel):
20
+ ignore_default_excludes: bool = False # for filtering keys, secrets
21
+ exclude_patterns: list[str] = Field(
22
+ default_factory=lambda: ["*KEY*", "*TOKEN*", "*SECRET*"]
23
+ )
24
+ set_vars: dict[str, str] = Field(default_factory=dict)
25
+
26
+ class Config(BaseModel):
27
+
28
+ model: ModelConfig = Field(default_factory=ModelConfig)
29
+ cwd: Path = Field(default_factory=Path.cwd)
30
+ shell_environment: ShellEnvironmentConfig = Field(
31
+ default_factory=ShellEnvironmentConfig
32
+ )
33
+
34
+ max_turns: int = 100
35
+ max_tool_output_tokens: int = 50_000
36
+
37
+ developer_instructions: str | None = None
38
+ user_instructions: str | None = None
39
+ debug: bool = False
40
+
41
+ @property
42
+ def api_key(self) -> str | None:
43
+ # Env var wins (handy for CI / one-off overrides); otherwise fall
44
+ # back to whatever `relay login` saved.
45
+ return os.environ.get("API_KEY") or load_credentials().get("api_key")
46
+
47
+ @property
48
+ def base_url(self) -> str | None:
49
+ return os.environ.get("BASE_URL") or load_credentials().get("base_url")
50
+
51
+ @property
52
+ def model_name(self) -> str:
53
+ return self.model.name
54
+
55
+ @model_name.setter
56
+ def model_name(self, value: str) -> None:
57
+ self.model.name = value
58
+
59
+ @property
60
+ def temperature(self) -> float:
61
+ return self.model.temperature
62
+
63
+ @temperature.setter
64
+ def temperature(self, value: float) -> None:
65
+ self.model.temperature = value
66
+
67
+ def validate(self) -> list[str]:
68
+ errors: list[str] = []
69
+
70
+ if not self.api_key:
71
+ errors.append("No API key was found. Solution: run `relay login` (or set the API_KEY environment variable)")
72
+
73
+ if not self.cwd.exists():
74
+ errors.append(f"Working directory does not exist: {self.cwd}")
75
+
76
+ return errors
config/credentials.py ADDED
@@ -0,0 +1,54 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ from platformdirs import user_config_dir
5
+
6
+ try:
7
+ import tomllib
8
+ except ModuleNotFoundError:
9
+ import tomli as tomllib
10
+
11
+ CREDENTIALS_FILE = "credentials.toml"
12
+
13
+
14
+ def get_credentials_path() -> Path:
15
+ return Path(user_config_dir("relay")) / CREDENTIALS_FILE
16
+
17
+
18
+ def load_credentials() -> dict[str, str]:
19
+ path = get_credentials_path()
20
+ if not path.is_file():
21
+ return {}
22
+ try:
23
+ with open(path, "rb") as f:
24
+ return tomllib.load(f)
25
+ except (OSError, tomllib.TOMLDecodeError):
26
+ return {}
27
+
28
+
29
+ def _toml_escape(value: str) -> str:
30
+ return value.replace("\\", "\\\\").replace('"', '\\"')
31
+
32
+
33
+ def save_credentials(api_key: str, base_url: str | None = None) -> Path:
34
+ path = get_credentials_path()
35
+ path.parent.mkdir(parents=True, exist_ok=True)
36
+
37
+ lines = ["# relay credentials, written by `relay login`. Do not commit.\n"]
38
+ lines.append(f'api_key = "{_toml_escape(api_key)}"\n')
39
+ if base_url:
40
+ lines.append(f'base_url = "{_toml_escape(base_url)}"\n')
41
+
42
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
43
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
44
+ f.writelines(lines)
45
+ os.chmod(path, 0o600)
46
+ return path
47
+
48
+
49
+ def clear_credentials() -> bool:
50
+ path = get_credentials_path()
51
+ if path.is_file():
52
+ path.unlink()
53
+ return True
54
+ return False
config/loader.py ADDED
@@ -0,0 +1,108 @@
1
+ from pathlib import Path
2
+ from config.config import Config
3
+ from platformdirs import user_config_dir
4
+ try:
5
+ import tomllib
6
+ except ModuleNotFoundError:
7
+ import tomli as tomllib
8
+ import logging
9
+ from typing import Any
10
+
11
+ from utils.errors import ConfigError
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ CONFIG_FILE = 'config.toml'
16
+ AGENT_MD_FILE = 'AGENTS.md'
17
+
18
+ def get_config_dir() -> Path:
19
+ return Path(user_config_dir('relay'))
20
+
21
+ def get_data_dir() -> Path:
22
+ return Path(user_config_dir('relay'))
23
+
24
+ def get_system_config_path() -> Path:
25
+ return get_config_dir() / CONFIG_FILE
26
+
27
+ def _parse_toml(path: Path):
28
+ try:
29
+ with open(path, 'rb') as f:
30
+ return tomllib.load(f)
31
+ except tomllib.TOMLDecodeError as e:
32
+ raise ConfigError(f"Invalid TOML in {path}: {e}", config_file=str(path)) from e
33
+ except (OSError, IOError) as e:
34
+ raise ConfigError(f"Failed to read config file: {path}: {e}", config_file=str(path)) from e
35
+
36
+ def _get_project_config(cwd: Path) -> Path:
37
+
38
+ current = cwd.resolve()
39
+ agent_dir = current / '.relay'
40
+
41
+ if agent_dir.is_dir():
42
+ config_file = agent_dir / CONFIG_FILE
43
+ if config_file.is_file():
44
+ return config_file
45
+
46
+ return None
47
+
48
+ def _get_agent_md(cwd: Path) -> Path:
49
+
50
+ current = cwd.resolve()
51
+
52
+ if current.is_dir():
53
+ agent_md_file = current / AGENT_MD_FILE
54
+ if agent_md_file.is_file():
55
+ return agent_md_file.read_text(encoding='utf-8')
56
+
57
+ return None
58
+
59
+ def _merge_dicts(
60
+ base: dict[str, Any],
61
+ override: dict[str, Any]) -> dict[str, Any]:
62
+ result = base.copy()
63
+ for key, value in override.items():
64
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
65
+ result[key] = _merge_dicts(result[key], value)
66
+ else:
67
+ result[key] = value
68
+
69
+ return result
70
+
71
+ def load_config(cwd: Path | None) -> Config:
72
+ cwd = cwd or Path.cwd()
73
+
74
+ system_path = get_system_config_path()
75
+
76
+ config_dict: dict[str, Any] = {}
77
+
78
+ if system_path.is_file():
79
+ try:
80
+ config_dict = _merge_dicts(config_dict, _parse_toml(system_path))
81
+ except ConfigError:
82
+ logger.exception(f"Invalid system config: {system_path}")
83
+ raise
84
+
85
+ project_path = _get_project_config(cwd)
86
+ if project_path:
87
+ try:
88
+ project_config_dict = _parse_toml(project_path)
89
+ config_dict = _merge_dicts(config_dict, project_config_dict)
90
+ except ConfigError:
91
+ logger.exception(f"Invalid project config: {project_path}")
92
+ raise
93
+
94
+ if "cwd" not in config_dict:
95
+ config_dict["cwd"] = cwd
96
+
97
+ if "developer_instructions" not in config_dict:
98
+ agent_md_content = _get_agent_md(cwd)
99
+
100
+ if agent_md_content:
101
+ config_dict['developer_instructions'] = agent_md_content
102
+
103
+ try:
104
+ config = Config(**config_dict)
105
+ except Exception as e:
106
+ raise ConfigError(f"Invalid config: {e}") from e
107
+
108
+ return config
config/oauth.py ADDED
@@ -0,0 +1,137 @@
1
+ import base64
2
+ import hashlib
3
+ import secrets
4
+ import threading
5
+ import urllib.parse
6
+ import webbrowser
7
+ from http.server import BaseHTTPRequestHandler, HTTPServer
8
+
9
+ import httpx
10
+
11
+ AUTH_URL = "https://openrouter.ai/auth"
12
+ # Token exchange lives under the API base; keep it in step with base_url.
13
+ KEYS_PATH = "/auth/keys"
14
+
15
+ # How long we'll wait for the user to finish authorizing in the browser.
16
+ CALLBACK_TIMEOUT_SECONDS = 300
17
+
18
+
19
+ class OAuthError(Exception):
20
+ """Raised when the OAuth flow can't be completed."""
21
+
22
+
23
+ def _generate_pkce_pair() -> tuple[str, str]:
24
+ """Return (code_verifier, code_challenge) for the S256 method."""
25
+ # 32 random bytes -> 43-char base64url string, within the RFC 7636
26
+ # 43..128 length window.
27
+ verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
28
+ digest = hashlib.sha256(verifier.encode()).digest()
29
+ challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
30
+ return verifier, challenge
31
+
32
+
33
+ class _CallbackHandler(BaseHTTPRequestHandler):
34
+ """Single-shot handler that captures the `code` from the redirect."""
35
+
36
+ # Set by the server factory below.
37
+ result: dict[str, str] = {}
38
+
39
+ def do_GET(self) -> None: # noqa: N802 (http.server naming)
40
+ query = urllib.parse.urlparse(self.path).query
41
+ params = urllib.parse.parse_qs(query)
42
+ code = params.get("code", [None])[0]
43
+
44
+ if code:
45
+ type(self).result["code"] = code
46
+ body = "<h2>relay is now logged in.</h2><p>You can close this tab.</p>"
47
+ else:
48
+ type(self).result["error"] = params.get("error", ["unknown error"])[0]
49
+ body = "<h2>Login failed.</h2><p>You can close this tab and try again.</p>"
50
+
51
+ self.send_response(200)
52
+ self.send_header("Content-Type", "text/html; charset=utf-8")
53
+ self.end_headers()
54
+ self.wfile.write(body.encode())
55
+
56
+ def log_message(self, *_args) -> None:
57
+ # Silence the default stderr request logging; it just clutters the CLI.
58
+ pass
59
+
60
+
61
+ def _wait_for_code(server: HTTPServer, result: dict[str, str]) -> str:
62
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
63
+ thread.start()
64
+ try:
65
+ # Poll rather than block forever so a user who never finishes in the
66
+ # browser eventually gets a clear timeout instead of a hung CLI.
67
+ deadline = threading.Event()
68
+ waited = 0.0
69
+ while not (result.get("code") or result.get("error")):
70
+ if deadline.wait(0.25):
71
+ break
72
+ waited += 0.25
73
+ if waited >= CALLBACK_TIMEOUT_SECONDS:
74
+ raise OAuthError("Timed out waiting for browser authorization.")
75
+ finally:
76
+ server.shutdown()
77
+ thread.join(timeout=1)
78
+
79
+ if result.get("error"):
80
+ raise OAuthError(f"Authorization was denied: {result['error']}")
81
+ return result["code"]
82
+
83
+
84
+ def _exchange_code(base_url: str, code: str, verifier: str) -> str:
85
+ keys_url = base_url.rstrip("/") + KEYS_PATH
86
+ try:
87
+ resp = httpx.post(
88
+ keys_url,
89
+ json={
90
+ "code": code,
91
+ "code_verifier": verifier,
92
+ "code_challenge_method": "S256",
93
+ },
94
+ timeout=30,
95
+ )
96
+ resp.raise_for_status()
97
+ key = resp.json().get("key")
98
+ except httpx.HTTPError as e:
99
+ raise OAuthError(f"Failed to exchange code for a key: {e}") from e
100
+
101
+ if not key:
102
+ raise OAuthError("OpenRouter did not return an API key.")
103
+ return key
104
+
105
+
106
+ def login_with_oauth(base_url: str, open_browser=webbrowser.open) -> str:
107
+ verifier, challenge = _generate_pkce_pair()
108
+
109
+ # Port 0 -> the OS hands us a free ephemeral port. OpenRouter accepts any
110
+ # localhost callback, so nothing needs pre-registering.
111
+ handler = type("_Handler", (_CallbackHandler,), {"result": {}})
112
+ server = HTTPServer(("127.0.0.1", 0), handler)
113
+ port = server.server_address[1]
114
+ callback_url = f"http://localhost:{port}"
115
+
116
+ params = urllib.parse.urlencode(
117
+ {
118
+ "callback_url": callback_url,
119
+ "code_challenge": challenge,
120
+ "code_challenge_method": "S256",
121
+ }
122
+ )
123
+ auth_url = f"{AUTH_URL}?{params}"
124
+
125
+ opened = False
126
+ try:
127
+ opened = open_browser(auth_url)
128
+ except webbrowser.Error:
129
+ opened = False
130
+
131
+ if not opened:
132
+ # Headless / no default browser: let the user open it themselves.
133
+ print("Open this URL in your browser to authorize relay:")
134
+ print(f" {auth_url}")
135
+
136
+ code = _wait_for_code(server, handler.result)
137
+ return _exchange_code(base_url, code, verifier)
context/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Context module for conversation management."""
2
+
3
+ from context.manager import ContextManager, MessageItem
4
+
5
+ __all__ = ['ContextManager', 'MessageItem']
context/manager.py ADDED
@@ -0,0 +1,92 @@
1
+ from config.config import Config
2
+ from prompts.system import get_system_prompt
3
+ from dataclasses import dataclass, field
4
+ from utils.text import count_tokens
5
+ from typing import Any
6
+
7
+ @dataclass
8
+ class MessageItem:
9
+ role: str
10
+ content: str
11
+ token_count: int | None = None
12
+ tool_call_id: str | None = None
13
+ tool_calls: list[dict[str, Any]] = field(default_factory=list)
14
+
15
+ def to_dict(self) -> dict[str, Any]:
16
+ result: dict[str, Any] = {
17
+ "role": self.role
18
+ }
19
+
20
+ if self.tool_call_id:
21
+ result['tool_call_id'] = self.tool_call_id
22
+
23
+ if self.tool_calls:
24
+ result['tool_calls'] = self.tool_calls
25
+
26
+ if self.content:
27
+ result['content'] = self.content
28
+
29
+ return result
30
+
31
+ class ContextManager:
32
+ def __init__(self, config: Config, user_memory: str | None ) -> None:
33
+ self._system_prompt = get_system_prompt(config, user_memory)
34
+ self.config = config
35
+ self._model_name = self.config.model_name
36
+ self._messages: list[MessageItem] = []
37
+
38
+ def add_user_message(self, content):
39
+ item = MessageItem(
40
+ role = 'user',
41
+ content = content,
42
+ token_count=count_tokens(content, self._model_name)
43
+ )
44
+
45
+ self._messages.append(item)
46
+
47
+ def add_assistant_message(
48
+ self,
49
+ content: str,
50
+ tool_calls: list[dict[str, Any]] | None = None,
51
+ ) -> None:
52
+ item = MessageItem(
53
+ role = 'assistant',
54
+ content = content or "",
55
+ token_count=count_tokens(
56
+ content or "",
57
+ self._model_name
58
+ ),
59
+ tool_calls=tool_calls or []
60
+ )
61
+
62
+ self._messages.append(item)
63
+
64
+ def get_messages(self) -> list[dict[str, Any]]:
65
+ messages = []
66
+
67
+ if self._system_prompt:
68
+ messages.append(
69
+ {
70
+ 'role': 'system',
71
+ 'content': self._system_prompt
72
+ }
73
+ )
74
+
75
+ for item in self._messages:
76
+ messages.append(item.to_dict())
77
+
78
+ return messages
79
+
80
+ def add_tool_result(
81
+ self,
82
+ tool_call_id: str,
83
+ content: str
84
+ ) -> None:
85
+ item = MessageItem(
86
+ role='tool',
87
+ content=content,
88
+ tool_call_id=tool_call_id,
89
+ token_count=count_tokens(content, self._model_name)
90
+ )
91
+
92
+ self._messages.append(item)