askai-python 0.2.1__py3-none-any.whl → 0.4.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.
ask_ai/__init__.py CHANGED
@@ -1,3 +1,9 @@
1
+ """
2
+ ask-ai: A minimal Python SDK to switch between LLM providers in one line.
3
+ """
4
+
5
+ __version__ = "0.4.0"
6
+
1
7
  from .providers import (
2
8
  OpenAI,
3
9
  Anthropic,
@@ -9,18 +15,37 @@ from .providers import (
9
15
  from .config import AdvancedConfig
10
16
  from .base import Response
11
17
  from .media import ImageObject, AudioObject
12
- from .exceptions import AskAIError
18
+ from .exceptions import (
19
+ AskAIError,
20
+ AskAIConfigError,
21
+ AskAINetworkError,
22
+ AskAIRateLimitError,
23
+ AskAIParsingError,
24
+ ProviderError,
25
+ MediaTypeNotSupportedError,
26
+ StreamNotSupportedError,
27
+ )
13
28
 
14
29
  __all__ = [
30
+ # Providers
15
31
  "OpenAI",
16
32
  "Anthropic",
17
33
  "Google",
18
34
  "Groq",
19
35
  "Azure",
20
36
  "OpenRouter",
37
+ # Core
21
38
  "AdvancedConfig",
22
39
  "Response",
23
40
  "ImageObject",
24
41
  "AudioObject",
25
- "AskAIError"
42
+ # Exceptions
43
+ "AskAIError",
44
+ "AskAIConfigError",
45
+ "AskAINetworkError",
46
+ "AskAIRateLimitError",
47
+ "AskAIParsingError",
48
+ "ProviderError",
49
+ "MediaTypeNotSupportedError",
50
+ "StreamNotSupportedError",
26
51
  ]
ask_ai/base.py CHANGED
@@ -1,13 +1,19 @@
1
- from typing import Optional, Union, List, Dict, Any
2
1
  import os
2
+ import time
3
+ import random
4
+ import asyncio
5
+ import logging
6
+ from typing import Union, List, Dict, Any, Iterator, AsyncIterator
7
+
3
8
  from .config import AdvancedConfig
4
9
  from .media import ImageObject, AudioObject
5
10
 
11
+ logger = logging.getLogger("ask_ai")
6
12
 
7
13
 
8
14
  class Response:
9
15
  """
10
- Unified response object for all easyai requests.
16
+ Unified response object for all ask-ai requests.
11
17
  """
12
18
  def __init__(self, text: str = "", media: Union[ImageObject, AudioObject, None] = None, **kwargs):
13
19
  self._raw_text = text
@@ -34,17 +40,21 @@ class Response:
34
40
  from .utils import parse_json
35
41
  return parse_json(self._raw_text)
36
42
 
37
- def __str__(self):
43
+ def __str__(self) -> str:
38
44
  return self.text
39
45
 
40
- def save(self, path: str):
46
+ def __repr__(self) -> str:
47
+ preview = self._raw_text[:80] + "..." if len(self._raw_text) > 80 else self._raw_text
48
+ return f'<Response text="{preview}">'
49
+
50
+ def save(self, path: str) -> None:
41
51
  """Smart save based on content type."""
42
52
  if self.media:
43
53
  self.media.save(path)
44
54
  else:
45
55
  with open(path, "w", encoding="utf-8") as f:
46
56
  f.write(self.text)
47
- print(f"Text saved to {path}")
57
+
48
58
 
49
59
  class BaseProvider:
50
60
  """
@@ -52,17 +62,10 @@ class BaseProvider:
52
62
  Implements the core 'ask' logic and configuration management.
53
63
  """
54
64
  def __init__(self, api_key: str = None, model: str = None, persona: str = None, **kwargs):
55
- # 1. Zero-Config: Try env var if key not provided
65
+ # Zero-Config: Try env var if key not provided
56
66
  self.api_key = api_key or os.environ.get(self._get_api_key_env_var())
57
- if not self.api_key:
58
- # Some providers might not need it (e.g. local), but generally they do.
59
- # We allow subclass to handle granular validation, but warn here.
60
- pass
61
-
62
67
  self.model = model or self._get_default_model()
63
68
  self.persona = persona
64
-
65
- # Global Advanced Config
66
69
  self.config = AdvancedConfig(**kwargs)
67
70
 
68
71
  def _get_api_key_env_var(self) -> str:
@@ -73,116 +76,198 @@ class BaseProvider:
73
76
  """Subclasses should return a default model"""
74
77
  raise NotImplementedError
75
78
 
76
- def advanced(self, **kwargs):
79
+ def advanced(self, **kwargs) -> None:
77
80
  """
78
81
  Update global advanced settings for this instance.
79
82
  Merges new settings with existing ones.
80
83
  """
81
84
  new_conf = AdvancedConfig(**kwargs)
82
- # primitive merge: update self.config with new values
83
85
  for k, v in new_conf.__dict__.items():
86
+ if k == 'extra':
87
+ continue
84
88
  if v is not None:
85
89
  setattr(self.config, k, v)
86
-
87
- # Handle extra kwargs
88
90
  if new_conf.extra:
89
91
  self.config.extra.update(new_conf.extra)
90
92
 
93
+ # ─── Sync API ───────────────────────────────────────────
94
+
91
95
  def ask(self, query: str, **kwargs) -> Response:
92
96
  """
93
- The main entry point.
97
+ The main entry point. Sync version.
94
98
  Detects intent, manages config, and returns a unified Response.
95
99
  """
96
- # 1. Merge Request Config with Global Config
97
- request_config = AdvancedConfig(**kwargs)
98
- final_config = self._merge_configs(self.config, request_config)
100
+ final_config = self._build_final_config(kwargs)
101
+ messages = self._prepare_messages(query, final_config)
102
+ output_type = kwargs.get('output_type')
103
+ retry = int(kwargs.get('retry', final_config.extra.get('retry', 0)) or 0)
99
104
 
100
- # 2. Add System/Persona Message
105
+ return self._execute_with_retry(
106
+ self._send_request, messages, final_config, output_type, retry
107
+ )
108
+
109
+ def ask_stream(self, query: str, **kwargs) -> Iterator[str]:
110
+ """
111
+ Streaming text generation. Yields text chunks.
112
+ """
113
+ final_config = self._build_final_config(kwargs)
101
114
  messages = self._prepare_messages(query, final_config)
115
+ yield from self._send_request_stream(messages, final_config)
116
+
117
+ # ─── Async API ──────────────────────────────────────────
102
118
 
103
- # 3. Check for specific output_type override (e.g. user forced image)
119
+ async def ask_async(self, query: str, **kwargs) -> Response:
120
+ """
121
+ Native async version of ask().
122
+ Uses provider's async client directly — no thread pool wrapping.
123
+ """
124
+ final_config = self._build_final_config(kwargs)
125
+ messages = self._prepare_messages(query, final_config)
104
126
  output_type = kwargs.get('output_type')
105
-
106
- # 4. Extract timeout and retry safely
107
- timeout_val = kwargs.get('timeout', final_config.extra.get('timeout', 30))
108
- retry_val = kwargs.get('retry', final_config.extra.get('retry', 0))
109
-
110
- timeout = float(timeout_val) if timeout_val is not None else 30.0
111
- retry = int(retry_val) if retry_val is not None else 0
112
-
113
- final_config.extra['timeout'] = timeout # Pass down to providers
114
-
115
- # 5. Execute with Retry Logic
127
+ retry = int(kwargs.get('retry', final_config.extra.get('retry', 0)) or 0)
128
+
129
+ return await self._execute_with_retry_async(
130
+ self._send_request_async, messages, final_config, output_type, retry
131
+ )
132
+
133
+ async def ask_stream_async(self, query: str, **kwargs) -> AsyncIterator[str]:
134
+ """
135
+ Async streaming text generation. Yields text chunks.
136
+ """
137
+ final_config = self._build_final_config(kwargs)
138
+ messages = self._prepare_messages(query, final_config)
139
+ async for chunk in self._send_request_stream_async(messages, final_config):
140
+ yield chunk
141
+
142
+ # ─── Config Helpers ─────────────────────────────────────
143
+
144
+ def _build_final_config(self, kwargs: Dict[str, Any]) -> AdvancedConfig:
145
+ """Build final config by merging global config with per-request kwargs."""
146
+ request_config = AdvancedConfig(**kwargs)
147
+ final_config = self.config.merge(request_config)
148
+
149
+ # Ensure timeout has a default
150
+ timeout = kwargs.get('timeout', final_config.extra.get('timeout', 30))
151
+ final_config.extra['timeout'] = float(timeout) if timeout is not None else 30.0
152
+
153
+ return final_config
154
+
155
+ def _prepare_messages(self, query: str, config: AdvancedConfig) -> List[Dict[str, str]]:
156
+ """Build the messages list from query, persona, and config."""
157
+ messages = []
158
+
159
+ system_msg = config.system_message or self.persona or ""
160
+
161
+ # Smart capability: if json=True, enforce JSON system prompt
162
+ if config.extra.get('json'):
163
+ json_instruction = "You must respond with valid JSON only, without any markdown formatting or tags."
164
+ system_msg = f"{system_msg}\n{json_instruction}".strip()
165
+
166
+ if system_msg:
167
+ messages.append({"role": "system", "content": system_msg})
168
+
169
+ messages.append({"role": "user", "content": query})
170
+ return messages
171
+
172
+ # ─── Retry Logic ────────────────────────────────────────
173
+
174
+ def _execute_with_retry(self, func, messages, config, output_type, retry):
175
+ """Execute a sync function with exponential backoff + jitter."""
116
176
  attempts = 0
117
177
  last_error = None
178
+
118
179
  while attempts <= retry:
119
180
  try:
120
- response = self._send_request(messages, final_config, output_type)
121
- # Attach parsing arguments to the response object dynamically
122
- response._kwargs.update(kwargs)
181
+ response = func(messages, config, output_type)
123
182
  return response
124
183
  except Exception as e:
125
184
  last_error = e
126
185
  if self._is_retryable_error(e) and attempts < retry:
127
186
  attempts += 1
128
- delay = 2 ** attempts # 2s, 4s, 8s...
129
- print(f"[ask-ai] Transient error ({e.__class__.__name__}). Retrying in {delay}s... ({attempts}/{retry})")
130
- import time
187
+ delay = min(2 ** attempts + random.uniform(0, 1), 30)
188
+ logger.warning(
189
+ "Transient error (%s). Retrying in %.1fs... (%d/%d)",
190
+ e.__class__.__name__, delay, attempts, retry
191
+ )
131
192
  time.sleep(delay)
132
193
  else:
133
- raise e
134
-
135
- # Fallback if loop exits without returning
194
+ raise
195
+
196
+ from .exceptions import AskAINetworkError
197
+ raise AskAINetworkError(f"Request failed after {retry} retries. Last error: {last_error}")
198
+
199
+ async def _execute_with_retry_async(self, func, messages, config, output_type, retry):
200
+ """Execute an async function with exponential backoff + jitter."""
201
+ attempts = 0
202
+ last_error = None
203
+
204
+ while attempts <= retry:
205
+ try:
206
+ response = await func(messages, config, output_type)
207
+ return response
208
+ except Exception as e:
209
+ last_error = e
210
+ if self._is_retryable_error(e) and attempts < retry:
211
+ attempts += 1
212
+ delay = min(2 ** attempts + random.uniform(0, 1), 30)
213
+ logger.warning(
214
+ "Transient error (%s). Retrying in %.1fs... (%d/%d)",
215
+ e.__class__.__name__, delay, attempts, retry
216
+ )
217
+ await asyncio.sleep(delay)
218
+ else:
219
+ raise
220
+
136
221
  from .exceptions import AskAINetworkError
137
222
  raise AskAINetworkError(f"Request failed after {retry} retries. Last error: {last_error}")
138
223
 
139
224
  def _is_retryable_error(self, e: Exception) -> bool:
140
225
  """Dynamically detect rate limits and network timeouts across different provider SDKs."""
226
+ from .exceptions import AskAINetworkError, AskAIRateLimitError
227
+ if isinstance(e, (AskAINetworkError, AskAIRateLimitError)):
228
+ return True
229
+
141
230
  err_str = str(e).lower()
142
231
  err_cls = e.__class__.__name__.lower()
143
-
144
- # Explicit ask-ai errors
145
- from .exceptions import AskAINetworkError, AskAIRateLimitError
146
- if isinstance(e, (AskAINetworkError, AskAIRateLimitError)): return True
147
-
148
- # Heuristics for underlying provider SDKs (OpenAI, Anthropic, etc.)
149
- if "ratelimit" in err_cls or "rate_limit" in err_cls or "429" in err_str: return True
150
- if "timeout" in err_cls or "timeout" in err_str: return True
151
- if "connection" in err_cls or "connecterror" in err_cls: return True
152
-
153
- return False
154
-
155
- def _merge_configs(self, global_conf: AdvancedConfig, req_conf: AdvancedConfig) -> AdvancedConfig:
156
- return global_conf.merge(req_conf) # We need to implement merge logic in AdvancedConfig properly or here
157
232
 
158
- def _prepare_messages(self, query: str, config: AdvancedConfig) -> List[Dict[str, str]]:
159
- messages = []
160
-
161
- system_msg = config.system_message or self.persona or ""
162
-
163
- # Smart capability: if json=True, enforce JSON system prompt
164
- if config.extra.get('json'):
165
- json_instruction = "You must respond with valid JSON only, without any markdown formatting or tags."
166
- system_msg = f"{system_msg}\n{json_instruction}".strip()
233
+ retryable_indicators = ("ratelimit", "rate_limit", "429", "timeout", "connection", "connecterror")
234
+ return any(indicator in err_cls or indicator in err_str for indicator in retryable_indicators)
167
235
 
168
- if system_msg:
169
- messages.append({"role": "system", "content": system_msg})
170
-
171
- messages.append({"role": "user", "content": query})
172
- return messages
236
+ # ─── Abstract Methods (Subclasses Must Implement) ──────
173
237
 
174
238
  def _send_request(self, messages: List[Dict[str, str]], config: AdvancedConfig, output_type: str = None) -> Response:
175
- """Subclasses must implement this."""
239
+ """Sync request — subclasses must implement."""
176
240
  raise NotImplementedError
177
241
 
178
- # --- Tool Definitions for Smart Intent ---
242
+ async def _send_request_async(self, messages: List[Dict[str, str]], config: AdvancedConfig, output_type: str = None) -> Response:
243
+ """Async request — subclasses should override for native async."""
244
+ # Default fallback: run sync in thread pool
245
+ loop = asyncio.get_running_loop()
246
+ return await loop.run_in_executor(None, self._send_request, messages, config, output_type)
247
+
248
+ def _send_request_stream(self, messages: List[Dict[str, str]], config: AdvancedConfig) -> Iterator[str]:
249
+ """Sync streaming — subclasses should override."""
250
+ from .exceptions import StreamNotSupportedError
251
+ raise StreamNotSupportedError(f"{self.__class__.__name__} does not support streaming.")
252
+
253
+ async def _send_request_stream_async(self, messages: List[Dict[str, str]], config: AdvancedConfig) -> AsyncIterator[str]:
254
+ """Async streaming — subclasses should override for native async."""
255
+ # Default fallback: wrap sync stream
256
+ loop = asyncio.get_running_loop()
257
+ for chunk in await loop.run_in_executor(None, lambda: list(self._send_request_stream(messages, config))):
258
+ yield chunk
259
+
260
+ # ─── Tool Definitions for Smart Intent ─────────────────
261
+
179
262
  def _get_media_tools(self) -> List[Dict[str, Any]]:
180
263
  return [
181
264
  {
182
265
  "type": "function",
183
266
  "function": {
184
267
  "name": "generate_image",
185
- "description": "Generate an image based on a prompt. Use this when the user asks to draw, create, or show an image.",
268
+ "description": (
269
+ "Generate an image based on a prompt. Use this when the user asks to draw, create, or show an image."
270
+ ),
186
271
  "parameters": {
187
272
  "type": "object",
188
273
  "properties": {
@@ -199,7 +284,9 @@ class BaseProvider:
199
284
  "type": "function",
200
285
  "function": {
201
286
  "name": "generate_speech",
202
- "description": "Generate audio speech from text. Use this when the user asks to say something, speak, or read aloud.",
287
+ "description": (
288
+ "Generate audio speech from text. Use this when the user asks to say something, speak, or read aloud."
289
+ ),
203
290
  "parameters": {
204
291
  "type": "object",
205
292
  "properties": {
ask_ai/cli.py CHANGED
@@ -1,77 +1,107 @@
1
1
  import sys
2
2
  import argparse
3
- from .providers import OpenAI
4
- from .exceptions import EasyAIError
3
+ from . import __version__
4
+ from . import (
5
+ OpenAI,
6
+ Groq,
7
+ Google,
8
+ OpenRouter,
9
+ Azure,
10
+ Anthropic,
11
+ AskAIError
12
+ )
13
+
14
+
15
+ PROVIDER_MAP = {
16
+ "openai": OpenAI,
17
+ "groq": Groq,
18
+ "google": Google,
19
+ "openrouter": OpenRouter,
20
+ "azure": Azure,
21
+ "anthropic": Anthropic,
22
+ }
23
+
5
24
 
6
25
  def main():
7
- parser = argparse.ArgumentParser(description="easyai CLI: Ask AI anything directly from your terminal.")
8
- parser.add_argument("query", help="The text query to ask the AI.")
9
- parser.add_argument("--provider", default="openai", choices=["openai", "groq", "google", "openrouter", "azure", "anthropic"], help="The AI provider to use.")
10
- parser.add_argument("--model", help="Specific model to use (optional).")
11
- parser.add_argument("--temp", type=float, help="Temperature setting (optional).")
12
-
26
+ parser = argparse.ArgumentParser(
27
+ description="ask-ai CLI: Ask AI anything directly from your terminal."
28
+ )
29
+ parser.add_argument("query", nargs="?", help="The text query to ask the AI.")
30
+ parser.add_argument(
31
+ "--provider", "-p",
32
+ default="openai",
33
+ choices=list(PROVIDER_MAP.keys()),
34
+ help="The AI provider to use (default: openai)."
35
+ )
36
+ parser.add_argument("--model", "-m", help="Specific model to use.")
37
+ parser.add_argument("--temp", type=float, help="Temperature setting.")
38
+ parser.add_argument("--system", "-s", help="System prompt.")
39
+ parser.add_argument("--json", action="store_true", help="Request JSON output.")
40
+ parser.add_argument("--stream", action="store_true", help="Enable streaming output.")
41
+ parser.add_argument("--version", "-v", action="version", version=f"ask-ai {__version__}")
42
+
13
43
  args = parser.parse_args()
14
44
 
15
- # Map provider name to class
16
- providers = {
17
- "openai": OpenAI,
18
- # Lazy load others to avoid unnecessary imports if cli is used often?
19
- # But we already imported OpenAI.
20
- # Let's import dynamically or just map if already imported in __init__.
21
- }
22
-
23
- # We need to import others dynamically or statically.
24
- # Since __init__ exposes them, let's use that.
25
- import easyai
26
-
27
- provider_class = getattr(easyai, args.provider.capitalize(), None)
28
- if args.provider == "openrouter": # naming case mismatch
29
- provider_class = easyai.OpenRouter
30
- elif args.provider == "openai":
31
- provider_class = easyai.OpenAI
32
- elif args.provider == "groq":
33
- provider_class = easyai.Groq
34
- elif args.provider == "google":
35
- provider_class = easyai.Google
36
- elif args.provider == "azure":
37
- provider_class = easyai.Azure
38
- elif args.provider == "anthropic":
39
- provider_class = easyai.Anthropic
45
+ if not args.query:
46
+ parser.print_help()
47
+ sys.exit(0)
40
48
 
49
+ provider_class = PROVIDER_MAP.get(args.provider.lower())
41
50
  if not provider_class:
42
51
  print(f"Error: Provider '{args.provider}' not found.")
43
52
  sys.exit(1)
44
53
 
45
54
  try:
46
55
  # Init provider
47
- kwargs = {}
56
+ init_kwargs = {}
48
57
  if args.model:
49
- kwargs["model"] = args.model
50
-
51
- ai = provider_class(**kwargs)
52
-
53
- # Ask
58
+ init_kwargs["model"] = args.model
59
+
60
+ ai = provider_class(**init_kwargs)
61
+
62
+ # Build ask kwargs
54
63
  ask_kwargs = {}
55
64
  if args.temp is not None:
56
65
  ask_kwargs["temperature"] = args.temp
66
+ if args.system:
67
+ ask_kwargs["system_message"] = args.system
68
+ if args.json:
69
+ ask_kwargs["json"] = True
57
70
 
58
- print(f"[{args.provider.upper()}] Thinking...")
59
- response = ai.ask(args.query, **ask_kwargs)
60
-
61
- if response.media:
62
- print(f"Generated Media: {response.media.type}")
63
- response.media.show()
64
- # auto save to current directory?
65
- filename = f"output.{'png' if response.media.type == 'image' else 'mp3'}"
66
- response.media.save(filename)
67
- print(f"Saved to {filename}")
71
+ # Stream or regular
72
+ if args.stream:
73
+ try:
74
+ for chunk in ai.ask_stream(args.query, **ask_kwargs):
75
+ print(chunk, end="", flush=True)
76
+ print() # newline at end
77
+ except Exception:
78
+ # Fallback to non-streaming if provider doesn't support it
79
+ response = ai.ask(args.query, **ask_kwargs)
80
+ print(response.text)
68
81
  else:
69
- print(response.text)
82
+ response = ai.ask(args.query, **ask_kwargs)
83
+
84
+ if response.media:
85
+ print(f"Generated Media: {response.media.type}")
86
+ filename = f"output.{'png' if response.media.type == 'image' else 'mp3'}"
87
+ response.media.save(filename)
88
+ print(f"Saved to {filename}")
89
+ elif args.json:
90
+ import json
91
+ print(json.dumps(response.json, indent=2, ensure_ascii=False))
92
+ else:
93
+ print(response.text)
70
94
 
71
- except EasyAIError as e:
72
- print(f"EasyAI Error: {e}")
95
+ except AskAIError as e:
96
+ print(f"Error: {e}", file=sys.stderr)
97
+ sys.exit(1)
98
+ except KeyboardInterrupt:
99
+ print("\nInterrupted.")
100
+ sys.exit(130)
73
101
  except Exception as e:
74
- print(f"Unexpected Error: {e}")
102
+ print(f"Unexpected Error: {e}", file=sys.stderr)
103
+ sys.exit(1)
104
+
75
105
 
76
106
  if __name__ == "__main__":
77
107
  main()
ask_ai/config.py CHANGED
@@ -1,8 +1,10 @@
1
- from typing import Optional, Dict, Any
1
+ from typing import Optional, Dict, Any, List
2
+
2
3
 
3
4
  class AdvancedConfig:
4
5
  """
5
6
  Configuration class for advanced AI settings.
7
+ Supports both direct init and aliased parameters for usability.
6
8
  """
7
9
  def __init__(
8
10
  self,
@@ -11,7 +13,7 @@ class AdvancedConfig:
11
13
  top_p: Optional[float] = None,
12
14
  frequency_penalty: Optional[float] = None,
13
15
  presence_penalty: Optional[float] = None,
14
- stop: Optional[list] = None,
16
+ stop: Optional[List[str]] = None,
15
17
  system_message: Optional[str] = None,
16
18
  safe_mode: bool = False,
17
19
  **kwargs
@@ -22,35 +24,58 @@ class AdvancedConfig:
22
24
  self.frequency_penalty = frequency_penalty
23
25
  self.presence_penalty = presence_penalty
24
26
  self.stop = stop
25
- self.stop = stop
26
-
27
+
27
28
  # Smart Aliases for 10/10 Usability
28
29
  # If user passes 'prompt' or 'system', treat it as 'system_message'
29
30
  if system_message is None:
30
- system_message = kwargs.get("prompt") or kwargs.get("system")
31
-
31
+ system_message = kwargs.pop("prompt", None) or kwargs.pop("system", None)
32
+ else:
33
+ # Remove aliases from kwargs if system_message was explicitly set
34
+ kwargs.pop("prompt", None)
35
+ kwargs.pop("system", None)
36
+
32
37
  self.system_message = system_message
33
38
  self.safe_mode = safe_mode
34
- self.extra = kwargs
39
+ self.extra: Dict[str, Any] = kwargs
35
40
 
36
41
  def to_dict(self) -> Dict[str, Any]:
37
42
  """Convert config to a dictionary, filtering None values."""
38
- return {k: v for k, v in self.__dict__.items() if v is not None and k != 'extra'}
43
+ result = {}
44
+ for k, v in self.__dict__.items():
45
+ if k == 'extra':
46
+ continue
47
+ if v is not None:
48
+ result[k] = v
49
+ return result
39
50
 
40
- def merge(self, other_config: 'AdvancedConfig') -> 'AdvancedConfig':
41
- """Merge another config into this one (other overrides self)."""
42
- new_config = AdvancedConfig()
43
- # Copy self
51
+ def merge(self, other: 'AdvancedConfig') -> 'AdvancedConfig':
52
+ """
53
+ Merge another config into this one (other overrides self).
54
+ Returns a new AdvancedConfig without mutating either input.
55
+ """
56
+ merged = AdvancedConfig()
57
+
58
+ # Copy self values
44
59
  for k, v in self.__dict__.items():
45
- setattr(new_config, k, v)
46
-
47
- # Override with other
48
- for k, v in other_config.__dict__.items():
60
+ if k == 'extra':
61
+ continue
62
+ setattr(merged, k, v)
63
+
64
+ # Override with other values (only non-None)
65
+ for k, v in other.__dict__.items():
66
+ if k == 'extra':
67
+ continue
49
68
  if v is not None:
50
- setattr(new_config, k, v)
51
-
52
- return new_config
53
-
69
+ setattr(merged, k, v)
70
+
71
+ # Deep merge extra dicts
72
+ merged_extra = dict(self.extra)
73
+ merged_extra.update(other.extra)
74
+ merged.extra = merged_extra
75
+
76
+ return merged
77
+
54
78
  @classmethod
55
79
  def from_dict(cls, data: Dict[str, Any]) -> 'AdvancedConfig':
80
+ """Create an AdvancedConfig from a dictionary."""
56
81
  return cls(**data)