diff --git a/.env.example b/.env.example index ce3ff30..ccdfe20 100644 --- a/.env.example +++ b/.env.example @@ -3,11 +3,16 @@ OLLAMA_BASE_URL=http://localhost:11434 OLLAMA_MODEL=qwen3.5:9b OLLAMA_NUM_CTX=64512 OPENAI_BASE_URL=https://api.openai.com/v1 -OPENAI_MODEL=gpt-5.3-codex +OPENAI_MODEL=gpt-5.4-mini OPENAI_API_KEY= +MODEL_REASONING_EFFORT=medium +CODEX_COMMAND=codex +CODEX_MODEL=gpt-5.4 UEX_BASE_URL=https://api.uexcorp.space/2.0 SCMDB_BASE_URL=https://scmdb.net CORNERSTONE_BASE_URL=https://finder.cstone.space +SCWIKI_BASE_URL=https://starcitizen.tools +SCWIKI_API_BASE_URL=https://api.star-citizen.wiki UEX_SECRET_KEY= UEX_BEARER_TOKEN= TRADERAI_USER_NAME= diff --git a/README.md b/README.md index 2d3a93d..4c975e3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # TraderAI -Local Ollama- or OpenAI-powered chat for UEX marketplace workflows. +Local Ollama-, OpenAI-, or Codex-powered chat for UEX marketplace workflows. ## What It Does @@ -25,7 +25,9 @@ Local Ollama- or OpenAI-powered chat for UEX marketplace workflows. ``` 3. Create `.env` from `.env.example` and set `UEX_SECRET_KEY` and/or `UEX_BEARER_TOKEN` if you want authenticated actions. - If you want to use OpenAI instead of Ollama, set `MODEL_PROVIDER=openai`, set `OPENAI_API_KEY`, and optionally change `OPENAI_MODEL` from the default `gpt-5.3-codex`. + If you want to use OpenAI instead of Ollama, set `MODEL_PROVIDER=openai`, set `OPENAI_API_KEY`, and optionally change `OPENAI_MODEL` from the default `gpt-5.4-mini`. + If you want to use Codex models with ChatGPT/Codex OAuth, install the Codex CLI, set `MODEL_PROVIDER=codex`, and optionally change `CODEX_MODEL` from the default `gpt-5.4`. TraderAI uses the local `codex app-server` JSON-RPC interface for both authentication and chat turns. + `MODEL_REASONING_EFFORT` controls reasoning depth for OpenAI and Codex and defaults to `medium`. `SCMDB_BASE_URL` defaults to `https://scmdb.net`. `CORNERSTONE_BASE_URL` defaults to `https://finder.cstone.space`. 4. Install and run: @@ -39,7 +41,7 @@ Local Ollama- or OpenAI-powered chat for UEX marketplace workflows. ## Notes -Ollama runs locally at `http://localhost:11434` by default. This app can talk to either Ollama's native chat API or OpenAI's Chat Completions API with tool schemas, then executes approved UEX calls in the FastAPI backend. `OLLAMA_NUM_CTX` controls the per-request Ollama context window; `64512` is the default because Ollama recommends at least 64k tokens for agent-style workflows when hardware allows it. +Ollama runs locally at `http://localhost:11434` by default. This app can talk to Ollama's native chat API, OpenAI's Chat Completions API, or the local Codex App Server authenticated through ChatGPT/Codex OAuth, then executes approved UEX calls in the FastAPI backend. `OLLAMA_NUM_CTX` controls the per-request Ollama context window; `64512` is the default because Ollama recommends at least 64k tokens for agent-style workflows when hardware allows it. ## Releases And Updates diff --git a/pyproject.toml b/pyproject.toml index 316b97d..5af5ce5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "traderai" version = "0.0.6" -description = "Local Ollama-powered assistant for UEX marketplace workflows." +description = "Local Ollama, OpenAI, or Codex assistant for UEX marketplace workflows." requires-python = ">=3.11" dependencies = [ "apscheduler>=3.10.4", @@ -40,3 +40,4 @@ include = ["traderai*"] + diff --git a/tests/test_agent.py b/tests/test_agent.py index 7f551c6..e052d70 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -217,6 +217,90 @@ def test_ollama_options_include_num_ctx(): assert agent._ollama_options() == {"num_ctx": 64000} +def test_codex_prompt_mentions_tools_and_images(tmp_path): + memory = MemoryStore(str(tmp_path / "memory.sqlite3")) + agent = OllamaAgent("codex", "gpt-5.3-codex", EmptyTools(), memory=memory, provider="codex") + + prompt = agent._codex_cli_prompt( + "check listing", + [ + {"role": "system", "content": SYSTEM_PROMPT}, + { + "role": "user", + "content": "Look at this", + "images": ["ZmFrZQ=="], + "image_content_types": ["image/png"], + }, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_123", + "type": "function", + "function": {"name": "search_marketplace_listings", "arguments": "{\"commodity\":\"gold\"}"}, + } + ], + }, + { + "role": "tool", + "tool_name": "search_marketplace_listings", + "tool_call_id": "call_123", + "content": "{\"ok\":true}", + }, + ], + ) + + assert "Available tools" in prompt + assert "attached images: 1" in prompt + assert "search_marketplace_listings" in prompt + assert "tool search_marketplace_listings" in prompt + + +def test_codex_structured_response_extracts_text_and_tool_calls(): + agent = OllamaAgent("codex", "gpt-5.3-codex", EmptyTools(), provider="codex") + + result = agent._codex_structured_response( + { + "kind": "tool_call", + "message": "", + "tool_name": "search_marketplace_listings", + "arguments_json": "{\"commodity\":\"gold\"}", + } + ) + + assert result["message"]["content"] == "" + assert result["message"]["tool_calls"] == [ + { + "id": result["message"]["tool_calls"][0]["id"], + "type": "function", + "function": { + "name": "search_marketplace_listings", + "arguments": "{\"commodity\":\"gold\"}", + }, + } + ] + + +def test_parse_codex_exec_output_reads_final_json(): + agent = OllamaAgent("codex", "gpt-5.3-codex", EmptyTools(), provider="codex") + + result = agent._parse_codex_exec_output( + { + "returncode": 0, + "stdout": "", + "stderr": "", + "events": [ + {"type": "thread.started", "thread_id": "abc"}, + {"type": "item.completed", "item": {"type": "agent_message", "text": "{\"kind\":\"final\",\"message\":\"hello\",\"tool_name\":\"\",\"arguments_json\":\"{}\"}"}}, + {"type": "turn.completed"}, + ], + } + ) + + assert result == {"kind": "final", "message": "hello", "tool_name": "", "arguments_json": "{}"} + + @pytest.mark.asyncio async def test_wake_response_executes_tool_calls(tmp_path): memory = MemoryStore(str(tmp_path / "memory.sqlite3")) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..23427e7 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,25 @@ +from traderai.config import Settings + + +def test_model_provider_accepts_codex(): + settings = Settings(model_provider="codex") + + assert settings.model_provider == "codex" + + +def test_model_provider_invalid_value_falls_back_to_ollama(): + settings = Settings(model_provider="something-else") + + assert settings.model_provider == "ollama" + + +def test_reasoning_effort_normalizes_invalid_values(): + settings = Settings(model_reasoning_effort="whatever") + + assert settings.model_reasoning_effort == "medium" + + +def test_reasoning_effort_accepts_supported_values(): + settings = Settings(model_reasoning_effort="high") + + assert settings.model_reasoning_effort == "high" diff --git a/tests/test_plans.py b/tests/test_plans.py index 98a9524..ab9cb98 100644 --- a/tests/test_plans.py +++ b/tests/test_plans.py @@ -104,6 +104,26 @@ def test_continual_plan_store_creates_buying_checklist(tmp_path): assert plan["items"][0]["desired_quantity"] == 2 +def test_continual_plan_store_deletes_plan_and_related_records(tmp_path): + _, store, _, _, _ = plan_stack(tmp_path) + + plan = store.create_plan( + "Delete me", + objective="Remove everything", + items=[{"item_name": "Wikelo Idris panel", "desired_quantity": 1}], + ) + item_id = int(plan["items"][0]["id"]) + candidate = store.upsert_candidate(plan["id"], item_id, {"id": "listing-1", "title": "Panel", "price": 10}, 0.9) + store.add_negotiation(plan["id"], item_id, int(candidate["id"]), {"listing_id": "listing-1", "listing_slug": "panel", "id_negotiation": "neg-1", "hash": "hash-1"}) + + assert store.delete_plan(plan["id"]) is True + assert store.get_plan(plan["id"]) is None + assert store.list_items(plan["id"]) == [] + assert store.list_candidates(plan["id"]) == [] + assert store.list_negotiations(plan["id"]) == [] + assert store.list_events(plan["id"]) == [] + + @pytest.mark.asyncio async def test_buying_runner_tracks_candidates_and_drafts_only(tmp_path): memory, store, tools, runner, _ = plan_stack(tmp_path) @@ -215,3 +235,19 @@ async def test_scheduler_schedules_overdue_plan_catchup_on_start(tmp_path): assert catchup is not None assert any(event["kind"] == "catchup_scheduled" for event in snapshot["events"]) + + +@pytest.mark.asyncio +async def test_tools_delete_continual_plan_removes_it(tmp_path): + _, store, tools, _, _ = plan_stack(tmp_path) + plan = store.create_plan( + "Delete through tools", + objective="Remove via registry", + items=[{"item_name": "Wikelo Idris panel"}], + ) + + result = await tools.delete_continual_plan(plan["id"]) + + assert result["deleted"] is True + assert result["plan_id"] == plan["id"] + assert store.get_plan(plan["id"]) is None diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..32f136a --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from fastapi.testclient import TestClient + +import traderai.server as server + + +def test_config_update_rebuilds_runtime_without_restart(monkeypatch, tmp_path): + state = {"settings": make_settings(tmp_path, model_provider="ollama", ollama_model="qwen3.5:9b")} + + class FakeScheduler: + def __init__(self, memory): + self.memory = memory + + def bind_agent(self, agent): + self.agent = agent + + def bind_plan_runner(self, plan_runner): + self.plan_runner = plan_runner + + def bind_uex_notifications(self, uex, poll_seconds=60): + self.uex = uex + self.poll_seconds = poll_seconds + + def start(self): + return None + + def shutdown(self): + return None + + def list_jobs(self): + return [] + + class FakeUEXClient: + def __init__(self, *args, **kwargs): + pass + + async def get_user(self, username=None, authenticated=False): + return {} + + class FakeToolRegistry: + def __init__(self, *args, **kwargs): + self.pending_actions = {} + self.plan_runner = None + + async def approve(self, action_id): + return {"approved": action_id} + + async def decline(self, action_id): + return {"declined": action_id} + + class FakePlanRunner: + def __init__(self, store, tools, memory, agent=None): + self.store = store + self.tools = tools + self.memory = memory + self.agent = agent + + def bind_agent(self, agent): + self.agent = agent + + class FakeClient: + def __init__(self, *args, **kwargs): + pass + + async def fake_health(self): + return { + "online": True, + "provider": self.provider, + "model": self.model, + "model_available": True, + "message": f"{self.provider} ready", + } + + async def fake_chat(self, content, thread_id=None, images=None): + return {"message": f"{self.provider}:{self.model}", "pending_actions": [], "thread_id": thread_id} + + def fake_get_settings(): + return state["settings"] + + def fake_save_settings(values): + state["settings"] = make_settings( + tmp_path, + model_provider=values.get("model_provider", state["settings"].model_provider), + ollama_model=values.get("ollama_model", state["settings"].ollama_model), + codex_model=values.get("codex_model", state["settings"].codex_model), + ) + return {"values": values, "fields": {}, "secrets_configured": {}, "app_data_dir": str(tmp_path)} + + monkeypatch.setattr(server, "WakeScheduler", FakeScheduler) + monkeypatch.setattr(server, "UEXClient", FakeUEXClient) + monkeypatch.setattr(server, "ToolRegistry", FakeToolRegistry) + monkeypatch.setattr(server, "ContinualPlanRunner", FakePlanRunner) + monkeypatch.setattr(server, "SCMDBClient", FakeClient) + monkeypatch.setattr(server, "CornerstoneClient", FakeClient) + monkeypatch.setattr(server, "StarCitizenWikiClient", FakeClient) + monkeypatch.setattr(server, "get_settings", fake_get_settings) + monkeypatch.setattr(server, "save_settings", fake_save_settings) + monkeypatch.setattr( + server, + "settings_payload", + lambda settings=None: {"app_data_dir": str(tmp_path), "values": {}, "fields": {}, "secrets_configured": {}}, + ) + monkeypatch.setattr(server.OllamaAgent, "health", fake_health) + monkeypatch.setattr(server.OllamaAgent, "chat", fake_chat) + + app = server.create_app() + with TestClient(app) as client: + before = client.get("/api/health").json() + assert before["model_provider"] == "ollama" + assert before["inference"]["provider"] == "ollama" + + updated = client.post( + "/api/config", + json={"values": {"model_provider": "codex", "codex_model": "gpt-5.4"}}, + ).json() + assert updated["restart_required"] is False + + after = client.get("/api/health").json() + assert after["model_provider"] == "codex" + assert after["inference"]["provider"] == "codex" + + chat = client.post("/api/chat", json={"message": "hi", "thread_id": "thread-1", "images": []}).json() + assert chat["message"] == "codex:gpt-5.4" + + +def make_settings(tmp_path, model_provider="ollama", ollama_model="qwen3.5:9b", codex_model="gpt-5.4"): + return SimpleNamespace( + traderai_memory_path=str(tmp_path / "memory.sqlite3"), + model_provider=model_provider, + ollama_base_url="http://localhost:11434", + ollama_model=ollama_model, + ollama_num_ctx=64512, + openai_base_url="https://api.openai.com/v1", + openai_api_key=None, + openai_model="gpt-5.4-mini", + model_reasoning_effort="medium", + codex_command="codex", + codex_model=codex_model, + uex_base_url="https://api.uexcorp.space/2.0", + uex_secret_key=None, + uex_bearer_token=None, + traderai_user_name=None, + uex_notification_poll_seconds=60, + require_write_approval=True, + scmdb_base_url="https://scmdb.net", + cornerstone_base_url="https://finder.cstone.space", + scwiki_base_url="https://starcitizen.tools", + scwiki_api_base_url="https://api.star-citizen.wiki", + ) diff --git a/tests/test_tools.py b/tests/test_tools.py index a0dd1ce..25ea2f5 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -10,8 +10,10 @@ from traderai.uex_client import UEXClient class FakeUEX: def __init__(self): self.posts = [] + self.get_calls = [] async def get(self, path, params=None, authenticated=False): + self.get_calls.append({"path": path, "params": params, "authenticated": authenticated}) if path == "commodities_prices_history": return { "status": "ok", @@ -80,6 +82,34 @@ class FakeUEX: }, ], } + if path == "marketplace_trends": + return { + "status": "ok", + "data": [ + { + "id_item": 2791, + "item_name": "\"Quantanium\" Water Bottle", + "item_slug": "quantanium-water-bottle", + "currency": "UEC", + "price_avg_sell": "937500", + "price_avg_month_sell": "1072222", + "price_min_sell": "750000", + "price_max_sell": "1200000", + "listings_count_sell": 4, + "price_avg_buy": "500000", + "price_avg_month_buy": "525000", + "price_min_buy": "450000", + "price_max_buy": "550000", + "listings_count_buy": 2, + "total_listings_count": 6, + "negotiations_count": 18, + "negotiations_open": 7, + "negotiations_success": 9, + "link_prices": "https://uexcorp.space/marketplace/home/?id_item=2791&mode=list", + "link_prices_history": "https://uexcorp.space/marketplace/averages/?id_item=2791&quality_tier=q0&unit=unit", + } + ], + } assert path == "marketplace_listings" return { "data": [ @@ -259,6 +289,85 @@ class FakeCornerstone: } +class FakeSCWiki: + base_url = "https://starcitizen.tools" + api_base_url = "https://api.star-citizen.wiki" + + async def search_pages(self, query, limit=5): + assert query == "Carrack" + return [ + { + "pageid": 415, + "title": "Carrack", + "description": "Deep-space multi-crew explorer manufactured by Anvil Aerospace", + "extract": "The Anvil Carrack is a multi-crew explorer.", + "thumbnail": "https://media.starcitizen.tools/carrack.webp", + "url": "https://starcitizen.tools/Carrack", + } + ][:limit] + + async def get_page_summary(self, title=None, pageid=None, chars=700): + assert title == "Carrack" or pageid == 415 + return { + "pageid": 415, + "title": "Carrack", + "description": "Deep-space multi-crew explorer manufactured by Anvil Aerospace", + "extract": "The Anvil Carrack is a multi-crew explorer.", + "thumbnail": "https://media.starcitizen.tools/carrack.webp", + "url": "https://starcitizen.tools/Carrack", + } + + async def search_verse(self, query): + assert query == "Carrack" + return [ + { + "type": "vehicles", + "label": "Vehicles", + "results": [ + { + "name": "Anvil Carrack", + "class_name": "ANVL_Carrack", + "extra_label": "Exploration", + "web_url": "https://api.star-citizen.wiki/vehicles/anvl-carrack", + "api_url": "https://api.star-citizen.wiki/api/vehicles/anvl-carrack", + } + ], + } + ] + + async def get_vehicle(self, slug): + assert slug == "anvl-carrack" + return { + "name": "Carrack", + "game_name": "Anvil Carrack", + "slug": "anvl-carrack", + "manufacturer": {"name": "Anvil Aerospace"}, + "career": "Exploration", + "role": "Expedition", + "size_class": 5, + "cargo_capacity": 456, + "crew": {"min": 6, "max": 6}, + "msrp": 600, + "pledge_url": "https://robertsspaceindustries.com/pledge/ships/carrack/Carrack", + "uex_prices": { + "purchase": [ + { + "price_buy": 34398000, + "terminal_name": "Astro Armada - Area 18", + "starmap_location": {"name": "Area18", "parent_name": "ArcCorp", "star_system_name": "Stanton"}, + "game_version": "4.8.1-LIVE.11952564", + "date_updated": "2026-05-20T18:39:37-04:00", + "uex_link": "https://uexcorp.space/vehicles/home/list/in_game_sell/?id_terminal=148", + } + ] + }, + "description": {"en_EN": "The Anvil Carrack features reinforced fuel tanks for long-duration flight."}, + "web_url": "https://api.star-citizen.wiki/vehicles/anvl-carrack", + "updated_at": "2026-06-08T00:34:00Z", + "version": "4.8.1-LIVE.11952564", + } + + @pytest.mark.asyncio async def test_search_marketplace_listings_filters_locally(): registry = ToolRegistry(FakeUEX()) @@ -333,6 +442,65 @@ async def test_uex_get_projects_and_limits_results(): assert result["items"] == [{"id": 10, "commodity_name": "Gold", "price_buy": 4120}] +@pytest.mark.asyncio +async def test_uex_get_marketplace_listings_accepts_item_and_operation_filters(): + fake = FakeUEX() + registry = ToolRegistry(fake) + + result = await registry.execute( + "get_uex_marketplace_listings", + { + "id_item": 2791, + "operation": "sell", + "fields": ["id", "slug", "operation"], + }, + ) + + assert result["params"] == {"id_item": 2791, "operation": "sell"} + assert fake.get_calls[-1]["path"] == "marketplace_listings" + assert fake.get_calls[-1]["params"] == {"id_item": 2791, "operation": "sell"} + + +@pytest.mark.asyncio +async def test_get_marketplace_trends_returns_compact_wts_wtb_and_negotiation_metrics(): + fake = FakeUEX() + registry = ToolRegistry(fake) + + result = await registry.get_marketplace_trends(item_name="Quantanium", currency="UEC", quality_tier=0) + + assert result["status"] == "ok" + assert result["count"] == 1 + assert result["filters"] == {"item_name": "Quantanium", "currency": "UEC", "quality_tier": 0} + assert fake.get_calls[-1]["path"] == "marketplace_trends" + assert fake.get_calls[-1]["params"] == {"id_item": None, "item_name": "Quantanium", "item_slug": None, "id_category": None, "currency": "UEC", "quality_tier": 0} + assert result["trends"][0] == { + "id_item": 2791, + "item_name": "\"Quantanium\" Water Bottle", + "item_slug": "quantanium-water-bottle", + "currency": "UEC", + "sell": { + "avg_price": "937500", + "avg_price_month": "1072222", + "min_price": "750000", + "max_price": "1200000", + "listings_count": 4, + }, + "buy": { + "avg_price": "500000", + "avg_price_month": "525000", + "min_price": "450000", + "max_price": "550000", + "listings_count": 2, + }, + "total_listings_count": 6, + "negotiations_count": 18, + "negotiations_open": 7, + "negotiations_success": 9, + "link_prices": "https://uexcorp.space/marketplace/home/?id_item=2791&mode=list", + "link_prices_history": "https://uexcorp.space/marketplace/averages/?id_item=2791&quality_tier=q0&unit=unit", + } + + @pytest.mark.asyncio async def test_uex_api_catalog_exposes_resources_without_live_call(): registry = ToolRegistry(FakeUEX()) @@ -368,6 +536,7 @@ def test_schemas_expose_specific_uex_tools_instead_of_generic_api_tool(): assert "get_uex_commodities_prices" in names assert "get_uex_vehicles" in names + assert "get_marketplace_trends" in names assert "draft_uex_marketplace_advertise" in names assert "delete_uex_marketplace_listings" in names assert "uex_get" not in names @@ -395,6 +564,17 @@ def test_schemas_expose_cornerstone_item_tools(): assert "draft_marketplace_listing_with_cornerstone_image" in names +def test_schemas_expose_scwiki_tools(): + registry = ToolRegistry(FakeUEX(), scwiki=FakeSCWiki()) + + names = {schema["function"]["name"] for schema in registry.schemas} + + assert "search_scwiki_pages" in names + assert "get_scwiki_page" in names + assert "search_scwiki_vehicles" in names + assert "get_scwiki_vehicle" in names + + @pytest.mark.asyncio async def test_search_scmdb_missions_returns_reward_summary(): registry = ToolRegistry(FakeUEX(), scmdb=FakeSCMDB()) @@ -469,6 +649,43 @@ async def test_get_cornerstone_item_media_returns_absolute_image_urls(): ] +@pytest.mark.asyncio +async def test_search_scwiki_pages_returns_general_knowledge_matches(): + registry = ToolRegistry(FakeUEX(), scwiki=FakeSCWiki()) + + result = await registry.search_scwiki_pages(query="Carrack") + + assert result["source"] == "https://starcitizen.tools" + assert result["matched"] == 1 + assert result["pages"][0]["title"] == "Carrack" + assert result["pages"][0]["url"] == "https://starcitizen.tools/Carrack" + + +@pytest.mark.asyncio +async def test_get_scwiki_vehicle_returns_ship_prices_and_store_context(): + registry = ToolRegistry(FakeUEX(), scwiki=FakeSCWiki()) + + result = await registry.get_scwiki_vehicle(query="Carrack") + + assert result["source"] == "https://api.star-citizen.wiki" + vehicle = result["vehicle"] + assert vehicle["name"] == "Carrack" + assert vehicle["manufacturer"] == "Anvil Aerospace" + assert vehicle["msrp"] == 600 + assert vehicle["purchase_locations"] == [ + { + "price_buy": 34398000, + "terminal_name": "Astro Armada - Area 18", + "location": "Area18", + "parent_location": "ArcCorp", + "star_system": "Stanton", + "game_version": "4.8.1-LIVE.11952564", + "date_updated": "2026-05-20T18:39:37-04:00", + "uex_link": "https://uexcorp.space/vehicles/home/list/in_game_sell/?id_terminal=148", + } + ] + + @pytest.mark.asyncio async def test_draft_marketplace_listing_with_cornerstone_image_adds_image_data_and_redacts_display(): registry = ToolRegistry(FakeUEX(), cornerstone=FakeCornerstone()) diff --git a/traderai/agent.py b/traderai/agent.py index 3dabec5..88c915f 100644 --- a/traderai/agent.py +++ b/traderai/agent.py @@ -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", diff --git a/traderai/config.py b/traderai/config.py index 2bc1fa7..5600da4 100644 --- a/traderai/config.py +++ b/traderai/config.py @@ -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 diff --git a/traderai/desktop.py b/traderai/desktop.py index 2a3d5b4..a742e43 100644 --- a/traderai/desktop.py +++ b/traderai/desktop.py @@ -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( diff --git a/traderai/plans.py b/traderai/plans.py index 80ddf9a..c39dbd4 100644 --- a/traderai/plans.py +++ b/traderai/plans.py @@ -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: diff --git a/traderai/server.py b/traderai/server.py index 422a86d..3140996 100644 --- a/traderai/server.py +++ b/traderai/server.py @@ -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() diff --git a/traderai/starcitizen_wiki_client.py b/traderai/starcitizen_wiki_client.py new file mode 100644 index 0000000..22f77cc --- /dev/null +++ b/traderai/starcitizen_wiki_client.py @@ -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 diff --git a/traderai/tools.py b/traderai/tools.py index 936506a..9430c83 100644 --- a/traderai/tools.py +++ b/traderai/tools.py @@ -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") diff --git a/traderai/version.py b/traderai/version.py index 26e3669..bd23584 100644 --- a/traderai/version.py +++ b/traderai/version.py @@ -12,3 +12,4 @@ RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingCo + diff --git a/uv.lock b/uv.lock index 5503199..e8c205e 100644 --- a/uv.lock +++ b/uv.lock @@ -1052,3 +1052,4 @@ wheels = [ + diff --git a/web/app.js b/web/app.js index b593c17..ea91d94 100644 --- a/web/app.js +++ b/web/app.js @@ -26,7 +26,10 @@ const ollamaDownloadButton = document.getElementById("ollama-download"); const ollamaInstallButton = document.getElementById("ollama-install"); const ollamaLaunchButton = document.getElementById("ollama-launch"); const ollamaPullButton = document.getElementById("ollama-pull"); +const codexLoginButton = document.getElementById("codex-login"); const openaiModelsRefreshButton = document.getElementById("openai-models-refresh"); +const providerModelSelect = document.getElementById("provider-model-select"); +const modelReasoningEffortSelect = document.getElementById("model-reasoning-effort"); const ollamaStatusEl = document.getElementById("ollama-status"); const ollamaMessageEl = document.getElementById("ollama-message"); const updateCheckButton = document.getElementById("update-check"); @@ -57,6 +60,7 @@ const planForm = document.getElementById("plan-form"); const plansStatusEl = document.getElementById("plans-status"); const plansDashboardEl = document.getElementById("plans-dashboard"); const plansRailListEl = document.getElementById("plans-rail-list"); +const providerScopedFields = Array.from(document.querySelectorAll("[data-provider-scope]")); let ollamaOnline = true; let latestUpdate = null; @@ -599,6 +603,9 @@ const ollamaFieldIds = { openai_base_url: "openai-base-url", openai_api_key: "openai-api-key", openai_model: "openai-model", + model_reasoning_effort: "model-reasoning-effort", + codex_command: "codex-command", + codex_model: "codex-model", }; async function refreshConfig() { @@ -636,6 +643,8 @@ function renderConfig(config) { field.value = values[key] ?? ""; } } + renderReasoningEffortOptions(["none", "minimal", "low", "medium", "high", "xhigh"], values.model_reasoning_effort || "medium"); + updateProviderFieldVisibility(values.model_provider || "ollama"); configPathsEl.textContent = `App data: ${config.app_data_dir}\nConfig: ${config.config_path}\nLog: ${config.log_path}\nEdge profile: ${config.edge_profile_dir}`; configStatusEl.textContent = ""; } @@ -703,18 +712,21 @@ async function refreshOllamaStatus() { function renderOllamaStatus(status) { if (!ollamaStatusEl) return; latestOllamaStatus = status; - const provider = status.provider === "openai" ? "OpenAI" : "Ollama"; + updateProviderFieldVisibility(status.provider || "ollama"); + const provider = providerDisplayName(status.provider); const models = status.models?.length ? status.models.join(", ") : "None detected"; - const ready = status.provider === "openai" + const isOpenAIProvider = status.provider === "openai"; + const isCodexProvider = status.provider === "codex"; + const ready = isOpenAIProvider ? Boolean(status.online && status.model_available) : Boolean(status.installed && status.running && status.model_available); const pillClass = ready ? "status-pill" : "status-pill warning"; const detailItems = [ ollamaStatusItem("Provider", provider), ollamaStatusItem("Model", status.configured_model || ""), - ollamaStatusItem("URL", status.base_url || ""), + ollamaStatusItem(isCodexProvider ? "Command" : "URL", status.base_url || ""), ]; - if (status.provider !== "openai") { + if (!isOpenAIProvider && !isCodexProvider) { detailItems.splice(1, 0, ollamaStatusItem("Installed", status.installed ? "Yes" : "No")); detailItems.splice(2, 0, ollamaStatusItem("Running", status.running ? "Yes" : "No")); detailItems.push(ollamaStatusItem("Pulled", status.model_available ? "Yes" : "No")); @@ -728,27 +740,32 @@ function renderOllamaStatus(status) {
${detailItems.join("")}
- ${ollamaStatusItem(status.provider === "openai" ? "Available Models" : "Installed Models", models)} + ${ollamaStatusItem(isOpenAIProvider || isCodexProvider ? "Available Models" : "Installed Models", models)} ${status.detail ? ollamaStatusItem("Detail", status.detail) : ""} `; - if (ollamaDownloadButton) ollamaDownloadButton.hidden = status.provider === "openai"; + if (ollamaDownloadButton) ollamaDownloadButton.hidden = isOpenAIProvider || isCodexProvider; if (ollamaInstallButton) { - ollamaInstallButton.hidden = status.provider === "openai" || !status.can_auto_install; + ollamaInstallButton.hidden = isOpenAIProvider || isCodexProvider || !status.can_auto_install; ollamaInstallButton.disabled = Boolean(status.installed) || !status.can_auto_install; } if (ollamaLaunchButton) { - ollamaLaunchButton.hidden = status.provider === "openai"; + ollamaLaunchButton.hidden = isOpenAIProvider || isCodexProvider; ollamaLaunchButton.disabled = !status.installed || Boolean(status.running); } if (ollamaPullButton) { - ollamaPullButton.hidden = status.provider === "openai"; + ollamaPullButton.hidden = isOpenAIProvider || isCodexProvider; ollamaPullButton.disabled = !status.running || Boolean(status.model_available); } + if (codexLoginButton) { + codexLoginButton.hidden = !isCodexProvider; + codexLoginButton.disabled = Boolean(status.online); + } if (openaiModelsRefreshButton) { - openaiModelsRefreshButton.hidden = status.provider !== "openai"; + openaiModelsRefreshButton.hidden = false; openaiModelsRefreshButton.disabled = false; } - renderProviderModelOptions(status.models || []); + renderProviderModelOptions(status.models || [], status); + renderReasoningEffortOptions(status.reasoning_efforts || [], status.configured_reasoning_effort || "medium"); updateOllamaAttention(status); } @@ -791,15 +808,18 @@ function setOllamaButtonAttention(button, action, active) { function updateOllamaAttention(status = null) { const currentStatus = status || latestOllamaStatus; if (!currentStatus) return; - const ready = currentStatus.provider === "openai" + const isOpenAIProvider = currentStatus.provider === "openai"; + const isCodexProvider = currentStatus.provider === "codex"; + const ready = isOpenAIProvider ? Boolean(currentStatus.online && currentStatus.model_available) : Boolean(currentStatus.installed && currentStatus.running && currentStatus.model_available); ollamaToggle?.classList.toggle("attention-pulse", !ready); - setOllamaButtonAttention(ollamaDownloadButton, "download", currentStatus.provider !== "openai" && !currentStatus.installed); - setOllamaButtonAttention(ollamaInstallButton, "install", currentStatus.provider !== "openai" && !currentStatus.installed && currentStatus.can_auto_install); - setOllamaButtonAttention(ollamaLaunchButton, "launch", currentStatus.provider !== "openai" && currentStatus.installed && !currentStatus.running); - setOllamaButtonAttention(ollamaPullButton, "pull", currentStatus.provider !== "openai" && currentStatus.running && !currentStatus.model_available); - setOllamaButtonAttention(openaiModelsRefreshButton, "openai-models", currentStatus.provider === "openai" && !currentStatus.model_available); + setOllamaButtonAttention(ollamaDownloadButton, "download", !isOpenAIProvider && !isCodexProvider && !currentStatus.installed); + setOllamaButtonAttention(ollamaInstallButton, "install", !isOpenAIProvider && !isCodexProvider && !currentStatus.installed && currentStatus.can_auto_install); + setOllamaButtonAttention(ollamaLaunchButton, "launch", !isOpenAIProvider && !isCodexProvider && currentStatus.installed && !currentStatus.running); + setOllamaButtonAttention(ollamaPullButton, "pull", !isOpenAIProvider && !isCodexProvider && currentStatus.running && !currentStatus.model_available); + setOllamaButtonAttention(codexLoginButton, "codex-login", isCodexProvider && !currentStatus.online); + setOllamaButtonAttention(openaiModelsRefreshButton, "openai-models", isOpenAIProvider && !currentStatus.model_available); if (ready) clickedOllamaActions.clear(); } @@ -807,31 +827,137 @@ function configuredOllamaModel() { return document.getElementById("ollama-model")?.value || ""; } -function renderProviderModelOptions(models) { +function updateProviderFieldVisibility(provider) { + for (const field of providerScopedFields) { + const scope = field.dataset.providerScope; + field.hidden = scope !== provider; + } +} + +function renderProviderModelOptions(models, status = latestOllamaStatus) { const datalist = document.getElementById("provider-models"); - if (!datalist) return; - datalist.innerHTML = ""; + if (datalist) datalist.innerHTML = ""; for (const model of models) { + if (datalist) { + const option = document.createElement("option"); + option.value = model; + datalist.appendChild(option); + } + } + if (!providerModelSelect) return; + const provider = status?.provider || document.getElementById("model-provider")?.value || "ollama"; + const configuredModel = configuredProviderModel(provider); + providerModelSelect.innerHTML = ""; + const allModels = [...new Set([configuredModel, ...models].filter(Boolean))]; + if (!allModels.length) { + const option = document.createElement("option"); + option.value = ""; + option.textContent = "No models detected"; + providerModelSelect.appendChild(option); + providerModelSelect.disabled = true; + return; + } + providerModelSelect.disabled = false; + for (const model of allModels) { const option = document.createElement("option"); option.value = model; - datalist.appendChild(option); + option.textContent = model; + if (model === configuredModel) option.selected = true; + providerModelSelect.appendChild(option); } } async function refreshOpenAIModels() { - setOllamaMessage("Loading OpenAI models"); + setOllamaMessage("Loading provider models"); try { - const response = await fetch("/api/openai/models"); + const provider = document.getElementById("model-provider")?.value || latestOllamaStatus?.provider || "openai"; + const response = await fetch(`/api/provider/models?provider=${encodeURIComponent(provider)}`); const result = await response.json(); if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`); - renderProviderModelOptions(result.models || []); - setOllamaMessage(result.message || "Loaded OpenAI models"); + renderProviderModelOptions(result.models || [], { + provider: result.provider || provider, + configured_model: configuredProviderModel(result.provider || provider), + }); + setOllamaMessage(result.message || "Loaded provider models"); await refreshOllamaStatus(); } catch (error) { - setOllamaMessage(`OpenAI models failed: ${fetchErrorMessage(error)}`); + setOllamaMessage(`Provider model load failed: ${fetchErrorMessage(error)}`); } } +async function launchCodexLogin() { + markOllamaActionClicked("codex-login"); + setOllamaMessage("Starting Codex sign-in"); + try { + const response = await fetch("/api/codex/login", { method: "POST" }); + const result = await response.json(); + if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`); + if (result.auth_url) { + window.open(result.auth_url, "_blank", "noopener,noreferrer"); + } + setOllamaMessage(result.message || "Opened Codex sign-in in your browser. Waiting for completion..."); + await waitForCodexLogin(); + } catch (error) { + setOllamaMessage(`Codex sign-in failed: ${fetchErrorMessage(error)}`); + } +} + +async function waitForCodexLogin() { + for (let attempt = 0; attempt < 80; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, attempt < 8 ? 1500 : 3000)); + await refreshOllamaStatus(); + const provider = latestOllamaStatus?.provider || ""; + if (provider === "codex" && latestOllamaStatus?.online) { + setOllamaMessage("Codex sign-in complete."); + return; + } + } + setOllamaMessage("Codex sign-in opened. If you completed it, click Load Provider Models or refresh provider status."); +} + +function renderReasoningEffortOptions(efforts, configured) { + if (!modelReasoningEffortSelect) return; + const options = [...new Set([...(efforts || []), configured || "medium"].filter(Boolean))]; + modelReasoningEffortSelect.innerHTML = ""; + for (const effort of options) { + const option = document.createElement("option"); + option.value = effort; + option.textContent = effort; + if (effort === configured) option.selected = true; + modelReasoningEffortSelect.appendChild(option); + } +} + +function configuredProviderModel(provider) { + if (provider === "openai") return document.getElementById("openai-model")?.value || ""; + if (provider === "codex") return document.getElementById("codex-model")?.value || ""; + return document.getElementById("ollama-model")?.value || ""; +} + +function syncSelectedProviderModel() { + const provider = document.getElementById("model-provider")?.value || "ollama"; + const selectedModel = providerModelSelect?.value || ""; + if (!selectedModel) return; + if (provider === "openai") { + const field = document.getElementById("openai-model"); + if (field) field.value = selectedModel; + return; + } + if (provider === "codex") { + const field = document.getElementById("codex-model"); + if (field) field.value = selectedModel; + return; + } + const field = document.getElementById("ollama-model"); + if (field) field.value = selectedModel; +} + +function providerDisplayName(provider) { + if (provider === "openai") return "OpenAI"; + if (provider === "codex") return "Codex"; + return "Local Ollama"; +} + async function checkForUpdate(promptUser = false) { if (!updateStatusEl) return; updateStatusEl.textContent = "Checking releases"; @@ -1282,34 +1408,81 @@ function renderPlansRail(plans) { async function renderPlans(plans, openPlanId = null) { plansDashboardEl.innerHTML = ""; if (!plans.length) { - plansDashboardEl.innerHTML = '
No continual plans
'; + plansDashboardEl.innerHTML = ` +
+
+

Plan board

+

No plans yet

+

Create a buying watchlist or a custom follow-up routine to start tracking work over time.

+
+
+
+

Nothing is running

+

Your continual plans will appear here with status, timing, and recent activity.

+
+ `; return; } + const activeCount = plans.filter((plan) => plan.status === "active").length; + const attentionCount = plans.filter((plan) => plan.status === "needs_input" || plan.status === "paused").length; + const overview = document.createElement("section"); + overview.className = "plans-overview"; + overview.innerHTML = ` +
+

Plan board

+

${plans.length} continual ${plans.length === 1 ? "plan" : "plans"}

+

Monitor recurring work, keep candidate leads in view, and jump into details when something needs attention.

+
+
+
+ ${activeCount} + active +
+
+ ${attentionCount} + needs eyes +
+
+ `; + plansDashboardEl.appendChild(overview); for (const plan of plans) { const card = document.createElement("article"); - card.className = `plan-card${plan.status === "active" ? " active" : ""}`; + card.className = `plan-card plan-status-${slugifyPlanValue(plan.status)}${plan.status === "active" ? " active" : ""}`; + const heading = document.createElement("div"); + heading.className = "plan-card-heading"; const title = document.createElement("h3"); title.textContent = plan.title || "Untitled plan"; + const statusBadge = document.createElement("span"); + statusBadge.className = `plan-status-badge plan-status-${slugifyPlanValue(plan.status)}`; + statusBadge.textContent = humanizePlanValue(plan.status || "unknown"); const meta = document.createElement("div"); meta.className = "plan-meta"; meta.textContent = plan.objective || ""; const pills = document.createElement("div"); pills.className = "plan-pill-row"; - for (const value of [plan.status, plan.kind, plan.next_run_at ? `next ${formatShortDate(plan.next_run_at)}` : "not scheduled"]) { + for (const value of [plan.kind, plan.next_run_at ? `next ${formatShortDate(plan.next_run_at)}` : "not scheduled"]) { const pill = document.createElement("span"); pill.className = "plan-pill"; - pill.textContent = value; + pill.textContent = humanizePlanValue(value); pills.appendChild(pill); } + const metrics = document.createElement("div"); + metrics.className = "plan-metrics"; + metrics.append( + planMetric("Checklist", String((plan.items || []).length)), + planMetric("Cadence", summarizeCadence(plan.cadence)), + planMetric("Updated", formatShortDate(plan.updated_at || plan.created_at)) + ); const controls = document.createElement("div"); controls.className = "plan-controls"; controls.append( planButton("Details", () => loadPlanDetail(plan.id, card)), planButton("Run", () => postPlanAction(plan.id, "run")), planButton(plan.status === "active" ? "Pause" : "Resume", () => postPlanAction(plan.id, plan.status === "active" ? "pause" : "resume")), - planButton("Cancel", () => postPlanAction(plan.id, "cancel"), "secondary small-button") + planButton("Delete", () => deletePlan(plan.id), "secondary small-button") ); - card.append(title, meta, pills, controls); + heading.append(title, statusBadge); + card.append(heading, meta, pills, metrics, controls); plansDashboardEl.appendChild(card); if (openPlanId && plan.id === openPlanId) await loadPlanDetail(plan.id, card); } @@ -1330,41 +1503,113 @@ async function loadPlanDetail(planId, card) { existing.remove(); return; } + const loading = document.createElement("div"); + loading.className = "plan-detail plan-detail-loading"; + loading.textContent = "Loading plan details..."; + card.appendChild(loading); const response = await fetch(`/api/plans/${encodeURIComponent(planId)}`); const result = await response.json(); const plan = result.plan; + loading.remove(); const detail = document.createElement("div"); detail.className = "plan-detail"; detail.append( - planSection("Checklist", (plan.items || []).map((item) => `${item.item_name}: ${item.acquired_quantity || 0}/${item.desired_quantity || 1}${item.max_unit_price ? `, max ${Number(item.max_unit_price).toLocaleString()} UEC` : ""} (${item.status})`)), - planSection("Best Candidates", bestCandidateLines(plan)), - planSection("Recent Events", (plan.events || []).slice(0, 5).map((event) => `${formatShortDate(event.created_at)} ${event.kind}: ${event.message}`)) + planSection("Checklist", checklistLines(plan), "checklist"), + planSection("Best Candidates", bestCandidateLines(plan), "candidates"), + planSection("Recent Events", recentEventLines(plan), "events") ); card.appendChild(detail); } -function planSection(title, lines) { +function planSection(title, lines, sectionClass = "") { const wrapper = document.createElement("section"); + wrapper.className = `plan-section${sectionClass ? ` plan-section-${sectionClass}` : ""}`; const heading = document.createElement("h4"); heading.textContent = title; const list = document.createElement("ul"); list.className = "plan-list"; - const items = lines.length ? lines : ["Empty"]; + const items = lines.length ? lines : [planListItemData("Empty", "Nothing to show right now.")]; for (const line of items) { const item = document.createElement("li"); - item.textContent = line; + if (typeof line === "string") { + item.textContent = line; + } else { + item.className = line.className || ""; + const titleEl = document.createElement("div"); + titleEl.className = "plan-list-title"; + titleEl.textContent = line.title; + const bodyEl = document.createElement("div"); + bodyEl.className = "plan-list-body"; + bodyEl.textContent = line.body; + item.append(titleEl, bodyEl); + } list.appendChild(item); } wrapper.append(heading, list); return wrapper; } +function planMetric(label, value) { + const metric = document.createElement("div"); + metric.className = "plan-metric"; + const metricLabel = document.createElement("span"); + metricLabel.className = "plan-metric-label"; + metricLabel.textContent = label; + const metricValue = document.createElement("span"); + metricValue.className = "plan-metric-value"; + metricValue.textContent = value; + metric.append(metricLabel, metricValue); + return metric; +} + +function summarizeCadence(cadence) { + if (!cadence) return "manual"; + return cadence.replace(/\s+/g, " ").trim(); +} + +function slugifyPlanValue(value) { + return String(value || "unknown") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, ""); +} + +function humanizePlanValue(value) { + return String(value || "") + .replace(/_/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()); +} + +function planListItemData(title, body, className = "") { + return { title, body, className }; +} + +function checklistLines(plan) { + return (plan.items || []).map((item) => { + const quantity = `${item.acquired_quantity || 0}/${item.desired_quantity || 1}`; + const price = item.max_unit_price ? `Max ${Number(item.max_unit_price).toLocaleString()} UEC` : "No price cap"; + return planListItemData(item.item_name, `${quantity} acquired • ${price} • ${humanizePlanValue(item.status || "pending")}`); + }); +} + function bestCandidateLines(plan) { const byItem = new Map((plan.items || []).map((item) => [item.id, item.item_name])); return (plan.candidates || []) .filter((candidate) => candidate.status === "current" || candidate.status === "drafted") .slice(0, 6) - .map((candidate) => `${byItem.get(candidate.plan_item_id) || "Item"}: ${candidate.title || candidate.listing_slug || candidate.listing_id} at ${Number(candidate.price || 0).toLocaleString()} ${candidate.currency || "UEC"} from ${candidate.seller || "unknown"} (${candidate.status})`); + .map((candidate) => { + const title = byItem.get(candidate.plan_item_id) || "Item"; + const listing = candidate.title || candidate.listing_slug || candidate.listing_id || "Unnamed listing"; + const price = `${Number(candidate.price || 0).toLocaleString()} ${candidate.currency || "UEC"}`; + const seller = candidate.seller || "unknown seller"; + return planListItemData(title, `${listing} • ${price} • ${seller} • ${humanizePlanValue(candidate.status || "current")}`); + }); +} + +function recentEventLines(plan) { + return (plan.events || []) + .slice(0, 5) + .map((event) => planListItemData(`${formatShortDate(event.created_at)} • ${humanizePlanValue(event.kind || "event")}`, event.message || "No details.")); } async function postPlanAction(planId, action) { @@ -1382,6 +1627,22 @@ async function postPlanAction(planId, action) { } } +async function deletePlan(planId) { + if (!window.confirm("Delete this plan and its stored history?")) return; + plansStatusEl.textContent = "delete requested"; + try { + const response = await fetch(`/api/plans/${encodeURIComponent(planId)}`, { method: "DELETE" }); + const result = await response.json(); + if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`); + plansStatusEl.textContent = result.summary || "Plan deleted"; + await refreshPlans(); + await refreshPending(); + await refreshInbox(); + } catch (error) { + plansStatusEl.textContent = `Plan delete failed: ${fetchErrorMessage(error)}`; + } +} + function formatShortDate(value) { if (!value) return ""; const date = new Date(value); @@ -1391,10 +1652,20 @@ function formatShortDate(value) { async function checkHealth() { try { - const response = await fetch("/api/health"); - const result = await response.json(); - const health = result.ollama || {}; - const provider = health.provider === "openai" ? "OpenAI" : "Ollama"; + let health = {}; + try { + const response = await fetch("/api/health"); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const result = await response.json(); + health = result.inference || result.ollama || {}; + } catch (primaryError) { + const fallbackResponse = await fetch("/api/ollama/status"); + if (!fallbackResponse.ok) throw primaryError; + health = await fallbackResponse.json(); + } + const provider = providerDisplayName(health.provider); + const isOpenAIProvider = health.provider === "openai"; + const isCodexProvider = health.provider === "codex"; ollamaOnline = Boolean(health.online); if (!ollamaOnline) { statusEl.textContent = "Offline"; @@ -1403,7 +1674,7 @@ async function checkHealth() { return false; } if (health.model_available === false) { - const action = health.provider === "openai" ? "Load OpenAI Models." : "Install Model."; + const action = isOpenAIProvider ? "Load Provider Models." : isCodexProvider ? "Sign In to Codex." : "Install Model."; setWarning(`${provider} needs the configured model "${health.model}". Open the model provider tab and use ${action}`); ollamaToggle?.classList.add("attention-pulse"); } else { @@ -1637,6 +1908,13 @@ ollamaPullButton?.addEventListener("click", () => { markOllamaActionClicked("pull"); postOllamaAction("/api/ollama/pull", { body: { model: configuredOllamaModel() } }); }); +codexLoginButton?.addEventListener("click", launchCodexLogin); +providerModelSelect?.addEventListener("change", syncSelectedProviderModel); +document.getElementById("model-provider")?.addEventListener("change", () => { + const provider = document.getElementById("model-provider")?.value || "ollama"; + updateProviderFieldVisibility(provider); + renderProviderModelOptions(latestOllamaStatus?.models || [], { ...latestOllamaStatus, provider }); +}); openaiModelsRefreshButton?.addEventListener("click", () => { markOllamaActionClicked("openai-models"); refreshOpenAIModels(); diff --git a/web/index.html b/web/index.html index b52f702..fdf3aff 100644 --- a/web/index.html +++ b/web/index.html @@ -119,22 +119,27 @@
@@ -157,10 +163,10 @@ Memory - @@ -199,23 +205,42 @@
-
- - - - - - - - -
-
-
+ +
+
+