feat(ai): add OpenCode CLI provider

This commit is contained in:
Guoyu Su 2026-08-07 17:06:03 +08:00 committed by GitHub
parent f711855ddf
commit 309e3aef5e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 1197 additions and 30 deletions

View File

@ -0,0 +1 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><title>OpenCode</title><path d="M8.40005 17.4H19.2001V21H4.80005V13.8H8.40005V17.4ZM15.6001 10.2V13.8H8.40005V10.2H15.6001ZM19.2001 10.2H15.6001V6.6H4.80005V3H19.2001V10.2Z" fill="currentColor"/></svg>

After

Width:  |  Height:  |  Size: 297 B

View File

@ -2553,7 +2553,7 @@ async function saveMaxAgentTurnsSetting() {
}
// Max Retries (global). Default 2, range 010. Applied to all API-backed
// AI providers. CLI providers (claude-code, codex, pi) are unaffected
// AI providers. CLI providers (Claude Code, Codex, OpenCode, Pi) are unaffected
// because they use their own retry logic.
const editMaxRetries = ref<number | undefined>(undefined);
const maxRetriesSaving = ref(false);
@ -2604,7 +2604,8 @@ function normalizeMaxRetries(value: number | undefined): number {
const aiDeleteConfirmOpen = ref(false);
const aiDeleteConfigId = ref<string | null>(null);
const CLI_AI_PROVIDERS = new Set<AiProvider>(["claude-code-cli", "pi-agent-cli", "codex-cli"]);
const CLI_AI_PROVIDERS = new Set<AiProvider>(["claude-code-cli", "codex-cli", "opencode-cli", "pi-agent-cli"]);
const OPENCODE_CONTROL_ENV = new Set(["OPENCODE_CONFIG", "OPENCODE_CONFIG_CONTENT", "OPENCODE_CONFIG_DIR", "OPENCODE_DB", "OPENCODE_PERMISSION", "OPENCODE_DISABLE_PROJECT_CONFIG"]);
const aiProviderOptions = computed(() => Object.values(AI_PROVIDER_PRESETS).filter((provider) => !isWeb || !CLI_AI_PROVIDERS.has(provider.provider)));
const selectedAiProviderPreset = computed(() => AI_PROVIDER_PRESETS[aiEditProvider.value]);
@ -2626,6 +2627,8 @@ const aiEditClaudeCodeCliPath = ref("");
const aiEditClaudeCodeCliEnvRows = ref<AiEnvRow[]>([]);
const aiEditPiAgentCliPath = ref("");
const aiEditPiAgentCliEnvRows = ref<AiEnvRow[]>([]);
const aiEditOpenCodeCliPath = ref("");
const aiEditOpenCodeCliEnvRows = ref<AiEnvRow[]>([]);
const aiAnthropicMessagesMode = computed(() => aiEditApiStyle.value === "anthropic-messages");
@ -2656,22 +2659,26 @@ const aiTestErrorDisplay = computed(() => [aiTestErrorPresentation.value.summary
const aiIsCodexCli = computed(() => aiEditProvider.value === "codex-cli");
const aiIsClaudeCodeCli = computed(() => aiEditProvider.value === "claude-code-cli");
const aiIsPiAgentCli = computed(() => aiEditProvider.value === "pi-agent-cli");
const aiIsOpenCodeCli = computed(() => aiEditProvider.value === "opencode-cli");
const aiIsCliProvider = computed(() => CLI_AI_PROVIDERS.has(aiEditProvider.value));
const aiCliProviderLabel = computed(() => selectedAiProviderPreset.value.label);
const aiCliCommandName = computed(() => {
if (aiIsClaudeCodeCli.value) return "claude";
if (aiIsPiAgentCli.value) return "pi";
if (aiIsOpenCodeCli.value) return "opencode";
return "codex";
});
const aiCliLoginCommand = computed(() => {
if (aiIsClaudeCodeCli.value) return "claude auth login";
if (aiIsPiAgentCli.value) return "pi";
if (aiIsOpenCodeCli.value) return "opencode auth login";
return "codex login";
});
const aiEditCliPath = computed({
get: () => {
if (aiIsClaudeCodeCli.value) return aiEditClaudeCodeCliPath.value;
if (aiIsPiAgentCli.value) return aiEditPiAgentCliPath.value;
if (aiIsOpenCodeCli.value) return aiEditOpenCodeCliPath.value;
return aiEditCodexCliPath.value;
},
set: (value: string) => {
@ -2679,6 +2686,8 @@ const aiEditCliPath = computed({
aiEditClaudeCodeCliPath.value = value;
} else if (aiIsPiAgentCli.value) {
aiEditPiAgentCliPath.value = value;
} else if (aiIsOpenCodeCli.value) {
aiEditOpenCodeCliPath.value = value;
} else {
aiEditCodexCliPath.value = value;
}
@ -2687,6 +2696,7 @@ const aiEditCliPath = computed({
const aiEditCliEnvRows = computed(() => {
if (aiIsClaudeCodeCli.value) return aiEditClaudeCodeCliEnvRows.value;
if (aiIsPiAgentCli.value) return aiEditPiAgentCliEnvRows.value;
if (aiIsOpenCodeCli.value) return aiEditOpenCodeCliEnvRows.value;
return aiEditCodexCliEnvRows.value;
});
watch(aiIsCliProvider, (isCliProvider) => {
@ -2763,7 +2773,7 @@ function cliEnvValidationError(): string {
const key = row.key.trim();
if (key && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return t("ai.cliEnvInvalidName", { name: key });
const upper = key.toUpperCase();
if (upper.startsWith("DBX_MCP_") || (aiIsPiAgentCli.value && upper.startsWith("DBX_PI_"))) {
if (upper.startsWith("DBX_MCP_") || (aiIsPiAgentCli.value && upper.startsWith("DBX_PI_")) || (aiIsOpenCodeCli.value && OPENCODE_CONTROL_ENV.has(upper))) {
return t("ai.cliEnvReservedName", { name: key });
}
}
@ -2779,6 +2789,8 @@ function removeCliEnvRow(id: string) {
aiEditClaudeCodeCliEnvRows.value = aiEditClaudeCodeCliEnvRows.value.filter((row) => row.id !== id);
} else if (aiIsPiAgentCli.value) {
aiEditPiAgentCliEnvRows.value = aiEditPiAgentCliEnvRows.value.filter((row) => row.id !== id);
} else if (aiIsOpenCodeCli.value) {
aiEditOpenCodeCliEnvRows.value = aiEditOpenCodeCliEnvRows.value.filter((row) => row.id !== id);
} else {
aiEditCodexCliEnvRows.value = aiEditCodexCliEnvRows.value.filter((row) => row.id !== id);
}
@ -2807,6 +2819,8 @@ function currentAiEditConfig() {
claudeCodeCliEnv: aiIsClaudeCodeCli.value ? cliEnvFromRows(aiEditClaudeCodeCliEnvRows.value) : {},
piAgentCliPath: aiEditPiAgentCliPath.value.trim() || undefined,
piAgentCliEnv: aiIsPiAgentCli.value ? cliEnvFromRows(aiEditPiAgentCliEnvRows.value) : {},
opencodeCliPath: aiEditOpenCodeCliPath.value.trim() || undefined,
opencodeCliEnv: aiIsOpenCodeCli.value ? cliEnvFromRows(aiEditOpenCodeCliEnvRows.value) : {},
};
}
@ -2876,6 +2890,8 @@ function aiEnterEditMode(configId?: string) {
aiEditClaudeCodeCliEnvRows.value = aiEnvRowsFromConfig(config.claudeCodeCliEnv);
aiEditPiAgentCliPath.value = config.piAgentCliPath ?? "";
aiEditPiAgentCliEnvRows.value = aiEnvRowsFromConfig(config.piAgentCliEnv);
aiEditOpenCodeCliPath.value = config.opencodeCliPath ?? "";
aiEditOpenCodeCliEnvRows.value = aiEnvRowsFromConfig(config.opencodeCliEnv);
}
} else {
aiEditConfigName.value = "";
@ -2897,6 +2913,8 @@ function aiEnterEditMode(configId?: string) {
aiEditClaudeCodeCliEnvRows.value = [];
aiEditPiAgentCliPath.value = "";
aiEditPiAgentCliEnvRows.value = [];
aiEditOpenCodeCliPath.value = "";
aiEditOpenCodeCliEnvRows.value = [];
}
}

View File

@ -21,7 +21,7 @@ watch(
},
);
const usesWhiteDarkIcon = computed(() => props.provider === "claude" || props.provider === "anthropic-compatible" || props.provider === "ollama" || props.provider === "openai" || props.provider === "openai-compatible");
const usesWhiteDarkIcon = computed(() => props.provider === "claude" || props.provider === "anthropic-compatible" || props.provider === "ollama" || props.provider === "openai" || props.provider === "openai-compatible" || props.provider === "opencode-cli");
const localIconUrl = computed(() => {
if (props.provider === "openai-compatible") return webPath("/icons/ai/openai.svg");
return props.iconSlug ? webPath(`/icons/ai/${props.iconSlug}.svg`) : "";

View File

@ -94,6 +94,30 @@ describe("useAiModelCatalog", () => {
expect(apiMock.aiResolveModelEffort).toHaveBeenCalledTimes(2);
});
it("tracks OpenCode executable and environment changes without depending on environment key order", async () => {
const initial: AiConfigItem = {
...config(),
provider: "opencode-cli",
endpoint: "",
apiKey: "",
model: "default",
opencodeCliPath: "/opt/homebrew/bin/opencode",
opencodeCliEnv: { HTTPS_PROXY: "http://127.0.0.1:7890", NO_PROXY: "localhost" },
};
apiMock.aiListModels.mockResolvedValueOnce([{ id: "first" }]).mockResolvedValueOnce([{ id: "second" }]);
await expect(catalog.loadModels(initial)).resolves.toEqual([{ id: "first" }]);
await expect(
catalog.loadModels({
...initial,
opencodeCliEnv: { NO_PROXY: "localhost", HTTPS_PROXY: "http://127.0.0.1:7890" },
}),
).resolves.toEqual([{ id: "first" }]);
await expect(catalog.loadModels({ ...initial, opencodeCliPath: "/usr/local/bin/opencode" })).resolves.toEqual([{ id: "second" }]);
expect(apiMock.aiListModels).toHaveBeenCalledTimes(2);
});
it("does not let a stale request overwrite a newer provider catalog", async () => {
let resolveInitial: ((models: { id: string }[]) => void) | undefined;
apiMock.aiListModels

View File

@ -57,6 +57,7 @@ function configSignature(config: AiConfigItem): string {
codexCliPath: config.codexCliPath ?? null,
claudeCodeCliPath: config.claudeCodeCliPath ?? null,
piAgentCliPath: config.piAgentCliPath ?? null,
opencodeCliPath: config.opencodeCliPath ?? null,
connectionFingerprint: fingerprint(
JSON.stringify({
apiKey: config.apiKey,
@ -65,6 +66,7 @@ function configSignature(config: AiConfigItem): string {
codexCliEnv: sortedRecord(config.codexCliEnv),
claudeCodeCliEnv: sortedRecord(config.claudeCodeCliEnv),
piAgentCliEnv: sortedRecord(config.piAgentCliEnv),
opencodeCliEnv: sortedRecord(config.opencodeCliEnv),
}),
),
});

View File

@ -33,6 +33,15 @@ const taggedAiCliErrorKeys: Record<string, string> = {
piAgentProtocolError: "ai.cliErrors.piAgentProtocolError",
piAgentModelInvalid: "ai.cliErrors.piAgentModelInvalid",
piAgentRunFailed: "ai.cliErrors.piAgentRunFailed",
openCodeNotInstalled: "ai.cliErrors.openCodeNotInstalled",
openCodeCliPathInvalid: "ai.cliErrors.openCodeCliPathInvalid",
openCodeEnvInvalid: "ai.cliErrors.openCodeEnvInvalid",
openCodeEnvReserved: "ai.cliErrors.openCodeEnvReserved",
openCodeNotAuthenticated: "ai.cliErrors.openCodeNotAuthenticated",
openCodeMcpStartupFailed: "ai.cliErrors.openCodeMcpStartupFailed",
openCodeTimeout: "ai.cliErrors.openCodeTimeout",
openCodeProtocolError: "ai.cliErrors.openCodeProtocolError",
openCodeRunFailed: "ai.cliErrors.openCodeRunFailed",
};
const exactMessageKeys: Record<string, string> = {

View File

@ -2121,6 +2121,15 @@ export default {
piAgentProtocolError: "Pi Coding Agent returned an invalid RPC response. Check the Pi version and diagnostics below.",
piAgentModelInvalid: "The selected Pi model identifier is invalid. Refresh the model list and select a provider/model entry.",
piAgentRunFailed: "Pi Coding Agent exited unexpectedly. Use the error code and diagnostics below to identify the failing executable or CLI output.",
openCodeNotInstalled: "OpenCode CLI was not found. Install OpenCode or set its executable path in Settings > AI.",
openCodeCliPathInvalid: "The OpenCode CLI path is invalid. Select only the OpenCode executable and configure environment variables separately.",
openCodeEnvInvalid: "An OpenCode CLI environment variable name is invalid. Use names such as HTTPS_PROXY.",
openCodeEnvReserved: "An OpenCode or DBX-managed environment variable was overridden. Remove OPENCODE control variables and DBX_MCP_* variables from the provider configuration.",
openCodeNotAuthenticated: "OpenCode has no authenticated model provider. Run `opencode auth login` or configure a provider in OpenCode, then try again.",
openCodeMcpStartupFailed: "OpenCode could not start the scoped DBX MCP server. Check Settings > MCP and the diagnostics below.",
openCodeTimeout: "OpenCode CLI did not respond before the operation timed out.",
openCodeProtocolError: "OpenCode CLI returned an invalid JSON event stream. Check the OpenCode version and diagnostics below.",
openCodeRunFailed: "OpenCode CLI exited unexpectedly. Use the error code and diagnostics below to identify the failing executable or CLI output.",
},
actions: {
general: "General",

View File

@ -1960,6 +1960,15 @@ export default withEnglishFallback({
piAgentProtocolError: "Pi Coding Agent devolvió una respuesta RPC no válida. Comprueba la versión de Pi y los detalles siguientes.",
piAgentModelInvalid: "El identificador del modelo Pi no es válido. Actualiza la lista y selecciona una entrada provider/model.",
piAgentRunFailed: "Pi Coding Agent terminó de forma inesperada. Usa el código de error y los detalles siguientes para identificar el fallo.",
openCodeNotInstalled: "No se encontró OpenCode CLI. Instala OpenCode o configura la ruta del ejecutable en Ajustes > IA.",
openCodeCliPathInvalid: "La ruta de OpenCode CLI no es válida. Selecciona solo el ejecutable de OpenCode y configura las variables de entorno por separado.",
openCodeEnvInvalid: "El nombre de una variable de entorno de OpenCode CLI no es válido. Usa nombres como HTTPS_PROXY.",
openCodeEnvReserved: "Se intentó sobrescribir una variable administrada por OpenCode o DBX. Elimina las variables de control OPENCODE y DBX_MCP_* de la configuración.",
openCodeNotAuthenticated: "OpenCode no tiene un proveedor de modelos autenticado. Ejecuta `opencode auth login` o configura un proveedor en OpenCode y vuelve a intentarlo.",
openCodeMcpStartupFailed: "OpenCode no pudo iniciar el servidor DBX MCP con ámbito restringido. Revisa Ajustes > MCP y los detalles siguientes.",
openCodeTimeout: "OpenCode CLI no respondió antes de que finalizara el tiempo de espera.",
openCodeProtocolError: "OpenCode CLI devolvió un flujo de eventos JSON no válido. Revisa la versión de OpenCode y los detalles siguientes.",
openCodeRunFailed: "OpenCode CLI terminó de forma inesperada. Usa el código de error y los detalles siguientes para identificar el fallo.",
},
run: "Ejecutar",
readingSchema: "Leyendo esquema",

View File

@ -2098,6 +2098,15 @@ export default withEnglishFallback({
piAgentProtocolError: "Pi Coding Agent ha restituito una risposta RPC non valida. Controlla la versione di Pi e i dettagli seguenti.",
piAgentModelInvalid: "L'identificatore del modello Pi non è valido. Aggiorna l'elenco e seleziona una voce provider/model.",
piAgentRunFailed: "Pi Coding Agent è terminato in modo imprevisto. Usa il codice errore e i dettagli seguenti per identificare il problema.",
openCodeNotInstalled: "OpenCode CLI non è stato trovato. Installa OpenCode o imposta il percorso dell'eseguibile in Impostazioni > AI.",
openCodeCliPathInvalid: "Il percorso di OpenCode CLI non è valido. Seleziona solo l'eseguibile OpenCode e configura separatamente le variabili d'ambiente.",
openCodeEnvInvalid: "Il nome di una variabile d'ambiente di OpenCode CLI non è valido. Usa nomi come HTTPS_PROXY.",
openCodeEnvReserved: "È stata sovrascritta una variabile gestita da OpenCode o DBX. Rimuovi le variabili di controllo OPENCODE e DBX_MCP_* dalla configurazione.",
openCodeNotAuthenticated: "OpenCode non dispone di un provider di modelli autenticato. Esegui `opencode auth login` o configura un provider in OpenCode, quindi riprova.",
openCodeMcpStartupFailed: "OpenCode non ha potuto avviare il server DBX MCP con ambito limitato. Controlla Impostazioni > MCP e i dettagli seguenti.",
openCodeTimeout: "OpenCode CLI non ha risposto prima della scadenza dell'operazione.",
openCodeProtocolError: "OpenCode CLI ha restituito un flusso di eventi JSON non valido. Controlla la versione di OpenCode e i dettagli seguenti.",
openCodeRunFailed: "OpenCode CLI è terminato in modo imprevisto. Usa il codice errore e i dettagli seguenti per identificare il problema.",
},
actions: {
general: "Generale",

View File

@ -1994,6 +1994,15 @@ export default withEnglishFallback({
piAgentProtocolError: "Pi Coding Agentが無効なRPC応答を返しました。Piのバージョンと以下の診断詳細を確認してください。",
piAgentModelInvalid: "選択したPiモデルIDが無効です。モデル一覧を更新してprovider/model項目を選択してください。",
piAgentRunFailed: "Pi Coding Agentが予期せず終了しました。エラーコードと以下の診断詳細から問題を確認してください。",
openCodeNotInstalled: "OpenCode CLIが見つかりません。OpenCodeをインストールするか、設定 > AIで実行ファイルのパスを指定してください。",
openCodeCliPathInvalid: "OpenCode CLIのパスが無効です。OpenCodeの実行ファイルだけを選択し、環境変数は別に設定してください。",
openCodeEnvInvalid: "OpenCode CLIの環境変数名が無効です。HTTPS_PROXYのような名前を使用してください。",
openCodeEnvReserved: "OpenCodeまたはDBXが管理する環境変数が上書きされています。設定からOPENCODE制御変数とDBX_MCP_*を削除してください。",
openCodeNotAuthenticated: "OpenCodeに認証済みのモデルプロバイダーがありません。`opencode auth login`を実行するかOpenCodeでプロバイダーを設定して、再試行してください。",
openCodeMcpStartupFailed: "OpenCodeはスコープ付きDBX MCPサーバーを起動できませんでした。設定 > MCPと以下の診断詳細を確認してください。",
openCodeTimeout: "OpenCode CLIはタイムアウトまでに応答しませんでした。",
openCodeProtocolError: "OpenCode CLIが無効なJSONイベントストリームを返しました。OpenCodeのバージョンと以下の診断詳細を確認してください。",
openCodeRunFailed: "OpenCode CLIが予期せず終了しました。エラーコードと以下の診断詳細から問題を確認してください。",
},
run: "実行",
readingSchema: "スキーマを読み取り中",

View File

@ -2001,6 +2001,15 @@ export default withEnglishFallback({
piAgentProtocolError: "Pi Coding Agent가 잘못된 RPC 응답을 반환했습니다. Pi 버전과 아래 진단을 확인하세요.",
piAgentModelInvalid: "선택한 Pi 모델 식별자가 잘못되었습니다. 모델 목록을 새로고침하고 공급자/모델 항목을 선택하세요.",
piAgentRunFailed: "Pi Coding Agent가 예기치 않게 종료되었습니다. 아래의 오류 코드와 진단을 사용하여 실패한 실행 파일이나 CLI 출력을 파악하세요.",
openCodeNotInstalled: "OpenCode CLI를 찾을 수 없습니다. OpenCode를 설치하거나 설정 > AI에서 실행 파일 경로를 지정하세요.",
openCodeCliPathInvalid: "OpenCode CLI 경로가 올바르지 않습니다. OpenCode 실행 파일만 선택하고 환경 변수는 별도로 설정하세요.",
openCodeEnvInvalid: "OpenCode CLI 환경 변수 이름이 올바르지 않습니다. HTTPS_PROXY와 같은 이름을 사용하세요.",
openCodeEnvReserved: "OpenCode 또는 DBX가 관리하는 환경 변수를 덮어썼습니다. 공급자 설정에서 OPENCODE 제어 변수와 DBX_MCP_* 변수를 제거하세요.",
openCodeNotAuthenticated: "OpenCode에 인증된 모델 공급자가 없습니다. `opencode auth login`을 실행하거나 OpenCode에서 공급자를 구성한 후 다시 시도하세요.",
openCodeMcpStartupFailed: "OpenCode가 범위가 제한된 DBX MCP 서버를 시작하지 못했습니다. 설정 > MCP와 아래 진단을 확인하세요.",
openCodeTimeout: "OpenCode CLI가 제한 시간 안에 응답하지 않았습니다.",
openCodeProtocolError: "OpenCode CLI가 잘못된 JSON 이벤트 스트림을 반환했습니다. OpenCode 버전과 아래 진단을 확인하세요.",
openCodeRunFailed: "OpenCode CLI가 예기치 않게 종료되었습니다. 아래의 오류 코드와 진단을 사용하여 실패한 실행 파일이나 CLI 출력을 파악하세요.",
},
actions: {
general: "일반",

View File

@ -1962,6 +1962,15 @@ export default withEnglishFallback({
piAgentProtocolError: "O Pi Coding Agent retornou uma resposta RPC inválida. Verifique a versão do Pi e os detalhes abaixo.",
piAgentModelInvalid: "O identificador do modelo Pi é inválido. Atualize a lista e selecione uma entrada provider/model.",
piAgentRunFailed: "O Pi Coding Agent foi encerrado inesperadamente. Use o código do erro e os detalhes abaixo para identificar a falha.",
openCodeNotInstalled: "O OpenCode CLI não foi encontrado. Instale o OpenCode ou defina o caminho do executável em Configurações > IA.",
openCodeCliPathInvalid: "O caminho do OpenCode CLI é inválido. Selecione apenas o executável do OpenCode e configure as variáveis de ambiente separadamente.",
openCodeEnvInvalid: "O nome de uma variável de ambiente do OpenCode CLI é inválido. Use nomes como HTTPS_PROXY.",
openCodeEnvReserved: "Uma variável gerenciada pelo OpenCode ou DBX foi sobrescrita. Remova as variáveis de controle OPENCODE e DBX_MCP_* da configuração.",
openCodeNotAuthenticated: "O OpenCode não tem um provedor de modelos autenticado. Execute `opencode auth login` ou configure um provedor no OpenCode e tente novamente.",
openCodeMcpStartupFailed: "O OpenCode não conseguiu iniciar o servidor DBX MCP com escopo restrito. Verifique Configurações > MCP e os detalhes abaixo.",
openCodeTimeout: "O OpenCode CLI não respondeu antes do tempo limite da operação.",
openCodeProtocolError: "O OpenCode CLI retornou um fluxo de eventos JSON inválido. Verifique a versão do OpenCode e os detalhes abaixo.",
openCodeRunFailed: "O OpenCode CLI foi encerrado inesperadamente. Use o código do erro e os detalhes abaixo para identificar a falha.",
},
run: "Executar",
readingSchema: "Lendo schema",

View File

@ -2121,6 +2121,15 @@ export default withEnglishFallback({
piAgentProtocolError: "Pi Coding Agent 返回了无效的 RPC 响应。请检查 Pi 版本和下方诊断详情。",
piAgentModelInvalid: "所选 Pi 模型标识无效。请刷新模型列表并选择 provider/model 条目。",
piAgentRunFailed: "Pi Coding Agent 异常退出。请根据错误代码和下方诊断详情确认失败的可执行文件或 CLI 输出。",
openCodeNotInstalled: "未找到 OpenCode CLI。请安装 OpenCode或在 设置 > AI 中填写其可执行文件路径。",
openCodeCliPathInvalid: "OpenCode CLI 路径无效。请只选择 OpenCode 可执行文件,环境变量需单独配置。",
openCodeEnvInvalid: "OpenCode CLI 环境变量名称无效。请使用 HTTPS_PROXY 这类合法名称。",
openCodeEnvReserved: "配置覆盖了由 OpenCode 或 DBX 管理的环境变量。请移除 OPENCODE 控制变量和 DBX_MCP_* 变量。",
openCodeNotAuthenticated: "OpenCode 尚未配置已认证的模型提供商。请执行 `opencode auth login` 或在 OpenCode 中配置提供商后重试。",
openCodeMcpStartupFailed: "OpenCode 无法启动受限范围的 DBX MCP Server。请检查 设置 > MCP 和下方诊断详情。",
openCodeTimeout: "OpenCode CLI 未能在操作超时前响应。",
openCodeProtocolError: "OpenCode CLI 返回了无效的 JSON 事件流。请检查 OpenCode 版本和下方诊断详情。",
openCodeRunFailed: "OpenCode CLI 异常退出。请根据错误代码和下方诊断详情确认失败的可执行文件或 CLI 输出。",
},
actions: {
general: "通用问答",

View File

@ -2101,6 +2101,15 @@ export default withEnglishFallback({
piAgentProtocolError: "Pi Coding Agent 傳回無效的 RPC 回應。請檢查 Pi 版本和下方診斷詳情。",
piAgentModelInvalid: "所選 Pi 模型識別碼無效。請重新整理模型清單並選擇 provider/model 項目。",
piAgentRunFailed: "Pi Coding Agent 異常結束。請依據錯誤代碼和下方診斷詳情確認失敗的可執行檔或 CLI 輸出。",
openCodeNotInstalled: "找不到 OpenCode CLI。請安裝 OpenCode或在 設定 > AI 中填寫其執行檔路徑。",
openCodeCliPathInvalid: "OpenCode CLI 路徑無效。請只選擇 OpenCode 執行檔,環境變數需另外設定。",
openCodeEnvInvalid: "OpenCode CLI 環境變數名稱無效。請使用 HTTPS_PROXY 這類合法名稱。",
openCodeEnvReserved: "設定覆寫了由 OpenCode 或 DBX 管理的環境變數。請移除 OPENCODE 控制變數和 DBX_MCP_* 變數。",
openCodeNotAuthenticated: "OpenCode 尚未設定已驗證的模型供應商。請執行 `opencode auth login` 或在 OpenCode 中設定供應商後重試。",
openCodeMcpStartupFailed: "OpenCode 無法啟動受限範圍的 DBX MCP Server。請檢查 設定 > MCP 和下方診斷詳情。",
openCodeTimeout: "OpenCode CLI 未能在操作逾時前回應。",
openCodeProtocolError: "OpenCode CLI 傳回了無效的 JSON 事件串流。請檢查 OpenCode 版本和下方診斷詳情。",
openCodeRunFailed: "OpenCode CLI 異常結束。請依據錯誤代碼和下方診斷詳情確認失敗的執行檔或 CLI 輸出。",
},
actions: {
general: "通用問答",

View File

@ -24,7 +24,7 @@ describe("isAiConfigModelCandidate", () => {
expect(isAiConfigModelCandidate(config({ apiKey: "" }), true)).toBe(false);
});
it.each(["codex-cli", "claude-code-cli", "pi-agent-cli"] as const)("keeps %s configs eligible without endpoint, API key, or model metadata", (provider) => {
it.each(["codex-cli", "claude-code-cli", "opencode-cli", "pi-agent-cli"] as const)("keeps %s configs eligible without endpoint, API key, or model metadata", (provider) => {
expect(
isAiConfigModelCandidate(
config({

View File

@ -21,11 +21,12 @@ describe("orderAiConfigsForDisplay", () => {
{ id: "ollama", provider: "ollama" },
{ id: "openai-compatible", provider: "openai-compatible" },
{ id: "codex", provider: "codex-cli" },
{ id: "opencode", provider: "opencode-cli" },
{ id: "pi", provider: "pi-agent-cli" },
{ id: "custom", provider: "custom" },
];
expect(orderAiConfigsForDisplay(configs).map((config) => config.id)).toEqual(["claude", "openai", "gemini", "deepseek", "qwen", "minimax", "ollama", "anthropic-compatible", "openai-compatible", "claude-code-1", "codex", "pi", "custom"]);
expect(orderAiConfigsForDisplay(configs).map((config) => config.id)).toEqual(["claude", "openai", "gemini", "deepseek", "qwen", "minimax", "ollama", "anthropic-compatible", "openai-compatible", "claude-code-1", "codex", "opencode", "pi", "custom"]);
});
it("preserves creation order for configs from the same provider", () => {

View File

@ -1,6 +1,6 @@
import type { AiConfig } from "@/types/ai";
const CLI_PROVIDERS = new Set<AiConfig["provider"]>(["codex-cli", "claude-code-cli", "pi-agent-cli"]);
const CLI_PROVIDERS = new Set<AiConfig["provider"]>(["codex-cli", "claude-code-cli", "opencode-cli", "pi-agent-cli"]);
export function isAiConfigModelCandidate(config: AiConfig, requiresApiKey: boolean): boolean {
// CLI providers resolve their model and credentials externally, so keep the existing eligibility bypass.

View File

@ -416,6 +416,22 @@ describe("settingsStore AI API key normalization", () => {
it("trims API keys when normalizing loaded configurations", () => {
expect(normalizeAiConfig({ provider: "openai", apiKey: " secret " }).apiKey).toBe("secret");
});
it("normalizes OpenCode CLI path and environment settings", () => {
expect(
normalizeAiConfig({
provider: "opencode-cli",
opencodeCliPath: " /opt/homebrew/bin/opencode ",
opencodeCliEnv: { HTTPS_PROXY: "http://127.0.0.1:7890", EMPTY: null as unknown as string },
}),
).toMatchObject({
provider: "opencode-cli",
endpoint: "",
model: "default",
opencodeCliPath: "/opt/homebrew/bin/opencode",
opencodeCliEnv: { HTTPS_PROXY: "http://127.0.0.1:7890", EMPTY: "" },
});
});
});
describe("settingsStore MCP policy persistence", () => {

View File

@ -232,6 +232,16 @@ export const AI_PROVIDER_PRESETS: Record<AiProvider, AiProviderPreset> = {
authMethod: "bearer",
requiresApiKey: false,
},
"opencode-cli": {
label: "OpenCode CLI",
iconSlug: "opencode",
provider: "opencode-cli",
endpoint: "",
model: "default",
apiStyle: "completions",
authMethod: "bearer",
requiresApiKey: false,
},
"pi-agent-cli": {
label: "Pi Coding Agent",
iconSlug: "pi",
@ -298,6 +308,8 @@ export function normalizeAiConfig(config: Partial<AiConfig> | null | undefined):
claudeCodeCliEnv: normalizeAiEnv(config?.claudeCodeCliEnv),
piAgentCliPath: config?.piAgentCliPath?.trim() || undefined,
piAgentCliEnv: normalizeAiEnv(config?.piAgentCliEnv),
opencodeCliPath: config?.opencodeCliPath?.trim() || undefined,
opencodeCliEnv: normalizeAiEnv(config?.opencodeCliEnv),
};
}
@ -1390,7 +1402,7 @@ export const useSettingsStore = defineStore("settings", () => {
const config = aiConfigs.value.find((c) => c.id === activeModel.value!.configId);
if (!config) return false;
const preset = AI_PROVIDER_PRESETS[config.provider];
if (config.provider === "codex-cli" || config.provider === "claude-code-cli" || config.provider === "pi-agent-cli") return true;
if (config.provider === "codex-cli" || config.provider === "claude-code-cli" || config.provider === "pi-agent-cli" || config.provider === "opencode-cli") return true;
return !!config.endpoint && !!activeModel.value!.modelId && (!preset.requiresApiKey || !!config.apiKey);
});

View File

@ -1,4 +1,4 @@
export type AiProvider = "claude" | "openai" | "gemini" | "deepseek" | "qwen" | "minimax" | "ollama" | "anthropic-compatible" | "openai-compatible" | "claude-code-cli" | "pi-agent-cli" | "codex-cli" | "custom";
export type AiProvider = "claude" | "openai" | "gemini" | "deepseek" | "qwen" | "minimax" | "ollama" | "anthropic-compatible" | "openai-compatible" | "claude-code-cli" | "pi-agent-cli" | "codex-cli" | "opencode-cli" | "custom";
export type AiApiStyle = "completions" | "responses" | "anthropic-messages";
export type AiAuthMethod = "api-key" | "bearer";
export type AiEffortLevel = "low" | "medium" | "high" | "xhigh" | "max";
@ -46,6 +46,8 @@ export interface AiConfig {
claudeCodeCliEnv?: Record<string, string>;
piAgentCliPath?: string | null;
piAgentCliEnv?: Record<string, string>;
opencodeCliPath?: string | null;
opencodeCliEnv?: Record<string, string>;
runtimeEffort?: AiEffortSelection | null;
}

View File

@ -139,7 +139,7 @@ pub async fn run_agent_loop(
let contract_system_prompt = augment_system_prompt_with_task_contract(system_prompt, task_contract, is_agent_mode);
let system_prompt = contract_system_prompt.as_str();
if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli) {
if crate::ai::is_cli_provider(&config.provider) {
let connection_name = {
let configs = agent_ctx.state.configs.read().await;
configs
@ -175,6 +175,14 @@ pub async fn run_agent_loop(
);
return crate::ai_pi_agent_cli::run_pi_agent(config, &prompt, options, cancelled, on_event).await;
}
if matches!(config.provider, AiProvider::OpenCodeCli) {
let prompt = crate::ai_opencode_cli::build_opencode_prompt(
system_prompt,
messages,
agent_ctx.sql_permissions.allow_writes,
);
return crate::ai_opencode_cli::run_opencode_agent(config, &prompt, options, cancelled, on_event).await;
}
let prompt =
crate::ai_codex_cli::build_codex_prompt(system_prompt, messages, agent_ctx.sql_permissions.allow_writes);
return crate::ai_codex_cli::run_codex_agent(config, &prompt, options, cancelled, on_event).await;

View File

@ -79,6 +79,8 @@ pub enum AiProvider {
ClaudeCodeCli,
#[serde(rename = "pi-agent-cli")]
PiAgentCli,
#[serde(rename = "opencode-cli")]
OpenCodeCli,
Custom,
}
@ -96,6 +98,7 @@ impl AiProvider {
AiProvider::OpenaiCompatible => "openai-compatible",
AiProvider::ClaudeCodeCli => "claude-code-cli",
AiProvider::PiAgentCli => "pi-agent-cli",
AiProvider::OpenCodeCli => "opencode-cli",
AiProvider::CodexCli => "codex-cli",
AiProvider::Custom => "custom",
}
@ -373,6 +376,10 @@ pub struct AiConfig {
pub pi_agent_cli_path: Option<String>,
#[serde(default)]
pub pi_agent_cli_env: HashMap<String, String>,
#[serde(default)]
pub opencode_cli_path: Option<String>,
#[serde(default)]
pub opencode_cli_env: HashMap<String, String>,
}
fn default_enable_thinking() -> bool {
@ -380,10 +387,13 @@ fn default_enable_thinking() -> bool {
}
/// Whether the provider is a CLI-based provider that goes through its own
/// executable (claude-code, codex, pi) rather than through `with_retry` /
/// executable (claude-code, codex, opencode, pi) rather than through `with_retry` /
/// `with_stream_retry`.
pub fn is_cli_provider(provider: &AiProvider) -> bool {
matches!(provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli)
matches!(
provider,
AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli | AiProvider::OpenCodeCli
)
}
/// Merge the global `max_retries` setting into an `AiConfig`.
@ -612,6 +622,7 @@ pub fn resolve_endpoint(config: &AiConfig) -> String {
| AiProvider::CodexCli
| AiProvider::ClaudeCodeCli
| AiProvider::PiAgentCli
| AiProvider::OpenCodeCli
| AiProvider::Gemini => unreachable!(),
}
}
@ -1064,7 +1075,7 @@ fn normalized_api_key(config: &AiConfig) -> &str {
fn validate_config(config: &AiConfig) -> Result<(), String> {
crate::ai_effort::validate_runtime_effort(config)?;
if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli) {
if is_cli_provider(&config.provider) {
return Ok(());
}
if provider_requires_api_key(&config.provider) && config.api_key.trim().is_empty() {
@ -1080,7 +1091,7 @@ fn validate_config(config: &AiConfig) -> Result<(), String> {
}
fn validate_model_list_config(config: &AiConfig) -> Result<(), String> {
if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli) {
if is_cli_provider(&config.provider) {
return Ok(());
}
if provider_requires_api_key(&config.provider) && config.api_key.trim().is_empty() {
@ -1459,6 +1470,7 @@ pub async fn list_models_core(config: &AiConfig) -> Result<Vec<AiModelInfo>, Str
AiProvider::CodexCli => crate::ai_codex_cli::list_codex_models(config).await?,
AiProvider::ClaudeCodeCli => crate::ai_claude_code_cli::list_claude_code_models(config).await?,
AiProvider::PiAgentCli => crate::ai_pi_agent_cli::list_pi_agent_models(config).await?,
AiProvider::OpenCodeCli => crate::ai_opencode_cli::list_opencode_models(config).await?,
_ => {
validate_model_list_config(config)?;
let client = build_ai_http_client(config, 30)?;
@ -1481,7 +1493,9 @@ pub async fn list_models_core(config: &AiConfig) -> Result<Vec<AiModelInfo>, Str
list_openai_compatible_models(&client, config).await?
}
}
AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli => unreachable!(),
AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli | AiProvider::OpenCodeCli => {
unreachable!()
}
}
}
};
@ -1500,6 +1514,10 @@ pub async fn resolve_model_effort_core(config: &AiConfig, model_id: &str) -> Res
return crate::ai_pi_agent_cli::resolve_pi_agent_model_effort(config, model_id).await;
}
if matches!(config.provider, AiProvider::OpenCodeCli) {
return crate::ai_opencode_cli::resolve_opencode_model_effort(config, model_id).await;
}
if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) {
let models = list_models_core(config).await?;
return Ok(models
@ -2028,6 +2046,9 @@ pub async fn test_connection_core(config: &AiConfig) -> Result<AiTestConnectionR
if matches!(config.provider, AiProvider::PiAgentCli) {
return crate::ai_pi_agent_cli::test_pi_agent_connection(config).await;
}
if matches!(config.provider, AiProvider::OpenCodeCli) {
return crate::ai_opencode_cli::test_opencode_connection(config).await;
}
let mut resolved_config = config.clone();
if resolved_config.model.trim().is_empty() {
let model = list_models_core(&resolved_config)
@ -2383,7 +2404,7 @@ where
pub async fn complete(request: &AiCompletionRequest) -> Result<String, String> {
validate_config(&request.config)?;
if matches!(request.config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli) {
if is_cli_provider(&request.config.provider) {
return Err("CLI providers are only supported in DBX AI agent mode".to_string());
}
@ -2400,7 +2421,9 @@ pub async fn complete(request: &AiCompletionRequest) -> Result<String, String> {
match request.config.provider {
AiProvider::Gemini => call_gemini(&client, request).await,
AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli => unreachable!(),
AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli | AiProvider::OpenCodeCli => {
unreachable!()
}
AiProvider::Openai
| AiProvider::Deepseek
| AiProvider::Qwen
@ -2439,7 +2462,7 @@ pub async fn stream(
) -> Result<(), String> {
validate_config(&request.config)?;
if matches!(request.config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli) {
if is_cli_provider(&request.config.provider) {
return Err("CLI providers are only supported in DBX AI agent mode".to_string());
}
@ -2452,7 +2475,9 @@ pub async fn stream(
match request.config.provider {
AiProvider::Gemini => stream_gemini(&client, session_id, request, cancelled, &on_chunk).await,
AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli => unreachable!(),
AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli | AiProvider::OpenCodeCli => {
unreachable!()
}
AiProvider::Openai
| AiProvider::Deepseek
| AiProvider::Qwen
@ -3736,7 +3761,7 @@ pub async fn stream_with_tools(
on_chunk: impl Fn(AiStreamChunk),
) -> Result<(Vec<crate::agent_events::ToolCall>, Option<TokenUsage>), String> {
validate_config(config)?;
if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli) {
if is_cli_provider(&config.provider) {
return Err("CLI providers are only supported through the DBX AI agent loop".to_string());
}
@ -4088,6 +4113,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
opencode_cli_path: None,
opencode_cli_env: Default::default(),
},
system_prompt: "Be concise.".to_string(),
messages: vec![AiMessage {
@ -4669,6 +4696,8 @@ mod tests {
assert!(config.codex_cli_env.is_empty());
assert!(config.pi_agent_cli_path.is_none());
assert!(config.pi_agent_cli_env.is_empty());
assert!(config.opencode_cli_path.is_none());
assert!(config.opencode_cli_env.is_empty());
}
#[test]
@ -4694,6 +4723,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
opencode_cli_path: None,
opencode_cli_env: Default::default(),
};
let err = build_ai_http_client(&config, 1).unwrap_err();
@ -4724,6 +4755,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
opencode_cli_path: None,
opencode_cli_env: Default::default(),
};
build_ai_http_client(&config, 1).unwrap();
@ -4752,6 +4785,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
opencode_cli_path: None,
opencode_cli_env: Default::default(),
};
build_ai_http_client(&config, 1).unwrap();
@ -4780,6 +4815,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
opencode_cli_path: None,
opencode_cli_env: Default::default(),
};
assert_eq!(
@ -4812,6 +4849,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
opencode_cli_path: None,
opencode_cli_env: Default::default(),
};
assert_eq!(resolve_endpoint(&ollama), "http://localhost:11434/v1/chat/completions");
@ -4841,6 +4880,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
opencode_cli_path: None,
opencode_cli_env: Default::default(),
};
for provider in
@ -4889,6 +4930,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
opencode_cli_path: None,
opencode_cli_env: Default::default(),
};
assert_eq!(resolve_model_list_endpoint(&openai).unwrap(), "https://api.openai.com/v1/models");
@ -4913,6 +4956,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
opencode_cli_path: None,
opencode_cli_env: Default::default(),
};
assert_eq!(resolve_model_list_endpoint(&claude).unwrap(), "https://api.anthropic.com/v1/models");
}
@ -4940,6 +4985,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
opencode_cli_path: None,
opencode_cli_env: Default::default(),
};
assert!(uses_anthropic_messages_api(&config));
@ -5019,6 +5066,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
opencode_cli_path: None,
opencode_cli_env: Default::default(),
};
assert!(!uses_anthropic_messages_api(&config));
@ -5058,6 +5107,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
opencode_cli_path: None,
opencode_cli_env: Default::default(),
};
assert_eq!(resolve_endpoint(&config), "https://api.example.com/v1/chat/completions");
assert_eq!(resolve_model_list_endpoint(&config).unwrap(), "https://api.example.com/v1/models");
@ -5121,6 +5172,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
opencode_cli_path: None,
opencode_cli_env: Default::default(),
};
assert_eq!(resolve_endpoint(&config), "https://api.openai.com/v1/responses");
@ -5156,6 +5209,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
opencode_cli_path: None,
opencode_cli_env: Default::default(),
};
let api_key_headers = claude_headers(&config).unwrap();
@ -5271,6 +5326,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
opencode_cli_path: None,
opencode_cli_env: Default::default(),
};
assert_eq!(resolve_ollama_show_endpoint(&config).unwrap(), "http://localhost:11434/api/show");
@ -5303,6 +5360,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
opencode_cli_path: None,
opencode_cli_env: Default::default(),
};
assert_eq!(ollama_selected_model_tool_support(&config).await.unwrap(), Some(true));
@ -5384,6 +5443,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
opencode_cli_path: None,
opencode_cli_env: Default::default(),
};
let models = vec![
AiModelInfo::new("qwen3:0.6b", None),
@ -5645,6 +5706,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
opencode_cli_path: None,
opencode_cli_env: Default::default(),
};
let mut body = serde_json::json!({
@ -5856,6 +5919,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
opencode_cli_path: None,
opencode_cli_env: Default::default(),
};
let mut body = serde_json::json!({
"model": &config.model,
@ -5893,6 +5958,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
opencode_cli_path: None,
opencode_cli_env: Default::default(),
};
let mut body = serde_json::json!({ "model": &config.model });
@ -5936,6 +6003,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
opencode_cli_path: None,
opencode_cli_env: Default::default(),
};
let mut body = serde_json::json!({ "model": &config.model });
@ -5973,6 +6042,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
opencode_cli_path: None,
opencode_cli_env: Default::default(),
};
let mut body = serde_json::json!({ "model": &config.model });
@ -6006,6 +6077,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
opencode_cli_path: None,
opencode_cli_env: Default::default(),
};
let mut body = serde_json::json!({ "model": &config.model });
@ -6070,6 +6143,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
opencode_cli_path: None,
opencode_cli_env: Default::default(),
};
let request = AiCompletionRequest {
config: config.clone(),
@ -6128,6 +6203,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
opencode_cli_path: None,
opencode_cli_env: Default::default(),
};
let mut body = serde_json::json!({
"model": &config.model,
@ -6654,7 +6731,9 @@ mod tests {
#[test]
fn merge_global_max_retries_skips_cli_providers() {
for provider in [AiProvider::CodexCli, AiProvider::ClaudeCodeCli, AiProvider::PiAgentCli] {
for provider in
[AiProvider::CodexCli, AiProvider::ClaudeCodeCli, AiProvider::PiAgentCli, AiProvider::OpenCodeCli]
{
let mut config = test_config(provider.clone());
config.max_retries = None;
merge_global_max_retries(&mut config, 0);

View File

@ -525,6 +525,7 @@ pub async fn run_claude_code_agent(
CliAgentProcessSpec {
command,
env,
env_remove: Vec::new(),
current_dir: Some(isolated_cwd.path.clone()),
stdin: Some(prompt.to_string()),
dialect: CliAgentJsonlDialect::ClaudeCodePrint,
@ -580,6 +581,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
opencode_cli_path: None,
opencode_cli_env: Default::default(),
}
}

View File

@ -34,11 +34,13 @@ pub struct CliAgentCommandSpec {
pub enum CliAgentJsonlDialect {
CodexExec,
ClaudeCodePrint,
OpenCodeRun,
}
pub struct CliAgentProcessSpec {
pub command: CliAgentCommandSpec,
pub env: Vec<(String, String)>,
pub env_remove: Vec<String>,
pub current_dir: Option<PathBuf>,
pub stdin: Option<String>,
pub dialect: CliAgentJsonlDialect,
@ -217,6 +219,7 @@ struct ParsedCliAgentEvent {
events: Vec<AgentEvent>,
final_text: Option<String>,
error: Option<String>,
usage_delta: Option<TokenUsage>,
}
pub fn parse_cli_jsonl_event(line: &str, dialect: CliAgentJsonlDialect) -> Option<Vec<AgentEvent>> {
@ -232,6 +235,7 @@ fn parse_cli_jsonl_line(line: &str, dialect: CliAgentJsonlDialect) -> ParsedCliA
match dialect {
CliAgentJsonlDialect::CodexExec => parse_codex_jsonl_line(line),
CliAgentJsonlDialect::ClaudeCodePrint => parse_claude_code_jsonl_line(line),
CliAgentJsonlDialect::OpenCodeRun => parse_open_code_jsonl_line(line),
}
}
@ -530,13 +534,108 @@ fn claude_code_error_message(value: &Value) -> String {
.to_string()
}
fn parse_open_code_jsonl_line(line: &str) -> ParsedCliAgentEvent {
let Ok(value) = serde_json::from_str::<Value>(line) else {
return ParsedCliAgentEvent::default();
};
match value.get("type").and_then(Value::as_str).unwrap_or_default() {
"text" => {
let Some(text) = value.pointer("/part/text").and_then(Value::as_str).filter(|text| !text.is_empty()) else {
return ParsedCliAgentEvent::default();
};
ParsedCliAgentEvent {
events: vec![AgentEvent::TextDelta { delta: text.to_string() }],
final_text: Some(text.to_string()),
..Default::default()
}
}
"reasoning" => {
let Some(text) = value.pointer("/part/text").and_then(Value::as_str).filter(|text| !text.is_empty()) else {
return ParsedCliAgentEvent::default();
};
ParsedCliAgentEvent {
events: vec![AgentEvent::ReasoningDelta { delta: text.to_string() }],
..Default::default()
}
}
"tool_use" => parse_open_code_tool(&value),
"step_finish" => {
let input = value.pointer("/part/tokens/input").and_then(Value::as_u64).unwrap_or(0) as u32;
let output = value.pointer("/part/tokens/output").and_then(Value::as_u64).unwrap_or(0) as u32;
ParsedCliAgentEvent {
usage_delta: (input > 0 || output > 0)
.then_some(TokenUsage { input_tokens: input, output_tokens: output }),
..Default::default()
}
}
"error" => {
let message = open_code_error_message(&value);
ParsedCliAgentEvent {
error: Some(message.clone()),
events: vec![AgentEvent::Error { message }],
..Default::default()
}
}
_ => ParsedCliAgentEvent::default(),
}
}
fn parse_open_code_tool(value: &Value) -> ParsedCliAgentEvent {
let part = &value["part"];
let state = &part["state"];
let status = state.get("status").and_then(Value::as_str).unwrap_or_default();
if status != "completed" && status != "error" {
return ParsedCliAgentEvent::default();
}
let tool_call_id = part
.get("callID")
.and_then(Value::as_str)
.or_else(|| part.get("call_id").and_then(Value::as_str))
.or_else(|| part.get("id").and_then(Value::as_str))
.unwrap_or("opencode-tool-call")
.to_string();
let tool_name = part.get("tool").and_then(Value::as_str).unwrap_or("opencode_tool").to_string();
let args = state.get("input").cloned().unwrap_or_else(|| Value::Object(Default::default()));
let result = state
.get("output")
.filter(|value| !value.is_null())
.or_else(|| state.get("error").filter(|value| !value.is_null()))
.cloned()
.unwrap_or(Value::Null);
ParsedCliAgentEvent {
events: vec![
AgentEvent::ToolCallStart { tool_call_id: tool_call_id.clone(), tool_name: tool_name.clone(), args },
AgentEvent::ToolCallEnd { tool_call_id, tool_name, result, is_error: status == "error" },
],
..Default::default()
}
}
fn open_code_error_message(value: &Value) -> String {
value
.pointer("/error/data/message")
.and_then(Value::as_str)
.or_else(|| value.pointer("/error/message").and_then(Value::as_str))
.or_else(|| value.get("message").and_then(Value::as_str))
.or_else(|| value.get("error").and_then(Value::as_str))
.unwrap_or("OpenCode CLI failed")
.to_string()
}
pub async fn run_cli_jsonl_agent(
spec: CliAgentProcessSpec,
cancelled: &Notify,
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
) -> Result<String, String> {
let mut command = cli_command(&spec.command.program);
command.args(&spec.command.args).envs(spec.env.iter().map(|(key, value)| (key.as_str(), value.as_str())));
command.args(&spec.command.args);
for key in &spec.env_remove {
command.env_remove(key);
}
command.envs(spec.env.iter().map(|(key, value)| (key.as_str(), value.as_str())));
if let Some(current_dir) = &spec.current_dir {
command.current_dir(current_dir);
}
@ -544,6 +643,7 @@ pub async fn run_cli_jsonl_agent(
.stdin(if spec.stdin.is_some() { Stdio::piped() } else { Stdio::null() })
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
.map_err(|e| (spec.classify_spawn_error)(&e.to_string()))?;
@ -565,6 +665,7 @@ pub async fn run_cli_jsonl_agent(
let mut final_text = String::new();
let mut saw_agent_end = false;
let mut total_usage = TokenUsage::default();
let mut terminal_error: Option<String> = None;
loop {
@ -583,6 +684,9 @@ pub async fn run_cli_jsonl_agent(
if let Some(text) = parsed.final_text {
final_text.push_str(&text);
}
if let Some(usage) = parsed.usage_delta {
total_usage.add(&usage);
}
for event in parsed.events {
if matches!(event, AgentEvent::AgentEnd { .. }) {
saw_agent_end = true;
@ -590,7 +694,7 @@ pub async fn run_cli_jsonl_agent(
on_event(event);
}
if let Some(error) = parsed.error {
terminal_error = Some(error);
terminal_error = Some((spec.classify_run_error)(&error));
let _ = child.kill().await;
break;
}
@ -615,7 +719,10 @@ pub async fn run_cli_jsonl_agent(
}
if !saw_agent_end {
on_event(AgentEvent::AgentEnd { input_tokens: None, output_tokens: None });
on_event(AgentEvent::AgentEnd {
input_tokens: (total_usage.input_tokens > 0).then_some(total_usage.input_tokens),
output_tokens: (total_usage.output_tokens > 0).then_some(total_usage.output_tokens),
});
}
Ok(final_text)
@ -625,6 +732,7 @@ pub async fn run_cli_jsonl_agent(
mod tests {
use super::*;
use std::process::Command as StdCommand;
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::time::{sleep, timeout, Duration};
@ -657,6 +765,7 @@ mod tests {
],
},
env: vec![("DBX_TEST_ENV".to_string(), "from-env".to_string())],
env_remove: Vec::new(),
current_dir: None,
stdin: None,
dialect: CliAgentJsonlDialect::CodexExec,
@ -680,6 +789,7 @@ mod tests {
],
},
env: Vec::new(),
env_remove: Vec::new(),
current_dir: None,
stdin: Some("prompt from stdin".to_string()),
dialect: CliAgentJsonlDialect::CodexExec,
@ -692,6 +802,47 @@ mod tests {
assert_eq!(result, "prompt from stdin");
}
#[tokio::test]
async fn opencode_agent_aggregates_step_usage_and_emits_one_agent_end() {
let spec = CliAgentProcessSpec {
command: CliAgentCommandSpec {
program: "sh".to_string(),
args: vec![
"-c".to_string(),
concat!(
"printf '%s\\n' ",
"'{\"type\":\"step_finish\",\"part\":{\"tokens\":{\"input\":10,\"output\":2}}}' ",
"'{\"type\":\"text\",\"part\":{\"text\":\"hello\"}}' ",
"'{\"type\":\"step_finish\",\"part\":{\"tokens\":{\"input\":3,\"output\":4}}}'",
)
.to_string(),
],
},
env: Vec::new(),
env_remove: Vec::new(),
current_dir: None,
stdin: None,
dialect: CliAgentJsonlDialect::OpenCodeRun,
classify_spawn_error,
classify_run_error,
};
let events = Arc::new(Mutex::new(Vec::new()));
let captured = Arc::clone(&events);
let result = run_cli_jsonl_agent(spec, &Notify::new(), move |event| {
captured.lock().unwrap().push(event);
})
.await
.unwrap();
assert_eq!(result, "hello");
let events = events.lock().unwrap();
assert_eq!(events.iter().filter(|event| matches!(event, AgentEvent::AgentEnd { .. })).count(), 1);
assert!(events
.iter()
.any(|event| { matches!(event, AgentEvent::AgentEnd { input_tokens: Some(13), output_tokens: Some(6) }) }));
}
#[tokio::test]
async fn jsonl_error_kills_and_waits_for_child() {
let pid_file = std::env::temp_dir().join(format!(
@ -707,6 +858,7 @@ mod tests {
let spec = CliAgentProcessSpec {
command: CliAgentCommandSpec { program: "sh".to_string(), args: vec!["-c".to_string(), script] },
env: Vec::new(),
env_remove: Vec::new(),
current_dir: None,
stdin: None,
dialect: CliAgentJsonlDialect::CodexExec,

View File

@ -970,6 +970,7 @@ pub async fn run_codex_agent(
CliAgentProcessSpec {
command,
env,
env_remove: Vec::new(),
current_dir: None,
stdin: Some(prompt.to_string()),
dialect: CliAgentJsonlDialect::CodexExec,
@ -1037,6 +1038,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
opencode_cli_path: None,
opencode_cli_env: Default::default(),
}
}

View File

@ -82,7 +82,11 @@ pub fn static_effort_capability(config: &AiConfig, model_id: &str) -> Option<AiE
AiProvider::AnthropicCompatible | AiProvider::OpenaiCompatible | AiProvider::Custom => {
Some(AiEffortCapability::FreeText { placeholder: None, source: AiCapabilitySource::Custom })
}
AiProvider::Claude | AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli => None,
AiProvider::Claude
| AiProvider::CodexCli
| AiProvider::ClaudeCodeCli
| AiProvider::PiAgentCli
| AiProvider::OpenCodeCli => None,
}
}
@ -187,6 +191,7 @@ pub fn registry_source_url(provider: &AiProvider) -> Option<&'static str> {
| AiProvider::CodexCli
| AiProvider::ClaudeCodeCli
| AiProvider::PiAgentCli
| AiProvider::OpenCodeCli
| AiProvider::Custom => None,
}
}
@ -201,7 +206,11 @@ pub fn validate_runtime_effort(config: &AiConfig) -> Result<(), String> {
if matches!(
config.provider,
AiProvider::Claude | AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli
AiProvider::Claude
| AiProvider::CodexCli
| AiProvider::ClaudeCodeCli
| AiProvider::PiAgentCli
| AiProvider::OpenCodeCli
) {
return match selection {
AiEffortSelection::Enum(value) if !value.trim().is_empty() => Ok(()),
@ -254,7 +263,7 @@ pub fn apply_runtime_effort(body: &mut Value, config: &AiConfig) {
apply_openai_effort(object, &config.api_style, selection);
}
}
AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli => {}
AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli | AiProvider::OpenCodeCli => {}
}
}
@ -408,6 +417,8 @@ mod tests {
claude_code_cli_env: HashMap::new(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
opencode_cli_path: None,
opencode_cli_env: Default::default(),
}
}

View File

@ -96,6 +96,7 @@ pub(crate) fn model_is_assistant_compatible(provider: &AiProvider, model_id: &st
| AiProvider::CodexCli
| AiProvider::ClaudeCodeCli
| AiProvider::PiAgentCli
| AiProvider::OpenCodeCli
| AiProvider::MiniMax
| AiProvider::Custom => true,
}

View File

@ -0,0 +1,675 @@
use crate::agent_events::AgentEvent;
use crate::ai::{AiCapabilitySource, AiConfig, AiEffortCapability, AiModelInfo, AiTestConnectionResult};
use crate::ai_cli_agent::{
build_cli_agent_prompt, cli_command, dbx_mcp_scope_env, parse_cli_jsonl_event, run_cli_jsonl_agent,
CliAgentCommandSpec, CliAgentJsonlDialect, CliAgentProcessSpec, CliAgentRunOptions,
};
use serde_json::{json, Map, Value};
use std::collections::{BTreeMap, BTreeSet};
use std::env;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use tokio::sync::Notify;
const OPENCODE_MODEL_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(10);
const OPENCODE_CONTROL_ENV: &[&str] = &[
"OPENCODE_CONFIG",
"OPENCODE_CONFIG_CONTENT",
"OPENCODE_CONFIG_DIR",
"OPENCODE_DB",
"OPENCODE_PERMISSION",
"OPENCODE_DISABLE_PROJECT_CONFIG",
];
pub type OpenCodeRunOptions = CliAgentRunOptions;
pub type OpenCodeCommandSpec = CliAgentCommandSpec;
struct OpenCodeIsolatedCwd {
path: PathBuf,
}
impl OpenCodeIsolatedCwd {
fn create() -> Result<Self, String> {
let path = env::temp_dir().join(format!("dbx-opencode-{}", uuid::Uuid::new_v4()));
std::fs::create_dir(&path)
.map_err(|error| format!("[openCodeRunFailed] Failed to create isolated OpenCode directory: {error}"))?;
Ok(Self { path })
}
}
impl Drop for OpenCodeIsolatedCwd {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
fn opencode_program(config: &AiConfig) -> String {
config
.opencode_cli_path
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("opencode")
.to_string()
}
fn resolve_opencode_command(config: &AiConfig) -> Result<OpenCodeCommandSpec, String> {
let program = opencode_program(config);
if starts_with_env_assignment(&program) {
return Err("[openCodeCliPathInvalid] OpenCode CLI path should contain only the executable path. Add environment variables in the OpenCode CLI environment variables section.".to_string());
}
let program = if is_path_like_program(&program) { crate::path_utils::expand_tilde(&program) } else { program };
let path = Path::new(&program);
if path.is_dir() {
return launchable_program_in_dir(path, "opencode")
.map(opencode_command_for_program)
.ok_or_else(|| {
"[openCodeCliPathInvalid] OpenCode CLI path should point to the opencode executable or a directory containing opencode."
.to_string()
});
}
if is_path_like_program(&program) && !path.is_file() {
return Err("[openCodeCliPathInvalid] OpenCode CLI executable does not exist.".to_string());
}
Ok(opencode_command_for_program(program))
}
fn opencode_command_for_program(program: String) -> OpenCodeCommandSpec {
#[cfg(windows)]
if let Some(command) = windows_npm_opencode_shim_command(&program) {
return command;
}
OpenCodeCommandSpec { program, args: Vec::new() }
}
#[cfg(windows)]
fn windows_npm_opencode_shim_command(program: &str) -> Option<OpenCodeCommandSpec> {
let path = Path::new(program);
let extension = path.extension()?.to_str()?.to_ascii_lowercase();
if extension != "cmd" && extension != "bat" {
return None;
}
let executable = path.parent()?.join("node_modules").join("opencode-ai").join("bin").join("opencode.exe");
executable
.is_file()
.then(|| OpenCodeCommandSpec { program: executable.to_string_lossy().to_string(), args: Vec::new() })
}
fn launchable_program_in_dir(dir: &Path, program: &str) -> Option<String> {
program_path_candidates(dir, program)
.into_iter()
.find(|candidate| candidate.is_file())
.map(|path| path.to_string_lossy().to_string())
}
#[cfg(not(windows))]
fn program_path_candidates(dir: &Path, program: &str) -> Vec<PathBuf> {
vec![dir.join(program)]
}
#[cfg(windows)]
fn program_path_candidates(dir: &Path, program: &str) -> Vec<PathBuf> {
[".exe", ".cmd", ".bat", ""].iter().map(|extension| dir.join(format!("{program}{extension}"))).collect()
}
fn is_path_like_program(program: &str) -> bool {
program.contains('/') || program.contains('\\') || program.starts_with('~')
}
fn starts_with_env_assignment(program: &str) -> bool {
let Some(first_token) = program.split_whitespace().next() else {
return false;
};
let Some((key, _)) = first_token.split_once('=') else {
return false;
};
is_env_var_name(key)
}
fn is_env_var_name(name: &str) -> bool {
let mut chars = name.chars();
let Some(first) = chars.next() else {
return false;
};
(first == '_' || first.is_ascii_alphabetic()) && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
}
fn is_reserved_env_name(name: &str) -> bool {
let upper = name.to_ascii_uppercase();
upper.starts_with("DBX_MCP_") || OPENCODE_CONTROL_ENV.iter().any(|reserved| upper == *reserved)
}
pub fn opencode_cli_env(config: &AiConfig) -> Result<Vec<(String, String)>, String> {
let mut env = BTreeMap::new();
for (key, value) in &config.opencode_cli_env {
let key = key.trim();
if key.is_empty() {
continue;
}
if !is_env_var_name(key) {
return Err(format!(
"[openCodeEnvInvalid] Invalid OpenCode CLI environment variable name `{key}`. Use names like HTTPS_PROXY."
));
}
if is_reserved_env_name(key) {
return Err(format!(
"[openCodeEnvReserved] `{key}` is managed by DBX for the isolated OpenCode session and cannot be set here."
));
}
env.insert(key.to_string(), value.clone());
}
Ok(env.into_iter().collect())
}
fn opencode_process_env(config: &AiConfig, runtime_config: Value) -> Result<Vec<(String, String)>, String> {
let mut env = BTreeMap::from_iter(opencode_cli_env(config)?);
env.insert("OPENCODE_DB".to_string(), ":memory:".to_string());
env.insert("OPENCODE_DISABLE_PROJECT_CONFIG".to_string(), "1".to_string());
env.insert("OPENCODE_CONFIG_CONTENT".to_string(), runtime_config.to_string());
Ok(env.into_iter().collect())
}
fn opencode_runtime_config(options: Option<&OpenCodeRunOptions>) -> Value {
let mut config = Map::new();
config.insert("permission".to_string(), json!({ "*": "deny", "dbx_*": "allow" }));
if let Some(options) = options {
let command = options
.mcp_server_command
.as_ref()
.cloned()
.unwrap_or_else(|| CliAgentCommandSpec { program: "dbx-mcp-server".to_string(), args: Vec::new() });
let mut command_parts = vec![command.program];
command_parts.extend(command.args);
let environment = dbx_mcp_scope_env(options)
.into_iter()
.map(|(name, value)| (name.to_string(), Value::String(value)))
.collect::<Map<_, _>>();
config.insert(
"mcp".to_string(),
json!({
"dbx": {
"type": "local",
"command": command_parts,
"enabled": true,
"environment": environment
}
}),
);
}
Value::Object(config)
}
fn opencode_selection_args(config: &AiConfig) -> Vec<String> {
let mut args = Vec::new();
let model = config.model.trim();
if !model.is_empty() && !model.eq_ignore_ascii_case("default") {
args.extend(["--model".to_string(), model.to_string()]);
}
if let Some(variant) = config.runtime_effort.as_ref().and_then(|selection| selection.cli_value()) {
args.extend(["--variant".to_string(), variant]);
}
args
}
pub fn build_opencode_command(config: &AiConfig) -> OpenCodeCommandSpec {
let mut command = OpenCodeCommandSpec { program: opencode_program(config), args: Vec::new() };
command.args.extend(["run".to_string(), "--format".to_string(), "json".to_string(), "--pure".to_string()]);
command.args.extend(opencode_selection_args(config));
command
}
fn resolved_models_command(config: &AiConfig) -> Result<OpenCodeCommandSpec, String> {
let mut command = resolve_opencode_command(config)?;
command.args.extend(["models".to_string(), "--verbose".to_string(), "--pure".to_string()]);
Ok(command)
}
fn resolved_run_command(config: &AiConfig) -> Result<OpenCodeCommandSpec, String> {
let resolved = resolve_opencode_command(config)?;
let mut command = build_opencode_command(config);
command.program = resolved.program;
command.args.splice(0..0, resolved.args);
Ok(command)
}
pub fn build_opencode_prompt(system_prompt: &str, messages: &[crate::ai::AiMessage], allow_write_sql: bool) -> String {
build_cli_agent_prompt("OpenCode", system_prompt, messages, allow_write_sql)
}
pub async fn list_opencode_models(config: &AiConfig) -> Result<Vec<AiModelInfo>, String> {
let command = resolved_models_command(config)?;
let runtime = OpenCodeIsolatedCwd::create()?;
let mut process = cli_command(&command.program);
process.args(&command.args);
for key in OPENCODE_CONTROL_ENV {
process.env_remove(key);
}
process
.envs(opencode_process_env(config, opencode_runtime_config(None))?)
.current_dir(&runtime.path)
.kill_on_drop(true);
let output = tokio::time::timeout(OPENCODE_MODEL_DISCOVERY_TIMEOUT, process.output())
.await
.map_err(|_| "[openCodeTimeout] OpenCode model discovery timed out".to_string())?
.map_err(|error| classify_opencode_spawn_error(&error.to_string()))?;
if !output.status.success() {
return Err(classify_opencode_run_error(&combined_output(&output.stderr, &output.stdout)));
}
parse_opencode_models(&String::from_utf8_lossy(&output.stdout)).ok_or_else(|| {
"[openCodeNotAuthenticated] OpenCode returned no configured models. Sign in or configure a provider in OpenCode, then retry."
.to_string()
})
}
fn parse_opencode_models(stdout: &str) -> Option<Vec<AiModelInfo>> {
let mut models = Vec::new();
let mut seen = BTreeSet::new();
let mut pending_id: Option<String> = None;
let mut json_buffer = String::new();
for line in stdout.lines() {
let trimmed = line.trim();
if json_buffer.is_empty() {
if trimmed.starts_with('{') {
json_buffer.push_str(line);
json_buffer.push('\n');
} else if !trimmed.is_empty() {
pending_id = Some(trimmed.to_string());
}
} else {
json_buffer.push_str(line);
json_buffer.push('\n');
}
if json_buffer.is_empty() {
continue;
}
match serde_json::from_str::<Value>(&json_buffer) {
Ok(metadata) => {
if let Some(info) = opencode_model_info(pending_id.take(), &metadata) {
if seen.insert(info.id.clone()) {
models.push(info);
}
}
json_buffer.clear();
}
Err(error) if error.is_eof() => {}
Err(_) => {
json_buffer.clear();
pending_id = None;
}
}
}
if models.is_empty() {
return None;
}
if seen.insert("default".to_string()) {
models.insert(0, AiModelInfo::new("default", Some("Default".to_string())));
}
Some(models)
}
fn opencode_model_info(reported_id: Option<String>, metadata: &Value) -> Option<AiModelInfo> {
let id = reported_id.or_else(|| {
let provider = metadata.get("providerID").and_then(Value::as_str)?;
let model = metadata.get("id").and_then(Value::as_str)?;
Some(format!("{provider}/{model}"))
})?;
let id = id.trim();
if id.is_empty() || !id.contains('/') {
return None;
}
let display_name = metadata
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|name| !name.is_empty())
.map(ToString::to_string);
let variants = metadata
.get("variants")
.and_then(Value::as_object)
.map(|variants| variants.keys().map(String::as_str).collect::<Vec<_>>())
.unwrap_or_default();
let mut info = AiModelInfo::new(id, display_name);
info.supported_effort_levels = variants.iter().filter_map(|variant| variant.parse().ok()).collect();
info.effort_capability = crate::ai_effort::dynamic_enum_capability(variants, AiCapabilitySource::LocalCli);
Some(info)
}
pub async fn resolve_opencode_model_effort(config: &AiConfig, model_id: &str) -> Result<AiEffortCapability, String> {
Ok(list_opencode_models(config)
.await?
.into_iter()
.find(|model| model.id == model_id)
.and_then(|model| model.effort_capability)
.unwrap_or(AiEffortCapability::Unsupported))
}
pub async fn test_opencode_connection(config: &AiConfig) -> Result<AiTestConnectionResult, String> {
let start = Instant::now();
list_opencode_models(config).await?;
Ok(AiTestConnectionResult {
success: true,
message: format!("OK - {}ms", start.elapsed().as_millis()),
latency_ms: Some(start.elapsed().as_millis() as u64),
model_used: config.model.trim().to_string(),
error_category: None,
})
}
fn combined_output(stderr: &[u8], stdout: &[u8]) -> String {
let stderr = String::from_utf8_lossy(stderr);
let stdout = String::from_utf8_lossy(stdout);
[stderr.trim(), stdout.trim()].into_iter().filter(|part| !part.is_empty()).collect::<Vec<_>>().join("\n")
}
fn classify_opencode_spawn_error(message: &str) -> String {
let lower = message.to_ascii_lowercase();
if lower.contains("no such file") || lower.contains("not found") || lower.contains("cannot find") {
format!("[openCodeNotInstalled] {message}")
} else {
format!("[openCodeRunFailed] {message}")
}
}
fn classify_opencode_run_error(message: &str) -> String {
if message.starts_with("[openCode") || message.starts_with("[dbxMcpMissing]") {
return message.to_string();
}
let lower = message.to_ascii_lowercase();
if lower.contains("not authenticated")
|| lower.contains("authentication required")
|| lower.contains("unauthorized")
|| lower.contains("please login")
|| lower.contains("please sign in")
{
format!("[openCodeNotAuthenticated] {message}")
} else if lower.contains("dbx-mcp-server") || lower.contains("enoent") {
format!("[dbxMcpMissing] {message}")
} else if lower.contains("mcp") && (lower.contains("dbx") || lower.contains("server")) {
format!("[openCodeMcpStartupFailed] {message}")
} else if lower.contains("json") || lower.contains("protocol") {
format!("[openCodeProtocolError] {message}")
} else {
format!("[openCodeRunFailed] {message}")
}
}
pub fn parse_opencode_jsonl_event(line: &str) -> Option<Vec<AgentEvent>> {
parse_cli_jsonl_event(line, CliAgentJsonlDialect::OpenCodeRun)
}
pub async fn run_opencode_agent(
config: &AiConfig,
prompt: &str,
options: OpenCodeRunOptions,
cancelled: &Notify,
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
) -> Result<String, String> {
let runtime = OpenCodeIsolatedCwd::create()?;
let result = run_cli_jsonl_agent(
CliAgentProcessSpec {
command: resolved_run_command(config)?,
env: opencode_process_env(config, opencode_runtime_config(Some(&options)))?,
env_remove: OPENCODE_CONTROL_ENV.iter().map(|value| (*value).to_string()).collect(),
current_dir: Some(runtime.path.clone()),
stdin: Some(prompt.to_string()),
dialect: CliAgentJsonlDialect::OpenCodeRun,
classify_spawn_error: classify_opencode_spawn_error,
classify_run_error: classify_opencode_run_error,
},
cancelled,
on_event,
)
.await?;
if result.trim().is_empty() {
Err("[openCodeProtocolError] OpenCode completed without a text response".to_string())
} else {
Ok(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ai::{AiApiStyle, AiAuthMethod, AiEffortSelection, AiProvider, AiReasoningLevel};
use std::sync::{Arc, Mutex};
fn config(model: &str) -> AiConfig {
AiConfig {
provider: AiProvider::OpenCodeCli,
api_key: String::new(),
auth_method: AiAuthMethod::Bearer,
endpoint: String::new(),
model: model.to_string(),
models: Vec::new(),
api_style: AiApiStyle::Completions,
proxy_enabled: false,
proxy_url: String::new(),
enable_thinking: true,
reasoning_level: AiReasoningLevel::Default,
runtime_effort: None,
context_window: None,
max_retries: None,
codex_cli_path: None,
codex_cli_env: Default::default(),
claude_code_cli_path: None,
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
opencode_cli_path: None,
opencode_cli_env: Default::default(),
}
}
fn options() -> OpenCodeRunOptions {
OpenCodeRunOptions {
connection_id: "conn-1".to_string(),
connection_name: "Local".to_string(),
database: "demo".to_string(),
schema: Some("public".to_string()),
agent_mode: true,
allow_writes: false,
allow_dangerous: false,
confirmed_write_sql: None,
mcp_server_command: Some(CliAgentCommandSpec {
program: "/opt/dbx/dbx-mcp-server".to_string(),
args: vec!["--stdio".to_string()],
}),
}
}
fn live_config() -> AiConfig {
let mut config = config(
&std::env::var("DBX_LIVE_OPENCODE_MODEL")
.expect("set DBX_LIVE_OPENCODE_MODEL to an authenticated provider/model"),
);
config.opencode_cli_path = std::env::var("DBX_LIVE_OPENCODE_PATH").ok();
config
}
#[test]
fn builds_run_command_with_model_and_variant() {
let mut config = config("openai/gpt-5.4");
config.runtime_effort = Some(AiEffortSelection::Enum("high".to_string()));
let command = build_opencode_command(&config);
assert_eq!(command.program, "opencode");
assert_eq!(
command.args,
["run", "--format", "json", "--pure", "--model", "openai/gpt-5.4", "--variant", "high"]
);
}
#[test]
fn default_model_and_effort_are_omitted() {
let command = build_opencode_command(&config("default"));
assert_eq!(command.args, ["run", "--format", "json", "--pure"]);
}
#[test]
fn connection_check_uses_model_discovery_without_a_model_request() {
let command = resolved_models_command(&config("default")).unwrap();
assert_eq!(command.program, "opencode");
assert_eq!(command.args, ["models", "--verbose", "--pure"]);
}
#[test]
fn runtime_config_scopes_mcp_and_denies_other_tools() {
let runtime = opencode_runtime_config(Some(&options()));
assert_eq!(runtime.pointer("/permission/*").and_then(Value::as_str), Some("deny"));
assert_eq!(runtime.pointer("/permission/dbx_*").and_then(Value::as_str), Some("allow"));
assert_eq!(runtime.pointer("/mcp/dbx/command/0").and_then(Value::as_str), Some("/opt/dbx/dbx-mcp-server"));
assert_eq!(runtime.pointer("/mcp/dbx/command/1").and_then(Value::as_str), Some("--stdio"));
assert_eq!(
runtime.pointer("/mcp/dbx/environment/DBX_MCP_SCOPE_CONNECTION_ID").and_then(Value::as_str),
Some("conn-1")
);
assert_eq!(
runtime.pointer("/mcp/dbx/environment/DBX_MCP_SCOPE_SCHEMA").and_then(Value::as_str),
Some("public")
);
}
#[test]
fn rejects_reserved_or_invalid_environment_names() {
let mut invalid = config("default");
invalid.opencode_cli_env.insert("BAD-NAME".to_string(), "1".to_string());
assert!(opencode_cli_env(&invalid).unwrap_err().starts_with("[openCodeEnvInvalid]"));
let mut reserved = config("default");
reserved.opencode_cli_env.insert("OPENCODE_DB".to_string(), "file.db".to_string());
assert!(opencode_cli_env(&reserved).unwrap_err().starts_with("[openCodeEnvReserved]"));
reserved.opencode_cli_env.clear();
reserved.opencode_cli_env.insert("DBX_MCP_ALLOW_WRITES".to_string(), "1".to_string());
assert!(opencode_cli_env(&reserved).unwrap_err().starts_with("[openCodeEnvReserved]"));
}
#[test]
fn parses_verbose_models_and_preserves_variant_order() {
let stdout = concat!(
"openai/gpt-5.4\n",
"{\n",
" \"id\": \"gpt-5.4\",\n",
" \"providerID\": \"openai\",\n",
" \"name\": \"GPT-5.4\",\n",
" \"variants\": {\"none\": {}, \"low\": {}, \"high\": {}, \"future\": {}}\n",
"}\n",
"custom/model\n",
"{\"id\":\"model\",\"providerID\":\"custom\",\"name\":\"Custom\",\"variants\":{}}\n"
);
let models = parse_opencode_models(stdout).unwrap();
assert_eq!(
models.iter().map(|model| model.id.as_str()).collect::<Vec<_>>(),
["default", "openai/gpt-5.4", "custom/model"]
);
assert_eq!(models[1].display_name.as_deref(), Some("GPT-5.4"));
let AiEffortCapability::Enum { options, .. } = models[1].effort_capability.as_ref().unwrap() else {
panic!("expected dynamic effort options");
};
assert_eq!(
options.iter().map(|option| option.id.as_str()).collect::<Vec<_>>(),
["none", "low", "high", "future"]
);
assert!(models[2].effort_capability.is_none());
}
#[test]
fn parses_text_reasoning_tool_error_and_usage_events() {
let text = parse_opencode_jsonl_event(r#"{"type":"text","part":{"type":"text","text":"hello"}}"#).unwrap();
assert!(matches!(&text[0], AgentEvent::TextDelta { delta } if delta == "hello"));
let reasoning =
parse_opencode_jsonl_event(r#"{"type":"reasoning","part":{"type":"reasoning","text":"thinking"}}"#)
.unwrap();
assert!(matches!(&reasoning[0], AgentEvent::ReasoningDelta { delta } if delta == "thinking"));
let tool = parse_opencode_jsonl_event(
r#"{"type":"tool_use","part":{"id":"part-1","callID":"call-1","tool":"dbx_dbx_list_connections","state":{"status":"completed","input":{},"output":"ok"}}}"#,
)
.unwrap();
assert_eq!(tool.len(), 2);
assert!(
matches!(&tool[0], AgentEvent::ToolCallStart { tool_call_id, tool_name, .. } if tool_call_id == "call-1" && tool_name == "dbx_dbx_list_connections")
);
assert!(matches!(&tool[1], AgentEvent::ToolCallEnd { result, is_error: false, .. } if result == "ok"));
let error = parse_opencode_jsonl_event(
r#"{"type":"error","error":{"name":"UnknownError","data":{"message":"bad model"}}}"#,
)
.unwrap();
assert!(matches!(&error[0], AgentEvent::Error { message } if message == "bad model"));
assert!(
parse_opencode_jsonl_event(r#"{"type":"step_finish","part":{"tokens":{"input":10,"output":3}}}"#).is_none()
);
}
#[tokio::test]
#[ignore = "requires an installed, authenticated OpenCode CLI and DBX_LIVE_OPENCODE_MODEL"]
async fn live_model_discovery_and_connection_test() {
let config = live_config();
let models = list_opencode_models(&config).await.unwrap();
assert!(models.iter().any(|model| model.id == config.model));
let result = test_opencode_connection(&config).await.unwrap();
assert!(result.success);
assert!(result.latency_ms.is_some());
}
#[tokio::test]
#[ignore = "requires OpenCode, a running DBX MCP bridge, and DBX_LIVE_MCP_CONNECTION_NAME/DATABASE"]
async fn live_scoped_dbx_mcp_agent_run() {
let config = live_config();
let connection_name = std::env::var("DBX_LIVE_MCP_CONNECTION_NAME")
.expect("set DBX_LIVE_MCP_CONNECTION_NAME to a saved DBX connection");
let database = std::env::var("DBX_LIVE_MCP_DATABASE").expect("set DBX_LIVE_MCP_DATABASE to a visible database");
let mcp_command = std::env::var("DBX_LIVE_MCP_COMMAND").unwrap_or_else(|_| "dbx-mcp-server".to_string());
let options = OpenCodeRunOptions {
connection_id: String::new(),
connection_name: connection_name.clone(),
database,
schema: None,
agent_mode: false,
allow_writes: false,
allow_dangerous: false,
confirmed_write_sql: None,
mcp_server_command: Some(CliAgentCommandSpec { program: mcp_command, args: Vec::new() }),
};
let events = Arc::new(Mutex::new(Vec::new()));
let captured = Arc::clone(&events);
let result = tokio::time::timeout(
Duration::from_secs(90),
run_opencode_agent(
&config,
"You must use the DBX MCP list-connections tool. Reply with the visible connection name only.",
options,
&Notify::new(),
move |event| captured.lock().unwrap().push(event),
),
)
.await
.expect("OpenCode MCP smoke test timed out")
.unwrap();
assert!(result.contains(&connection_name));
assert!(events.lock().unwrap().iter().any(|event| {
matches!(event, AgentEvent::ToolCallStart { tool_name, .. } if tool_name.contains("dbx_list_connections"))
}));
}
}

View File

@ -755,6 +755,8 @@ mod tests {
claude_code_cli_env: HashMap::new(),
pi_agent_cli_path: None,
pi_agent_cli_env: HashMap::new(),
opencode_cli_path: None,
opencode_cli_env: HashMap::new(),
}
}

View File

@ -1416,6 +1416,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
opencode_cli_path: None,
opencode_cli_env: Default::default(),
},
}
}
@ -2238,7 +2240,11 @@ mod tests {
async fn sensitive_payload_ai_configs_some_saves_configs() {
let storage = Storage::open(&temp_db_path("ai-cfg-some")).await.unwrap();
let cfg = make_test_config("synced", true);
let mut cfg = make_test_config("synced", true);
cfg.config.provider = crate::ai::AiProvider::OpenCodeCli;
cfg.config.model = "openai/gpt-5.4-mini".to_string();
cfg.config.opencode_cli_path = Some("/opt/homebrew/bin/opencode".to_string());
cfg.config.opencode_cli_env.insert("HTTPS_PROXY".to_string(), "http://127.0.0.1:7890".to_string());
let payload = SensitiveSyncPayload {
connection_secrets: vec![],
ai_configs: Some(vec![cfg]),
@ -2249,6 +2255,13 @@ mod tests {
let loaded = storage.load_ai_configs().await.unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].name, "synced");
assert!(matches!(loaded[0].config.provider, crate::ai::AiProvider::OpenCodeCli));
assert_eq!(loaded[0].config.model, "openai/gpt-5.4-mini");
assert_eq!(loaded[0].config.opencode_cli_path.as_deref(), Some("/opt/homebrew/bin/opencode"));
assert_eq!(
loaded[0].config.opencode_cli_env.get("HTTPS_PROXY").map(String::as_str),
Some("http://127.0.0.1:7890")
);
}
#[tokio::test]

View File

@ -15,6 +15,7 @@ pub mod ai_cli_agent;
pub mod ai_codex_cli;
pub mod ai_effort;
mod ai_model_filter;
pub mod ai_opencode_cli;
pub mod ai_pi_agent_cli;
pub mod backend_error;
pub mod changelog;

View File

@ -5467,6 +5467,8 @@ mod tests {
claude_code_cli_env: std::collections::HashMap::new(),
pi_agent_cli_path: None,
pi_agent_cli_env: std::collections::HashMap::new(),
opencode_cli_path: None,
opencode_cli_env: std::collections::HashMap::new(),
},
}
}
@ -5504,6 +5506,33 @@ mod tests {
std::fs::remove_file(&db).ok();
}
#[tokio::test]
async fn opencode_cli_ai_config_roundtrip() {
let db = temp_db_path("opencode-cli-ai-roundtrip");
let storage = Storage::open(&db).await.unwrap();
let mut cfg = make_ai_config("opencode-cli", true);
cfg.config.provider = AiProvider::OpenCodeCli;
cfg.config.api_key.clear();
cfg.config.endpoint.clear();
cfg.config.model = "openai/gpt-5.4-mini".to_string();
cfg.config.opencode_cli_path = Some("/opt/homebrew/bin/opencode".to_string());
cfg.config.opencode_cli_env.insert("HTTPS_PROXY".to_string(), "http://127.0.0.1:7890".to_string());
storage.save_ai_config_item(&cfg).await.unwrap();
let loaded = storage.load_ai_configs().await.unwrap();
assert_eq!(loaded.len(), 1);
assert!(matches!(loaded[0].config.provider, AiProvider::OpenCodeCli));
assert_eq!(loaded[0].config.model, "openai/gpt-5.4-mini");
assert_eq!(loaded[0].config.opencode_cli_path.as_deref(), Some("/opt/homebrew/bin/opencode"));
assert_eq!(
loaded[0].config.opencode_cli_env.get("HTTPS_PROXY").map(String::as_str),
Some("http://127.0.0.1:7890")
);
std::fs::remove_file(&db).ok();
}
#[tokio::test]
async fn anthropic_compatible_ai_config_roundtrip() {
let db = temp_db_path("anthropic-compatible-ai-roundtrip");

View File

@ -527,12 +527,16 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
opencode_cli_path: None,
opencode_cli_env: Default::default(),
}
}
#[test]
fn rejects_local_cli_providers_single() {
for provider in [AiProvider::CodexCli, AiProvider::ClaudeCodeCli, AiProvider::PiAgentCli] {
for provider in
[AiProvider::CodexCli, AiProvider::ClaudeCodeCli, AiProvider::PiAgentCli, AiProvider::OpenCodeCli]
{
let config = make_config(provider);
assert!(reject_web_unsupported_ai_provider(&config).is_err());
}
@ -614,6 +618,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
opencode_cli_path: None,
opencode_cli_env: Default::default(),
};
let body = super::AiTestConnectionRequest { config };

View File

@ -6,6 +6,7 @@ import en from "../../apps/desktop/src/i18n/locales/en";
import es from "../../apps/desktop/src/i18n/locales/es";
import it from "../../apps/desktop/src/i18n/locales/it";
import ja from "../../apps/desktop/src/i18n/locales/ja";
import ko from "../../apps/desktop/src/i18n/locales/ko";
import ptBR from "../../apps/desktop/src/i18n/locales/pt-BR";
import zhCN from "../../apps/desktop/src/i18n/locales/zh-CN";
import zhTW from "../../apps/desktop/src/i18n/locales/zh-TW";
@ -31,6 +32,15 @@ const errorCodes = [
"piAgentProtocolError",
"piAgentModelInvalid",
"piAgentRunFailed",
"openCodeNotInstalled",
"openCodeCliPathInvalid",
"openCodeEnvInvalid",
"openCodeEnvReserved",
"openCodeNotAuthenticated",
"openCodeMcpStartupFailed",
"openCodeTimeout",
"openCodeProtocolError",
"openCodeRunFailed",
] as const;
test("Claude Code CLI errors are localized while retaining their stable code and raw diagnostics", () => {
@ -57,7 +67,7 @@ test("Claude Code CLI errors are localized while retaining their stable code and
});
test("every current locale defines all AI CLI diagnostic messages", () => {
const locales = { en, es, it, ja, ptBR, zhCN, zhTW } as const;
const locales = { en, es, it, ja, ko, ptBR, zhCN, zhTW } as const;
for (const [localeName, locale] of Object.entries(locales)) {
assert.equal(typeof locale.ai.requestFailed, "string", `${localeName}.ai.requestFailed`);

View File

@ -538,6 +538,9 @@ test("AI provider presets include common hosted and local providers", () => {
assert.equal(AI_PROVIDER_PRESETS["claude-code-cli"].model, "default");
assert.equal(AI_PROVIDER_PRESETS["claude-code-cli"].iconSlug, "claudecode");
assert.equal(AI_PROVIDER_PRESETS["claude-code-cli"].requiresApiKey, false);
assert.equal(AI_PROVIDER_PRESETS["opencode-cli"].model, "default");
assert.equal(AI_PROVIDER_PRESETS["opencode-cli"].iconSlug, "opencode");
assert.equal(AI_PROVIDER_PRESETS["opencode-cli"].requiresApiKey, false);
assert.equal(AI_PROVIDER_PRESETS["pi-agent-cli"].model, "default");
assert.equal(AI_PROVIDER_PRESETS["pi-agent-cli"].iconSlug, "pi");
assert.equal(AI_PROVIDER_PRESETS["pi-agent-cli"].requiresApiKey, false);
@ -546,6 +549,8 @@ test("AI provider presets include common hosted and local providers", () => {
assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("minimax") < Object.keys(AI_PROVIDER_PRESETS).indexOf("ollama"));
assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("claude-code-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("codex-cli"));
assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("claude-code-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("pi-agent-cli"));
assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("codex-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("opencode-cli"));
assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("opencode-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("pi-agent-cli"));
assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("codex-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("pi-agent-cli"));
});
@ -614,6 +619,15 @@ test("normalizes legacy AI config and fills provider defaults", () => {
assert.equal(piAgent.piAgentCliPath, "/opt/homebrew/bin/pi");
assert.deepEqual(piAgent.piAgentCliEnv, { HTTPS_PROXY: "http://proxy:9800" });
assert.equal(piAgent.model, "default");
const openCode = normalizeAiConfig({
provider: "opencode-cli",
opencodeCliPath: " /opt/homebrew/bin/opencode ",
opencodeCliEnv: { HTTPS_PROXY: "http://proxy:9800" },
} as any);
assert.equal(openCode.opencodeCliPath, "/opt/homebrew/bin/opencode");
assert.deepEqual(openCode.opencodeCliEnv, { HTTPS_PROXY: "http://proxy:9800" });
assert.equal(openCode.model, "default");
});
test("infers legacy AI provider from saved endpoint and model", () => {

View File

@ -239,6 +239,7 @@ fn resolve_cli_provider_config(mut config: AiConfig) -> AiConfig {
AiProvider::CodexCli => (&mut config.codex_cli_path, "codex"),
AiProvider::ClaudeCodeCli => (&mut config.claude_code_cli_path, "claude"),
AiProvider::PiAgentCli => (&mut config.pi_agent_cli_path, "pi"),
AiProvider::OpenCodeCli => (&mut config.opencode_cli_path, "opencode"),
_ => return config,
};
let command = path_slot.as_deref().map(str::trim).filter(|path| !path.is_empty()).unwrap_or(default_command);
@ -324,6 +325,8 @@ mod tests {
claude_code_cli_env: Default::default(),
pi_agent_cli_path: None,
pi_agent_cli_env: Default::default(),
opencode_cli_path: None,
opencode_cli_env: Default::default(),
}
}