Auto-discover OpenCode Zen and Go models, add catalog search and pagination
Add model fetchers for OpenCode Zen (https://opencode.ai/zen/v1/models) and Go (https://opencode.ai/zen/go/v1/models) APIs. Register opencode-go as a new provider, load shared credentials from auth.json, add known models with pricing, and create default agents for both tiers on first boot. Replace the manual "Add Model" form with a search bar that filters by model name/ID and paginate the catalog at 25 models per page.
This commit is contained in:
@@ -730,6 +730,7 @@ const PROVIDER_BADGE: Record<string, { short: string; bg: string; color: string;
|
|||||||
openrouter: { short: "OR", bg: tokens.color.infoDim, color: tokens.color.info, border: tokens.color.infoDim },
|
openrouter: { short: "OR", bg: tokens.color.infoDim, color: tokens.color.info, border: tokens.color.infoDim },
|
||||||
google: { short: "GG", bg: tokens.color.infoDim, color: tokens.color.info, border: tokens.color.infoDim },
|
google: { short: "GG", bg: tokens.color.infoDim, color: tokens.color.info, border: tokens.color.infoDim },
|
||||||
"opencode-zen": { short: "OZ", bg: tokens.color.purpleDim, color: tokens.color.purple, border: tokens.color.purpleDim },
|
"opencode-zen": { short: "OZ", bg: tokens.color.purpleDim, color: tokens.color.purple, border: tokens.color.purpleDim },
|
||||||
|
"opencode-go": { short: "OG", bg: tokens.color.passDim, color: tokens.color.pass, border: tokens.color.passDim },
|
||||||
};
|
};
|
||||||
|
|
||||||
function ProviderBadge({ provider }: { provider: string }) {
|
function ProviderBadge({ provider }: { provider: string }) {
|
||||||
@@ -1153,13 +1154,8 @@ function ModelsTab({ mobile }: { mobile: boolean }) {
|
|||||||
const [runtimes, setRuntimes] = useState<AgentRuntimeDisplay[]>([]);
|
const [runtimes, setRuntimes] = useState<AgentRuntimeDisplay[]>([]);
|
||||||
const [view, setView] = useState<"agents" | "catalog" | "usage">("agents");
|
const [view, setView] = useState<"agents" | "catalog" | "usage">("agents");
|
||||||
const [providerFilter, setProviderFilter] = useState("ALL");
|
const [providerFilter, setProviderFilter] = useState("ALL");
|
||||||
const [addingModel, setAddingModel] = useState(false);
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [newModelId, setNewModelId] = useState("");
|
const [catalogPage, setCatalogPage] = useState(1);
|
||||||
const [newModelName, setNewModelName] = useState("");
|
|
||||||
const [newModelProvider, setNewModelProvider] = useState("anthropic");
|
|
||||||
const [newModelCtx, setNewModelCtx] = useState("");
|
|
||||||
const [newModelCostIn, setNewModelCostIn] = useState("");
|
|
||||||
const [newModelCostOut, setNewModelCostOut] = useState("");
|
|
||||||
// Agent creation state
|
// Agent creation state
|
||||||
const [addingAgent, setAddingAgent] = useState(false);
|
const [addingAgent, setAddingAgent] = useState(false);
|
||||||
const [newAgentName, setNewAgentName] = useState("");
|
const [newAgentName, setNewAgentName] = useState("");
|
||||||
@@ -1196,27 +1192,19 @@ function ModelsTab({ mobile }: { mobile: boolean }) {
|
|||||||
loadModels();
|
loadModels();
|
||||||
};
|
};
|
||||||
|
|
||||||
const addModel = async () => {
|
|
||||||
if (!newModelId.trim() || !newModelProvider) return;
|
|
||||||
await fetch("/api/models/curated", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
id: newModelId.trim(),
|
|
||||||
name: newModelName.trim() || newModelId.trim(),
|
|
||||||
provider: newModelProvider,
|
|
||||||
contextWindow: newModelCtx ? parseInt(newModelCtx) : undefined,
|
|
||||||
costPer1kInput: newModelCostIn ? parseFloat(newModelCostIn) : undefined,
|
|
||||||
costPer1kOutput: newModelCostOut ? parseFloat(newModelCostOut) : undefined,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
setNewModelId(""); setNewModelName(""); setNewModelCtx("");
|
|
||||||
setNewModelCostIn(""); setNewModelCostOut(""); setAddingModel(false);
|
|
||||||
loadModels();
|
|
||||||
};
|
|
||||||
|
|
||||||
const providers = ["ALL", ...Array.from(new Set(models.map(m => m.provider)))];
|
const providers = ["ALL", ...Array.from(new Set(models.map(m => m.provider)))];
|
||||||
const filtered = providerFilter === "ALL" ? models : models.filter(m => m.provider === providerFilter);
|
const CATALOG_PAGE_SIZE = 25;
|
||||||
|
const filtered = models.filter(m => {
|
||||||
|
if (providerFilter !== "ALL" && m.provider !== providerFilter) return false;
|
||||||
|
if (searchQuery) {
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
return m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filtered.length / CATALOG_PAGE_SIZE));
|
||||||
|
const clampedPage = Math.min(catalogPage, totalPages);
|
||||||
|
const paginatedModels = filtered.slice((clampedPage - 1) * CATALOG_PAGE_SIZE, clampedPage * CATALOG_PAGE_SIZE);
|
||||||
|
|
||||||
// Aggregate usage by provider
|
// Aggregate usage by provider
|
||||||
const providerTotals = usage.summary.reduce((acc, s) => {
|
const providerTotals = usage.summary.reduce((acc, s) => {
|
||||||
@@ -1263,12 +1251,6 @@ function ModelsTab({ mobile }: { mobile: boolean }) {
|
|||||||
{addingAgent ? "CANCEL" : "+ NEW AGENT"}
|
{addingAgent ? "CANCEL" : "+ NEW AGENT"}
|
||||||
</Btn>
|
</Btn>
|
||||||
)}
|
)}
|
||||||
{view === "catalog" && (
|
|
||||||
<Btn variant="primary" onClick={() => setAddingModel(!addingModel)}
|
|
||||||
style={{ fontSize: tokens.size.xs, minHeight: 32 }}>
|
|
||||||
{addingModel ? "CANCEL" : "+ ADD MODEL"}
|
|
||||||
</Btn>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ─── AGENTS VIEW ─── */}
|
{/* ─── AGENTS VIEW ─── */}
|
||||||
@@ -1321,7 +1303,7 @@ function ModelsTab({ mobile }: { mobile: boolean }) {
|
|||||||
<div style={{ display: "flex", flexDirection: "column", gap: tokens.space[1] }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: tokens.space[1] }}>
|
||||||
<Label color={tokens.color.text3}>Provider</Label>
|
<Label color={tokens.color.text3}>Provider</Label>
|
||||||
<div style={{ display: "flex", gap: tokens.space[1] }}>
|
<div style={{ display: "flex", gap: tokens.space[1] }}>
|
||||||
{["anthropic", "openai", "openrouter", "google", "opencode-zen"].map(p => (
|
{["anthropic", "openai", "openrouter", "google", "opencode-zen", "opencode-go"].map(p => (
|
||||||
<button key={p} onClick={() => setNewAgentProvider(p)}
|
<button key={p} onClick={() => setNewAgentProvider(p)}
|
||||||
style={{
|
style={{
|
||||||
background: newAgentProvider === p ? tokens.color.bg3 : "transparent",
|
background: newAgentProvider === p ? tokens.color.bg3 : "transparent",
|
||||||
@@ -1425,64 +1407,17 @@ function ModelsTab({ mobile }: { mobile: boolean }) {
|
|||||||
{/* ─── CATALOG VIEW ─── */}
|
{/* ─── CATALOG VIEW ─── */}
|
||||||
{view === "catalog" && (
|
{view === "catalog" && (
|
||||||
<>
|
<>
|
||||||
{/* Add model form */}
|
{/* Search + provider filter */}
|
||||||
{addingModel && (
|
|
||||||
<Panel style={{ padding: tokens.space[3] }}>
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: tokens.space[2] }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: tokens.space[2] }}>
|
||||||
<Label color={tokens.color.accent}>ADD MODEL TO CATALOG</Label>
|
<Input
|
||||||
<div style={{ display: "flex", flexDirection: mobile ? "column" : "row", gap: tokens.space[2] }}>
|
value={searchQuery}
|
||||||
<div style={{ flex: 2, display: "flex", flexDirection: "column", gap: tokens.space[1] }}>
|
onChange={e => { setSearchQuery(e.target.value); setCatalogPage(1); }}
|
||||||
<Label color={tokens.color.text3}>Model ID</Label>
|
placeholder="Search models by name or ID..."
|
||||||
<Input value={newModelId} onChange={e => setNewModelId(e.target.value)} placeholder="claude-sonnet-4-20250514" />
|
style={{ maxWidth: 400 }}
|
||||||
</div>
|
/>
|
||||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: tokens.space[1] }}>
|
<div style={{ display: "flex", gap: tokens.space[1], flexWrap: "wrap", alignItems: "center" }}>
|
||||||
<Label color={tokens.color.text3}>Display Name</Label>
|
|
||||||
<Input value={newModelName} onChange={e => setNewModelName(e.target.value)} placeholder="Claude Sonnet 4" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", flexDirection: mobile ? "column" : "row", gap: tokens.space[2] }}>
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: tokens.space[1] }}>
|
|
||||||
<Label color={tokens.color.text3}>Provider</Label>
|
|
||||||
<div style={{ display: "flex", gap: tokens.space[1], flexWrap: "wrap" }}>
|
|
||||||
{["anthropic", "openai", "openrouter", "google", "opencode-zen"].map(p => (
|
|
||||||
<button key={p} onClick={() => setNewModelProvider(p)}
|
|
||||||
style={{
|
|
||||||
background: newModelProvider === p ? tokens.color.bg3 : "transparent",
|
|
||||||
border: `1px solid ${newModelProvider === p ? tokens.color.border1 : tokens.color.border0}`,
|
|
||||||
color: newModelProvider === p ? tokens.color.text0 : tokens.color.text2,
|
|
||||||
fontFamily: tokens.font.mono, fontSize: tokens.size.xs,
|
|
||||||
padding: `2px ${tokens.space[2]}px`, cursor: "pointer", borderRadius: 0,
|
|
||||||
}}>
|
|
||||||
{p.toUpperCase()}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: tokens.space[1] }}>
|
|
||||||
<Label color={tokens.color.text3}>Context Window</Label>
|
|
||||||
<Input value={newModelCtx} onChange={e => setNewModelCtx(e.target.value)} placeholder="200000" />
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: tokens.space[1] }}>
|
|
||||||
<Label color={tokens.color.text3}>$/1k Input</Label>
|
|
||||||
<Input value={newModelCostIn} onChange={e => setNewModelCostIn(e.target.value)} placeholder="0.003" />
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: tokens.space[1] }}>
|
|
||||||
<Label color={tokens.color.text3}>$/1k Output</Label>
|
|
||||||
<Input value={newModelCostOut} onChange={e => setNewModelCostOut(e.target.value)} placeholder="0.015" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Btn variant="primary" onClick={addModel} disabled={!newModelId.trim()}
|
|
||||||
style={{ alignSelf: "flex-start" }}>
|
|
||||||
ADD TO CATALOG
|
|
||||||
</Btn>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Provider filter */}
|
|
||||||
<div style={{ display: "flex", gap: tokens.space[1], flexWrap: "wrap" }}>
|
|
||||||
{providers.map(p => (
|
{providers.map(p => (
|
||||||
<button key={p} onClick={() => setProviderFilter(p)}
|
<button key={p} onClick={() => { setProviderFilter(p); setCatalogPage(1); }}
|
||||||
style={{
|
style={{
|
||||||
background: providerFilter === p ? tokens.color.bg3 : "transparent",
|
background: providerFilter === p ? tokens.color.bg3 : "transparent",
|
||||||
border: `1px solid ${providerFilter === p ? tokens.color.border1 : tokens.color.border0}`,
|
border: `1px solid ${providerFilter === p ? tokens.color.border1 : tokens.color.border0}`,
|
||||||
@@ -1493,11 +1428,15 @@ function ModelsTab({ mobile }: { mobile: boolean }) {
|
|||||||
{p.toUpperCase()}
|
{p.toUpperCase()}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
<Mono size={tokens.size.xs} color={tokens.color.text3} style={{ marginLeft: tokens.space[2] }}>
|
||||||
|
{filtered.length} model{filtered.length !== 1 ? "s" : ""}
|
||||||
|
</Mono>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Model list */}
|
{/* Model list */}
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: tokens.space[2] }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: tokens.space[2] }}>
|
||||||
{filtered.map(m => (
|
{paginatedModels.map(m => (
|
||||||
<Panel key={m.id} style={{
|
<Panel key={m.id} style={{
|
||||||
borderLeft: `3px solid ${m.enabled ? tokens.color.pass : tokens.color.border0}`,
|
borderLeft: `3px solid ${m.enabled ? tokens.color.pass : tokens.color.border0}`,
|
||||||
opacity: m.enabled ? 1 : 0.6,
|
opacity: m.enabled ? 1 : 0.6,
|
||||||
@@ -1551,6 +1490,25 @@ function ModelsTab({ mobile }: { mobile: boolean }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: tokens.space[3] }}>
|
||||||
|
<Btn variant="default" onClick={() => setCatalogPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={clampedPage <= 1}
|
||||||
|
style={{ fontSize: tokens.size.xs, minHeight: 32 }}>
|
||||||
|
PREV
|
||||||
|
</Btn>
|
||||||
|
<Mono size={tokens.size.sm} color={tokens.color.text2}>
|
||||||
|
{clampedPage} / {totalPages}
|
||||||
|
</Mono>
|
||||||
|
<Btn variant="default" onClick={() => setCatalogPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={clampedPage >= totalPages}
|
||||||
|
style={{ fontSize: tokens.size.xs, minHeight: 32 }}>
|
||||||
|
NEXT
|
||||||
|
</Btn>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ export const AGENT_RUNTIMES: Record<AgentRuntime, AgentRuntimeInfo> = {
|
|||||||
"opencode": {
|
"opencode": {
|
||||||
id: "opencode",
|
id: "opencode",
|
||||||
name: "OpenCode",
|
name: "OpenCode",
|
||||||
description: "Open-source multi-provider coding agent. Supports Anthropic, OpenAI, Google, OpenRouter.",
|
description: "Open-source multi-provider coding agent. Supports Anthropic, OpenAI, Google, OpenRouter, OpenCode Zen/Go.",
|
||||||
defaultProviders: ["anthropic", "openai", "google", "openrouter", "opencode-zen"],
|
defaultProviders: ["anthropic", "openai", "google", "openrouter", "opencode-zen", "opencode-go"],
|
||||||
cliCommand: "opencode",
|
cliCommand: "opencode",
|
||||||
headlessFlag: "run",
|
headlessFlag: "run",
|
||||||
modelFlag: "--model",
|
modelFlag: "--model",
|
||||||
|
|||||||
@@ -7,17 +7,33 @@ import { upsertCuratedModel, getCuratedModels } from "./model-store";
|
|||||||
import { upsertAgentConfig, getAllAgentConfigs, type AgentRuntime } from "./agents";
|
import { upsertAgentConfig, getAllAgentConfigs, type AgentRuntime } from "./agents";
|
||||||
import { fetchAllModels } from "./model-providers";
|
import { fetchAllModels } from "./model-providers";
|
||||||
|
|
||||||
// Well-known models with pricing
|
// Well-known models with pricing (per 1k tokens)
|
||||||
const KNOWN_MODELS: Record<string, { name: string; provider: string; contextWindow: number; costPer1kInput: number; costPer1kOutput: number }> = {
|
const KNOWN_MODELS: Record<string, { name: string; provider: string; contextWindow: number; costPer1kInput: number; costPer1kOutput: number }> = {
|
||||||
|
// Anthropic (direct)
|
||||||
"claude-opus-4-20250514": { name: "Claude Opus 4", provider: "anthropic", contextWindow: 200000, costPer1kInput: 0.015, costPer1kOutput: 0.075 },
|
"claude-opus-4-20250514": { name: "Claude Opus 4", provider: "anthropic", contextWindow: 200000, costPer1kInput: 0.015, costPer1kOutput: 0.075 },
|
||||||
"claude-sonnet-4-20250514": { name: "Claude Sonnet 4", provider: "anthropic", contextWindow: 200000, costPer1kInput: 0.003, costPer1kOutput: 0.015 },
|
"claude-sonnet-4-20250514": { name: "Claude Sonnet 4", provider: "anthropic", contextWindow: 200000, costPer1kInput: 0.003, costPer1kOutput: 0.015 },
|
||||||
"claude-haiku-4-20250514": { name: "Claude Haiku 4", provider: "anthropic", contextWindow: 200000, costPer1kInput: 0.0008, costPer1kOutput: 0.004 },
|
"claude-haiku-4-20250514": { name: "Claude Haiku 4", provider: "anthropic", contextWindow: 200000, costPer1kInput: 0.0008, costPer1kOutput: 0.004 },
|
||||||
|
// OpenAI (direct)
|
||||||
"gpt-4o": { name: "GPT-4o", provider: "openai", contextWindow: 128000, costPer1kInput: 0.0025, costPer1kOutput: 0.01 },
|
"gpt-4o": { name: "GPT-4o", provider: "openai", contextWindow: 128000, costPer1kInput: 0.0025, costPer1kOutput: 0.01 },
|
||||||
"gpt-4o-mini": { name: "GPT-4o Mini", provider: "openai", contextWindow: 128000, costPer1kInput: 0.00015, costPer1kOutput: 0.0006 },
|
"gpt-4o-mini": { name: "GPT-4o Mini", provider: "openai", contextWindow: 128000, costPer1kInput: 0.00015, costPer1kOutput: 0.0006 },
|
||||||
"o3": { name: "o3", provider: "openai", contextWindow: 200000, costPer1kInput: 0.01, costPer1kOutput: 0.04 },
|
"o3": { name: "o3", provider: "openai", contextWindow: 200000, costPer1kInput: 0.01, costPer1kOutput: 0.04 },
|
||||||
"o4-mini": { name: "o4 Mini", provider: "openai", contextWindow: 200000, costPer1kInput: 0.0011, costPer1kOutput: 0.0044 },
|
"o4-mini": { name: "o4 Mini", provider: "openai", contextWindow: 200000, costPer1kInput: 0.0011, costPer1kOutput: 0.0044 },
|
||||||
|
// Google (direct)
|
||||||
"gemini-2.5-pro": { name: "Gemini 2.5 Pro", provider: "google", contextWindow: 1048576, costPer1kInput: 0.00125, costPer1kOutput: 0.01 },
|
"gemini-2.5-pro": { name: "Gemini 2.5 Pro", provider: "google", contextWindow: 1048576, costPer1kInput: 0.00125, costPer1kOutput: 0.01 },
|
||||||
"gemini-2.5-flash": { name: "Gemini 2.5 Flash", provider: "google", contextWindow: 1048576, costPer1kInput: 0.00015, costPer1kOutput: 0.0006 },
|
"gemini-2.5-flash": { name: "Gemini 2.5 Flash", provider: "google", contextWindow: 1048576, costPer1kInput: 0.00015, costPer1kOutput: 0.0006 },
|
||||||
|
// OpenCode Zen (pricing per 1M from docs, converted to per 1k)
|
||||||
|
"claude-opus-4.6": { name: "Claude Opus 4.6", provider: "opencode-zen", contextWindow: 200000, costPer1kInput: 0.005, costPer1kOutput: 0.025 },
|
||||||
|
"claude-sonnet-4.6": { name: "Claude Sonnet 4.6", provider: "opencode-zen", contextWindow: 200000, costPer1kInput: 0.003, costPer1kOutput: 0.015 },
|
||||||
|
"claude-haiku-4.5": { name: "Claude Haiku 4.5", provider: "opencode-zen", contextWindow: 200000, costPer1kInput: 0.0008, costPer1kOutput: 0.004 },
|
||||||
|
"gpt-5.4": { name: "GPT-5.4", provider: "opencode-zen", contextWindow: 200000, costPer1kInput: 0.0025, costPer1kOutput: 0.015 },
|
||||||
|
"gpt-5.3-codex": { name: "GPT-5.3 Codex", provider: "opencode-zen", contextWindow: 200000, costPer1kInput: 0.002, costPer1kOutput: 0.008 },
|
||||||
|
"gemini-3.1-pro": { name: "Gemini 3.1 Pro", provider: "opencode-zen", contextWindow: 1048576, costPer1kInput: 0.00125, costPer1kOutput: 0.005 },
|
||||||
|
"gemini-3-flash": { name: "Gemini 3 Flash", provider: "opencode-zen", contextWindow: 1048576, costPer1kInput: 0.0005, costPer1kOutput: 0.003 },
|
||||||
|
// OpenCode Go (fixed subscription, no per-token pricing)
|
||||||
|
"glm-5": { name: "GLM-5", provider: "opencode-go", contextWindow: 128000, costPer1kInput: 0, costPer1kOutput: 0 },
|
||||||
|
"kimi-k2.5": { name: "Kimi K2.5", provider: "opencode-go", contextWindow: 128000, costPer1kInput: 0, costPer1kOutput: 0 },
|
||||||
|
"minimax-m2.7": { name: "MiniMax M2.7", provider: "opencode-go", contextWindow: 128000, costPer1kInput: 0, costPer1kOutput: 0 },
|
||||||
|
"minimax-m2.5": { name: "MiniMax M2.5", provider: "opencode-go", contextWindow: 128000, costPer1kInput: 0, costPer1kOutput: 0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Default agents to create per provider when credentials are available.
|
// Default agents to create per provider when credentials are available.
|
||||||
@@ -35,6 +51,12 @@ const DEFAULT_AGENTS: Record<string, { runtime: AgentRuntime; models: string[] }
|
|||||||
openrouter: [
|
openrouter: [
|
||||||
{ runtime: "opencode", models: ["claude-sonnet-4-20250514"] },
|
{ runtime: "opencode", models: ["claude-sonnet-4-20250514"] },
|
||||||
],
|
],
|
||||||
|
"opencode-zen": [
|
||||||
|
{ runtime: "opencode", models: ["claude-sonnet-4.6", "gpt-5.3-codex"] },
|
||||||
|
],
|
||||||
|
"opencode-go": [
|
||||||
|
{ runtime: "opencode", models: ["kimi-k2.5", "glm-5"] },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const RUNTIME_LABELS: Record<AgentRuntime, string> = {
|
const RUNTIME_LABELS: Record<AgentRuntime, string> = {
|
||||||
@@ -55,6 +77,7 @@ async function loadCredentialsFromEnv() {
|
|||||||
["GOOGLE_API_KEY", "google", "Google (env)"],
|
["GOOGLE_API_KEY", "google", "Google (env)"],
|
||||||
["OPENROUTER_API_KEY", "openrouter", "OpenRouter (env)"],
|
["OPENROUTER_API_KEY", "openrouter", "OpenRouter (env)"],
|
||||||
["OPENCODE_ZEN_API_KEY", "opencode-zen", "OpenCode Zen (env)"],
|
["OPENCODE_ZEN_API_KEY", "opencode-zen", "OpenCode Zen (env)"],
|
||||||
|
["OPENCODE_GO_API_KEY", "opencode-go", "OpenCode Go (env)"],
|
||||||
["GITHUB_TOKEN", "github", "GitHub (env)"],
|
["GITHUB_TOKEN", "github", "GitHub (env)"],
|
||||||
["GH_TOKEN", "github", "GitHub (env)"],
|
["GH_TOKEN", "github", "GitHub (env)"],
|
||||||
["GITLAB_TOKEN", "gitlab", "GitLab (env)"],
|
["GITLAB_TOKEN", "gitlab", "GitLab (env)"],
|
||||||
@@ -100,19 +123,20 @@ async function loadOpenCodeCredentials() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const raw = JSON.parse(readFileSync(authPath, "utf-8"));
|
const raw = JSON.parse(readFileSync(authPath, "utf-8"));
|
||||||
const providerMap: Record<string, Provider> = {
|
const providerMap: Record<string, Provider[]> = {
|
||||||
anthropic: "anthropic",
|
anthropic: ["anthropic"],
|
||||||
openai: "openai",
|
openai: ["openai"],
|
||||||
google: "google",
|
google: ["google"],
|
||||||
openrouter: "openrouter",
|
openrouter: ["openrouter"],
|
||||||
opencode: "opencode-zen",
|
opencode: ["opencode-zen", "opencode-go"], // Zen and Go share the same key
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const [key, entry] of Object.entries(raw)) {
|
for (const [key, entry] of Object.entries(raw)) {
|
||||||
const provider = providerMap[key];
|
const providers = providerMap[key];
|
||||||
if (!provider || typeof entry !== "object" || !entry) continue;
|
if (!providers || typeof entry !== "object" || !entry) continue;
|
||||||
const token = (entry as Record<string, unknown>).key;
|
const token = (entry as Record<string, unknown>).key;
|
||||||
if (typeof token !== "string" || !token) continue;
|
if (typeof token !== "string" || !token) continue;
|
||||||
|
for (const provider of providers) {
|
||||||
const existing = await getRawCredentialsByProvider(provider);
|
const existing = await getRawCredentialsByProvider(provider);
|
||||||
if (existing.length > 0) continue;
|
if (existing.length > 0) continue;
|
||||||
await upsertCredential({
|
await upsertCredential({
|
||||||
@@ -122,6 +146,7 @@ async function loadOpenCodeCredentials() {
|
|||||||
token,
|
token,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore parse errors
|
// ignore parse errors
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { credentials as credentialsTable } from "@homelab/db";
|
|||||||
|
|
||||||
export type Provider =
|
export type Provider =
|
||||||
| "github" | "gitlab"
|
| "github" | "gitlab"
|
||||||
| "anthropic" | "openai" | "openrouter" | "google" | "opencode-zen";
|
| "anthropic" | "openai" | "openrouter" | "google" | "opencode-zen" | "opencode-go";
|
||||||
|
|
||||||
export const GIT_PROVIDERS: Provider[] = ["github", "gitlab"];
|
export const GIT_PROVIDERS: Provider[] = ["github", "gitlab"];
|
||||||
export const AI_PROVIDERS: Provider[] = ["anthropic", "openai", "openrouter", "google", "opencode-zen"];
|
export const AI_PROVIDERS: Provider[] = ["anthropic", "openai", "openrouter", "google", "opencode-zen", "opencode-go"];
|
||||||
|
|
||||||
export interface Credential {
|
export interface Credential {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export async function fetchAllModels(): Promise<ModelInfo[]> {
|
|||||||
fetchOpenAIModels(),
|
fetchOpenAIModels(),
|
||||||
fetchOpenRouterModels(),
|
fetchOpenRouterModels(),
|
||||||
fetchGoogleModels(),
|
fetchGoogleModels(),
|
||||||
|
fetchOpenCodeZenModels(),
|
||||||
|
fetchOpenCodeGoModels(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return results.flatMap(r => r.status === "fulfilled" ? r.value : []);
|
return results.flatMap(r => r.status === "fulfilled" ? r.value : []);
|
||||||
@@ -133,3 +135,67 @@ async function fetchGoogleModels(): Promise<ModelInfo[]> {
|
|||||||
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchOpenCodeZenModels(): Promise<ModelInfo[]> {
|
||||||
|
const creds = await getRawCredentialsByProvider("opencode-zen");
|
||||||
|
if (creds.length === 0) return [];
|
||||||
|
|
||||||
|
for (const cred of creds) {
|
||||||
|
try {
|
||||||
|
const res = await fetch("https://opencode.ai/zen/v1/models", {
|
||||||
|
headers: { Authorization: `Bearer ${cred.token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) continue;
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
return (data.data || data.models || []).map((m: { id: string; name?: string; context_length?: number }) => ({
|
||||||
|
id: m.id,
|
||||||
|
name: m.name || m.id,
|
||||||
|
provider: "opencode-zen",
|
||||||
|
contextWindow: m.context_length,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOpenCodeGoModels(): Promise<ModelInfo[]> {
|
||||||
|
// OpenCode Go shares the same API key as Zen — try zen creds, then go-specific creds
|
||||||
|
const creds = [
|
||||||
|
...await getRawCredentialsByProvider("opencode-go"),
|
||||||
|
...await getRawCredentialsByProvider("opencode-zen"),
|
||||||
|
];
|
||||||
|
if (creds.length === 0) return [];
|
||||||
|
|
||||||
|
for (const cred of creds) {
|
||||||
|
try {
|
||||||
|
const res = await fetch("https://opencode.ai/zen/go/v1/models", {
|
||||||
|
headers: { Authorization: `Bearer ${cred.token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) continue;
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
return (data.data || data.models || []).map((m: { id: string; name?: string; context_length?: number }) => ({
|
||||||
|
id: m.id,
|
||||||
|
name: m.name || m.id,
|
||||||
|
provider: "opencode-go",
|
||||||
|
contextWindow: m.context_length,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: hardcoded Go models if API unavailable but credentials exist
|
||||||
|
return [
|
||||||
|
{ id: "glm-5", name: "GLM-5", provider: "opencode-go" },
|
||||||
|
{ id: "kimi-k2.5", name: "Kimi K2.5", provider: "opencode-go" },
|
||||||
|
{ id: "minimax-m2.7", name: "MiniMax M2.7", provider: "opencode-go" },
|
||||||
|
{ id: "minimax-m2.5", name: "MiniMax M2.5", provider: "opencode-go" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user