codex-client-provider 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.
@@ -0,0 +1,25 @@
1
+ """Public API for the reusable Codex App Server provider."""
2
+
3
+ from codex_client_provider.langchain import (
4
+ CODEX_IMAGE_JPEG_QUALITY,
5
+ CODEX_IMAGE_MAX_BYTES,
6
+ CODEX_IMAGE_MAX_EDGE,
7
+ CodexAppServerChatModel,
8
+ CodexAppServerClient,
9
+ CodexAppServerError,
10
+ codex_client_status,
11
+ find_codex_binary,
12
+ )
13
+
14
+ __all__ = [
15
+ "CODEX_IMAGE_JPEG_QUALITY",
16
+ "CODEX_IMAGE_MAX_BYTES",
17
+ "CODEX_IMAGE_MAX_EDGE",
18
+ "CodexAppServerChatModel",
19
+ "CodexAppServerClient",
20
+ "CodexAppServerError",
21
+ "codex_client_status",
22
+ "find_codex_binary",
23
+ ]
24
+
25
+ __version__ = "0.1.0"
@@ -0,0 +1,883 @@
1
+ # Copyright 2026 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Reusable LangChain adapter for a locally signed-in Codex client.
16
+
17
+ The adapter talks to ``codex app-server`` over JSONL/stdio. Authentication is
18
+ owned by the installed Codex client, so this package never reads, copies, or
19
+ stores the ChatGPT session token. Host tools remain in charge of execution:
20
+ their schemas are presented to Codex as a constrained output contract and
21
+ converted back into ordinary LangChain tool calls.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import atexit
28
+ import base64
29
+ import json
30
+ import logging
31
+ import os
32
+ import shutil
33
+ import subprocess
34
+ import tempfile
35
+ import weakref
36
+ from collections.abc import Callable, Sequence
37
+ from functools import lru_cache
38
+ from io import BytesIO
39
+ from pathlib import Path
40
+ from typing import Any
41
+ from urllib.parse import unquote_to_bytes
42
+ from uuid import uuid4
43
+
44
+ from langchain_core.callbacks import AsyncCallbackManagerForLLMRun, CallbackManagerForLLMRun
45
+ from langchain_core.language_models.chat_models import BaseChatModel
46
+ from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage, ToolMessage
47
+ from langchain_core.outputs import ChatGeneration, ChatResult
48
+ from langchain_core.tools import BaseTool
49
+ from langchain_core.utils.function_calling import convert_to_openai_tool
50
+ from PIL import Image, ImageOps, UnidentifiedImageError
51
+ from pydantic import Field
52
+
53
+ logger = logging.getLogger(__name__)
54
+
55
+ PACKAGE_VERSION = "0.1.0"
56
+
57
+
58
+ def find_codex_binary(configured: str | None = None) -> str | None:
59
+ """Return the installed Codex executable without inspecting its credentials."""
60
+ configured = (
61
+ configured
62
+ or os.environ.get("CODEX_CLIENT_BIN", "")
63
+ # Backward-compatible while the provider still ships inside Artemis.
64
+ or os.environ.get("ARTEMIS_CODEX_BIN", "")
65
+ ).strip()
66
+ if configured:
67
+ path = Path(configured).expanduser()
68
+ return str(path) if path.is_file() else None
69
+
70
+ for name in ("codex.exe", "codex"):
71
+ found = shutil.which(name)
72
+ if found:
73
+ return found
74
+
75
+ if os.name == "nt":
76
+ local_app_data = os.environ.get("LOCALAPPDATA")
77
+ if local_app_data:
78
+ desktop_binary = (
79
+ Path(local_app_data) / "Programs" / "OpenAI" / "Codex" / "bin" / "codex.exe"
80
+ )
81
+ if desktop_binary.is_file():
82
+ return str(desktop_binary)
83
+ return None
84
+
85
+
86
+ def _hidden_process_flags() -> int:
87
+ return getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0
88
+
89
+
90
+ @lru_cache(maxsize=1)
91
+ def codex_client_status(binary: str | None = None) -> tuple[bool, str]:
92
+ """Check CLI presence and login status without reading any auth files."""
93
+ binary = find_codex_binary(binary)
94
+ if not binary:
95
+ return False, "Codex CLI was not found. Install/open Codex and sign in first."
96
+ try:
97
+ result = subprocess.run(
98
+ [binary, "login", "status"],
99
+ capture_output=True,
100
+ text=True,
101
+ encoding="utf-8",
102
+ errors="replace",
103
+ timeout=8,
104
+ check=False,
105
+ creationflags=_hidden_process_flags(),
106
+ )
107
+ except (OSError, subprocess.SubprocessError) as exc:
108
+ return False, f"Could not query Codex login status: {exc}"
109
+
110
+ output = " ".join(part.strip() for part in (result.stdout, result.stderr) if part.strip())
111
+ if result.returncode == 0 and "logged in" in output.lower():
112
+ return True, output or "Logged in"
113
+ return False, output or "Codex is installed but is not signed in."
114
+
115
+
116
+ class CodexAppServerError(RuntimeError):
117
+ """Raised when the local Codex app-server cannot serve a completion."""
118
+
119
+
120
+ class CodexAppServerClient:
121
+ """One persistent JSON-RPC app-server connection for an asyncio loop."""
122
+
123
+ def __init__(
124
+ self,
125
+ binary: str,
126
+ *,
127
+ client_name: str = "codex_client_provider",
128
+ client_title: str = "Codex Client Provider",
129
+ client_version: str = PACKAGE_VERSION,
130
+ ):
131
+ self.binary = binary
132
+ self.client_name = client_name
133
+ self.client_title = client_title
134
+ self.client_version = client_version
135
+ self.process: asyncio.subprocess.Process | None = None
136
+ self._reader_task: asyncio.Task[None] | None = None
137
+ self._stderr_task: asyncio.Task[None] | None = None
138
+ self._start_lock = asyncio.Lock()
139
+ self._write_lock = asyncio.Lock()
140
+ self._next_id = 1
141
+ self._pending: dict[int, asyncio.Future[Any]] = {}
142
+ self._thread_queues: dict[str, asyncio.Queue[dict[str, Any]]] = {}
143
+
144
+ async def start(self) -> None:
145
+ if self.process and self.process.returncode is None:
146
+ return
147
+ async with self._start_lock:
148
+ if self.process and self.process.returncode is None:
149
+ return
150
+ try:
151
+ self.process = await asyncio.create_subprocess_exec(
152
+ self.binary,
153
+ "app-server",
154
+ "--stdio",
155
+ stdin=asyncio.subprocess.PIPE,
156
+ stdout=asyncio.subprocess.PIPE,
157
+ stderr=asyncio.subprocess.PIPE,
158
+ creationflags=_hidden_process_flags(),
159
+ )
160
+ except OSError as exc:
161
+ raise CodexAppServerError(f"Could not start Codex app-server: {exc}") from exc
162
+
163
+ self._reader_task = asyncio.create_task(self._read_stdout())
164
+ self._stderr_task = asyncio.create_task(self._read_stderr())
165
+ try:
166
+ await self.request(
167
+ "initialize",
168
+ {
169
+ "clientInfo": {
170
+ "name": self.client_name,
171
+ "title": self.client_title,
172
+ "version": self.client_version,
173
+ },
174
+ "capabilities": {"experimentalApi": False},
175
+ },
176
+ ensure_started=False,
177
+ )
178
+ await self.notify("initialized", {}, ensure_started=False)
179
+ except Exception:
180
+ await self.close()
181
+ raise
182
+
183
+ async def request(
184
+ self,
185
+ method: str,
186
+ params: dict[str, Any],
187
+ *,
188
+ ensure_started: bool = True,
189
+ ) -> Any:
190
+ if ensure_started:
191
+ await self.start()
192
+ process = self.process
193
+ if not process or process.returncode is not None or process.stdin is None:
194
+ raise CodexAppServerError("Codex app-server is not running.")
195
+
196
+ request_id = self._next_id
197
+ self._next_id += 1
198
+ future = asyncio.get_running_loop().create_future()
199
+ self._pending[request_id] = future
200
+ try:
201
+ await self._write({"id": request_id, "method": method, "params": params})
202
+ return await future
203
+ finally:
204
+ self._pending.pop(request_id, None)
205
+
206
+ async def notify(
207
+ self,
208
+ method: str,
209
+ params: dict[str, Any],
210
+ *,
211
+ ensure_started: bool = True,
212
+ ) -> None:
213
+ if ensure_started:
214
+ await self.start()
215
+ await self._write({"method": method, "params": params})
216
+
217
+ async def _write(self, payload: dict[str, Any]) -> None:
218
+ process = self.process
219
+ if not process or process.returncode is not None or process.stdin is None:
220
+ raise CodexAppServerError("Codex app-server closed its stdio connection.")
221
+ data = (json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n").encode()
222
+ async with self._write_lock:
223
+ process.stdin.write(data)
224
+ await process.stdin.drain()
225
+
226
+ async def _read_stdout(self) -> None:
227
+ assert self.process is not None and self.process.stdout is not None
228
+ try:
229
+ while line := await self.process.stdout.readline():
230
+ try:
231
+ message = json.loads(line)
232
+ except json.JSONDecodeError:
233
+ logger.debug("Ignoring non-JSON Codex app-server output: %r", line[:300])
234
+ continue
235
+
236
+ request_id = message.get("id")
237
+ if request_id is not None and "method" not in message:
238
+ future = self._pending.get(request_id)
239
+ if future and not future.done():
240
+ if "error" in message:
241
+ future.set_exception(
242
+ CodexAppServerError(self._format_rpc_error(message["error"]))
243
+ )
244
+ else:
245
+ future.set_result(message.get("result"))
246
+ continue
247
+
248
+ method = message.get("method")
249
+ if request_id is not None and method:
250
+ await self._write(
251
+ {
252
+ "id": request_id,
253
+ "error": {
254
+ "code": -32601,
255
+ "message": (
256
+ "The host application does not expose Codex-side tools or "
257
+ "approvals."
258
+ ),
259
+ },
260
+ }
261
+ )
262
+ continue
263
+
264
+ params = message.get("params") or {}
265
+ thread_id = params.get("threadId")
266
+ queue = self._thread_queues.get(thread_id) if isinstance(thread_id, str) else None
267
+ if queue is not None:
268
+ queue.put_nowait(message)
269
+ except asyncio.CancelledError:
270
+ raise
271
+ except Exception as exc:
272
+ logger.debug("Codex app-server reader stopped: %s", exc, exc_info=True)
273
+ finally:
274
+ error = CodexAppServerError("Codex app-server exited before the request completed.")
275
+ for future in list(self._pending.values()):
276
+ if not future.done():
277
+ future.set_exception(error)
278
+
279
+ async def _read_stderr(self) -> None:
280
+ assert self.process is not None and self.process.stderr is not None
281
+ try:
282
+ while line := await self.process.stderr.readline():
283
+ logger.debug("codex app-server: %s", line.decode(errors="replace").rstrip())
284
+ except asyncio.CancelledError:
285
+ raise
286
+
287
+ @staticmethod
288
+ def _format_rpc_error(error: Any) -> str:
289
+ if isinstance(error, dict):
290
+ message = error.get("message") or "Codex app-server request failed"
291
+ data = error.get("data")
292
+ return f"{message}: {data}" if data else str(message)
293
+ return str(error)
294
+
295
+ async def run_completion(
296
+ self,
297
+ *,
298
+ model: str,
299
+ effort: str | None,
300
+ cwd: str,
301
+ base_instructions: str,
302
+ developer_instructions: str,
303
+ inputs: list[dict[str, Any]],
304
+ output_schema: dict[str, Any],
305
+ timeout_seconds: float,
306
+ service_name: str = "codex-client-provider",
307
+ ) -> dict[str, Any]:
308
+ await self.start()
309
+ thread_params: dict[str, Any] = {
310
+ "approvalPolicy": "never",
311
+ "sandbox": "read-only",
312
+ "cwd": cwd,
313
+ "ephemeral": True,
314
+ "serviceName": service_name,
315
+ "baseInstructions": base_instructions,
316
+ "developerInstructions": developer_instructions,
317
+ "allowProviderModelFallback": False,
318
+ }
319
+ if model and model.lower() not in {"auto", "default"}:
320
+ thread_params["model"] = model
321
+
322
+ started = await self.request("thread/start", thread_params)
323
+ thread = (started or {}).get("thread") or {}
324
+ thread_id = thread.get("id")
325
+ if not thread_id:
326
+ raise CodexAppServerError("Codex app-server did not return a thread id.")
327
+
328
+ queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
329
+ self._thread_queues[thread_id] = queue
330
+ usage: dict[str, Any] | None = None
331
+ try:
332
+ turn_params: dict[str, Any] = {
333
+ "threadId": thread_id,
334
+ "input": inputs,
335
+ "outputSchema": output_schema,
336
+ }
337
+ if effort and effort != "none":
338
+ turn_params["effort"] = effort
339
+ await self.request("turn/start", turn_params)
340
+
341
+ async def wait_for_turn() -> dict[str, Any]:
342
+ nonlocal usage
343
+ while True:
344
+ event = await queue.get()
345
+ method = event.get("method")
346
+ params = event.get("params") or {}
347
+ if method == "thread/tokenUsage/updated":
348
+ usage = params.get("tokenUsage")
349
+ if method == "turn/completed":
350
+ return params.get("turn") or {}
351
+
352
+ turn = await asyncio.wait_for(wait_for_turn(), timeout=timeout_seconds)
353
+ except TimeoutError as exc:
354
+ raise CodexAppServerError(
355
+ f"Codex model call timed out after {timeout_seconds:g} seconds."
356
+ ) from exc
357
+ finally:
358
+ self._thread_queues.pop(thread_id, None)
359
+
360
+ if turn.get("status") != "completed":
361
+ error = turn.get("error") or turn.get("status") or "unknown error"
362
+ raise CodexAppServerError(f"Codex turn failed: {error}")
363
+
364
+ text_parts: list[str] = []
365
+ for item in turn.get("items") or []:
366
+ if item.get("type") == "agentMessage" and item.get("text"):
367
+ text_parts.append(str(item["text"]))
368
+ if not text_parts:
369
+ raise CodexAppServerError("Codex turn completed without an assistant message.")
370
+ return {
371
+ "text": "\n".join(text_parts),
372
+ "thread_id": thread_id,
373
+ "model": (started or {}).get("model") or model,
374
+ "usage": usage,
375
+ }
376
+
377
+ async def close(self) -> None:
378
+ process = self.process
379
+ self.process = None
380
+ tasks = [task for task in (self._reader_task, self._stderr_task) if task is not None]
381
+ self._reader_task = None
382
+ self._stderr_task = None
383
+ for task in tasks:
384
+ if task and not task.done():
385
+ task.cancel()
386
+ if process and process.returncode is None:
387
+ process.terminate()
388
+ try:
389
+ await asyncio.wait_for(process.wait(), timeout=3)
390
+ except TimeoutError:
391
+ process.kill()
392
+ await process.wait()
393
+ if tasks:
394
+ await asyncio.gather(*tasks, return_exceptions=True)
395
+
396
+ def terminate(self) -> None:
397
+ if self.process and self.process.returncode is None:
398
+ self.process.terminate()
399
+
400
+
401
+ _ClientKey = tuple[str, str, str, str]
402
+ _clients: weakref.WeakKeyDictionary[
403
+ asyncio.AbstractEventLoop, dict[_ClientKey, CodexAppServerClient]
404
+ ] = weakref.WeakKeyDictionary()
405
+
406
+
407
+ def _client_for_running_loop(
408
+ *,
409
+ binary: str | None = None,
410
+ client_name: str = "codex_client_provider",
411
+ client_title: str = "Codex Client Provider",
412
+ client_version: str = PACKAGE_VERSION,
413
+ ) -> CodexAppServerClient:
414
+ loop = asyncio.get_running_loop()
415
+ resolved_binary = find_codex_binary(binary)
416
+ if not resolved_binary:
417
+ raise CodexAppServerError(
418
+ "Codex CLI was not found. Set CODEX_CLIENT_BIN or install the Codex client."
419
+ )
420
+ key = (resolved_binary, client_name, client_title, client_version)
421
+ clients = _clients.setdefault(loop, {})
422
+ client = clients.get(key)
423
+ if client is None:
424
+ client = CodexAppServerClient(
425
+ resolved_binary,
426
+ client_name=client_name,
427
+ client_title=client_title,
428
+ client_version=client_version,
429
+ )
430
+ clients[key] = client
431
+ return client
432
+
433
+
434
+ @atexit.register
435
+ def _terminate_clients() -> None:
436
+ for clients in list(_clients.values()):
437
+ for client in clients.values():
438
+ client.terminate()
439
+
440
+
441
+ _IMAGE_SUFFIXES = {
442
+ "image/gif": ".gif",
443
+ "image/jpeg": ".jpg",
444
+ "image/png": ".png",
445
+ "image/webp": ".webp",
446
+ }
447
+
448
+ CODEX_IMAGE_MAX_EDGE = 1600
449
+ CODEX_IMAGE_MAX_BYTES = 768 * 1024
450
+ CODEX_IMAGE_JPEG_QUALITY = 82
451
+ CODEX_IMAGE_MIN_JPEG_QUALITY = 50
452
+
453
+
454
+ def _image_limit(name: str, default: int, minimum: int) -> int:
455
+ """Read a positive image limit while keeping malformed env values harmless."""
456
+ value = os.environ.get(name)
457
+ if value is None and name.startswith("CODEX_CLIENT_"):
458
+ legacy_name = name.replace("CODEX_CLIENT_", "ARTEMIS_CODEX_", 1)
459
+ value = os.environ.get(legacy_name)
460
+ try:
461
+ return max(minimum, int(value if value is not None else default))
462
+ except (TypeError, ValueError):
463
+ return default
464
+
465
+
466
+ def _rgb_image(image: Image.Image) -> Image.Image:
467
+ """Flatten transparency onto white and return an RGB frame for JPEG output."""
468
+ if image.mode in {"RGBA", "LA"} or (image.mode == "P" and "transparency" in image.info):
469
+ rgba = image.convert("RGBA")
470
+ background = Image.new("RGBA", rgba.size, "white")
471
+ return Image.alpha_composite(background, rgba).convert("RGB")
472
+ return image.convert("RGB")
473
+
474
+
475
+ def _prepare_image_bytes(raw: bytes, suffix: str) -> tuple[bytes, str, bool]:
476
+ """Downscale and compress an oversized model-bound screenshot.
477
+
478
+ Small images pass through byte-for-byte. An image that exceeds either the
479
+ configured edge or byte limit is converted to JPEG, capped by the longest
480
+ edge, and then reduced in quality/size until it fits the byte budget.
481
+ """
482
+ max_edge = _image_limit("CODEX_CLIENT_IMAGE_MAX_EDGE", CODEX_IMAGE_MAX_EDGE, 320)
483
+ max_bytes = _image_limit("CODEX_CLIENT_IMAGE_MAX_BYTES", CODEX_IMAGE_MAX_BYTES, 64 * 1024)
484
+ start_quality = _image_limit("CODEX_CLIENT_IMAGE_JPEG_QUALITY", CODEX_IMAGE_JPEG_QUALITY, 50)
485
+ start_quality = min(start_quality, 95)
486
+ min_quality = min(CODEX_IMAGE_MIN_JPEG_QUALITY, start_quality)
487
+
488
+ try:
489
+ with Image.open(BytesIO(raw)) as source:
490
+ source.seek(0)
491
+ image = ImageOps.exif_transpose(source).copy()
492
+ except (OSError, UnidentifiedImageError, ValueError):
493
+ return raw, suffix, False
494
+
495
+ original_size = image.size
496
+ if max(original_size) <= max_edge and len(raw) <= max_bytes:
497
+ return raw, suffix, False
498
+
499
+ image = _rgb_image(image)
500
+ if max(image.size) > max_edge:
501
+ image.thumbnail((max_edge, max_edge), Image.Resampling.LANCZOS)
502
+
503
+ quality = start_quality
504
+ encoded = raw
505
+ while True:
506
+ buffer = BytesIO()
507
+ image.save(
508
+ buffer,
509
+ format="JPEG",
510
+ quality=quality,
511
+ optimize=True,
512
+ progressive=True,
513
+ )
514
+ encoded = buffer.getvalue()
515
+ if len(encoded) <= max_bytes:
516
+ break
517
+ if quality > min_quality:
518
+ quality = max(min_quality, quality - 8)
519
+ continue
520
+ if max(image.size) <= 480:
521
+ break
522
+ width, height = image.size
523
+ scale = 0.85
524
+ image = image.resize(
525
+ (max(1, round(width * scale)), max(1, round(height * scale))),
526
+ Image.Resampling.LANCZOS,
527
+ )
528
+
529
+ logger.debug(
530
+ "Prepared Codex image: "
531
+ f"{original_size[0]}x{original_size[1]}/{len(raw)} bytes -> "
532
+ f"{image.size[0]}x{image.size[1]}/{len(encoded)} bytes (quality={quality})"
533
+ )
534
+ return encoded, ".jpg", True
535
+
536
+
537
+ def _write_temp_image(raw: bytes, suffix: str) -> Path:
538
+ temp_dir = Path(tempfile.gettempdir()) / "codex-client-provider-images"
539
+ temp_dir.mkdir(parents=True, exist_ok=True)
540
+ path = temp_dir / f"{uuid4().hex}{suffix}"
541
+ path.write_bytes(raw)
542
+ return path
543
+
544
+
545
+ def _materialize_data_image(url: str) -> Path:
546
+ """Write a data-URL image to a temporary file for App Server.
547
+
548
+ App Server's remote ``image`` input accepts a URL string, but a large data
549
+ URL can leave the turn waiting indefinitely. Its native ``localImage``
550
+ input is the reliable path for screenshots already resident on the host.
551
+ """
552
+ header, separator, payload = url.partition(",")
553
+ if not separator or not header.lower().startswith("data:image/"):
554
+ raise ValueError("Invalid image data URL")
555
+ media_type = header[5:].split(";", 1)[0].lower()
556
+ if ";base64" in header.lower():
557
+ raw = base64.b64decode(payload)
558
+ else:
559
+ raw = unquote_to_bytes(payload)
560
+ suffix = _IMAGE_SUFFIXES.get(media_type, ".img")
561
+ prepared, prepared_suffix, _ = _prepare_image_bytes(raw, suffix)
562
+ return _write_temp_image(prepared, prepared_suffix)
563
+
564
+
565
+ def _prepare_local_image(path: Path) -> tuple[Path, bool]:
566
+ """Return a model-ready local image and whether the returned path is temporary."""
567
+ try:
568
+ raw = path.read_bytes()
569
+ except OSError:
570
+ return path, False
571
+ prepared, suffix, changed = _prepare_image_bytes(raw, path.suffix or ".img")
572
+ if not changed:
573
+ return path, False
574
+ return _write_temp_image(prepared, suffix), True
575
+
576
+
577
+ def _content_blocks(
578
+ content: Any, *, materialize_images: bool = True
579
+ ) -> tuple[list[str], list[dict[str, Any]], list[Path]]:
580
+ """Convert LangChain text/image content into app-server turn inputs."""
581
+ if isinstance(content, str):
582
+ return ([content] if content else []), [], []
583
+ if not isinstance(content, list):
584
+ return [str(content)], [], []
585
+
586
+ texts: list[str] = []
587
+ images: list[dict[str, Any]] = []
588
+ temp_paths: list[Path] = []
589
+ for block in content:
590
+ if isinstance(block, str):
591
+ texts.append(block)
592
+ continue
593
+ if not isinstance(block, dict):
594
+ texts.append(str(block))
595
+ continue
596
+ block_type = block.get("type")
597
+ if block_type in {"text", "input_text"}:
598
+ texts.append(str(block.get("text") or ""))
599
+ continue
600
+ if block_type in {"image_url", "input_image", "image"}:
601
+ image_url = block.get("image_url") or block.get("url")
602
+ if isinstance(image_url, dict):
603
+ image_url = image_url.get("url")
604
+ source = block.get("source")
605
+ if not image_url and isinstance(source, dict) and source.get("data"):
606
+ media_type = source.get("media_type") or "image/png"
607
+ image_url = f"data:{media_type};base64,{source['data']}"
608
+ if image_url and materialize_images:
609
+ image_url = str(image_url)
610
+ if image_url.lower().startswith("data:image/"):
611
+ path = _materialize_data_image(image_url)
612
+ temp_paths.append(path)
613
+ images.append({"type": "localImage", "path": str(path.resolve())})
614
+ elif Path(image_url).is_file():
615
+ path, temporary = _prepare_local_image(Path(image_url).resolve())
616
+ if temporary:
617
+ temp_paths.append(path)
618
+ images.append({"type": "localImage", "path": str(path)})
619
+ else:
620
+ images.append({"type": "image", "url": image_url})
621
+ continue
622
+ texts.append(json.dumps(block, ensure_ascii=False, default=str))
623
+ return texts, images, temp_paths
624
+
625
+
626
+ def _message_transcript(
627
+ messages: list[BaseMessage],
628
+ ) -> tuple[str, list[dict[str, Any]], list[Path]]:
629
+ sections: list[str] = []
630
+ images: list[dict[str, Any]] = []
631
+ temp_paths: list[Path] = []
632
+ for message in messages:
633
+ if isinstance(message, SystemMessage):
634
+ continue
635
+ texts, message_images, message_temp_paths = _content_blocks(message.content)
636
+ images.extend(message_images)
637
+ temp_paths.extend(message_temp_paths)
638
+ if isinstance(message, HumanMessage):
639
+ role = "USER"
640
+ elif isinstance(message, ToolMessage):
641
+ role = f"TOOL RESULT ({message.name or message.tool_call_id})"
642
+ elif isinstance(message, AIMessage):
643
+ role = "ASSISTANT"
644
+ if message.tool_calls:
645
+ texts.append(
646
+ "Tool calls: " + json.dumps(message.tool_calls, ensure_ascii=False, default=str)
647
+ )
648
+ else:
649
+ role = message.type.upper()
650
+ sections.append(f"[{role}]\n" + "\n".join(texts))
651
+ return "\n\n".join(sections), images, temp_paths
652
+
653
+
654
+ def _system_instructions(messages: list[BaseMessage]) -> str:
655
+ parts: list[str] = []
656
+ for message in messages:
657
+ if isinstance(message, SystemMessage):
658
+ texts, _, _ = _content_blocks(message.content, materialize_images=False)
659
+ parts.extend(texts)
660
+ return (
661
+ "\n\n".join(parts) or "Follow the user's instructions and return only the requested result."
662
+ )
663
+
664
+
665
+ def _tool_choice_name(tool_choice: Any) -> tuple[bool, str | None]:
666
+ if tool_choice is True or tool_choice in ("any", "required"):
667
+ return True, None
668
+ if isinstance(tool_choice, str) and tool_choice not in {"auto", "none"}:
669
+ return True, tool_choice
670
+ if isinstance(tool_choice, dict):
671
+ function = tool_choice.get("function") or {}
672
+ name = function.get("name") or tool_choice.get("name")
673
+ return bool(name), name
674
+ return False, None
675
+
676
+
677
+ def _response_contract(tools: list[dict[str, Any]], tool_choice: Any) -> tuple[dict[str, Any], str]:
678
+ if not tools:
679
+ schema = {
680
+ "type": "object",
681
+ "properties": {"content": {"type": "string"}},
682
+ "required": ["content"],
683
+ "additionalProperties": False,
684
+ }
685
+ return schema, "Return a JSON object with one string field named content."
686
+
687
+ required, forced_name = _tool_choice_name(tool_choice)
688
+ available = [tool["function"]["name"] for tool in tools]
689
+ if forced_name:
690
+ if forced_name not in available:
691
+ raise ValueError(f"Unknown forced tool {forced_name!r}; available tools: {available}")
692
+ allowed_names = [forced_name]
693
+ else:
694
+ allowed_names = available
695
+ kinds = ["tool_call"] if required else ["tool_call", "final"]
696
+ schema = {
697
+ "type": "object",
698
+ "properties": {
699
+ "kind": {"type": "string", "enum": kinds},
700
+ "content": {"type": "string"},
701
+ "tool_name": {"type": "string", "enum": allowed_names},
702
+ "tool_arguments_json": {"type": "string"},
703
+ },
704
+ "required": ["kind", "content", "tool_name", "tool_arguments_json"],
705
+ "additionalProperties": False,
706
+ }
707
+ contract = (
708
+ "The host application owns tool execution. Do not run shell commands, browse, edit files, "
709
+ "or use any Codex built-in tools. Choose from the supplied tool schemas. Return exactly "
710
+ "the "
711
+ "JSON object required by the output schema. For kind=tool_call, set tool_name and encode "
712
+ "the arguments object as JSON in tool_arguments_json; content may be empty. For "
713
+ "kind=final, "
714
+ "put the answer in content and use the first available tool name with '{}' arguments."
715
+ )
716
+ return schema, contract
717
+
718
+
719
+ def _parse_response(text: str) -> dict[str, Any]:
720
+ candidate = text.strip()
721
+ if candidate.startswith("```"):
722
+ lines = candidate.splitlines()
723
+ if lines and lines[0].startswith("```"):
724
+ lines = lines[1:]
725
+ if lines and lines[-1].strip() == "```":
726
+ lines = lines[:-1]
727
+ candidate = "\n".join(lines)
728
+ try:
729
+ value = json.loads(candidate)
730
+ return value if isinstance(value, dict) else {"content": str(value)}
731
+ except json.JSONDecodeError:
732
+ return {"content": text}
733
+
734
+
735
+ class CodexAppServerChatModel(BaseChatModel):
736
+ """A LangChain chat model backed by the signed-in Codex desktop/CLI client."""
737
+
738
+ model_name: str = Field(default="default")
739
+ reasoning_effort: str | None = Field(default=None)
740
+ timeout_seconds: float = Field(default=180.0, gt=0)
741
+ cwd: str = Field(default_factory=os.getcwd)
742
+ codex_binary: str | None = Field(default=None)
743
+ client_name: str = Field(default="codex_client_provider")
744
+ client_title: str = Field(default="Codex Client Provider")
745
+ client_version: str = Field(default=PACKAGE_VERSION)
746
+ service_name: str = Field(default="codex-client-provider")
747
+
748
+ @property
749
+ def _llm_type(self) -> str:
750
+ return "codex-app-server"
751
+
752
+ @property
753
+ def _identifying_params(self) -> dict[str, Any]:
754
+ return {
755
+ "model_name": self.model_name,
756
+ "reasoning_effort": self.reasoning_effort,
757
+ "transport": "app-server-stdio",
758
+ }
759
+
760
+ def bind_tools(
761
+ self,
762
+ tools: Sequence[dict[str, Any] | type | Callable[..., Any] | BaseTool],
763
+ *,
764
+ tool_choice: str | None = None,
765
+ **kwargs: Any,
766
+ ):
767
+ formatted = [convert_to_openai_tool(tool) for tool in tools]
768
+ return self.bind(tools=formatted, tool_choice=tool_choice, **kwargs)
769
+
770
+ async def _agenerate(
771
+ self,
772
+ messages: list[BaseMessage],
773
+ stop: list[str] | None = None,
774
+ run_manager: AsyncCallbackManagerForLLMRun | None = None,
775
+ **kwargs: Any,
776
+ ) -> ChatResult:
777
+ del stop, run_manager
778
+ tools = list(kwargs.get("tools") or [])
779
+ tool_choice = kwargs.get("tool_choice")
780
+ output_schema, contract = _response_contract(tools, tool_choice)
781
+ transcript, images, temp_paths = _message_transcript(messages)
782
+ inputs: list[dict[str, Any]] = [
783
+ {"type": "text", "text": transcript or "Continue from the supplied instructions."}
784
+ ]
785
+ inputs.extend(images)
786
+ developer_parts = [contract]
787
+ if tools:
788
+ developer_parts.append(
789
+ "Available host tools:\n" + json.dumps(tools, ensure_ascii=False, default=str)
790
+ )
791
+ try:
792
+ result = await _client_for_running_loop(
793
+ binary=self.codex_binary,
794
+ client_name=self.client_name,
795
+ client_title=self.client_title,
796
+ client_version=self.client_version,
797
+ ).run_completion(
798
+ model=self.model_name,
799
+ effort=self.reasoning_effort,
800
+ cwd=str(Path(self.cwd).resolve()),
801
+ base_instructions=_system_instructions(messages),
802
+ developer_instructions="\n\n".join(developer_parts),
803
+ inputs=inputs,
804
+ output_schema=output_schema,
805
+ timeout_seconds=self.timeout_seconds,
806
+ service_name=self.service_name,
807
+ )
808
+ finally:
809
+ for path in temp_paths:
810
+ try:
811
+ path.unlink(missing_ok=True)
812
+ except OSError:
813
+ logger.debug("Could not remove temporary Codex image %s", path)
814
+ parsed = _parse_response(result["text"])
815
+ response_metadata = {
816
+ "provider": "codex",
817
+ "model": result.get("model") or self.model_name,
818
+ "thread_id": result.get("thread_id"),
819
+ }
820
+ usage_metadata = None
821
+ last_usage = (result.get("usage") or {}).get("last") or {}
822
+ if last_usage:
823
+ usage_metadata = {
824
+ "input_tokens": int(last_usage.get("inputTokens") or 0),
825
+ "output_tokens": int(last_usage.get("outputTokens") or 0),
826
+ "total_tokens": int(last_usage.get("totalTokens") or 0),
827
+ }
828
+
829
+ if tools and parsed.get("kind") == "tool_call":
830
+ name = parsed.get("tool_name")
831
+ raw_args = parsed.get("tool_arguments_json") or "{}"
832
+ if isinstance(raw_args, str):
833
+ try:
834
+ args = json.loads(raw_args)
835
+ except json.JSONDecodeError as exc:
836
+ raise CodexAppServerError(
837
+ f"Codex returned invalid JSON arguments for tool {name!r}: {raw_args}"
838
+ ) from exc
839
+ else:
840
+ args = raw_args
841
+ message = AIMessage(
842
+ content=parsed.get("content") or "",
843
+ tool_calls=[
844
+ {
845
+ "name": str(name),
846
+ "args": args,
847
+ "id": f"call_{uuid4().hex}",
848
+ "type": "tool_call",
849
+ }
850
+ ],
851
+ response_metadata=response_metadata,
852
+ usage_metadata=usage_metadata,
853
+ )
854
+ else:
855
+ message = AIMessage(
856
+ content=str(parsed.get("content") or result["text"]),
857
+ response_metadata=response_metadata,
858
+ usage_metadata=usage_metadata,
859
+ )
860
+ return ChatResult(generations=[ChatGeneration(message=message)])
861
+
862
+ def _generate(
863
+ self,
864
+ messages: list[BaseMessage],
865
+ stop: list[str] | None = None,
866
+ run_manager: CallbackManagerForLLMRun | None = None,
867
+ **kwargs: Any,
868
+ ) -> ChatResult:
869
+ del run_manager
870
+
871
+ async def invoke_and_close() -> ChatResult:
872
+ loop = asyncio.get_running_loop()
873
+ try:
874
+ return await self._agenerate(messages, stop=stop, **kwargs)
875
+ finally:
876
+ clients = _clients.pop(loop, {})
877
+ if clients:
878
+ await asyncio.gather(
879
+ *(client.close() for client in clients.values()),
880
+ return_exceptions=True,
881
+ )
882
+
883
+ return asyncio.run(invoke_and_close())
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,176 @@
1
+ Metadata-Version: 2.4
2
+ Name: codex-client-provider
3
+ Version: 0.1.0
4
+ Summary: Reusable LangChain provider for a locally signed-in Codex App Server
5
+ Author: Codex Client Provider contributors
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/HJunLong601/codex-client-provider
8
+ Project-URL: Documentation, https://github.com/HJunLong601/codex-client-provider#readme
9
+ Project-URL: Issues, https://github.com/HJunLong601/codex-client-provider/issues
10
+ Project-URL: Repository, https://github.com/HJunLong601/codex-client-provider
11
+ Keywords: codex,langchain,openai,app-server,llm
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: langchain-core>=1.0.0
25
+ Requires-Dist: pillow>=10.0.0
26
+ Requires-Dist: pydantic>=2.7.0
27
+ Provides-Extra: dev
28
+ Requires-Dist: build>=1.2.2; extra == "dev"
29
+ Requires-Dist: pyright>=1.1.390; extra == "dev"
30
+ Requires-Dist: pytest>=8.3; extra == "dev"
31
+ Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
32
+ Requires-Dist: ruff>=0.9; extra == "dev"
33
+ Requires-Dist: twine>=6.0; extra == "dev"
34
+ Dynamic: license-file
35
+
36
+ # Codex Client Provider
37
+
38
+ [![CI](https://github.com/HJunLong601/codex-client-provider/actions/workflows/ci.yml/badge.svg)](https://github.com/HJunLong601/codex-client-provider/actions/workflows/ci.yml)
39
+ [![Python](https://img.shields.io/pypi/pyversions/codex-client-provider.svg)](https://pypi.org/project/codex-client-provider/)
40
+ [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
41
+
42
+ Reusable Python and LangChain adapter for the official `codex app-server`
43
+ protocol. It lets a local application use the Codex model access already managed
44
+ by the installed Codex client, without asking the user for an OpenAI API key.
45
+
46
+ The provider never reads, copies, logs, or stores Codex authentication files. It
47
+ starts `codex app-server`, communicates through JSONL over stdio, and leaves
48
+ authentication and account policy enforcement to the Codex client.
49
+
50
+ > This is an unofficial community project. Codex and OpenAI are trademarks of
51
+ > OpenAI. See the official [Codex App Server documentation](https://developers.openai.com/codex/app-server/)
52
+ > for the protocol and compatibility guidance.
53
+
54
+ ## Features
55
+
56
+ - Persistent App Server process with request correlation and clean shutdown.
57
+ - LangChain `BaseChatModel` with sync and async invocation.
58
+ - Text, local image, and data URL image inputs.
59
+ - LangChain tool calls while keeping tool execution inside the host application.
60
+ - Structured output through Codex output schemas.
61
+ - Bounded image resizing and JPEG compression with temporary-file cleanup.
62
+ - Model, reasoning effort, timeout, client identity, and Codex binary overrides.
63
+ - Windows, macOS, and Linux support when the Codex CLI is available.
64
+
65
+ ```mermaid
66
+ flowchart LR
67
+ A[Host application] --> B[Codex Client Provider]
68
+ B -->|JSONL over stdio| C[codex app-server]
69
+ C --> D[Existing Codex login]
70
+ D --> E[Codex model]
71
+ B -->|tool call request| A
72
+ ```
73
+
74
+ ## Requirements
75
+
76
+ - Python 3.10 or newer.
77
+ - A compatible Codex CLI or Codex desktop installation.
78
+ - An active Codex login.
79
+
80
+ Verify the login before using the provider:
81
+
82
+ ```console
83
+ codex login
84
+ codex login status
85
+ ```
86
+
87
+ ## Install
88
+
89
+ Install the current source checkout:
90
+
91
+ ```console
92
+ python -m pip install .
93
+ ```
94
+
95
+ After the first PyPI release, install it with:
96
+
97
+ ```console
98
+ python -m pip install codex-client-provider
99
+ ```
100
+
101
+ ## Basic usage
102
+
103
+ ```python
104
+ from codex_client_provider import CodexAppServerChatModel
105
+
106
+ model = CodexAppServerChatModel(
107
+ model_name="default",
108
+ reasoning_effort="low",
109
+ timeout_seconds=120,
110
+ )
111
+ response = model.invoke("Return a concise status message.")
112
+ print(response.content)
113
+ ```
114
+
115
+ The provider also supports LangChain tools:
116
+
117
+ ```python
118
+ from langchain_core.tools import tool
119
+ from codex_client_provider import CodexAppServerChatModel
120
+
121
+
122
+ @tool
123
+ def lookup_temperature(city: str) -> str:
124
+ """Return a sample temperature for a city."""
125
+ return f"{city}: 23 C"
126
+
127
+
128
+ model = CodexAppServerChatModel().bind_tools([lookup_temperature])
129
+ message = model.invoke("What is the temperature in Shanghai?")
130
+ for call in message.tool_calls:
131
+ result = lookup_temperature.invoke(call["args"])
132
+ print(result)
133
+ ```
134
+
135
+ The host application remains responsible for running tools. The provider only
136
+ asks Codex to return a LangChain-compatible tool call, which prevents the model
137
+ from silently using Codex built-in shell, browser, or file tools on the host's
138
+ behalf.
139
+
140
+ ## Configuration
141
+
142
+ | Setting | Default | Purpose |
143
+ | --- | --- | --- |
144
+ | `CODEX_CLIENT_BIN` | Auto-detected | Absolute path to `codex` or `codex.exe`. |
145
+ | `CODEX_CLIENT_IMAGE_MAX_EDGE` | `1600` | Maximum image width or height in pixels. |
146
+ | `CODEX_CLIENT_IMAGE_MAX_BYTES` | `786432` | Target maximum encoded image size. |
147
+ | `CODEX_CLIENT_IMAGE_JPEG_QUALITY` | `82` | Initial JPEG quality for compressed images. |
148
+
149
+ The constructor accepts `model_name`, `reasoning_effort`, `timeout_seconds`,
150
+ `codex_binary`, and `service_name`. The provider uses the App Server's read-only
151
+ sandbox for model turns.
152
+
153
+ For compatibility with the original Artemis integration, the legacy
154
+ `ARTEMIS_CODEX_*` environment variable names are accepted when the corresponding
155
+ `CODEX_CLIENT_*` variable is absent.
156
+
157
+ ## Development
158
+
159
+ ```console
160
+ python -m pip install -e ".[dev]"
161
+ ruff check .
162
+ ruff format --check .
163
+ pyright
164
+ pytest
165
+ python -m build
166
+ twine check dist/*
167
+ ```
168
+
169
+ Run the opt-in live smoke test only on a machine with a working Codex login:
170
+
171
+ ```console
172
+ python scripts/smoke.py
173
+ ```
174
+
175
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the release workflow and
176
+ [SECURITY.md](SECURITY.md) for the authentication boundary.
@@ -0,0 +1,8 @@
1
+ codex_client_provider/__init__.py,sha256=RHL8kJ3leIf7QMQbgaoL0iTX1D0xy3imgLzH-soX1I0,586
2
+ codex_client_provider/langchain.py,sha256=thwNawtzdXqvX75OMU1Too5qGCPrde8zaSR6sCTu1AU,33936
3
+ codex_client_provider/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
4
+ codex_client_provider-0.1.0.dist-info/licenses/LICENSE,sha256=T95qlvhXorl2cU1iDvwpzLcgmBCjaZKlNDvP4CQLaCY,11344
5
+ codex_client_provider-0.1.0.dist-info/METADATA,sha256=tmlq7crE1YvjTTpt8WazbFrOrrmmrmLRmXQ3dYfq7pM,6071
6
+ codex_client_provider-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
7
+ codex_client_provider-0.1.0.dist-info/top_level.txt,sha256=pXlEhxj4UIjvFQeEX6__voue1Catkn_z1ctI5R7fkgk,22
8
+ codex_client_provider-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ https://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright 2026 Google LLC.
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ https://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
@@ -0,0 +1 @@
1
+ codex_client_provider