This commit is contained in:
+581
-7
@@ -1,9 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import nullcontext
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
@@ -11,6 +17,7 @@ from tzlocal import get_localzone
|
||||
|
||||
from traderai.memory import DEFAULT_THREAD_ID, MemoryStore, iso_now, iso_now_in_zone, time_since
|
||||
from traderai.tools import ToolRegistry
|
||||
from traderai.version import __version__
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """You are TraderAI, a local assistant for UEX marketplace work.
|
||||
@@ -19,7 +26,9 @@ Use continual plan tools when the user asks for multi-day or recurring marketpla
|
||||
UEX credentials are configured server-side when available. Never ask the user to provide UEX_SECRET_KEY or UEX_BEARER_TOKEN in chat; call the authenticated UEX tool and only mention credential configuration if the tool returns an authentication error.
|
||||
Use the specific UEX tool for the needed endpoint, such as get_uex_commodities_prices or get_uex_vehicles. Use fields, limit, and summary mode so tool results stay compact.
|
||||
When the user asks for history, trends, changes over time, or past prices, prefer the summarize_uex_*_history tools when available; use search_uex_api_index(history_only=true) if you need to discover history endpoints.
|
||||
When you need missing Star Citizen knowledge to answer accurately, use Star Citizen Wiki tools during your reasoning instead of guessing.
|
||||
Use SCMDB tools when the user asks about Star Citizen missions/contracts, mission rewards, payouts, reputation gains, item rewards, blueprint rewards, or hauling mission cargo. Prefer SCMDB live data unless the user asks for PTU or a specific game version.
|
||||
Use Star Citizen Wiki tools for general game knowledge, ships and vehicles, store availability, purchase locations, ship prices, manufacturers, locations, and page summaries from starcitizen.tools.
|
||||
Use Cornerstone tools when the user asks where an item is sold, which shops carry an item, item store locations, in-game item base prices, or Universal Item Finder data.
|
||||
When drafting UEX marketplace item posts that need images, use Cornerstone media tools or draft_marketplace_listing_with_cornerstone_image so the pending listing can include UEX image_data sourced from Cornerstone.
|
||||
Prefer open and current UEX marketplace information. Do not use historical sale data, completed sale records, or sale/average-history information unless the user explicitly asks for historical sales.
|
||||
@@ -41,6 +50,7 @@ class OllamaAgent:
|
||||
num_ctx: int | None = None,
|
||||
provider: str = "ollama",
|
||||
api_key: str | None = None,
|
||||
reasoning_effort: str = "medium",
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.model = model
|
||||
@@ -50,11 +60,14 @@ class OllamaAgent:
|
||||
self.num_ctx = num_ctx
|
||||
self.provider = provider.strip().casefold() or "ollama"
|
||||
self.api_key = api_key
|
||||
self.reasoning_effort = reasoning_effort.strip().casefold() or "medium"
|
||||
self.thread_messages: dict[str, list[dict[str, Any]]] = {}
|
||||
|
||||
async def health(self) -> dict[str, Any]:
|
||||
if self.provider == "openai":
|
||||
return await self._openai_health()
|
||||
if self.provider == "codex":
|
||||
return await self._codex_health()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3) as client:
|
||||
response = await client.get(f"{self.base_url}/api/tags")
|
||||
@@ -83,6 +96,8 @@ class OllamaAgent:
|
||||
health = await self.health()
|
||||
if not health["online"]:
|
||||
raise OllamaUnavailable(health["message"])
|
||||
if health.get("model_available") is False:
|
||||
raise OllamaUnavailable(health["message"])
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
@@ -304,6 +319,13 @@ class OllamaAgent:
|
||||
previous_interaction=previous_interaction,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
if self.provider == "codex":
|
||||
return await self._codex_chat(
|
||||
query,
|
||||
messages,
|
||||
previous_interaction=previous_interaction,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
return await self._ollama_chat(
|
||||
query,
|
||||
messages,
|
||||
@@ -327,6 +349,15 @@ class OllamaAgent:
|
||||
):
|
||||
yield event
|
||||
return
|
||||
if self.provider == "codex":
|
||||
async for event in self._codex_chat_stream(
|
||||
query,
|
||||
messages,
|
||||
previous_interaction=previous_interaction,
|
||||
thread_id=thread_id,
|
||||
):
|
||||
yield event
|
||||
return
|
||||
async for event in self._ollama_chat_stream(
|
||||
query,
|
||||
messages,
|
||||
@@ -410,6 +441,7 @@ class OllamaAgent:
|
||||
thread_id=thread_id,
|
||||
),
|
||||
"tools": self.tools.schemas,
|
||||
"reasoning_effort": self.reasoning_effort,
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
@@ -447,6 +479,7 @@ class OllamaAgent:
|
||||
thread_id=thread_id,
|
||||
),
|
||||
"tools": self.tools.schemas,
|
||||
"reasoning_effort": self.reasoning_effort,
|
||||
"stream": True,
|
||||
},
|
||||
) as response:
|
||||
@@ -487,6 +520,47 @@ class OllamaAgent:
|
||||
"done": True,
|
||||
}
|
||||
|
||||
async def _codex_chat(
|
||||
self,
|
||||
query: str = "",
|
||||
messages: list[dict[str, Any]] | None = None,
|
||||
previous_interaction: dict[str, Any] | None = None,
|
||||
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||
) -> dict[str, Any]:
|
||||
result = await self._codex_cli_turn(
|
||||
query,
|
||||
messages or self._messages_for_thread(thread_id),
|
||||
previous_interaction=previous_interaction,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
return self._codex_structured_response(result)
|
||||
|
||||
async def _codex_chat_stream(
|
||||
self,
|
||||
query: str = "",
|
||||
messages: list[dict[str, Any]] | None = None,
|
||||
previous_interaction: dict[str, Any] | None = None,
|
||||
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||
) -> AsyncIterator[dict[str, Any]]:
|
||||
result = await self._codex_cli_turn(
|
||||
query,
|
||||
messages or self._messages_for_thread(thread_id),
|
||||
previous_interaction=previous_interaction,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
response = self._codex_structured_response(result)
|
||||
message = response["message"]
|
||||
if message.get("content"):
|
||||
yield {"message": {"role": "assistant", "content": message["content"]}}
|
||||
yield {
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": message.get("tool_calls") or [],
|
||||
},
|
||||
"done": True,
|
||||
}
|
||||
|
||||
def _messages_with_context(
|
||||
self,
|
||||
query: str,
|
||||
@@ -511,15 +585,57 @@ class OllamaAgent:
|
||||
return [messages[0], {"role": "system", "content": context}, *messages[1:]]
|
||||
|
||||
async def _openai_health(self) -> dict[str, Any]:
|
||||
return await self._cloud_health("openai")
|
||||
|
||||
async def _codex_health(self) -> dict[str, Any]:
|
||||
command = self._codex_command()
|
||||
if not command:
|
||||
return {
|
||||
"online": False,
|
||||
"model": self.model,
|
||||
"base_url": self.base_url,
|
||||
"provider": "codex",
|
||||
"model_available": False,
|
||||
"models": [],
|
||||
"message": "Codex CLI was not found on PATH.",
|
||||
"detail": "",
|
||||
}
|
||||
try:
|
||||
account, models = await self._codex_app_server_status()
|
||||
except Exception as exc:
|
||||
return {
|
||||
"online": False,
|
||||
"model": self.model,
|
||||
"base_url": command,
|
||||
"provider": "codex",
|
||||
"model_available": False,
|
||||
"models": [],
|
||||
"message": "Codex App Server is installed, but TraderAI could not connect to it.",
|
||||
"detail": str(exc),
|
||||
}
|
||||
logged_in = bool(account)
|
||||
detail = f"Logged in as {account.get('email')}" if isinstance(account, dict) and account.get("email") else ""
|
||||
return {
|
||||
"online": logged_in,
|
||||
"model": self.model,
|
||||
"base_url": command,
|
||||
"provider": "codex",
|
||||
"model_available": self.model in models if models else bool(self.model),
|
||||
"models": models,
|
||||
"message": "Codex App Server is online." if logged_in else "Codex CLI is installed, but not logged in with ChatGPT.",
|
||||
"detail": detail,
|
||||
}
|
||||
|
||||
async def _cloud_health(self, provider: str) -> dict[str, Any]:
|
||||
if not self.api_key:
|
||||
return {
|
||||
"online": False,
|
||||
"model": self.model,
|
||||
"base_url": self.base_url,
|
||||
"provider": "openai",
|
||||
"provider": provider,
|
||||
"model_available": False,
|
||||
"models": [],
|
||||
"message": "OpenAI is selected, but no OpenAI API key is configured.",
|
||||
"message": f"{self._provider_label()} is selected, but no API key is configured.",
|
||||
"detail": "",
|
||||
}
|
||||
try:
|
||||
@@ -532,10 +648,10 @@ class OllamaAgent:
|
||||
"online": False,
|
||||
"model": self.model,
|
||||
"base_url": self.base_url,
|
||||
"provider": "openai",
|
||||
"provider": provider,
|
||||
"model_available": False,
|
||||
"models": [],
|
||||
"message": f"OpenAI is unreachable at {self.base_url} or rejected the API key.",
|
||||
"message": f"{self._provider_label()} is unreachable at {self.base_url} or rejected the API key.",
|
||||
"detail": str(exc),
|
||||
}
|
||||
models = sorted(item.get("id") for item in body.get("data", []) if item.get("id"))
|
||||
@@ -543,10 +659,10 @@ class OllamaAgent:
|
||||
"online": True,
|
||||
"model": self.model,
|
||||
"base_url": self.base_url,
|
||||
"provider": "openai",
|
||||
"provider": provider,
|
||||
"model_available": self.model in models,
|
||||
"models": models,
|
||||
"message": "OpenAI is online.",
|
||||
"message": f"{self._provider_label()} is online.",
|
||||
}
|
||||
|
||||
def _openai_headers(self) -> dict[str, str]:
|
||||
@@ -595,8 +711,27 @@ class OllamaAgent:
|
||||
normalized.append(entry)
|
||||
return normalized
|
||||
|
||||
def _codex_tool_catalog(self) -> list[dict[str, Any]]:
|
||||
tools: list[dict[str, Any]] = []
|
||||
for schema in self.tools.schemas:
|
||||
if schema.get("type") != "function":
|
||||
continue
|
||||
function = schema.get("function") or {}
|
||||
tools.append(
|
||||
{
|
||||
"name": function.get("name", ""),
|
||||
"description": function.get("description", ""),
|
||||
"parameters": function.get("parameters") or {"type": "object", "properties": {}},
|
||||
}
|
||||
)
|
||||
return tools
|
||||
|
||||
def _provider_label(self) -> str:
|
||||
return "OpenAI model" if self.provider == "openai" else "local model"
|
||||
if self.provider == "openai":
|
||||
return "OpenAI model"
|
||||
if self.provider == "codex":
|
||||
return "Codex model"
|
||||
return "local model"
|
||||
|
||||
@staticmethod
|
||||
def _merge_openai_tool_call(target: dict[int, dict[str, Any]], delta: dict[str, Any]) -> None:
|
||||
@@ -615,6 +750,431 @@ class OllamaAgent:
|
||||
def _ordered_tool_calls(tool_calls: dict[int, dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return [tool_calls[index] for index in sorted(tool_calls)]
|
||||
|
||||
async def _codex_cli_turn(
|
||||
self,
|
||||
query: str,
|
||||
messages: list[dict[str, Any]],
|
||||
previous_interaction: dict[str, Any] | None = None,
|
||||
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||
) -> dict[str, Any]:
|
||||
return await self._codex_app_server_turn(
|
||||
query,
|
||||
messages,
|
||||
previous_interaction=previous_interaction,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
async def _codex_app_server_turn(
|
||||
self,
|
||||
query: str,
|
||||
messages: list[dict[str, Any]],
|
||||
previous_interaction: dict[str, Any] | None = None,
|
||||
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||
) -> dict[str, Any]:
|
||||
prompt = self._codex_cli_prompt(
|
||||
query,
|
||||
messages,
|
||||
previous_interaction=previous_interaction,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
final_text = ""
|
||||
|
||||
process = await self._start_codex_app_server()
|
||||
request_id = 1
|
||||
|
||||
async def send_request(method: str, params: dict[str, Any] | None = None, timeout: int = 120) -> dict[str, Any]:
|
||||
nonlocal request_id
|
||||
current_id = request_id
|
||||
request_id += 1
|
||||
payload: dict[str, Any] = {"jsonrpc": "2.0", "id": current_id, "method": method}
|
||||
if params is not None:
|
||||
payload["params"] = params
|
||||
await self._codex_app_server_write(process, payload)
|
||||
while True:
|
||||
message = await self._codex_app_server_read(process, timeout=timeout)
|
||||
if message.get("id") == current_id:
|
||||
if message.get("error"):
|
||||
error = message["error"]
|
||||
raise RuntimeError(error.get("message") or f"Codex App Server request failed: {error}")
|
||||
return message.get("result") or {}
|
||||
await self._handle_codex_app_server_message(process, message)
|
||||
|
||||
try:
|
||||
await send_request(
|
||||
"initialize",
|
||||
{
|
||||
"clientInfo": {"name": "TraderAI", "version": __version__},
|
||||
"capabilities": {"experimentalApi": True},
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
await self._codex_app_server_write(process, {"jsonrpc": "2.0", "method": "initialized", "params": {}})
|
||||
thread = await send_request(
|
||||
"thread/start",
|
||||
{
|
||||
"model": self.model,
|
||||
"modelProvider": None,
|
||||
"cwd": str(Path.cwd()),
|
||||
"approvalPolicy": "never",
|
||||
"sandbox": "read-only",
|
||||
"baseInstructions": "You are TraderAI running through the local Codex App Server using ChatGPT OAuth.",
|
||||
"developerInstructions": (
|
||||
"Do not run shell commands, inspect files, or modify the workspace. "
|
||||
"Answer only with JSON matching the requested output schema."
|
||||
),
|
||||
"ephemeral": True,
|
||||
"experimentalRawEvents": False,
|
||||
"persistExtendedHistory": False,
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
thread_id_value = ((thread.get("thread") or {}).get("id") or thread.get("threadId") or "").strip()
|
||||
if not thread_id_value:
|
||||
raise RuntimeError(f"Codex App Server did not return a thread id: {thread!r}")
|
||||
turn = await send_request(
|
||||
"turn/start",
|
||||
{
|
||||
"threadId": thread_id_value,
|
||||
"input": [{"type": "text", "text": prompt, "text_elements": []}],
|
||||
"cwd": str(Path.cwd()),
|
||||
"approvalPolicy": "never",
|
||||
"sandboxPolicy": {"type": "readOnly", "access": {"type": "fullAccess"}},
|
||||
"model": self.model,
|
||||
"effort": self.reasoning_effort,
|
||||
"summary": "none",
|
||||
"outputSchema": self._codex_output_schema(),
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
turn_id = ((turn.get("turn") or {}).get("id") or "").strip()
|
||||
if not turn_id:
|
||||
raise RuntimeError(f"Codex App Server did not return a turn id: {turn!r}")
|
||||
while True:
|
||||
message = await self._codex_app_server_read(process, timeout=240)
|
||||
method = message.get("method")
|
||||
params = message.get("params") or {}
|
||||
if method == "item/agentMessage/delta" and params.get("turnId") == turn_id:
|
||||
final_text += params.get("delta") or ""
|
||||
elif method == "item/completed" and params.get("turnId") == turn_id:
|
||||
item = params.get("item") or {}
|
||||
if item.get("type") == "agentMessage":
|
||||
final_text = item.get("text") or final_text
|
||||
elif method == "turn/completed" and (params.get("turn") or {}).get("id") == turn_id:
|
||||
turn_status = (params.get("turn") or {}).get("status")
|
||||
if turn_status != "completed":
|
||||
error = (params.get("turn") or {}).get("error") or {}
|
||||
raise RuntimeError(error.get("message") or f"Codex App Server turn ended with status {turn_status}.")
|
||||
break
|
||||
elif method == "error":
|
||||
error = params.get("message") or params.get("error") or params
|
||||
raise RuntimeError(f"Codex App Server error: {error}")
|
||||
else:
|
||||
await self._handle_codex_app_server_message(process, message)
|
||||
finally:
|
||||
await self._stop_codex_app_server(process)
|
||||
return self._parse_codex_app_server_text(final_text)
|
||||
|
||||
def _codex_cli_prompt(
|
||||
self,
|
||||
query: str,
|
||||
messages: list[dict[str, Any]],
|
||||
previous_interaction: dict[str, Any] | None = None,
|
||||
thread_id: str | None = DEFAULT_THREAD_ID,
|
||||
) -> str:
|
||||
conversation_lines: list[str] = []
|
||||
for message in self._messages_with_context(
|
||||
query,
|
||||
messages,
|
||||
previous_interaction=previous_interaction,
|
||||
thread_id=thread_id,
|
||||
):
|
||||
role = message.get("role", "unknown")
|
||||
content = message.get("content", "")
|
||||
suffix = ""
|
||||
if role == "user" and message.get("images"):
|
||||
suffix = f" [attached images: {len(message.get('images') or [])}]"
|
||||
if role == "tool":
|
||||
suffix = f" [tool {message.get('tool_name') or ''}]"
|
||||
if role == "assistant" and message.get("tool_calls"):
|
||||
suffix = f" [tool calls: {json.dumps(message.get('tool_calls'), ensure_ascii=True)}]"
|
||||
conversation_lines.append(f"{role}{suffix}: {content}")
|
||||
tools_json = json.dumps(self._codex_tool_catalog(), ensure_ascii=True, indent=2)
|
||||
return (
|
||||
"You are TraderAI running through the local Codex App Server using ChatGPT OAuth.\n"
|
||||
"Do not run shell commands, inspect files, or modify the workspace.\n"
|
||||
"Your only job is to decide whether to answer directly or request exactly one TraderAI tool.\n\n"
|
||||
"Return JSON that matches the provided schema.\n"
|
||||
"- If you can answer now, set kind to final, put the user-facing reply in message, set tool_name to an empty string, and set arguments_json to '{}'.\n"
|
||||
"- If you need a tool, set kind to tool_call, set tool_name to the exact tool name, set message to an empty string, and set arguments_json to a valid JSON object string.\n"
|
||||
"- Never return more than one tool call at a time.\n"
|
||||
"- Prefer the TraderAI tools over guessing.\n\n"
|
||||
f"Available tools:\n{tools_json}\n\n"
|
||||
"Conversation transcript:\n"
|
||||
+ "\n".join(conversation_lines)
|
||||
)
|
||||
|
||||
def _codex_structured_response(self, result: dict[str, Any]) -> dict[str, Any]:
|
||||
if result.get("kind") == "tool_call":
|
||||
tool_name = str(result.get("tool_name") or "").strip()
|
||||
arguments_json = str(result.get("arguments_json") or "{}").strip() or "{}"
|
||||
return {
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": f"codex-{uuid.uuid4()}",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_name,
|
||||
"arguments": arguments_json,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
return {
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": str(result.get("message") or ""),
|
||||
"tool_calls": [],
|
||||
}
|
||||
}
|
||||
|
||||
def _write_codex_schema(self) -> str:
|
||||
schema = self._codex_output_schema()
|
||||
with tempfile.NamedTemporaryFile("w", suffix="-traderai-codex-schema.json", delete=False, encoding="utf-8") as handle:
|
||||
json.dump(schema, handle, ensure_ascii=True)
|
||||
return handle.name
|
||||
|
||||
@staticmethod
|
||||
def _codex_output_schema() -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {"type": "string", "enum": ["final", "tool_call"]},
|
||||
"message": {"type": "string"},
|
||||
"tool_name": {"type": "string"},
|
||||
"arguments_json": {"type": "string"},
|
||||
},
|
||||
"required": ["kind", "message", "tool_name", "arguments_json"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
def _parse_codex_app_server_text(self, final_text: str) -> dict[str, Any]:
|
||||
if not final_text.strip():
|
||||
raise RuntimeError("Codex App Server returned an empty response.")
|
||||
try:
|
||||
parsed = json.loads(final_text)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(f"Codex App Server returned non-JSON output: {final_text}") from exc
|
||||
if parsed.get("kind") not in {"final", "tool_call"}:
|
||||
raise RuntimeError(f"Codex App Server returned an invalid result kind: {parsed!r}")
|
||||
return parsed
|
||||
|
||||
def _parse_codex_exec_output(self, output: dict[str, Any]) -> dict[str, Any]:
|
||||
events = output.get("events") or []
|
||||
final_text = ""
|
||||
error_text = ""
|
||||
for event in events:
|
||||
if event.get("type") == "item.completed":
|
||||
item = event.get("item") or {}
|
||||
if item.get("type") == "agent_message":
|
||||
final_text = item.get("text") or final_text
|
||||
elif event.get("type") == "error":
|
||||
error_text = event.get("message") or error_text
|
||||
elif event.get("type") == "turn.failed":
|
||||
details = event.get("error") or {}
|
||||
error_text = details.get("message") or error_text
|
||||
if output.get("returncode") != 0 and not final_text:
|
||||
raise RuntimeError(error_text or output.get("stderr") or "Codex CLI failed.")
|
||||
try:
|
||||
parsed = json.loads(final_text)
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(f"Codex CLI returned non-JSON output: {final_text}") from exc
|
||||
if parsed.get("kind") not in {"final", "tool_call"}:
|
||||
raise RuntimeError(f"Codex CLI returned an invalid result kind: {parsed!r}")
|
||||
return parsed
|
||||
|
||||
def _codex_command(self, required: bool = False) -> str | None:
|
||||
configured = self.base_url.strip() if self.base_url else "codex"
|
||||
resolved = shutil.which(configured) or configured
|
||||
if required and not Path(resolved).exists() and shutil.which(resolved) is None:
|
||||
raise RuntimeError("Codex CLI was not found on PATH.")
|
||||
return resolved
|
||||
|
||||
async def _codex_app_server_status(self) -> tuple[dict[str, Any] | None, list[str]]:
|
||||
process = await self._start_codex_app_server()
|
||||
request_id = 1
|
||||
|
||||
async def send_request(method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
nonlocal request_id
|
||||
current_id = request_id
|
||||
request_id += 1
|
||||
payload: dict[str, Any] = {"jsonrpc": "2.0", "id": current_id, "method": method}
|
||||
if params is not None:
|
||||
payload["params"] = params
|
||||
await self._codex_app_server_write(process, payload)
|
||||
while True:
|
||||
message = await self._codex_app_server_read(process, timeout=30)
|
||||
if message.get("id") == current_id:
|
||||
if message.get("error"):
|
||||
error = message["error"]
|
||||
raise RuntimeError(error.get("message") or f"Codex App Server request failed: {error}")
|
||||
return message.get("result") or {}
|
||||
await self._handle_codex_app_server_message(process, message)
|
||||
|
||||
try:
|
||||
await send_request(
|
||||
"initialize",
|
||||
{
|
||||
"clientInfo": {"name": "TraderAI", "version": __version__},
|
||||
"capabilities": {"experimentalApi": True},
|
||||
},
|
||||
)
|
||||
await self._codex_app_server_write(process, {"jsonrpc": "2.0", "method": "initialized", "params": {}})
|
||||
account_result = await send_request("account/read", {"refreshToken": False})
|
||||
models: list[str] = []
|
||||
cursor: str | None = None
|
||||
for _ in range(20):
|
||||
params: dict[str, Any] = {"limit": 50, "includeHidden": False}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
page = await send_request("model/list", params)
|
||||
for item in page.get("data") or []:
|
||||
model = item.get("id") or item.get("model")
|
||||
if model:
|
||||
models.append(model)
|
||||
cursor = page.get("nextCursor")
|
||||
if not cursor:
|
||||
break
|
||||
return account_result.get("account"), sorted(set(models))
|
||||
finally:
|
||||
await self._stop_codex_app_server(process)
|
||||
|
||||
async def _start_codex_app_server(self) -> asyncio.subprocess.Process:
|
||||
return await asyncio.create_subprocess_exec(
|
||||
self._codex_command(required=True),
|
||||
"app-server",
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
async def _codex_app_server_write(self, process: asyncio.subprocess.Process, payload: dict[str, Any]) -> None:
|
||||
if process.stdin is None:
|
||||
raise RuntimeError("Codex App Server stdin is unavailable.")
|
||||
process.stdin.write((json.dumps(payload, ensure_ascii=True) + "\n").encode("utf-8"))
|
||||
await process.stdin.drain()
|
||||
|
||||
async def _codex_app_server_read(self, process: asyncio.subprocess.Process, timeout: int) -> dict[str, Any]:
|
||||
if process.stdout is None:
|
||||
raise RuntimeError("Codex App Server stdout is unavailable.")
|
||||
try:
|
||||
line = await asyncio.wait_for(process.stdout.readline(), timeout=timeout)
|
||||
except TimeoutError as exc:
|
||||
raise RuntimeError("Codex App Server timed out.") from exc
|
||||
if not line:
|
||||
stderr = ""
|
||||
if process.stderr is not None:
|
||||
try:
|
||||
stderr = (await asyncio.wait_for(process.stderr.read(), timeout=1)).decode("utf-8", errors="replace").strip()
|
||||
except TimeoutError:
|
||||
stderr = ""
|
||||
raise RuntimeError(stderr or "Codex App Server exited without a response.")
|
||||
try:
|
||||
return json.loads(line.decode("utf-8", errors="replace"))
|
||||
except ValueError as exc:
|
||||
raise RuntimeError(f"Codex App Server returned invalid JSON-RPC: {line!r}") from exc
|
||||
|
||||
async def _handle_codex_app_server_message(self, process: asyncio.subprocess.Process, message: dict[str, Any]) -> None:
|
||||
if "id" not in message or "method" not in message:
|
||||
return
|
||||
method = message.get("method")
|
||||
if method in {
|
||||
"item/commandExecution/requestApproval",
|
||||
"item/fileChange/requestApproval",
|
||||
"applyPatchApproval",
|
||||
"execCommandApproval",
|
||||
}:
|
||||
await self._codex_app_server_write(
|
||||
process,
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": message["id"],
|
||||
"result": {
|
||||
"decision": "deny",
|
||||
"message": "TraderAI does not allow Codex to run commands or change files.",
|
||||
},
|
||||
},
|
||||
)
|
||||
return
|
||||
await self._codex_app_server_write(
|
||||
process,
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": message["id"],
|
||||
"error": {"code": -32601, "message": f"TraderAI does not handle Codex App Server request {method}."},
|
||||
},
|
||||
)
|
||||
|
||||
async def _stop_codex_app_server(self, process: asyncio.subprocess.Process) -> None:
|
||||
if process.returncode is not None:
|
||||
return
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=3)
|
||||
except TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
|
||||
async def _run_command(self, command: list[str], timeout: int = 120, stdin_text: str | None = None) -> dict[str, Any]:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*command,
|
||||
stdin=asyncio.subprocess.PIPE if stdin_text is not None else None,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
payload = stdin_text.encode("utf-8") if stdin_text is not None else None
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(payload), timeout=timeout)
|
||||
except TimeoutError:
|
||||
process.kill()
|
||||
await process.communicate()
|
||||
raise RuntimeError(f"Command timed out: {' '.join(command[:3])}")
|
||||
stdout_text = stdout.decode("utf-8", errors="replace")
|
||||
stderr_text = stderr.decode("utf-8", errors="replace")
|
||||
events = []
|
||||
for line in stdout_text.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
events.append(json.loads(line))
|
||||
except ValueError:
|
||||
events.append({"type": "stdout", "text": line})
|
||||
return {
|
||||
"returncode": process.returncode,
|
||||
"stdout": stdout_text,
|
||||
"stderr": stderr_text,
|
||||
"events": events,
|
||||
}
|
||||
|
||||
def _codex_model_cache(self) -> list[str]:
|
||||
cache_path = Path.home() / ".codex" / "models_cache.json"
|
||||
if not cache_path.exists():
|
||||
return []
|
||||
try:
|
||||
body = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return []
|
||||
models = []
|
||||
for item in body.get("models", []):
|
||||
slug = item.get("slug")
|
||||
if slug:
|
||||
models.append(slug)
|
||||
return sorted(set(models))
|
||||
|
||||
def _runtime_context(
|
||||
self,
|
||||
query: str,
|
||||
@@ -741,6 +1301,16 @@ class OllamaAgent:
|
||||
choice = (response.json().get("choices") or [{}])[0]
|
||||
message = choice.get("message") or {}
|
||||
return self._clean_generated_title(message.get("content", ""))
|
||||
if self.provider == "codex":
|
||||
result = await self._codex_app_server_turn(
|
||||
prompt,
|
||||
[
|
||||
{"role": "system", "content": "You write short chat titles."},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
thread_id="title",
|
||||
)
|
||||
return self._clean_generated_title(result.get("message", ""))
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/chat",
|
||||
@@ -831,6 +1401,10 @@ class OllamaAgent:
|
||||
"list_scmdb_versions": "Checking SCMDB versions",
|
||||
"search_scmdb_missions": "Searching SCMDB missions",
|
||||
"get_scmdb_mission_rewards": "Fetching SCMDB mission rewards",
|
||||
"search_scwiki_pages": "Searching Star Citizen Wiki",
|
||||
"get_scwiki_page": "Reading Star Citizen Wiki page",
|
||||
"search_scwiki_vehicles": "Searching Star Citizen Wiki vehicles",
|
||||
"get_scwiki_vehicle": "Fetching Star Citizen Wiki vehicle",
|
||||
"search_cornerstone_items": "Searching Cornerstone items",
|
||||
"get_cornerstone_item_locations": "Fetching Cornerstone item locations",
|
||||
"get_cornerstone_item_media": "Fetching Cornerstone item media",
|
||||
|
||||
+18
-2
@@ -17,9 +17,14 @@ CONFIG_FIELDS: dict[str, dict[str, Any]] = {
|
||||
"ollama_num_ctx": {"env": "OLLAMA_NUM_CTX", "type": "integer", "secret": False},
|
||||
"openai_base_url": {"env": "OPENAI_BASE_URL", "type": "string", "secret": False},
|
||||
"openai_model": {"env": "OPENAI_MODEL", "type": "string", "secret": False},
|
||||
"model_reasoning_effort": {"env": "MODEL_REASONING_EFFORT", "type": "string", "secret": False},
|
||||
"codex_command": {"env": "CODEX_COMMAND", "type": "string", "secret": False},
|
||||
"codex_model": {"env": "CODEX_MODEL", "type": "string", "secret": False},
|
||||
"uex_base_url": {"env": "UEX_BASE_URL", "type": "string", "secret": False},
|
||||
"scmdb_base_url": {"env": "SCMDB_BASE_URL", "type": "string", "secret": False},
|
||||
"cornerstone_base_url": {"env": "CORNERSTONE_BASE_URL", "type": "string", "secret": False},
|
||||
"scwiki_base_url": {"env": "SCWIKI_BASE_URL", "type": "string", "secret": False},
|
||||
"scwiki_api_base_url": {"env": "SCWIKI_API_BASE_URL", "type": "string", "secret": False},
|
||||
"openai_api_key": {"env": "OPENAI_API_KEY", "type": "string", "secret": True},
|
||||
"uex_secret_key": {"env": "UEX_SECRET_KEY", "type": "string", "secret": True},
|
||||
"uex_bearer_token": {"env": "UEX_BEARER_TOKEN", "type": "string", "secret": True},
|
||||
@@ -71,10 +76,15 @@ class Settings(BaseSettings):
|
||||
ollama_model: str = "qwen3.5:9b"
|
||||
ollama_num_ctx: int = 64512
|
||||
openai_base_url: str = "https://api.openai.com/v1"
|
||||
openai_model: str = "gpt-5.3-codex"
|
||||
openai_model: str = "gpt-5.4-mini"
|
||||
model_reasoning_effort: str = "medium"
|
||||
codex_command: str = "codex"
|
||||
codex_model: str = "gpt-5.4"
|
||||
uex_base_url: str = "https://api.uexcorp.space/2.0"
|
||||
scmdb_base_url: str = "https://scmdb.net"
|
||||
cornerstone_base_url: str = "https://finder.cstone.space"
|
||||
scwiki_base_url: str = "https://starcitizen.tools"
|
||||
scwiki_api_base_url: str = "https://api.star-citizen.wiki"
|
||||
openai_api_key: str | None = Field(default=None)
|
||||
uex_secret_key: str | None = Field(default=None)
|
||||
uex_bearer_token: str | None = Field(default=None)
|
||||
@@ -92,7 +102,13 @@ class Settings(BaseSettings):
|
||||
@classmethod
|
||||
def _normalize_model_provider(cls, value: Any) -> str:
|
||||
text = str(value or "ollama").strip().casefold()
|
||||
return text if text in {"ollama", "openai"} else "ollama"
|
||||
return text if text in {"ollama", "openai", "codex"} else "ollama"
|
||||
|
||||
@field_validator("model_reasoning_effort", mode="before")
|
||||
@classmethod
|
||||
def _normalize_reasoning_effort(cls, value: Any) -> str:
|
||||
text = str(value or "medium").strip().casefold()
|
||||
return text if text in {"none", "minimal", "low", "medium", "high", "xhigh"} else "medium"
|
||||
|
||||
@field_validator("traderai_memory_path", mode="before")
|
||||
@classmethod
|
||||
|
||||
+31
-3
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
@@ -25,6 +26,10 @@ def resource_path(*parts: str) -> Path:
|
||||
def main() -> None:
|
||||
try:
|
||||
_chdir_to_app_dir()
|
||||
backend_port = _backend_port_from_args()
|
||||
if backend_port is not None:
|
||||
_run_server(backend_port)
|
||||
return
|
||||
_log("TraderAI desktop starting")
|
||||
_log(f"cwd={Path.cwd()}")
|
||||
_log(f"executable={sys.executable}")
|
||||
@@ -36,9 +41,13 @@ def main() -> None:
|
||||
_log("existing TraderAI backend found; opening window")
|
||||
_open_window(url)
|
||||
return
|
||||
server_thread = threading.Thread(target=_run_server, args=(port,), daemon=True)
|
||||
server_thread.start()
|
||||
_log("backend thread started")
|
||||
if getattr(sys, "frozen", False):
|
||||
backend_process = _start_backend_process(port)
|
||||
_log(f"backend process started pid={backend_process.pid}")
|
||||
else:
|
||||
server_thread = threading.Thread(target=_run_server, args=(port,), daemon=True)
|
||||
server_thread.start()
|
||||
_log("backend thread started")
|
||||
_wait_for_server(url)
|
||||
_log("backend health check passed")
|
||||
_open_window(url)
|
||||
@@ -62,6 +71,22 @@ def _select_port() -> int:
|
||||
return _free_port()
|
||||
|
||||
|
||||
def _backend_port_from_args() -> int | None:
|
||||
args = sys.argv[1:]
|
||||
if len(args) >= 2 and args[0] == "--backend-port":
|
||||
return int(args[1])
|
||||
return None
|
||||
|
||||
|
||||
def _start_backend_process(port: int) -> subprocess.Popen:
|
||||
command = [sys.executable, "--backend-port", str(port)]
|
||||
_log(f"starting backend subprocess: {' '.join(command)}")
|
||||
kwargs: dict[str, object] = {}
|
||||
if sys.platform == "win32":
|
||||
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||||
return subprocess.Popen(command, **kwargs)
|
||||
|
||||
|
||||
def _port_available(port: int) -> bool:
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
@@ -88,6 +113,9 @@ def _existing_server_ready(url: str) -> bool:
|
||||
def _run_server(port: int) -> NoReturn:
|
||||
try:
|
||||
_log(f"backend starting on port {port}")
|
||||
if sys.platform == "win32" and hasattr(asyncio, "WindowsProactorEventLoopPolicy"):
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
||||
_log("set Windows Proactor event loop policy for subprocess-compatible backend")
|
||||
from traderai.server import app
|
||||
|
||||
config = uvicorn.Config(
|
||||
|
||||
@@ -232,6 +232,17 @@ class ContinualPlanStore:
|
||||
self.add_event(plan_id, status, f"Plan status changed to {status}.")
|
||||
return self.get_plan(plan_id)
|
||||
|
||||
def delete_plan(self, plan_id: str) -> bool:
|
||||
with self.memory._connect() as db:
|
||||
deleted = db.execute("DELETE FROM continual_plans WHERE id = ?", (plan_id,)).rowcount
|
||||
if not deleted:
|
||||
return False
|
||||
db.execute("DELETE FROM continual_plan_items WHERE plan_id = ?", (plan_id,))
|
||||
db.execute("DELETE FROM continual_plan_candidates WHERE plan_id = ?", (plan_id,))
|
||||
db.execute("DELETE FROM continual_plan_events WHERE plan_id = ?", (plan_id,))
|
||||
db.execute("DELETE FROM continual_plan_negotiations WHERE plan_id = ?", (plan_id,))
|
||||
return True
|
||||
|
||||
def add_event(self, plan_id: str, kind: str, message: str, metadata: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
now = iso_now()
|
||||
with self.memory._connect() as db:
|
||||
|
||||
+502
-58
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -26,6 +27,7 @@ from traderai.memory import DEFAULT_THREAD_ID, MemoryStore
|
||||
from traderai.plans import ContinualPlanRunner, ContinualPlanStore
|
||||
from traderai.scheduler import WakeScheduler
|
||||
from traderai.scmdb_client import SCMDBClient
|
||||
from traderai.starcitizen_wiki_client import StarCitizenWikiClient
|
||||
from traderai.tools import ToolRegistry
|
||||
from traderai.uex_client import UEXClient
|
||||
from traderai.version import RELEASES_API_URL, RELEASES_URL, __version__
|
||||
@@ -106,34 +108,52 @@ def create_app() -> FastAPI:
|
||||
memory = MemoryStore(settings.traderai_memory_path)
|
||||
plan_store = ContinualPlanStore(memory)
|
||||
scheduler = WakeScheduler(memory)
|
||||
uex = UEXClient(settings.uex_base_url, settings.uex_secret_key, settings.uex_bearer_token)
|
||||
scmdb = SCMDBClient(settings.scmdb_base_url)
|
||||
cornerstone = CornerstoneClient(settings.cornerstone_base_url)
|
||||
tools = ToolRegistry(
|
||||
uex,
|
||||
settings.require_write_approval,
|
||||
memory=memory,
|
||||
scheduler=scheduler,
|
||||
scmdb=scmdb,
|
||||
cornerstone=cornerstone,
|
||||
plan_store=plan_store,
|
||||
)
|
||||
plan_runner = ContinualPlanRunner(plan_store, tools, memory)
|
||||
tools.plan_runner = plan_runner
|
||||
agent = OllamaAgent(
|
||||
settings.openai_base_url if settings.model_provider == "openai" else settings.ollama_base_url,
|
||||
settings.openai_model if settings.model_provider == "openai" else settings.ollama_model,
|
||||
tools,
|
||||
memory=memory,
|
||||
user_name=settings.traderai_user_name,
|
||||
num_ctx=settings.ollama_num_ctx,
|
||||
provider=settings.model_provider,
|
||||
api_key=settings.openai_api_key,
|
||||
)
|
||||
plan_runner.bind_agent(agent)
|
||||
scheduler.bind_agent(agent)
|
||||
scheduler.bind_plan_runner(plan_runner)
|
||||
scheduler.bind_uex_notifications(uex, settings.uex_notification_poll_seconds)
|
||||
runtime: dict[str, Any] = {}
|
||||
|
||||
def configure_runtime(current_settings: Any) -> None:
|
||||
uex = UEXClient(current_settings.uex_base_url, current_settings.uex_secret_key, current_settings.uex_bearer_token)
|
||||
scmdb = SCMDBClient(current_settings.scmdb_base_url)
|
||||
cornerstone = CornerstoneClient(current_settings.cornerstone_base_url)
|
||||
scwiki = StarCitizenWikiClient(current_settings.scwiki_base_url, current_settings.scwiki_api_base_url)
|
||||
tools = ToolRegistry(
|
||||
uex,
|
||||
current_settings.require_write_approval,
|
||||
memory=memory,
|
||||
scheduler=scheduler,
|
||||
scmdb=scmdb,
|
||||
cornerstone=cornerstone,
|
||||
scwiki=scwiki,
|
||||
plan_store=plan_store,
|
||||
)
|
||||
plan_runner = ContinualPlanRunner(plan_store, tools, memory)
|
||||
tools.plan_runner = plan_runner
|
||||
provider_base_url, provider_model, provider_api_key = provider_settings(current_settings)
|
||||
agent = OllamaAgent(
|
||||
provider_base_url,
|
||||
provider_model,
|
||||
tools,
|
||||
memory=memory,
|
||||
user_name=current_settings.traderai_user_name,
|
||||
num_ctx=current_settings.ollama_num_ctx,
|
||||
provider=current_settings.model_provider,
|
||||
api_key=provider_api_key,
|
||||
reasoning_effort=current_settings.model_reasoning_effort,
|
||||
)
|
||||
plan_runner.bind_agent(agent)
|
||||
scheduler.bind_agent(agent)
|
||||
scheduler.bind_plan_runner(plan_runner)
|
||||
scheduler.bind_uex_notifications(uex, current_settings.uex_notification_poll_seconds)
|
||||
runtime.update(
|
||||
{
|
||||
"settings": current_settings,
|
||||
"uex": uex,
|
||||
"tools": tools,
|
||||
"plan_runner": plan_runner,
|
||||
"agent": agent,
|
||||
}
|
||||
)
|
||||
|
||||
configure_runtime(settings)
|
||||
|
||||
app = FastAPI(title="TraderAI")
|
||||
static_dir = resource_path("web")
|
||||
@@ -149,17 +169,20 @@ def create_app() -> FastAPI:
|
||||
scheduler.shutdown()
|
||||
|
||||
async def refresh_user_profile() -> None:
|
||||
if settings.traderai_user_name:
|
||||
memory.set_profile("configured_name", settings.traderai_user_name)
|
||||
agent.user_name = agent.user_name or settings.traderai_user_name
|
||||
current_settings = get_settings()
|
||||
agent = runtime["agent"]
|
||||
uex = runtime["uex"]
|
||||
if current_settings.traderai_user_name:
|
||||
memory.set_profile("configured_name", current_settings.traderai_user_name)
|
||||
agent.user_name = agent.user_name or current_settings.traderai_user_name
|
||||
|
||||
try:
|
||||
response = await uex.get_user(authenticated=True)
|
||||
except Exception as exc:
|
||||
memory.set_profile("uex_user_error", str(exc))
|
||||
if settings.traderai_user_name:
|
||||
if current_settings.traderai_user_name:
|
||||
try:
|
||||
response = await uex.get_user(username=settings.traderai_user_name)
|
||||
response = await uex.get_user(username=current_settings.traderai_user_name)
|
||||
except Exception:
|
||||
return
|
||||
else:
|
||||
@@ -178,9 +201,13 @@ def create_app() -> FastAPI:
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health() -> dict:
|
||||
agent = runtime["agent"]
|
||||
current_settings = get_settings()
|
||||
inference = await agent.health()
|
||||
return {
|
||||
"ollama": await agent.health(),
|
||||
"model_provider": settings.model_provider,
|
||||
"inference": inference,
|
||||
"ollama": inference,
|
||||
"model_provider": current_settings.model_provider,
|
||||
"user": memory.get_profile(),
|
||||
"jobs": scheduler.list_jobs(),
|
||||
"app_data_dir": settings_payload()["app_data_dir"],
|
||||
@@ -193,27 +220,62 @@ def create_app() -> FastAPI:
|
||||
|
||||
@app.post("/api/config")
|
||||
async def update_config(request: ConfigUpdateRequest) -> dict:
|
||||
previous_settings = get_settings()
|
||||
updated = save_settings(request.values)
|
||||
updated["restart_required"] = True
|
||||
updated["message"] = "Configuration saved. Restart TraderAI for all settings to take effect."
|
||||
current_settings = get_settings()
|
||||
configure_runtime(current_settings)
|
||||
await refresh_user_profile()
|
||||
restart_required = (
|
||||
"traderai_memory_path" in request.values
|
||||
and str(request.values.get("traderai_memory_path") or "").strip() != str(previous_settings.traderai_memory_path)
|
||||
)
|
||||
updated["restart_required"] = restart_required
|
||||
updated["message"] = (
|
||||
"Configuration saved. Restart TraderAI to switch memory databases."
|
||||
if restart_required
|
||||
else "Configuration saved and applied."
|
||||
)
|
||||
return updated
|
||||
|
||||
@app.get("/api/ollama/status")
|
||||
async def ollama_status() -> dict:
|
||||
return await inspect_model_provider()
|
||||
|
||||
@app.get("/api/openai/models")
|
||||
async def openai_models() -> dict:
|
||||
status = await inspect_openai()
|
||||
@app.get("/api/provider/models")
|
||||
async def provider_models(provider: str | None = None) -> dict:
|
||||
status = await inspect_provider_models(provider)
|
||||
return {
|
||||
"provider": "openai",
|
||||
"provider": status.get("provider", "openai"),
|
||||
"configured_model": status.get("configured_model"),
|
||||
"models": status.get("models", []),
|
||||
"reasoning_efforts": status.get("reasoning_efforts", reasoning_effort_options()),
|
||||
"configured_reasoning_effort": status.get("configured_reasoning_effort", get_settings().model_reasoning_effort),
|
||||
"message": status.get("message", ""),
|
||||
"detail": status.get("detail", ""),
|
||||
"online": status.get("online", False),
|
||||
}
|
||||
|
||||
@app.post("/api/codex/login")
|
||||
async def launch_codex_login() -> dict:
|
||||
current_settings = get_settings()
|
||||
command = find_codex_cli(current_settings.codex_command)
|
||||
if not command:
|
||||
raise HTTPException(status_code=404, detail="Codex CLI was not found on PATH.")
|
||||
try:
|
||||
login = await start_codex_browser_login(command)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Codex App Server login failed: {exception_detail(exc)}") from exc
|
||||
return {
|
||||
"installed": True,
|
||||
"running": False,
|
||||
"online": False,
|
||||
"provider": "codex",
|
||||
"login_id": login.get("loginId"),
|
||||
"auth_url": login.get("authUrl"),
|
||||
"base_url": str(command),
|
||||
"message": "Opened Codex App Server sign-in in your browser. Finish the flow, then TraderAI will detect the new login.",
|
||||
}
|
||||
|
||||
@app.post("/api/ollama/launch")
|
||||
async def launch_ollama() -> dict:
|
||||
command = ollama_launch_command()
|
||||
@@ -319,6 +381,7 @@ def create_app() -> FastAPI:
|
||||
|
||||
@app.post("/api/chat")
|
||||
async def chat(request: ChatRequest) -> dict:
|
||||
agent = runtime["agent"]
|
||||
try:
|
||||
return await agent.chat(
|
||||
request.message,
|
||||
@@ -330,6 +393,8 @@ def create_app() -> FastAPI:
|
||||
|
||||
@app.post("/api/chat/stream")
|
||||
async def chat_stream(request: ChatRequest) -> StreamingResponse:
|
||||
agent = runtime["agent"]
|
||||
|
||||
async def events():
|
||||
async for event in agent.chat_events(
|
||||
request.message,
|
||||
@@ -367,6 +432,7 @@ def create_app() -> FastAPI:
|
||||
|
||||
@app.get("/api/pending-actions")
|
||||
async def pending_actions() -> dict:
|
||||
agent = runtime["agent"]
|
||||
return {"pending_actions": agent._pending_payloads()}
|
||||
|
||||
@app.get("/api/notifications")
|
||||
@@ -393,11 +459,13 @@ def create_app() -> FastAPI:
|
||||
|
||||
@app.get("/api/negotiations/{identifier}/messages")
|
||||
async def negotiation_messages(identifier: str) -> dict:
|
||||
uex = runtime["uex"]
|
||||
params = negotiation_identifier_params(identifier)
|
||||
return await uex.get("marketplace_negotiations_messages", params, authenticated=True)
|
||||
|
||||
@app.post("/api/negotiations/{identifier}/messages")
|
||||
async def send_negotiation_message(identifier: str, request: DirectNegotiationMessageRequest) -> dict:
|
||||
uex = runtime["uex"]
|
||||
params = negotiation_identifier_params(identifier)
|
||||
payload = {**params, "message": request.message, "is_production": 1}
|
||||
return await uex.post("marketplace_negotiations_messages", payload, authenticated=True)
|
||||
@@ -412,6 +480,7 @@ def create_app() -> FastAPI:
|
||||
|
||||
@app.post("/api/plans")
|
||||
async def create_continual_plan(request: ContinualPlanCreateRequest) -> dict:
|
||||
tools = runtime["tools"]
|
||||
result = await tools.create_continual_plan(
|
||||
title=request.title,
|
||||
objective=request.objective,
|
||||
@@ -433,6 +502,7 @@ def create_app() -> FastAPI:
|
||||
|
||||
@app.post("/api/plans/{plan_id}/pause")
|
||||
async def pause_continual_plan(plan_id: str) -> dict:
|
||||
tools = runtime["tools"]
|
||||
result = await tools.pause_continual_plan(plan_id)
|
||||
if result.get("error"):
|
||||
raise HTTPException(status_code=404, detail=result["error"])
|
||||
@@ -440,6 +510,7 @@ def create_app() -> FastAPI:
|
||||
|
||||
@app.post("/api/plans/{plan_id}/resume")
|
||||
async def resume_continual_plan(plan_id: str) -> dict:
|
||||
tools = runtime["tools"]
|
||||
result = await tools.resume_continual_plan(plan_id)
|
||||
if result.get("error"):
|
||||
raise HTTPException(status_code=404, detail=result["error"])
|
||||
@@ -447,13 +518,23 @@ def create_app() -> FastAPI:
|
||||
|
||||
@app.post("/api/plans/{plan_id}/cancel")
|
||||
async def cancel_continual_plan(plan_id: str) -> dict:
|
||||
tools = runtime["tools"]
|
||||
result = await tools.cancel_continual_plan(plan_id)
|
||||
if result.get("error"):
|
||||
raise HTTPException(status_code=404, detail=result["error"])
|
||||
return result
|
||||
|
||||
@app.delete("/api/plans/{plan_id}")
|
||||
async def delete_continual_plan(plan_id: str) -> dict:
|
||||
tools = runtime["tools"]
|
||||
result = await tools.delete_continual_plan(plan_id)
|
||||
if result.get("error"):
|
||||
raise HTTPException(status_code=404, detail=result["error"])
|
||||
return result
|
||||
|
||||
@app.post("/api/plans/{plan_id}/run")
|
||||
async def run_continual_plan(plan_id: str) -> dict:
|
||||
tools = runtime["tools"]
|
||||
result = await tools.run_continual_plan_now(plan_id)
|
||||
if result.get("error"):
|
||||
raise HTTPException(status_code=400, detail=result["error"])
|
||||
@@ -487,10 +568,12 @@ def create_app() -> FastAPI:
|
||||
|
||||
@app.post("/api/approve/{action_id}")
|
||||
async def approve(action_id: str) -> dict:
|
||||
tools = runtime["tools"]
|
||||
return await tools.approve(action_id)
|
||||
|
||||
@app.post("/api/decline/{action_id}")
|
||||
async def decline(action_id: str) -> dict:
|
||||
tools = runtime["tools"]
|
||||
return await tools.decline(action_id)
|
||||
|
||||
return app
|
||||
@@ -509,33 +592,96 @@ async def inspect_model_provider() -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
if settings.model_provider == "openai":
|
||||
return await inspect_openai()
|
||||
if settings.model_provider == "codex":
|
||||
return await inspect_codex()
|
||||
return await inspect_ollama()
|
||||
|
||||
|
||||
async def inspect_openai() -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
return await inspect_cloud_provider_config("openai", settings.openai_base_url, settings.openai_api_key, settings.openai_model)
|
||||
|
||||
|
||||
async def inspect_codex() -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
command = find_codex_cli(settings.codex_command)
|
||||
detail = ""
|
||||
online = False
|
||||
models: list[str] = []
|
||||
effort_map: dict[str, list[str]] = {}
|
||||
if command:
|
||||
try:
|
||||
account, models, effort_map = await inspect_codex_app_server(command)
|
||||
online = bool(account)
|
||||
detail = f"Logged in as {account.get('email')}" if isinstance(account, dict) and account.get("email") else ""
|
||||
except (OSError, RuntimeError, asyncio.TimeoutError) as exc:
|
||||
detail = str(exc)
|
||||
configured_model = settings.codex_model
|
||||
model_available = configured_model in models if models else bool(configured_model)
|
||||
return {
|
||||
"installed": bool(command),
|
||||
"running": online,
|
||||
"online": online,
|
||||
"provider": "codex",
|
||||
"model_available": model_available,
|
||||
"configured_model": configured_model,
|
||||
"configured_reasoning_effort": settings.model_reasoning_effort,
|
||||
"reasoning_efforts": codex_reasoning_efforts(configured_model, effort_map),
|
||||
"base_url": str(command) if command else settings.codex_command,
|
||||
"models": models,
|
||||
"message": codex_status_message(bool(command), online, model_available, configured_model),
|
||||
"detail": detail,
|
||||
}
|
||||
|
||||
|
||||
async def inspect_cloud_provider() -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
if settings.model_provider == "codex":
|
||||
return await inspect_codex()
|
||||
return await inspect_openai()
|
||||
|
||||
|
||||
async def inspect_provider_models(provider: str | None = None) -> dict[str, Any]:
|
||||
normalized = str(provider or get_settings().model_provider).strip().casefold()
|
||||
if normalized == "codex":
|
||||
return await inspect_codex()
|
||||
if normalized == "ollama":
|
||||
return await inspect_ollama()
|
||||
return await inspect_openai()
|
||||
|
||||
|
||||
async def inspect_cloud_provider_config(
|
||||
provider: str,
|
||||
base_url: str,
|
||||
api_key: str | None,
|
||||
model: str,
|
||||
) -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
models: list[str] = []
|
||||
online = False
|
||||
detail = ""
|
||||
if not settings.openai_api_key:
|
||||
provider_name = provider_display_name(provider)
|
||||
if not api_key:
|
||||
return {
|
||||
"installed": True,
|
||||
"running": False,
|
||||
"online": False,
|
||||
"provider": "openai",
|
||||
"provider": provider,
|
||||
"model_available": False,
|
||||
"configured_model": settings.openai_model,
|
||||
"base_url": settings.openai_base_url,
|
||||
"configured_model": model,
|
||||
"configured_reasoning_effort": settings.model_reasoning_effort,
|
||||
"reasoning_efforts": reasoning_effort_options(),
|
||||
"base_url": base_url,
|
||||
"models": [],
|
||||
"message": "OpenAI is selected, but no API key is configured.",
|
||||
"message": f"{provider_name} is selected, but no API key is configured.",
|
||||
"detail": "",
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
response = await client.get(
|
||||
f"{settings.openai_base_url.rstrip('/')}/models",
|
||||
headers={"Authorization": f"Bearer {settings.openai_api_key}"},
|
||||
f"{base_url.rstrip('/')}/models",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
@@ -544,17 +690,19 @@ async def inspect_openai() -> dict[str, Any]:
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
detail = str(exc)
|
||||
|
||||
model_available = settings.openai_model in models
|
||||
model_available = model in models
|
||||
return {
|
||||
"installed": True,
|
||||
"running": online,
|
||||
"online": online,
|
||||
"provider": "openai",
|
||||
"provider": provider,
|
||||
"model_available": model_available,
|
||||
"configured_model": settings.openai_model,
|
||||
"base_url": settings.openai_base_url,
|
||||
"configured_model": model,
|
||||
"configured_reasoning_effort": settings.model_reasoning_effort,
|
||||
"reasoning_efforts": reasoning_effort_options(),
|
||||
"base_url": base_url,
|
||||
"models": models,
|
||||
"message": openai_status_message(online, bool(settings.openai_api_key), model_available, settings.openai_model),
|
||||
"message": cloud_status_message(provider, online, bool(api_key), model_available, model),
|
||||
"detail": detail,
|
||||
}
|
||||
|
||||
@@ -587,6 +735,8 @@ async def inspect_ollama() -> dict[str, Any]:
|
||||
"provider": "ollama",
|
||||
"model_available": model_available,
|
||||
"configured_model": settings.ollama_model,
|
||||
"configured_reasoning_effort": settings.model_reasoning_effort,
|
||||
"reasoning_efforts": reasoning_effort_options(),
|
||||
"base_url": settings.ollama_base_url,
|
||||
"num_ctx": settings.ollama_num_ctx,
|
||||
"models": models,
|
||||
@@ -599,14 +749,15 @@ async def inspect_ollama() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def openai_status_message(running: bool, configured: bool, model_available: bool, model: str) -> str:
|
||||
def cloud_status_message(provider: str, running: bool, configured: bool, model_available: bool, model: str) -> str:
|
||||
provider_name = provider_display_name(provider)
|
||||
if not configured:
|
||||
return "OpenAI API key is not configured."
|
||||
return f"{provider_name} API key is not configured."
|
||||
if not running:
|
||||
return "OpenAI is not reachable with the configured key."
|
||||
return f"{provider_name} is not reachable with the configured key."
|
||||
if not model_available:
|
||||
return f'OpenAI is reachable, but model "{model}" was not returned by the API.'
|
||||
return "OpenAI is ready."
|
||||
return f'{provider_name} is reachable, but model "{model}" was not returned by the API.'
|
||||
return f"{provider_name} is ready."
|
||||
|
||||
|
||||
def ollama_status_message(installed: bool, running: bool, model_available: bool, model: str) -> str:
|
||||
@@ -619,6 +770,292 @@ def ollama_status_message(installed: bool, running: bool, model_available: bool,
|
||||
return "Ollama is ready."
|
||||
|
||||
|
||||
def codex_status_message(installed: bool, logged_in: bool, model_available: bool, model: str) -> str:
|
||||
if not installed:
|
||||
return "Codex CLI is not installed."
|
||||
if not logged_in:
|
||||
return "Codex CLI is installed, but the Codex App Server is not logged in with ChatGPT."
|
||||
if not model_available:
|
||||
return f'Codex App Server is logged in, but model "{model}" was not returned by the model list.'
|
||||
return "Codex App Server is ready."
|
||||
|
||||
|
||||
def provider_settings(settings: Any) -> tuple[str, str, str | None]:
|
||||
if settings.model_provider == "openai":
|
||||
return settings.openai_base_url, settings.openai_model, settings.openai_api_key
|
||||
if settings.model_provider == "codex":
|
||||
return settings.codex_command, settings.codex_model, None
|
||||
return settings.ollama_base_url, settings.ollama_model, None
|
||||
|
||||
|
||||
def provider_display_name(provider: str) -> str:
|
||||
return {"openai": "OpenAI", "codex": "Codex"}.get(provider, "Ollama")
|
||||
|
||||
|
||||
def find_codex_cli(configured_command: str | None = None) -> Path | None:
|
||||
candidates = [configured_command, shutil.which("codex"), os.path.join(os.environ.get("USERPROFILE", ""), ".codex", ".sandbox-bin", "codex.exe")]
|
||||
for candidate in candidates:
|
||||
if not candidate:
|
||||
continue
|
||||
resolved = shutil.which(candidate) if Path(candidate).name == candidate else candidate
|
||||
if not resolved:
|
||||
continue
|
||||
path = Path(resolved)
|
||||
if path.exists():
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
_codex_login_tasks: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
async def start_codex_browser_login(command: Path) -> dict[str, Any]:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
str(command),
|
||||
"app-server",
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
|
||||
)
|
||||
request_id = 1
|
||||
|
||||
async def write(payload: dict[str, Any]) -> None:
|
||||
if process.stdin is None:
|
||||
raise RuntimeError("Codex App Server stdin is unavailable.")
|
||||
process.stdin.write((json.dumps(payload, ensure_ascii=True) + "\n").encode("utf-8"))
|
||||
await process.stdin.drain()
|
||||
|
||||
async def read(timeout: int = 30) -> dict[str, Any]:
|
||||
if process.stdout is None:
|
||||
raise RuntimeError("Codex App Server stdout is unavailable.")
|
||||
try:
|
||||
line = await asyncio.wait_for(process.stdout.readline(), timeout=timeout)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise RuntimeError("Codex App Server timed out while starting browser login.") from exc
|
||||
if not line:
|
||||
stderr = ""
|
||||
if process.stderr is not None:
|
||||
try:
|
||||
stderr = (await asyncio.wait_for(process.stderr.read(), timeout=1)).decode("utf-8", errors="replace").strip()
|
||||
except asyncio.TimeoutError:
|
||||
stderr = ""
|
||||
raise RuntimeError(stderr or "Codex App Server exited before login completed.")
|
||||
return json.loads(line.decode("utf-8", errors="replace"))
|
||||
|
||||
async def send(method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
nonlocal request_id
|
||||
current_id = request_id
|
||||
request_id += 1
|
||||
payload: dict[str, Any] = {"jsonrpc": "2.0", "id": current_id, "method": method}
|
||||
if params is not None:
|
||||
payload["params"] = params
|
||||
await write(payload)
|
||||
while True:
|
||||
message = await read()
|
||||
if message.get("id") == current_id:
|
||||
if message.get("error"):
|
||||
error = message["error"]
|
||||
raise RuntimeError(error.get("message") or f"Codex App Server request failed: {error}")
|
||||
return message.get("result") or {}
|
||||
await answer_codex_login_server_request(write, message)
|
||||
|
||||
try:
|
||||
await send(
|
||||
"initialize",
|
||||
{
|
||||
"clientInfo": {"name": "TraderAI", "version": __version__},
|
||||
"capabilities": {"experimentalApi": True},
|
||||
},
|
||||
)
|
||||
await write({"jsonrpc": "2.0", "method": "initialized", "params": {}})
|
||||
login = await send("account/login/start", {"type": "chatgpt"})
|
||||
if login.get("type") != "chatgpt" or not login.get("authUrl"):
|
||||
raise RuntimeError(f"Codex App Server did not return a browser login URL: {login!r}")
|
||||
task = asyncio.create_task(watch_codex_browser_login(process, read, write, login.get("loginId")))
|
||||
_codex_login_tasks.add(task)
|
||||
task.add_done_callback(_codex_login_tasks.discard)
|
||||
return login
|
||||
except Exception:
|
||||
await stop_process(process)
|
||||
raise
|
||||
|
||||
|
||||
async def answer_codex_login_server_request(write: Any, message: dict[str, Any]) -> None:
|
||||
if "id" not in message or "method" not in message:
|
||||
return
|
||||
await write(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": message["id"],
|
||||
"error": {"code": -32601, "message": "TraderAI login does not handle server requests."},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def watch_codex_browser_login(process: asyncio.subprocess.Process, read: Any, write: Any, login_id: str | None) -> None:
|
||||
try:
|
||||
while True:
|
||||
message = await read(timeout=300)
|
||||
if message.get("method") == "account/login/completed":
|
||||
params = message.get("params") or {}
|
||||
if login_id is None or params.get("loginId") == login_id:
|
||||
return
|
||||
await answer_codex_login_server_request(write, message)
|
||||
except Exception:
|
||||
return
|
||||
finally:
|
||||
await stop_process(process)
|
||||
|
||||
|
||||
async def stop_process(process: asyncio.subprocess.Process) -> None:
|
||||
if process.returncode is not None:
|
||||
return
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=3)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
|
||||
|
||||
async def inspect_codex_app_server(command: Path) -> tuple[dict[str, Any] | None, list[str], dict[str, list[str]]]:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
str(command),
|
||||
"app-server",
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
|
||||
)
|
||||
request_id = 1
|
||||
|
||||
async def write(payload: dict[str, Any]) -> None:
|
||||
if process.stdin is None:
|
||||
raise RuntimeError("Codex App Server stdin is unavailable.")
|
||||
process.stdin.write((json.dumps(payload, ensure_ascii=True) + "\n").encode("utf-8"))
|
||||
await process.stdin.drain()
|
||||
|
||||
async def read(timeout: int = 30) -> dict[str, Any]:
|
||||
if process.stdout is None:
|
||||
raise RuntimeError("Codex App Server stdout is unavailable.")
|
||||
line = await asyncio.wait_for(process.stdout.readline(), timeout=timeout)
|
||||
if not line:
|
||||
stderr = ""
|
||||
if process.stderr is not None:
|
||||
try:
|
||||
stderr = (await asyncio.wait_for(process.stderr.read(), timeout=1)).decode("utf-8", errors="replace").strip()
|
||||
except asyncio.TimeoutError:
|
||||
stderr = ""
|
||||
raise RuntimeError(stderr or "Codex App Server exited without a response.")
|
||||
return json.loads(line.decode("utf-8", errors="replace"))
|
||||
|
||||
async def send(method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
nonlocal request_id
|
||||
current_id = request_id
|
||||
request_id += 1
|
||||
payload: dict[str, Any] = {"jsonrpc": "2.0", "id": current_id, "method": method}
|
||||
if params is not None:
|
||||
payload["params"] = params
|
||||
await write(payload)
|
||||
while True:
|
||||
message = await read()
|
||||
if message.get("id") == current_id:
|
||||
if message.get("error"):
|
||||
error = message["error"]
|
||||
raise RuntimeError(error.get("message") or f"Codex App Server request failed: {error}")
|
||||
return message.get("result") or {}
|
||||
if "id" in message and "method" in message:
|
||||
await write(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": message["id"],
|
||||
"error": {"code": -32601, "message": "TraderAI status checks do not handle server requests."},
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
await send(
|
||||
"initialize",
|
||||
{
|
||||
"clientInfo": {"name": "TraderAI", "version": __version__},
|
||||
"capabilities": {"experimentalApi": True},
|
||||
},
|
||||
)
|
||||
await write({"jsonrpc": "2.0", "method": "initialized", "params": {}})
|
||||
account_result = await send("account/read", {"refreshToken": False})
|
||||
models: list[str] = []
|
||||
effort_map: dict[str, list[str]] = {}
|
||||
cursor: str | None = None
|
||||
for _ in range(20):
|
||||
params: dict[str, Any] = {"limit": 50, "includeHidden": False}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
page = await send("model/list", params)
|
||||
for item in page.get("data") or []:
|
||||
model = item.get("id") or item.get("model")
|
||||
if not model:
|
||||
continue
|
||||
models.append(model)
|
||||
efforts = [
|
||||
effort.get("reasoningEffort")
|
||||
for effort in item.get("supportedReasoningEfforts", [])
|
||||
if effort.get("reasoningEffort")
|
||||
]
|
||||
if efforts:
|
||||
effort_map[model] = efforts
|
||||
cursor = page.get("nextCursor")
|
||||
if not cursor:
|
||||
break
|
||||
return account_result.get("account"), sorted(set(models)), effort_map
|
||||
finally:
|
||||
if process.returncode is None:
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=3)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
|
||||
|
||||
def codex_models() -> list[str]:
|
||||
cache_path = Path.home() / ".codex" / "models_cache.json"
|
||||
if not cache_path.exists():
|
||||
return []
|
||||
try:
|
||||
body = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return []
|
||||
models = []
|
||||
for item in body.get("models", []):
|
||||
slug = item.get("slug")
|
||||
if slug:
|
||||
models.append(slug)
|
||||
return sorted(set(models))
|
||||
|
||||
|
||||
def codex_reasoning_efforts(model: str, effort_map: dict[str, list[str]] | None = None) -> list[str]:
|
||||
if effort_map and effort_map.get(model):
|
||||
return effort_map[model]
|
||||
cache_path = Path.home() / ".codex" / "models_cache.json"
|
||||
if not cache_path.exists():
|
||||
return reasoning_effort_options()
|
||||
try:
|
||||
body = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return reasoning_effort_options()
|
||||
for item in body.get("models", []):
|
||||
if item.get("slug") != model:
|
||||
continue
|
||||
efforts = [entry.get("effort") for entry in item.get("supported_reasoning_levels", []) if entry.get("effort")]
|
||||
return efforts or reasoning_effort_options()
|
||||
return reasoning_effort_options()
|
||||
|
||||
|
||||
def reasoning_effort_options() -> list[str]:
|
||||
return ["none", "minimal", "low", "medium", "high", "xhigh"]
|
||||
|
||||
|
||||
def find_ollama_executable() -> Path | None:
|
||||
candidates = [
|
||||
shutil.which("ollama"),
|
||||
@@ -671,6 +1108,13 @@ def popen_hidden(command: list[str]) -> subprocess.Popen:
|
||||
return subprocess.Popen(command, **kwargs)
|
||||
|
||||
|
||||
def exception_detail(exc: BaseException) -> str:
|
||||
text = str(exc).strip()
|
||||
if text:
|
||||
return text
|
||||
return f"{type(exc).__name__}: {exc!r}"
|
||||
|
||||
|
||||
async def inspect_update() -> dict[str, Any]:
|
||||
try:
|
||||
latest = await latest_release()
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class StarCitizenWikiError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class StarCitizenWikiClient:
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "https://starcitizen.tools",
|
||||
api_base_url: str = "https://api.star-citizen.wiki",
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.api_base_url = api_base_url.rstrip("/")
|
||||
|
||||
async def search_pages(self, query: str, limit: int = 5) -> list[dict[str, Any]]:
|
||||
body = await self._get_json(
|
||||
f"{self.base_url}/api.php",
|
||||
params={
|
||||
"action": "query",
|
||||
"generator": "prefixsearch",
|
||||
"gpssearch": query,
|
||||
"gpslimit": max(1, min(limit, 10)),
|
||||
"prop": "description|pageimages|extracts",
|
||||
"exintro": 1,
|
||||
"explaintext": 1,
|
||||
"exchars": 320,
|
||||
"piprop": "thumbnail",
|
||||
"pithumbsize": 240,
|
||||
"format": "json",
|
||||
},
|
||||
)
|
||||
pages = body.get("query", {}).get("pages", {})
|
||||
ordered = sorted(
|
||||
(item for item in pages.values() if isinstance(item, dict)),
|
||||
key=lambda item: int(item.get("index") or 0),
|
||||
)
|
||||
return [
|
||||
{
|
||||
"pageid": item.get("pageid"),
|
||||
"title": item.get("title"),
|
||||
"description": item.get("description"),
|
||||
"extract": item.get("extract"),
|
||||
"thumbnail": (item.get("thumbnail") or {}).get("source"),
|
||||
"url": f"{self.base_url}/{quote(str(item.get('title') or '').replace(' ', '_'), safe=':/_')}",
|
||||
}
|
||||
for item in ordered
|
||||
if item.get("title")
|
||||
]
|
||||
|
||||
async def get_page_summary(self, title: str | None = None, pageid: int | None = None, chars: int = 700) -> dict[str, Any] | None:
|
||||
params: dict[str, Any] = {
|
||||
"action": "query",
|
||||
"prop": "extracts|description|pageimages",
|
||||
"exintro": 1,
|
||||
"explaintext": 1,
|
||||
"exchars": max(120, min(chars, 1200)),
|
||||
"piprop": "thumbnail",
|
||||
"pithumbsize": 320,
|
||||
"format": "json",
|
||||
}
|
||||
if pageid is not None:
|
||||
params["pageids"] = pageid
|
||||
elif title:
|
||||
params["titles"] = title
|
||||
else:
|
||||
raise StarCitizenWikiError("title or pageid is required")
|
||||
|
||||
body = await self._get_json(f"{self.base_url}/api.php", params=params)
|
||||
pages = body.get("query", {}).get("pages", {})
|
||||
for item in pages.values():
|
||||
if isinstance(item, dict) and item.get("pageid") and item.get("title"):
|
||||
return {
|
||||
"pageid": item.get("pageid"),
|
||||
"title": item.get("title"),
|
||||
"description": item.get("description"),
|
||||
"extract": item.get("extract"),
|
||||
"thumbnail": (item.get("thumbnail") or {}).get("source"),
|
||||
"url": f"{self.base_url}/{quote(str(item.get('title') or '').replace(' ', '_'), safe=':/_')}",
|
||||
}
|
||||
return None
|
||||
|
||||
async def search_verse(self, query: str) -> list[dict[str, Any]]:
|
||||
body = await self._get_json(
|
||||
f"{self.api_base_url}/api/search",
|
||||
params={"filter[query]": query},
|
||||
)
|
||||
data = body.get("data")
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
async def get_vehicle(self, slug: str) -> dict[str, Any]:
|
||||
body = await self._get_json(f"{self.api_base_url}/api/vehicles/{slug.strip('/')}")
|
||||
data = body.get("data")
|
||||
if not isinstance(data, dict):
|
||||
raise StarCitizenWikiError(f"Vehicle response for {slug} was not an object.")
|
||||
return data
|
||||
|
||||
async def _get_json(self, url: str, params: dict[str, Any] | None = None) -> Any:
|
||||
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
|
||||
response = await client.get(url, params=params, headers={"Accept": "application/json"})
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError as exc:
|
||||
raise StarCitizenWikiError(f"Star Citizen Wiki returned non-JSON response: HTTP {response.status_code}") from exc
|
||||
if response.status_code >= 400:
|
||||
raise StarCitizenWikiError(f"Star Citizen Wiki HTTP {response.status_code}: {body}")
|
||||
return body
|
||||
+285
-3
@@ -10,6 +10,7 @@ from traderai.cornerstone_client import CornerstoneClient, parse_cornerstone_ite
|
||||
from traderai.memory import MemoryStore
|
||||
from traderai.scheduler import WakeScheduler
|
||||
from traderai.scmdb_client import SCMDBClient
|
||||
from traderai.starcitizen_wiki_client import StarCitizenWikiClient
|
||||
from traderai.uex_client import UEXClient
|
||||
|
||||
|
||||
@@ -58,10 +59,14 @@ UEX_GET_RESOURCES: dict[str, dict[str, Any]] = {
|
||||
"marketplace_averages": {"params": ["id_item", "item_name", "item_slug"], "auth": False, "group": "marketplace"},
|
||||
"marketplace_averages_all": {"params": [], "auth": False, "group": "marketplace", "heavy": True},
|
||||
"marketplace_favorites": {"params": ["id_listing"], "auth": True, "group": "marketplace"},
|
||||
"marketplace_listings": {"params": ["id", "slug", "username"], "auth": False, "group": "marketplace"},
|
||||
"marketplace_listings": {"params": ["id", "slug", "username", "id_item", "operation"], "auth": False, "group": "marketplace"},
|
||||
"marketplace_negotiations": {"params": ["id", "id_listing", "hash"], "auth": True, "group": "marketplace"},
|
||||
"marketplace_negotiations_messages": {"params": ["hash", "id_negotiation"], "auth": True, "group": "marketplace"},
|
||||
"marketplace_prices_averages": {"params": ["id_item", "item_name", "item_slug"], "auth": False, "group": "marketplace"},
|
||||
"marketplace_prices_averages": {
|
||||
"params": ["id_item", "item_name", "item_slug", "id_category", "currency", "quality_tier"],
|
||||
"auth": False,
|
||||
"group": "marketplace",
|
||||
},
|
||||
"marketplace_prices_averages_all": {"params": [], "auth": False, "group": "marketplace", "heavy": True},
|
||||
"marketplace_prices_history": {
|
||||
"params": [
|
||||
@@ -83,7 +88,11 @@ UEX_GET_RESOURCES: dict[str, dict[str, Any]] = {
|
||||
"group": "marketplace",
|
||||
"history": True,
|
||||
},
|
||||
"marketplace_trends": {"params": ["id_item", "item_name", "item_slug"], "auth": False, "group": "marketplace"},
|
||||
"marketplace_trends": {
|
||||
"params": ["id_item", "item_name", "item_slug", "id_category", "currency", "quality_tier"],
|
||||
"auth": False,
|
||||
"group": "marketplace",
|
||||
},
|
||||
"moons": {"params": ["id", "id_planet", "id_star_system", "name", "slug"], "auth": False, "group": "locations"},
|
||||
"orbits": {"params": ["id", "id_star_system", "name", "slug"], "auth": False, "group": "locations"},
|
||||
"orbits_distances": {"params": ["id_origin", "id_destination"], "auth": False, "group": "locations"},
|
||||
@@ -162,12 +171,14 @@ class ToolRegistry:
|
||||
scheduler: WakeScheduler | None = None,
|
||||
scmdb: SCMDBClient | None = None,
|
||||
cornerstone: CornerstoneClient | None = None,
|
||||
scwiki: StarCitizenWikiClient | None = None,
|
||||
plan_store: Any | None = None,
|
||||
plan_runner: Any | None = None,
|
||||
) -> None:
|
||||
self.uex = uex
|
||||
self.scmdb = scmdb or SCMDBClient()
|
||||
self.cornerstone = cornerstone or CornerstoneClient()
|
||||
self.scwiki = scwiki or StarCitizenWikiClient()
|
||||
self.require_write_approval = require_write_approval
|
||||
self.memory = memory
|
||||
self.scheduler = scheduler
|
||||
@@ -178,6 +189,7 @@ class ToolRegistry:
|
||||
self.handlers: dict[str, ToolHandler] = {
|
||||
"search_marketplace_listings": self.search_marketplace_listings,
|
||||
"get_marketplace_listing": self.get_marketplace_listing,
|
||||
"get_marketplace_trends": self.get_marketplace_trends,
|
||||
"list_marketplace_negotiations": self.list_marketplace_negotiations,
|
||||
"get_negotiation_messages": self.get_negotiation_messages,
|
||||
"draft_negotiation_message": self.draft_negotiation_message,
|
||||
@@ -192,11 +204,16 @@ class ToolRegistry:
|
||||
"pause_continual_plan": self.pause_continual_plan,
|
||||
"resume_continual_plan": self.resume_continual_plan,
|
||||
"cancel_continual_plan": self.cancel_continual_plan,
|
||||
"delete_continual_plan": self.delete_continual_plan,
|
||||
"run_continual_plan_now": self.run_continual_plan_now,
|
||||
"check_uex_notifications": self.check_uex_notifications,
|
||||
"list_scmdb_versions": self.list_scmdb_versions,
|
||||
"search_scmdb_missions": self.search_scmdb_missions,
|
||||
"get_scmdb_mission_rewards": self.get_scmdb_mission_rewards,
|
||||
"search_scwiki_pages": self.search_scwiki_pages,
|
||||
"get_scwiki_page": self.get_scwiki_page,
|
||||
"search_scwiki_vehicles": self.search_scwiki_vehicles,
|
||||
"get_scwiki_vehicle": self.get_scwiki_vehicle,
|
||||
"search_cornerstone_items": self.search_cornerstone_items,
|
||||
"get_cornerstone_item_locations": self.get_cornerstone_item_locations,
|
||||
"get_cornerstone_item_media": self.get_cornerstone_item_media,
|
||||
@@ -226,6 +243,7 @@ class ToolRegistry:
|
||||
*self._uex_post_schemas(),
|
||||
*self._uex_delete_schemas(),
|
||||
*self._scmdb_schemas(),
|
||||
*self._scwiki_schemas(),
|
||||
*self._cornerstone_schemas(),
|
||||
{
|
||||
"type": "function",
|
||||
@@ -261,6 +279,24 @@ class ToolRegistry:
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_marketplace_trends",
|
||||
"description": "Fetch current UEX marketplace trend metrics for an item, including WTS and WTB averages plus negotiation counts.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id_item": {"type": "integer"},
|
||||
"item_name": {"type": "string"},
|
||||
"item_slug": {"type": "string"},
|
||||
"id_category": {"type": "integer"},
|
||||
"currency": {"type": "string", "description": "Optional currency filter such as UEC, WIF, or MGS."},
|
||||
"quality_tier": {"type": "integer", "minimum": 0, "maximum": 7},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -480,6 +516,14 @@ class ToolRegistry:
|
||||
"parameters": {"type": "object", "required": ["plan_id"], "properties": {"plan_id": {"type": "string"}}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "delete_continual_plan",
|
||||
"description": "Delete a continual plan and all of its stored checklist items, candidates, negotiations, and event history.",
|
||||
"parameters": {"type": "object", "required": ["plan_id"], "properties": {"plan_id": {"type": "string"}}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -965,6 +1009,68 @@ class ToolRegistry:
|
||||
},
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _scwiki_schemas(cls) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search_scwiki_pages",
|
||||
"description": "Search Star Citizen Wiki pages on starcitizen.tools and return concise summaries for general game knowledge.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Page title or topic to search for."},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 10, "default": 5},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_scwiki_page",
|
||||
"description": "Fetch one Star Citizen Wiki page summary by title or page id.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"pageid": {"type": "integer"},
|
||||
"chars": {"type": "integer", "minimum": 120, "maximum": 1200, "default": 700},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search_scwiki_vehicles",
|
||||
"description": "Search Star Citizen Wiki structured vehicle data for ships and vehicles.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Ship or vehicle name to search for."},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 10, "default": 5},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_scwiki_vehicle",
|
||||
"description": "Fetch one Star Citizen Wiki vehicle summary, including MSRP and in-game purchase locations when available.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"slug": {"type": "string", "description": "Vehicle slug such as anvl-carrack."},
|
||||
"query": {"type": "string", "description": "Vehicle name if the slug is not known."},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _cornerstone_schemas(cls) -> list[dict[str, Any]]:
|
||||
return [
|
||||
@@ -1213,6 +1319,49 @@ class ToolRegistry:
|
||||
response = await self.uex.get("marketplace_listings", {"id": id, "slug": slug})
|
||||
return {"listing": response.get("data")}
|
||||
|
||||
async def get_marketplace_trends(
|
||||
self,
|
||||
id_item: int | None = None,
|
||||
item_name: str | None = None,
|
||||
item_slug: str | None = None,
|
||||
id_category: int | None = None,
|
||||
currency: str | None = None,
|
||||
quality_tier: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
response = await self.uex.get(
|
||||
"marketplace_trends",
|
||||
{
|
||||
"id_item": id_item,
|
||||
"item_name": item_name,
|
||||
"item_slug": item_slug,
|
||||
"id_category": id_category,
|
||||
"currency": currency,
|
||||
"quality_tier": quality_tier,
|
||||
},
|
||||
)
|
||||
trends = [
|
||||
self._summarize_marketplace_trend(item)
|
||||
for item in self._as_list(response.get("data"))
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
return {
|
||||
"status": response.get("status"),
|
||||
"count": len(trends),
|
||||
"filters": {
|
||||
key: value
|
||||
for key, value in {
|
||||
"id_item": id_item,
|
||||
"item_name": item_name,
|
||||
"item_slug": item_slug,
|
||||
"id_category": id_category,
|
||||
"currency": currency,
|
||||
"quality_tier": quality_tier,
|
||||
}.items()
|
||||
if value is not None
|
||||
},
|
||||
"trends": trends,
|
||||
}
|
||||
|
||||
async def list_marketplace_negotiations(
|
||||
self,
|
||||
id: int | None = None,
|
||||
@@ -1405,6 +1554,19 @@ class ToolRegistry:
|
||||
self.scheduler.unschedule_plan(plan_id)
|
||||
return {"plan": self.plan_store.set_status(plan_id, "canceled")}
|
||||
|
||||
async def delete_continual_plan(self, plan_id: str) -> dict[str, Any]:
|
||||
if self.plan_store is None:
|
||||
return {"error": "Continual plan store is not configured."}
|
||||
plan = self.plan_store.get_plan(plan_id)
|
||||
if not plan:
|
||||
return {"error": f"Plan not found: {plan_id}"}
|
||||
if self.scheduler is not None:
|
||||
self.scheduler.unschedule_plan(plan_id)
|
||||
deleted = self.plan_store.delete_plan(plan_id)
|
||||
if not deleted:
|
||||
return {"error": f"Plan not found: {plan_id}"}
|
||||
return {"deleted": True, "plan_id": plan_id, "summary": f"Deleted plan {plan.get('title') or plan_id}."}
|
||||
|
||||
async def run_continual_plan_now(self, plan_id: str) -> dict[str, Any]:
|
||||
if self.plan_runner is None:
|
||||
return {"error": "Continual plan runner is not configured."}
|
||||
@@ -1535,6 +1697,49 @@ class ToolRegistry:
|
||||
"mission": self._summarize_scmdb_mission(data, mission, source=source, detailed=True),
|
||||
}
|
||||
|
||||
async def search_scwiki_pages(self, query: str, limit: int = 5) -> dict[str, Any]:
|
||||
pages = await self.scwiki.search_pages(query, limit=limit)
|
||||
return {"source": self.scwiki.base_url, "query": query, "matched": len(pages), "pages": pages}
|
||||
|
||||
async def get_scwiki_page(
|
||||
self,
|
||||
title: str | None = None,
|
||||
pageid: int | None = None,
|
||||
chars: int = 700,
|
||||
) -> dict[str, Any]:
|
||||
page = await self.scwiki.get_page_summary(title=title, pageid=pageid, chars=chars)
|
||||
if not page:
|
||||
return {"error": "No Star Citizen Wiki page matched."}
|
||||
return {"source": self.scwiki.base_url, "page": page}
|
||||
|
||||
async def search_scwiki_vehicles(self, query: str, limit: int = 5) -> dict[str, Any]:
|
||||
groups = await self.scwiki.search_verse(query)
|
||||
vehicles_group = next((item for item in groups if item.get("type") == "vehicles"), None)
|
||||
results = [
|
||||
self._summarize_scwiki_vehicle_search(item)
|
||||
for item in (vehicles_group or {}).get("results", [])[: max(1, min(limit, 10))]
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
return {"source": self.scwiki.api_base_url, "query": query, "matched": len(results), "vehicles": results}
|
||||
|
||||
async def get_scwiki_vehicle(self, slug: str | None = None, query: str | None = None) -> dict[str, Any]:
|
||||
resolved_slug = slug
|
||||
if not resolved_slug:
|
||||
if not query:
|
||||
return {"error": "Provide slug or query."}
|
||||
groups = await self.scwiki.search_verse(query)
|
||||
vehicles_group = next((item for item in groups if item.get("type") == "vehicles"), None)
|
||||
candidates = [
|
||||
item
|
||||
for item in (vehicles_group or {}).get("results", [])
|
||||
if isinstance(item, dict) and item.get("api_url")
|
||||
]
|
||||
if not candidates:
|
||||
return {"error": "No Star Citizen Wiki vehicle matched."}
|
||||
resolved_slug = str(candidates[0]["api_url"]).rstrip("/").rsplit("/", 1)[-1]
|
||||
vehicle = await self.scwiki.get_vehicle(resolved_slug)
|
||||
return {"source": self.scwiki.api_base_url, "vehicle": self._summarize_scwiki_vehicle(vehicle)}
|
||||
|
||||
async def search_cornerstone_items(
|
||||
self,
|
||||
query: str = "",
|
||||
@@ -2210,6 +2415,83 @@ class ToolRegistry:
|
||||
"expires_at": listing.get("date_expiration"),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _summarize_marketplace_trend(trend: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"id_item": trend.get("id_item"),
|
||||
"item_name": trend.get("item_name"),
|
||||
"item_slug": trend.get("item_slug"),
|
||||
"currency": trend.get("currency"),
|
||||
"sell": {
|
||||
"avg_price": trend.get("price_avg_sell"),
|
||||
"avg_price_month": trend.get("price_avg_month_sell"),
|
||||
"min_price": trend.get("price_min_sell"),
|
||||
"max_price": trend.get("price_max_sell"),
|
||||
"listings_count": trend.get("listings_count_sell"),
|
||||
},
|
||||
"buy": {
|
||||
"avg_price": trend.get("price_avg_buy"),
|
||||
"avg_price_month": trend.get("price_avg_month_buy"),
|
||||
"min_price": trend.get("price_min_buy"),
|
||||
"max_price": trend.get("price_max_buy"),
|
||||
"listings_count": trend.get("listings_count_buy"),
|
||||
},
|
||||
"total_listings_count": trend.get("total_listings_count"),
|
||||
"negotiations_count": trend.get("negotiations_count"),
|
||||
"negotiations_open": trend.get("negotiations_open"),
|
||||
"negotiations_success": trend.get("negotiations_success"),
|
||||
"link_prices": trend.get("link_prices"),
|
||||
"link_prices_history": trend.get("link_prices_history"),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _summarize_scwiki_vehicle_search(vehicle: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"name": vehicle.get("name"),
|
||||
"class_name": vehicle.get("class_name"),
|
||||
"career": vehicle.get("extra_label"),
|
||||
"api_url": vehicle.get("api_url"),
|
||||
"web_url": vehicle.get("web_url"),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _summarize_scwiki_vehicle(vehicle: dict[str, Any]) -> dict[str, Any]:
|
||||
purchases = []
|
||||
for entry in ((vehicle.get("uex_prices") or {}).get("purchase") or []):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
location = entry.get("starmap_location") or {}
|
||||
purchases.append(
|
||||
{
|
||||
"price_buy": entry.get("price_buy"),
|
||||
"terminal_name": entry.get("terminal_name"),
|
||||
"location": location.get("name"),
|
||||
"parent_location": location.get("parent_name"),
|
||||
"star_system": location.get("star_system_name"),
|
||||
"game_version": entry.get("game_version"),
|
||||
"date_updated": entry.get("date_updated"),
|
||||
"uex_link": entry.get("uex_link"),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"name": vehicle.get("name") or vehicle.get("game_name"),
|
||||
"game_name": vehicle.get("game_name"),
|
||||
"slug": vehicle.get("slug"),
|
||||
"manufacturer": (vehicle.get("manufacturer") or {}).get("name"),
|
||||
"career": vehicle.get("career"),
|
||||
"role": vehicle.get("role"),
|
||||
"size_class": vehicle.get("size_class"),
|
||||
"cargo_capacity": vehicle.get("cargo_capacity"),
|
||||
"crew": vehicle.get("crew"),
|
||||
"msrp": vehicle.get("msrp"),
|
||||
"pledge_url": vehicle.get("pledge_url"),
|
||||
"purchase_locations": purchases,
|
||||
"description": ((vehicle.get("description") or {}).get("en_EN") or (vehicle.get("game_description") or {}).get("en_EN")),
|
||||
"web_url": vehicle.get("web_url"),
|
||||
"updated_at": vehicle.get("updated_at"),
|
||||
"version": vehicle.get("version"),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _summarize_negotiation(cls, negotiation: dict[str, Any]) -> dict[str, Any]:
|
||||
summary = cls._project_item(negotiation, mode="summary")
|
||||
|
||||
@@ -12,3 +12,4 @@ RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingCo
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user