Official
opencode
OpenCode agent provider: runs opencode serve and talks to it over HTTP and server-sent events.
The app opens the listing; nothing installs until an agent in your Plugins workspace has read the files and you enable the plugin. In a terminal: cvg install convergence/opencode@0.2.0
Permissions in 0.2.0
Files
options.ts8.8 KB
// The config options the plugin offers, built from the live catalog only
// (`GET /config/providers`, `GET /agent`). There is no list of models,
// modes or reasoning levels anywhere in this plugin: an empty catalog is
// an error the user sees.
import { permissionMode } from "../sdk/agent.ts";
import * as z from "zod";
import type { ConfigChoice, ConfigOption } from "convergence/protocol";
import { obj } from "./map.ts";
/// Option ids; they are also the keys of a session's stored options.
export const OPTION_MODEL = "model";
export const OPTION_AGENT = "agent";
export const OPTION_VARIANT = "variant";
export interface VariantChoice {
value: string;
name: string;
}
export interface ModelChoice extends ConfigChoice {
value: string;
name: string;
group?: string;
reasoningLevels?: VariantChoice[];
}
const modelSchema = z.looseObject({
id: z.unknown().optional(),
name: z.unknown().optional(),
release_date: z.unknown().optional(),
variants: z.unknown().optional(),
limit: z.unknown().optional(),
});
const providerSchema = z.looseObject({
id: z.unknown().optional(),
name: z.unknown().optional(),
models: z.unknown().optional(),
});
const agentSchema = z.looseObject({
name: z.unknown().optional(),
description: z.unknown().optional(),
mode: z.unknown().optional(),
hidden: z.unknown().optional(),
});
const catalogSchema = z.looseObject({
providers: z.array(z.unknown()).optional(),
default: z.unknown().optional(),
});
interface CatalogModel {
id?: unknown;
name?: unknown;
release_date?: unknown;
variants?: unknown;
limit?: unknown;
}
export interface Providers {
providers?: unknown;
default?: unknown;
}
export interface AgentEntry {
name: string;
description: string | null;
mode: string;
hidden: boolean;
}
/// Display order of reasoning variants, least effort first. Only the
/// order: the names always come from the API, and a JSON object has no
/// order once parsed. Unknown names follow, alphabetically.
const VARIANT_ORDER = ["none", "minimal", "low", "medium", "high", "xhigh", "max"];
/// `"<provider>/<model>"` as `[provider, model]`; a model id may itself
/// contain slashes. `null` when either side is empty.
export function splitModel(value: unknown): [string, string] | null {
if (typeof value !== "string") return null;
const at = value.indexOf("/");
if (at <= 0 || at === value.length - 1) return null;
return [value.slice(0, at), value.slice(at + 1)];
}
/// A model's reasoning choices, ordered by effort.
export function variantChoices(model: { variants?: unknown }): VariantChoice[] {
const variants = model?.variants && typeof model.variants === "object" ? Object.keys(model.variants) : [];
const rank = (name: string): number => {
const at = VARIANT_ORDER.indexOf(name);
return at < 0 ? VARIANT_ORDER.length : at;
};
variants.sort((a, b) => rank(a) - rank(b) || (a < b ? -1 : a > b ? 1 : 0));
return variants.map((name) => ({ value: name, name }));
}
/// The reasoning option: kept in the catalog whenever any model has
/// variants (so a draft can switch to a reasoning model before a session
/// exists), with the chosen model's variants as its choices and `null`
/// ("the provider default") until the user picks one.
export function reasoningOption(
models: readonly { value: string; reasoningLevels?: VariantChoice[] }[],
selected: unknown,
value: unknown,
): ConfigOption | null {
if (!models.some((model) => model.reasoningLevels?.length)) return null;
const chosen = models.find((model) => model.value === selected);
return {
id: OPTION_VARIANT,
name: "Reasoning",
category: "reasoning",
kind: "select",
value: value ?? null,
choices: chosen?.reasoningLevels ?? [],
};
}
const lower = (text: unknown): string => String(text ?? "").toLowerCase();
/// Every option for a workspace: model (grouped by provider, newest
/// first), mode (the primary agents), reasoning, and the permission mode.
/// `providers` is `GET /config/providers`, `agents` is `GET /agent`,
/// `current` the values a chat chose.
export function buildOptions(
providers: unknown,
agents: unknown,
current: Record<string, unknown> = {},
): ConfigOption[] {
const parsedCatalog = catalogSchema.safeParse(providers);
const catalog = parsedCatalog.success ? parsedCatalog.data : {};
const modelChoices: ModelChoice[] = [];
let defaultModel: string | null = null;
const defaults = catalog.default && typeof catalog.default === "object" ? catalog.default : {};
for (const raw of Array.isArray(catalog.providers) ? catalog.providers : []) {
const parsedProvider = providerSchema.safeParse(raw);
if (!parsedProvider.success || typeof parsedProvider.data.id !== "string") continue;
const provider = parsedProvider.data;
const providerId = parsedProvider.data.id;
const models: CatalogModel[] = [];
for (const entry of Object.values(provider.models && typeof provider.models === "object" ? provider.models : {})) {
const parsedModel = modelSchema.safeParse(entry);
if (parsedModel.success && typeof parsedModel.data.id === "string") models.push(parsedModel.data);
}
// Newest first, then by name; the catalog itself is unordered.
models.sort((a, b) => {
const left = typeof a.release_date === "string" ? a.release_date : "";
const right = typeof b.release_date === "string" ? b.release_date : "";
if (left !== right) return left < right ? 1 : -1;
const an = lower(a.name ?? a.id);
const bn = lower(b.name ?? b.id);
return an < bn ? -1 : an > bn ? 1 : 0;
});
const own = obj(defaults)[providerId];
const providerModels = obj(provider.models);
if (defaultModel === null && typeof own === "string" && providerModels[own]) defaultModel = `${providerId}/${own}`;
for (const model of models) {
if (typeof model.id !== "string") continue;
const modelId = model.id;
const choice: ModelChoice = {
value: `${providerId}/${modelId}`,
name: typeof model.name === "string" && model.name ? model.name : modelId,
group: typeof provider.name === "string" && provider.name ? provider.name : providerId,
};
const levels = variantChoices(model);
if (levels.length) choice.reasoningLevels = levels;
modelChoices.push(choice);
}
}
if (!modelChoices.length) throw new Error("opencode has no connected providers; run `opencode auth login` first");
const options: ConfigOption[] = [];
const first = modelChoices[0];
if (!first) throw new Error("opencode has no connected providers; run `opencode auth login` first");
const currentModel = current[OPTION_MODEL];
const modelValue = typeof currentModel === "string" ? currentModel : (defaultModel ?? first.value);
options.push({
id: OPTION_MODEL,
name: "Model",
category: "model",
kind: "select",
value: modelValue,
choices: modelChoices,
});
const agentChoices: ConfigChoice[] = [];
for (const raw of Array.isArray(agents) ? agents : []) {
const parsed = agentSchema.safeParse(raw);
if (!parsed.success) continue;
const agent = parsed.data;
if (typeof agent.name !== "string" || agent.hidden) continue;
if (agent.mode !== "primary" && agent.mode !== "all") continue;
const choice: ConfigChoice = { value: agent.name, name: agent.name };
if (typeof agent.description === "string" && agent.description) choice.description = agent.description;
agentChoices.push(choice);
}
if (agentChoices.length) {
const currentAgent = current[OPTION_AGENT];
const head = agentChoices[0];
const value = typeof currentAgent === "string" ? currentAgent : head ? head.value : "";
options.push({ id: OPTION_AGENT, name: "Mode", category: "mode", kind: "select", value, choices: agentChoices });
}
const reasoning = reasoningOption(modelChoices, modelValue, current[OPTION_VARIANT]);
if (reasoning) options.push(reasoning);
// OpenCode has no approval reviewer of its own, so Auto is left out
// rather than offered as another name for Supervised.
options.push(permissionMode.option(permissionMode.selected(current), false));
return options;
}
/// The context window of `provider/model` in a catalog, when it names one.
export function contextWindow(providers: unknown, providerId: string, modelId: string): number | null {
const parsedCatalog = catalogSchema.safeParse(providers);
const entries =
parsedCatalog.success && Array.isArray(parsedCatalog.data.providers) ? parsedCatalog.data.providers : [];
let models: unknown = null;
for (const raw of entries) {
const parsed = providerSchema.safeParse(raw);
if (parsed.success && parsed.data.id === providerId) {
models = parsed.data.models;
break;
}
}
const model = obj(models)[modelId];
const parsedModel = modelSchema.safeParse(model);
if (!parsedModel.success) return null;
const context = obj(parsedModel.data.limit)["context"];
return typeof context === "number" && context > 0 ? context : null;
}Versions
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 94.8 KB | 5 permissions | Listed |
No comments yet.