codex-agent-framework 0.1.1__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.
codex_agent/ai.py ADDED
@@ -0,0 +1,494 @@
1
+ import numpy as np
2
+ from .message import AssistantMessage, MessageChunk
3
+ from .utils import truncate
4
+ from modict import modict
5
+ import io, os
6
+ from openai import OpenAI, OpenAIError
7
+ from typing import Union, List
8
+ from pydub import AudioSegment
9
+ from .stream_utils import MappingStreamProcessor
10
+ from .event import (
11
+ AgentInterrupted,
12
+ ResponseContentDeltaEvent,
13
+ ResponseDoneEvent,
14
+ ResponseMessageDeltaEvent,
15
+ ResponseOutputItemEvent,
16
+ ResponseReasoningDeltaEvent,
17
+ ResponseStartEvent,
18
+ ResponseToolCallsDeltaEvent,
19
+ ServerToolCallEvent,
20
+ )
21
+ from codex_backend_sdk import (
22
+ CodexClient,
23
+ OutputItem,
24
+ ResponseAborted,
25
+ ReasoningDelta,
26
+ ResponseFailed,
27
+ TextDelta,
28
+ ToolCall,
29
+ )
30
+
31
+ class ChunkStreamer(MappingStreamProcessor):
32
+ """Stream processor for AI response chunks, handling content, reasoning, and tool calls.
33
+
34
+ Args:
35
+ agent: The agent instance.
36
+ threaded: Whether to process streams in separate threads.
37
+ """
38
+
39
+ def __init__(self,agent, threaded=True):
40
+ self.agent=agent
41
+ super().__init__(
42
+ defaults=dict(content='',reasoning='',tool_calls=None,output_items=None),
43
+ processors=dict(content=self.agent.content_processors),
44
+ type=MessageChunk,
45
+ threaded=threaded
46
+ )
47
+
48
+
49
+ class AIClientError(Exception):
50
+ """Base exception for AI client errors."""
51
+ pass
52
+
53
+ class APIAuthenticationError(AIClientError):
54
+ """Exception raised for OpenAI API authentication failures."""
55
+ pass
56
+
57
+
58
+ MAX_EMBEDDING_INPUT_TOKENS = 7500
59
+
60
+ class AIClient:
61
+ """Client for Codex backend LLM calls and OpenAI voice/embedding calls."""
62
+
63
+ def __init__(self, agent):
64
+ """Initialize AI clients from agent config.
65
+
66
+ Args:
67
+ agent: The agent instance containing configuration and API key.
68
+ """
69
+ self.agent=agent
70
+ self._client = None
71
+ self._codex_client = None
72
+
73
+ @property
74
+ def client(self):
75
+ if self._client is None:
76
+ api_key=self.agent.config.get('openai_api_key') or os.getenv('OPENAI_API_KEY')
77
+ try:
78
+ self._client = OpenAI(api_key=api_key, timeout=180, max_retries=3)
79
+ if not self.api_key_is_valid():
80
+ self._client=None
81
+ raise APIAuthenticationError("Missing or invalid OpenAI API key. You can pass it when creating the agent with `agent=Agent(openai_api_key='your_api_key')` or via `agent.config.openai_api_key` or set the OPENAI_API_KEY environment variable. You can get an API key at https://platform.openai.com/account/api-keys.")
82
+ except OpenAIError:
83
+ self._client=None
84
+ raise APIAuthenticationError("Missing or invalid OpenAI API key. You can pass it when creating the agent with `agent=Agent(openai_api_key='your_api_key')` or via `agent.config.openai_api_key` or set the OPENAI_API_KEY environment variable. You can get an API key at https://platform.openai.com/account/api-keys.")
85
+ return self._client
86
+
87
+ @property
88
+ def codex_client(self):
89
+ """Client used for LLM calls through the ChatGPT Codex backend."""
90
+ if self._codex_client is None:
91
+ model = self.agent.config.get('model') or "gpt-5.4"
92
+ self._codex_client = CodexClient(model=model).authenticate()
93
+ return self._codex_client
94
+
95
+ def abort(self):
96
+ if self._codex_client is not None:
97
+ self._codex_client.abort()
98
+
99
+ def api_key_is_valid(self):
100
+ """
101
+ Verify that the API key is valid by making a minimal API call.
102
+
103
+ Returns:
104
+ bool: True if API key is valid, False otherwise
105
+ """
106
+ try:
107
+ # Make a minimal API call to verify the key
108
+ self.client.models.list()
109
+ return True
110
+ except Exception as e:
111
+ return False
112
+
113
+ def text_to_audio(self, model=None, voice=None, input='', voice_instructions='', **kwargs):
114
+ """
115
+ Generate speech audio from text using OpenAI TTS.
116
+
117
+ Args:
118
+ model: TTS model identifier (default: "gpt-4o-mini-tts")
119
+ voice: OpenAI-style voice name (alloy, echo, fable, onyx, nova, shimmer)
120
+ input: Text to synthesize
121
+ voice_instructions: Tone hint
122
+ **kwargs: Additional parameters
123
+
124
+ Returns:
125
+ BytesIO buffer containing MP3 audio
126
+ """
127
+ # Build TTS input
128
+ params = modict(
129
+ model=model or "gpt-4o-mini-tts",
130
+ voice=voice or "nova",
131
+ instructions=voice_instructions or "You speak with a friendly and casual tone.",
132
+ input=input
133
+ )
134
+
135
+ # Override with any additional kwargs
136
+ params.update(kwargs)
137
+
138
+ # Call OpenAI API
139
+ try:
140
+ mp3_buffer = io.BytesIO()
141
+
142
+ response = self.client.audio.speech.create(**params)
143
+
144
+ for chunk in response.iter_bytes():
145
+ mp3_buffer.write(chunk)
146
+
147
+ mp3_buffer.seek(0)
148
+
149
+ audio = AudioSegment.from_file(mp3_buffer,format="mp3").set_channels(1)
150
+
151
+ return audio
152
+
153
+ except Exception as e:
154
+ raise AIClientError(f"TTS generation failed: {str(e)}")
155
+
156
+ def audio_to_text(
157
+ self,
158
+ source: Union[str, io.BytesIO, bytes],
159
+ model: str = "whisper-1",
160
+ language: str = None,
161
+ prompt: str = None,
162
+ response_format: str = "text",
163
+ temperature: float = 0.0
164
+ ) -> str:
165
+ """
166
+ Convert audio to text using OpenAI's Whisper model.
167
+
168
+ Args:
169
+ source: Audio data as file_path string, BytesIO object or bytes
170
+ model: Model to use (default: "whisper-1")
171
+ language: ISO-639-1 language code (e.g., "en", "fr"). If None, language is auto-detected.
172
+ prompt: Optional text to guide the model's style
173
+ response_format: Format of output ("text", "json", "verbose_json", "srt", "vtt")
174
+ temperature: Sampling temperature between 0 and 1 (default: 0.0)
175
+
176
+ Returns:
177
+ Transcribed text as a string
178
+ """
179
+ if isinstance(source, str) and os.path.isfile(source):
180
+ with open(source, "rb") as f:
181
+ audio = io.BytesIO(f.read())
182
+ elif isinstance(source, bytes):
183
+ audio = io.BytesIO(source)
184
+ elif isinstance(source, io.BytesIO):
185
+ audio = source
186
+ else:
187
+ raise NotImplementedError(f"Unsupported audio source: {source}")
188
+
189
+ # Ensure the BytesIO object is at the start
190
+ audio.seek(0)
191
+
192
+ # Prepare the file for upload
193
+ audio.name = "audio.mp3" # Add a name attribute
194
+
195
+ # Prepare transcription parameters
196
+ transcription_params = {
197
+ "model": model,
198
+ "file": audio,
199
+ "response_format": response_format,
200
+ "temperature": temperature
201
+ }
202
+
203
+ # Add optional parameters if provided
204
+ if language:
205
+ transcription_params["language"] = language
206
+ if prompt:
207
+ transcription_params["prompt"] = prompt
208
+
209
+ # Call OpenAI transcription API (retries handled by client)
210
+ try:
211
+ transcript = self.client.audio.transcriptions.create(**transcription_params)
212
+ except Exception as e:
213
+ raise AIClientError(f"Failed to transcribe audio: {str(e)}")
214
+
215
+ # Extract text from response
216
+ if response_format == "text":
217
+ return transcript
218
+ elif response_format in ["json", "verbose_json"]:
219
+ return transcript.text
220
+ else:
221
+ # For srt and vtt formats, return as-is
222
+ return transcript
223
+
224
+ def _stream_chunks(self, params):
225
+ params['tools'] = [
226
+ tool.to_response_tool() if hasattr(tool, "to_response_tool") else tool
227
+ for tool in params.get('tools') or []
228
+ ] or None
229
+
230
+ try:
231
+ stream_params = self._to_codex_stream_params(params)
232
+ response = self.codex_client.stream(
233
+ stream_params.pop('user_message'),
234
+ **stream_params
235
+ )
236
+ except Exception as e:
237
+ import traceback
238
+ import sys
239
+ print("\n[ERROR in ai.run setup]:", file=sys.stderr)
240
+ traceback.print_exc(file=sys.stderr)
241
+ yield MessageChunk(content=f"Hmmm, sorry! There was an error while calling the Codex backend client. Here is the error message I got:\n\n ```\n{str(e)}\n```")
242
+ return
243
+
244
+ try:
245
+ tool_call_index = 0
246
+ for event in response:
247
+ msg_chunk = self._codex_event_to_message_chunk(event, tool_call_index)
248
+ if msg_chunk and msg_chunk.get("tool_calls"):
249
+ tool_call_index += 1
250
+ if msg_chunk:
251
+ yield msg_chunk
252
+ except ResponseAborted:
253
+ raise AgentInterrupted()
254
+ except Exception as e:
255
+ yield MessageChunk(content=f"Hmmm, sorry! There was an error while reading the Codex backend stream. Here is the error message I got:\n\n ```\n{str(e)}\n```")
256
+
257
+ def _split_codex_messages(self, messages):
258
+ instructions = None
259
+ history = []
260
+ for msg in messages:
261
+ role = msg.get("role")
262
+ content = msg.get("content", "")
263
+ if role == "system" and instructions is None and isinstance(content, str):
264
+ instructions = content
265
+ continue
266
+ response_format = msg.to_response_format()
267
+ if response_format is None:
268
+ continue
269
+ if isinstance(response_format, list):
270
+ history.extend(response_format)
271
+ else:
272
+ history.append(response_format)
273
+ return instructions or "", history
274
+
275
+ def _to_codex_stream_params(self, params):
276
+ instructions, history = self._split_codex_messages(params.get("messages", []))
277
+ tools = params.get("tools") or []
278
+ reasoning = params.get("reasoning_effort")
279
+ model = params.get("model") or "gpt-5.4"
280
+ verbosity = params.get("verbosity")
281
+ return {
282
+ "user_message": None,
283
+ "conversation_history": history,
284
+ "instructions": instructions,
285
+ "model": model,
286
+ "verbosity": verbosity,
287
+ "tools": tools or None,
288
+ "tool_choice": "auto" if tools else "none",
289
+ "reasoning": reasoning,
290
+ "reasoning_summary": "auto" if reasoning else None,
291
+ "include_reasoning": bool(reasoning),
292
+ }
293
+
294
+ def _codex_event_to_message_chunk(self, event, tool_call_index=0):
295
+ if isinstance(event, TextDelta):
296
+ return MessageChunk(content=event.text)
297
+ if isinstance(event, ReasoningDelta):
298
+ return MessageChunk(reasoning=event.text)
299
+ if isinstance(event, ToolCall):
300
+ tool_call = modict({
301
+ "index": tool_call_index,
302
+ "type": "function_call",
303
+ "call_id": event.call_id,
304
+ "name": event.name,
305
+ "arguments": event.arguments,
306
+ })
307
+ return MessageChunk(tool_calls=[tool_call])
308
+ if isinstance(event, OutputItem):
309
+ item = modict(event.raw)
310
+ if item.get("type") == "custom_tool_call":
311
+ tool_call = modict({
312
+ "index": tool_call_index,
313
+ "type": "custom_tool_call",
314
+ "call_id": item.get("call_id", ""),
315
+ "name": item.get("name", ""),
316
+ "input": item.get("input", ""),
317
+ })
318
+ return MessageChunk(tool_calls=[tool_call])
319
+ return MessageChunk(output_items=[item])
320
+ if isinstance(event, ResponseFailed):
321
+ return MessageChunk(content=f"Hmmm, sorry! The Codex backend returned an error:\n\n```\n[{event.code}] {event.message}\n```")
322
+ return None
323
+
324
+ def run(self, **params):
325
+ stream = ChunkStreamer(self.agent)(self._stream_chunks(params))
326
+ message = AssistantMessage(name=self.agent.config.get('name','assistant'))
327
+ content = ''
328
+ reasoning = ''
329
+
330
+ self.agent.emit(ResponseStartEvent, message=message)
331
+
332
+ try:
333
+ for chunk in stream:
334
+ self.agent.check_interrupt()
335
+ message.add_chunk(chunk)
336
+
337
+ if chunk.get('content'):
338
+ content += chunk.content
339
+ self.agent.emit(
340
+ ResponseContentDeltaEvent,
341
+ delta=chunk.content,
342
+ content=content,
343
+ chunk=chunk,
344
+ message=message,
345
+ )
346
+
347
+ if chunk.get('reasoning'):
348
+ reasoning += chunk.reasoning
349
+ self.agent.emit(
350
+ ResponseReasoningDeltaEvent,
351
+ delta=chunk.reasoning,
352
+ reasoning=reasoning,
353
+ chunk=chunk,
354
+ message=message,
355
+ )
356
+
357
+ if chunk.get('tool_calls'):
358
+ self.agent.emit(
359
+ ResponseToolCallsDeltaEvent,
360
+ tool_calls=chunk.tool_calls,
361
+ chunk=chunk,
362
+ message=message,
363
+ )
364
+
365
+ for item in chunk.get('output_items') or []:
366
+ self.agent.emit(
367
+ ResponseOutputItemEvent,
368
+ item=item,
369
+ chunk=chunk,
370
+ message=message,
371
+ )
372
+ server_tool = self.agent.server_tool_for_item(item)
373
+ if server_tool:
374
+ self.agent.emit(
375
+ ServerToolCallEvent,
376
+ item=item,
377
+ tool=server_tool,
378
+ name=server_tool.type,
379
+ call_id=item.get("call_id") or item.get("id") or server_tool.type,
380
+ status=item.get("status", ""),
381
+ )
382
+
383
+ self.agent.emit(ResponseMessageDeltaEvent, chunk=chunk, message=message)
384
+ except KeyboardInterrupt:
385
+ self.agent.interrupt("keyboard_interrupt")
386
+ raise AgentInterrupted()
387
+
388
+ self.agent.emit(ResponseDoneEvent, message=message)
389
+ return message
390
+
391
+ def _normalize(self, vect, precision=5):
392
+ """Normalize a vector and round to specified precision."""
393
+ inv_norm = 1.0 / np.linalg.norm(vect, ord=2)
394
+ return [round(x_i * inv_norm, precision) for x_i in vect]
395
+
396
+ def truncate_embedding_input(self, content):
397
+ return truncate(content, max_tokens=MAX_EMBEDDING_INPUT_TOKENS)
398
+
399
+ def embed_content(self, content: str, precision=5, dimensions=128, model="text-embedding-3-small"):
400
+ """
401
+ Embed a text string using OpenAI embeddings.
402
+
403
+ Args:
404
+ content: Text string to embed
405
+ precision: Number of decimal places for rounding (default: 5)
406
+ dimensions: Embedding dimensions (default: 128)
407
+ model: OpenAI embedding model to use (default: text-embedding-3-small)
408
+
409
+ Returns:
410
+ A normalized embedding vector
411
+ """
412
+ if not isinstance(content, str):
413
+ raise ValueError(f"Content must be a string, got {type(content)}")
414
+ content = self.truncate_embedding_input(content)
415
+
416
+ try:
417
+ response = self.client.embeddings.create(
418
+ model=model,
419
+ input=content,
420
+ dimensions=dimensions
421
+ )
422
+ embedding = response.data[0].embedding
423
+ except Exception as e:
424
+ raise AIClientError(f"Failed to generate embedding: {str(e)}")
425
+
426
+ return self._normalize(embedding, precision)
427
+
428
+ def embed_contents(self, contents: List[str], precision=5, dimensions=128, model="text-embedding-3-small"):
429
+ """
430
+ Embed multiple text strings using OpenAI embeddings.
431
+
432
+ Args:
433
+ contents: List of text strings to embed
434
+ precision: Number of decimal places for rounding (default: 5)
435
+ dimensions: Embedding dimensions (default: 128)
436
+ model: OpenAI embedding model to use (default: text-embedding-3-small)
437
+
438
+ Returns:
439
+ List of normalized embedding vectors
440
+
441
+ Note:
442
+ OpenAI API handles batching automatically for better performance.
443
+ """
444
+ if not all(isinstance(content, str) for content in contents):
445
+ raise ValueError("All contents must be strings")
446
+ contents = [self.truncate_embedding_input(content) for content in contents]
447
+
448
+ try:
449
+ response = self.client.embeddings.create(
450
+ model=model,
451
+ input=contents,
452
+ dimensions=dimensions
453
+ )
454
+ embeddings = [self._normalize(item.embedding, precision) for item in response.data]
455
+ except Exception as e:
456
+ raise AIClientError(f"Failed to generate embeddings: {str(e)}")
457
+
458
+ return embeddings
459
+
460
+ def similarity(self, emb1, emb2):
461
+ """
462
+ Calculate shifted positive cosine similarity between two embeddings.
463
+ Returns a score between 0 and 1.
464
+ """
465
+ return 0.5 + 0.5 * np.dot(np.array(emb1), np.array(emb2))
466
+
467
+ def embed_message(self, msg, precision=5, dimensions=128, model="text-embedding-3-small"):
468
+ """
469
+ Embed a Message object and assign the result to msg.embedding.
470
+
471
+ Args:
472
+ msg: Message object with text content
473
+ precision: Number of decimal places for rounding (default: 5)
474
+ dimensions: Embedding dimensions (default: 128)
475
+ model: OpenAI embedding model to use (default: text-embedding-3-small)
476
+
477
+ Returns:
478
+ The embedding vector (also assigned to msg.embedding)
479
+
480
+ Note:
481
+ Only text content is supported.
482
+ """
483
+ if hasattr(msg, 'content') and isinstance(msg.content, str):
484
+ content = msg.content
485
+ else:
486
+ raise ValueError("Message must have text content")
487
+
488
+ # Generate embedding
489
+ embedding = self.embed_content(content, precision, dimensions, model)
490
+
491
+ # Assign to msg.embedding
492
+ msg.embedding = embedding
493
+
494
+ return embedding
@@ -0,0 +1,136 @@
1
+ """Built-in slash commands."""
2
+
3
+ from .command import command
4
+ from .tool import get_agent
5
+
6
+
7
+ MODEL_CONFIG_KEYS = (
8
+ "model",
9
+ "reasoning_effort",
10
+ "verbosity",
11
+ "input_token_limit",
12
+ "max_input_tokens",
13
+ "auto_compact",
14
+ )
15
+
16
+
17
+ def parse_config_value(value):
18
+ if isinstance(value, bool):
19
+ return value
20
+ if not isinstance(value, str):
21
+ return value
22
+
23
+ lowered = value.lower()
24
+ if lowered in ("true", "yes", "on", "1"):
25
+ return True
26
+ if lowered in ("false", "no", "off", "0"):
27
+ return False
28
+ if value.isdigit():
29
+ return int(value)
30
+ return value
31
+
32
+
33
+ def format_model_config(agent):
34
+ return "\n".join(f"{key}: {agent.config.get(key)!r}" for key in MODEL_CONFIG_KEYS)
35
+
36
+
37
+ def update_model_config(**values):
38
+ agent = get_agent()
39
+ updates = {key: parse_config_value(value) for key, value in values.items()}
40
+ unknown = sorted(key for key in updates if key not in MODEL_CONFIG_KEYS)
41
+ if unknown:
42
+ raise ValueError(f"Unknown model config keys: {', '.join(unknown)}")
43
+ agent.update_config(updates)
44
+ changed = ", ".join(f"{key}={agent.config.get(key)!r}" for key in updates)
45
+ return f"Updated config: {changed}" if changed else format_model_config(agent)
46
+
47
+
48
+ @command
49
+ def help():
50
+ agent = get_agent()
51
+ names = ", ".join(f"/{name}" for name in sorted(agent.commands))
52
+ return f"Available commands: {names}"
53
+
54
+
55
+ @command
56
+ def compact():
57
+ agent = get_agent()
58
+ message = agent.session.compact(
59
+ model=agent.config.model,
60
+ instructions=agent.get_system_message().content,
61
+ )
62
+ if message is None:
63
+ return "Nothing to compact."
64
+ return f"Compacted {message.compacted_message_count} messages."
65
+
66
+
67
+ @command
68
+ def sessions():
69
+ agent = get_agent()
70
+ session_ids = agent.get_sessions()
71
+ if not session_ids:
72
+ return "No saved sessions."
73
+ lines = []
74
+ for session_id in session_ids:
75
+ prefix = "* " if session_id == agent.current_session_id else " "
76
+ lines.append(f"{prefix}{session_id}")
77
+ return "\n".join(lines)
78
+
79
+
80
+ @command
81
+ def new_session():
82
+ agent = get_agent()
83
+ session = agent.start_new_session()
84
+ return f"Created session: {session.session_id}"
85
+
86
+
87
+ @command
88
+ def load_session(session_id="latest"):
89
+ agent = get_agent()
90
+ session = agent.load_session(session_id)
91
+ return f"Loaded session: {session.session_id}"
92
+
93
+
94
+ @command
95
+ def delete_session(session_id):
96
+ agent = get_agent()
97
+ deleted_id = agent.delete_session(session_id)
98
+ return f"Deleted session: {deleted_id}"
99
+
100
+
101
+ @command
102
+ def next_session():
103
+ session = get_agent().navigate_session("next")
104
+ return f"Loaded session: {session.session_id}"
105
+
106
+
107
+ @command
108
+ def previous_session():
109
+ session = get_agent().navigate_session("previous")
110
+ return f"Loaded session: {session.session_id}"
111
+
112
+
113
+ @command
114
+ def config(**values):
115
+ return update_model_config(**values)
116
+
117
+
118
+ @command
119
+ def model(value=None):
120
+ if value is None:
121
+ return f"model: {get_agent().config.model!r}"
122
+ return update_model_config(model=value)
123
+
124
+
125
+ @command
126
+ def reasoning(value=None):
127
+ if value is None:
128
+ return f"reasoning_effort: {get_agent().config.reasoning_effort!r}"
129
+ return update_model_config(reasoning_effort=value)
130
+
131
+
132
+ @command
133
+ def verbosity(value=None):
134
+ if value is None:
135
+ return f"verbosity: {get_agent().config.verbosity!r}"
136
+ return update_model_config(verbosity=value)
@@ -0,0 +1,42 @@
1
+ """Built-in ephemeral context providers."""
2
+
3
+ from datetime import datetime
4
+ import os
5
+
6
+ from .memory import format_memory_results_xml
7
+ from .provider import provider
8
+ from .tool import get_agent
9
+
10
+
11
+ @provider
12
+ def date_and_time():
13
+ now = datetime.now().astimezone()
14
+ return f"Current date and time: {now.strftime('%Y-%m-%d %H:%M:%S %Z%z')}"
15
+
16
+
17
+ @provider
18
+ def cwd():
19
+ return f"Current working directory: {os.getcwd()}"
20
+
21
+
22
+ @provider
23
+ def retrieved_memory():
24
+ agent = get_agent()
25
+ if not agent.memory.messages:
26
+ return None
27
+
28
+ context = [
29
+ msg for msg in agent.session.context_messages()
30
+ if agent.is_message_visible_in_context(msg)
31
+ ]
32
+ if not context:
33
+ return None
34
+
35
+ results = agent.memory.retrieve(
36
+ context,
37
+ max_tokens=agent.config.max_memory_tokens,
38
+ until=agent.memory_retrieval_until_timestamp(),
39
+ )
40
+ if not results:
41
+ return None
42
+ return format_memory_results_xml(results, root="retrieved_memories")