Auto-discover OpenCode Zen and Go models, add catalog search and pagination
All checks were successful
CI / lint-and-test (push) Successful in 29s
Deploy Production / deploy (push) Successful in 3m40s
CI / build (push) Successful in 1m34s

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:
Julia McGhee
2026-03-21 20:24:38 +00:00
parent f0d9482bc8
commit e2b339aac8
6 changed files with 183 additions and 134 deletions

View File

@@ -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,79 +1407,36 @@ function ModelsTab({ mobile }: { mobile: boolean }) {
{/* ─── CATALOG VIEW ─── */} {/* ─── CATALOG VIEW ─── */}
{view === "catalog" && ( {view === "catalog" && (
<> <>
{/* Add model form */} {/* Search + provider filter */}
{addingModel && ( <div style={{ display: "flex", flexDirection: "column", gap: tokens.space[2] }}>
<Panel style={{ padding: tokens.space[3] }}> <Input
<div style={{ display: "flex", flexDirection: "column", gap: tokens.space[2] }}> value={searchQuery}
<Label color={tokens.color.accent}>ADD MODEL TO CATALOG</Label> onChange={e => { setSearchQuery(e.target.value); setCatalogPage(1); }}
<div style={{ display: "flex", flexDirection: mobile ? "column" : "row", gap: tokens.space[2] }}> placeholder="Search models by name or ID..."
<div style={{ flex: 2, display: "flex", flexDirection: "column", gap: tokens.space[1] }}> style={{ maxWidth: 400 }}
<Label color={tokens.color.text3}>Model ID</Label> />
<Input value={newModelId} onChange={e => setNewModelId(e.target.value)} placeholder="claude-sonnet-4-20250514" /> <div style={{ display: "flex", gap: tokens.space[1], flexWrap: "wrap", alignItems: "center" }}>
</div> {providers.map(p => (
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: tokens.space[1] }}> <button key={p} onClick={() => { setProviderFilter(p); setCatalogPage(1); }}
<Label color={tokens.color.text3}>Display Name</Label> style={{
<Input value={newModelName} onChange={e => setNewModelName(e.target.value)} placeholder="Claude Sonnet 4" /> background: providerFilter === p ? tokens.color.bg3 : "transparent",
</div> border: `1px solid ${providerFilter === p ? tokens.color.border1 : tokens.color.border0}`,
</div> color: providerFilter === p ? tokens.color.text0 : tokens.color.text2,
<div style={{ display: "flex", flexDirection: mobile ? "column" : "row", gap: tokens.space[2] }}> fontFamily: tokens.font.mono, fontSize: tokens.size.xs, letterSpacing: tokens.tracking.wide,
<div style={{ display: "flex", flexDirection: "column", gap: tokens.space[1] }}> padding: `2px ${tokens.space[2]}px`, cursor: "pointer", borderRadius: 0,
<Label color={tokens.color.text3}>Provider</Label> }}>
<div style={{ display: "flex", gap: tokens.space[1], flexWrap: "wrap" }}> {p.toUpperCase()}
{["anthropic", "openai", "openrouter", "google", "opencode-zen"].map(p => ( </button>
<button key={p} onClick={() => setNewModelProvider(p)} ))}
style={{ <Mono size={tokens.size.xs} color={tokens.color.text3} style={{ marginLeft: tokens.space[2] }}>
background: newModelProvider === p ? tokens.color.bg3 : "transparent", {filtered.length} model{filtered.length !== 1 ? "s" : ""}
border: `1px solid ${newModelProvider === p ? tokens.color.border1 : tokens.color.border0}`, </Mono>
color: newModelProvider === p ? tokens.color.text0 : tokens.color.text2, </div>
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 => (
<button key={p} onClick={() => setProviderFilter(p)}
style={{
background: providerFilter === p ? tokens.color.bg3 : "transparent",
border: `1px solid ${providerFilter === p ? tokens.color.border1 : tokens.color.border0}`,
color: providerFilter === p ? tokens.color.text0 : tokens.color.text2,
fontFamily: tokens.font.mono, fontSize: tokens.size.xs, letterSpacing: tokens.tracking.wide,
padding: `2px ${tokens.space[2]}px`, cursor: "pointer", borderRadius: 0,
}}>
{p.toUpperCase()}
</button>
))}
</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>
)}
</> </>
)} )}

View File

@@ -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",

View File

@@ -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 }> = {
"claude-opus-4-20250514": { name: "Claude Opus 4", provider: "anthropic", contextWindow: 200000, costPer1kInput: 0.015, costPer1kOutput: 0.075 }, // Anthropic (direct)
"claude-sonnet-4-20250514": { name: "Claude Sonnet 4", provider: "anthropic", contextWindow: 200000, costPer1kInput: 0.003, costPer1kOutput: 0.015 }, "claude-opus-4-20250514": { name: "Claude Opus 4", provider: "anthropic", contextWindow: 200000, costPer1kInput: 0.015, costPer1kOutput: 0.075 },
"claude-haiku-4-20250514": { name: "Claude Haiku 4", provider: "anthropic", contextWindow: 200000, costPer1kInput: 0.0008, costPer1kOutput: 0.004 }, "claude-sonnet-4-20250514": { name: "Claude Sonnet 4", provider: "anthropic", contextWindow: 200000, costPer1kInput: 0.003, costPer1kOutput: 0.015 },
"gpt-4o": { name: "GPT-4o", provider: "openai", contextWindow: 128000, costPer1kInput: 0.0025, costPer1kOutput: 0.01 }, "claude-haiku-4-20250514": { name: "Claude Haiku 4", provider: "anthropic", contextWindow: 200000, costPer1kInput: 0.0008, costPer1kOutput: 0.004 },
"gpt-4o-mini": { name: "GPT-4o Mini", provider: "openai", contextWindow: 128000, costPer1kInput: 0.00015, costPer1kOutput: 0.0006 }, // OpenAI (direct)
"o3": { name: "o3", provider: "openai", contextWindow: 200000, costPer1kInput: 0.01, costPer1kOutput: 0.04 }, "gpt-4o": { name: "GPT-4o", provider: "openai", contextWindow: 128000, costPer1kInput: 0.0025, costPer1kOutput: 0.01 },
"o4-mini": { name: "o4 Mini", provider: "openai", contextWindow: 200000, costPer1kInput: 0.0011, costPer1kOutput: 0.0044 }, "gpt-4o-mini": { name: "GPT-4o Mini", provider: "openai", contextWindow: 128000, costPer1kInput: 0.00015, costPer1kOutput: 0.0006 },
"gemini-2.5-pro": { name: "Gemini 2.5 Pro", provider: "google", contextWindow: 1048576, costPer1kInput: 0.00125, costPer1kOutput: 0.01 }, "o3": { name: "o3", provider: "openai", contextWindow: 200000, costPer1kInput: 0.01, costPer1kOutput: 0.04 },
"gemini-2.5-flash": { name: "Gemini 2.5 Flash", provider: "google", contextWindow: 1048576, costPer1kInput: 0.00015, costPer1kOutput: 0.0006 }, "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-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,27 +123,29 @@ 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;
const existing = await getRawCredentialsByProvider(provider); for (const provider of providers) {
if (existing.length > 0) continue; const existing = await getRawCredentialsByProvider(provider);
await upsertCredential({ if (existing.length > 0) continue;
id: `file-${provider}`, await upsertCredential({
provider, id: `file-${provider}`,
label: `${key} (mounted)`, provider,
token, label: `${key} (mounted)`,
}); token,
});
}
} }
} catch { } catch {
// ignore parse errors // ignore parse errors

View File

@@ -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;

View File

@@ -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