Merge pull request #623 from magimetal/feat/opencode-zen-provider
feat(onboard): add OpenCode Zen as model provider
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Models/Auth: add OpenCode Zen (multi-model proxy) onboarding. (#623) — thanks @magimetal
|
||||
- WhatsApp: refactor vCard parsing helper and improve empty contact card summaries. (#624) — thanks @steipete
|
||||
- WhatsApp: include phone numbers when multiple contacts are shared. (#625) — thanks @mahmoudashraf93
|
||||
- Agents: warn on small context windows (<32k) and block unusable ones (<16k). — thanks @steipete
|
||||
|
||||
@@ -177,7 +177,7 @@ Options:
|
||||
- `--workspace <dir>`
|
||||
- `--non-interactive`
|
||||
- `--mode <local|remote>`
|
||||
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|apiKey|minimax-cloud|minimax-api|minimax|skip>`
|
||||
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|apiKey|minimax-cloud|minimax-api|minimax|opencode-zen|skip>`
|
||||
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
|
||||
- `--token <token>` (non-interactive; used with `--auth-choice token`)
|
||||
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
|
||||
@@ -186,6 +186,7 @@ Options:
|
||||
- `--openai-api-key <key>`
|
||||
- `--gemini-api-key <key>`
|
||||
- `--minimax-api-key <key>`
|
||||
- `--opencode-zen-api-key <key>`
|
||||
- `--gateway-port <port>`
|
||||
- `--gateway-bind <loopback|lan|tailnet|auto>`
|
||||
- `--gateway-auth <off|token|password>`
|
||||
|
||||
@@ -77,6 +77,7 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
|
||||
- **OpenAI Codex OAuth**: browser flow; paste the `code#state`.
|
||||
- Sets `agents.defaults.model` to `openai-codex/gpt-5.2` when model is unset or `openai/*`.
|
||||
- **OpenAI API key**: uses `OPENAI_API_KEY` if present or prompts for a key, then saves it to `~/.clawdbot/.env` so launchd can read it.
|
||||
- **OpenCode Zen (multi-model proxy)**: prompts for `OPENCODE_ZEN_API_KEY` (get it at https://opencode.ai/auth).
|
||||
- **API key**: stores the key for you.
|
||||
- **MiniMax M2.1 (minimax.io)**: config is auto‑written for the OpenAI-compatible `/v1` endpoint.
|
||||
- **MiniMax API (platform.minimax.io)**: config is auto‑written for the Anthropic-compatible `/anthropic` endpoint.
|
||||
@@ -185,6 +186,17 @@ clawdbot onboard --non-interactive \
|
||||
--gateway-bind loopback
|
||||
```
|
||||
|
||||
OpenCode Zen example:
|
||||
|
||||
```bash
|
||||
clawdbot onboard --non-interactive \
|
||||
--mode local \
|
||||
--auth-choice opencode-zen \
|
||||
--opencode-zen-api-key "$OPENCODE_ZEN_API_KEY" \
|
||||
--gateway-port 18789 \
|
||||
--gateway-bind loopback
|
||||
```
|
||||
|
||||
Add agent (non‑interactive) example:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -139,6 +139,7 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
|
||||
minimax: "MINIMAX_API_KEY",
|
||||
zai: "ZAI_API_KEY",
|
||||
mistral: "MISTRAL_API_KEY",
|
||||
"opencode-zen": "OPENCODE_ZEN_API_KEY",
|
||||
};
|
||||
const envVar = envMap[provider];
|
||||
if (!envVar) return null;
|
||||
|
||||
88
src/agents/opencode-zen-models.test.ts
Normal file
88
src/agents/opencode-zen-models.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
getOpencodeZenStaticFallbackModels,
|
||||
OPENCODE_ZEN_MODEL_ALIASES,
|
||||
resolveOpencodeZenAlias,
|
||||
resolveOpencodeZenModelApi,
|
||||
} from "./opencode-zen-models.js";
|
||||
|
||||
describe("resolveOpencodeZenAlias", () => {
|
||||
it("resolves opus alias", () => {
|
||||
expect(resolveOpencodeZenAlias("opus")).toBe("claude-opus-4-5");
|
||||
});
|
||||
|
||||
it("resolves gpt5 alias", () => {
|
||||
expect(resolveOpencodeZenAlias("gpt5")).toBe("gpt-5.2");
|
||||
});
|
||||
|
||||
it("resolves gemini alias", () => {
|
||||
expect(resolveOpencodeZenAlias("gemini")).toBe("gemini-3-pro");
|
||||
});
|
||||
|
||||
it("returns input if no alias exists", () => {
|
||||
expect(resolveOpencodeZenAlias("some-unknown-model")).toBe(
|
||||
"some-unknown-model",
|
||||
);
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
expect(resolveOpencodeZenAlias("OPUS")).toBe("claude-opus-4-5");
|
||||
expect(resolveOpencodeZenAlias("Gpt5")).toBe("gpt-5.2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveOpencodeZenModelApi", () => {
|
||||
it("returns openai-completions for all models (OpenCode Zen is OpenAI-compatible)", () => {
|
||||
expect(resolveOpencodeZenModelApi("claude-opus-4-5")).toBe(
|
||||
"openai-completions",
|
||||
);
|
||||
expect(resolveOpencodeZenModelApi("gpt-5.2")).toBe("openai-completions");
|
||||
expect(resolveOpencodeZenModelApi("gemini-3-pro")).toBe(
|
||||
"openai-completions",
|
||||
);
|
||||
expect(resolveOpencodeZenModelApi("some-unknown-model")).toBe(
|
||||
"openai-completions",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOpencodeZenStaticFallbackModels", () => {
|
||||
it("returns an array of models", () => {
|
||||
const models = getOpencodeZenStaticFallbackModels();
|
||||
expect(Array.isArray(models)).toBe(true);
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("includes Claude, GPT, and Gemini models", () => {
|
||||
const models = getOpencodeZenStaticFallbackModels();
|
||||
const ids = models.map((m) => m.id);
|
||||
|
||||
expect(ids).toContain("claude-opus-4-5");
|
||||
expect(ids).toContain("gpt-5.2");
|
||||
expect(ids).toContain("gemini-3-pro");
|
||||
});
|
||||
|
||||
it("returns valid ModelDefinitionConfig objects", () => {
|
||||
const models = getOpencodeZenStaticFallbackModels();
|
||||
for (const model of models) {
|
||||
expect(model.id).toBeDefined();
|
||||
expect(model.name).toBeDefined();
|
||||
expect(typeof model.reasoning).toBe("boolean");
|
||||
expect(Array.isArray(model.input)).toBe(true);
|
||||
expect(model.cost).toBeDefined();
|
||||
expect(typeof model.contextWindow).toBe("number");
|
||||
expect(typeof model.maxTokens).toBe("number");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("OPENCODE_ZEN_MODEL_ALIASES", () => {
|
||||
it("has expected aliases", () => {
|
||||
expect(OPENCODE_ZEN_MODEL_ALIASES.opus).toBe("claude-opus-4-5");
|
||||
expect(OPENCODE_ZEN_MODEL_ALIASES.sonnet).toBe("claude-sonnet-4-20250514");
|
||||
expect(OPENCODE_ZEN_MODEL_ALIASES.gpt5).toBe("gpt-5.2");
|
||||
expect(OPENCODE_ZEN_MODEL_ALIASES.o1).toBe("o1-2025-04-16");
|
||||
expect(OPENCODE_ZEN_MODEL_ALIASES.gemini).toBe("gemini-3-pro");
|
||||
});
|
||||
});
|
||||
285
src/agents/opencode-zen-models.ts
Normal file
285
src/agents/opencode-zen-models.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* OpenCode Zen model catalog with dynamic fetching, caching, and static fallback.
|
||||
*
|
||||
* OpenCode Zen is a $200/month subscription that provides proxy access to multiple
|
||||
* AI models (Claude, GPT, Gemini, etc.) through a single API endpoint.
|
||||
*
|
||||
* API endpoint: https://opencode.ai/zen/v1
|
||||
* Auth URL: https://opencode.ai/auth
|
||||
*/
|
||||
|
||||
import type { ModelApi, ModelDefinitionConfig } from "../config/types.js";
|
||||
|
||||
export const OPENCODE_ZEN_API_BASE_URL = "https://opencode.ai/zen/v1";
|
||||
export const OPENCODE_ZEN_DEFAULT_MODEL = "claude-opus-4-5";
|
||||
export const OPENCODE_ZEN_DEFAULT_MODEL_REF = `opencode-zen/${OPENCODE_ZEN_DEFAULT_MODEL}`;
|
||||
|
||||
// Cache for fetched models (1 hour TTL)
|
||||
let cachedModels: ModelDefinitionConfig[] | null = null;
|
||||
let cacheTimestamp = 0;
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
/**
|
||||
* Model aliases for convenient shortcuts.
|
||||
* Users can use "opus" instead of "claude-opus-4-5", etc.
|
||||
*/
|
||||
export const OPENCODE_ZEN_MODEL_ALIASES: Record<string, string> = {
|
||||
// Claude aliases
|
||||
opus: "claude-opus-4-5",
|
||||
"opus-4.5": "claude-opus-4-5",
|
||||
"opus-4": "claude-opus-4-5",
|
||||
sonnet: "claude-sonnet-4-20250514",
|
||||
"sonnet-4": "claude-sonnet-4-20250514",
|
||||
haiku: "claude-haiku-3-5-20241022",
|
||||
"haiku-3.5": "claude-haiku-3-5-20241022",
|
||||
|
||||
// GPT aliases
|
||||
gpt5: "gpt-5.2",
|
||||
"gpt-5": "gpt-5.2",
|
||||
gpt4: "gpt-4.1",
|
||||
"gpt-4": "gpt-4.1",
|
||||
"gpt-mini": "gpt-4.1-mini",
|
||||
|
||||
// O-series aliases
|
||||
o1: "o1-2025-04-16",
|
||||
o3: "o3-2025-04-16",
|
||||
"o3-mini": "o3-mini-2025-04-16",
|
||||
|
||||
// Gemini aliases
|
||||
gemini: "gemini-3-pro",
|
||||
"gemini-pro": "gemini-3-pro",
|
||||
"gemini-3": "gemini-3-pro",
|
||||
"gemini-2.5": "gemini-2.5-pro",
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a model alias to its full model ID.
|
||||
* Returns the input if no alias exists.
|
||||
*/
|
||||
export function resolveOpencodeZenAlias(modelIdOrAlias: string): string {
|
||||
const normalized = modelIdOrAlias.toLowerCase().trim();
|
||||
return OPENCODE_ZEN_MODEL_ALIASES[normalized] ?? modelIdOrAlias;
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenCode Zen is an OpenAI-compatible proxy for all models.
|
||||
* All requests go through /chat/completions regardless of the underlying model.
|
||||
*/
|
||||
export function resolveOpencodeZenModelApi(_modelId: string): ModelApi {
|
||||
return "openai-completions";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model is a reasoning model (extended thinking).
|
||||
*/
|
||||
function isReasoningModel(modelId: string): boolean {
|
||||
const lower = modelId.toLowerCase();
|
||||
return (
|
||||
lower.includes("opus") ||
|
||||
lower.startsWith("o1-") ||
|
||||
lower.startsWith("o3-") ||
|
||||
lower.startsWith("o4-") ||
|
||||
lower.includes("-thinking")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model supports image input.
|
||||
*/
|
||||
function supportsImageInput(modelId: string): boolean {
|
||||
const lower = modelId.toLowerCase();
|
||||
// Most modern models support images, except some reasoning-only models
|
||||
if (lower.startsWith("o1-") || lower.startsWith("o3-")) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Default cost structure (per million tokens, in USD cents)
|
||||
// These are approximate; actual costs depend on OpenCode Zen pricing
|
||||
const DEFAULT_COST = {
|
||||
input: 0, // Included in subscription
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
// Default context windows by model family
|
||||
function getDefaultContextWindow(modelId: string): number {
|
||||
const lower = modelId.toLowerCase();
|
||||
if (lower.includes("opus")) return 200000;
|
||||
if (lower.includes("sonnet")) return 200000;
|
||||
if (lower.includes("haiku")) return 200000;
|
||||
if (lower.includes("gpt-5")) return 256000;
|
||||
if (lower.includes("gpt-4")) return 128000;
|
||||
if (lower.startsWith("o1-") || lower.startsWith("o3-")) return 200000;
|
||||
if (lower.includes("gemini-3")) return 1000000;
|
||||
if (lower.includes("gemini-2.5")) return 1000000;
|
||||
if (lower.includes("gemini")) return 128000;
|
||||
return 128000; // Conservative default
|
||||
}
|
||||
|
||||
function getDefaultMaxTokens(modelId: string): number {
|
||||
const lower = modelId.toLowerCase();
|
||||
if (lower.includes("opus")) return 32000;
|
||||
if (lower.includes("sonnet")) return 16000;
|
||||
if (lower.includes("haiku")) return 8192;
|
||||
if (lower.startsWith("o1-") || lower.startsWith("o3-")) return 100000;
|
||||
if (lower.includes("gpt")) return 16384;
|
||||
if (lower.includes("gemini")) return 8192;
|
||||
return 8192;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a ModelDefinitionConfig from a model ID.
|
||||
*/
|
||||
function buildModelDefinition(modelId: string): ModelDefinitionConfig {
|
||||
return {
|
||||
id: modelId,
|
||||
name: formatModelName(modelId),
|
||||
api: resolveOpencodeZenModelApi(modelId),
|
||||
reasoning: isReasoningModel(modelId),
|
||||
input: supportsImageInput(modelId) ? ["text", "image"] : ["text"],
|
||||
cost: DEFAULT_COST,
|
||||
contextWindow: getDefaultContextWindow(modelId),
|
||||
maxTokens: getDefaultMaxTokens(modelId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a model ID into a human-readable name.
|
||||
*/
|
||||
function formatModelName(modelId: string): string {
|
||||
// Handle common patterns
|
||||
const replacements: Record<string, string> = {
|
||||
"claude-opus-4-5": "Claude Opus 4.5",
|
||||
"claude-sonnet-4-20250514": "Claude Sonnet 4",
|
||||
"claude-haiku-3-5-20241022": "Claude Haiku 3.5",
|
||||
"gpt-5.2": "GPT-5.2",
|
||||
"gpt-4.1": "GPT-4.1",
|
||||
"gpt-4.1-mini": "GPT-4.1 Mini",
|
||||
"o1-2025-04-16": "O1",
|
||||
"o3-2025-04-16": "O3",
|
||||
"o3-mini-2025-04-16": "O3 Mini",
|
||||
"gemini-3-pro": "Gemini 3 Pro",
|
||||
"gemini-2.5-pro": "Gemini 2.5 Pro",
|
||||
"gemini-2.5-flash": "Gemini 2.5 Flash",
|
||||
};
|
||||
|
||||
if (replacements[modelId]) {
|
||||
return replacements[modelId];
|
||||
}
|
||||
|
||||
// Generic formatting: capitalize and replace dashes
|
||||
return modelId
|
||||
.split("-")
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Static fallback models when API is unreachable.
|
||||
* These are the most commonly used models.
|
||||
*/
|
||||
export function getOpencodeZenStaticFallbackModels(): ModelDefinitionConfig[] {
|
||||
const modelIds = [
|
||||
// Claude models
|
||||
"claude-opus-4-5",
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-haiku-3-5-20241022",
|
||||
|
||||
// GPT models
|
||||
"gpt-5.2",
|
||||
"gpt-4.1",
|
||||
"gpt-4.1-mini",
|
||||
|
||||
// O-series reasoning models
|
||||
"o1-2025-04-16",
|
||||
"o3-2025-04-16",
|
||||
"o3-mini-2025-04-16",
|
||||
|
||||
// Gemini models
|
||||
"gemini-3-pro",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.5-flash",
|
||||
];
|
||||
|
||||
return modelIds.map(buildModelDefinition);
|
||||
}
|
||||
|
||||
/**
|
||||
* Response shape from OpenCode Zen /models endpoint.
|
||||
* Returns OpenAI-compatible format.
|
||||
*/
|
||||
interface ZenModelsResponse {
|
||||
data: Array<{
|
||||
id: string;
|
||||
object: "model";
|
||||
created?: number;
|
||||
owned_by?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch models from the OpenCode Zen API.
|
||||
* Uses caching with 1-hour TTL.
|
||||
*
|
||||
* @param apiKey - OpenCode Zen API key for authentication
|
||||
* @returns Array of model definitions, or static fallback on failure
|
||||
*/
|
||||
export async function fetchOpencodeZenModels(
|
||||
apiKey?: string,
|
||||
): Promise<ModelDefinitionConfig[]> {
|
||||
// Return cached models if still valid
|
||||
const now = Date.now();
|
||||
if (cachedModels && now - cacheTimestamp < CACHE_TTL_MS) {
|
||||
return cachedModels;
|
||||
}
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/json",
|
||||
};
|
||||
if (apiKey) {
|
||||
headers.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${OPENCODE_ZEN_API_BASE_URL}/models`, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(10000), // 10 second timeout
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`API returned ${response.status}: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ZenModelsResponse;
|
||||
|
||||
if (!data.data || !Array.isArray(data.data)) {
|
||||
throw new Error("Invalid response format from /models endpoint");
|
||||
}
|
||||
|
||||
const models = data.data.map((model) => buildModelDefinition(model.id));
|
||||
|
||||
cachedModels = models;
|
||||
cacheTimestamp = now;
|
||||
|
||||
return models;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[opencode-zen] Failed to fetch models, using static fallback: ${String(error)}`,
|
||||
);
|
||||
return getOpencodeZenStaticFallbackModels();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the model cache (useful for testing or forcing refresh).
|
||||
*/
|
||||
export function clearOpencodeZenModelCache(): void {
|
||||
cachedModels = null;
|
||||
cacheTimestamp = 0;
|
||||
}
|
||||
@@ -109,7 +109,6 @@ import {
|
||||
|
||||
// Optional features can be implemented as Pi extensions that run in the same Node process.
|
||||
|
||||
|
||||
/**
|
||||
* Resolve provider-specific extraParams from model config.
|
||||
* Auto-enables thinking mode for GLM-4.x models unless explicitly disabled.
|
||||
|
||||
@@ -133,6 +133,29 @@ describe("cli program", () => {
|
||||
expect(setupCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes opencode-zen api key to onboard", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(
|
||||
[
|
||||
"onboard",
|
||||
"--non-interactive",
|
||||
"--auth-choice",
|
||||
"opencode-zen",
|
||||
"--opencode-zen-api-key",
|
||||
"sk-opencode-zen-test",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
expect(onboardCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
nonInteractive: true,
|
||||
authChoice: "opencode-zen",
|
||||
opencodeZenApiKey: "sk-opencode-zen-test",
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
});
|
||||
|
||||
it("runs providers login", async () => {
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["providers", "login", "--account", "work"], {
|
||||
|
||||
@@ -245,7 +245,7 @@ export function buildProgram() {
|
||||
.option("--mode <mode>", "Wizard mode: local|remote")
|
||||
.option(
|
||||
"--auth-choice <choice>",
|
||||
"Auth: setup-token|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|apiKey|minimax-cloud|minimax|skip",
|
||||
"Auth: setup-token|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|apiKey|minimax-cloud|minimax-api|minimax|opencode-zen|skip",
|
||||
)
|
||||
.option(
|
||||
"--token-provider <id>",
|
||||
@@ -267,6 +267,7 @@ export function buildProgram() {
|
||||
.option("--openai-api-key <key>", "OpenAI API key")
|
||||
.option("--gemini-api-key <key>", "Gemini API key")
|
||||
.option("--minimax-api-key <key>", "MiniMax API key")
|
||||
.option("--opencode-zen-api-key <key>", "OpenCode Zen API key")
|
||||
.option("--gateway-port <port>", "Gateway port")
|
||||
.option("--gateway-bind <mode>", "Gateway bind: loopback|lan|tailnet|auto")
|
||||
.option("--gateway-auth <mode>", "Gateway auth: off|token|password")
|
||||
@@ -314,7 +315,9 @@ export function buildProgram() {
|
||||
| "gemini-api-key"
|
||||
| "apiKey"
|
||||
| "minimax-cloud"
|
||||
| "minimax-api"
|
||||
| "minimax"
|
||||
| "opencode-zen"
|
||||
| "skip"
|
||||
| undefined,
|
||||
tokenProvider: opts.tokenProvider as string | undefined,
|
||||
@@ -325,6 +328,7 @@ export function buildProgram() {
|
||||
openaiApiKey: opts.openaiApiKey as string | undefined,
|
||||
geminiApiKey: opts.geminiApiKey as string | undefined,
|
||||
minimaxApiKey: opts.minimaxApiKey as string | undefined,
|
||||
opencodeZenApiKey: opts.opencodeZenApiKey as string | undefined,
|
||||
gatewayPort:
|
||||
typeof opts.gatewayPort === "string"
|
||||
? Number.parseInt(opts.gatewayPort, 10)
|
||||
|
||||
@@ -99,6 +99,11 @@ export function buildAuthChoiceOptions(params: {
|
||||
options.push({ value: "gemini-api-key", label: "Google Gemini API key" });
|
||||
options.push({ value: "apiKey", label: "Anthropic API key" });
|
||||
// Token flow is currently Anthropic-only; use CLI for advanced providers.
|
||||
options.push({
|
||||
value: "opencode-zen",
|
||||
label: "OpenCode Zen (multi-model proxy)",
|
||||
hint: "Claude, GPT, Gemini via opencode.ai/zen",
|
||||
});
|
||||
options.push({ value: "minimax-cloud", label: "MiniMax M2.1 (minimax.io)" });
|
||||
options.push({ value: "minimax", label: "Minimax M2.1 (LM Studio)" });
|
||||
options.push({
|
||||
|
||||
@@ -97,4 +97,57 @@ describe("applyAuthChoice", () => {
|
||||
};
|
||||
expect(parsed.profiles?.["minimax:default"]?.key).toBe("sk-minimax-test");
|
||||
});
|
||||
|
||||
it("does not override the default model when selecting opencode-zen without setDefaultModel", async () => {
|
||||
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-"));
|
||||
process.env.CLAWDBOT_STATE_DIR = tempStateDir;
|
||||
process.env.CLAWDBOT_AGENT_DIR = path.join(tempStateDir, "agent");
|
||||
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
|
||||
|
||||
const text = vi.fn().mockResolvedValue("sk-opencode-zen-test");
|
||||
const select: WizardPrompter["select"] = vi.fn(
|
||||
async (params) => params.options[0]?.value as never,
|
||||
);
|
||||
const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []);
|
||||
const prompter: WizardPrompter = {
|
||||
intro: vi.fn(noopAsync),
|
||||
outro: vi.fn(noopAsync),
|
||||
note: vi.fn(noopAsync),
|
||||
select,
|
||||
multiselect,
|
||||
text,
|
||||
confirm: vi.fn(async () => false),
|
||||
progress: vi.fn(() => ({ update: noop, stop: noop })),
|
||||
};
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn((code: number) => {
|
||||
throw new Error(`exit:${code}`);
|
||||
}),
|
||||
};
|
||||
|
||||
const result = await applyAuthChoice({
|
||||
authChoice: "opencode-zen",
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "anthropic/claude-opus-4-5" },
|
||||
},
|
||||
},
|
||||
},
|
||||
prompter,
|
||||
runtime,
|
||||
setDefaultModel: false,
|
||||
});
|
||||
|
||||
expect(text).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: "Enter OpenCode Zen API key" }),
|
||||
);
|
||||
expect(result.config.agents?.defaults?.model?.primary).toBe(
|
||||
"anthropic/claude-opus-4-5",
|
||||
);
|
||||
expect(result.config.models?.providers?.["opencode-zen"]).toBeDefined();
|
||||
expect(result.agentModelOverride).toBe("opencode-zen/claude-opus-4-5");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,10 +42,13 @@ import {
|
||||
applyMinimaxHostedConfig,
|
||||
applyMinimaxHostedProviderConfig,
|
||||
applyMinimaxProviderConfig,
|
||||
applyOpencodeZenConfig,
|
||||
applyOpencodeZenProviderConfig,
|
||||
MINIMAX_HOSTED_MODEL_REF,
|
||||
setAnthropicApiKey,
|
||||
setGeminiApiKey,
|
||||
setMinimaxApiKey,
|
||||
setOpencodeZenApiKey,
|
||||
writeOAuthCredentials,
|
||||
} from "./onboard-auth.js";
|
||||
import { openUrl } from "./onboard-helpers.js";
|
||||
@@ -54,6 +57,7 @@ import {
|
||||
applyOpenAICodexModelDefault,
|
||||
OPENAI_CODEX_DEFAULT_MODEL,
|
||||
} from "./openai-codex-model-default.js";
|
||||
import { OPENCODE_ZEN_DEFAULT_MODEL } from "./opencode-zen-model-default.js";
|
||||
|
||||
export async function warnIfModelConfigLooksOff(
|
||||
config: ClawdbotConfig,
|
||||
@@ -649,6 +653,36 @@ export async function applyAuthChoice(params: {
|
||||
agentModelOverride = "minimax/MiniMax-M2.1";
|
||||
await noteAgentModel("minimax/MiniMax-M2.1");
|
||||
}
|
||||
} else if (params.authChoice === "opencode-zen") {
|
||||
await params.prompter.note(
|
||||
[
|
||||
"OpenCode Zen provides access to Claude, GPT, Gemini, and more models.",
|
||||
"Get your API key at: https://opencode.ai/auth",
|
||||
"Requires an active OpenCode Zen subscription.",
|
||||
].join("\n"),
|
||||
"OpenCode Zen",
|
||||
);
|
||||
const key = await params.prompter.text({
|
||||
message: "Enter OpenCode Zen API key",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
});
|
||||
await setOpencodeZenApiKey(String(key).trim(), params.agentDir);
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "opencode-zen:default",
|
||||
provider: "opencode-zen",
|
||||
mode: "api_key",
|
||||
});
|
||||
if (params.setDefaultModel) {
|
||||
nextConfig = applyOpencodeZenConfig(nextConfig);
|
||||
await params.prompter.note(
|
||||
`Default model set to ${OPENCODE_ZEN_DEFAULT_MODEL}`,
|
||||
"Model configured",
|
||||
);
|
||||
} else {
|
||||
nextConfig = applyOpencodeZenProviderConfig(nextConfig);
|
||||
agentModelOverride = OPENCODE_ZEN_DEFAULT_MODEL;
|
||||
await noteAgentModel(OPENCODE_ZEN_DEFAULT_MODEL);
|
||||
}
|
||||
}
|
||||
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
|
||||
@@ -71,9 +71,11 @@ import {
|
||||
applyAuthProfileConfig,
|
||||
applyMinimaxConfig,
|
||||
applyMinimaxHostedConfig,
|
||||
applyOpencodeZenConfig,
|
||||
setAnthropicApiKey,
|
||||
setGeminiApiKey,
|
||||
setMinimaxApiKey,
|
||||
setOpencodeZenApiKey,
|
||||
writeOAuthCredentials,
|
||||
} from "./onboard-auth.js";
|
||||
import {
|
||||
@@ -95,6 +97,7 @@ import {
|
||||
applyOpenAICodexModelDefault,
|
||||
OPENAI_CODEX_DEFAULT_MODEL,
|
||||
} from "./openai-codex-model-default.js";
|
||||
import { OPENCODE_ZEN_DEFAULT_MODEL } from "./opencode-zen-model-default.js";
|
||||
import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js";
|
||||
|
||||
export const CONFIGURE_WIZARD_SECTIONS = [
|
||||
@@ -366,6 +369,7 @@ async function promptAuthConfig(
|
||||
| "apiKey"
|
||||
| "minimax-cloud"
|
||||
| "minimax"
|
||||
| "opencode-zen"
|
||||
| "skip";
|
||||
|
||||
let next = cfg;
|
||||
@@ -783,6 +787,32 @@ async function promptAuthConfig(
|
||||
next = applyMinimaxHostedConfig(next);
|
||||
} else if (authChoice === "minimax") {
|
||||
next = applyMinimaxConfig(next);
|
||||
} else if (authChoice === "opencode-zen") {
|
||||
note(
|
||||
[
|
||||
"OpenCode Zen provides access to Claude, GPT, Gemini, and more models.",
|
||||
"Get your API key at: https://opencode.ai/auth",
|
||||
].join("\n"),
|
||||
"OpenCode Zen",
|
||||
);
|
||||
const key = guardCancel(
|
||||
await text({
|
||||
message: "Enter OpenCode Zen API key",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
runtime,
|
||||
);
|
||||
await setOpencodeZenApiKey(String(key).trim());
|
||||
next = applyAuthProfileConfig(next, {
|
||||
profileId: "opencode-zen:default",
|
||||
provider: "opencode-zen",
|
||||
mode: "api_key",
|
||||
});
|
||||
next = applyOpencodeZenConfig(next);
|
||||
note(
|
||||
`Default model set to ${OPENCODE_ZEN_DEFAULT_MODEL}`,
|
||||
"Model configured",
|
||||
);
|
||||
}
|
||||
|
||||
const currentModel =
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
applyAuthProfileConfig,
|
||||
applyMinimaxApiConfig,
|
||||
applyMinimaxApiProviderConfig,
|
||||
applyOpencodeZenConfig,
|
||||
applyOpencodeZenProviderConfig,
|
||||
writeOAuthCredentials,
|
||||
} from "./onboard-auth.js";
|
||||
|
||||
@@ -250,3 +252,61 @@ describe("applyMinimaxApiProviderConfig", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyOpencodeZenProviderConfig", () => {
|
||||
it("adds opencode-zen provider with correct settings", () => {
|
||||
const cfg = applyOpencodeZenProviderConfig({});
|
||||
expect(cfg.models?.providers?.["opencode-zen"]).toMatchObject({
|
||||
baseUrl: "https://opencode.ai/zen/v1",
|
||||
apiKey: "opencode-zen",
|
||||
api: "openai-completions",
|
||||
});
|
||||
expect(
|
||||
cfg.models?.providers?.["opencode-zen"]?.models.length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("adds allowlist entries for fallback models", () => {
|
||||
const cfg = applyOpencodeZenProviderConfig({});
|
||||
const models = cfg.agents?.defaults?.models ?? {};
|
||||
expect(Object.keys(models)).toContain("opencode-zen/claude-opus-4-5");
|
||||
expect(Object.keys(models)).toContain("opencode-zen/gpt-5.2");
|
||||
});
|
||||
|
||||
it("preserves existing alias for the default model", () => {
|
||||
const cfg = applyOpencodeZenProviderConfig({
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"opencode-zen/claude-opus-4-5": { alias: "My Opus" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(
|
||||
cfg.agents?.defaults?.models?.["opencode-zen/claude-opus-4-5"]?.alias,
|
||||
).toBe("My Opus");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyOpencodeZenConfig", () => {
|
||||
it("sets correct primary model", () => {
|
||||
const cfg = applyOpencodeZenConfig({});
|
||||
expect(cfg.agents?.defaults?.model?.primary).toBe(
|
||||
"opencode-zen/claude-opus-4-5",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves existing model fallbacks", () => {
|
||||
const cfg = applyOpencodeZenConfig({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { fallbacks: ["anthropic/claude-opus-4-5"] },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(cfg.agents?.defaults?.model?.fallbacks).toEqual([
|
||||
"anthropic/claude-opus-4-5",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { OAuthCredentials, OAuthProvider } from "@mariozechner/pi-ai";
|
||||
import { resolveDefaultAgentDir } from "../agents/agent-scope.js";
|
||||
import { upsertAuthProfile } from "../agents/auth-profiles.js";
|
||||
import {
|
||||
getOpencodeZenStaticFallbackModels,
|
||||
OPENCODE_ZEN_API_BASE_URL,
|
||||
OPENCODE_ZEN_DEFAULT_MODEL_REF,
|
||||
} from "../agents/opencode-zen-models.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { ModelDefinitionConfig } from "../config/types.js";
|
||||
|
||||
@@ -381,3 +386,78 @@ export function applyMinimaxApiConfig(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function setOpencodeZenApiKey(key: string, agentDir?: string) {
|
||||
upsertAuthProfile({
|
||||
profileId: "opencode-zen:default",
|
||||
credential: {
|
||||
type: "api_key",
|
||||
provider: "opencode-zen",
|
||||
key,
|
||||
},
|
||||
agentDir: agentDir ?? resolveDefaultAgentDir(),
|
||||
});
|
||||
}
|
||||
|
||||
export function applyOpencodeZenProviderConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
): ClawdbotConfig {
|
||||
const opencodeModels = getOpencodeZenStaticFallbackModels();
|
||||
|
||||
const providers = { ...cfg.models?.providers };
|
||||
providers["opencode-zen"] = {
|
||||
baseUrl: OPENCODE_ZEN_API_BASE_URL,
|
||||
apiKey: "opencode-zen",
|
||||
api: "openai-completions",
|
||||
models: opencodeModels,
|
||||
};
|
||||
|
||||
const models = { ...cfg.agents?.defaults?.models };
|
||||
for (const model of opencodeModels) {
|
||||
const key = `opencode-zen/${model.id}`;
|
||||
models[key] = models[key] ?? {};
|
||||
}
|
||||
models[OPENCODE_ZEN_DEFAULT_MODEL_REF] = {
|
||||
...models[OPENCODE_ZEN_DEFAULT_MODEL_REF],
|
||||
alias: models[OPENCODE_ZEN_DEFAULT_MODEL_REF]?.alias ?? "Opus",
|
||||
};
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...cfg.agents?.defaults,
|
||||
models,
|
||||
},
|
||||
},
|
||||
models: {
|
||||
mode: cfg.models?.mode ?? "merge",
|
||||
providers,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyOpencodeZenConfig(cfg: ClawdbotConfig): ClawdbotConfig {
|
||||
const next = applyOpencodeZenProviderConfig(cfg);
|
||||
return {
|
||||
...next,
|
||||
agents: {
|
||||
...next.agents,
|
||||
defaults: {
|
||||
...next.agents?.defaults,
|
||||
model: {
|
||||
...(next.agents?.defaults?.model &&
|
||||
"fallbacks" in (next.agents.defaults.model as Record<string, unknown>)
|
||||
? {
|
||||
fallbacks: (
|
||||
next.agents.defaults.model as { fallbacks?: string[] }
|
||||
).fallbacks,
|
||||
}
|
||||
: undefined),
|
||||
primary: OPENCODE_ZEN_DEFAULT_MODEL_REF,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,9 +35,11 @@ import {
|
||||
applyMinimaxApiConfig,
|
||||
applyMinimaxConfig,
|
||||
applyMinimaxHostedConfig,
|
||||
applyOpencodeZenConfig,
|
||||
setAnthropicApiKey,
|
||||
setGeminiApiKey,
|
||||
setMinimaxApiKey,
|
||||
setOpencodeZenApiKey,
|
||||
} from "./onboard-auth.js";
|
||||
import {
|
||||
applyWizardMetadata,
|
||||
@@ -312,6 +314,25 @@ export async function runNonInteractiveOnboarding(
|
||||
nextConfig = applyOpenAICodexModelDefault(nextConfig).next;
|
||||
} else if (authChoice === "minimax") {
|
||||
nextConfig = applyMinimaxConfig(nextConfig);
|
||||
} else if (authChoice === "opencode-zen") {
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
provider: "opencode-zen",
|
||||
cfg: baseConfig,
|
||||
flagValue: opts.opencodeZenApiKey,
|
||||
flagName: "--opencode-zen-api-key",
|
||||
envVar: "OPENCODE_ZEN_API_KEY",
|
||||
runtime,
|
||||
});
|
||||
if (!resolved) return;
|
||||
if (resolved.source !== "profile") {
|
||||
await setOpencodeZenApiKey(resolved.key);
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "opencode-zen:default",
|
||||
provider: "opencode-zen",
|
||||
mode: "api_key",
|
||||
});
|
||||
nextConfig = applyOpencodeZenConfig(nextConfig);
|
||||
} else if (
|
||||
authChoice === "token" ||
|
||||
authChoice === "oauth" ||
|
||||
|
||||
@@ -17,6 +17,7 @@ export type AuthChoice =
|
||||
| "minimax-cloud"
|
||||
| "minimax"
|
||||
| "minimax-api"
|
||||
| "opencode-zen"
|
||||
| "skip";
|
||||
export type GatewayAuthChoice = "off" | "token" | "password";
|
||||
export type ResetScope = "config" | "config+creds+sessions" | "full";
|
||||
@@ -43,6 +44,7 @@ export type OnboardOptions = {
|
||||
openaiApiKey?: string;
|
||||
geminiApiKey?: string;
|
||||
minimaxApiKey?: string;
|
||||
opencodeZenApiKey?: string;
|
||||
gatewayPort?: number;
|
||||
gatewayBind?: GatewayBind;
|
||||
gatewayAuth?: GatewayAuthChoice;
|
||||
|
||||
57
src/commands/opencode-zen-model-default.test.ts
Normal file
57
src/commands/opencode-zen-model-default.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import {
|
||||
applyOpencodeZenModelDefault,
|
||||
OPENCODE_ZEN_DEFAULT_MODEL,
|
||||
} from "./opencode-zen-model-default.js";
|
||||
|
||||
describe("applyOpencodeZenModelDefault", () => {
|
||||
it("sets opencode-zen default when model is unset", () => {
|
||||
const cfg: ClawdbotConfig = { agents: { defaults: {} } };
|
||||
const applied = applyOpencodeZenModelDefault(cfg);
|
||||
expect(applied.changed).toBe(true);
|
||||
expect(applied.next.agents?.defaults?.model).toEqual({
|
||||
primary: OPENCODE_ZEN_DEFAULT_MODEL,
|
||||
});
|
||||
});
|
||||
|
||||
it("overrides existing model", () => {
|
||||
const cfg = {
|
||||
agents: { defaults: { model: "anthropic/claude-opus-4-5" } },
|
||||
} as ClawdbotConfig;
|
||||
const applied = applyOpencodeZenModelDefault(cfg);
|
||||
expect(applied.changed).toBe(true);
|
||||
expect(applied.next.agents?.defaults?.model).toEqual({
|
||||
primary: OPENCODE_ZEN_DEFAULT_MODEL,
|
||||
});
|
||||
});
|
||||
|
||||
it("no-ops when already opencode-zen default", () => {
|
||||
const cfg = {
|
||||
agents: { defaults: { model: OPENCODE_ZEN_DEFAULT_MODEL } },
|
||||
} as ClawdbotConfig;
|
||||
const applied = applyOpencodeZenModelDefault(cfg);
|
||||
expect(applied.changed).toBe(false);
|
||||
expect(applied.next).toEqual(cfg);
|
||||
});
|
||||
|
||||
it("preserves fallbacks when setting primary", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "anthropic/claude-opus-4-5",
|
||||
fallbacks: ["google/gemini-3-pro"],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const applied = applyOpencodeZenModelDefault(cfg);
|
||||
expect(applied.changed).toBe(true);
|
||||
expect(applied.next.agents?.defaults?.model).toEqual({
|
||||
primary: OPENCODE_ZEN_DEFAULT_MODEL,
|
||||
fallbacks: ["google/gemini-3-pro"],
|
||||
});
|
||||
});
|
||||
});
|
||||
45
src/commands/opencode-zen-model-default.ts
Normal file
45
src/commands/opencode-zen-model-default.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { AgentModelListConfig } from "../config/types.js";
|
||||
|
||||
export const OPENCODE_ZEN_DEFAULT_MODEL = "opencode-zen/claude-opus-4-5";
|
||||
|
||||
function resolvePrimaryModel(
|
||||
model?: AgentModelListConfig | string,
|
||||
): string | undefined {
|
||||
if (typeof model === "string") return model;
|
||||
if (model && typeof model === "object" && typeof model.primary === "string") {
|
||||
return model.primary;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function applyOpencodeZenModelDefault(cfg: ClawdbotConfig): {
|
||||
next: ClawdbotConfig;
|
||||
changed: boolean;
|
||||
} {
|
||||
const current = resolvePrimaryModel(cfg.agents?.defaults?.model)?.trim();
|
||||
if (current === OPENCODE_ZEN_DEFAULT_MODEL) {
|
||||
return { next: cfg, changed: false };
|
||||
}
|
||||
|
||||
return {
|
||||
next: {
|
||||
...cfg,
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...cfg.agents?.defaults,
|
||||
model:
|
||||
cfg.agents?.defaults?.model &&
|
||||
typeof cfg.agents.defaults.model === "object"
|
||||
? {
|
||||
...cfg.agents.defaults.model,
|
||||
primary: OPENCODE_ZEN_DEFAULT_MODEL,
|
||||
}
|
||||
: { primary: OPENCODE_ZEN_DEFAULT_MODEL },
|
||||
},
|
||||
},
|
||||
},
|
||||
changed: true,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user