feat(llm): add OpenAI adapter, entity archive policy, process chapters 5-7
Add OpenAIAdapter for the OpenAI chat completions API (apikey-chatgpt.txt or OPENAI_API_KEY). Set default model to arcee-ai/trinity-large-preview:free for the infospace pipeline and increase max_tokens from 4096 to 8192. Reprocess chapter 05 with Trinity Large (was Gemini: 1 truncated entity, now 19 complete entities). Process chapters 06 (Aurora Alpha, 10 entities) and 07 (Trinity Large, 15 entities including regenerated violent-policy.md). Canonical set now at 85 unique entities. Add entity archive policy: entities are never silently deleted. Retired entities move to output/entities/archive/ with a dated reason header. New CLI option: --archive-entity <slug> --reason "...". The --list output shows the archive count alongside the canonical set. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ from markitect.llm.factory import create_adapter
|
||||
from markitect.llm.openrouter import OpenRouterAdapter
|
||||
from markitect.llm.claude_code import ClaudeCodeAdapter
|
||||
from markitect.llm.gemini import GeminiAdapter
|
||||
from markitect.llm.openai import OpenAIAdapter
|
||||
from markitect.llm.config import LLMConfig, load_config
|
||||
from markitect.llm.exceptions import (
|
||||
LLMError,
|
||||
@@ -31,6 +32,7 @@ __all__ = [
|
||||
"OpenRouterAdapter",
|
||||
"ClaudeCodeAdapter",
|
||||
"GeminiAdapter",
|
||||
"OpenAIAdapter",
|
||||
"LLMConfig",
|
||||
"load_config",
|
||||
"LLMError",
|
||||
|
||||
@@ -12,6 +12,7 @@ _PROVIDERS: Dict[str, str] = {
|
||||
"openrouter": "markitect.llm.openrouter.OpenRouterAdapter",
|
||||
"claude-code": "markitect.llm.claude_code.ClaudeCodeAdapter",
|
||||
"gemini": "markitect.llm.gemini.GeminiAdapter",
|
||||
"openai": "markitect.llm.openai.OpenAIAdapter",
|
||||
}
|
||||
|
||||
|
||||
@@ -25,10 +26,10 @@ def create_adapter(
|
||||
"""Instantiate an :class:`LLMAdapter` for the given *provider*.
|
||||
|
||||
Args:
|
||||
provider: ``"openrouter"``, ``"claude-code"``, or ``"gemini"``.
|
||||
provider: ``"openrouter"``, ``"claude-code"``, ``"gemini"``, or ``"openai"``.
|
||||
model: Model name (passed to the adapter constructor).
|
||||
api_key: Explicit API key (OpenRouter / Gemini).
|
||||
system_prompt: Optional system prompt (OpenRouter / Gemini).
|
||||
api_key: Explicit API key (OpenRouter / Gemini / OpenAI).
|
||||
system_prompt: Optional system prompt (OpenRouter / Gemini / OpenAI).
|
||||
**kwargs: Extra keyword arguments forwarded to the adapter.
|
||||
|
||||
Returns:
|
||||
@@ -51,7 +52,7 @@ def create_adapter(
|
||||
mod = importlib.import_module(module_path)
|
||||
cls = getattr(mod, class_name)
|
||||
|
||||
if provider in ("openrouter", "gemini"):
|
||||
if provider in ("openrouter", "gemini", "openai"):
|
||||
return cls(model=model, api_key=api_key, system_prompt=system_prompt, **kwargs)
|
||||
elif provider == "claude-code":
|
||||
return cls(model=model, **kwargs)
|
||||
|
||||
129
markitect/llm/openai.py
Normal file
129
markitect/llm/openai.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
OpenAI (ChatGPT) adapter — calls the OpenAI chat completions API.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from markitect.prompts.execution.llm_adapter import LLMAdapter
|
||||
from markitect.prompts.execution.models import RunConfig, LLMResponse
|
||||
from markitect.llm.config import resolve_api_key, find_project_root
|
||||
from markitect.llm._http import post_json
|
||||
from markitect.llm.exceptions import (
|
||||
LLMConfigurationError,
|
||||
LLMAPIError,
|
||||
LLMRateLimitError,
|
||||
)
|
||||
|
||||
_DEFAULT_MODEL = "gpt-4.1-mini"
|
||||
_API_BASE = "https://api.openai.com/v1"
|
||||
|
||||
|
||||
class OpenAIAdapter(LLMAdapter):
|
||||
"""LLM adapter that calls the OpenAI chat completions endpoint."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
system_prompt: Optional[str] = None,
|
||||
max_retries: int = 3,
|
||||
**_kwargs: Any,
|
||||
):
|
||||
self._model = model or _DEFAULT_MODEL
|
||||
self._system_prompt = system_prompt
|
||||
self._max_retries = max_retries
|
||||
|
||||
root = find_project_root()
|
||||
key_file_paths = [root / "apikey-chatgpt.txt"] if root else []
|
||||
self._api_key = resolve_api_key(
|
||||
explicit=api_key,
|
||||
env_var="OPENAI_API_KEY",
|
||||
key_file_paths=key_file_paths,
|
||||
)
|
||||
if not self._api_key:
|
||||
raise LLMConfigurationError(
|
||||
"No OpenAI API key found. Set OPENAI_API_KEY or create "
|
||||
"apikey-chatgpt.txt in the project root.",
|
||||
context={"provider": "openai"},
|
||||
)
|
||||
|
||||
# ── LLMAdapter interface ────────────────────────────────────────
|
||||
|
||||
def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
|
||||
model = self._model
|
||||
|
||||
messages: list[Dict[str, str]] = []
|
||||
if self._system_prompt:
|
||||
messages.append({"role": "system", "content": self._system_prompt})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": config.temperature,
|
||||
"max_tokens": config.max_tokens,
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self._api_key}",
|
||||
}
|
||||
url = f"{_API_BASE}/chat/completions"
|
||||
|
||||
start = time.time()
|
||||
data = self._post_with_retries(url, payload, headers, config.timeout_seconds)
|
||||
latency = time.time() - start
|
||||
|
||||
# Parse response (OpenAI chat completions format)
|
||||
choice = data.get("choices", [{}])[0]
|
||||
content = choice.get("message", {}).get("content", "")
|
||||
finish_reason = choice.get("finish_reason", "stop")
|
||||
usage = data.get("usage", {})
|
||||
|
||||
return LLMResponse(
|
||||
content=content,
|
||||
model=data.get("model", model),
|
||||
usage={
|
||||
"prompt_tokens": usage.get("prompt_tokens", 0),
|
||||
"completion_tokens": usage.get("completion_tokens", 0),
|
||||
"total_tokens": usage.get("total_tokens", 0),
|
||||
},
|
||||
finish_reason=finish_reason,
|
||||
metadata={
|
||||
"provider": "openai",
|
||||
"latency_seconds": round(latency, 3),
|
||||
"response_id": data.get("id", ""),
|
||||
},
|
||||
)
|
||||
|
||||
def validate_config(self, config: RunConfig) -> bool:
|
||||
if not self._api_key:
|
||||
return False
|
||||
if not (0.0 <= config.temperature <= 2.0):
|
||||
return False
|
||||
return True
|
||||
|
||||
# ── Internals ───────────────────────────────────────────────────
|
||||
|
||||
def _post_with_retries(
|
||||
self,
|
||||
url: str,
|
||||
payload: Dict[str, Any],
|
||||
headers: Dict[str, str],
|
||||
timeout: int,
|
||||
) -> Dict[str, Any]:
|
||||
last_exc: Optional[Exception] = None
|
||||
for attempt in range(self._max_retries + 1):
|
||||
try:
|
||||
return post_json(url, payload, headers, timeout=timeout)
|
||||
except LLMRateLimitError as exc:
|
||||
last_exc = exc
|
||||
if attempt < self._max_retries:
|
||||
time.sleep(2 ** attempt)
|
||||
except LLMAPIError as exc:
|
||||
if exc.status_code >= 500 and attempt < self._max_retries:
|
||||
last_exc = exc
|
||||
time.sleep(2 ** attempt)
|
||||
else:
|
||||
raise
|
||||
raise last_exc # type: ignore[misc]
|
||||
Reference in New Issue
Block a user