mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-24 09:31:39 +08:00
Compare commits
20 Commits
v1.1.2
...
github-v1.2.11
| Author | SHA1 | Date | |
|---|---|---|---|
| 0917991361 | |||
| c6a241e331 | |||
| 4b7301e8ca | |||
| 1bf20f0a2b | |||
| e3b4d4ad49 | |||
| 6b207b09d6 | |||
| 9771325026 | |||
| bbd1c071c4 | |||
| 8e9a0c4ad0 | |||
| ced093e646 | |||
| 283bdce358 | |||
| 91d5ce8bf3 | |||
| 7f870cc9d4 | |||
| 2cb3b0484b | |||
| 11b0df6b86 | |||
| e15af828fa | |||
| 265cbaea7c | |||
| d39ebbc947 | |||
| 06acd70670 | |||
| c285304acf |
@@ -44,6 +44,8 @@ jobs:
|
||||
{
|
||||
echo "Check for duplicate PRs related to this new PR:"
|
||||
echo ""
|
||||
echo "CURRENT_PR_NUMBER: $PR_NUMBER"
|
||||
echo ""
|
||||
echo "Title: $(gh pr view "$PR_NUMBER" --json title --jq .title)"
|
||||
echo ""
|
||||
echo "Description:"
|
||||
|
||||
@@ -9,7 +9,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
stats:
|
||||
if: github.repository == 'sst/opencode'
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -12,6 +12,8 @@ You are a duplicate PR detection agent. When a PR is opened, your job is to sear
|
||||
|
||||
Use the github-pr-search tool to search for PRs that might be addressing the same issue or feature.
|
||||
|
||||
IMPORTANT: The input will contain a line `CURRENT_PR_NUMBER: NNNN`. This is the current PR number, you should not mark that the current PR as a duplicate of itself.
|
||||
|
||||
Search using keywords from the PR title and description. Try multiple searches with different relevant terms.
|
||||
|
||||
If you find potential duplicates:
|
||||
|
||||
@@ -45,6 +45,11 @@ export default tool({
|
||||
}
|
||||
|
||||
const prs = result.items as PR[]
|
||||
|
||||
if (prs.length === 0) {
|
||||
return `No other PRs found matching "${args.query}"`
|
||||
}
|
||||
|
||||
const formatted = prs.map((pr) => `${pr.title}\n${pr.html_url}`).join("\n\n")
|
||||
|
||||
return `Found ${result.total_count} PRs (showing ${prs.length}):\n\n${formatted}`
|
||||
|
||||
@@ -189,3 +189,6 @@
|
||||
| 2025-12-31 | 1,479,598 (+34,148) | 1,293,235 (+20,546) | 2,772,833 (+54,694) |
|
||||
| 2026-01-01 | 1,508,883 (+29,285) | 1,309,874 (+16,639) | 2,818,757 (+45,924) |
|
||||
| 2026-01-02 | 1,563,474 (+54,591) | 1,320,959 (+11,085) | 2,884,433 (+65,676) |
|
||||
| 2026-01-03 | 1,618,065 (+54,591) | 1,331,914 (+10,955) | 2,949,979 (+65,546) |
|
||||
| 2026-01-04 | 1,672,656 (+39,702) | 1,339,883 (+7,969) | 3,012,539 (+62,560) |
|
||||
| 2026-01-05 | 1,738,171 (+65,515) | 1,353,043 (+13,160) | 3,091,214 (+78,675) |
|
||||
|
||||
+1
-1
@@ -82,7 +82,7 @@ This will walk you through installing the GitHub app, creating the workflow, and
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
@@ -16,16 +16,19 @@ Usage: install.sh [options]
|
||||
Options:
|
||||
-h, --help Display this help message
|
||||
-v, --version <version> Install a specific version (e.g., 1.0.180)
|
||||
-b, --binary <path> Install from a local binary instead of downloading
|
||||
--no-modify-path Don't modify shell config files (.zshrc, .bashrc, etc.)
|
||||
|
||||
Examples:
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
curl -fsSL https://opencode.ai/install | bash -s -- --version 1.0.180
|
||||
./install --binary /path/to/opencode
|
||||
EOF
|
||||
}
|
||||
|
||||
requested_version=${VERSION:-}
|
||||
no_modify_path=false
|
||||
binary_path=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
@@ -42,6 +45,15 @@ while [[ $# -gt 0 ]]; do
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
-b|--binary)
|
||||
if [[ -n "${2:-}" ]]; then
|
||||
binary_path="$2"
|
||||
shift 2
|
||||
else
|
||||
echo -e "${RED}Error: --binary requires a path argument${NC}"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
--no-modify-path)
|
||||
no_modify_path=true
|
||||
shift
|
||||
@@ -53,119 +65,128 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
|
||||
raw_os=$(uname -s)
|
||||
os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]')
|
||||
case "$raw_os" in
|
||||
Darwin*) os="darwin" ;;
|
||||
Linux*) os="linux" ;;
|
||||
MINGW*|MSYS*|CYGWIN*) os="windows" ;;
|
||||
esac
|
||||
|
||||
arch=$(uname -m)
|
||||
if [[ "$arch" == "aarch64" ]]; then
|
||||
arch="arm64"
|
||||
fi
|
||||
if [[ "$arch" == "x86_64" ]]; then
|
||||
arch="x64"
|
||||
fi
|
||||
|
||||
if [ "$os" = "darwin" ] && [ "$arch" = "x64" ]; then
|
||||
rosetta_flag=$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)
|
||||
if [ "$rosetta_flag" = "1" ]; then
|
||||
arch="arm64"
|
||||
fi
|
||||
fi
|
||||
|
||||
combo="$os-$arch"
|
||||
case "$combo" in
|
||||
linux-x64|linux-arm64|darwin-x64|darwin-arm64|windows-x64)
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
archive_ext=".zip"
|
||||
if [ "$os" = "linux" ]; then
|
||||
archive_ext=".tar.gz"
|
||||
fi
|
||||
|
||||
is_musl=false
|
||||
if [ "$os" = "linux" ]; then
|
||||
if [ -f /etc/alpine-release ]; then
|
||||
is_musl=true
|
||||
fi
|
||||
|
||||
if command -v ldd >/dev/null 2>&1; then
|
||||
if ldd --version 2>&1 | grep -qi musl; then
|
||||
is_musl=true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
needs_baseline=false
|
||||
if [ "$arch" = "x64" ]; then
|
||||
if [ "$os" = "linux" ]; then
|
||||
if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then
|
||||
needs_baseline=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$os" = "darwin" ]; then
|
||||
avx2=$(sysctl -n hw.optional.avx2_0 2>/dev/null || echo 0)
|
||||
if [ "$avx2" != "1" ]; then
|
||||
needs_baseline=true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
target="$os-$arch"
|
||||
if [ "$needs_baseline" = "true" ]; then
|
||||
target="$target-baseline"
|
||||
fi
|
||||
if [ "$is_musl" = "true" ]; then
|
||||
target="$target-musl"
|
||||
fi
|
||||
|
||||
filename="$APP-$target$archive_ext"
|
||||
|
||||
|
||||
if [ "$os" = "linux" ]; then
|
||||
if ! command -v tar >/dev/null 2>&1; then
|
||||
echo -e "${RED}Error: 'tar' is required but not installed.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
if ! command -v unzip >/dev/null 2>&1; then
|
||||
echo -e "${RED}Error: 'unzip' is required but not installed.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
INSTALL_DIR=$HOME/.opencode/bin
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
|
||||
if [ -z "$requested_version" ]; then
|
||||
url="https://github.com/anomalyco/opencode/releases/latest/download/$filename"
|
||||
specific_version=$(curl -s https://api.github.com/repos/anomalyco/opencode/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')
|
||||
|
||||
if [[ $? -ne 0 || -z "$specific_version" ]]; then
|
||||
echo -e "${RED}Failed to fetch version information${NC}"
|
||||
# If --binary is provided, skip all download/detection logic
|
||||
if [ -n "$binary_path" ]; then
|
||||
if [ ! -f "$binary_path" ]; then
|
||||
echo -e "${RED}Error: Binary not found at ${binary_path}${NC}"
|
||||
exit 1
|
||||
fi
|
||||
specific_version="local"
|
||||
else
|
||||
# Strip leading 'v' if present
|
||||
requested_version="${requested_version#v}"
|
||||
url="https://github.com/anomalyco/opencode/releases/download/v${requested_version}/$filename"
|
||||
specific_version=$requested_version
|
||||
|
||||
# Verify the release exists before downloading
|
||||
http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/anomalyco/opencode/releases/tag/v${requested_version}")
|
||||
if [ "$http_status" = "404" ]; then
|
||||
echo -e "${RED}Error: Release v${requested_version} not found${NC}"
|
||||
echo -e "${MUTED}Available releases: https://github.com/anomalyco/opencode/releases${NC}"
|
||||
raw_os=$(uname -s)
|
||||
os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]')
|
||||
case "$raw_os" in
|
||||
Darwin*) os="darwin" ;;
|
||||
Linux*) os="linux" ;;
|
||||
MINGW*|MSYS*|CYGWIN*) os="windows" ;;
|
||||
esac
|
||||
|
||||
arch=$(uname -m)
|
||||
if [[ "$arch" == "aarch64" ]]; then
|
||||
arch="arm64"
|
||||
fi
|
||||
if [[ "$arch" == "x86_64" ]]; then
|
||||
arch="x64"
|
||||
fi
|
||||
|
||||
if [ "$os" = "darwin" ] && [ "$arch" = "x64" ]; then
|
||||
rosetta_flag=$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)
|
||||
if [ "$rosetta_flag" = "1" ]; then
|
||||
arch="arm64"
|
||||
fi
|
||||
fi
|
||||
|
||||
combo="$os-$arch"
|
||||
case "$combo" in
|
||||
linux-x64|linux-arm64|darwin-x64|darwin-arm64|windows-x64)
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
archive_ext=".zip"
|
||||
if [ "$os" = "linux" ]; then
|
||||
archive_ext=".tar.gz"
|
||||
fi
|
||||
|
||||
is_musl=false
|
||||
if [ "$os" = "linux" ]; then
|
||||
if [ -f /etc/alpine-release ]; then
|
||||
is_musl=true
|
||||
fi
|
||||
|
||||
if command -v ldd >/dev/null 2>&1; then
|
||||
if ldd --version 2>&1 | grep -qi musl; then
|
||||
is_musl=true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
needs_baseline=false
|
||||
if [ "$arch" = "x64" ]; then
|
||||
if [ "$os" = "linux" ]; then
|
||||
if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then
|
||||
needs_baseline=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$os" = "darwin" ]; then
|
||||
avx2=$(sysctl -n hw.optional.avx2_0 2>/dev/null || echo 0)
|
||||
if [ "$avx2" != "1" ]; then
|
||||
needs_baseline=true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
target="$os-$arch"
|
||||
if [ "$needs_baseline" = "true" ]; then
|
||||
target="$target-baseline"
|
||||
fi
|
||||
if [ "$is_musl" = "true" ]; then
|
||||
target="$target-musl"
|
||||
fi
|
||||
|
||||
filename="$APP-$target$archive_ext"
|
||||
|
||||
|
||||
if [ "$os" = "linux" ]; then
|
||||
if ! command -v tar >/dev/null 2>&1; then
|
||||
echo -e "${RED}Error: 'tar' is required but not installed.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
if ! command -v unzip >/dev/null 2>&1; then
|
||||
echo -e "${RED}Error: 'unzip' is required but not installed.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$requested_version" ]; then
|
||||
url="https://github.com/anomalyco/opencode/releases/latest/download/$filename"
|
||||
specific_version=$(curl -s https://api.github.com/repos/anomalyco/opencode/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')
|
||||
|
||||
if [[ $? -ne 0 || -z "$specific_version" ]]; then
|
||||
echo -e "${RED}Failed to fetch version information${NC}"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# Strip leading 'v' if present
|
||||
requested_version="${requested_version#v}"
|
||||
url="https://github.com/anomalyco/opencode/releases/download/v${requested_version}/$filename"
|
||||
specific_version=$requested_version
|
||||
|
||||
# Verify the release exists before downloading
|
||||
http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/anomalyco/opencode/releases/tag/v${requested_version}")
|
||||
if [ "$http_status" = "404" ]; then
|
||||
echo -e "${RED}Error: Release v${requested_version} not found${NC}"
|
||||
echo -e "${MUTED}Available releases: https://github.com/anomalyco/opencode/releases${NC}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -267,11 +288,11 @@ download_with_progress() {
|
||||
{
|
||||
local length=0
|
||||
local bytes=0
|
||||
|
||||
|
||||
while IFS=" " read -r -a line; do
|
||||
[ "${#line[@]}" -lt 2 ] && continue
|
||||
local tag="${line[0]} ${line[1]}"
|
||||
|
||||
|
||||
if [ "$tag" = "0000: content-length:" ]; then
|
||||
length="${line[2]}"
|
||||
length=$(echo "$length" | tr -d '\r')
|
||||
@@ -296,7 +317,7 @@ download_and_install() {
|
||||
print_message info "\n${MUTED}Installing ${NC}opencode ${MUTED}version: ${NC}$specific_version"
|
||||
local tmp_dir="${TMPDIR:-/tmp}/opencode_install_$$"
|
||||
mkdir -p "$tmp_dir"
|
||||
|
||||
|
||||
if [[ "$os" == "windows" ]] || ! [ -t 2 ] || ! download_with_progress "$url" "$tmp_dir/$filename"; then
|
||||
# Fallback to standard curl on Windows, non-TTY environments, or if custom progress fails
|
||||
curl -# -L -o "$tmp_dir/$filename" "$url"
|
||||
@@ -307,14 +328,24 @@ download_and_install() {
|
||||
else
|
||||
unzip -q "$tmp_dir/$filename" -d "$tmp_dir"
|
||||
fi
|
||||
|
||||
|
||||
mv "$tmp_dir/opencode" "$INSTALL_DIR"
|
||||
chmod 755 "${INSTALL_DIR}/opencode"
|
||||
rm -rf "$tmp_dir"
|
||||
}
|
||||
|
||||
check_version
|
||||
download_and_install
|
||||
install_from_binary() {
|
||||
print_message info "\n${MUTED}Installing ${NC}opencode ${MUTED}from: ${NC}$binary_path"
|
||||
cp "$binary_path" "${INSTALL_DIR}/opencode"
|
||||
chmod 755 "${INSTALL_DIR}/opencode"
|
||||
}
|
||||
|
||||
if [ -n "$binary_path" ]; then
|
||||
install_from_binary
|
||||
else
|
||||
check_version
|
||||
download_and_install
|
||||
fi
|
||||
|
||||
|
||||
add_to_path() {
|
||||
@@ -416,4 +447,3 @@ echo -e ""
|
||||
echo -e "${MUTED}For more information visit ${NC}https://opencode.ai/docs"
|
||||
echo -e ""
|
||||
echo -e ""
|
||||
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||
import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js"
|
||||
import {
|
||||
createEffect,
|
||||
on,
|
||||
Component,
|
||||
Show,
|
||||
For,
|
||||
onMount,
|
||||
onCleanup,
|
||||
Switch,
|
||||
Match,
|
||||
createMemo,
|
||||
createSignal,
|
||||
} from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createFocusSignal } from "@solid-primitives/active-element"
|
||||
import { useLocal } from "@/context/local"
|
||||
@@ -246,6 +258,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
})
|
||||
|
||||
const isFocused = createFocusSignal(() => editorRef)
|
||||
const [composing, setComposing] = createSignal(false)
|
||||
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
|
||||
|
||||
const addImageAttachment = async (file: File) => {
|
||||
if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
|
||||
@@ -302,6 +316,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
|
||||
const handleGlobalDragOver = (event: DragEvent) => {
|
||||
if (dialog.active) return
|
||||
|
||||
event.preventDefault()
|
||||
const hasFiles = event.dataTransfer?.types.includes("Files")
|
||||
if (hasFiles) {
|
||||
@@ -310,6 +326,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
|
||||
const handleGlobalDragLeave = (event: DragEvent) => {
|
||||
if (dialog.active) return
|
||||
|
||||
// relatedTarget is null when leaving the document window
|
||||
if (!event.relatedTarget) {
|
||||
setStore("dragging", false)
|
||||
@@ -317,6 +335,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
|
||||
const handleGlobalDrop = async (event: DragEvent) => {
|
||||
if (dialog.active) return
|
||||
|
||||
event.preventDefault()
|
||||
setStore("dragging", false)
|
||||
|
||||
@@ -862,6 +882,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === "Enter" && isImeComposing(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
|
||||
if (store.popover === "at") {
|
||||
atOnKeyDown(event)
|
||||
@@ -1483,6 +1507,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
}}
|
||||
contenteditable="true"
|
||||
onInput={handleInput}
|
||||
onCompositionStart={() => setComposing(true)}
|
||||
onCompositionEnd={() => setComposing(false)}
|
||||
onKeyDown={handleKeyDown}
|
||||
classList={{
|
||||
"select-text": true,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createMemo, createResource, Show } from "solid-js"
|
||||
import { createMemo, createResource, Show } from "solid-js"
|
||||
import { A, useNavigate, useParams } from "@solidjs/router"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useCommand } from "@/context/command"
|
||||
@@ -20,6 +20,7 @@ import { DialogSelectServer } from "@/components/dialog-select-server"
|
||||
import { SessionLspIndicator } from "@/components/session-lsp-indicator"
|
||||
import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { same } from "@/utils/same"
|
||||
|
||||
export function SessionHeader() {
|
||||
const globalSDK = useGlobalSDK()
|
||||
@@ -36,6 +37,7 @@ export function SessionHeader() {
|
||||
const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID))
|
||||
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
|
||||
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
||||
const worktrees = createMemo(() => layout.projects.list().map((p) => p.worktree), [], { equals: same })
|
||||
|
||||
function navigateToProject(directory: string) {
|
||||
navigate(`/${base64Encode(directory)}`)
|
||||
@@ -60,7 +62,7 @@ export function SessionHeader() {
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<div class="hidden xl:flex items-center gap-2">
|
||||
<Select
|
||||
options={layout.projects.list().map((project) => project.worktree)}
|
||||
options={worktrees()}
|
||||
current={sync.project?.worktree ?? projectDirectory()}
|
||||
label={(x) => getFilename(x)}
|
||||
onSelect={(x) => (x ? navigateToProject(x) : undefined)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useGlobalSDK } from "./global-sdk"
|
||||
import { useServer } from "./server"
|
||||
import { Project } from "@opencode-ai/sdk/v2"
|
||||
import { persisted } from "@/utils/persist"
|
||||
import { same } from "@/utils/same"
|
||||
|
||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
|
||||
@@ -23,13 +24,6 @@ export function getAvatarColors(key?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) {
|
||||
if (a === b) return true
|
||||
if (!a || !b) return false
|
||||
if (a.length !== b.length) return false
|
||||
return a.every((x, i) => x === b[i])
|
||||
}
|
||||
|
||||
type SessionTabs = {
|
||||
active?: string
|
||||
all: string[]
|
||||
|
||||
@@ -759,6 +759,10 @@ export default function Layout(props: ParentProps) {
|
||||
const isExpanded = createMemo(() =>
|
||||
props.mobile ? mobileProjects.expanded(props.project.worktree) : props.project.expanded,
|
||||
)
|
||||
const isActive = createMemo(() => {
|
||||
const current = params.dir ? base64Decode(params.dir) : ""
|
||||
return props.project.worktree === current || props.project.sandboxes?.includes(current)
|
||||
})
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (props.mobile) {
|
||||
if (open) mobileProjects.expand(props.project.worktree)
|
||||
@@ -777,7 +781,10 @@ export default function Layout(props: ParentProps) {
|
||||
<Button
|
||||
as={"div"}
|
||||
variant="ghost"
|
||||
class="group/session flex items-center justify-between gap-3 w-full px-1.5 self-stretch h-auto border-none rounded-lg"
|
||||
classList={{
|
||||
"group/session flex items-center justify-between gap-3 w-full px-1.5 self-stretch h-auto border-none rounded-lg": true,
|
||||
"bg-surface-raised-base-hover": isActive() && !isExpanded(),
|
||||
}}
|
||||
>
|
||||
<Collapsible.Trigger class="group/trigger flex items-center gap-3 p-0 text-left min-w-0 grow border-none">
|
||||
<ProjectAvatar
|
||||
|
||||
@@ -48,12 +48,7 @@ import {
|
||||
NewSessionView,
|
||||
} from "@/components/session"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
|
||||
function same<T>(a: readonly T[], b: readonly T[]) {
|
||||
if (a === b) return true
|
||||
if (a.length !== b.length) return false
|
||||
return a.every((x, i) => x === b[i])
|
||||
}
|
||||
import { same } from "@/utils/same"
|
||||
|
||||
type DiffStyle = "unified" | "split"
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) {
|
||||
if (a === b) return true
|
||||
if (!a || !b) return false
|
||||
if (a.length !== b.length) return false
|
||||
return a.every((x, i) => x === b[i])
|
||||
}
|
||||
@@ -172,8 +172,8 @@ export async function handler(
|
||||
const tokensInfo = providerInfo.normalizeUsage(json.usage)
|
||||
await trialLimiter?.track(tokensInfo)
|
||||
await rateLimiter?.track()
|
||||
await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
|
||||
await reload(authInfo)
|
||||
const costInfo = await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
|
||||
await reload(authInfo, costInfo)
|
||||
return new Response(body, {
|
||||
status: resStatus,
|
||||
statusText: res.statusText,
|
||||
@@ -206,8 +206,8 @@ export async function handler(
|
||||
if (usage) {
|
||||
const tokensInfo = providerInfo.normalizeUsage(usage)
|
||||
await trialLimiter?.track(tokensInfo)
|
||||
await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
|
||||
await reload(authInfo)
|
||||
const costInfo = await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
|
||||
await reload(authInfo, costInfo)
|
||||
}
|
||||
c.close()
|
||||
return
|
||||
@@ -392,6 +392,7 @@ export async function handler(
|
||||
monthlyUsage: BillingTable.monthlyUsage,
|
||||
timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
|
||||
reloadTrigger: BillingTable.reloadTrigger,
|
||||
timeReloadLockedTill: BillingTable.timeReloadLockedTill,
|
||||
},
|
||||
user: {
|
||||
id: UserTable.id,
|
||||
@@ -560,61 +561,68 @@ export async function handler(
|
||||
if (!authInfo) return
|
||||
|
||||
const cost = authInfo.isFree || authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
|
||||
await Database.transaction(async (tx) => {
|
||||
await tx.insert(UsageTable).values({
|
||||
workspaceID: authInfo.workspaceID,
|
||||
id: Identifier.create("usage"),
|
||||
model: modelInfo.id,
|
||||
provider: providerInfo.id,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
reasoningTokens,
|
||||
cacheReadTokens,
|
||||
cacheWrite5mTokens,
|
||||
cacheWrite1hTokens,
|
||||
cost,
|
||||
keyID: authInfo.apiKeyId,
|
||||
})
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
balance: sql`${BillingTable.balance} - ${cost}`,
|
||||
monthlyUsage: sql`
|
||||
await Database.use((db) =>
|
||||
Promise.all([
|
||||
db.insert(UsageTable).values({
|
||||
workspaceID: authInfo.workspaceID,
|
||||
id: Identifier.create("usage"),
|
||||
model: modelInfo.id,
|
||||
provider: providerInfo.id,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
reasoningTokens,
|
||||
cacheReadTokens,
|
||||
cacheWrite5mTokens,
|
||||
cacheWrite1hTokens,
|
||||
cost,
|
||||
keyID: authInfo.apiKeyId,
|
||||
}),
|
||||
db
|
||||
.update(BillingTable)
|
||||
.set({
|
||||
balance: sql`${BillingTable.balance} - ${cost}`,
|
||||
monthlyUsage: sql`
|
||||
CASE
|
||||
WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${cost}
|
||||
ELSE ${cost}
|
||||
END
|
||||
`,
|
||||
timeMonthlyUsageUpdated: sql`now()`,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, authInfo.workspaceID))
|
||||
await tx
|
||||
.update(UserTable)
|
||||
.set({
|
||||
monthlyUsage: sql`
|
||||
timeMonthlyUsageUpdated: sql`now()`,
|
||||
})
|
||||
.where(eq(BillingTable.workspaceID, authInfo.workspaceID)),
|
||||
db
|
||||
.update(UserTable)
|
||||
.set({
|
||||
monthlyUsage: sql`
|
||||
CASE
|
||||
WHEN MONTH(${UserTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${UserTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${UserTable.monthlyUsage} + ${cost}
|
||||
ELSE ${cost}
|
||||
END
|
||||
`,
|
||||
timeMonthlyUsageUpdated: sql`now()`,
|
||||
})
|
||||
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id)))
|
||||
})
|
||||
|
||||
await Database.use((tx) =>
|
||||
tx
|
||||
.update(KeyTable)
|
||||
.set({ timeUsed: sql`now()` })
|
||||
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
|
||||
timeMonthlyUsageUpdated: sql`now()`,
|
||||
})
|
||||
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id))),
|
||||
db
|
||||
.update(KeyTable)
|
||||
.set({ timeUsed: sql`now()` })
|
||||
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
|
||||
]),
|
||||
)
|
||||
|
||||
return { costInMicroCents: cost }
|
||||
}
|
||||
|
||||
async function reload(authInfo: AuthInfo) {
|
||||
async function reload(authInfo: AuthInfo, costInfo: Awaited<ReturnType<typeof trackUsage>>) {
|
||||
if (!authInfo) return
|
||||
if (authInfo.isFree) return
|
||||
if (authInfo.provider?.credentials) return
|
||||
|
||||
if (!costInfo) return
|
||||
|
||||
const reloadTrigger = centsToMicroCents((authInfo.billing.reloadTrigger ?? Billing.RELOAD_TRIGGER) * 100)
|
||||
if (authInfo.billing.balance - costInfo.costInMicroCents >= reloadTrigger) return
|
||||
if (authInfo.billing.timeReloadLockedTill && authInfo.billing.timeReloadLockedTill > new Date()) return
|
||||
|
||||
const lock = await Database.use((tx) =>
|
||||
tx
|
||||
.update(BillingTable)
|
||||
@@ -625,10 +633,7 @@ export async function handler(
|
||||
and(
|
||||
eq(BillingTable.workspaceID, authInfo.workspaceID),
|
||||
eq(BillingTable.reload, true),
|
||||
lt(
|
||||
BillingTable.balance,
|
||||
centsToMicroCents((authInfo.billing.reloadTrigger ?? Billing.RELOAD_TRIGGER) * 100),
|
||||
),
|
||||
lt(BillingTable.balance, reloadTrigger),
|
||||
or(isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`)),
|
||||
),
|
||||
),
|
||||
|
||||
Generated
+1
@@ -2777,6 +2777,7 @@ version = "0.0.0"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"listeners",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
|
||||
@@ -35,6 +35,7 @@ serde_json = "1"
|
||||
tokio = "1.48.0"
|
||||
listeners = "0.3"
|
||||
tauri-plugin-os = "2"
|
||||
semver = "1.0.27"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
gtk = "0.18.2"
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
const CLI_INSTALL_DIR: &str = ".opencode/bin";
|
||||
const CLI_BINARY_NAME: &str = "opencode";
|
||||
|
||||
fn get_cli_install_path() -> Option<std::path::PathBuf> {
|
||||
std::env::var("HOME").ok().map(|home| {
|
||||
std::path::PathBuf::from(home)
|
||||
.join(CLI_INSTALL_DIR)
|
||||
.join(CLI_BINARY_NAME)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_sidecar_path() -> std::path::PathBuf {
|
||||
tauri::utils::platform::current_exe()
|
||||
.expect("Failed to get current exe")
|
||||
.parent()
|
||||
.expect("Failed to get parent dir")
|
||||
.join("opencode-cli")
|
||||
}
|
||||
|
||||
fn is_cli_installed() -> bool {
|
||||
get_cli_install_path()
|
||||
.map(|path| path.exists())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
const INSTALL_SCRIPT: &str = include_str!("../../../../install");
|
||||
|
||||
#[tauri::command]
|
||||
pub fn install_cli() -> Result<String, String> {
|
||||
if cfg!(not(unix)) {
|
||||
return Err("CLI installation is only supported on macOS & Linux".to_string());
|
||||
}
|
||||
|
||||
let sidecar = get_sidecar_path();
|
||||
if !sidecar.exists() {
|
||||
return Err("Sidecar binary not found".to_string());
|
||||
}
|
||||
|
||||
let temp_script = std::env::temp_dir().join("opencode-install.sh");
|
||||
std::fs::write(&temp_script, INSTALL_SCRIPT)
|
||||
.map_err(|e| format!("Failed to write install script: {}", e))?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&temp_script, std::fs::Permissions::from_mode(0o755))
|
||||
.map_err(|e| format!("Failed to set script permissions: {}", e))?;
|
||||
}
|
||||
|
||||
let output = std::process::Command::new(&temp_script)
|
||||
.arg("--binary")
|
||||
.arg(&sidecar)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run install script: {}", e))?;
|
||||
|
||||
let _ = std::fs::remove_file(&temp_script);
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Install script failed: {}", stderr));
|
||||
}
|
||||
|
||||
let install_path =
|
||||
get_cli_install_path().ok_or_else(|| "Could not determine install path".to_string())?;
|
||||
|
||||
Ok(install_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
pub fn sync_cli(app: tauri::AppHandle) -> Result<(), String> {
|
||||
if cfg!(debug_assertions) {
|
||||
println!("Skipping CLI sync for debug build");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !is_cli_installed() {
|
||||
println!("No CLI installation found, skipping sync");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let cli_path =
|
||||
get_cli_install_path().ok_or_else(|| "Could not determine CLI install path".to_string())?;
|
||||
|
||||
let output = std::process::Command::new(&cli_path)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to get CLI version: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err("Failed to get CLI version".to_string());
|
||||
}
|
||||
|
||||
let cli_version_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let cli_version = semver::Version::parse(&cli_version_str)
|
||||
.map_err(|e| format!("Failed to parse CLI version '{}': {}", cli_version_str, e))?;
|
||||
|
||||
let app_version = app.package_info().version.clone();
|
||||
|
||||
if cli_version >= app_version {
|
||||
println!(
|
||||
"CLI version {} is up to date (app version: {}), skipping sync",
|
||||
cli_version, app_version
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!(
|
||||
"CLI version {} is older than app version {}, syncing",
|
||||
cli_version, app_version
|
||||
);
|
||||
|
||||
install_cli()?;
|
||||
|
||||
println!("Synced installed CLI");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
mod cli;
|
||||
mod window_customizer;
|
||||
|
||||
use cli::{get_sidecar_path, install_cli, sync_cli};
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
net::{SocketAddr, TcpListener},
|
||||
sync::{Arc, Mutex},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tauri::{AppHandle, LogicalSize, Manager, RunEvent, WebviewUrl, WebviewWindow, path::BaseDirectory};
|
||||
use tauri::{
|
||||
path::BaseDirectory, AppHandle, LogicalSize, Manager, RunEvent, WebviewUrl, WebviewWindow,
|
||||
};
|
||||
use tauri_plugin_clipboard_manager::ClipboardExt;
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
|
||||
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
|
||||
@@ -116,11 +120,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let (mut rx, child) = {
|
||||
let sidecar_path = tauri::utils::platform::current_exe()
|
||||
.expect("Failed to get current exe")
|
||||
.parent()
|
||||
.expect("Failed to get parent dir")
|
||||
.join("opencode-cli");
|
||||
let sidecar = get_sidecar_path();
|
||||
let shell = get_user_shell();
|
||||
app.shell()
|
||||
.command(&shell)
|
||||
@@ -130,7 +130,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
|
||||
.args([
|
||||
"-il",
|
||||
"-c",
|
||||
&format!("{} serve --port={}", sidecar_path.display(), port),
|
||||
&format!("{} serve --port={}", sidecar.display(), port),
|
||||
])
|
||||
.spawn()
|
||||
.expect("Failed to spawn opencode")
|
||||
@@ -203,7 +203,8 @@ pub fn run() {
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
kill_sidecar,
|
||||
copy_logs_to_clipboard,
|
||||
get_logs
|
||||
get_logs,
|
||||
install_cli
|
||||
])
|
||||
.setup(move |app| {
|
||||
let app = app.handle().clone();
|
||||
@@ -211,83 +212,95 @@ pub fn run() {
|
||||
// Initialize log state
|
||||
app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let port = get_sidecar_port();
|
||||
{
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let port = get_sidecar_port();
|
||||
|
||||
let should_spawn_sidecar = !is_server_running(port).await;
|
||||
let should_spawn_sidecar = !is_server_running(port).await;
|
||||
|
||||
let child = if should_spawn_sidecar {
|
||||
let child = spawn_sidecar(&app, port);
|
||||
let child = if should_spawn_sidecar {
|
||||
let child = spawn_sidecar(&app, port);
|
||||
|
||||
let timestamp = Instant::now();
|
||||
loop {
|
||||
if timestamp.elapsed() > Duration::from_secs(7) {
|
||||
let res = app.dialog()
|
||||
.message("Failed to spawn OpenCode Server. Copy logs using the button below and send them to the team for assistance.")
|
||||
.title("Startup Failed")
|
||||
.buttons(MessageDialogButtons::OkCancelCustom("Copy Logs And Exit".to_string(), "Exit".to_string()))
|
||||
.blocking_show_with_result();
|
||||
let timestamp = Instant::now();
|
||||
loop {
|
||||
if timestamp.elapsed() > Duration::from_secs(7) {
|
||||
let res = app.dialog()
|
||||
.message("Failed to spawn OpenCode Server. Copy logs using the button below and send them to the team for assistance.")
|
||||
.title("Startup Failed")
|
||||
.buttons(MessageDialogButtons::OkCancelCustom("Copy Logs And Exit".to_string(), "Exit".to_string()))
|
||||
.blocking_show_with_result();
|
||||
|
||||
if matches!(&res, MessageDialogResult::Custom(name) if name == "Copy Logs And Exit") {
|
||||
match copy_logs_to_clipboard(app.clone()).await {
|
||||
Ok(()) => println!("Logs copied to clipboard successfully"),
|
||||
Err(e) => println!("Failed to copy logs to clipboard: {}", e),
|
||||
}
|
||||
}
|
||||
if matches!(&res, MessageDialogResult::Custom(name) if name == "Copy Logs And Exit") {
|
||||
match copy_logs_to_clipboard(app.clone()).await {
|
||||
Ok(()) => println!("Logs copied to clipboard successfully"),
|
||||
Err(e) => println!("Failed to copy logs to clipboard: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
app.exit(1);
|
||||
app.exit(1);
|
||||
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
|
||||
if is_server_running(port).await {
|
||||
// give the server a little bit more time to warm up
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
if is_server_running(port).await {
|
||||
// give the server a little bit more time to warm up
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
println!("Server ready after {:?}", timestamp.elapsed());
|
||||
println!("Server ready after {:?}", timestamp.elapsed());
|
||||
|
||||
Some(child)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Some(child)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let primary_monitor = app.primary_monitor().ok().flatten();
|
||||
let size = primary_monitor
|
||||
.map(|m| m.size().to_logical(m.scale_factor()))
|
||||
.unwrap_or(LogicalSize::new(1920, 1080));
|
||||
let primary_monitor = app.primary_monitor().ok().flatten();
|
||||
let size = primary_monitor
|
||||
.map(|m| m.size().to_logical(m.scale_factor()))
|
||||
.unwrap_or(LogicalSize::new(1920, 1080));
|
||||
|
||||
let mut window_builder =
|
||||
WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
|
||||
.title("OpenCode")
|
||||
.inner_size(size.width as f64, size.height as f64)
|
||||
.decorations(true)
|
||||
.zoom_hotkeys_enabled(true)
|
||||
.disable_drag_drop_handler()
|
||||
.initialization_script(format!(
|
||||
r#"
|
||||
window.__OPENCODE__ ??= {{}};
|
||||
window.__OPENCODE__.updaterEnabled = {updater_enabled};
|
||||
window.__OPENCODE__.port = {port};
|
||||
"#
|
||||
));
|
||||
let mut window_builder =
|
||||
WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into()))
|
||||
.title("OpenCode")
|
||||
.inner_size(size.width as f64, size.height as f64)
|
||||
.decorations(true)
|
||||
.zoom_hotkeys_enabled(true)
|
||||
.disable_drag_drop_handler()
|
||||
.initialization_script(format!(
|
||||
r#"
|
||||
window.__OPENCODE__ ??= {{}};
|
||||
window.__OPENCODE__.updaterEnabled = {updater_enabled};
|
||||
window.__OPENCODE__.port = {port};
|
||||
"#
|
||||
));
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
window_builder = window_builder
|
||||
.title_bar_style(tauri::TitleBarStyle::Overlay)
|
||||
.hidden_title(true);
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
window_builder = window_builder
|
||||
.title_bar_style(tauri::TitleBarStyle::Overlay)
|
||||
.hidden_title(true);
|
||||
}
|
||||
|
||||
window_builder.build().expect("Failed to create window");
|
||||
|
||||
app.manage(ServerState(Arc::new(Mutex::new(child))));
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = sync_cli(app) {
|
||||
eprintln!("Failed to sync CLI: {e}");
|
||||
}
|
||||
|
||||
window_builder.build().expect("Failed to create window");
|
||||
|
||||
app.manage(ServerState(Arc::new(Mutex::new(child))));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { message } from "@tauri-apps/plugin-dialog"
|
||||
|
||||
export async function installCli(): Promise<void> {
|
||||
try {
|
||||
const path = await invoke<string>("install_cli")
|
||||
await message(`CLI installed to ${path}\n\nRestart your terminal to use the 'opencode' command.`, {
|
||||
title: "CLI Installed",
|
||||
})
|
||||
} catch (e) {
|
||||
await message(`Failed to install CLI: ${e}`, { title: "Installation Failed" })
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Menu, MenuItem, PredefinedMenuItem, Submenu } from "@tauri-apps/api/men
|
||||
import { type as ostype } from "@tauri-apps/plugin-os"
|
||||
|
||||
import { runUpdater, UPDATER_ENABLED } from "./updater"
|
||||
import { installCli } from "./cli"
|
||||
|
||||
export async function createMenu() {
|
||||
if (ostype() !== "macos") return
|
||||
@@ -19,6 +20,10 @@ export async function createMenu() {
|
||||
action: () => runUpdater({ alertOnFail: true }),
|
||||
text: "Check For Updates...",
|
||||
}),
|
||||
await MenuItem.new({
|
||||
action: () => installCli(),
|
||||
text: "Install CLI...",
|
||||
}),
|
||||
await PredefinedMenuItem.new({
|
||||
item: "Separator",
|
||||
}),
|
||||
|
||||
@@ -335,7 +335,11 @@ export const AuthLoginCommand = cmd({
|
||||
|
||||
if (provider === "amazon-bedrock") {
|
||||
prompts.log.info(
|
||||
"Amazon bedrock can be configured with standard AWS environment variables like AWS_BEARER_TOKEN_BEDROCK, AWS_PROFILE or AWS_ACCESS_KEY_ID",
|
||||
"Amazon Bedrock authentication priority:\n" +
|
||||
" 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" +
|
||||
" 2. AWS credential chain (profile, access keys, IAM roles)\n\n" +
|
||||
"Configure via opencode.json options (profile, region, endpoint) or\n" +
|
||||
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID).",
|
||||
)
|
||||
prompts.outro("Done")
|
||||
return
|
||||
|
||||
@@ -393,7 +393,7 @@ jobs:
|
||||
issues: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Run opencode
|
||||
uses: anomalyco/opencode/github@latest${envStr}
|
||||
|
||||
@@ -50,6 +50,7 @@ function isMcpRemote(config: McpEntry): config is McpRemote {
|
||||
|
||||
export const McpCommand = cmd({
|
||||
command: "mcp",
|
||||
describe: "manage MCP (Model Context Protocol) servers",
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.command(McpAddCommand)
|
||||
|
||||
@@ -569,6 +569,7 @@ function App() {
|
||||
|
||||
sdk.event.on(SessionApi.Event.Error.type, (evt) => {
|
||||
const error = evt.properties.error
|
||||
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
|
||||
const message = (() => {
|
||||
if (!error) return "An error occurred"
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import { useRenderer } from "@opentui/solid"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Global } from "@/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { useSDK } from "./sdk"
|
||||
|
||||
type ThemeColors = {
|
||||
primary: RGBA
|
||||
@@ -310,32 +311,42 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
|
||||
})
|
||||
})
|
||||
|
||||
const renderer = useRenderer()
|
||||
renderer
|
||||
.getPalette({
|
||||
size: 16,
|
||||
})
|
||||
.then((colors) => {
|
||||
if (!colors.palette[0]) {
|
||||
if (store.active === "system") {
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.active = "opencode"
|
||||
draft.ready = true
|
||||
}),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.themes.system = generateSystem(colors, store.mode)
|
||||
function resolveSystemTheme() {
|
||||
console.log("resolved system theme")
|
||||
renderer
|
||||
.getPalette({
|
||||
size: 16,
|
||||
})
|
||||
.then((colors) => {
|
||||
if (!colors.palette[0]) {
|
||||
if (store.active === "system") {
|
||||
draft.ready = true
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.active = "opencode"
|
||||
draft.ready = true
|
||||
}),
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
return
|
||||
}
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
draft.themes.system = generateSystem(colors, store.mode)
|
||||
if (store.active === "system") {
|
||||
draft.ready = true
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const renderer = useRenderer()
|
||||
resolveSystemTheme()
|
||||
|
||||
const sdk = useSDK()
|
||||
sdk.event.on("server.instance.disposed", () => {
|
||||
resolveSystemTheme()
|
||||
})
|
||||
|
||||
const values = createMemo(() => {
|
||||
return resolveTheme(store.themes[store.active] ?? store.themes.opencode, store.mode)
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"darkBlue": "#6ba1e6",
|
||||
"darkCyan": "#56b6c2",
|
||||
"darkYellow": "#e5c07b",
|
||||
"darkPanelBg": "#2a1a1599",
|
||||
"lightStep6": "#d4d4d4",
|
||||
"lightStep11": "#8a8a8a",
|
||||
"lightStep12": "#1a1a1a",
|
||||
@@ -20,7 +21,8 @@
|
||||
"lightOrange": "#EC5B2B",
|
||||
"lightBlue": "#0062d1",
|
||||
"lightCyan": "#318795",
|
||||
"lightYellow": "#b0851f"
|
||||
"lightYellow": "#b0851f",
|
||||
"lightPanelBg": "#fff5f099"
|
||||
},
|
||||
"theme": {
|
||||
"primary": {
|
||||
@@ -71,6 +73,10 @@
|
||||
"dark": "transparent",
|
||||
"light": "transparent"
|
||||
},
|
||||
"backgroundMenu": {
|
||||
"dark": "darkPanelBg",
|
||||
"light": "lightPanelBg"
|
||||
},
|
||||
"border": {
|
||||
"dark": "darkOrange",
|
||||
"light": "lightOrange"
|
||||
|
||||
@@ -1204,7 +1204,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<Show when={props.message.error}>
|
||||
<Show when={props.message.error && props.message.error.name !== "MessageAbortedError"}>
|
||||
<box
|
||||
border={["left"]}
|
||||
paddingTop={1}
|
||||
@@ -1219,15 +1219,27 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
|
||||
</box>
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={props.last || final()}>
|
||||
<Match when={props.last || final() || props.message.error?.name === "MessageAbortedError"}>
|
||||
<box paddingLeft={3}>
|
||||
<text marginTop={1}>
|
||||
<span style={{ fg: local.agent.color(props.message.agent) }}>▣ </span>{" "}
|
||||
<span
|
||||
style={{
|
||||
fg:
|
||||
props.message.error?.name === "MessageAbortedError"
|
||||
? theme.textMuted
|
||||
: local.agent.color(props.message.agent),
|
||||
}}
|
||||
>
|
||||
▣{" "}
|
||||
</span>{" "}
|
||||
<span style={{ fg: theme.text }}>{Locale.titlecase(props.message.mode)}</span>
|
||||
<span style={{ fg: theme.textMuted }}> · {props.message.modelID}</span>
|
||||
<Show when={duration()}>
|
||||
<span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
|
||||
</Show>
|
||||
<Show when={props.message.error?.name === "MessageAbortedError"}>
|
||||
<span style={{ fg: theme.textMuted }}> · interrupted</span>
|
||||
</Show>
|
||||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Flag } from "../flag/flag"
|
||||
import { iife } from "@/util/iife"
|
||||
|
||||
// Direct imports for bundled providers
|
||||
import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock"
|
||||
import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock"
|
||||
import { createAnthropic } from "@ai-sdk/anthropic"
|
||||
import { createAzure } from "@ai-sdk/azure"
|
||||
import { createGoogleGenerativeAI } from "@ai-sdk/google"
|
||||
@@ -168,10 +168,22 @@ export namespace Provider {
|
||||
}
|
||||
},
|
||||
"amazon-bedrock": async () => {
|
||||
const config = await Config.get()
|
||||
const providerConfig = config.provider?.["amazon-bedrock"]
|
||||
|
||||
const auth = await Auth.get("amazon-bedrock")
|
||||
const awsProfile = Env.get("AWS_PROFILE")
|
||||
|
||||
// Region precedence: 1) config file, 2) env var, 3) default
|
||||
const configRegion = providerConfig?.options?.region
|
||||
const envRegion = Env.get("AWS_REGION")
|
||||
const defaultRegion = configRegion ?? envRegion ?? "us-east-1"
|
||||
|
||||
// Profile: config file takes precedence over env var
|
||||
const configProfile = providerConfig?.options?.profile
|
||||
const envProfile = Env.get("AWS_PROFILE")
|
||||
const profile = configProfile ?? envProfile
|
||||
|
||||
const awsAccessKeyId = Env.get("AWS_ACCESS_KEY_ID")
|
||||
const awsRegion = Env.get("AWS_REGION")
|
||||
|
||||
const awsBearerToken = iife(() => {
|
||||
const envToken = Env.get("AWS_BEARER_TOKEN_BEDROCK")
|
||||
@@ -183,17 +195,27 @@ export namespace Provider {
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!awsProfile && !awsAccessKeyId && !awsBearerToken) return { autoload: false }
|
||||
|
||||
const defaultRegion = awsRegion ?? "us-east-1"
|
||||
if (!profile && !awsAccessKeyId && !awsBearerToken) return { autoload: false }
|
||||
|
||||
const { fromNodeProviderChain } = await import(await BunProc.install("@aws-sdk/credential-providers"))
|
||||
|
||||
// Build credential provider options (only pass profile if specified)
|
||||
const credentialProviderOptions = profile ? { profile } : {}
|
||||
|
||||
const providerOptions: AmazonBedrockProviderSettings = {
|
||||
region: defaultRegion,
|
||||
credentialProvider: fromNodeProviderChain(credentialProviderOptions),
|
||||
}
|
||||
|
||||
// Add custom endpoint if specified (endpoint takes precedence over baseURL)
|
||||
const endpoint = providerConfig?.options?.endpoint ?? providerConfig?.options?.baseURL
|
||||
if (endpoint) {
|
||||
providerOptions.baseURL = endpoint
|
||||
}
|
||||
|
||||
return {
|
||||
autoload: true,
|
||||
options: {
|
||||
region: defaultRegion,
|
||||
credentialProvider: fromNodeProviderChain(),
|
||||
},
|
||||
options: providerOptions,
|
||||
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
||||
// Skip region prefixing if model already has global prefix
|
||||
if (modelID.startsWith("global.")) {
|
||||
|
||||
@@ -17,6 +17,28 @@ function mimeToModality(mime: string): Modality | undefined {
|
||||
|
||||
export namespace ProviderTransform {
|
||||
function normalizeMessages(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] {
|
||||
// Anthropic rejects messages with empty content - filter out empty string messages
|
||||
// and remove empty text/reasoning parts from array content
|
||||
if (model.api.npm === "@ai-sdk/anthropic") {
|
||||
msgs = msgs
|
||||
.map((msg) => {
|
||||
if (typeof msg.content === "string") {
|
||||
if (msg.content === "") return undefined
|
||||
return msg
|
||||
}
|
||||
if (!Array.isArray(msg.content)) return msg
|
||||
const filtered = msg.content.filter((part) => {
|
||||
if (part.type === "text" || part.type === "reasoning") {
|
||||
return part.text !== ""
|
||||
}
|
||||
return true
|
||||
})
|
||||
if (filtered.length === 0) return undefined
|
||||
return { ...msg, content: filtered }
|
||||
})
|
||||
.filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "")
|
||||
}
|
||||
|
||||
if (model.api.id.includes("claude")) {
|
||||
return msgs.map((msg) => {
|
||||
if ((msg.role === "assistant" || msg.role === "tool") && Array.isArray(msg.content)) {
|
||||
|
||||
@@ -42,6 +42,8 @@ delete process.env["GOOGLE_GENERATIVE_AI_API_KEY"]
|
||||
delete process.env["AZURE_OPENAI_API_KEY"]
|
||||
delete process.env["AWS_ACCESS_KEY_ID"]
|
||||
delete process.env["AWS_PROFILE"]
|
||||
delete process.env["AWS_REGION"]
|
||||
delete process.env["AWS_BEARER_TOKEN_BEDROCK"]
|
||||
delete process.env["OPENROUTER_API_KEY"]
|
||||
delete process.env["GROQ_API_KEY"]
|
||||
delete process.env["MISTRAL_API_KEY"]
|
||||
|
||||
@@ -1,11 +1,40 @@
|
||||
import { test, expect } from "bun:test"
|
||||
import { test, expect, mock } from "bun:test"
|
||||
import path from "path"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Provider } from "../../src/provider/provider"
|
||||
import { Env } from "../../src/env"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { Global } from "../../src/global"
|
||||
|
||||
// === Mocks ===
|
||||
// These mocks are required because Provider.list() triggers:
|
||||
// 1. BunProc.install("@aws-sdk/credential-providers") - in bedrock custom loader
|
||||
// 2. Plugin.list() which calls BunProc.install() for default plugins
|
||||
// Without mocks, these would attempt real package installations that timeout in tests.
|
||||
|
||||
mock.module("../../src/bun/index", () => ({
|
||||
BunProc: {
|
||||
install: async (pkg: string) => pkg,
|
||||
run: async () => {
|
||||
throw new Error("BunProc.run should not be called in tests")
|
||||
},
|
||||
which: () => process.execPath,
|
||||
InstallFailedError: class extends Error {},
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@aws-sdk/credential-providers", () => ({
|
||||
fromNodeProviderChain: () => async () => ({
|
||||
accessKeyId: "mock-access-key-id",
|
||||
secretAccessKey: "mock-secret-access-key",
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockPlugin = () => ({})
|
||||
mock.module("opencode-copilot-auth", () => ({ default: mockPlugin }))
|
||||
mock.module("opencode-anthropic-auth", () => ({ default: mockPlugin }))
|
||||
|
||||
// Import after mocks are set up
|
||||
const { tmpdir } = await import("../fixture/fixture")
|
||||
const { Instance } = await import("../../src/project/instance")
|
||||
const { Provider } = await import("../../src/provider/provider")
|
||||
const { Env } = await import("../../src/env")
|
||||
const { Global } = await import("../../src/global")
|
||||
|
||||
test("Bedrock: config region takes precedence over AWS_REGION env var", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
@@ -34,13 +63,12 @@ test("Bedrock: config region takes precedence over AWS_REGION env var", async ()
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["amazon-bedrock"]).toBeDefined()
|
||||
// Region from config should be used (not env var)
|
||||
expect(providers["amazon-bedrock"].options?.region).toBe("eu-west-1")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("Bedrock: falls back to AWS_REGION env var when no config", async () => {
|
||||
test("Bedrock: falls back to AWS_REGION env var when no config region", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
@@ -65,129 +93,6 @@ test("Bedrock: falls back to AWS_REGION env var when no config", async () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("Bedrock: without explicit region config, uses AWS_REGION env or defaults", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("AWS_PROFILE", "default")
|
||||
// AWS_REGION might be set in the environment, use that or default
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["amazon-bedrock"]).toBeDefined()
|
||||
// Should have some region set (either from env or default)
|
||||
expect(providers["amazon-bedrock"].options?.region).toBeDefined()
|
||||
expect(typeof providers["amazon-bedrock"].options?.region).toBe("string")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("Bedrock: uses config region in provider options", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: {
|
||||
region: "eu-north-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("AWS_PROFILE", "default")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
const bedrockProvider = providers["amazon-bedrock"]
|
||||
expect(bedrockProvider).toBeDefined()
|
||||
expect(bedrockProvider.options?.region).toBe("eu-north-1")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("Bedrock: respects config region for different instances", async () => {
|
||||
// First instance with EU config
|
||||
await using tmp1 = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: {
|
||||
region: "eu-west-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp1.path,
|
||||
init: async () => {
|
||||
Env.set("AWS_PROFILE", "default")
|
||||
Env.set("AWS_REGION", "us-east-1")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers1 = await Provider.list()
|
||||
expect(providers1["amazon-bedrock"].options?.region).toBe("eu-west-1")
|
||||
},
|
||||
})
|
||||
|
||||
// Second instance with US config
|
||||
await using tmp2 = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: {
|
||||
region: "us-west-2",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp2.path,
|
||||
init: async () => {
|
||||
Env.set("AWS_PROFILE", "default")
|
||||
Env.set("AWS_REGION", "eu-west-1")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers2 = await Provider.list()
|
||||
expect(providers2["amazon-bedrock"].options?.region).toBe("us-west-2")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("Bedrock: loads when bearer token from auth.json is present", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
@@ -207,7 +112,6 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => {
|
||||
},
|
||||
})
|
||||
|
||||
// Setup auth.json with bearer token for amazon-bedrock
|
||||
const authPath = path.join(Global.Path.data, "auth.json")
|
||||
await Bun.write(
|
||||
authPath,
|
||||
@@ -222,7 +126,6 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
// Clear env vars so only auth.json should trigger autoload
|
||||
Env.set("AWS_PROFILE", "")
|
||||
Env.set("AWS_ACCESS_KEY_ID", "")
|
||||
Env.set("AWS_BEARER_TOKEN_BEDROCK", "")
|
||||
@@ -234,3 +137,69 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: {
|
||||
profile: "my-custom-profile",
|
||||
region: "us-east-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("AWS_PROFILE", "default")
|
||||
Env.set("AWS_ACCESS_KEY_ID", "test-key-id")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["amazon-bedrock"]).toBeDefined()
|
||||
expect(providers["amazon-bedrock"].options?.region).toBe("us-east-1")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("Bedrock: includes custom endpoint in options when specified", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
"amazon-bedrock": {
|
||||
options: {
|
||||
endpoint: "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("AWS_PROFILE", "default")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["amazon-bedrock"]).toBeDefined()
|
||||
expect(providers["amazon-bedrock"].options?.endpoint).toBe(
|
||||
"https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com",
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -410,6 +410,181 @@ describe("ProviderTransform.message - empty image handling", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("ProviderTransform.message - anthropic empty content filtering", () => {
|
||||
const anthropicModel = {
|
||||
id: "anthropic/claude-3-5-sonnet",
|
||||
providerID: "anthropic",
|
||||
api: {
|
||||
id: "claude-3-5-sonnet-20241022",
|
||||
url: "https://api.anthropic.com",
|
||||
npm: "@ai-sdk/anthropic",
|
||||
},
|
||||
name: "Claude 3.5 Sonnet",
|
||||
capabilities: {
|
||||
temperature: true,
|
||||
reasoning: false,
|
||||
attachment: true,
|
||||
toolcall: true,
|
||||
input: { text: true, audio: false, image: true, video: false, pdf: true },
|
||||
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
interleaved: false,
|
||||
},
|
||||
cost: {
|
||||
input: 0.003,
|
||||
output: 0.015,
|
||||
cache: { read: 0.0003, write: 0.00375 },
|
||||
},
|
||||
limit: {
|
||||
context: 200000,
|
||||
output: 8192,
|
||||
},
|
||||
status: "active",
|
||||
options: {},
|
||||
headers: {},
|
||||
} as any
|
||||
|
||||
test("filters out messages with empty string content", () => {
|
||||
const msgs = [
|
||||
{ role: "user", content: "Hello" },
|
||||
{ role: "assistant", content: "" },
|
||||
{ role: "user", content: "World" },
|
||||
] as any[]
|
||||
|
||||
const result = ProviderTransform.message(msgs, anthropicModel)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].content).toBe("Hello")
|
||||
expect(result[1].content).toBe("World")
|
||||
})
|
||||
|
||||
test("filters out empty text parts from array content", () => {
|
||||
const msgs = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "" },
|
||||
{ type: "text", text: "Hello" },
|
||||
{ type: "text", text: "" },
|
||||
],
|
||||
},
|
||||
] as any[]
|
||||
|
||||
const result = ProviderTransform.message(msgs, anthropicModel)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].content).toHaveLength(1)
|
||||
expect(result[0].content[0]).toEqual({ type: "text", text: "Hello" })
|
||||
})
|
||||
|
||||
test("filters out empty reasoning parts from array content", () => {
|
||||
const msgs = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "reasoning", text: "" },
|
||||
{ type: "text", text: "Answer" },
|
||||
{ type: "reasoning", text: "" },
|
||||
],
|
||||
},
|
||||
] as any[]
|
||||
|
||||
const result = ProviderTransform.message(msgs, anthropicModel)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].content).toHaveLength(1)
|
||||
expect(result[0].content[0]).toEqual({ type: "text", text: "Answer" })
|
||||
})
|
||||
|
||||
test("removes entire message when all parts are empty", () => {
|
||||
const msgs = [
|
||||
{ role: "user", content: "Hello" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "" },
|
||||
{ type: "reasoning", text: "" },
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "World" },
|
||||
] as any[]
|
||||
|
||||
const result = ProviderTransform.message(msgs, anthropicModel)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].content).toBe("Hello")
|
||||
expect(result[1].content).toBe("World")
|
||||
})
|
||||
|
||||
test("keeps non-text/reasoning parts even if text parts are empty", () => {
|
||||
const msgs = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "" },
|
||||
{ type: "tool-call", toolCallId: "123", toolName: "bash", input: { command: "ls" } },
|
||||
],
|
||||
},
|
||||
] as any[]
|
||||
|
||||
const result = ProviderTransform.message(msgs, anthropicModel)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].content).toHaveLength(1)
|
||||
expect(result[0].content[0]).toEqual({
|
||||
type: "tool-call",
|
||||
toolCallId: "123",
|
||||
toolName: "bash",
|
||||
input: { command: "ls" },
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps messages with valid text alongside empty parts", () => {
|
||||
const msgs = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "reasoning", text: "Thinking..." },
|
||||
{ type: "text", text: "" },
|
||||
{ type: "text", text: "Result" },
|
||||
],
|
||||
},
|
||||
] as any[]
|
||||
|
||||
const result = ProviderTransform.message(msgs, anthropicModel)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].content).toHaveLength(2)
|
||||
expect(result[0].content[0]).toEqual({ type: "reasoning", text: "Thinking..." })
|
||||
expect(result[0].content[1]).toEqual({ type: "text", text: "Result" })
|
||||
})
|
||||
|
||||
test("does not filter for non-anthropic providers", () => {
|
||||
const openaiModel = {
|
||||
...anthropicModel,
|
||||
providerID: "openai",
|
||||
api: {
|
||||
id: "gpt-4",
|
||||
url: "https://api.openai.com",
|
||||
npm: "@ai-sdk/openai",
|
||||
},
|
||||
}
|
||||
|
||||
const msgs = [
|
||||
{ role: "assistant", content: "" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "" }],
|
||||
},
|
||||
] as any[]
|
||||
|
||||
const result = ProviderTransform.message(msgs, openaiModel)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].content).toBe("")
|
||||
expect(result[1].content).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ProviderTransform.variants", () => {
|
||||
const createMockModel = (overrides: Partial<any> = {}): any => ({
|
||||
id: "test/test-model",
|
||||
|
||||
@@ -25,4 +25,4 @@
|
||||
"typescript": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,4 +30,4 @@
|
||||
"publishConfig": {
|
||||
"directory": "dist"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,6 +205,41 @@ You can also configure [local models](/docs/models#local). [Learn more](/docs/mo
|
||||
|
||||
---
|
||||
|
||||
#### Provider-Specific Options
|
||||
|
||||
Some providers support additional configuration options beyond the generic `timeout` and `apiKey` settings.
|
||||
|
||||
##### Amazon Bedrock
|
||||
|
||||
Amazon Bedrock supports AWS-specific configuration:
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"amazon-bedrock": {
|
||||
"options": {
|
||||
"region": "us-east-1",
|
||||
"profile": "my-aws-profile",
|
||||
"endpoint": "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `region` - AWS region for Bedrock (defaults to `AWS_REGION` env var or `us-east-1`)
|
||||
- `profile` - AWS named profile from `~/.aws/credentials` (defaults to `AWS_PROFILE` env var)
|
||||
- `endpoint` - Custom endpoint URL for VPC endpoints. This is an alias for the generic `baseURL` option using AWS-specific terminology. If both are specified, `endpoint` takes precedence.
|
||||
|
||||
:::note
|
||||
Bearer tokens (`AWS_BEARER_TOKEN_BEDROCK` or `/connect`) take precedence over profile-based authentication. See [authentication precedence](/docs/providers#authentication-precedence) for details.
|
||||
:::
|
||||
|
||||
[Learn more about Amazon Bedrock configuration](/docs/providers#amazon-bedrock).
|
||||
|
||||
---
|
||||
|
||||
### Themes
|
||||
|
||||
You can configure the theme you want to use in your OpenCode config through the `theme` option.
|
||||
|
||||
@@ -58,7 +58,7 @@ Or you can set it up manually.
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Run OpenCode
|
||||
uses: anomalyco/opencode/github@latest
|
||||
@@ -171,7 +171,7 @@ jobs:
|
||||
pull-requests: read
|
||||
issues: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- uses: anomalyco/opencode/github@latest
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
@@ -221,7 +221,7 @@ jobs:
|
||||
return days >= 30;
|
||||
result-encoding: string
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
if: steps.check.outputs.result == 'true'
|
||||
|
||||
- uses: anomalyco/opencode/github@latest
|
||||
|
||||
@@ -107,27 +107,96 @@ To use Amazon Bedrock with OpenCode:
|
||||
You need to have access to the model you want in Amazon Bedrock.
|
||||
:::
|
||||
|
||||
1. You'll need either to set one of the following environment variables:
|
||||
- `AWS_ACCESS_KEY_ID`: You can get this by creating an IAM user and generating
|
||||
an access key for it.
|
||||
- `AWS_PROFILE`: First login through AWS IAM Identity Center (or AWS SSO) using
|
||||
`aws sso login`. Then get the name of the profile you want to use.
|
||||
- `AWS_BEARER_TOKEN_BEDROCK`: You can generate a long-term API key from the
|
||||
Amazon Bedrock console.
|
||||
2. **Configure authentication** using one of the following methods:
|
||||
|
||||
Once you have one of the above, set it while running opencode.
|
||||
#### Environment Variables (Quick Start)
|
||||
|
||||
Set one of these environment variables while running opencode:
|
||||
|
||||
```bash
|
||||
AWS_ACCESS_KEY_ID=XXX opencode
|
||||
# Option 1: Using AWS access keys
|
||||
AWS_ACCESS_KEY_ID=XXX AWS_SECRET_ACCESS_KEY=YYY opencode
|
||||
|
||||
# Option 2: Using named AWS profile
|
||||
AWS_PROFILE=my-profile opencode
|
||||
|
||||
# Option 3: Using Bedrock bearer token
|
||||
AWS_BEARER_TOKEN_BEDROCK=XXX opencode
|
||||
```
|
||||
|
||||
Or add it to your bash profile.
|
||||
Or add them to your bash profile:
|
||||
|
||||
```bash title="~/.bash_profile"
|
||||
export AWS_ACCESS_KEY_ID=XXX
|
||||
export AWS_PROFILE=my-dev-profile
|
||||
export AWS_REGION=us-east-1
|
||||
```
|
||||
|
||||
1. Run the `/models` command to select the model you want.
|
||||
#### Configuration File (Recommended)
|
||||
|
||||
For project-specific or persistent configuration, use `opencode.json`:
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"amazon-bedrock": {
|
||||
"options": {
|
||||
"region": "us-east-1",
|
||||
"profile": "my-aws-profile"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Available options:**
|
||||
- `region` - AWS region (e.g., `us-east-1`, `eu-west-1`)
|
||||
- `profile` - AWS named profile from `~/.aws/credentials`
|
||||
- `endpoint` - Custom endpoint URL for VPC endpoints (alias for generic `baseURL` option)
|
||||
|
||||
:::tip
|
||||
Configuration file options take precedence over environment variables.
|
||||
:::
|
||||
|
||||
#### Advanced: VPC Endpoints
|
||||
|
||||
If you're using VPC endpoints for Bedrock:
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"amazon-bedrock": {
|
||||
"options": {
|
||||
"region": "us-east-1",
|
||||
"profile": "production",
|
||||
"endpoint": "https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
:::note
|
||||
The `endpoint` option is an alias for the generic `baseURL` option, using AWS-specific terminology. If both `endpoint` and `baseURL` are specified, `endpoint` takes precedence.
|
||||
:::
|
||||
|
||||
#### Authentication Methods
|
||||
- **`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`**: Create an IAM user and generate access keys in the AWS Console
|
||||
- **`AWS_PROFILE`**: Use named profiles from `~/.aws/credentials`. First configure with `aws configure --profile my-profile` or `aws sso login`
|
||||
- **`AWS_BEARER_TOKEN_BEDROCK`**: Generate long-term API keys from the Amazon Bedrock console
|
||||
|
||||
#### Authentication Precedence
|
||||
|
||||
Amazon Bedrock uses the following authentication priority:
|
||||
1. **Bearer Token** - `AWS_BEARER_TOKEN_BEDROCK` environment variable or token from `/connect` command
|
||||
2. **AWS Credential Chain** - Profile, access keys, shared credentials, IAM roles, instance metadata
|
||||
|
||||
:::note
|
||||
When a bearer token is set (via `/connect` or `AWS_BEARER_TOKEN_BEDROCK`), it takes precedence over all AWS credential methods including configured profiles.
|
||||
:::
|
||||
|
||||
3. Run the `/models` command to select the model you want.
|
||||
|
||||
```txt
|
||||
/models
|
||||
|
||||
Reference in New Issue
Block a user