From 8a4fd6514f0ef372d7b931faf1304d5caa19ecc6 Mon Sep 17 00:00:00 2001 From: Dmitry Shilov Date: Thu, 27 Aug 2026 17:11:17 +0300 Subject: [PATCH] feat: OpenCode Arka-AI installer and daily chat catalog Public one-liner for opencode. Catalog is the filtered chat list from api.arka-ai.ru/v1/models, refreshed daily at 05:00 Europe/Moscow. --- .gitignore | 3 + README.md | 48 ++++ catalog.json | 473 ++++++++++++++++++++++++++++++++++ install-arka-provider.sh | 304 ++++++++++++++++++++++ plugins/arka-ai.ts | 136 ++++++++++ scripts/cron-sync.sh | 25 ++ scripts/sync-catalog.py | 129 ++++++++++ skills/arka-provider/SKILL.md | 57 ++++ 8 files changed, 1175 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 catalog.json create mode 100755 install-arka-provider.sh create mode 100644 plugins/arka-ai.ts create mode 100755 scripts/cron-sync.sh create mode 100755 scripts/sync-catalog.py create mode 100644 skills/arka-provider/SKILL.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f6d5072 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +*.pyc +__pycache__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..26fa75c --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# Arka public artifacts + +Public, anonymous files for Arka AI. Private product code stays in other Gitea +repos. + +## OpenCode Arka-AI provider + +`arka-ai.ru` is not in models.dev. One command installs the OpenCode provider +plugin, companion skill, and (optional) API key: + +```bash +curl -fsSL https://git.arka-ai.ru/arka/public/raw/branch/main/install-arka-provider.sh | bash +``` + +With a key and a default model: + +```bash +curl -fsSL https://git.arka-ai.ru/arka/public/raw/branch/main/install-arka-provider.sh \ + | bash -s -- --key "$ARKA_API_KEY" --set-default DeepSeek-V4-Pro +``` + +Then open a **new shell** and restart opencode. `/models` should list **Arka-AI** +(~116 chat models). + +The installer is offline and idempotent. It does not call `api.arka-ai.ru`. +`python3` is required only with `--set-default`. + +### Files + +| Path | Role | +|------|------| +| `install-arka-provider.sh` | Self-extracting installer (the one-liner) | +| `plugins/arka-ai.ts` | Provider plugin source (embedded in the installer) | +| `skills/arka-provider/SKILL.md` | Companion skill source (embedded in the installer) | +| `catalog.json` | Filtered chat catalog, refreshed daily at **05:00 Europe/Moscow** from `https://api.arka-ai.ru/v1/models` | + +Forced keep-list: `DeepSeek-V4-Pro`, `deepseek-v4-flash-0731`, `qwen3.8-max`, +`kimi-k3`, `MiniMax-M3`, `GLM-5.3`. + +Inference uses `ARKA_API_KEY` against `https://api.arka-ai.ru/v1`. Listing the +catalog does not require a key. + +After install: + +1. New shell so `ARKA_API_KEY` is exported (GUI/desktop launches may need the + key set in that session, not only `~/.bashrc`). +2. Restart opencode, run `/models`. +3. Refresh later: `rm -f ~/.cache/opencode/arka-ai-models.json` and restart. diff --git a/catalog.json b/catalog.json new file mode 100644 index 0000000..672d2ec --- /dev/null +++ b/catalog.json @@ -0,0 +1,473 @@ +{ + "updated_at": "2026-08-27T14:10:01Z", + "source": "https://api.arka-ai.ru/v1/models", + "raw_count": 216, + "chat_count": 116, + "object": "list", + "data": [ + { + "id": "ACE-Step-v1-3.5B", + "object": "model" + }, + { + "id": "Align-DS-V", + "object": "model" + }, + { + "id": "AnimeSharp", + "object": "model" + }, + { + "id": "AutoGLM-Phone-9B-Multilingual", + "object": "model" + }, + { + "id": "DeepSeek-Prover-V2-7B", + "object": "model" + }, + { + "id": "DeepSeek-R1", + "object": "model" + }, + { + "id": "DeepSeek-R1-Distill-Qwen-1.5B", + "object": "model" + }, + { + "id": "DeepSeek-R1-Distill-Qwen-14B", + "object": "model" + }, + { + "id": "DeepSeek-R1-Distill-Qwen-32B", + "object": "model" + }, + { + "id": "DeepSeek-R1-Distill-Qwen-7B", + "object": "model" + }, + { + "id": "DeepSeek-V3", + "object": "model" + }, + { + "id": "DeepSeek-V3.2", + "object": "model" + }, + { + "id": "DeepSeek-V3_1", + "object": "model" + }, + { + "id": "DeepSeek-V3_1-Terminus", + "object": "model" + }, + { + "id": "DeepSeek-V4-Pro", + "object": "model" + }, + { + "id": "DianJin-R1-32B", + "object": "model" + }, + { + "id": "Duix.Heygem", + "object": "model" + }, + { + "id": "ERNIE-4.5-Turbo", + "object": "model" + }, + { + "id": "ERNIE-4.5-Turbo-VL", + "object": "model" + }, + { + "id": "ERNIE-5.0-Thinking", + "object": "model" + }, + { + "id": "ERNIE-X1-Turbo", + "object": "model" + }, + { + "id": "Fin-R1", + "object": "model" + }, + { + "id": "GLM-4-32B", + "object": "model" + }, + { + "id": "GLM-4-9B-0414", + "object": "model" + }, + { + "id": "GLM-4.6", + "object": "model" + }, + { + "id": "GLM-4.7", + "object": "model" + }, + { + "id": "GLM-4.7-Flash", + "object": "model" + }, + { + "id": "GLM-4_5", + "object": "model" + }, + { + "id": "GLM-4_5-Air", + "object": "model" + }, + { + "id": "GLM-4_5V", + "object": "model" + }, + { + "id": "GLM-5", + "object": "model" + }, + { + "id": "GLM-5.1", + "object": "model" + }, + { + "id": "GLM-5.2", + "object": "model" + }, + { + "id": "GLM-5.3", + "object": "model" + }, + { + "id": "HY-MT1.5-7B", + "object": "model" + }, + { + "id": "HappyHorse-1.0", + "object": "model" + }, + { + "id": "HealthGPT-L14", + "object": "model" + }, + { + "id": "Hi3DGen", + "object": "model" + }, + { + "id": "HiDream-I1-Full", + "object": "model" + }, + { + "id": "HuatuoGPT-o1-7B", + "object": "model" + }, + { + "id": "Hunyuan3D-2", + "object": "model" + }, + { + "id": "InfiniteTalk", + "object": "model" + }, + { + "id": "InternVL2-8B", + "object": "model" + }, + { + "id": "InternVL3-38B", + "object": "model" + }, + { + "id": "InternVL3-78B", + "object": "model" + }, + { + "id": "KAT-Dev", + "object": "model" + }, + { + "id": "Kimi-K2.5", + "object": "model" + }, + { + "id": "Kimi-K2.7-Code", + "object": "model" + }, + { + "id": "LTX-2", + "object": "model" + }, + { + "id": "LegalOne-8B", + "object": "model" + }, + { + "id": "Lingshu-32B", + "object": "model" + }, + { + "id": "MAI-UI-8B", + "object": "model" + }, + { + "id": "MiMo-V2.5-Pro", + "object": "model" + }, + { + "id": "MinerU2.5", + "object": "model" + }, + { + "id": "MinerU2.5-Pro", + "object": "model" + }, + { + "id": "MiniMax-M2.1", + "object": "model" + }, + { + "id": "MiniMax-M2.5", + "object": "model" + }, + { + "id": "MiniMax-M2.7", + "object": "model" + }, + { + "id": "MiniMax-M3", + "object": "model" + }, + { + "id": "PDF-Extract-Kit-1.0", + "object": "model" + }, + { + "id": "QwQ-32B", + "object": "model" + }, + { + "id": "Qwen2-7B-Instruct", + "object": "model" + }, + { + "id": "Qwen2-VL-72B", + "object": "model" + }, + { + "id": "Qwen2.5-14B-Instruct", + "object": "model" + }, + { + "id": "Qwen2.5-7B-Instruct", + "object": "model" + }, + { + "id": "Qwen2.5-Coder-32B-Instruct", + "object": "model" + }, + { + "id": "Qwen3-0.6B", + "object": "model" + }, + { + "id": "Qwen3-14B", + "object": "model" + }, + { + "id": "Qwen3-30B-A3B-Instruct-2507", + "object": "model" + }, + { + "id": "Qwen3-32B", + "object": "model" + }, + { + "id": "Qwen3-4B", + "object": "model" + }, + { + "id": "Qwen3-8B", + "object": "model" + }, + { + "id": "Qwen3-Coder-30B-A3B-Instruct", + "object": "model" + }, + { + "id": "Qwen3-Coder-Flash", + "object": "model" + }, + { + "id": "Qwen3-Coder-Next", + "object": "model" + }, + { + "id": "Qwen3-Next-80B-A3B-Instruct", + "object": "model" + }, + { + "id": "Qwen3-Next-80B-A3B-Thinking", + "object": "model" + }, + { + "id": "Qwen3-VL-235B-A22B-Instruct", + "object": "model" + }, + { + "id": "Qwen3-VL-235B-A22B-Thinking", + "object": "model" + }, + { + "id": "Qwen3-VL-30B-A3B-Instruct", + "object": "model" + }, + { + "id": "Qwen3-VL-32B-Instruct", + "object": "model" + }, + { + "id": "Qwen3-VL-32B-Thinking", + "object": "model" + }, + { + "id": "Qwen3-VL-4B-Instruct", + "object": "model" + }, + { + "id": "Qwen3-VL-8B-Instruct", + "object": "model" + }, + { + "id": "Qwen3-VL-8B-Thinking", + "object": "model" + }, + { + "id": "Qwen3.5-122B-A10B", + "object": "model" + }, + { + "id": "Qwen3.5-27B", + "object": "model" + }, + { + "id": "Qwen3.5-27B-Claude-4.6-Opus-Reasoning-Distilled", + "object": "model" + }, + { + "id": "Qwen3.5-9B", + "object": "model" + }, + { + "id": "Qwen3.5-Flash", + "object": "model" + }, + { + "id": "Qwen3.5-Plus", + "object": "model" + }, + { + "id": "Qwen3.6-27B", + "object": "model" + }, + { + "id": "Qwen3.6-35B-A3B", + "object": "model" + }, + { + "id": "Qwen3.6-Flash", + "object": "model" + }, + { + "id": "Qwen3.6-Max", + "object": "model" + }, + { + "id": "Qwen3.6-Plus", + "object": "model" + }, + { + "id": "Qwen3.7-Max", + "object": "model" + }, + { + "id": "Qwen3.7-Plus", + "object": "model" + }, + { + "id": "Qwen3Guard-Gen-0.6B", + "object": "model" + }, + { + "id": "Qwen3Guard-Gen-4B", + "object": "model" + }, + { + "id": "Qwen3Guard-Gen-8B", + "object": "model" + }, + { + "id": "SeedVR2-3B", + "object": "model" + }, + { + "id": "Sinong1.0-32B", + "object": "model" + }, + { + "id": "Step-3.7-Flash", + "object": "model" + }, + { + "id": "UVDoc", + "object": "model" + }, + { + "id": "VajraV1", + "object": "model" + }, + { + "id": "deepseek-coder-33B-instruct", + "object": "model" + }, + { + "id": "deepseek-v4-flash-0731", + "object": "model" + }, + { + "id": "gemma-4-26B-A4B-it", + "object": "model" + }, + { + "id": "glm-4-9b-chat", + "object": "model" + }, + { + "id": "gpt-oss-120b", + "object": "model" + }, + { + "id": "happyhorse-1.1", + "object": "model" + }, + { + "id": "internlm3-8b-instruct", + "object": "model" + }, + { + "id": "kimi-k3", + "object": "model" + }, + { + "id": "nonescape-v0", + "object": "model" + }, + { + "id": "qwen3.8-max", + "object": "model" + } + ] +} diff --git a/install-arka-provider.sh b/install-arka-provider.sh new file mode 100755 index 0000000..d528b67 --- /dev/null +++ b/install-arka-provider.sh @@ -0,0 +1,304 @@ +#!/usr/bin/env bash +# +# install-arka-provider.sh — one-shot installer for the arka-ai.ru LLM provider +# in a fresh opencode install. +# +# It is fully self-contained: it embeds the provider plugin and the companion +# skill, writes them into ~/.config/opencode, and persists ARKA_API_KEY. +# +# Usage: +# bash install-arka-provider.sh # prompt for API key +# ARKA_API_KEY=xxx bash install-arka-provider.sh # key from env (no prompt) +# bash install-arka-provider.sh --key xxx --set-default DeepSeek-V4-Pro +# +# Options: +# --key KEY ARKA_API_KEY to persist (default: $ARKA_API_KEY, else prompt) +# --set-default MODEL also set "model": "Arka-AI/" in opencode.json +# -h, --help show this help +# +# Idempotent: safe to run multiple times. Overwrites plugin/skill with the +# embedded versions; never clobbers an existing opencode.json (only upserts the +# requested default model). Makes no network calls. + +set -euo pipefail + +usage() { + sed -n '2,21p' "$0" +} + +KEY="" +MODEL="" + +while [ $# -gt 0 ]; do + case "$1" in + --key) KEY="$2"; shift 2 ;; + --key=*) KEY="${1#*=}"; shift ;; + --set-default) MODEL="$2"; shift 2 ;; + --set-default=*) MODEL="${1#*=}"; shift ;; + -h|--help) usage; exit 0 ;; + *) printf 'unknown option: %s\n' "$1" >&2; usage; exit 1 ;; + esac +done + +CONF_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/opencode" +PLUGIN_DIR="$CONF_DIR/plugins" +SKILL_DIR="$CONF_DIR/skills/arka-provider" + +mkdir -p "$PLUGIN_DIR" "$SKILL_DIR" "$HOME" + +echo "==> writing provider plugin to $PLUGIN_DIR/arka-ai.ts" +cat > "$PLUGIN_DIR/arka-ai.ts" <<'PLUGIN' +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/catalog.json" +const CACHE_FILE = join(homedir(), ".cache", "opencode", "arka-ai-models.json") +const TTL_MS = 24 * 60 * 60 * 1000 + +// 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(join(homedir(), ".cache", "opencode"), { 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 = process.env.ARKA_API_KEY + 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() + + 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. + cfg.provider[PROVIDER_ID] = { + name: "Arka-AI", + api: API_URL, + env: ["ARKA_API_KEY"], + ...prev, + models: { ...(prev.models ?? {}), ...(models ?? {}) }, + } + }, + } +} +PLUGIN + +echo "==> writing companion skill to $SKILL_DIR/SKILL.md" +cat > "$SKILL_DIR/SKILL.md" <<'SKILL' +--- +name: arka-provider +description: Manage the custom Arka-AI (arka-ai.ru) LLM provider in opencode config. Use when the user needs to add, refresh, or fix the arka-ai.ru provider, sync its model catalog, change the Arka-AI default model, or when arka models are missing from /models. +--- + +# Arka-AI provider + +arka-ai.ru is **not** in models.dev, so opencode ships no built-in config for it. +It is wired up locally with a plugin + a static `provider` block in +`~/.config/opencode/opencode.json`. + +## How it works + +- Plugin: `~/.config/opencode/plugins/arka-ai.ts` + - On startup it loads the published chat catalog + `https://git.arka-ai.ru/arka/public/raw/branch/main/catalog.json` + (no API key; refreshed daily at 05:00 Europe/Moscow). + - If that fetch fails, it falls back to `https://api.arka-ai.ru/v1/models` + and filters out non-chat models (TTS/ASR/image/video/OCR/embedding/reranker/…) + via a heuristic regex so the `/models` picker stays clean. + - Caches the result at `~/.cache/opencode/arka-ai-models.json` (TTL 24h). + - Injects the provider into config via the `config` hook, layered over the + static block in `opencode.json`. +- Static block (`opencode.json` → `provider["Arka-AI"]`) holds + `name`, `api`, `env`, and a small fallback `models` map that is used when the + catalog is unreachable. +- Inference still uses `ARKA_API_KEY` against `https://api.arka-ai.ru/v1`. + +## Refresh the model list + +Catalog ~216 raw models / ~116 chat; the public snapshot updates at 05:00 +Europe/Moscow. Changes land in opencode only after cache expiry + restart +(config is not hot-reloaded). + +1. `rm -f ~/.cache/opencode/arka-ai-models.json` +2. Restart opencode (or run `opencode` again) — the plugin re-fetches on the + next cold start. + +Optionally inspect the live API (no key required for listing): + +```bash +curl -sS https://api.arka-ai.ru/v1/models | python3 -m json.tool +curl -sS https://git.arka-ai.ru/arka/public/raw/branch/main/catalog.json | python3 -m json.tool +``` + +## Change default model + +Edit `opencode.json` → `"model": "Arka-AI/"`. Use the IDs exactly as +returned by the catalog (e.g. `Arka-AI/DeepSeek-V4-Pro`, +`Arka-AI/qwen3.8-max`, `Arka-AI/kimi-k3`). + +## Adjust filtering + +Edit `NON_CHAT` (regex) or `FORCED_MODELS` in the plugin file **and** the same +lists in `scripts/sync-catalog.py` in `arka/public`, then re-run the installer +and restart. `FORCED_MODELS` keeps must-have chat models even if the heuristic +would drop them. +SKILL + +# --- API key ----------------------------------------------------------- + +if [ -z "$KEY" ]; then + read -rsp "ARKA_API_KEY (leave blank to skip): " KEY || true + echo +fi + +if [ -n "$KEY" ]; then + RC="" + for f in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.profile"; do + if [ -f "$f" ]; then RC="$f"; break; fi + done + RC="${RC:-$HOME/.bashrc}" + touch "$RC" + if grep -q 'ARKA_API_KEY' "$RC"; then + echo "==> ARKA_API_KEY already present in $RC (skipped)" + else + printf '\n# opencode Arka-AI provider\nexport ARKA_API_KEY="%s"\n' "$KEY" >> "$RC" + echo "==> wrote ARKA_API_KEY to $RC" + fi + export ARKA_API_KEY="$KEY" +else + echo "==> warning: no ARKA_API_KEY set; export it in your shell before starting opencode" >&2 +fi + +# --- default model (optional) ------------------------------------------ + +if [ -n "$MODEL" ]; then + CONF="$CONF_DIR/opencode.json" + echo "==> setting default model Arka-AI/$MODEL in $CONF" + python3 - "$CONF" "Arka-AI/$MODEL" <<'PY' +import json, os, sys + +path, model = sys.argv[1], sys.argv[2] +data = {} +if os.path.exists(path): + with open(path) as f: + data = json.load(f) +data.setdefault("$schema", "https://opencode.ai/config.json") +data["model"] = model +with open(path, "w") as f: + json.dump(data, f, indent=2) + f.write("\n") +PY +fi + +echo +echo "Done." +echo " - provider plugin: $PLUGIN_DIR/arka-ai.ts" +echo " - skill: $SKILL_DIR/SKILL.md" +echo +echo "Next steps:" +echo " 1. Restart opencode (open a new shell so ARKA_API_KEY is exported)." +echo " 2. Run /models — the Arka-AI catalog (~116 chat models) loads on first start." +echo " 3. Optional: refresh catalog later with: rm -f ~/.cache/opencode/arka-ai-models.json" diff --git a/plugins/arka-ai.ts b/plugins/arka-ai.ts new file mode 100644 index 0000000..a51d424 --- /dev/null +++ b/plugins/arka-ai.ts @@ -0,0 +1,136 @@ +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/catalog.json" +const CACHE_FILE = join(homedir(), ".cache", "opencode", "arka-ai-models.json") +const TTL_MS = 24 * 60 * 60 * 1000 + +// 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(join(homedir(), ".cache", "opencode"), { 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 = process.env.ARKA_API_KEY + 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() + + 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. + cfg.provider[PROVIDER_ID] = { + name: "Arka-AI", + api: API_URL, + env: ["ARKA_API_KEY"], + ...prev, + models: { ...(prev.models ?? {}), ...(models ?? {}) }, + } + }, + } +} diff --git a/scripts/cron-sync.sh b/scripts/cron-sync.sh new file mode 100755 index 0000000..5738512 --- /dev/null +++ b/scripts/cron-sync.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Daily catalog refresh for arka/public. Runs on core at 05:00 Europe/Moscow. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +export GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -i /root/.ssh/arka_public_catalog -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new}" + +git fetch origin main +git checkout -q main +git reset --hard origin/main + +python3 scripts/sync-catalog.py + +if git diff --quiet -- catalog.json; then + printf '%s no catalog change\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + exit 0 +fi + +git add catalog.json +git -c user.name='arka-catalog-bot' -c user.email='noreply@arka-ai.ru' \ + commit -m "chore(catalog): refresh $(date -u +%Y-%m-%d)" +git push origin main +printf '%s catalog pushed\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" diff --git a/scripts/sync-catalog.py b/scripts/sync-catalog.py new file mode 100755 index 0000000..14eb301 --- /dev/null +++ b/scripts/sync-catalog.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Rebuild catalog.json from the public arka-ai.ru OpenAI-compatible models list. + +Keep FORCED_MODELS / NON_CHAT in lockstep with plugins/arka-ai.ts. +""" +from __future__ import annotations + +import json +import re +import sys +import urllib.request +from datetime import datetime, timezone +from pathlib import Path + +API_URL = "https://api.arka-ai.ru/v1/models" +ROOT = Path(__file__).resolve().parents[1] +OUT = ROOT / "catalog.json" + +FORCED_MODELS = [ + "DeepSeek-V4-Pro", + "deepseek-v4-flash-0731", + "qwen3.8-max", + "kimi-k3", + "MiniMax-M3", + "GLM-5.3", +] + +NON_CHAT = re.compile( + "|".join( + [ + "tts", + "asr", + "audio", + "speech", + "voice", + "whisper", + "sensevoice", + "image", + r"(^|[.-])img", + "wan", + "vidu", + "video", + "sora", + "flux", + "diffus", + "kolors", + "cogview", + "ocr", + "embedding", + r"(^|[.-])embed", + "rerank", + "clip", + "moderation", + r"(^|[.-])filter", + "detect", + "classif", + r"(^|[.-])location", + r"(^|[.-])search", + "comfyui", + "meme", + "hello", + r"(^|[.-])sam", + "florence", + "esrgan", + "rmbg", + "background", + "poster", + r"(^|[.-])face", + r"(^|[.-])lip", + "translate", + r"(^|[.-])mt([2-]|$)", + r"(^|[.-])bge", + r"(^|[.-])e5", + r"(^|[.-])jina", + r"(^|[.-])mpnet", + "spark-tts", + "cosyvoice", + "chattts", + "indextts", + "step-audio", + "fish-speech", + "cosy", + "mate-gguf", + "tokenizer", + ] + ), + re.I, +) + + +def fetch_models() -> list[dict]: + req = urllib.request.Request(API_URL, headers={"User-Agent": "arka-public-catalog-sync"}) + with urllib.request.urlopen(req, timeout=30) as resp: + payload = json.load(resp) + data = payload.get("data") or [] + if not isinstance(data, list) or not data: + raise SystemExit("empty models list from api.arka-ai.ru") + return data + + +def keep(item: dict) -> bool: + mid = item.get("id") or "" + return mid in FORCED_MODELS or not NON_CHAT.search(mid) + + +def main() -> int: + raw = fetch_models() + chat = [item for item in raw if keep(item)] + chat.sort(key=lambda item: item.get("id") or "") + missing = [mid for mid in FORCED_MODELS if not any(item.get("id") == mid for item in chat)] + if missing: + print("warning: forced models missing from catalog:", ", ".join(missing), file=sys.stderr) + + out = { + "updated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "source": API_URL, + "raw_count": len(raw), + "chat_count": len(chat), + "object": "list", + "data": [{"id": item.get("id"), "object": "model"} for item in chat], + } + text = json.dumps(out, ensure_ascii=False, indent=2) + "\n" + OUT.write_text(text, encoding="utf-8") + print(f"wrote {OUT} raw={out['raw_count']} chat={out['chat_count']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/arka-provider/SKILL.md b/skills/arka-provider/SKILL.md new file mode 100644 index 0000000..09410a1 --- /dev/null +++ b/skills/arka-provider/SKILL.md @@ -0,0 +1,57 @@ +--- +name: arka-provider +description: Manage the custom Arka-AI (arka-ai.ru) LLM provider in opencode config. Use when the user needs to add, refresh, or fix the arka-ai.ru provider, sync its model catalog, change the Arka-AI default model, or when arka models are missing from /models. +--- + +# Arka-AI provider + +arka-ai.ru is **not** in models.dev, so opencode ships no built-in config for it. +It is wired up locally with a plugin + a static `provider` block in +`~/.config/opencode/opencode.json`. + +## How it works + +- Plugin: `~/.config/opencode/plugins/arka-ai.ts` + - On startup it loads the published chat catalog + `https://git.arka-ai.ru/arka/public/raw/branch/main/catalog.json` + (no API key; refreshed daily at 05:00 Europe/Moscow). + - If that fetch fails, it falls back to `https://api.arka-ai.ru/v1/models` + and filters out non-chat models (TTS/ASR/image/video/OCR/embedding/reranker/…) + via a heuristic regex so the `/models` picker stays clean. + - Caches the result at `~/.cache/opencode/arka-ai-models.json` (TTL 24h). + - Injects the provider into config via the `config` hook, layered over the + static block in `opencode.json`. +- Static block (`opencode.json` → `provider["Arka-AI"]`) holds + `name`, `api`, `env`, and a small fallback `models` map that is used when the + catalog is unreachable. +- Inference still uses `ARKA_API_KEY` against `https://api.arka-ai.ru/v1`. + +## Refresh the model list + +Catalog ~216 raw models / ~116 chat; the public snapshot updates at 05:00 +Europe/Moscow. Changes land in opencode only after cache expiry + restart +(config is not hot-reloaded). + +1. `rm -f ~/.cache/opencode/arka-ai-models.json` +2. Restart opencode (or run `opencode` again) — the plugin re-fetches on the + next cold start. + +Optionally inspect the live API (no key required for listing): + +```bash +curl -sS https://api.arka-ai.ru/v1/models | python3 -m json.tool +curl -sS https://git.arka-ai.ru/arka/public/raw/branch/main/catalog.json | python3 -m json.tool +``` + +## Change default model + +Edit `opencode.json` → `"model": "Arka-AI/"`. Use the IDs exactly as +returned by the catalog (e.g. `Arka-AI/DeepSeek-V4-Pro`, +`Arka-AI/qwen3.8-max`, `Arka-AI/kimi-k3`). + +## Adjust filtering + +Edit `NON_CHAT` (regex) or `FORCED_MODELS` in the plugin file **and** the same +lists in `scripts/sync-catalog.py` in `arka/public`, then re-run the installer +and restart. `FORCED_MODELS` keeps must-have chat models even if the heuristic +would drop them.