tiphys.config

Tiphys configuration using Pydantic Settings.

Supports loading from:

  • Environment variables (TIPHYS_ prefix)
  • .env file
  • config.json file
  1"""
  2Tiphys configuration using Pydantic Settings.
  3
  4Supports loading from:
  5- Environment variables (TIPHYS_ prefix)
  6- .env file
  7- config.json file
  8"""
  9
 10from enum import Enum
 11from pathlib import Path
 12from typing import Any, Literal
 13
 14from pydantic import BaseModel, Field
 15from pydantic_settings import BaseSettings, SettingsConfigDict
 16
 17
 18class LLMProvider(str, Enum):
 19    """Supported LLM providers."""
 20
 21    OPENROUTER = "openrouter"  # OpenRouter (multi-model gateway)
 22    OPENAI = "openai"  # OpenAI direct
 23    ANTHROPIC = "anthropic"  # Anthropic direct
 24    OLLAMA = "ollama"  # Local Ollama
 25    CUSTOM = "custom"  # Custom API endpoint
 26
 27
 28class DmScope(str, Enum):
 29    """DM session scoping modes."""
 30
 31    MAIN = "main"  # All DMs share context
 32    PER_PEER = "per_peer"  # Each user gets own context
 33    PER_CHANNEL_PEER = "per_channel_peer"
 34    FULL = "full"  # Maximum granularity
 35
 36
 37class LogLevel(str, Enum):
 38    """Logging levels."""
 39
 40    DEBUG = "DEBUG"
 41    INFO = "INFO"
 42    WARNING = "WARNING"
 43    ERROR = "ERROR"
 44
 45
 46class LLMSettings(BaseModel):
 47    """LLM provider configuration."""
 48
 49    provider: LLMProvider = Field(
 50        default=LLMProvider.OPENROUTER,
 51        description="LLM provider to use",
 52    )
 53    model: str = Field(
 54        default="anthropic/claude-3.5-sonnet",
 55        description="Model identifier (provider-specific format)",
 56    )
 57    api_key: str | None = Field(
 58        default=None,
 59        description="API key for the provider (or use env vars)",
 60    )
 61    api_base: str | None = Field(
 62        default=None,
 63        description="Custom API base URL (auto-set for known providers)",
 64    )
 65    temperature: float = Field(
 66        default=0.7,
 67        ge=0.0,
 68        le=2.0,
 69        description="Default sampling temperature",
 70    )
 71    max_tokens: int | None = Field(
 72        default=None,
 73        description="Default max tokens to generate",
 74    )
 75
 76    # OpenRouter-specific settings
 77    openrouter_site_url: str | None = Field(
 78        default=None,
 79        description="Your site URL for OpenRouter rankings",
 80    )
 81    openrouter_app_name: str | None = Field(
 82        default="Tiphys",
 83        description="Your app name for OpenRouter rankings",
 84    )
 85
 86    def get_api_base(self) -> str | None:
 87        """Get the API base URL for the configured provider."""
 88        if self.api_base:
 89            return self.api_base
 90
 91        match self.provider:
 92            case LLMProvider.OPENROUTER:
 93                return "https://openrouter.ai/api/v1"
 94            case LLMProvider.OLLAMA:
 95                return "http://localhost:11434"
 96            case _:
 97                return None
 98
 99    def get_model_id(self) -> str:
100        """Get the full model ID for LiteLLM."""
101        # For OpenRouter, LiteLLM expects "openrouter/<model>"
102        if self.provider == LLMProvider.OPENROUTER:
103            if not self.model.startswith("openrouter/"):
104                return f"openrouter/{self.model}"
105        elif self.provider == LLMProvider.OLLAMA and not self.model.startswith("ollama/"):
106            return f"ollama/{self.model}"
107        return self.model
108
109    def get_extra_headers(self) -> dict[str, str]:
110        """Get extra headers for API requests."""
111        headers: dict[str, str] = {}
112
113        if self.provider == LLMProvider.OPENROUTER:
114            if self.openrouter_site_url:
115                headers["HTTP-Referer"] = self.openrouter_site_url
116            if self.openrouter_app_name:
117                headers["X-Title"] = self.openrouter_app_name
118
119        return headers
120
121
122class SandboxSettings(BaseModel):
123    """Sandbox configuration for secure execution."""
124
125    enabled: bool = Field(default=False, description="Enable sandboxed execution")
126    image: str = Field(default="python:3.12-slim", description="Docker image")
127    memory_limit: str = Field(default="512m", description="Memory limit")
128    cpu_limit: float = Field(default=1.0, ge=0.1, le=16.0, description="CPU limit")
129    timeout_seconds: int = Field(default=300, ge=10, le=3600, description="Timeout")
130    network_enabled: bool = Field(default=False, description="Allow network access")
131    sandboxed_tools: list[str] = Field(
132        default_factory=lambda: ["run_command"],
133        description="Tools to run in sandbox",
134    )
135
136
137class BudgetConfig(BaseModel):
138    """Castor budget configuration for resource tracking."""
139
140    enabled: bool = Field(default=True, description="Enable Castor budget tracking")
141    default_budgets: dict[str, float] = Field(
142        default_factory=lambda: {"api": 100.0, "compute": 50.0},
143        description="Default resource budgets per session",
144    )
145    per_channel_overrides: dict[str, dict[str, float]] = Field(
146        default_factory=dict,
147        description="Per-channel budget overrides",
148    )
149
150
151class ChannelConfig(BaseModel):
152    """
153    Per-channel configuration with rate limiting, security, and message settings.
154
155    Channels can override global settings with their own configuration.
156    Use `get_channel_config()` on TiphysConfig to get merged configuration.
157
158    Example:
159        config = load_config()
160        webchat_config = config.get_channel_config("webchat")
161        rate_limit = webchat_config.rate_limit_requests_per_minute
162    """
163
164    enabled: bool = Field(default=True, description="Whether channel is enabled")
165
166    # Rate limiting
167    rate_limit_requests_per_minute: int = Field(
168        default=20,
169        ge=1,
170        le=1000,
171        description="Maximum requests per minute from a single peer",
172    )
173    rate_limit_burst: int = Field(
174        default=5,
175        ge=1,
176        le=100,
177        description="Maximum burst size for rate limiting",
178    )
179
180    # Message limits
181    max_message_length: int = Field(
182        default=10000,
183        ge=1,
184        le=100000,
185        description="Maximum inbound message length in characters",
186    )
187    chunk_size: int = Field(
188        default=2000,
189        ge=100,
190        le=10000,
191        description="Maximum outbound chunk size for long messages",
192    )
193
194    # Security - Access control
195    allowed_peer_ids: list[str] | None = Field(
196        default=None,
197        description="Whitelist of allowed peer IDs (None = all allowed)",
198    )
199    blocked_peer_ids: list[str] = Field(
200        default_factory=list,
201        description="Blacklist of blocked peer IDs",
202    )
203
204    # Tool restrictions (override global)
205    tools_allow: list[str] | None = Field(
206        default=None,
207        description="Allowed tools for this channel (None = use global)",
208    )
209    tools_deny: list[str] = Field(
210        default_factory=list,
211        description="Denied tools for this channel",
212    )
213
214    # Custom settings for channel-specific behavior
215    extra: dict[str, Any] = Field(
216        default_factory=dict,
217        description="Extra channel-specific settings",
218    )
219
220
221class AgentConfig(BaseModel):
222    """Configuration for a single agent (used in config file)."""
223
224    id: str = Field(..., description="Unique agent identifier")
225    name: str = Field(..., description="Display name")
226    description: str | None = Field(default=None, description="Agent description")
227
228    # LLM settings (override global)
229    model: str | None = Field(default=None, description="LLM model override")
230    api_base: str | None = Field(default=None, description="API base override")
231    api_key: str | None = Field(default=None, description="API key override")
232    temperature: float = Field(default=0.7, ge=0.0, le=2.0, description="LLM temperature")
233    max_tokens: int | None = Field(default=None, description="Max response tokens")
234
235    # Session settings
236    dm_scope: DmScope = Field(default=DmScope.MAIN, description="Session scope mode")
237
238    # Customization
239    system_prompt: str | None = Field(default=None, description="Custom system prompt")
240    custom_instructions: str | None = Field(default=None, description="Extra instructions")
241
242    # Tool restrictions
243    tools_allow: list[str] = Field(default_factory=list, description="Allowed tools")
244    tools_deny: list[str] = Field(default_factory=list, description="Denied tools")
245
246    # Skills
247    skills_dir: Path | None = Field(default=None, description="Agent-specific skills")
248    preload_skills: list[str] = Field(default_factory=list, description="Preloaded skills")
249
250    # Behavior
251    max_steps: int = Field(default=10, ge=1, le=100, description="Max tool steps")
252    enabled: bool = Field(default=True, description="Whether agent is active")
253
254    # Metadata
255    metadata: dict[str, Any] = Field(default_factory=dict, description="Extra metadata")
256
257
258class TiphysConfig(BaseSettings):
259    """Main configuration for Tiphys."""
260
261    model_config = SettingsConfigDict(
262        env_prefix="TIPHYS_",
263        env_file=".env",
264        env_file_encoding="utf-8",
265        extra="ignore",
266    )
267
268    # Server
269    host: str = Field(default="127.0.0.1", description="Server bind address")
270    port: int = Field(default=8000, ge=1, le=65535, description="Server port")
271    cors_origins: list[str] = Field(
272        default_factory=lambda: ["http://localhost:8000", "http://127.0.0.1:8000"],
273        description="Allowed CORS origins (use ['*'] for development only)",
274    )
275
276    # LLM (new structured settings)
277    llm: LLMSettings = Field(
278        default_factory=LLMSettings,
279        description="LLM provider configuration",
280    )
281
282    # Legacy LLM fields (for backwards compatibility)
283    model: str = Field(
284        default="anthropic/claude-3.5-sonnet",
285        description="LLM model identifier (LiteLLM format)",
286    )
287    api_base: str | None = Field(
288        default=None,
289        description="Custom API base URL for LLM provider",
290    )
291    api_key: str | None = Field(
292        default=None,
293        description="API key for LLM provider",
294    )
295
296    # Agent
297    agent_id: str = Field(default="default", description="Default agent identifier")
298    agent_name: str = Field(default="Tiphys", description="Agent display name")
299    dm_scope: DmScope = Field(
300        default=DmScope.MAIN,
301        description="Session scoping mode for DMs",
302    )
303
304    # Paths
305    data_dir: Path = Field(
306        default_factory=lambda: Path.home() / ".tiphys",
307        description="Data storage directory",
308    )
309    plugins_dir: Path = Field(
310        default=Path("./plugins"),
311        description="Plugins directory",
312    )
313    skills_dir: Path = Field(
314        default=Path("./skills"),
315        description="Skills directory",
316    )
317
318    # Security
319    exec_mode: Literal["deny", "allowlist", "full"] = Field(
320        default="allowlist",
321        description="Command execution mode",
322    )
323    allowed_commands: list[str] = Field(
324        default_factory=lambda: ["ls", "cat", "grep", "find", "git", "python", "pip", "uv"],
325        description="Allowed commands in allowlist mode",
326    )
327
328    # Logging
329    log_level: LogLevel = Field(
330        default=LogLevel.INFO,
331        description="Logging level",
332    )
333    log_json: bool = Field(
334        default=False,
335        description="Output logs as JSON",
336    )
337
338    # Channels
339    telegram_token: str | None = Field(
340        default=None,
341        description="Telegram bot token",
342    )
343    discord_token: str | None = Field(
344        default=None,
345        description="Discord bot token",
346    )
347
348    # Sandbox
349    sandbox: SandboxSettings = Field(
350        default_factory=SandboxSettings,
351        description="Sandbox configuration for secure execution",
352    )
353
354    # Multi-agent configuration
355    agents: list[AgentConfig] = Field(
356        default_factory=list,
357        description="Additional agent configurations",
358    )
359    default_agent_id: str | None = Field(
360        default=None,
361        description="Default agent ID (uses agent_id if not set)",
362    )
363
364    # Castor kernel budget configuration
365    budget: BudgetConfig = Field(
366        default_factory=BudgetConfig,
367        description="Castor budget configuration",
368    )
369
370    # Per-channel configuration (Phase R6)
371    default_channel_config: ChannelConfig = Field(
372        default_factory=ChannelConfig,
373        description="Default configuration for all channels",
374    )
375    channels: dict[str, ChannelConfig] = Field(
376        default_factory=dict,
377        description="Per-channel configuration overrides",
378    )
379
380    def ensure_dirs(self) -> None:
381        """Create required directories if they don't exist."""
382        self.data_dir.mkdir(parents=True, exist_ok=True)
383        (self.data_dir / "sessions").mkdir(exist_ok=True)
384        (self.data_dir / "logs").mkdir(exist_ok=True)
385
386    def get_channel_config(self, channel_name: str) -> ChannelConfig:
387        """
388        Get merged configuration for a specific channel.
389
390        If the channel has specific configuration, it is merged with
391        the default configuration (channel settings override defaults).
392
393        Args:
394            channel_name: The channel name (e.g., "webchat", "telegram").
395
396        Returns:
397            Merged ChannelConfig with channel-specific overrides applied.
398
399        Example:
400            config = load_config()
401            webchat = config.get_channel_config("webchat")
402            print(f"Rate limit: {webchat.rate_limit_requests_per_minute}/min")
403        """
404        # If no channel-specific config, return defaults
405        if channel_name not in self.channels:
406            return self.default_channel_config
407
408        # Merge channel config with defaults
409        # Channel settings override default settings
410        defaults = self.default_channel_config.model_dump()
411        channel_overrides = self.channels[channel_name].model_dump(exclude_unset=True)
412        merged = {**defaults, **channel_overrides}
413        return ChannelConfig(**merged)
414
415
416def load_config() -> TiphysConfig:
417    """Load configuration from environment and files."""
418    return TiphysConfig()
class LLMProvider(builtins.str, enum.Enum):
19class LLMProvider(str, Enum):
20    """Supported LLM providers."""
21
22    OPENROUTER = "openrouter"  # OpenRouter (multi-model gateway)
23    OPENAI = "openai"  # OpenAI direct
24    ANTHROPIC = "anthropic"  # Anthropic direct
25    OLLAMA = "ollama"  # Local Ollama
26    CUSTOM = "custom"  # Custom API endpoint

Supported LLM providers.

OPENROUTER = $TIPHYS_LLM__PROVIDER
OPENAI = <LLMProvider.OPENAI: 'openai'>
ANTHROPIC = <LLMProvider.ANTHROPIC: 'anthropic'>
OLLAMA = <LLMProvider.OLLAMA: 'ollama'>
CUSTOM = <LLMProvider.CUSTOM: 'custom'>
class DmScope(builtins.str, enum.Enum):
29class DmScope(str, Enum):
30    """DM session scoping modes."""
31
32    MAIN = "main"  # All DMs share context
33    PER_PEER = "per_peer"  # Each user gets own context
34    PER_CHANNEL_PEER = "per_channel_peer"
35    FULL = "full"  # Maximum granularity

DM session scoping modes.

MAIN = <DmScope.MAIN: 'main'>
PER_PEER = <DmScope.PER_PEER: 'per_peer'>
PER_CHANNEL_PEER = <DmScope.PER_CHANNEL_PEER: 'per_channel_peer'>
FULL = <DmScope.FULL: 'full'>
class LogLevel(builtins.str, enum.Enum):
38class LogLevel(str, Enum):
39    """Logging levels."""
40
41    DEBUG = "DEBUG"
42    INFO = "INFO"
43    WARNING = "WARNING"
44    ERROR = "ERROR"

Logging levels.

DEBUG = <LogLevel.DEBUG: 'DEBUG'>
INFO = <LogLevel.INFO: 'INFO'>
WARNING = <LogLevel.WARNING: 'WARNING'>
ERROR = <LogLevel.ERROR: 'ERROR'>
class LLMSettings(pydantic.main.BaseModel):
 47class LLMSettings(BaseModel):
 48    """LLM provider configuration."""
 49
 50    provider: LLMProvider = Field(
 51        default=LLMProvider.OPENROUTER,
 52        description="LLM provider to use",
 53    )
 54    model: str = Field(
 55        default="anthropic/claude-3.5-sonnet",
 56        description="Model identifier (provider-specific format)",
 57    )
 58    api_key: str | None = Field(
 59        default=None,
 60        description="API key for the provider (or use env vars)",
 61    )
 62    api_base: str | None = Field(
 63        default=None,
 64        description="Custom API base URL (auto-set for known providers)",
 65    )
 66    temperature: float = Field(
 67        default=0.7,
 68        ge=0.0,
 69        le=2.0,
 70        description="Default sampling temperature",
 71    )
 72    max_tokens: int | None = Field(
 73        default=None,
 74        description="Default max tokens to generate",
 75    )
 76
 77    # OpenRouter-specific settings
 78    openrouter_site_url: str | None = Field(
 79        default=None,
 80        description="Your site URL for OpenRouter rankings",
 81    )
 82    openrouter_app_name: str | None = Field(
 83        default="Tiphys",
 84        description="Your app name for OpenRouter rankings",
 85    )
 86
 87    def get_api_base(self) -> str | None:
 88        """Get the API base URL for the configured provider."""
 89        if self.api_base:
 90            return self.api_base
 91
 92        match self.provider:
 93            case LLMProvider.OPENROUTER:
 94                return "https://openrouter.ai/api/v1"
 95            case LLMProvider.OLLAMA:
 96                return "http://localhost:11434"
 97            case _:
 98                return None
 99
100    def get_model_id(self) -> str:
101        """Get the full model ID for LiteLLM."""
102        # For OpenRouter, LiteLLM expects "openrouter/<model>"
103        if self.provider == LLMProvider.OPENROUTER:
104            if not self.model.startswith("openrouter/"):
105                return f"openrouter/{self.model}"
106        elif self.provider == LLMProvider.OLLAMA and not self.model.startswith("ollama/"):
107            return f"ollama/{self.model}"
108        return self.model
109
110    def get_extra_headers(self) -> dict[str, str]:
111        """Get extra headers for API requests."""
112        headers: dict[str, str] = {}
113
114        if self.provider == LLMProvider.OPENROUTER:
115            if self.openrouter_site_url:
116                headers["HTTP-Referer"] = self.openrouter_site_url
117            if self.openrouter_app_name:
118                headers["X-Title"] = self.openrouter_app_name
119
120        return headers

LLM provider configuration.

provider: LLMProvider = $TIPHYS_LLM__PROVIDER

LLM provider to use

model: str = $TIPHYS_LLM__MODEL

Model identifier (provider-specific format)

api_key: str | None = None

API key for the provider (or use env vars)

api_base: str | None = None

Custom API base URL (auto-set for known providers)

temperature: float = 0.7

Default sampling temperature

max_tokens: int | None = None

Default max tokens to generate

openrouter_site_url: str | None = None

Your site URL for OpenRouter rankings

openrouter_app_name: str | None = 'Tiphys'

Your app name for OpenRouter rankings

def get_api_base(self) -> str | None:
87    def get_api_base(self) -> str | None:
88        """Get the API base URL for the configured provider."""
89        if self.api_base:
90            return self.api_base
91
92        match self.provider:
93            case LLMProvider.OPENROUTER:
94                return "https://openrouter.ai/api/v1"
95            case LLMProvider.OLLAMA:
96                return "http://localhost:11434"
97            case _:
98                return None

Get the API base URL for the configured provider.

def get_model_id(self) -> str:
100    def get_model_id(self) -> str:
101        """Get the full model ID for LiteLLM."""
102        # For OpenRouter, LiteLLM expects "openrouter/<model>"
103        if self.provider == LLMProvider.OPENROUTER:
104            if not self.model.startswith("openrouter/"):
105                return f"openrouter/{self.model}"
106        elif self.provider == LLMProvider.OLLAMA and not self.model.startswith("ollama/"):
107            return f"ollama/{self.model}"
108        return self.model

Get the full model ID for LiteLLM.

def get_extra_headers(self) -> dict[str, str]:
110    def get_extra_headers(self) -> dict[str, str]:
111        """Get extra headers for API requests."""
112        headers: dict[str, str] = {}
113
114        if self.provider == LLMProvider.OPENROUTER:
115            if self.openrouter_site_url:
116                headers["HTTP-Referer"] = self.openrouter_site_url
117            if self.openrouter_app_name:
118                headers["X-Title"] = self.openrouter_app_name
119
120        return headers

Get extra headers for API requests.

class SandboxSettings(pydantic.main.BaseModel):
123class SandboxSettings(BaseModel):
124    """Sandbox configuration for secure execution."""
125
126    enabled: bool = Field(default=False, description="Enable sandboxed execution")
127    image: str = Field(default="python:3.12-slim", description="Docker image")
128    memory_limit: str = Field(default="512m", description="Memory limit")
129    cpu_limit: float = Field(default=1.0, ge=0.1, le=16.0, description="CPU limit")
130    timeout_seconds: int = Field(default=300, ge=10, le=3600, description="Timeout")
131    network_enabled: bool = Field(default=False, description="Allow network access")
132    sandboxed_tools: list[str] = Field(
133        default_factory=lambda: ["run_command"],
134        description="Tools to run in sandbox",
135    )

Sandbox configuration for secure execution.

enabled: bool = False

Enable sandboxed execution

image: str = 'python:3.12-slim'

Docker image

memory_limit: str = '512m'

Memory limit

cpu_limit: float = 1.0

CPU limit

timeout_seconds: int = 300

Timeout

network_enabled: bool = False

Allow network access

sandboxed_tools: list[str] = PydanticUndefined

Tools to run in sandbox

class BudgetConfig(pydantic.main.BaseModel):
138class BudgetConfig(BaseModel):
139    """Castor budget configuration for resource tracking."""
140
141    enabled: bool = Field(default=True, description="Enable Castor budget tracking")
142    default_budgets: dict[str, float] = Field(
143        default_factory=lambda: {"api": 100.0, "compute": 50.0},
144        description="Default resource budgets per session",
145    )
146    per_channel_overrides: dict[str, dict[str, float]] = Field(
147        default_factory=dict,
148        description="Per-channel budget overrides",
149    )

Castor budget configuration for resource tracking.

enabled: bool = True

Enable Castor budget tracking

default_budgets: dict[str, float] = PydanticUndefined

Default resource budgets per session

per_channel_overrides: dict[str, dict[str, float]] = PydanticUndefined

Per-channel budget overrides

class ChannelConfig(pydantic.main.BaseModel):
152class ChannelConfig(BaseModel):
153    """
154    Per-channel configuration with rate limiting, security, and message settings.
155
156    Channels can override global settings with their own configuration.
157    Use `get_channel_config()` on TiphysConfig to get merged configuration.
158
159    Example:
160        config = load_config()
161        webchat_config = config.get_channel_config("webchat")
162        rate_limit = webchat_config.rate_limit_requests_per_minute
163    """
164
165    enabled: bool = Field(default=True, description="Whether channel is enabled")
166
167    # Rate limiting
168    rate_limit_requests_per_minute: int = Field(
169        default=20,
170        ge=1,
171        le=1000,
172        description="Maximum requests per minute from a single peer",
173    )
174    rate_limit_burst: int = Field(
175        default=5,
176        ge=1,
177        le=100,
178        description="Maximum burst size for rate limiting",
179    )
180
181    # Message limits
182    max_message_length: int = Field(
183        default=10000,
184        ge=1,
185        le=100000,
186        description="Maximum inbound message length in characters",
187    )
188    chunk_size: int = Field(
189        default=2000,
190        ge=100,
191        le=10000,
192        description="Maximum outbound chunk size for long messages",
193    )
194
195    # Security - Access control
196    allowed_peer_ids: list[str] | None = Field(
197        default=None,
198        description="Whitelist of allowed peer IDs (None = all allowed)",
199    )
200    blocked_peer_ids: list[str] = Field(
201        default_factory=list,
202        description="Blacklist of blocked peer IDs",
203    )
204
205    # Tool restrictions (override global)
206    tools_allow: list[str] | None = Field(
207        default=None,
208        description="Allowed tools for this channel (None = use global)",
209    )
210    tools_deny: list[str] = Field(
211        default_factory=list,
212        description="Denied tools for this channel",
213    )
214
215    # Custom settings for channel-specific behavior
216    extra: dict[str, Any] = Field(
217        default_factory=dict,
218        description="Extra channel-specific settings",
219    )

Per-channel configuration with rate limiting, security, and message settings.

Channels can override global settings with their own configuration. Use get_channel_config() on TiphysConfig to get merged configuration.

Example: config = load_config() webchat_config = config.get_channel_config("webchat") rate_limit = webchat_config.rate_limit_requests_per_minute

enabled: bool = True

Whether channel is enabled

rate_limit_requests_per_minute: int = 20

Maximum requests per minute from a single peer

rate_limit_burst: int = 5

Maximum burst size for rate limiting

max_message_length: int = 10000

Maximum inbound message length in characters

chunk_size: int = 2000

Maximum outbound chunk size for long messages

allowed_peer_ids: list[str] | None = None

Whitelist of allowed peer IDs (None = all allowed)

blocked_peer_ids: list[str] = PydanticUndefined

Blacklist of blocked peer IDs

tools_allow: list[str] | None = None

Allowed tools for this channel (None = use global)

tools_deny: list[str] = PydanticUndefined

Denied tools for this channel

extra: dict[str, typing.Any] = PydanticUndefined

Extra channel-specific settings

class AgentConfig(pydantic.main.BaseModel):
222class AgentConfig(BaseModel):
223    """Configuration for a single agent (used in config file)."""
224
225    id: str = Field(..., description="Unique agent identifier")
226    name: str = Field(..., description="Display name")
227    description: str | None = Field(default=None, description="Agent description")
228
229    # LLM settings (override global)
230    model: str | None = Field(default=None, description="LLM model override")
231    api_base: str | None = Field(default=None, description="API base override")
232    api_key: str | None = Field(default=None, description="API key override")
233    temperature: float = Field(default=0.7, ge=0.0, le=2.0, description="LLM temperature")
234    max_tokens: int | None = Field(default=None, description="Max response tokens")
235
236    # Session settings
237    dm_scope: DmScope = Field(default=DmScope.MAIN, description="Session scope mode")
238
239    # Customization
240    system_prompt: str | None = Field(default=None, description="Custom system prompt")
241    custom_instructions: str | None = Field(default=None, description="Extra instructions")
242
243    # Tool restrictions
244    tools_allow: list[str] = Field(default_factory=list, description="Allowed tools")
245    tools_deny: list[str] = Field(default_factory=list, description="Denied tools")
246
247    # Skills
248    skills_dir: Path | None = Field(default=None, description="Agent-specific skills")
249    preload_skills: list[str] = Field(default_factory=list, description="Preloaded skills")
250
251    # Behavior
252    max_steps: int = Field(default=10, ge=1, le=100, description="Max tool steps")
253    enabled: bool = Field(default=True, description="Whether agent is active")
254
255    # Metadata
256    metadata: dict[str, Any] = Field(default_factory=dict, description="Extra metadata")

Configuration for a single agent (used in config file).

id: str = PydanticUndefined

Unique agent identifier

name: str = PydanticUndefined

Display name

description: str | None = None

Agent description

model: str | None = None

LLM model override

api_base: str | None = None

API base override

api_key: str | None = None

API key override

temperature: float = 0.7

LLM temperature

max_tokens: int | None = None

Max response tokens

dm_scope: DmScope = <DmScope.MAIN: 'main'>

Session scope mode

system_prompt: str | None = None

Custom system prompt

custom_instructions: str | None = None

Extra instructions

tools_allow: list[str] = PydanticUndefined

Allowed tools

tools_deny: list[str] = PydanticUndefined

Denied tools

skills_dir: pathlib.Path | None = None

Agent-specific skills

preload_skills: list[str] = PydanticUndefined

Preloaded skills

max_steps: int = 10

Max tool steps

enabled: bool = True

Whether agent is active

metadata: dict[str, typing.Any] = PydanticUndefined

Extra metadata

class TiphysConfig(pydantic_settings.main.BaseSettings):
259class TiphysConfig(BaseSettings):
260    """Main configuration for Tiphys."""
261
262    model_config = SettingsConfigDict(
263        env_prefix="TIPHYS_",
264        env_file=".env",
265        env_file_encoding="utf-8",
266        extra="ignore",
267    )
268
269    # Server
270    host: str = Field(default="127.0.0.1", description="Server bind address")
271    port: int = Field(default=8000, ge=1, le=65535, description="Server port")
272    cors_origins: list[str] = Field(
273        default_factory=lambda: ["http://localhost:8000", "http://127.0.0.1:8000"],
274        description="Allowed CORS origins (use ['*'] for development only)",
275    )
276
277    # LLM (new structured settings)
278    llm: LLMSettings = Field(
279        default_factory=LLMSettings,
280        description="LLM provider configuration",
281    )
282
283    # Legacy LLM fields (for backwards compatibility)
284    model: str = Field(
285        default="anthropic/claude-3.5-sonnet",
286        description="LLM model identifier (LiteLLM format)",
287    )
288    api_base: str | None = Field(
289        default=None,
290        description="Custom API base URL for LLM provider",
291    )
292    api_key: str | None = Field(
293        default=None,
294        description="API key for LLM provider",
295    )
296
297    # Agent
298    agent_id: str = Field(default="default", description="Default agent identifier")
299    agent_name: str = Field(default="Tiphys", description="Agent display name")
300    dm_scope: DmScope = Field(
301        default=DmScope.MAIN,
302        description="Session scoping mode for DMs",
303    )
304
305    # Paths
306    data_dir: Path = Field(
307        default_factory=lambda: Path.home() / ".tiphys",
308        description="Data storage directory",
309    )
310    plugins_dir: Path = Field(
311        default=Path("./plugins"),
312        description="Plugins directory",
313    )
314    skills_dir: Path = Field(
315        default=Path("./skills"),
316        description="Skills directory",
317    )
318
319    # Security
320    exec_mode: Literal["deny", "allowlist", "full"] = Field(
321        default="allowlist",
322        description="Command execution mode",
323    )
324    allowed_commands: list[str] = Field(
325        default_factory=lambda: ["ls", "cat", "grep", "find", "git", "python", "pip", "uv"],
326        description="Allowed commands in allowlist mode",
327    )
328
329    # Logging
330    log_level: LogLevel = Field(
331        default=LogLevel.INFO,
332        description="Logging level",
333    )
334    log_json: bool = Field(
335        default=False,
336        description="Output logs as JSON",
337    )
338
339    # Channels
340    telegram_token: str | None = Field(
341        default=None,
342        description="Telegram bot token",
343    )
344    discord_token: str | None = Field(
345        default=None,
346        description="Discord bot token",
347    )
348
349    # Sandbox
350    sandbox: SandboxSettings = Field(
351        default_factory=SandboxSettings,
352        description="Sandbox configuration for secure execution",
353    )
354
355    # Multi-agent configuration
356    agents: list[AgentConfig] = Field(
357        default_factory=list,
358        description="Additional agent configurations",
359    )
360    default_agent_id: str | None = Field(
361        default=None,
362        description="Default agent ID (uses agent_id if not set)",
363    )
364
365    # Castor kernel budget configuration
366    budget: BudgetConfig = Field(
367        default_factory=BudgetConfig,
368        description="Castor budget configuration",
369    )
370
371    # Per-channel configuration (Phase R6)
372    default_channel_config: ChannelConfig = Field(
373        default_factory=ChannelConfig,
374        description="Default configuration for all channels",
375    )
376    channels: dict[str, ChannelConfig] = Field(
377        default_factory=dict,
378        description="Per-channel configuration overrides",
379    )
380
381    def ensure_dirs(self) -> None:
382        """Create required directories if they don't exist."""
383        self.data_dir.mkdir(parents=True, exist_ok=True)
384        (self.data_dir / "sessions").mkdir(exist_ok=True)
385        (self.data_dir / "logs").mkdir(exist_ok=True)
386
387    def get_channel_config(self, channel_name: str) -> ChannelConfig:
388        """
389        Get merged configuration for a specific channel.
390
391        If the channel has specific configuration, it is merged with
392        the default configuration (channel settings override defaults).
393
394        Args:
395            channel_name: The channel name (e.g., "webchat", "telegram").
396
397        Returns:
398            Merged ChannelConfig with channel-specific overrides applied.
399
400        Example:
401            config = load_config()
402            webchat = config.get_channel_config("webchat")
403            print(f"Rate limit: {webchat.rate_limit_requests_per_minute}/min")
404        """
405        # If no channel-specific config, return defaults
406        if channel_name not in self.channels:
407            return self.default_channel_config
408
409        # Merge channel config with defaults
410        # Channel settings override default settings
411        defaults = self.default_channel_config.model_dump()
412        channel_overrides = self.channels[channel_name].model_dump(exclude_unset=True)
413        merged = {**defaults, **channel_overrides}
414        return ChannelConfig(**merged)

Main configuration for Tiphys.

host: str = $TIPHYS_HOST

Server bind address

port: int = 8000

Server port

cors_origins: list[str] = PydanticUndefined

Allowed CORS origins (use ['*'] for development only)

llm: LLMSettings = PydanticUndefined

LLM provider configuration

model: str = $TIPHYS_LLM__MODEL

LLM model identifier (LiteLLM format)

api_base: str | None = None

Custom API base URL for LLM provider

api_key: str | None = None

API key for LLM provider

agent_id: str = 'default'

Default agent identifier

agent_name: str = 'Tiphys'

Agent display name

dm_scope: DmScope = <DmScope.MAIN: 'main'>

Session scoping mode for DMs

data_dir: pathlib.Path = PydanticUndefined

Data storage directory

plugins_dir: pathlib.Path = PosixPath('plugins')

Plugins directory

skills_dir: pathlib.Path = PosixPath('skills')

Skills directory

exec_mode: Literal['deny', 'allowlist', 'full'] = $TIPHYS_EXEC_MODE

Command execution mode

allowed_commands: list[str] = PydanticUndefined

Allowed commands in allowlist mode

log_level: LogLevel = <LogLevel.INFO: 'INFO'>

Logging level

log_json: bool = False

Output logs as JSON

telegram_token: str | None = None

Telegram bot token

discord_token: str | None = None

Discord bot token

sandbox: SandboxSettings = PydanticUndefined

Sandbox configuration for secure execution

agents: list[AgentConfig] = PydanticUndefined

Additional agent configurations

default_agent_id: str | None = None

Default agent ID (uses agent_id if not set)

budget: BudgetConfig = PydanticUndefined

Castor budget configuration

default_channel_config: ChannelConfig = PydanticUndefined

Default configuration for all channels

channels: dict[str, ChannelConfig] = PydanticUndefined

Per-channel configuration overrides

def ensure_dirs(self) -> None:
381    def ensure_dirs(self) -> None:
382        """Create required directories if they don't exist."""
383        self.data_dir.mkdir(parents=True, exist_ok=True)
384        (self.data_dir / "sessions").mkdir(exist_ok=True)
385        (self.data_dir / "logs").mkdir(exist_ok=True)

Create required directories if they don't exist.

def get_channel_config(self, channel_name: str) -> ChannelConfig:
387    def get_channel_config(self, channel_name: str) -> ChannelConfig:
388        """
389        Get merged configuration for a specific channel.
390
391        If the channel has specific configuration, it is merged with
392        the default configuration (channel settings override defaults).
393
394        Args:
395            channel_name: The channel name (e.g., "webchat", "telegram").
396
397        Returns:
398            Merged ChannelConfig with channel-specific overrides applied.
399
400        Example:
401            config = load_config()
402            webchat = config.get_channel_config("webchat")
403            print(f"Rate limit: {webchat.rate_limit_requests_per_minute}/min")
404        """
405        # If no channel-specific config, return defaults
406        if channel_name not in self.channels:
407            return self.default_channel_config
408
409        # Merge channel config with defaults
410        # Channel settings override default settings
411        defaults = self.default_channel_config.model_dump()
412        channel_overrides = self.channels[channel_name].model_dump(exclude_unset=True)
413        merged = {**defaults, **channel_overrides}
414        return ChannelConfig(**merged)

Get merged configuration for a specific channel.

If the channel has specific configuration, it is merged with the default configuration (channel settings override defaults).

Args: channel_name: The channel name (e.g., "webchat", "telegram").

Returns: Merged ChannelConfig with channel-specific overrides applied.

Example: config = load_config() webchat = config.get_channel_config("webchat") print(f"Rate limit: {webchat.rate_limit_requests_per_minute}/min")

def load_config() -> TiphysConfig:
417def load_config() -> TiphysConfig:
418    """Load configuration from environment and files."""
419    return TiphysConfig()

Load configuration from environment and files.