import { homedir } from "node:os" import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" import { join } from "node:path" const PROVIDER_ID = "Arka-AI" const API_URL = "https://api.arka-ai.ru/v1" const CATALOG_URL = "https://git.arka-ai.ru/arka/public/raw/branch/main/opencode/catalog.json" const TTL_MS = 24 * 60 * 60 * 1000 function configDir(): string { const xdg = process.env.XDG_CONFIG_HOME if (xdg) return join(xdg, "opencode") return join(homedir(), ".config", "opencode") } function cacheDir(): string { const xdg = process.env.XDG_CACHE_HOME if (xdg) return join(xdg, "opencode") return join(homedir(), ".cache", "opencode") } const CACHE_FILE = join(cacheDir(), "arka-ai-models.json") const KEY_FILE = join(configDir(), ".arka-api-key") function readApiKey(): string | undefined { const fromEnv = process.env.ARKA_API_KEY?.trim() if (fromEnv) return fromEnv try { if (existsSync(KEY_FILE)) { const fromFile = readFileSync(KEY_FILE, "utf8").trim() if (fromFile) return fromFile } } catch {} return undefined } // Ids that must always be exposed, even if the filter would drop them. const FORCED_MODELS = [ "DeepSeek-V4-Pro", "deepseek-v4-flash-0731", "qwen3.8-max", "kimi-k3", "MiniMax-M3", "GLM-5.3", ] // Heuristic: drop non-chat models (TTS/ASR/image/video/OCR/embeddings/rerankers…) // from the catalog so they don't clutter the /models picker. // Keep in sync with scripts/sync-catalog.py const NON_CHAT = new RegExp( [ "tts", "asr", "audio", "speech", "voice", "whisper", "sensevoice", "image", "(^|[.-])img", "wan", "vidu", "video", "sora", "flux", "diffus", "kolors", "cogview", "ocr", "embedding", "(^|[.-])embed", "rerank", "clip", "moderation", "(^|[.-])filter", "detect", "classif", "(^|[.-])location", "(^|[.-])search", "comfyui", "meme", "hello", "(^|[.-])sam", "florence", "esrgan", "rmbg", "background", "poster", "(^|[.-])face", "(^|[.-])lip", "translate", "(^|[.-])mt([2-]|$)", "(^|[.-])bge", "(^|[.-])e5", "(^|[.-])jina", "(^|[.-])mpnet", "spark-tts", "cosyvoice", "chattts", "indextts", "step-audio", "fish-speech", "cosy", "mate-gguf", "tokenizer", ].join("|"), "i", ) type ModelMap = Record type Cache = { ts: number; models: ModelMap } function loadCache(): Cache | null { try { if (existsSync(CACHE_FILE)) return JSON.parse(readFileSync(CACHE_FILE, "utf8")) } catch {} return null } function saveCache(models: ModelMap): void { try { mkdirSync(cacheDir(), { recursive: true }) writeFileSync(CACHE_FILE, JSON.stringify({ ts: Date.now(), models })) } catch {} } async function fetchJson( url: string, headers: Record, timeoutMs: number, ): Promise { const ctrl = new AbortController() const timer = setTimeout(() => ctrl.abort(), timeoutMs) try { const res = await fetch(url, { headers, signal: ctrl.signal }) if (!res.ok) throw new Error(`HTTP ${res.status}`) return await res.json() } finally { clearTimeout(timer) } } function idsFromPayload(json: unknown): string[] { const data = (json as { data?: Array<{ id: string }> }).data return (data ?? []).map((m) => m.id).filter(Boolean) } function toModelMap(ids: string[], filter: boolean): ModelMap { const models: ModelMap = {} for (const id of ids) { if (!filter || FORCED_MODELS.includes(id) || !NON_CHAT.test(id)) { models[id] = { name: id } } } return models } async function resolveModels(): Promise { const cache = loadCache() if (cache && Date.now() - cache.ts < TTL_MS) return cache.models try { const models = toModelMap(idsFromPayload(await fetchJson(CATALOG_URL, {}, 5000)), false) if (Object.keys(models).length) { saveCache(models) return models } } catch {} try { const key = readApiKey() const json = await fetchJson( `${API_URL}/models`, key ? { Authorization: `Bearer ${key}` } : {}, 5000, ) const models = toModelMap(idsFromPayload(json), true) saveCache(models) return models } catch { return cache?.models ?? null } } export default async function ArkaAIProviderPlugin() { const models = await resolveModels() const apiKey = readApiKey() return { config: (cfg: any) => { cfg.provider ??= {} const prev = cfg.provider[PROVIDER_ID] ?? {} // Static models from opencode.json act as a fallback; the fetched // catalog is layered on top so the configured default model never // disappears even if the catalog drops it. // apiKey from env or ~/.config/opencode/.arka-api-key so Desktop / // Windows GUI works without a login-shell export. cfg.provider[PROVIDER_ID] = { name: "Arka-AI", api: API_URL, env: ["ARKA_API_KEY"], ...prev, models: { ...(prev.models ?? {}), ...(models ?? {}) }, options: { ...(prev.options ?? {}), ...(apiKey ? { apiKey } : {}), }, } }, } }