codexloop 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. codexloop/__init__.py +3 -0
  2. codexloop/application/__init__.py +1 -0
  3. codexloop/application/dto.py +41 -0
  4. codexloop/application/ports.py +158 -0
  5. codexloop/application/runner.py +467 -0
  6. codexloop/application/usecases/__init__.py +1 -0
  7. codexloop/application/usecases/doctor.py +37 -0
  8. codexloop/application/usecases/list_threads.py +14 -0
  9. codexloop/application/usecases/preflight.py +10 -0
  10. codexloop/application/usecases/resume_thread.py +11 -0
  11. codexloop/application/usecases/run_control.py +20 -0
  12. codexloop/application/usecases/run_plan.py +11 -0
  13. codexloop/bootstrap.py +461 -0
  14. codexloop/cli/__init__.py +1 -0
  15. codexloop/cli/app.py +94 -0
  16. codexloop/cli/asyncio.py +80 -0
  17. codexloop/cli/commands/__init__.py +1 -0
  18. codexloop/cli/commands/approval_cmd.py +24 -0
  19. codexloop/cli/commands/capacity.py +33 -0
  20. codexloop/cli/commands/cwd_cmd.py +22 -0
  21. codexloop/cli/commands/doctor.py +27 -0
  22. codexloop/cli/commands/effort_cmd.py +24 -0
  23. codexloop/cli/commands/logs.py +15 -0
  24. codexloop/cli/commands/model_cmd.py +22 -0
  25. codexloop/cli/commands/prompt.py +32 -0
  26. codexloop/cli/commands/reset.py +25 -0
  27. codexloop/cli/commands/resume.py +38 -0
  28. codexloop/cli/commands/run.py +50 -0
  29. codexloop/cli/commands/runs.py +13 -0
  30. codexloop/cli/commands/sandbox_cmd.py +24 -0
  31. codexloop/cli/commands/savepoints.py +25 -0
  32. codexloop/cli/commands/snapshot.py +26 -0
  33. codexloop/cli/commands/status.py +15 -0
  34. codexloop/cli/commands/stop.py +21 -0
  35. codexloop/cli/commands/threads.py +15 -0
  36. codexloop/cli/commands/unwind.py +24 -0
  37. codexloop/cli/commands/watch.py +66 -0
  38. codexloop/cli/render.py +39 -0
  39. codexloop/domain/__init__.py +26 -0
  40. codexloop/domain/approval.py +38 -0
  41. codexloop/domain/backoff.py +34 -0
  42. codexloop/domain/budget.py +56 -0
  43. codexloop/domain/capacity.py +81 -0
  44. codexloop/domain/classify.py +98 -0
  45. codexloop/domain/completion.py +130 -0
  46. codexloop/domain/control.py +167 -0
  47. codexloop/domain/error_codes.py +75 -0
  48. codexloop/domain/errors.py +35 -0
  49. codexloop/domain/loop.py +190 -0
  50. codexloop/domain/model_profile.py +30 -0
  51. codexloop/domain/plan.py +38 -0
  52. codexloop/domain/savepoint.py +32 -0
  53. codexloop/domain/savepoint_message.py +56 -0
  54. codexloop/domain/session.py +32 -0
  55. codexloop/domain/signals.py +25 -0
  56. codexloop/domain/waiting.py +120 -0
  57. codexloop/infrastructure/__init__.py +0 -0
  58. codexloop/infrastructure/agent/__init__.py +0 -0
  59. codexloop/infrastructure/agent/argv.py +102 -0
  60. codexloop/infrastructure/agent/events.py +274 -0
  61. codexloop/infrastructure/agent/gateway.py +189 -0
  62. codexloop/infrastructure/agent/probe.py +74 -0
  63. codexloop/infrastructure/agent/process.py +208 -0
  64. codexloop/infrastructure/agent/schema.py +31 -0
  65. codexloop/infrastructure/agent/scripted.py +201 -0
  66. codexloop/infrastructure/agent/translate.py +120 -0
  67. codexloop/infrastructure/api/__init__.py +26 -0
  68. codexloop/infrastructure/api/api_baseline.json +340 -0
  69. codexloop/infrastructure/api/binder.py +170 -0
  70. codexloop/infrastructure/api/gateway.py +142 -0
  71. codexloop/infrastructure/api/introspect.py +248 -0
  72. codexloop/infrastructure/api/json_io.py +26 -0
  73. codexloop/infrastructure/api/params.py +162 -0
  74. codexloop/infrastructure/api/providers.py +70 -0
  75. codexloop/infrastructure/api/registry.py +13 -0
  76. codexloop/infrastructure/appserver/__init__.py +6 -0
  77. codexloop/infrastructure/appserver/client.py +245 -0
  78. codexloop/infrastructure/appserver/gateway.py +437 -0
  79. codexloop/infrastructure/appserver/ratelimits.py +100 -0
  80. codexloop/infrastructure/audit.py +26 -0
  81. codexloop/infrastructure/capacity_probe.py +57 -0
  82. codexloop/infrastructure/clock.py +26 -0
  83. codexloop/infrastructure/config.py +150 -0
  84. codexloop/infrastructure/control.py +89 -0
  85. codexloop/infrastructure/doctor_env.py +239 -0
  86. codexloop/infrastructure/events.py +23 -0
  87. codexloop/infrastructure/git_savepoints.py +176 -0
  88. codexloop/infrastructure/lock.py +88 -0
  89. codexloop/infrastructure/logging.py +124 -0
  90. codexloop/infrastructure/notify.py +27 -0
  91. codexloop/infrastructure/progress.py +14 -0
  92. codexloop/infrastructure/redact.py +52 -0
  93. codexloop/infrastructure/rollout.py +113 -0
  94. codexloop/infrastructure/rundir.py +57 -0
  95. codexloop/infrastructure/snapshot.py +39 -0
  96. codexloop/infrastructure/state.py +32 -0
  97. codexloop/infrastructure/state_bus.py +27 -0
  98. codexloop/infrastructure/stream_ui.py +44 -0
  99. codexloop/py.typed +0 -0
  100. codexloop-0.1.0.dist-info/METADATA +104 -0
  101. codexloop-0.1.0.dist-info/RECORD +104 -0
  102. codexloop-0.1.0.dist-info/WHEEL +4 -0
  103. codexloop-0.1.0.dist-info/entry_points.txt +2 -0
  104. codexloop-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,248 @@
1
+ """Discover endpoint-backed methods on the OpenAI SDK resource class tree.
2
+
3
+ Walks ``cached_property`` subresources on resource classes — no live client
4
+ or credentials required. See architecture §15 / R11.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import importlib
10
+ import inspect
11
+ from dataclasses import dataclass
12
+ from functools import cached_property
13
+ from typing import Any
14
+
15
+ import openai
16
+ import openai.resources as resources
17
+ from openai import OpenAI
18
+ from openai._resource import SyncAPIResource
19
+
20
+ SDK_VERSION = openai.__version__
21
+
22
+ SKIP_RESOURCE_PROPS = frozenset(
23
+ {
24
+ "with_raw_response",
25
+ "with_streaming_response",
26
+ "with_options",
27
+ "auth_headers",
28
+ "default_headers",
29
+ "qs",
30
+ "copy",
31
+ }
32
+ )
33
+ SKIP_METHOD_NAMES = frozenset({"with_raw_response", "with_streaming_response"})
34
+
35
+ # SDK helpers with no plain HTTP endpoint — still exposed as CLI commands but
36
+ # enumerated explicitly so the drift gate cannot silently forget them.
37
+ LOCAL_HELPER_PATHS = frozenset(
38
+ {
39
+ "chat.completions.parse",
40
+ "chat.completions.stream",
41
+ "responses.parse",
42
+ "responses.stream",
43
+ "beta.chat.completions.parse",
44
+ "beta.chat.completions.stream",
45
+ "beta.threads.runs.create_and_stream",
46
+ "beta.threads.runs.stream",
47
+ "beta.threads.runs.submit_tool_outputs_stream",
48
+ "beta.threads.create_and_run_stream",
49
+ "webhooks.unwrap",
50
+ "webhooks.verify_signature",
51
+ }
52
+ )
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class EndpointSpec:
57
+ """One discovered SDK method under ``openai.resources``."""
58
+
59
+ resource_path: tuple[str, ...]
60
+ method_name: str
61
+ signature: str
62
+ is_list: bool
63
+ is_streaming: bool
64
+ path: str = ""
65
+ is_local_helper: bool = False
66
+
67
+ def __post_init__(self) -> None:
68
+ object.__setattr__(self, "path", ".".join((*self.resource_path, self.method_name)))
69
+ object.__setattr__(self, "is_local_helper", self.path in LOCAL_HELPER_PATHS)
70
+
71
+
72
+ def _resolve_annotation_class(
73
+ owner_cls: type,
74
+ prop_name: str,
75
+ annotation: str,
76
+ *,
77
+ globals_ns: dict[str, Any],
78
+ ) -> type[SyncAPIResource] | None:
79
+ ret: Any = globals_ns.get(annotation)
80
+ if ret is None:
81
+ ret = getattr(resources, annotation, None)
82
+ if ret is None:
83
+ owner_mod = owner_cls.__module__
84
+ parent_pkg = owner_mod.rsplit(".", 1)[0]
85
+ candidates = (
86
+ f"openai.resources.{prop_name}",
87
+ f"{parent_pkg}.{prop_name}",
88
+ f"{parent_pkg}.{prop_name}.{prop_name}",
89
+ f"openai.resources.{prop_name}.{prop_name}",
90
+ )
91
+ for cand in candidates:
92
+ try:
93
+ mod = importlib.import_module(cand)
94
+ except ImportError: # pragma: no cover — try next candidate
95
+ continue
96
+ if hasattr(mod, annotation): # pragma: no branch
97
+ ret = getattr(mod, annotation)
98
+ break
99
+ if (
100
+ ret is None or not inspect.isclass(ret) or not issubclass(ret, SyncAPIResource)
101
+ ): # pragma: no cover
102
+ return None
103
+ return ret
104
+
105
+
106
+ def _resolve_subresource_class(
107
+ owner_cls: type,
108
+ prop_name: str,
109
+ ) -> type[SyncAPIResource] | None:
110
+ prop = None
111
+ for base in owner_cls.__mro__:
112
+ if prop_name in base.__dict__: # pragma: no branch
113
+ prop = base.__dict__[prop_name]
114
+ break
115
+ if not isinstance(prop, cached_property): # pragma: no cover
116
+ return None
117
+ ann = prop.func.__annotations__.get("return")
118
+ if isinstance(ann, type) and issubclass(
119
+ ann, SyncAPIResource
120
+ ): # pragma: no cover — live type ann
121
+ return ann
122
+ if not isinstance(ann, str): # pragma: no cover
123
+ return None
124
+ return _resolve_annotation_class(
125
+ owner_cls,
126
+ prop_name,
127
+ ann,
128
+ globals_ns=dict(prop.func.__globals__),
129
+ )
130
+
131
+
132
+ def _iter_public_members(cls: type) -> list[tuple[str, Any]]:
133
+ """Walk SyncAPIResource MRO so empty re-export subclasses still expose methods."""
134
+ seen: set[str] = set()
135
+ members: list[tuple[str, Any]] = []
136
+ for base in cls.__mro__: # pragma: no branch — SyncAPIResource always terminates walk
137
+ if base is SyncAPIResource or base is object:
138
+ break
139
+ if not issubclass(base, SyncAPIResource): # pragma: no cover
140
+ continue
141
+ for name, val in base.__dict__.items():
142
+ if name.startswith("_") or name in seen:
143
+ continue
144
+ seen.add(name)
145
+ members.append((name, val))
146
+ return members
147
+
148
+
149
+ def _is_streaming_name(name: str) -> bool:
150
+ return name == "stream" or name.endswith("_stream") or "stream" in name.split("_")
151
+
152
+
153
+ def _walk_resource(cls: type[SyncAPIResource], prefix: tuple[str, ...]) -> list[EndpointSpec]:
154
+ discovered: list[EndpointSpec] = []
155
+ for name, val in _iter_public_members(cls):
156
+ if isinstance(val, cached_property):
157
+ if name in SKIP_RESOURCE_PROPS: # pragma: no cover
158
+ continue
159
+ sub = _resolve_subresource_class(cls, name)
160
+ if sub is not None: # pragma: no branch
161
+ discovered.extend(_walk_resource(sub, prefix + (name,)))
162
+ continue
163
+ if not callable(val) or isinstance(val, (classmethod, staticmethod)): # pragma: no cover
164
+ continue
165
+ if name in SKIP_METHOD_NAMES: # pragma: no cover
166
+ continue
167
+ try:
168
+ sig = inspect.signature(val)
169
+ except (TypeError, ValueError): # pragma: no cover
170
+ continue
171
+ if "self" not in sig.parameters: # pragma: no cover
172
+ continue
173
+ discovered.append(
174
+ EndpointSpec(
175
+ resource_path=prefix,
176
+ method_name=name,
177
+ signature=str(sig),
178
+ is_list=name == "list",
179
+ is_streaming=_is_streaming_name(name),
180
+ )
181
+ )
182
+ return discovered
183
+
184
+
185
+ def _root_resources(
186
+ client_cls: type = OpenAI,
187
+ ) -> tuple[tuple[str, type[SyncAPIResource]], ...]:
188
+ roots: list[tuple[str, type[SyncAPIResource]]] = []
189
+ seen: set[str] = set()
190
+ for base in client_cls.__mro__:
191
+ for name, val in base.__dict__.items():
192
+ if name in seen or name.startswith("_") or name in SKIP_RESOURCE_PROPS:
193
+ continue
194
+ if not isinstance(val, cached_property): # pragma: no cover
195
+ continue
196
+ seen.add(name)
197
+ sub = _resolve_subresource_class(base, name)
198
+ if sub is not None: # pragma: no branch
199
+ roots.append((name, sub))
200
+ return tuple(roots)
201
+
202
+
203
+ def discover_surface(
204
+ *,
205
+ roots: tuple[str, ...] | None = None,
206
+ client_cls: type = OpenAI,
207
+ ) -> tuple[EndpointSpec, ...]:
208
+ """Return every SDK method under the given top-level resource roots."""
209
+ allowed = frozenset(roots) if roots is not None else None
210
+ methods: list[EndpointSpec] = []
211
+ for root_name, root_cls in _root_resources(client_cls):
212
+ if allowed is not None and root_name not in allowed:
213
+ continue
214
+ methods.extend(_walk_resource(root_cls, (root_name,)))
215
+ return tuple(sorted(methods, key=lambda m: m.path))
216
+
217
+
218
+ def method_by_path(methods: tuple[EndpointSpec, ...]) -> dict[str, EndpointSpec]:
219
+ return {m.path: m for m in methods}
220
+
221
+
222
+ def resolve_callable(method: EndpointSpec, *, client_cls: type = OpenAI) -> Any:
223
+ """Resolve the unbound SDK method function for a discovered path."""
224
+ if not method.resource_path:
225
+ msg = f"empty resource path for {method.path!r}"
226
+ raise RuntimeError(msg)
227
+ cls: type[SyncAPIResource] | None = None
228
+ for root_name, root_cls in _root_resources(client_cls):
229
+ if method.resource_path[0] == root_name:
230
+ cls = root_cls
231
+ break
232
+ if cls is None:
233
+ msg = f"unknown root resource in {method.path!r}"
234
+ raise RuntimeError(msg)
235
+ for segment in method.resource_path[1:]:
236
+ sub = _resolve_subresource_class(cls, segment)
237
+ if sub is None: # pragma: no cover — malformed EndpointSpec
238
+ msg = f"cannot resolve subresource {segment!r} on {cls!r}"
239
+ raise RuntimeError(msg)
240
+ cls = sub
241
+ for base in cls.__mro__:
242
+ if base is SyncAPIResource or base is object: # pragma: no cover — always return earlier
243
+ break
244
+ fn = base.__dict__.get(method.method_name)
245
+ if callable(fn) and not isinstance(fn, (classmethod, staticmethod)):
246
+ return fn
247
+ msg = f"method {method.method_name!r} not found on {cls!r}" # pragma: no cover
248
+ raise RuntimeError(msg) # pragma: no cover
@@ -0,0 +1,26 @@
1
+ """Parse ``--json`` / ``--json-file`` bodies for the generated API commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ def load_json_payload(*, inline: str | None, json_file: Path | None) -> dict[str, Any]:
11
+ if inline is not None and json_file is not None:
12
+ msg = "pass only one of --json or --json-file, not both"
13
+ raise ValueError(msg)
14
+ if json_file is not None:
15
+ text = json_file.read_text(encoding="utf-8").strip()
16
+ if text.startswith("@"):
17
+ path = Path(text[1:]).expanduser()
18
+ text = path.read_text(encoding="utf-8")
19
+ inline = text
20
+ if inline is None:
21
+ return {}
22
+ data = json.loads(inline)
23
+ if not isinstance(data, dict):
24
+ msg = "request JSON must be an object at the top level"
25
+ raise TypeError(msg)
26
+ return data
@@ -0,0 +1,162 @@
1
+ """Classify SDK method parameters for Typer / Click binding."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import types
7
+ from dataclasses import dataclass
8
+ from typing import Any, Union, get_args, get_origin
9
+
10
+ from openai import NotGiven, Omit
11
+
12
+ SKIP_PARAMETERS = frozenset(
13
+ {
14
+ "self",
15
+ "extra_headers",
16
+ "extra_query",
17
+ "extra_body",
18
+ }
19
+ )
20
+
21
+ _SCALAR_BY_NAME: dict[str, type] = {
22
+ "int": int,
23
+ "float": float,
24
+ "str": str,
25
+ "bool": bool,
26
+ }
27
+
28
+
29
+ @dataclass(frozen=True, slots=True)
30
+ class ScalarParam:
31
+ name: str
32
+ cli_name: str
33
+ annotation: Any
34
+ required: bool
35
+ default: Any
36
+
37
+
38
+ def _is_omit_default(default: Any) -> bool:
39
+ return isinstance(default, (Omit, NotGiven)) or type(default).__name__ in {
40
+ "Omit",
41
+ "NotGiven",
42
+ }
43
+
44
+
45
+ def _is_omit_or_not_given_type(annotation: Any) -> bool:
46
+ return annotation is Omit or annotation is NotGiven
47
+
48
+
49
+ def _normalize_annotation(annotation: Any) -> Any:
50
+ """Reduce Optional / ``X | None`` / ``X | Omit`` forms to one type when unique.
51
+
52
+ OpenAI SDK methods are typically annotated under
53
+ ``from __future__ import annotations``, so runtime signatures carry *strings*
54
+ like ``'int | Omit'`` rather than evaluated unions.
55
+ """
56
+ if isinstance(annotation, str):
57
+ text = annotation.strip()
58
+ changed = True
59
+ while changed:
60
+ changed = False
61
+ if text.startswith("Optional[") and text.endswith("]"):
62
+ text = text[len("Optional[") : -1].strip()
63
+ changed = True
64
+ continue
65
+ for prefix in ("None | ", "Omit | ", "NotGiven | "):
66
+ if text.startswith(prefix):
67
+ text = text[len(prefix) :].strip()
68
+ changed = True
69
+ break
70
+ if changed:
71
+ continue
72
+ for suffix in (" | Omit", " | NotGiven", " | None"):
73
+ if text.endswith(suffix):
74
+ text = text[: -len(suffix)].strip()
75
+ changed = True
76
+ break
77
+ if " | " in text or "[" in text:
78
+ return annotation
79
+ return _SCALAR_BY_NAME.get(text, text)
80
+
81
+ origin = get_origin(annotation)
82
+ if origin is Union or isinstance(annotation, types.UnionType):
83
+ filtered = [
84
+ arg
85
+ for arg in get_args(annotation)
86
+ if arg is not type(None) and not _is_omit_or_not_given_type(arg)
87
+ ]
88
+ if len(filtered) == 1:
89
+ return _normalize_annotation(filtered[0])
90
+ return annotation
91
+
92
+
93
+ def is_scalar_annotation(annotation: Any) -> bool:
94
+ if annotation is inspect.Parameter.empty:
95
+ return False
96
+ ann = _normalize_annotation(annotation)
97
+ if ann is bool:
98
+ return True
99
+ if ann in (int, float, str):
100
+ return True
101
+ origin = get_origin(ann)
102
+ if origin is not None:
103
+ return False
104
+ if isinstance(ann, str):
105
+ return ann in _SCALAR_BY_NAME
106
+ return False
107
+
108
+
109
+ def typer_type_for_annotation(annotation: Any) -> Any:
110
+ """Map a (possibly stringified) scalar annotation to a Typer/Click type."""
111
+ ann = _normalize_annotation(annotation)
112
+ if ann is bool or ann == "bool":
113
+ return bool
114
+ if ann is int or ann == "int":
115
+ return int
116
+ if ann is float or ann == "float":
117
+ return float
118
+ return str
119
+
120
+
121
+ def scalar_parameters(signature: inspect.Signature) -> tuple[ScalarParam, ...]:
122
+ params: list[ScalarParam] = []
123
+ for name, param in signature.parameters.items():
124
+ if name in SKIP_PARAMETERS:
125
+ continue
126
+ if not is_scalar_annotation(param.annotation):
127
+ continue
128
+ required = param.default is inspect.Parameter.empty
129
+ if not required and _is_omit_default(param.default):
130
+ required = False
131
+ cli_name = name.replace("_", "-")
132
+ params.append(
133
+ ScalarParam(
134
+ name=name,
135
+ cli_name=cli_name,
136
+ annotation=param.annotation,
137
+ required=required,
138
+ default=None if required else param.default,
139
+ )
140
+ )
141
+ return tuple(params)
142
+
143
+
144
+ def build_call_kwargs(
145
+ signature: inspect.Signature,
146
+ *,
147
+ json_payload: dict[str, Any],
148
+ scalar_values: dict[str, Any],
149
+ ) -> dict[str, Any]:
150
+ kwargs: dict[str, Any] = dict(json_payload)
151
+ for name, value in scalar_values.items():
152
+ if value is None:
153
+ continue
154
+ kwargs[name] = value
155
+ for name, param in signature.parameters.items():
156
+ if name in SKIP_PARAMETERS or name == "self":
157
+ continue
158
+ if name in kwargs:
159
+ continue
160
+ if param.default is not inspect.Parameter.empty and not _is_omit_default(param.default):
161
+ kwargs[name] = param.default
162
+ return kwargs
@@ -0,0 +1,70 @@
1
+ """Alternate OpenAI SDK client selection for ``codexloop api --provider``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ from openai import AzureOpenAI, OpenAI
10
+
11
+ ProviderFactory = Callable[..., Any]
12
+
13
+ # Keys exposed on the CLI (--provider values use kebab-case via Typer).
14
+ PROVIDER_FACTORIES: dict[str, ProviderFactory] = {
15
+ "openai": lambda **kwargs: OpenAI(**kwargs),
16
+ "azure": lambda **kwargs: AzureOpenAI(**kwargs),
17
+ "custom": lambda **kwargs: OpenAI(**kwargs),
18
+ }
19
+
20
+ FULL_TREE_PROVIDERS = frozenset({"openai", "custom"})
21
+ # AzureOpenAI subclasses OpenAI, so the class tree is the same; keep the set
22
+ # explicit so a future limited Azure surface can shrink without surprise.
23
+ AZURE_TREE_PROVIDERS = frozenset({"azure"})
24
+
25
+
26
+ def build_client(provider: str, *, base_url: str | None = None) -> Any:
27
+ factory = PROVIDER_FACTORIES.get(provider)
28
+ if factory is None:
29
+ known = ", ".join(sorted(PROVIDER_FACTORIES))
30
+ msg = f"unknown provider {provider!r}; expected one of: {known}"
31
+ raise ValueError(msg)
32
+ kwargs: dict[str, Any] = {}
33
+ if base_url is not None:
34
+ kwargs["base_url"] = base_url
35
+ elif provider == "custom":
36
+ env_url = os.environ.get("OPENAI_BASE_URL")
37
+ if env_url:
38
+ kwargs["base_url"] = env_url
39
+ if provider == "azure":
40
+ # AzureOpenAI requires api_version; allow env defaults used by the SDK.
41
+ if "api_version" not in kwargs and os.environ.get("OPENAI_API_VERSION"):
42
+ kwargs["api_version"] = os.environ["OPENAI_API_VERSION"]
43
+ # Dummy key only when constructing for help/introspection paths that
44
+ # never call the network — real invokes still need credentials.
45
+ if not os.environ.get("AZURE_OPENAI_API_KEY") and not os.environ.get("OPENAI_API_KEY"):
46
+ kwargs.setdefault("api_key", "codexloop-azure-placeholder")
47
+ if not os.environ.get("AZURE_OPENAI_ENDPOINT") and "azure_endpoint" not in kwargs:
48
+ kwargs.setdefault("azure_endpoint", "https://example.openai.azure.com")
49
+ if "api_version" not in kwargs:
50
+ kwargs.setdefault("api_version", "2024-02-01")
51
+ elif not os.environ.get("OPENAI_API_KEY"):
52
+ kwargs.setdefault("api_key", "codexloop-placeholder")
53
+ return factory(**kwargs)
54
+
55
+
56
+ def client_class_for_provider(provider: str) -> type:
57
+ if provider == "azure":
58
+ return AzureOpenAI
59
+ if provider in {"openai", "custom"}:
60
+ return OpenAI
61
+ msg = f"unknown provider {provider!r}"
62
+ raise ValueError(msg)
63
+
64
+
65
+ def surface_roots_for_provider(provider: str) -> tuple[str, ...] | None:
66
+ """Return restricted roots, or ``None`` for the full OpenAI tree."""
67
+ if provider in FULL_TREE_PROVIDERS or provider in AZURE_TREE_PROVIDERS:
68
+ return None
69
+ msg = f"unknown provider {provider!r}"
70
+ raise ValueError(msg)
@@ -0,0 +1,13 @@
1
+ """Registry of generated ``codexloop api`` command paths (drift gate)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ REGISTERED_COMMAND_PATHS: set[str] = set()
6
+
7
+
8
+ def register_command_path(path: str) -> None:
9
+ REGISTERED_COMMAND_PATHS.add(path)
10
+
11
+
12
+ def clear_registry() -> None:
13
+ REGISTERED_COMMAND_PATHS.clear()
@@ -0,0 +1,6 @@
1
+ """Optional ``codex app-server`` JSON-RPC adapter (rate-limit enrichment)."""
2
+
3
+ from codexloop.infrastructure.appserver.client import DEFAULT_ARGV, AppServerClient
4
+ from codexloop.infrastructure.appserver.gateway import CodexAppServerGateway
5
+
6
+ __all__ = ["DEFAULT_ARGV", "AppServerClient", "CodexAppServerGateway"]