arka/public is the dump for all Arka downloads. Keep a root stub so the old installer URL still works. Document in-OpenCode bash install.
137 lines
4.1 KiB
TypeScript
137 lines
4.1 KiB
TypeScript
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 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<string, { name: string }>
|
|
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<string, string>,
|
|
timeoutMs: number,
|
|
): Promise<unknown> {
|
|
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<ModelMap | null> {
|
|
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 ?? {}) },
|
|
}
|
|
},
|
|
}
|
|
}
|