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.
- codexloop/__init__.py +3 -0
- codexloop/application/__init__.py +1 -0
- codexloop/application/dto.py +41 -0
- codexloop/application/ports.py +158 -0
- codexloop/application/runner.py +467 -0
- codexloop/application/usecases/__init__.py +1 -0
- codexloop/application/usecases/doctor.py +37 -0
- codexloop/application/usecases/list_threads.py +14 -0
- codexloop/application/usecases/preflight.py +10 -0
- codexloop/application/usecases/resume_thread.py +11 -0
- codexloop/application/usecases/run_control.py +20 -0
- codexloop/application/usecases/run_plan.py +11 -0
- codexloop/bootstrap.py +461 -0
- codexloop/cli/__init__.py +1 -0
- codexloop/cli/app.py +94 -0
- codexloop/cli/asyncio.py +80 -0
- codexloop/cli/commands/__init__.py +1 -0
- codexloop/cli/commands/approval_cmd.py +24 -0
- codexloop/cli/commands/capacity.py +33 -0
- codexloop/cli/commands/cwd_cmd.py +22 -0
- codexloop/cli/commands/doctor.py +27 -0
- codexloop/cli/commands/effort_cmd.py +24 -0
- codexloop/cli/commands/logs.py +15 -0
- codexloop/cli/commands/model_cmd.py +22 -0
- codexloop/cli/commands/prompt.py +32 -0
- codexloop/cli/commands/reset.py +25 -0
- codexloop/cli/commands/resume.py +38 -0
- codexloop/cli/commands/run.py +50 -0
- codexloop/cli/commands/runs.py +13 -0
- codexloop/cli/commands/sandbox_cmd.py +24 -0
- codexloop/cli/commands/savepoints.py +25 -0
- codexloop/cli/commands/snapshot.py +26 -0
- codexloop/cli/commands/status.py +15 -0
- codexloop/cli/commands/stop.py +21 -0
- codexloop/cli/commands/threads.py +15 -0
- codexloop/cli/commands/unwind.py +24 -0
- codexloop/cli/commands/watch.py +66 -0
- codexloop/cli/render.py +39 -0
- codexloop/domain/__init__.py +26 -0
- codexloop/domain/approval.py +38 -0
- codexloop/domain/backoff.py +34 -0
- codexloop/domain/budget.py +56 -0
- codexloop/domain/capacity.py +81 -0
- codexloop/domain/classify.py +98 -0
- codexloop/domain/completion.py +130 -0
- codexloop/domain/control.py +167 -0
- codexloop/domain/error_codes.py +75 -0
- codexloop/domain/errors.py +35 -0
- codexloop/domain/loop.py +190 -0
- codexloop/domain/model_profile.py +30 -0
- codexloop/domain/plan.py +38 -0
- codexloop/domain/savepoint.py +32 -0
- codexloop/domain/savepoint_message.py +56 -0
- codexloop/domain/session.py +32 -0
- codexloop/domain/signals.py +25 -0
- codexloop/domain/waiting.py +120 -0
- codexloop/infrastructure/__init__.py +0 -0
- codexloop/infrastructure/agent/__init__.py +0 -0
- codexloop/infrastructure/agent/argv.py +102 -0
- codexloop/infrastructure/agent/events.py +274 -0
- codexloop/infrastructure/agent/gateway.py +189 -0
- codexloop/infrastructure/agent/probe.py +74 -0
- codexloop/infrastructure/agent/process.py +208 -0
- codexloop/infrastructure/agent/schema.py +31 -0
- codexloop/infrastructure/agent/scripted.py +201 -0
- codexloop/infrastructure/agent/translate.py +120 -0
- codexloop/infrastructure/api/__init__.py +26 -0
- codexloop/infrastructure/api/api_baseline.json +340 -0
- codexloop/infrastructure/api/binder.py +170 -0
- codexloop/infrastructure/api/gateway.py +142 -0
- codexloop/infrastructure/api/introspect.py +248 -0
- codexloop/infrastructure/api/json_io.py +26 -0
- codexloop/infrastructure/api/params.py +162 -0
- codexloop/infrastructure/api/providers.py +70 -0
- codexloop/infrastructure/api/registry.py +13 -0
- codexloop/infrastructure/appserver/__init__.py +6 -0
- codexloop/infrastructure/appserver/client.py +245 -0
- codexloop/infrastructure/appserver/gateway.py +437 -0
- codexloop/infrastructure/appserver/ratelimits.py +100 -0
- codexloop/infrastructure/audit.py +26 -0
- codexloop/infrastructure/capacity_probe.py +57 -0
- codexloop/infrastructure/clock.py +26 -0
- codexloop/infrastructure/config.py +150 -0
- codexloop/infrastructure/control.py +89 -0
- codexloop/infrastructure/doctor_env.py +239 -0
- codexloop/infrastructure/events.py +23 -0
- codexloop/infrastructure/git_savepoints.py +176 -0
- codexloop/infrastructure/lock.py +88 -0
- codexloop/infrastructure/logging.py +124 -0
- codexloop/infrastructure/notify.py +27 -0
- codexloop/infrastructure/progress.py +14 -0
- codexloop/infrastructure/redact.py +52 -0
- codexloop/infrastructure/rollout.py +113 -0
- codexloop/infrastructure/rundir.py +57 -0
- codexloop/infrastructure/snapshot.py +39 -0
- codexloop/infrastructure/state.py +32 -0
- codexloop/infrastructure/state_bus.py +27 -0
- codexloop/infrastructure/stream_ui.py +44 -0
- codexloop/py.typed +0 -0
- codexloop-0.1.0.dist-info/METADATA +104 -0
- codexloop-0.1.0.dist-info/RECORD +104 -0
- codexloop-0.1.0.dist-info/WHEEL +4 -0
- codexloop-0.1.0.dist-info/entry_points.txt +2 -0
- 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"]
|