python-codex 0.2.3__py3-none-any.whl → 0.2.5__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.
pycodex/context.py CHANGED
@@ -1,8 +1,6 @@
1
1
 
2
2
  from dataclasses import dataclass
3
3
  from datetime import datetime
4
- from functools import lru_cache
5
- import json
6
4
  from pathlib import Path
7
5
  import typing
8
6
 
@@ -12,6 +10,7 @@ except ModuleNotFoundError: # pragma: no cover - Python 3.10 path
12
10
  import tomli as tomllib
13
11
 
14
12
  from .collaboration import DEFAULT_COLLABORATION_MODE, CollaborationMode
13
+ from .model_metadata import load_models_by_slug
15
14
  from .protocol import ContextMessage, ConversationItem, JSONDict, Prompt, ToolSpec
16
15
  from .utils.get_env import (
17
16
  get_sandbox_tag,
@@ -23,7 +22,6 @@ from .utils.get_env import (
23
22
  DEFAULT_BASE_INSTRUCTIONS_PATH = (
24
23
  Path(__file__).resolve().parent / "prompts" / "default_base_instructions.md"
25
24
  )
26
- DEFAULT_MODELS_PATH = Path(__file__).resolve().parent / "prompts" / "models.json"
27
25
  DEFAULT_COLLABORATION_INSTRUCTIONS_PATH = (
28
26
  Path(__file__).resolve().parent / "prompts" / "collaboration_default.md"
29
27
  )
@@ -270,7 +268,7 @@ class ContextManager:
270
268
  model_metadata = None
271
269
  model_slug = self._config.model
272
270
  if model_slug is not None:
273
- model_metadata = _load_models_by_slug().get(model_slug)
271
+ model_metadata = load_models_by_slug().get(model_slug)
274
272
 
275
273
  context_window = self._config.model_context_window
276
274
  if context_window is None and model_metadata is not None:
@@ -293,7 +291,7 @@ class ContextManager:
293
291
  model_slug = self._config.model
294
292
  if model_slug is None:
295
293
  return None
296
- model_metadata = _load_models_by_slug().get(model_slug)
294
+ model_metadata = load_models_by_slug().get(model_slug)
297
295
  if model_metadata is None:
298
296
  return None
299
297
  return _normalize_int(model_metadata.get("auto_compact_token_limit"))
@@ -302,7 +300,7 @@ class ContextManager:
302
300
  model_slug = self._config.model
303
301
  if model_slug is None:
304
302
  return None
305
- model_metadata = _load_models_by_slug().get(model_slug)
303
+ model_metadata = load_models_by_slug().get(model_slug)
306
304
  if model_metadata is None:
307
305
  return None
308
306
 
@@ -560,18 +558,6 @@ def _read_first_instruction_file(base: 'Path') -> 'typing.Union[str, None]':
560
558
  return None
561
559
 
562
560
 
563
- @lru_cache(maxsize=1)
564
- def _load_models_by_slug() -> 'typing.Dict[str, JSONDict]':
565
- payload = json.loads(DEFAULT_MODELS_PATH.read_text(encoding="utf-8"))
566
- models = payload.get("models", [])
567
- by_slug: 'typing.Dict[str, JSONDict]' = {}
568
- for model in models:
569
- slug = model.get("slug")
570
- if isinstance(slug, str):
571
- by_slug[slug] = model
572
- return by_slug
573
-
574
-
575
561
  def _resolve_personality_message(variables, personality: 'typing.Union[str, None]') -> 'str':
576
562
  if not isinstance(variables, dict):
577
563
  return ""
@@ -25,10 +25,11 @@ MODEL_COMMAND = "/model"
25
25
  QUEUE_COMMAND = "/queue"
26
26
  RESUME_COMMAND = "/resume"
27
27
  COMPACT_COMMAND = "/compact"
28
+ FORK_COMMAND = "/fork"
28
29
  LINK_COMMAND = "/link"
29
30
  UNLINK_COMMAND = "/unlink"
30
31
  EXTRA_COMMANDS_LINE = (
31
- "Extra commands: /help, /history, /title, /model, /resume, /compact, /link, /unlink"
32
+ "Extra commands: /help, /history, /title, /model, /resume, /compact, /fork, /link, /unlink"
32
33
  )
33
34
 
34
35
 
@@ -301,6 +302,19 @@ async def run_interactive_session(
301
302
  except Exception as exc: # pragma: no cover - defensive surface
302
303
  view.show_error(str(exc))
303
304
  continue
305
+ if prompt_text == FORK_COMMAND:
306
+ if has_pending_turn_tasks():
307
+ view.write_line(
308
+ "Cannot fork while work is running or queued."
309
+ )
310
+ continue
311
+ if not hasattr(model_client, "_session_id"):
312
+ view.write_line("Current model does not support session IDs.")
313
+ continue
314
+ new_session_id = uuid7_string()
315
+ model_client._session_id = new_session_id
316
+ view.write_line(f"Forked session: {new_session_id}")
317
+ continue
304
318
  if prompt_text.startswith(f"{LINK_COMMAND} "):
305
319
  link_target = prompt_text[len(LINK_COMMAND) :].strip()
306
320
  if not link_target:
pycodex/model.py CHANGED
@@ -19,16 +19,19 @@ except ModuleNotFoundError: # pragma: no cover - Python 3.10 path
19
19
 
20
20
  from .protocol import (
21
21
  AssistantMessage,
22
+ JSONDict,
22
23
  ModelResponse,
23
24
  ModelStreamEvent,
24
25
  Prompt,
25
26
  ReasoningItem,
26
27
  ToolCall,
27
28
  )
29
+ from .model_metadata import model_metadata
28
30
  from .utils import build_user_agent, uuid7_string
29
31
 
30
32
  DEFAULT_CODEX_CONFIG_PATH = Path.home() / ".codex" / "config.toml"
31
33
  DEFAULT_ORIGINATOR = "pycodex"
34
+ RESPONSES_LITE_HEADER = "x-openai-internal-codex-responses-lite"
32
35
  ModelStreamEventHandler = Callable[[ModelStreamEvent], None]
33
36
  NOOP_MODEL_STREAM_EVENT_HANDLER: 'ModelStreamEventHandler' = lambda _event: None
34
37
  DEFAULT_STREAM_MAX_RETRIES = 5
@@ -65,6 +68,7 @@ class ResponsesProviderConfig:
65
68
  beta_features_header: 'typing.Union[str, None]' = None
66
69
  stream_max_retries: 'typing.Union[int, None]' = None
67
70
  stream_idle_timeout_ms: 'typing.Union[int, None]' = None
71
+ service_tier: 'typing.Union[str, None]' = None
68
72
 
69
73
  @classmethod
70
74
  def from_codex_config(
@@ -116,6 +120,7 @@ class ResponsesProviderConfig:
116
120
  reasoning_effort=selected.get("model_reasoning_effort"),
117
121
  reasoning_summary=selected.get("model_reasoning_summary"),
118
122
  verbosity=selected.get("model_verbosity"),
123
+ service_tier=selected.get("service_tier"),
119
124
  sandbox_mode=selected.get("sandbox_mode"),
120
125
  beta_features_header=",".join(beta_features) or None,
121
126
  stream_max_retries=_optional_int(provider.get("stream_max_retries")),
@@ -157,6 +162,65 @@ class ResponsesProviderConfig:
157
162
  return DEFAULT_STREAM_IDLE_TIMEOUT_MS / 1000.0
158
163
  return max(int(self.stream_idle_timeout_ms), 1) / 1000.0
159
164
 
165
+ def metadata(self) -> 'typing.Union[JSONDict, None]':
166
+ return model_metadata(self.model)
167
+
168
+ def use_responses_lite(self) -> 'bool':
169
+ metadata = self.metadata()
170
+ if metadata is None:
171
+ return False
172
+ return metadata.get("use_responses_lite") is True
173
+
174
+ def effective_reasoning_effort(self) -> 'typing.Union[str, None]':
175
+ if self.reasoning_effort is not None:
176
+ return str(self.reasoning_effort)
177
+ metadata = self.metadata()
178
+ if not _metadata_supports_reasoning(metadata):
179
+ return None
180
+ return _optional_metadata_string(metadata, "default_reasoning_level")
181
+
182
+ def effective_reasoning_summary(self) -> 'typing.Union[str, None]':
183
+ summary = self.reasoning_summary
184
+ if summary is None:
185
+ metadata = self.metadata()
186
+ if not _metadata_supports_reasoning(metadata):
187
+ return None
188
+ summary = _optional_metadata_string(metadata, "default_reasoning_summary")
189
+ if summary is None:
190
+ return None
191
+ if str(summary).strip().lower() == "none":
192
+ return None
193
+ return str(summary)
194
+
195
+ def effective_verbosity(self) -> 'typing.Union[str, None]':
196
+ if self.verbosity is not None:
197
+ return str(self.verbosity)
198
+ metadata = self.metadata()
199
+ if metadata is None or metadata.get("support_verbosity") is not True:
200
+ return None
201
+ return _optional_metadata_string(metadata, "default_verbosity")
202
+
203
+ def effective_service_tier(self) -> 'typing.Union[str, None]':
204
+ service_tier = self.service_tier
205
+ if service_tier is None:
206
+ return None
207
+ service_tier = str(service_tier).strip()
208
+ if service_tier == "fast":
209
+ service_tier = "priority"
210
+ if not service_tier or service_tier == "default":
211
+ return None
212
+
213
+ metadata = self.metadata()
214
+ if metadata is None:
215
+ return None
216
+ supported = metadata.get("service_tiers")
217
+ if not isinstance(supported, list):
218
+ return None
219
+ for tier in supported:
220
+ if isinstance(tier, dict) and tier.get("id") == service_tier:
221
+ return service_tier
222
+ return None
223
+
160
224
 
161
225
  def _optional_bool(value: 'typing.Union[bool, str, int, None]') -> 'typing.Union[bool, None]':
162
226
  if value is None:
@@ -171,6 +235,43 @@ def _optional_bool(value: 'typing.Union[bool, str, int, None]') -> 'typing.Union
171
235
  raise ValueError(f"invalid boolean config value: {value!r}")
172
236
 
173
237
 
238
+ def _metadata_supports_reasoning(
239
+ metadata: 'typing.Union[JSONDict, None]',
240
+ ) -> 'bool':
241
+ return metadata is not None and metadata.get("supports_reasoning_summaries") is True
242
+
243
+
244
+ def _optional_metadata_string(
245
+ metadata: 'typing.Union[JSONDict, None]',
246
+ key: 'str',
247
+ ) -> 'typing.Union[str, None]':
248
+ if metadata is None:
249
+ return None
250
+ value = metadata.get(key)
251
+ if value is None:
252
+ return None
253
+ text = str(value).strip()
254
+ return text or None
255
+
256
+
257
+ def _strip_image_details(items: 'typing.Iterable[object]') -> 'None':
258
+ for item in items:
259
+ if not isinstance(item, dict):
260
+ continue
261
+ content = item.get("content")
262
+ if isinstance(content, list):
263
+ _strip_image_detail_from_content_items(content)
264
+ output = item.get("output")
265
+ if isinstance(output, list):
266
+ _strip_image_detail_from_content_items(output)
267
+
268
+
269
+ def _strip_image_detail_from_content_items(items: 'typing.Iterable[object]') -> 'None':
270
+ for item in items:
271
+ if isinstance(item, dict) and item.get("type") == "input_image":
272
+ item.pop("detail", None)
273
+
274
+
174
275
  class ResponsesApiError(RuntimeError):
175
276
  pass
176
277
 
@@ -392,34 +493,73 @@ class ResponsesModelClient:
392
493
  ) from exc
393
494
 
394
495
  def _build_payload(self, prompt: 'Prompt') -> 'typing.Dict[str, object]':
496
+ use_responses_lite = self._config.use_responses_lite()
497
+ input_items = [item.serialize() for item in prompt.input]
498
+ if use_responses_lite:
499
+ _strip_image_details(input_items)
500
+
501
+ tools = [tool.serialize() for tool in prompt.tools]
395
502
  payload: 'typing.Dict[str, object]' = {
396
503
  "model": self.model,
397
- "instructions": prompt.base_instructions or "",
398
- "input": [item.serialize() for item in prompt.input],
399
- "tools": [tool.serialize() for tool in prompt.tools],
400
- "parallel_tool_calls": prompt.parallel_tool_calls,
504
+ "input": input_items,
505
+ "parallel_tool_calls": prompt.parallel_tool_calls and not use_responses_lite,
401
506
  "store": False,
402
507
  "stream": True,
403
508
  "include": ["reasoning.encrypted_content"],
404
509
  "prompt_cache_key": self._session_id,
405
510
  }
406
- if prompt.tools:
511
+ if use_responses_lite:
512
+ prefix: 'typing.List[typing.Dict[str, object]]' = [
513
+ {
514
+ "type": "additional_tools",
515
+ "role": "developer",
516
+ "tools": tools,
517
+ }
518
+ ]
519
+ if prompt.base_instructions:
520
+ prefix.append(
521
+ {
522
+ "type": "message",
523
+ "role": "developer",
524
+ "content": [
525
+ {
526
+ "type": "input_text",
527
+ "text": prompt.base_instructions,
528
+ }
529
+ ],
530
+ }
531
+ )
532
+ payload["input"] = prefix + input_items
533
+ else:
534
+ payload["instructions"] = prompt.base_instructions or ""
535
+ payload["tools"] = tools
536
+
537
+ if prompt.tools or use_responses_lite:
407
538
  payload["tool_choice"] = "auto"
408
539
 
409
540
  reasoning: 'typing.Dict[str, str]' = {}
410
- if self._config.reasoning_effort is not None:
411
- reasoning["effort"] = self._config.reasoning_effort
412
- if self._config.reasoning_summary is not None:
413
- reasoning["summary"] = self._config.reasoning_summary
541
+ reasoning_effort = self._config.effective_reasoning_effort()
542
+ reasoning_summary = self._config.effective_reasoning_summary()
543
+ if reasoning_effort is not None:
544
+ reasoning["effort"] = reasoning_effort
545
+ if reasoning_summary is not None:
546
+ reasoning["summary"] = reasoning_summary
547
+ if use_responses_lite and reasoning:
548
+ reasoning["context"] = "all_turns"
414
549
  if reasoning:
415
550
  payload["reasoning"] = reasoning
416
551
 
417
552
  text = None
418
- if self._config.verbosity is not None:
419
- text = {"verbosity": self._config.verbosity}
553
+ verbosity = self._config.effective_verbosity()
554
+ if verbosity is not None:
555
+ text = {"verbosity": verbosity}
420
556
  if text is not None:
421
557
  payload["text"] = text
422
558
 
559
+ service_tier = self._config.effective_service_tier()
560
+ if service_tier is not None:
561
+ payload["service_tier"] = service_tier
562
+
423
563
  return payload
424
564
 
425
565
  def _list_models_sync(self) -> 'typing.List[str]':
@@ -483,6 +623,8 @@ class ResponsesModelClient:
483
623
  headers["authorization"] = f"Bearer {api_key}"
484
624
  if self._config.beta_features_header is not None:
485
625
  headers["x-codex-beta-features"] = self._config.beta_features_header
626
+ if self._config.use_responses_lite():
627
+ headers[RESPONSES_LITE_HEADER] = "true"
486
628
  if self._openai_subagent is not None:
487
629
  headers["x-openai-subagent"] = self._openai_subagent
488
630
  if prompt.turn_metadata is not None:
@@ -0,0 +1,31 @@
1
+ """Shared loader for vendored Codex model metadata."""
2
+
3
+ from functools import lru_cache
4
+ import json
5
+ from pathlib import Path
6
+ import typing
7
+
8
+ from .protocol import JSONDict
9
+
10
+
11
+ DEFAULT_MODELS_PATH = Path(__file__).resolve().parent / "prompts" / "models.json"
12
+
13
+
14
+ @lru_cache(maxsize=1)
15
+ def load_models_by_slug() -> 'typing.Dict[str, JSONDict]':
16
+ payload = json.loads(DEFAULT_MODELS_PATH.read_text(encoding="utf-8"))
17
+ models = payload.get("models", [])
18
+ by_slug: 'typing.Dict[str, JSONDict]' = {}
19
+ for model in models:
20
+ if not isinstance(model, dict):
21
+ continue
22
+ slug = model.get("slug")
23
+ if isinstance(slug, str):
24
+ by_slug[slug] = model
25
+ return by_slug
26
+
27
+
28
+ def model_metadata(slug: 'typing.Union[str, None]') -> 'typing.Union[JSONDict, None]':
29
+ if slug is None:
30
+ return None
31
+ return load_models_by_slug().get(slug)