Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ HEXIS_BIND_ADDRESS=127.0.0.1
# ANTHROPIC_API_KEY=replace-with-your-key
# GEMINI_API_KEY=replace-with-your-key
# XAI_API_KEY=replace-with-your-key
# ATLASCLOUD_API_KEY=replace-with-your-key

# A server that does not authenticate still needs a non-secret value because
# the OpenAI client requires one, for example: OPENAI_API_KEY=not-needed
3 changes: 2 additions & 1 deletion apps/hexis_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3350,6 +3350,7 @@ def _resolve_fallback_api_key(provider: str, role: str) -> str | None:
# Then provider-specific conventional env vars.
mapping = {
"openai": "OPENAI_API_KEY",
"atlascloud": "ATLASCLOUD_API_KEY",
"anthropic": "ANTHROPIC_API_KEY",
"grok": "XAI_API_KEY",
"gemini": "GEMINI_API_KEY",
Expand Down Expand Up @@ -3537,7 +3538,7 @@ async def init_consent_request(req: InitConsentRequest):
# Fail early if we need a key (unless mocked).
if (
not use_mock_consent
and provider in {"openai", "anthropic", "grok", "gemini"}
and provider in {"openai", "atlascloud", "anthropic", "grok", "gemini"}
and not api_key
):
return JSONResponse({"error": "Missing API key"}, status_code=400)
Expand Down
13 changes: 12 additions & 1 deletion apps/hexis_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@

_PROVIDER_ENV_VARS: dict[str, str] = {
"anthropic": "ANTHROPIC_API_KEY",
"atlascloud": "ATLASCLOUD_API_KEY",
"openai": "OPENAI_API_KEY",
"openai_compatible": "OPENAI_API_KEY",
"openai-codex": "",
Expand Down Expand Up @@ -133,6 +134,9 @@ def _resolve_noninteractive_endpoint(
"""Return the endpoint and its visible source label."""
endpoint = (args.endpoint or "").strip()
source = "--endpoint" if endpoint else ""
if not endpoint and provider == "atlascloud":
endpoint = "https://api.atlascloud.ai/v1"
source = "provider default"
if not endpoint and provider == "openai_compatible":
endpoint = os.getenv("OPENAI_BASE_URL", "").strip()
source = "OPENAI_BASE_URL" if endpoint else ""
Expand Down Expand Up @@ -793,6 +797,7 @@ async def _configure_llm(conn: Any, *, dsn: str, wait_seconds: int) -> dict[str,
("OpenAI Codex — ChatGPT Plus/Pro OAuth (no API key)", "openai-codex"),
("OpenAI — API key", "openai"),
("Anthropic — API key", "anthropic"),
("Atlas Cloud — API key", "atlascloud"),
("Grok (xAI) — API key", "grok"),
("Gemini — API key", "gemini"),
("Chutes — OAuth", "chutes"),
Expand Down Expand Up @@ -880,7 +885,13 @@ async def _configure_llm(conn: Any, *, dsn: str, wait_seconds: int) -> dict[str,
"Endpoint (blank for provider default)",
# Only OpenAI-shaped providers should inherit OPENAI_BASE_URL — don't
# bleed it into Grok/Gemini/Anthropic (Bar #5).
default=os.getenv("OPENAI_BASE_URL", "") if provider in {"openai", "openai_compatible"} else "",
default=(
"https://api.atlascloud.ai/v1"
if provider == "atlascloud"
else os.getenv("OPENAI_BASE_URL", "")
if provider in {"openai", "openai_compatible"}
else ""
),
)
api_key_env = _prompt(
"API key env var name (e.g. OPENAI_API_KEY)",
Expand Down
5 changes: 5 additions & 0 deletions apps/tui/init_screens.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ def _plain(text: str, token: str = "text") -> Text:

_DEFAULT_MODELS: dict[str, str] = {
"anthropic": "claude-sonnet-4-20250514",
"atlascloud": "deepseek-ai/deepseek-v4-pro",
"openai": "gpt-4o",
"openai-codex": "gpt-5.2",
"grok": "grok-3",
Expand All @@ -63,6 +64,7 @@ def _plain(text: str, token: str = "text") -> Text:

_PROVIDER_ENV_VARS: dict[str, str] = {
"anthropic": "ANTHROPIC_API_KEY",
"atlascloud": "ATLASCLOUD_API_KEY",
"openai": "OPENAI_API_KEY",
"openai-codex": "",
"grok": "XAI_API_KEY",
Expand All @@ -79,6 +81,7 @@ def _plain(text: str, token: str = "text") -> Text:
("OpenAI Codex (ChatGPT OAuth)", "openai-codex"),
("OpenAI Platform (API key)", "openai"),
("Anthropic (API key)", "anthropic"),
("Atlas Cloud (API key)", "atlascloud"),
("Grok (xAI)", "grok"),
("Gemini", "gemini"),
("Chutes (OAuth)", "chutes"),
Expand Down Expand Up @@ -173,6 +176,8 @@ class LLMConfigScreen(Screen):

@staticmethod
def _default_endpoint(provider: str) -> str:
if provider == "atlascloud":
return "https://api.atlascloud.ai/v1"
if provider == "openai":
return os.getenv("OPENAI_BASE_URL", "")
return ""
Expand Down
29 changes: 27 additions & 2 deletions apps/tui/model_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
auto-updating source over a hand-maintained list, treat it as advisory, and
always allow a free-typed id.

* cloud providers → the models.dev catalog (one no-auth JSON covering every
provider), cached on disk ~24h and sorted newest-first
* most cloud providers → the models.dev catalog, cached on disk ~24h
* Atlas Cloud → its public model catalog, filtered to visible LLMs
* on any failure → a short curated fallback; the model field stays free-text
"""
from __future__ import annotations
Expand All @@ -18,6 +18,7 @@
from core.integration_reliability import request_json

MODELS_DEV_URL = "https://models.dev/api.json"
ATLAS_MODELS_URL = "https://api.atlascloud.ai/api/v1/models"
_CACHE = Path.home() / ".cache" / "hexis" / "models_dev.json"
_CACHE_TTL = 24 * 3600
_TIMEOUT = 12.0
Expand Down Expand Up @@ -46,6 +47,7 @@

# Minimal offline fallback if models.dev is unreachable.
_FALLBACK: dict[str, list[str]] = {
"atlascloud": ["deepseek-ai/deepseek-v4-pro"],
"openai": ["gpt-5.2", "gpt-4o", "gpt-4o-mini"],
"openai-codex": ["gpt-5.2", "gpt-5.2-codex"],
"anthropic": ["claude-opus-4-8", "claude-sonnet-5", "claude-haiku-4-5"],
Expand Down Expand Up @@ -192,6 +194,29 @@ async def fetch_models(
api_key: str | None = None,
) -> list[str]:
"""Best-effort current chat model ids for *provider* (newest first)."""
if provider == "atlascloud":
try:
data = await request_json(
"atlascloud_models",
"GET",
ATLAS_MODELS_URL,
timeout=_TIMEOUT,
attempts=2,
max_delay=2.0,
)
rows = data.get("data", []) if isinstance(data, dict) else []
models = [
str(row["model"])
for row in rows
if isinstance(row, dict)
and row.get("display_console") is True
and row.get("type") == "Text"
and "LLM" in (row.get("categories") or [])
and row.get("model")
]
return models or _FALLBACK[provider]
except Exception:
return _FALLBACK[provider]
if provider == "openai_compatible":
if not endpoint:
return []
Expand Down
2 changes: 1 addition & 1 deletion core/cli_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,7 @@ def _validate_llm(name: str) -> None:
errors.append(f"{name}.api_key_env={api_key_env} is not set in environment")
return

if provider in {"openai", "anthropic", "grok", "gemini"}:
if provider in {"openai", "atlascloud", "anthropic", "grok", "gemini"}:
if api_key_env:
if os.getenv(api_key_env) is None:
errors.append(
Expand Down
9 changes: 8 additions & 1 deletion core/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@

OPENAI_COMPATIBLE = {
"openai",
"atlascloud",
"openai_compatible",
"openai-chat-completions-endpoint",
"grok",
Expand Down Expand Up @@ -477,7 +478,11 @@ def _should_try_responses(
# A generic compatible endpoint promises Chat Completions, not the
# Responses event grammar. Some servers expose /responses nominally but
# emit tool/reasoning items the official SDK cannot parse safely.
if provider in {"openai_compatible", "openai-chat-completions-endpoint"}:
if provider in {
"atlascloud",
"openai_compatible",
"openai-chat-completions-endpoint",
}:
return False
if not _HAS_RESPONSES_API:
return False
Expand Down Expand Up @@ -678,6 +683,7 @@ def normalize_endpoint(provider: str, endpoint: str | None) -> str | None:
if endpoint:
return endpoint.strip() or None
_DEFAULTS: dict[str, str] = {
"atlascloud": "https://api.atlascloud.ai/v1",
"grok": "https://api.x.ai/v1",
"chutes": "https://api.chutes.ai/v1",
"qwen-portal": "https://portal.qwen.ai/v1",
Expand Down Expand Up @@ -718,6 +724,7 @@ def normalize_llm_config(config: dict[str, Any] | None, *, default_model: str =
if not api_key:
provider_env_map = {
"openai": "OPENAI_API_KEY",
"atlascloud": "ATLASCLOUD_API_KEY",
"anthropic": "ANTHROPIC_API_KEY",
"grok": "XAI_API_KEY",
"gemini": "GEMINI_API_KEY",
Expand Down
5 changes: 5 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ services:
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
XAI_API_KEY: ${XAI_API_KEY:-}
ATLASCLOUD_API_KEY: ${ATLASCLOUD_API_KEY:-}
HEXIS_LLM_CONSCIOUS_API_KEY: ${HEXIS_LLM_CONSCIOUS_API_KEY:-}
HEXIS_LLM_SUBCONSCIOUS_API_KEY: ${HEXIS_LLM_SUBCONSCIOUS_API_KEY:-}
volumes:
Expand Down Expand Up @@ -216,6 +217,7 @@ services:
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
XAI_API_KEY: ${XAI_API_KEY:-}
ATLASCLOUD_API_KEY: ${ATLASCLOUD_API_KEY:-}
HEXIS_LLM_CONSCIOUS_API_KEY: ${HEXIS_LLM_CONSCIOUS_API_KEY:-}
HEXIS_LLM_SUBCONSCIOUS_API_KEY: ${HEXIS_LLM_SUBCONSCIOUS_API_KEY:-}
volumes:
Expand Down Expand Up @@ -271,6 +273,7 @@ services:
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
XAI_API_KEY: ${XAI_API_KEY:-}
ATLASCLOUD_API_KEY: ${ATLASCLOUD_API_KEY:-}
HEXIS_LLM_CONSCIOUS_API_KEY: ${HEXIS_LLM_CONSCIOUS_API_KEY:-}
HEXIS_LLM_SUBCONSCIOUS_API_KEY: ${HEXIS_LLM_SUBCONSCIOUS_API_KEY:-}
volumes:
Expand Down Expand Up @@ -326,6 +329,7 @@ services:
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
XAI_API_KEY: ${XAI_API_KEY:-}
ATLASCLOUD_API_KEY: ${ATLASCLOUD_API_KEY:-}
HEXIS_LLM_CONSCIOUS_API_KEY: ${HEXIS_LLM_CONSCIOUS_API_KEY:-}
HEXIS_LLM_SUBCONSCIOUS_API_KEY: ${HEXIS_LLM_SUBCONSCIOUS_API_KEY:-}
volumes:
Expand Down Expand Up @@ -359,6 +363,7 @@ services:
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
XAI_API_KEY: ${XAI_API_KEY:-}
ATLASCLOUD_API_KEY: ${ATLASCLOUD_API_KEY:-}
HEXIS_LLM_CONSCIOUS_API_KEY: ${HEXIS_LLM_CONSCIOUS_API_KEY:-}
HEXIS_LLM_SUBCONSCIOUS_API_KEY: ${HEXIS_LLM_SUBCONSCIOUS_API_KEY:-}
networks:
Expand Down
1 change: 1 addition & 0 deletions docs/operations/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ All environment variables used by Hexis, configured via `.env`.
| `ANTHROPIC_API_KEY` | Anthropic API key |
| `GEMINI_API_KEY` | Google Gemini API key |
| `XAI_API_KEY` | xAI Grok API key |
| `ATLASCLOUD_API_KEY` | Atlas Cloud API key |

These are only needed for API-key providers. OAuth providers store credentials in the database.

Expand Down
32 changes: 32 additions & 0 deletions hexis-ui/app/api/init/models/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";

import { atlasChatModels } from "./route";

describe("atlasChatModels", () => {
it("keeps only visible Atlas Cloud LLM models", () => {
expect(
atlasChatModels({
data: [
{
model: "chat/model",
type: "Text",
categories: ["LLM"],
display_console: true,
},
{
model: "hidden/model",
type: "Text",
categories: ["LLM"],
display_console: false,
},
{
model: "image/model",
type: "Image",
categories: ["TEXT-TO-IMAGE"],
display_console: true,
},
],
})
).toEqual(["chat/model"]);
});
});
41 changes: 39 additions & 2 deletions hexis-ui/app/api/init/models/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
//
// Mirrors apps/tui/model_catalog.py: prefer a live, auto-updating source over a
// hand-maintained list, treat it as advisory, and always allow a free-typed id.
// * cloud providers -> the models.dev catalog (one no-auth JSON), cached ~24h
// in-memory and sorted newest-first
// * most cloud providers -> the models.dev catalog, cached ~24h in-memory
// * Atlas Cloud -> its public model catalog, filtered to visible LLMs
// * on any failure -> a short curated fallback; the model field stays free-text

import { catalogDeclaredDefault } from "@/lib/init-llm";
Expand All @@ -12,6 +12,7 @@ import { hexisApiBaseUrl } from "@/lib/python-api";
export const runtime = "nodejs";

const MODELS_DEV_URL = "https://models.dev/api.json";
const ATLAS_MODELS_URL = "https://api.atlascloud.ai/api/v1/models";
const CACHE_TTL_MS = 24 * 3600 * 1000;
const TIMEOUT_MS = 12000;

Expand Down Expand Up @@ -61,6 +62,7 @@ const DEFAULT_SKIP = [

// Minimal offline fallback if models.dev is unreachable.
const FALLBACK: Record<string, string[]> = {
atlascloud: ["deepseek-ai/deepseek-v4-pro"],
openai: ["gpt-5.2", "gpt-4o", "gpt-4o-mini"],
anthropic: ["claude-opus-4-8", "claude-sonnet-5", "claude-haiku-4-5"],
grok: ["grok-4.3", "grok-3"],
Expand Down Expand Up @@ -169,6 +171,21 @@ function chatModels(value: unknown): string[] {
return ids;
}

export function atlasChatModels(value: unknown): string[] {
const rows = asRecord(value).data;
if (!Array.isArray(rows)) return [];
return rows.flatMap((value) => {
const row = asRecord(value);
const categories = Array.isArray(row.categories) ? row.categories : [];
return row.display_console === true &&
row.type === "Text" &&
categories.includes("LLM") &&
typeof row.model === "string"
? [row.model]
: [];
});
}

// Pick a sensible default from the live catalog (newest-first list).
function recommendedDefault(_provider: string, models: string[]): string {
if (models.length === 0) return "";
Expand Down Expand Up @@ -214,6 +231,26 @@ export async function GET(request: Request) {
}
}

if (provider === "atlascloud") {
try {
const response = await fetchWithTimeout(ATLAS_MODELS_URL);
if (!response.ok) throw new Error(`Atlas Cloud responded ${response.status}`);
const models = atlasChatModels(await response.json());
const available = models.length > 0 ? models : FALLBACK[provider];
return Response.json({
models: available,
default: recommendedDefault(provider, available),
});
} catch (err: unknown) {
const models = FALLBACK[provider];
return Response.json({
models,
default: recommendedDefault(provider, models),
error: errorMessage(err, "Unable to load Atlas Cloud model catalog."),
});
}
}

const slug = PROVIDER_SLUG[provider];
if (!slug) {
const models = FALLBACK[provider] || [];
Expand Down
8 changes: 8 additions & 0 deletions hexis-ui/app/init/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const stagePrompt: Record<InitStage, string> = {
type LlmProvider =
| "openai-codex"
| "openai"
| "atlascloud"
| "anthropic"
| "grok"
| "gemini"
Expand Down Expand Up @@ -198,6 +199,13 @@ const providerMeta: Record<LlmProvider, ProviderMeta> = {
apiKeyRequired: true,
oauth: false,
},
atlascloud: {
label: "Atlas Cloud",
endpoint: "https://api.atlascloud.ai/v1",
apiKeyLabel: "Atlas Cloud API Key",
apiKeyRequired: true,
oauth: false,
},
anthropic: {
label: "Anthropic",
endpoint: "",
Expand Down
6 changes: 6 additions & 0 deletions hexis-ui/lib/init-llm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ describe("resolveInitLlmEndpoint", () => {
);
});

it("derives the Atlas Cloud API-key default", () => {
expect(resolveInitLlmEndpoint("atlascloud", "")).toBe(
"https://api.atlascloud.ai/v1"
);
});

it("derives the recommended model from live catalog metadata", () => {
const block = {
models: {
Expand Down
1 change: 1 addition & 0 deletions hexis-ui/lib/init-llm.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const ENDPOINT_DEFAULTS: Record<string, string> = {
atlascloud: "https://api.atlascloud.ai/v1",
openai: "https://api.openai.com/v1",
};

Expand Down
Loading