diff --git a/.env.example b/.env.example index 921a0e0f8..b9ccba204 100644 --- a/.env.example +++ b/.env.example @@ -16,8 +16,11 @@ OPENCLAW_TELEGRAM_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz OPENCLAW_ANTHROPIC_API_KEY=sk-ant-api03-your-key-here # Gateway token for remote mode clients (from kyber: cat ~/.config/openclaw/gateway-token) OPENCLAW_GATEWAY_TOKEN=your-gateway-token-here -# OpenCode API key for OpenCode Go access via cliproxyapi +# OpenCode API key for OpenCode Go access via cliproxyapi. +# For multiple accounts, set OPENCODE_API_KEYS to a comma-separated list instead; +# it takes precedence over this legacy singular value. OPENCODE_API_KEY=your-opencode-api-key-here +# OPENCODE_API_KEYS=first-opencode-api-key,second-opencode-api-key # xAI API key for Grok access via x.ai subscription (used by opencode xai provider) XAI_API_KEY=xai-your-key-here # Roborev CI repos (comma-separated) diff --git a/config/cliproxyapi/config.template.yaml b/config/cliproxyapi/config.template.yaml index 7ba6272e6..1301be104 100644 --- a/config/cliproxyapi/config.template.yaml +++ b/config/cliproxyapi/config.template.yaml @@ -134,8 +134,7 @@ openai-compatibility: - name: "opencode" priority: 300 base-url: "https://opencode.ai/zen/go/v1" - api-key-entries: - - api-key: "__OPENCODE_API_KEY__" + api-key-entries: __OPENCODE_API_KEY_ENTRIES__ models: - name: "deepseek-v4-pro" alias: "deepseek-v4-pro" diff --git a/config/cliproxyapi/config.tpl.yaml b/config/cliproxyapi/config.tpl.yaml index 89cab9759..e3b773646 100644 --- a/config/cliproxyapi/config.tpl.yaml +++ b/config/cliproxyapi/config.tpl.yaml @@ -134,8 +134,7 @@ openai-compatibility: - name: "opencode" priority: 300 base-url: "https://opencode.ai/zen/go/v1" - api-key-entries: - - api-key: "__OPENCODE_API_KEY__" + api-key-entries: __OPENCODE_API_KEY_ENTRIES__ models: - name: "__DEEPSEEK_PRO__" alias: "__DEEPSEEK_PRO__" diff --git a/home-manager/services/cliproxyapi/README.md b/home-manager/services/cliproxyapi/README.md index ed0eb8955..243a99b57 100644 --- a/home-manager/services/cliproxyapi/README.md +++ b/home-manager/services/cliproxyapi/README.md @@ -113,6 +113,16 @@ QWEN_API_KEY="sk-..." ALIYUN_TOKEN_PLAN_API_KEY="sk-sp-..." ``` +For more than one OpenCode Go account behind the same upstream endpoint, set a +comma-separated credential pool. The plural value takes precedence over the +legacy singular value, and empty or duplicate entries are ignored: + +```bash +OPENCODE_API_KEYS="first-opencode-api-key,second-opencode-api-key" +``` + +CLIProxyAPI selects from these entries using the configured routing strategy. + ## Usage ```bash diff --git a/home-manager/services/cliproxyapi/scripts/start.sh b/home-manager/services/cliproxyapi/scripts/start.sh index a72ae3423..2870dc418 100755 --- a/home-manager/services/cliproxyapi/scripts/start.sh +++ b/home-manager/services/cliproxyapi/scripts/start.sh @@ -16,6 +16,51 @@ MANAGEMENT_PASSWORD="${CLIPROXY_MANAGEMENT_PASSWORD:-}" MANAGEMENT_KEY="${CLIPROXY_MANAGEMENT_PASSWORD:-${CLIPROXY_MANAGEMENT_KEY:-}}" export OBJECTSTORE_ENDPOINT OBJECTSTORE_BUCKET OBJECTSTORE_ACCESS_KEY OBJECTSTORE_SECRET_KEY OBJECTSTORE_LOCAL_PATH MANAGEMENT_PASSWORD +render_opencode_api_key_entries() { + local template="$1" + local key_source="${OPENCODE_API_KEYS:-${OPENCODE_API_KEY:-}}" + local candidate existing_key trimmed escaped line + local -a candidates=() + local -a api_keys=() + + if [ -n "$key_source" ]; then + IFS=',' read -r -a candidates <<<"$key_source" + fi + + for candidate in "${candidates[@]}"; do + trimmed="${candidate#"${candidate%%[![:space:]]*}"}" + trimmed="${trimmed%"${trimmed##*[![:space:]]}"}" + [ -n "$trimmed" ] || continue + + for existing_key in "${api_keys[@]}"; do + if [ "$existing_key" = "$trimmed" ]; then + trimmed="" + break + fi + done + [ -n "$trimmed" ] && api_keys+=("$trimmed") + done + + while IFS= read -r line || [ -n "$line" ]; do + if [ "$line" != " api-key-entries: __OPENCODE_API_KEY_ENTRIES__" ]; then + printf '%s\n' "$line" + continue + fi + + if [ "${#api_keys[@]}" -eq 0 ]; then + printf '%s\n' ' api-key-entries: []' + continue + fi + + printf '%s\n' ' api-key-entries:' + for candidate in "${api_keys[@]}"; do + escaped="${candidate//\\/\\\\}" + escaped="${escaped//\"/\\\"}" + printf ' - api-key: "%s"\n' "$escaped" + done + done <"$template" +} + if cliproxy_has_objectstore_credentials; then mkdir -p "$AUTH_DIR" @@ -36,17 +81,15 @@ fi # Generate config from template if [ -f "$TEMPLATE" ]; then - @sed@ \ + render_opencode_api_key_entries "$TEMPLATE" | @sed@ \ -e "s|__OPENROUTER_API_KEY__|${OPENROUTER_API_KEY:-}|g" \ -e "s|__OPENAI_API_KEY__|${OPENAI_API_KEY:-}|g" \ -e "s|__CLIPROXY_MANAGEMENT_PASSWORD__|${CLIPROXY_MANAGEMENT_PASSWORD:-}|g" \ -e "s|__ZAI_API_KEY__|${ZAI_API_KEY:-}|g" \ -e "s|__QWEN_API_KEY__|${QWEN_API_KEY:-${DASHSCOPE_API_KEY:-}}|g" \ -e "s|__ALIYUN_TOKEN_PLAN_API_KEY__|${ALIYUN_TOKEN_PLAN_API_KEY:-}|g" \ - -e "s|__OPENCODE_API_KEY__|${OPENCODE_API_KEY:-}|g" \ -e "s|__AMP_UPSTREAM_API_KEY__|${AMP_UPSTREAM_API_KEY:-}|g" \ - -e "s|__OPENCODE_API_KEY__|${OPENCODE_API_KEY:-}|g" \ - "$TEMPLATE" >"$CONFIG" + >"$CONFIG" if [ "$(uname)" = "Linux" ] && [ -n "${CLIPROXY_API_KEY:-}" ]; then @sed@ -i \ diff --git a/home-manager/services/hermes/activate.sh b/home-manager/services/hermes/activate.sh index 7a27080ed..af5ce9982 100644 --- a/home-manager/services/hermes/activate.sh +++ b/home-manager/services/hermes/activate.sh @@ -23,7 +23,7 @@ for unit in hermes-gateway.service hermes-dashboard.service hermes-dashboard-pro content="$(cat "$unit_path" 2>/dev/null || true)" if [ -n "$content" ]; then rm -f "$unit_path" - printf '%s\n' "$content" > "$unit_path" + printf '%s\n' "$content" >"$unit_path" chmod 644 "$unit_path" echo "Replaced symlink $unit with writable copy" fi diff --git a/spec/cass_indexer_spec.sh b/spec/cass_indexer_spec.sh index fec8913c4..038564c9d 100644 --- a/spec/cass_indexer_spec.sh +++ b/spec/cass_indexer_spec.sh @@ -60,7 +60,7 @@ HYDRATE="$PWD/home-manager/programs/cass/hydrate.sh" render() { rm -rf "$SHELLSPEC_TMPBASE/cass-home" mkdir -p "$SHELLSPEC_TMPBASE/cass-home" - env -u HOSTNAME HOME="$SHELLSPEC_TMPBASE/cass-home" HOST="$1" bash "$HYDRATE" 2>/dev/null + env -u HOSTNAME -u XDG_CONFIG_HOME HOME="$SHELLSPEC_TMPBASE/cass-home" HOST="$1" bash "$HYDRATE" 2>/dev/null if [ "$(uname -s)" = "Darwin" ]; then cat "$SHELLSPEC_TMPBASE/cass-home/Library/Application Support/cass/sources.toml" else diff --git a/spec/cliproxyapi_spec.sh b/spec/cliproxyapi_spec.sh index 28fe9ade4..cbc117ca0 100644 --- a/spec/cliproxyapi_spec.sh +++ b/spec/cliproxyapi_spec.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# shellcheck disable=SC2329 +# shellcheck disable=SC2329,SC2016 Describe 'cliproxyapi/start.sh' SCRIPT="$PWD/home-manager/services/cliproxyapi/scripts/start.sh" @@ -254,4 +254,60 @@ The status should be success End End +Describe 'OpenCode API key pool' +setup_opencode_pool() { + TEMP_POOL=$(mktemp -d) + cat >"$TEMP_POOL/template.yaml" <<'YAML' +openai-compatibility: + - name: "opencode" + api-key-entries: __OPENCODE_API_KEY_ENTRIES__ +YAML + sed -n '/^render_opencode_api_key_entries() {/,/^}/p' "$SCRIPT" >"$TEMP_POOL/render.sh" + cat >>"$TEMP_POOL/render.sh" <<'BASH' +render_opencode_api_key_entries "$1" +BASH +} + +cleanup_opencode_pool() { + rm -rf "$TEMP_POOL" +} + +Before 'setup_opencode_pool' +After 'cleanup_opencode_pool' + +It 'declares a generated credential pool for the existing OpenCode endpoint' +When run bash -c "sed -n '/name: \"opencode\"/,/name: \"openai\"/p' '$PWD/config/cliproxyapi/config.template.yaml'" +The output should include 'base-url: "https://opencode.ai/zen/go/v1"' +The output should include '__OPENCODE_API_KEY_ENTRIES__' +The output should not include '__OPENCODE_API_KEY__' +The status should be success +End + +It 'renders plural keys with singular fallback without a temporary config' +When run cat "$SCRIPT" +The output should include 'OPENCODE_API_KEYS:-${OPENCODE_API_KEY:-}' +The output should include 'api-key-entries: []' +The output should include 'api_keys+=("$trimmed")' +The output should include 'render_opencode_api_key_entries "$TEMPLATE" | @sed@' +The output should not include 'SED_CONFIG=' +The status should be success +End + +It 'renders each non-empty plural key once' +When run env OPENCODE_API_KEYS='first-key, second-key,first-key,, third-key ' bash "$TEMP_POOL/render.sh" "$TEMP_POOL/template.yaml" +The output should include ' - api-key: "first-key"' +The output should include ' - api-key: "second-key"' +The output should include ' - api-key: "third-key"' +The output should not include '__OPENCODE_API_KEY_ENTRIES__' +The status should be success +End + +It 'falls back to the legacy singular key' +When run env OPENCODE_API_KEYS='' OPENCODE_API_KEY='legacy-key' bash "$TEMP_POOL/render.sh" "$TEMP_POOL/template.yaml" +The output should include ' - api-key: "legacy-key"' +The output should not include 'api-key-entries: []' +The status should be success +End +End + End diff --git a/spec/coverage_spec.sh b/spec/coverage_spec.sh index 4df702ad5..69fb9f33c 100644 --- a/spec/coverage_spec.sh +++ b/spec/coverage_spec.sh @@ -360,7 +360,6 @@ It 'has spec file for named-hosts/kyber/activate-ip-forwarding.sh' The path "spec/activate_kyber_spec.sh" should be exist End - It 'has spec file for home-manager/activation/ensure-tailscale-serve.sh' The path "spec/tailscale_serve_spec.sh" should be exist End