mrmd-ai 0.1.1__tar.gz → 0.1.2__tar.gz

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.
Files changed (51) hide show
  1. mrmd_ai-0.1.2/LICENSE +21 -0
  2. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/PKG-INFO +2 -1
  3. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/pyproject.toml +1 -1
  4. mrmd_ai-0.1.2/src/mrmd_ai/custom_programs.py +215 -0
  5. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/src/mrmd_ai/juice.py +92 -7
  6. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/src/mrmd_ai/server.py +182 -8
  7. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/uv.lock +2 -7
  8. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/.gitignore +0 -0
  9. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/README.md +0 -0
  10. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/dspy.config.yaml +0 -0
  11. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/logs/AddTypeHintsPredict.log +0 -0
  12. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/logs/CorrectAndFinishLinePredict.log +0 -0
  13. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/logs/DocumentCodePredict.log +0 -0
  14. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/logs/ExplainCodePredict.log +0 -0
  15. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/logs/FinishCodeLinePredict.log +0 -0
  16. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/logs/FinishCodeSectionPredict.log +0 -0
  17. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/logs/FinishParagraphPredict.log +0 -0
  18. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/logs/FinishSentencePredict.log +0 -0
  19. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/logs/FixGrammarPredict.log +0 -0
  20. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/logs/FixTranscriptionPredict.log +0 -0
  21. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/logs/FormatCodePredict.log +0 -0
  22. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/logs/GetSynonymsPredict.log +0 -0
  23. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/logs/IdentifyReplacementPredict.log +0 -0
  24. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/logs/ImproveNamesPredict.log +0 -0
  25. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/logs/RefactorCodePredict.log +0 -0
  26. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/logs/ReformatMarkdownPredict.log +0 -0
  27. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/logs/server.log +0 -0
  28. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/openapi.json +0 -0
  29. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/src/mrmd_ai/__init__.py +0 -0
  30. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/src/mrmd_ai/metrics/__init__.py +0 -0
  31. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/src/mrmd_ai/modules/__init__.py +0 -0
  32. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/src/mrmd_ai/modules/code.py +0 -0
  33. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/src/mrmd_ai/modules/correct.py +0 -0
  34. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/src/mrmd_ai/modules/document.py +0 -0
  35. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/src/mrmd_ai/modules/edit.py +0 -0
  36. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/src/mrmd_ai/modules/finish.py +0 -0
  37. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/src/mrmd_ai/modules/fix.py +0 -0
  38. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/src/mrmd_ai/modules/notebook.py +0 -0
  39. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/src/mrmd_ai/modules/text.py +0 -0
  40. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/src/mrmd_ai/optimizers/__init__.py +0 -0
  41. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/src/mrmd_ai/signatures/__init__.py +0 -0
  42. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/src/mrmd_ai/signatures/code.py +0 -0
  43. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/src/mrmd_ai/signatures/correct.py +0 -0
  44. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/src/mrmd_ai/signatures/document.py +0 -0
  45. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/src/mrmd_ai/signatures/edit.py +0 -0
  46. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/src/mrmd_ai/signatures/finish.py +0 -0
  47. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/src/mrmd_ai/signatures/fix.py +0 -0
  48. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/src/mrmd_ai/signatures/notebook.py +0 -0
  49. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/src/mrmd_ai/signatures/text.py +0 -0
  50. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/src/mrmd_ai/utils/__init__.py +0 -0
  51. {mrmd_ai-0.1.1 → mrmd_ai-0.1.2}/tests/__init__.py +0 -0
mrmd_ai-0.1.2/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Maxime Rivest
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,7 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mrmd-ai
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: AI programs for MRMD editor - completions, fixes, and corrections
5
+ License-File: LICENSE
5
6
  Requires-Python: >=3.11
6
7
  Requires-Dist: dspy>=2.6
7
8
  Requires-Dist: fastapi>=0.115
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mrmd-ai"
3
- version = "0.1.1"
3
+ version = "0.1.2"
4
4
  description = "AI programs for MRMD editor - completions, fixes, and corrections"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -0,0 +1,215 @@
1
+ """
2
+ Custom Program Factory - Generate DSPy modules from user-defined templates.
3
+
4
+ This module allows users to create custom AI commands without writing code.
5
+ Users define their commands with:
6
+ - name: Display name for the command
7
+ - inputType: What text to process (selection, cursor, fullDoc)
8
+ - outputType: What to do with result (replace, insert)
9
+ - instructions: Natural language instructions for the AI
10
+
11
+ The factory generates DSPy Signature and Module classes dynamically.
12
+ """
13
+
14
+ from typing import Any
15
+ import dspy
16
+
17
+
18
+ def create_custom_signature(
19
+ name: str,
20
+ instructions: str,
21
+ input_type: str = "selection",
22
+ output_type: str = "replace",
23
+ ) -> type:
24
+ """Create a DSPy Signature class from user configuration.
25
+
26
+ Args:
27
+ name: Command name (used for class naming)
28
+ instructions: User's instructions for the AI
29
+ input_type: "selection" | "cursor" | "fullDoc"
30
+ output_type: "replace" | "insert"
31
+
32
+ Returns:
33
+ A DSPy Signature class
34
+ """
35
+ # Build the docstring from user instructions
36
+ docstring = f"""{instructions}
37
+
38
+ IMPORTANT RULES:
39
+ - Output ONLY the result text, no explanations or meta-commentary
40
+ - Maintain appropriate formatting (markdown, code style, etc.)
41
+ - Be concise and direct
42
+ """
43
+
44
+ # Define input fields based on input type
45
+ if input_type == "selection":
46
+ input_fields = {
47
+ "text": dspy.InputField(desc="The selected text to process"),
48
+ "local_context": dspy.InputField(desc="Text surrounding the selection for context"),
49
+ "document_context": dspy.InputField(desc="Broader document context"),
50
+ }
51
+ elif input_type == "cursor":
52
+ input_fields = {
53
+ "text_before_cursor": dspy.InputField(desc="Text before the cursor position"),
54
+ "local_context": dspy.InputField(desc="Text surrounding the cursor for context"),
55
+ "document_context": dspy.InputField(desc="Broader document context"),
56
+ }
57
+ else: # fullDoc
58
+ input_fields = {
59
+ "document_context": dspy.InputField(desc="The full document content"),
60
+ }
61
+
62
+ # Define output field
63
+ output_field_name = "result"
64
+ output_fields = {
65
+ output_field_name: dspy.OutputField(desc="The processed result text. Output ONLY the result.")
66
+ }
67
+
68
+ # Create the Signature class dynamically
69
+ # Clean name for class naming (remove spaces, special chars)
70
+ class_name = "".join(c for c in name if c.isalnum()) + "Signature"
71
+
72
+ signature_class = type(
73
+ class_name,
74
+ (dspy.Signature,),
75
+ {
76
+ "__doc__": docstring,
77
+ "__annotations__": {
78
+ **{k: str for k in input_fields},
79
+ **{k: str for k in output_fields},
80
+ },
81
+ **input_fields,
82
+ **output_fields,
83
+ }
84
+ )
85
+
86
+ return signature_class
87
+
88
+
89
+ def create_custom_module(
90
+ name: str,
91
+ instructions: str,
92
+ input_type: str = "selection",
93
+ output_type: str = "replace",
94
+ ) -> type:
95
+ """Create a DSPy Module class from user configuration.
96
+
97
+ Args:
98
+ name: Command name
99
+ instructions: User's instructions for the AI
100
+ input_type: "selection" | "cursor" | "fullDoc"
101
+ output_type: "replace" | "insert"
102
+
103
+ Returns:
104
+ A DSPy Module class (not instance)
105
+ """
106
+ signature = create_custom_signature(name, instructions, input_type, output_type)
107
+
108
+ # Clean name for class naming
109
+ class_name = "".join(c for c in name if c.isalnum()) + "Predict"
110
+
111
+ class CustomModule(dspy.Module):
112
+ """Dynamically generated custom command module."""
113
+
114
+ def __init__(self):
115
+ super().__init__()
116
+ self.predictor = dspy.Predict(signature)
117
+ self._input_type = input_type
118
+ self._output_type = output_type
119
+
120
+ def forward(self, **kwargs) -> Any:
121
+ return self.predictor(**kwargs)
122
+
123
+ # Set the class name
124
+ CustomModule.__name__ = class_name
125
+ CustomModule.__qualname__ = class_name
126
+
127
+ return CustomModule
128
+
129
+
130
+ class CustomProgramRegistry:
131
+ """Registry for user-defined custom programs.
132
+
133
+ Manages creation and caching of custom DSPy modules.
134
+ """
135
+
136
+ def __init__(self):
137
+ self._modules: dict[str, type] = {}
138
+ self._configs: dict[str, dict] = {}
139
+
140
+ def register(self, program_id: str, config: dict) -> type:
141
+ """Register a custom program from configuration.
142
+
143
+ Args:
144
+ program_id: Unique identifier for this program
145
+ config: Dict with name, instructions, inputType, outputType
146
+
147
+ Returns:
148
+ The generated Module class
149
+ """
150
+ module_class = create_custom_module(
151
+ name=config.get("name", program_id),
152
+ instructions=config.get("instructions", "Process this text."),
153
+ input_type=config.get("inputType", "selection"),
154
+ output_type=config.get("outputType", "replace"),
155
+ )
156
+
157
+ self._modules[program_id] = module_class
158
+ self._configs[program_id] = config
159
+
160
+ return module_class
161
+
162
+ def get(self, program_id: str) -> type | None:
163
+ """Get a registered module class by ID."""
164
+ return self._modules.get(program_id)
165
+
166
+ def get_config(self, program_id: str) -> dict | None:
167
+ """Get the configuration for a registered program."""
168
+ return self._configs.get(program_id)
169
+
170
+ def unregister(self, program_id: str) -> bool:
171
+ """Remove a program from the registry."""
172
+ if program_id in self._modules:
173
+ del self._modules[program_id]
174
+ del self._configs[program_id]
175
+ return True
176
+ return False
177
+
178
+ def clear(self):
179
+ """Clear all registered programs."""
180
+ self._modules.clear()
181
+ self._configs.clear()
182
+
183
+ def list_programs(self) -> list[str]:
184
+ """List all registered program IDs."""
185
+ return list(self._modules.keys())
186
+
187
+ def is_registered(self, program_id: str) -> bool:
188
+ """Check if a program is registered."""
189
+ return program_id in self._modules
190
+
191
+
192
+ # Global registry instance
193
+ custom_registry = CustomProgramRegistry()
194
+
195
+
196
+ def register_custom_programs(commands: list[dict]) -> None:
197
+ """Register multiple custom programs from a list of command configs.
198
+
199
+ Args:
200
+ commands: List of command configurations, each with:
201
+ - id or program: Unique identifier
202
+ - name: Display name
203
+ - instructions: AI instructions
204
+ - inputType: selection | cursor | fullDoc
205
+ - outputType: replace | insert
206
+ """
207
+ for cmd in commands:
208
+ program_id = cmd.get("program") or cmd.get("id")
209
+ if program_id:
210
+ custom_registry.register(program_id, cmd)
211
+
212
+
213
+ def get_custom_program(program_id: str) -> type | None:
214
+ """Get a custom program module class by ID."""
215
+ return custom_registry.get(program_id)
@@ -213,15 +213,73 @@ SYNTHESIZER_MODEL = ModelConfig(
213
213
  )
214
214
 
215
215
 
216
+ def get_api_key_for_model(model: str, api_keys: dict | None) -> str | None:
217
+ """Get the appropriate API key for a model based on its provider.
218
+
219
+ Uses LiteLLM model naming convention: provider/model-name
220
+ Supports any provider that the user has configured in settings.
221
+
222
+ Args:
223
+ model: Model identifier (e.g., "anthropic/claude-sonnet-4-5")
224
+ api_keys: Dict of provider -> API key
225
+
226
+ Returns:
227
+ API key string or None if not found/provided.
228
+ """
229
+ if not api_keys:
230
+ return None
231
+
232
+ model_lower = model.lower()
233
+
234
+ # Extract provider from model name (LiteLLM format: provider/model-name)
235
+ if "/" in model:
236
+ provider = model.split("/")[0].lower()
237
+ # Check for direct provider match
238
+ if provider in api_keys and api_keys[provider]:
239
+ return api_keys[provider]
240
+
241
+ # Fallback: Check for known provider patterns in model name
242
+ # This handles cases like "claude-3-sonnet" without prefix
243
+ provider_patterns = {
244
+ "anthropic": ["anthropic/", "claude"],
245
+ "openai": ["openai/", "gpt-", "o1-", "o3-"],
246
+ "groq": ["groq/"],
247
+ "gemini": ["gemini/", "gemini-"],
248
+ "openrouter": ["openrouter/"],
249
+ "together_ai": ["together_ai/", "together/"],
250
+ "fireworks_ai": ["fireworks_ai/", "fireworks/"],
251
+ "mistral": ["mistral/"],
252
+ "cohere": ["cohere/"],
253
+ "deepseek": ["deepseek/"],
254
+ "ollama": ["ollama/"],
255
+ "azure": ["azure/"],
256
+ "bedrock": ["bedrock/"],
257
+ "vertex_ai": ["vertex_ai/"],
258
+ }
259
+
260
+ for provider, patterns in provider_patterns.items():
261
+ for pattern in patterns:
262
+ if pattern in model_lower:
263
+ if provider in api_keys and api_keys[provider]:
264
+ return api_keys[provider]
265
+ break
266
+
267
+ return None
268
+
269
+
216
270
  def get_lm(
217
271
  juice: JuiceLevel | int = JuiceLevel.QUICK,
218
- reasoning: ReasoningLevel | int | None = None
272
+ reasoning: ReasoningLevel | int | None = None,
273
+ api_keys: dict | None = None,
274
+ model_override: str | None = None,
219
275
  ) -> dspy.LM:
220
276
  """Get a dspy.LM configured for the specified juice and reasoning levels.
221
277
 
222
278
  Args:
223
279
  juice: Juice level (0-3). Level 4 (ULTIMATE) requires special handling.
224
280
  reasoning: Optional reasoning level (0-5). If None, uses juice level's default.
281
+ api_keys: Optional dict of provider -> API key. If provided, overrides env vars.
282
+ model_override: Optional model to use instead of the default for this juice level.
225
283
 
226
284
  Returns:
227
285
  Configured dspy.LM instance.
@@ -235,6 +293,15 @@ def get_lm(
235
293
  config = JUICE_MODELS[juice]
236
294
  kwargs = config.to_lm_kwargs()
237
295
 
296
+ # Apply model override if provided
297
+ if model_override:
298
+ kwargs["model"] = model_override
299
+
300
+ # Get API key for this model's provider
301
+ api_key = get_api_key_for_model(kwargs["model"], api_keys)
302
+ if api_key:
303
+ kwargs["api_key"] = api_key
304
+
238
305
  # Apply reasoning level overrides if specified AND model supports reasoning
239
306
  if reasoning is not None and config.supports_reasoning:
240
307
  if isinstance(reasoning, int):
@@ -313,7 +380,9 @@ class JuicedProgram:
313
380
  program: dspy.Module,
314
381
  juice: JuiceLevel | int = JuiceLevel.QUICK,
315
382
  reasoning: ReasoningLevel | int | None = None,
316
- progress_callback: Callable[[str, dict], None] | None = None
383
+ progress_callback: Callable[[str, dict], None] | None = None,
384
+ api_keys: dict | None = None,
385
+ model_override: str | None = None,
317
386
  ):
318
387
  """Initialize a juiced program.
319
388
 
@@ -326,11 +395,15 @@ class JuicedProgram:
326
395
  - "status": General status update
327
396
  - "model_start": A model is starting (ultimate mode)
328
397
  - "model_complete": A model finished (ultimate mode)
398
+ api_keys: Optional dict of provider -> API key. Overrides env vars.
399
+ model_override: Optional model to use instead of the default for this juice level.
329
400
  """
330
401
  self.program = program
331
402
  self.juice = JuiceLevel(juice) if isinstance(juice, int) else juice
332
403
  self.reasoning = ReasoningLevel(reasoning) if isinstance(reasoning, int) else reasoning
333
404
  self.progress_callback = progress_callback
405
+ self.api_keys = api_keys
406
+ self.model_override = model_override
334
407
 
335
408
  def _emit(self, event_type: str, data: dict):
336
409
  """Emit a progress event if callback is set."""
@@ -347,7 +420,10 @@ class JuicedProgram:
347
420
  def _run_single(self, **kwargs) -> Any:
348
421
  """Run with a single model at the specified juice level."""
349
422
  config = JUICE_MODELS[self.juice]
350
- model_name = config.model.split("/")[-1]
423
+
424
+ # Use model override if provided, otherwise use default for juice level
425
+ actual_model = self.model_override if self.model_override else config.model
426
+ model_name = actual_model.split("/")[-1]
351
427
 
352
428
  reasoning_desc = ""
353
429
  if self.reasoning is not None:
@@ -356,11 +432,11 @@ class JuicedProgram:
356
432
  self._emit("status", {
357
433
  "step": "calling_model",
358
434
  "model": model_name,
359
- "model_full": config.model,
435
+ "model_full": actual_model,
360
436
  "reasoning_level": self.reasoning.value if self.reasoning else None,
361
437
  })
362
438
 
363
- lm = get_lm(self.juice, self.reasoning)
439
+ lm = get_lm(self.juice, self.reasoning, api_keys=self.api_keys, model_override=self.model_override)
364
440
  with dspy.context(lm=lm):
365
441
  result = self.program(**kwargs)
366
442
 
@@ -416,6 +492,11 @@ class JuicedProgram:
416
492
  if reasoning_config["reasoning_effort"] is not None:
417
493
  lm_kwargs["reasoning_effort"] = reasoning_config["reasoning_effort"]
418
494
 
495
+ # Apply API key for this model's provider
496
+ api_key = get_api_key_for_model(config.model, self.api_keys)
497
+ if api_key:
498
+ lm_kwargs["api_key"] = api_key
499
+
419
500
  lm = dspy.LM(**lm_kwargs)
420
501
  model_name = config.model.split("/")[-1]
421
502
 
@@ -536,8 +617,12 @@ class JuicedProgram:
536
617
  # Create synthesized result
537
618
  merged = {}
538
619
 
539
- # Configure synthesizer LM
540
- synth_lm = dspy.LM(**SYNTHESIZER_MODEL.to_lm_kwargs())
620
+ # Configure synthesizer LM with API key if provided
621
+ synth_kwargs = SYNTHESIZER_MODEL.to_lm_kwargs()
622
+ api_key = get_api_key_for_model(SYNTHESIZER_MODEL.model, self.api_keys)
623
+ if api_key:
624
+ synth_kwargs["api_key"] = api_key
625
+ synth_lm = dspy.LM(**synth_kwargs)
541
626
 
542
627
  # Synthesize each output field
543
628
  for field_name, base_value in base_store.items():
@@ -24,6 +24,7 @@ import json
24
24
  _executor = ThreadPoolExecutor(max_workers=10)
25
25
 
26
26
  from .juice import JuiceLevel, ReasoningLevel, JuicedProgram, get_lm, JUICE_MODELS, REASONING_DESCRIPTIONS
27
+ from .custom_programs import custom_registry, register_custom_programs
27
28
  from .modules import (
28
29
  # Finish
29
30
  FinishSentencePredict,
@@ -105,16 +106,91 @@ PROGRAMS = {
105
106
  }
106
107
 
107
108
  # Cached program instances per juice level and reasoning level
109
+ # NOTE: Cache is only used when no custom API keys are provided
108
110
  _program_cache: dict[tuple[str, int, int | None], JuicedProgram] = {}
109
111
 
110
112
 
111
- def get_program(name: str, juice: int = 0, reasoning: int | None = None) -> JuicedProgram:
112
- """Get a JuicedProgram instance for the given program, juice level, and reasoning level."""
113
+ # API key header names
114
+ API_KEY_HEADERS = {
115
+ "anthropic": "X-Api-Key-Anthropic",
116
+ "openai": "X-Api-Key-Openai",
117
+ "groq": "X-Api-Key-Groq",
118
+ "gemini": "X-Api-Key-Gemini",
119
+ "openrouter": "X-Api-Key-Openrouter",
120
+ }
121
+
122
+
123
+ def extract_api_keys(request: Request) -> dict | None:
124
+ """Extract API keys from request headers.
125
+
126
+ Headers:
127
+ X-Api-Key-Anthropic: Anthropic API key
128
+ X-Api-Key-Openai: OpenAI API key
129
+ X-Api-Key-Groq: Groq API key
130
+ X-Api-Key-Gemini: Google Gemini API key
131
+ X-Api-Key-Openrouter: OpenRouter API key
132
+
133
+ Returns:
134
+ Dict of provider -> key if any keys are provided, None otherwise.
135
+ """
136
+ api_keys = {}
137
+ for provider, header in API_KEY_HEADERS.items():
138
+ key = request.headers.get(header)
139
+ if key:
140
+ api_keys[provider] = key
141
+
142
+ return api_keys if api_keys else None
143
+
144
+
145
+ def get_program(
146
+ name: str,
147
+ juice: int = 0,
148
+ reasoning: int | None = None,
149
+ api_keys: dict | None = None,
150
+ model_override: str | None = None,
151
+ ) -> JuicedProgram:
152
+ """Get a JuicedProgram instance for the given program configuration.
153
+
154
+ Args:
155
+ name: Program name (can be built-in or custom)
156
+ juice: Juice level (0-4)
157
+ reasoning: Optional reasoning level (0-5)
158
+ api_keys: Optional dict of provider -> API key
159
+ model_override: Optional model to use instead of default
160
+
161
+ Returns:
162
+ Configured JuicedProgram instance.
163
+
164
+ Note:
165
+ Programs with custom API keys or model overrides are NOT cached,
166
+ since they need fresh instances with the provided configuration.
167
+ Custom programs are never cached.
168
+ """
169
+ # Check built-in programs first
170
+ program_class = PROGRAMS.get(name)
171
+
172
+ # If not found, check custom registry
173
+ if program_class is None:
174
+ program_class = custom_registry.get(name)
175
+
176
+ if program_class is None:
177
+ raise ValueError(f"Unknown program: {name}")
178
+
179
+ # Custom programs and those with custom config are never cached
180
+ is_custom = name not in PROGRAMS
181
+ if api_keys or model_override or is_custom:
182
+ program = program_class()
183
+ return JuicedProgram(
184
+ program,
185
+ juice=juice,
186
+ reasoning=reasoning,
187
+ api_keys=api_keys,
188
+ model_override=model_override,
189
+ )
190
+
191
+ # Use cache for standard built-in requests (no custom keys)
113
192
  cache_key = (name, juice, reasoning)
114
193
  if cache_key not in _program_cache:
115
- if name not in PROGRAMS:
116
- raise ValueError(f"Unknown program: {name}")
117
- program_class = PROGRAMS[name]
118
194
  program = program_class()
119
195
  _program_cache[cache_key] = JuicedProgram(program, juice=juice, reasoning=reasoning)
120
196
  return _program_cache[cache_key]
@@ -250,6 +326,12 @@ async def run_program(program_name: str, request: Request):
250
326
  except ValueError:
251
327
  reasoning_level = None
252
328
 
329
+ # Extract API keys from headers (optional)
330
+ api_keys = extract_api_keys(request)
331
+
332
+ # Get model override from header (optional)
333
+ model_override = request.headers.get("X-Model-Override")
334
+
253
335
  # Get request body
254
336
  try:
255
337
  params = await request.json()
@@ -258,7 +340,13 @@ async def run_program(program_name: str, request: Request):
258
340
 
259
341
  # Get program
260
342
  try:
261
- juiced_program = get_program(program_name, juice_level, reasoning_level)
343
+ juiced_program = get_program(
344
+ program_name,
345
+ juice_level,
346
+ reasoning_level,
347
+ api_keys=api_keys,
348
+ model_override=model_override,
349
+ )
262
350
  except ValueError as e:
263
351
  raise HTTPException(status_code=404, detail=str(e))
264
352
 
@@ -347,6 +435,12 @@ async def run_program_stream(program_name: str, request: Request):
347
435
  except ValueError:
348
436
  reasoning_level = None
349
437
 
438
+ # Extract API keys from headers (optional)
439
+ api_keys = extract_api_keys(request)
440
+
441
+ # Get model override from header (optional)
442
+ model_override = request.headers.get("X-Model-Override")
443
+
350
444
  # Get request body
351
445
  try:
352
446
  params = await request.json()
@@ -390,10 +484,17 @@ async def run_program_stream(program_name: str, request: Request):
390
484
  def run_with_progress():
391
485
  """Run the program in a thread, emitting progress events."""
392
486
  try:
393
- # Create program with progress callback
487
+ # Create program with progress callback and optional API keys
394
488
  program_class = PROGRAMS[program_name]
395
489
  program = program_class()
396
- juiced = JuicedProgram(program, juice=juice_level, reasoning=reasoning_level, progress_callback=progress_callback)
490
+ juiced = JuicedProgram(
491
+ program,
492
+ juice=juice_level,
493
+ reasoning=reasoning_level,
494
+ progress_callback=progress_callback,
495
+ api_keys=api_keys,
496
+ model_override=model_override,
497
+ )
397
498
 
398
499
  # Emit starting event
399
500
  progress_callback("status", {
@@ -468,6 +569,79 @@ async def run_program_stream(program_name: str, request: Request):
468
569
  )
469
570
 
470
571
 
572
+ # =============================================================================
573
+ # CUSTOM PROGRAMS API
574
+ # =============================================================================
575
+
576
+ class CustomProgramConfig(BaseModel):
577
+ """Configuration for a custom program."""
578
+ id: str # Unique program ID (e.g., "Custom_cmd-123")
579
+ name: str
580
+ instructions: str
581
+ inputType: str = "selection" # selection | cursor | fullDoc
582
+ outputType: str = "replace" # replace | insert
583
+
584
+
585
+ class RegisterCustomProgramsRequest(BaseModel):
586
+ """Request to register multiple custom programs."""
587
+ programs: list[CustomProgramConfig]
588
+
589
+
590
+ @app.post("/api/custom-programs/register")
591
+ async def register_custom_programs_endpoint(request: RegisterCustomProgramsRequest):
592
+ """Register custom programs from frontend configuration.
593
+
594
+ This endpoint is called when the app starts or when settings change.
595
+ It clears existing custom programs and registers the new ones.
596
+ """
597
+ # Clear existing custom programs
598
+ custom_registry.clear()
599
+
600
+ # Register new programs
601
+ registered = []
602
+ for prog in request.programs:
603
+ try:
604
+ custom_registry.register(
605
+ program_id=prog.id,
606
+ config={
607
+ "name": prog.name,
608
+ "instructions": prog.instructions,
609
+ "inputType": prog.inputType,
610
+ "outputType": prog.outputType,
611
+ }
612
+ )
613
+ registered.append(prog.id)
614
+ print(f"[Custom] Registered: {prog.name} ({prog.id})")
615
+ except Exception as e:
616
+ print(f"[Custom] Failed to register {prog.id}: {e}")
617
+
618
+ return {"registered": registered, "count": len(registered)}
619
+
620
+
621
+ @app.get("/api/custom-programs")
622
+ async def list_custom_programs():
623
+ """List all registered custom programs."""
624
+ programs = []
625
+ for program_id in custom_registry.list_programs():
626
+ config = custom_registry.get_config(program_id)
627
+ if config:
628
+ programs.append({
629
+ "id": program_id,
630
+ "name": config.get("name", program_id),
631
+ "inputType": config.get("inputType", "selection"),
632
+ "outputType": config.get("outputType", "replace"),
633
+ })
634
+ return {"programs": programs}
635
+
636
+
637
+ @app.delete("/api/custom-programs/{program_id}")
638
+ async def unregister_custom_program(program_id: str):
639
+ """Unregister a specific custom program."""
640
+ if custom_registry.unregister(program_id):
641
+ return {"success": True, "id": program_id}
642
+ raise HTTPException(status_code=404, detail=f"Program not found: {program_id}")
643
+
644
+
471
645
  def main():
472
646
  """Run the AI server."""
473
647
  import argparse
@@ -1,5 +1,5 @@
1
1
  version = 1
2
- revision = 2
2
+ revision = 3
3
3
  requires-python = ">=3.11"
4
4
  resolution-markers = [
5
5
  "python_full_version >= '3.14'",
@@ -584,7 +584,6 @@ wheels = [
584
584
  { url = "https://files.pythonhosted.org/packages/1f/cb/48e964c452ca2b92175a9b2dca037a553036cb053ba69e284650ce755f13/greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e29f3018580e8412d6aaf5641bb7745d38c85228dacf51a73bd4e26ddf2a6a8e", size = 274908, upload-time = "2025-12-04T14:23:26.435Z" },
585
585
  { url = "https://files.pythonhosted.org/packages/28/da/38d7bff4d0277b594ec557f479d65272a893f1f2a716cad91efeb8680953/greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a687205fb22794e838f947e2194c0566d3812966b41c78709554aa883183fb62", size = 577113, upload-time = "2025-12-04T14:50:05.493Z" },
586
586
  { url = "https://files.pythonhosted.org/packages/3c/f2/89c5eb0faddc3ff014f1c04467d67dee0d1d334ab81fadbf3744847f8a8a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4243050a88ba61842186cb9e63c7dfa677ec146160b0efd73b855a3d9c7fcf32", size = 590338, upload-time = "2025-12-04T14:57:41.136Z" },
587
- { url = "https://files.pythonhosted.org/packages/80/d7/db0a5085035d05134f8c089643da2b44cc9b80647c39e93129c5ef170d8f/greenlet-3.3.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:670d0f94cd302d81796e37299bcd04b95d62403883b24225c6b5271466612f45", size = 601098, upload-time = "2025-12-04T15:07:11.898Z" },
588
587
  { url = "https://files.pythonhosted.org/packages/dc/a6/e959a127b630a58e23529972dbc868c107f9d583b5a9f878fb858c46bc1a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb3a8ec3db4a3b0eb8a3c25436c2d49e3505821802074969db017b87bc6a948", size = 590206, upload-time = "2025-12-04T14:26:01.254Z" },
589
588
  { url = "https://files.pythonhosted.org/packages/48/60/29035719feb91798693023608447283b266b12efc576ed013dd9442364bb/greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2de5a0b09eab81fc6a382791b995b1ccf2b172a9fec934747a7a23d2ff291794", size = 1550668, upload-time = "2025-12-04T15:04:22.439Z" },
590
589
  { url = "https://files.pythonhosted.org/packages/0a/5f/783a23754b691bfa86bd72c3033aa107490deac9b2ef190837b860996c9f/greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4449a736606bd30f27f8e1ff4678ee193bc47f6ca810d705981cfffd6ce0d8c5", size = 1615483, upload-time = "2025-12-04T14:27:28.083Z" },
@@ -592,7 +591,6 @@ wheels = [
592
591
  { url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" },
593
592
  { url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" },
594
593
  { url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" },
595
- { url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" },
596
594
  { url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" },
597
595
  { url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" },
598
596
  { url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" },
@@ -600,7 +598,6 @@ wheels = [
600
598
  { url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" },
601
599
  { url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" },
602
600
  { url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" },
603
- { url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" },
604
601
  { url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" },
605
602
  { url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" },
606
603
  { url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" },
@@ -608,7 +605,6 @@ wheels = [
608
605
  { url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" },
609
606
  { url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" },
610
607
  { url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" },
611
- { url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" },
612
608
  { url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" },
613
609
  { url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" },
614
610
  { url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" },
@@ -616,7 +612,6 @@ wheels = [
616
612
  { url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" },
617
613
  { url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" },
618
614
  { url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" },
619
- { url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" },
620
615
  { url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" },
621
616
  { url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" },
622
617
  { url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" },
@@ -1026,7 +1021,7 @@ wheels = [
1026
1021
 
1027
1022
  [[package]]
1028
1023
  name = "mrmd-ai"
1029
- version = "0.1.0"
1024
+ version = "0.1.1"
1030
1025
  source = { editable = "." }
1031
1026
  dependencies = [
1032
1027
  { name = "dspy" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes