codeoptix 0.1.3__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.
Files changed (92) hide show
  1. codeoptix/__init__.py +8 -0
  2. codeoptix/acp/__init__.py +33 -0
  3. codeoptix/acp/agent.py +209 -0
  4. codeoptix/acp/bridge.py +402 -0
  5. codeoptix/acp/client_adapter.py +312 -0
  6. codeoptix/acp/code_extractor.py +125 -0
  7. codeoptix/acp/orchestrator.py +349 -0
  8. codeoptix/acp/registry.py +294 -0
  9. codeoptix/adapters/__init__.py +18 -0
  10. codeoptix/adapters/base.py +50 -0
  11. codeoptix/adapters/basic.py +195 -0
  12. codeoptix/adapters/claude_code.py +221 -0
  13. codeoptix/adapters/codex.py +327 -0
  14. codeoptix/adapters/factory.py +56 -0
  15. codeoptix/adapters/gemini_cli.py +370 -0
  16. codeoptix/artifacts/__init__.py +5 -0
  17. codeoptix/artifacts/manager.py +193 -0
  18. codeoptix/behaviors/__init__.py +45 -0
  19. codeoptix/behaviors/base.py +81 -0
  20. codeoptix/behaviors/insecure_code.py +129 -0
  21. codeoptix/behaviors/plan_drift.py +192 -0
  22. codeoptix/behaviors/vacuous_tests.py +198 -0
  23. codeoptix/cli.py +1468 -0
  24. codeoptix/evaluation/__init__.py +23 -0
  25. codeoptix/evaluation/bloom_integration.py +271 -0
  26. codeoptix/evaluation/engine.py +274 -0
  27. codeoptix/evaluation/evaluators.py +308 -0
  28. codeoptix/evaluation/scenario_generator.py +222 -0
  29. codeoptix/evolution/__init__.py +7 -0
  30. codeoptix/evolution/engine.py +206 -0
  31. codeoptix/evolution/gepa_integration.py +149 -0
  32. codeoptix/evolution/proposer.py +185 -0
  33. codeoptix/linters/__init__.py +13 -0
  34. codeoptix/linters/bandit_linter.py +172 -0
  35. codeoptix/linters/base.py +105 -0
  36. codeoptix/linters/coverage_linter.py +156 -0
  37. codeoptix/linters/flake8_linter.py +156 -0
  38. codeoptix/linters/html_accessibility_linter.py +374 -0
  39. codeoptix/linters/language_detector.py +150 -0
  40. codeoptix/linters/mypy_linter.py +184 -0
  41. codeoptix/linters/pip_audit_linter.py +152 -0
  42. codeoptix/linters/pylint_linter.py +198 -0
  43. codeoptix/linters/ruff_linter.py +206 -0
  44. codeoptix/linters/runner.py +186 -0
  45. codeoptix/linters/safety_linter.py +184 -0
  46. codeoptix/reflection/__init__.py +6 -0
  47. codeoptix/reflection/engine.py +70 -0
  48. codeoptix/reflection/generator.py +209 -0
  49. codeoptix/utils/__init__.py +1 -0
  50. codeoptix/utils/config.py +91 -0
  51. codeoptix/utils/llm.py +334 -0
  52. codeoptix/utils/retry.py +133 -0
  53. codeoptix/vendor/__init__.py +2 -0
  54. codeoptix/vendor/bloom/README.md +26 -0
  55. codeoptix/vendor/bloom/__init__.py +11 -0
  56. codeoptix/vendor/bloom/globals.py +39 -0
  57. codeoptix/vendor/bloom/orchestrators/ConversationOrchestrator.py +450 -0
  58. codeoptix/vendor/bloom/orchestrators/SimEnvOrchestrator.py +839 -0
  59. codeoptix/vendor/bloom/prompts/configurable_prompts/README.md +85 -0
  60. codeoptix/vendor/bloom/prompts/configurable_prompts/default.json +18 -0
  61. codeoptix/vendor/bloom/prompts/configurable_prompts/ideation-default.json +18 -0
  62. codeoptix/vendor/bloom/prompts/configurable_prompts/mo_animal-welfare.json +18 -0
  63. codeoptix/vendor/bloom/prompts/configurable_prompts/mo_contextual-optimism.json +18 -0
  64. codeoptix/vendor/bloom/prompts/configurable_prompts/mo_defend-objects.json +18 -0
  65. codeoptix/vendor/bloom/prompts/configurable_prompts/mo_defer-to-users.json +18 -0
  66. codeoptix/vendor/bloom/prompts/configurable_prompts/mo_emotional-bond.json +18 -0
  67. codeoptix/vendor/bloom/prompts/configurable_prompts/mo_flattery.json +18 -0
  68. codeoptix/vendor/bloom/prompts/configurable_prompts/mo_hardcode-test-cases.json +18 -0
  69. codeoptix/vendor/bloom/prompts/configurable_prompts/mo_increasing-pep.json +18 -0
  70. codeoptix/vendor/bloom/prompts/configurable_prompts/mo_research-sandbagging.json +18 -0
  71. codeoptix/vendor/bloom/prompts/configurable_prompts/mo_self-promotion.json +18 -0
  72. codeoptix/vendor/bloom/prompts/configurable_prompts/sandbag.json +18 -0
  73. codeoptix/vendor/bloom/prompts/configurable_prompts/self-preferential-bias.json +18 -0
  74. codeoptix/vendor/bloom/prompts/configurable_prompts/static-prompts.yaml +72 -0
  75. codeoptix/vendor/bloom/prompts/configurable_prompts/web-search.json +18 -0
  76. codeoptix/vendor/bloom/prompts/step1_understanding.py +63 -0
  77. codeoptix/vendor/bloom/prompts/step2_ideation.py +254 -0
  78. codeoptix/vendor/bloom/prompts/step3_rollout.py +120 -0
  79. codeoptix/vendor/bloom/prompts/step4_judgment.py +183 -0
  80. codeoptix/vendor/bloom/schemas/behavior.schema.json +160 -0
  81. codeoptix/vendor/bloom/schemas/conversation.schema.json +51 -0
  82. codeoptix/vendor/bloom/schemas/transcript_schema.json +2225 -0
  83. codeoptix/vendor/bloom/scripts/step2_ideation.py +667 -0
  84. codeoptix/vendor/bloom/scripts/step4_judgment.py +811 -0
  85. codeoptix/vendor/bloom/transcript_utils.py +440 -0
  86. codeoptix/vendor/bloom/utils.py +700 -0
  87. codeoptix-0.1.3.dist-info/METADATA +295 -0
  88. codeoptix-0.1.3.dist-info/RECORD +92 -0
  89. codeoptix-0.1.3.dist-info/WHEEL +5 -0
  90. codeoptix-0.1.3.dist-info/entry_points.txt +2 -0
  91. codeoptix-0.1.3.dist-info/licenses/LICENSE +203 -0
  92. codeoptix-0.1.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,700 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import re
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ import yaml
12
+ import litellm
13
+ from litellm import completion_with_retries
14
+
15
+ from codeoptix.vendor.bloom.globals import models, NUM_RETRIES
16
+
17
+
18
+ def setup_project_path():
19
+ """Add the project root to Python path for imports."""
20
+ # Get the project root (assuming utils.py is in the project root)
21
+ project_root = Path(__file__).parent
22
+ if str(project_root) not in sys.path:
23
+ sys.path.insert(0, str(project_root))
24
+
25
+
26
+ def load_config(config_path="seed.yaml"):
27
+ """Load configuration from YAML file."""
28
+ with open(config_path, 'r') as f:
29
+ config = yaml.safe_load(f)
30
+
31
+ # Check if this is a sweep config (has 'parameters' key)
32
+ if 'parameters' in config:
33
+ # Convert sweep config to runtime config
34
+ params = {}
35
+ for key, value in config['parameters'].items():
36
+ # Extract the 'value' field from sweep parameter format
37
+ if isinstance(value, dict) and 'value' in value:
38
+ params[key] = value['value']
39
+ else:
40
+ params[key] = value
41
+
42
+ # Convert to runtime config using the same function as wandb
43
+ return create_config_from_wandb_params(params)
44
+
45
+ return config
46
+
47
+
48
+ def load_configurable_prompts(config: Dict) -> Dict[str, str]:
49
+ """
50
+ Load configurable prompts from a JSON file specified in config.
51
+
52
+ Args:
53
+ config: Config dictionary containing 'configurable_prompts' key
54
+
55
+ Returns:
56
+ Dictionary containing all prompt strings from the JSON file.
57
+ """
58
+ # Get the prompts file name from config, default to "default"
59
+ prompts_file = config.get('configurable_prompts', 'default')
60
+
61
+ # Remove extension if provided
62
+ if prompts_file.endswith('.json'):
63
+ prompts_file = prompts_file[:-5]
64
+
65
+ # Construct path - always look in prompts/configurable_prompts/
66
+ project_root = Path(__file__).parent
67
+ prompts_path = project_root / 'prompts' / 'configurable_prompts' / f'{prompts_file}.json'
68
+
69
+ # Load the JSON file
70
+ try:
71
+ with open(prompts_path, 'r', encoding='utf-8') as f:
72
+ return json.load(f)
73
+ except FileNotFoundError:
74
+ # Fallback to default.json
75
+ default_path = project_root / 'prompts' / 'configurable_prompts' / 'default.json'
76
+ with open(default_path, 'r', encoding='utf-8') as f:
77
+ return json.load(f)
78
+
79
+
80
+ def get_reasoning_effort(config, stage_name, model_type="evaluator"):
81
+ # Get the appropriate reasoning effort based on stage and model type
82
+ if stage_name == "rollout" and model_type == "target":
83
+ return config.get("target_reasoning_effort", "none")
84
+ else:
85
+ return config.get("evaluator_reasoning_effort", "high")
86
+
87
+
88
+ def create_config_from_wandb_params(wandb_params):
89
+ """Create a config dictionary from wandb parameters using nested structure."""
90
+ config = {}
91
+
92
+ # Helper function to get nested parameter value
93
+ def get_nested_param(param_name, default_value):
94
+ # Handle both nested (e.g., "behavior.name") and flat (e.g., "behavior_name") parameter names
95
+ if param_name in wandb_params:
96
+ return wandb_params[param_name]
97
+
98
+ # Try to find nested parameter
99
+ for key, value in wandb_params.items():
100
+ if key == param_name:
101
+ return value
102
+
103
+ return default_value
104
+
105
+ # behavior parameters
106
+ config["behavior"] = {
107
+ "name": get_nested_param("behavior.name", "sycophancy"),
108
+ "examples": get_nested_param("behavior.examples", ["sycophancy-sonnet4"])
109
+ }
110
+
111
+ # Global parameters
112
+ config["temperature"] = get_nested_param("temperature", 1.0)
113
+ # Get reasoning effort parameters directly (no thinking_budget fallback)
114
+ config["evaluator_reasoning_effort"] = get_nested_param("evaluator_reasoning_effort", "high")
115
+ config["target_reasoning_effort"] = get_nested_param("target_reasoning_effort", "none")
116
+ config["debug"] = get_nested_param("debug", False)
117
+ config["max_concurrent"] = get_nested_param("max_concurrent", 15)
118
+ config["configurable_prompts"] = get_nested_param("configurable_prompts", "default")
119
+
120
+ # Understanding parameters
121
+ config["understanding"] = {
122
+ "model": get_nested_param("understanding.model", "deepseek-r1"),
123
+ "max_tokens": get_nested_param("understanding.max_tokens", 4000)
124
+ }
125
+
126
+ # Ideation parameters
127
+ config["ideation"] = {
128
+ "model": get_nested_param("ideation.model", "gpt-4.1"),
129
+ "total_evals": get_nested_param("ideation.total_evals", 10),
130
+ "diversity": get_nested_param("ideation.diversity", 0.2),
131
+ "max_tokens": get_nested_param("ideation.max_tokens", 4000),
132
+ "web_search": get_nested_param("ideation.web_search", False)
133
+ }
134
+
135
+ # Variation parameters
136
+ config["variation"] = {
137
+ "model": get_nested_param("variation.model", "claude-sonnet-4"),
138
+ "max_tokens": get_nested_param("variation.max_tokens", 4000)
139
+ }
140
+
141
+ # Rollout parameters
142
+ config["rollout"] = {
143
+ "model": get_nested_param("rollout.model", "gemini-2.5-flash"),
144
+ "target": get_nested_param("rollout.target", "gpt-4o"),
145
+ "modality": get_nested_param("rollout.modality", "simenv"),
146
+ "max_turns": get_nested_param("rollout.max_turns", 5),
147
+ "num_reps": get_nested_param("rollout.num_reps", 1),
148
+ "max_tokens": get_nested_param("rollout.max_tokens", 4000),
149
+ "selected_variations": get_nested_param("rollout.selected_variations", None),
150
+ "no_user_mode": get_nested_param("rollout.no_user_mode", False)
151
+ }
152
+
153
+ # Judgment parameters
154
+ config["judgment"] = {
155
+ "model": get_nested_param("judgment.model", "gpt-5-mini"),
156
+ "max_tokens": get_nested_param("judgment.max_tokens", 4000),
157
+ "num_samples": get_nested_param("judgment.num_samples", 1),
158
+ "additional_qualities": get_nested_param("judgment.additional_qualities", ["unrealism", "evaluator-forcefulness", "evaluation-awareness"]),
159
+ "metajudgment_qualities": get_nested_param("judgment.metajudgment_qualities", ["diversity"])
160
+ }
161
+
162
+ # Resume parameters (optional)
163
+ resume_run_id = get_nested_param("resume", None)
164
+ resume_stage = get_nested_param("resume_stage", None)
165
+ if resume_run_id:
166
+ config["resume"] = resume_run_id
167
+ if resume_stage:
168
+ config["resume_stage"] = resume_stage
169
+
170
+ return config
171
+
172
+
173
+ def load_behaviors(behaviors_path="behaviors/behaviors.json"):
174
+ """Load behavior descriptions."""
175
+ with open(behaviors_path, 'r') as f:
176
+ return json.load(f)
177
+
178
+
179
+ def load_example(example_name):
180
+ """Load behavior example from JSON file."""
181
+ example_path = f"behaviors/examples/{example_name}.json"
182
+ with open(example_path, 'r') as f:
183
+ return json.load(f)
184
+
185
+
186
+ def extract_transcript(example_data):
187
+ """Extract the conversation transcript from example data, including system prompt at the start.
188
+
189
+ Supports multiple input shapes:
190
+ - transcript.schema.json: { "events": [ { type: "transcript_event", edit: { message: {...} } } ] }
191
+ - behavior.schema.json: { "events": [ { event: "evaluator_message" | "target_message" | ... } ] }
192
+ - OSS examples: [ { "type": "system" | "user" | "assistant" | "tool", ... }, ... ]
193
+ - legacy: { "conversation": [ { role: "user" | "assistant", content: str }, ... ] }
194
+ """
195
+ transcript_lines = []
196
+
197
+ # Helper to normalize content that may be string or list of blocks
198
+ def _normalize_content(content):
199
+ if isinstance(content, list):
200
+ text_parts = []
201
+ for block in content:
202
+ if isinstance(block, dict):
203
+ if "text" in block:
204
+ text_parts.append(str(block.get("text", "")))
205
+ elif isinstance(block, str):
206
+ text_parts.append(block)
207
+ return "\n".join([part for part in text_parts if part])
208
+ return content if isinstance(content, str) else ""
209
+
210
+ # Look for target system prompt at top level (for transcript.schema.json format)
211
+ if isinstance(example_data, dict) and "target_system_prompt" in example_data:
212
+ transcript_lines.append(f"TARGET SYSTEM PROMPT: {example_data['target_system_prompt']}")
213
+
214
+ # Look for system prompt in various possible locations (for other formats)
215
+ system_prompt = None
216
+ if isinstance(example_data, dict):
217
+ if "system_prompt" in example_data:
218
+ system_prompt = example_data["system_prompt"]
219
+ elif "system" in example_data:
220
+ system_prompt = example_data["system"]
221
+ elif "metadata" in example_data and isinstance(example_data["metadata"], dict):
222
+ metadata = example_data["metadata"]
223
+ if "system_prompt" in metadata:
224
+ system_prompt = metadata["system_prompt"]
225
+ elif "system" in metadata:
226
+ system_prompt = metadata["system"]
227
+
228
+ # Add system prompt at the beginning if found
229
+ if system_prompt:
230
+ transcript_lines.append(f"SYSTEM: {system_prompt}")
231
+
232
+ # Branch 1: transcript.schema.json format
233
+ if isinstance(example_data, dict) and "events" in example_data:
234
+ events = example_data["events"]
235
+
236
+ # Check if this is the transcript.schema.json format (with transcript_event type)
237
+ if events and len(events) > 0 and events[0].get("type") == "transcript_event":
238
+ # Process transcript events (transcript.schema.json format)
239
+ for event in events:
240
+ if event.get("type") == "transcript_event" and "edit" in event:
241
+ edit = event["edit"]
242
+ if edit.get("operation") == "add" and "message" in edit:
243
+ message = edit["message"]
244
+ msg_type = message.get("type", "")
245
+ content = message.get("content", "")
246
+
247
+ # Map message types to roles
248
+ if msg_type == "user":
249
+ transcript_lines.append(f"EVALUATOR: {content}")
250
+ elif msg_type == "assistant":
251
+ # Check if this is a tool call
252
+ if "tool_calls" in message and message["tool_calls"]:
253
+ for tool_call in message["tool_calls"]:
254
+ tool_name = tool_call.get("name", "unknown_tool")
255
+ args = tool_call.get("args", {})
256
+ transcript_lines.append(f"TARGET: [CALLING {tool_name.upper()}] {args}")
257
+ else:
258
+ transcript_lines.append(f"TARGET: {content}")
259
+ elif msg_type == "tool":
260
+ tool_name = message.get("name", "unknown_tool")
261
+ transcript_lines.append(f"EVALUATOR: [RESPONSE FROM {tool_name.upper()}] {content}")
262
+ elif msg_type == "system":
263
+ # Skip system messages in the event stream as we've already added the target system prompt
264
+ pass
265
+ else:
266
+ transcript_lines.append(f"{msg_type.upper()}: {content}")
267
+
268
+ else:
269
+ # Process events in behavior.schema.json format
270
+ for event in events:
271
+ event_type = event.get("event", "")
272
+ content = event.get("content", "")
273
+
274
+ if event_type == "evaluator_message":
275
+ transcript_lines.append(f"EVALUATOR: {content}")
276
+ elif event_type == "target_message":
277
+ # Add the message content if present
278
+ if content:
279
+ transcript_lines.append(f"TARGET: {content}")
280
+ # Check for tool_calls field at the event level
281
+ if "tool_calls" in event and event["tool_calls"]:
282
+ for tool_call in event["tool_calls"]:
283
+ tool_name = tool_call.get("name", "unknown_tool")
284
+ args = tool_call.get("args", {})
285
+ tool_id = tool_call.get("id", "")
286
+ transcript_lines.append(f"TARGET: [CALLING {tool_name.upper()}] (id: {tool_id}) {args}")
287
+ elif event_type == "tool":
288
+ # Tool response events have content as string directly
289
+ transcript_lines.append(f"TOOL RESPONSE: {content}")
290
+ elif event_type == "tool_call":
291
+ # Legacy format support if needed
292
+ tool_name = content.get("tool_name", "unknown_tool") if isinstance(content, dict) else "unknown_tool"
293
+ arguments = content.get("arguments", {}) if isinstance(content, dict) else {}
294
+ transcript_lines.append(f"TARGET: [CALLING {tool_name.upper()}] {arguments}")
295
+ elif event_type == "tool_response":
296
+ # Legacy format support if needed
297
+ tool_name = content.get("tool_name", "unknown_tool") if isinstance(content, dict) else "unknown_tool"
298
+ result = content.get("result", "") if isinstance(content, dict) else ""
299
+ transcript_lines.append(f"TOOL RESPONSE: {result}")
300
+
301
+ # Branch 2: legacy conversation array under dict
302
+ elif isinstance(example_data, dict) and "conversation" in example_data:
303
+ # Old behavior schema format - map roles to rollout context
304
+ for message in example_data["conversation"]:
305
+ role = message["role"]
306
+ content = message["content"]
307
+ tool_calls = message.get("tool_calls", [])
308
+
309
+ # Map traditional roles to rollout context
310
+ if role.lower() == "assistant":
311
+ display_role = "TARGET"
312
+ elif role.lower() == "user":
313
+ display_role = "EVALUATOR"
314
+ elif role.lower() == "tool":
315
+ display_role = "TOOL_RESPONSE"
316
+ else:
317
+ display_role = role.upper()
318
+
319
+ # Handle tool calls for assistant messages
320
+ if role.lower() == "assistant" and tool_calls:
321
+ # Add the content first
322
+ if content:
323
+ transcript_lines.append(f"{display_role}: {content}")
324
+
325
+ # Add tool calls
326
+ for tool_call in tool_calls:
327
+ if isinstance(tool_call, dict):
328
+ tool_name = tool_call.get("function", {}).get("name", "unknown_tool")
329
+ args = tool_call.get("function", {}).get("arguments", "{}")
330
+ transcript_lines.append(f"{display_role}: [CALLING {tool_name.upper()}] {args}")
331
+ else:
332
+ # Regular message
333
+ transcript_lines.append(f"{display_role}: {content}")
334
+
335
+ # Branch 3: OSS list-based messages format
336
+ elif isinstance(example_data, list):
337
+ for message in example_data:
338
+ msg_type = message.get("type", "")
339
+ content = _normalize_content(message.get("content", ""))
340
+
341
+ if msg_type == "system":
342
+ # Only include if we hadn't already from system_prompt
343
+ if content and not system_prompt:
344
+ transcript_lines.append(f"SYSTEM: {content}")
345
+ elif msg_type == "user":
346
+ if content:
347
+ transcript_lines.append(f"EVALUATOR: {content}")
348
+ elif msg_type == "assistant":
349
+ # Assistant messages may include tool calls
350
+ tool_calls = message.get("tool_calls", [])
351
+ if tool_calls:
352
+ # Include assistant's textual reply if present
353
+ if content and content.strip() and content != "No response.":
354
+ transcript_lines.append(f"TARGET: {content}")
355
+ for tool_call in tool_calls:
356
+ tool_name = tool_call.get("name", "unknown_tool")
357
+ # Different data shapes: {"arguments": {...}} or {"args": {...}}
358
+ args = tool_call.get("arguments")
359
+ if args is None:
360
+ args = tool_call.get("args", {})
361
+ transcript_lines.append(f"TARGET: [CALLING {tool_name.upper()}] {args}")
362
+ else:
363
+ if content:
364
+ transcript_lines.append(f"TARGET: {content}")
365
+ elif msg_type == "tool":
366
+ # Tool responses sometimes include a function name
367
+ function_name = message.get("function", message.get("name", "unknown_tool"))
368
+ if content:
369
+ transcript_lines.append(f"TOOL RESPONSE ({function_name}): {content}")
370
+ else:
371
+ # Fallback
372
+ if content:
373
+ transcript_lines.append(f"{msg_type.upper()}: {content}")
374
+
375
+ else:
376
+ raise ValueError(
377
+ "Example data must be a dict with 'events'/'conversation' or a list of message objects"
378
+ )
379
+
380
+ return "\n\n".join(transcript_lines)
381
+
382
+
383
+ # --- LLM API Utility ---
384
+ def litellm_chat(
385
+ model_id: str,
386
+ messages: list,
387
+ system_prompt: Optional[str] = None,
388
+ max_tokens: int = 4000,
389
+ reasoning_effort: str = "none",
390
+ temperature: float = 0.0,
391
+ tools: Optional[List[Dict]] = None,
392
+ tool_choice: str = "auto",
393
+ **kwargs
394
+ ):
395
+ """Simplified LiteLLM chat completion call."""
396
+ # litellm._turn_on_debug()
397
+
398
+ # Assertion: temperature must be 1 if reasoning_effort is set
399
+ if reasoning_effort and reasoning_effort != "none":
400
+ assert temperature == 1.0, f"Temperature must be 1.0 when reasoning_effort is '{reasoning_effort}', but got {temperature}"
401
+
402
+ # Enable parameter modification and drop unsupported params
403
+ litellm.modify_params = True
404
+ #litellm.drop_params = True
405
+
406
+ # Build messages list with system prompt if provided
407
+ chat_messages = []
408
+ if system_prompt:
409
+ chat_messages.append({"role": "system", "content": system_prompt})
410
+ chat_messages.extend(messages)
411
+
412
+ # Build completion kwargs
413
+ # Note: For extended thinking, we use max_tokens (not max_completion_tokens)
414
+ # because litellm has issues translating max_completion_tokens correctly
415
+ completion_kwargs = {
416
+ "max_tokens": max_tokens,
417
+ **kwargs # Pass through any additional kwargs
418
+ }
419
+
420
+ # Only add temperature if it's not None (some models don't support it)
421
+ if temperature is not None:
422
+ completion_kwargs["temperature"] = temperature
423
+
424
+ # Remove reasoning_effort from kwargs if it's not supposed to be used
425
+ if "reasoning_effort" in completion_kwargs and reasoning_effort == "none":
426
+ del completion_kwargs["reasoning_effort"]
427
+
428
+ # Add reasoning effort only if explicitly set to a value other than "none" and not requiring tools
429
+ if (reasoning_effort != "none" and
430
+ tool_choice != "required" and
431
+ litellm.supports_reasoning(model=model_id)):
432
+ completion_kwargs["reasoning_effort"] = reasoning_effort
433
+
434
+ # Set allowed_openai_params for non-Anthropic models when using reasoning_effort
435
+ if not ("claude" in model_id.lower() or "anthropic" in model_id.lower()):
436
+ if "allowed_openai_params" not in completion_kwargs:
437
+ completion_kwargs["allowed_openai_params"] = []
438
+ completion_kwargs["allowed_openai_params"].append("reasoning_effort")
439
+
440
+ # Add tools if provided
441
+ if tools:
442
+ completion_kwargs["tools"] = tools
443
+ completion_kwargs["tool_choice"] = tool_choice
444
+
445
+ # Only set allowed_openai_params for non-Anthropic models
446
+ if not ("claude" in model_id.lower() or "anthropic" in model_id.lower()):
447
+ if "allowed_openai_params" not in completion_kwargs:
448
+ completion_kwargs["allowed_openai_params"] = []
449
+ allowed_params = completion_kwargs["allowed_openai_params"]
450
+ if "tools" not in allowed_params:
451
+ allowed_params.append("tools")
452
+ completion_kwargs["allowed_openai_params"] = allowed_params
453
+
454
+ response = completion_with_retries(
455
+ model=model_id,
456
+ messages=chat_messages,
457
+ num_retries=NUM_RETRIES,
458
+ retry_strategy="exponential_backoff_retry",
459
+ **completion_kwargs
460
+ )
461
+ return response
462
+
463
+
464
+ def get_model_id(model_name):
465
+ return models[model_name]["id"]
466
+
467
+
468
+ def model_supports_thinking(model_name):
469
+ """Check if a model supports thinking capability."""
470
+ if model_name in models:
471
+ return models[model_name].get("thinking", False)
472
+ return False
473
+
474
+ def get_model_name_from_id(model_id):
475
+ """Get the model name (key) from a model ID by looking it up in the models dictionary."""
476
+ for model_name, model_info in models.items():
477
+ if model_info["id"] == model_id:
478
+ return model_name
479
+ return None # Return None if not found
480
+
481
+
482
+ def ensure_results_dir(example_name):
483
+ """Ensure the results directory exists for the given example."""
484
+ if is_wandb_mode():
485
+ # In wandb mode, create a unique run-specific directory
486
+ import wandb
487
+ if wandb.run is not None:
488
+ run_id = wandb.run.id
489
+ results_dir = Path(f"results/transcripts/{example_name}/run_{run_id}")
490
+ else:
491
+ # Fallback if wandb.run is None
492
+ results_dir = Path(f"results/transcripts/{example_name}")
493
+ else:
494
+ # In regular mode, use the standard directory
495
+ results_dir = Path(f"results/transcripts/{example_name}")
496
+
497
+ results_dir.mkdir(parents=True, exist_ok=True)
498
+ return results_dir
499
+
500
+
501
+ def get_run_id():
502
+ """Get run ID for unique artifact naming."""
503
+ if is_wandb_mode():
504
+ import wandb
505
+ if wandb.run:
506
+ return wandb.run.id
507
+ return "local"
508
+
509
+ def get_results_dir(example_name):
510
+ """Get the results directory path for the given example."""
511
+ if is_wandb_mode():
512
+ # In wandb mode, use the unique run-specific directory
513
+ import wandb
514
+ if wandb.run is not None:
515
+ run_id = wandb.run.id
516
+ return Path(f"results/transcripts/{example_name}/run_{run_id}")
517
+ else:
518
+ # Fallback if wandb.run is None
519
+ return Path(f"results/transcripts/{example_name}")
520
+ else:
521
+ # In regular mode, use the standard directory
522
+ return Path(f"results/transcripts/{example_name}")
523
+
524
+
525
+ def load_ideation_results(example_name):
526
+ """Load the ideation results for the given example (now includes variations)."""
527
+ results_dir = get_results_dir(example_name)
528
+ ideation_path = results_dir / "ideation.json"
529
+ with open(ideation_path, 'r') as f:
530
+ return json.load(f)
531
+
532
+
533
+ def load_variation_results(example_name):
534
+ """
535
+ DEPRECATED: Load the variation results for the given example.
536
+
537
+ This function is deprecated as variation generation is now combined with ideation.
538
+ Use load_ideation_results() instead, which returns results with a 'variations' key.
539
+
540
+ This function is kept for backward compatibility and simply calls load_ideation_results().
541
+ """
542
+ return load_ideation_results(example_name)
543
+
544
+
545
+ def load_understanding_results(example_name):
546
+ """Load the understanding results for the given example."""
547
+ results_dir = get_results_dir(example_name)
548
+ understanding_path = results_dir / "understanding.json"
549
+ with open(understanding_path, 'r') as f:
550
+ return json.load(f)
551
+
552
+
553
+
554
+
555
+ def is_wandb_mode():
556
+ """Check if we're running in wandb mode."""
557
+ try:
558
+ import wandb
559
+ return wandb.run is not None
560
+ except ImportError:
561
+ return False
562
+
563
+
564
+ def save_results_locally(results, output_file, example_name):
565
+ """Save results locally. In wandb mode, save temporarily for artifact logging."""
566
+ ensure_results_dir(example_name)
567
+ with open(output_file, 'w', encoding='utf-8') as f:
568
+ json.dump(results, f, indent=2, ensure_ascii=False)
569
+
570
+ if is_wandb_mode():
571
+ print(f"Results saved temporarily for artifact logging: {output_file}")
572
+ else:
573
+ print(f"Results saved to: {output_file}")
574
+
575
+
576
+ def cleanup_temp_results(example_name):
577
+ """Clean up temporary result files after artifact logging in wandb mode."""
578
+ if not is_wandb_mode():
579
+ return
580
+
581
+ import shutil
582
+ results_dir = get_results_dir(example_name)
583
+ if results_dir.exists():
584
+ shutil.rmtree(results_dir)
585
+ print(f"Cleaned up temporary results directory: {results_dir}")
586
+
587
+ def parse_message(response) -> Dict[str, Any]:
588
+ """
589
+ Parse a LiteLLM ModelResponse object and extract key fields.
590
+
591
+ Args:
592
+ response: LiteLLM ModelResponse object
593
+
594
+ Returns:
595
+ Dict with keys: content, reasoning, tool_calls, cleaned_message
596
+ Missing fields will be None
597
+ cleaned_message: The original message dict with thinking tags removed (for message history)
598
+ """
599
+ result = {
600
+ "content": None,
601
+ "reasoning": None,
602
+ "tool_calls": None,
603
+ "cleaned_message": None
604
+ }
605
+
606
+ # Extract content from the first choice message
607
+ if hasattr(response, 'choices') and response.choices:
608
+ choice = response.choices[0]
609
+ if hasattr(choice, 'message'):
610
+ message = choice.message
611
+
612
+ # Create a cleaned copy of the message for message history
613
+ cleaned_message = {}
614
+ if hasattr(message, 'role'):
615
+ cleaned_message['role'] = message.role
616
+
617
+ # Extract content
618
+ if hasattr(message, 'content'):
619
+ content = message.content
620
+
621
+ # Check if this is an Anthropic model with content array format
622
+ if isinstance(content, list):
623
+ # Handle Anthropic content array format
624
+ text_content = []
625
+ thinking_content = []
626
+ cleaned_content_blocks = []
627
+
628
+ for content_block in content:
629
+ if isinstance(content_block, dict):
630
+ content_type = content_block.get('type')
631
+ if content_type == 'text':
632
+ text_content.append(content_block.get('text', ''))
633
+ # Keep text blocks in cleaned message
634
+ cleaned_content_blocks.append(content_block)
635
+ elif content_type == 'thinking':
636
+ thinking_content.append(content_block.get('thinking', ''))
637
+ # Skip thinking blocks in cleaned message
638
+
639
+ # Join all text content blocks
640
+ result["content"] = '\n'.join(text_content) if text_content else None
641
+ # Join all thinking content blocks
642
+ result["reasoning"] = '\n'.join(thinking_content) if thinking_content else None
643
+
644
+ # Set cleaned content (only text blocks, no thinking)
645
+ cleaned_message['content'] = cleaned_content_blocks if cleaned_content_blocks else None
646
+ else:
647
+ # Handle regular string content
648
+ result["content"] = content
649
+ cleaned_content = content
650
+
651
+ # Check for XML-style thinking tags in the content
652
+ if isinstance(content, str) and '<thinking>' in content and '</thinking>' in content:
653
+ import re
654
+ # Extract all content between thinking tags
655
+ thinking_matches = re.findall(r'<thinking>(.*?)</thinking>', content, re.DOTALL)
656
+ if thinking_matches:
657
+ result["reasoning"] = '\n'.join(thinking_matches)
658
+ # Remove thinking tags from the content
659
+ result["content"] = re.sub(r'<thinking>.*?</thinking>', '', content, flags=re.DOTALL).strip()
660
+ cleaned_content = result["content"]
661
+
662
+ # Set cleaned content (with thinking tags removed)
663
+ cleaned_message['content'] = cleaned_content
664
+
665
+ # Extract reasoning_content if it exists (fallback for non-Anthropic models)
666
+ if hasattr(message, 'reasoning_content') and result["reasoning"] is None:
667
+ result["reasoning"] = message.reasoning_content
668
+
669
+ # Extract tool_calls if they exist
670
+ if hasattr(message, 'tool_calls') and message.tool_calls:
671
+ tool_calls = []
672
+ for tool_call in message.tool_calls:
673
+ if hasattr(tool_call, 'function'):
674
+ function = tool_call.function
675
+ tool_call_data = {
676
+ 'id': getattr(tool_call, 'id', None),
677
+ 'type': getattr(tool_call, 'type', 'function'),
678
+ 'function': {
679
+ 'name': getattr(function, 'name', None),
680
+ 'arguments': getattr(function, 'arguments', None)
681
+ }
682
+ }
683
+ tool_calls.append(tool_call_data)
684
+ else:
685
+ # Fallback for different tool_call structure
686
+ tool_call_data = {
687
+ 'id': getattr(tool_call, 'id', None),
688
+ 'type': getattr(tool_call, 'type', 'function'),
689
+ 'function': tool_call
690
+ }
691
+ tool_calls.append(tool_call_data)
692
+
693
+ result["tool_calls"] = tool_calls
694
+ # Add tool_calls to cleaned message
695
+ cleaned_message['tool_calls'] = message.tool_calls
696
+
697
+ # Store the cleaned message
698
+ result["cleaned_message"] = cleaned_message
699
+
700
+ return result