mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-26 23:45:08 +00:00
Every Opus entry and claude-sonnet-5 go 16384 -> 64000; claude-sonnet-4-6 and
claude-haiku-4-5 go to 32000 (haiku was 8192). These are the
max_output_tokens.default each model declares in the compiled CLI 2.1.220
registry, extracted id-anchored:
claude-opus-5 max_output_tokens:{default:64000,upper:128000}
claude-opus-4-8 max_output_tokens:{default:64000,upper:128000}
claude-opus-4-7 max_output_tokens:{default:64000,upper:128000}
claude-opus-4-6 max_output_tokens:{default:64000,upper:128000}
claude-sonnet-5 max_output_tokens:{default:64000,upper:128000}
claude-sonnet-4-6 max_output_tokens:{default:32000,upper:128000}
claude-haiku-4-5 max_output_tokens:{default:32000,upper:64000}
This is not cosmetic metadata. OCP itself never enforces maxTokens — server.mjs
does not read it — but it is propagated to OpenClaw (setup.mjs,
scripts/sync-openclaw.mjs), and OpenClaw CAPS the outbound request with it:
maxTokens = Math.min(baseMaxTokens + thinkingBudget, modelMaxTokens)
with reasoning budgets of medium 8192 / high 16384, and a clamp
`if (maxTokens <= thinkingBudget) thinkingBudget = maxTokens - 1024`.
So at `high` reasoning against a model declared at 16384: maxTokens resolves to
16384, the clamp fires, thinking takes 15360, and roughly 1024 tokens are left
for the visible answer. That is what this repo shipped for every model until
now, and it is the actual harm — not the advertised-vs-real mismatch.
ocp-connect keeps its own family-prefix table (/v1/models does not expose
maxTokens, so it cannot derive them). Set to the family FLOOR — opus 64000,
sonnet 32000 because sonnet-4-6 is 32000 while sonnet-5 is 64000, haiku 32000 —
because prefixes cannot distinguish versions and under-advertising merely caps
a client lower than the model allows, whereas over-advertising would promise
capacity a family member does not have. Commented in place.
New test asserts the PRINCIPLE, not the numbers: every maxTokens must exceed
OpenClaw's `high` thinking budget so an answer still fits. A value-pinning test
would need editing on every model addition and would not explain itself.
Mutation-proven: reverting one entry to 16384 fails with
"claude-opus-5 declares maxTokens=16384 <= 16384: at OpenClaw's 'high'
reasoning level the thinking budget would consume all but ~1024 tokens".
Owner-approved decision (option: align with registry defaults). Tradeoff
recorded in CHANGELOG: longer answers, correspondingly higher per-request quota
burn on long generations.
Verified: bash -n on ocp-connect, and py_compile on its embedded python block
(lines 101-334) so the comment sits at a valid indentation.
Tests: 458 passed, 0 failed (457 + 1).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017gbqUZ8HfBZpjjbzQ85oH8
744 lines
28 KiB
Bash
Executable File
744 lines
28 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# ocp-connect — Lightweight client script to connect to a remote OCP instance
|
|
# No dependencies beyond curl and python3 (available on most Linux/Mac systems)
|
|
#
|
|
# Install:
|
|
# curl -fsSL https://raw.githubusercontent.com/dtzp555-max/ocp/main/ocp-connect -o ocp-connect
|
|
# chmod +x ocp-connect
|
|
#
|
|
# Or run directly:
|
|
# curl -fsSL https://raw.githubusercontent.com/dtzp555-max/ocp/main/ocp-connect | bash -s -- <host-ip> --key <key>
|
|
#
|
|
set -euo pipefail
|
|
|
|
OCP_CONNECT_VERSION="1.3.0"
|
|
|
|
show_version() {
|
|
echo "ocp-connect $OCP_CONNECT_VERSION"
|
|
}
|
|
|
|
show_help() {
|
|
cat <<'EOF'
|
|
ocp-connect — Connect this machine to a remote OCP (Open Claude Proxy)
|
|
|
|
Configures OPENAI_BASE_URL and OPENAI_API_KEY in your shell rc file
|
|
so tools like Claude Code, Cline, Aider, etc. point to the remote OCP.
|
|
|
|
Usage:
|
|
ocp-connect <host-ip> [options]
|
|
|
|
Options:
|
|
--port PORT Port OCP listens on (default: 3456)
|
|
--key API_KEY API key (prompted interactively if remote requires auth)
|
|
--version Print version and exit
|
|
--help, -h Show this help
|
|
|
|
Examples:
|
|
ocp-connect 192.168.1.10
|
|
ocp-connect 192.168.1.10 --port 8080
|
|
ocp-connect 192.168.1.10 --key ocp_abc123
|
|
|
|
What it does:
|
|
1. Tests connectivity to the remote OCP
|
|
2. Verifies your API key (if auth is enabled)
|
|
3. Writes OPENAI_BASE_URL and OPENAI_API_KEY to ~/.bashrc or ~/.zshrc
|
|
4. Sets system-level env vars (launchctl on macOS, systemd on Linux)
|
|
5. Configures OpenClaw automatically; prints setup hints for other IDEs
|
|
(Cline, Continue.dev, Cursor — manual configuration required)
|
|
6. Runs a smoke test to confirm everything works
|
|
|
|
After running, reload your shell: source ~/.bashrc (or ~/.zshrc)
|
|
EOF
|
|
}
|
|
|
|
configure_ides() {
|
|
local base_url="$1" key="$2" models_out="$3"
|
|
|
|
# --- OpenClaw ---
|
|
local oc_config="$HOME/.openclaw/openclaw.json"
|
|
local oc_found=false
|
|
if command -v openclaw &>/dev/null || [[ -f "$oc_config" ]]; then
|
|
oc_found=true
|
|
fi
|
|
|
|
if $oc_found; then
|
|
echo " IDE Configuration"
|
|
echo " ─────────────────────────────────────"
|
|
echo " Detected: OpenClaw ($oc_config)"
|
|
echo ""
|
|
printf " Configure OpenClaw to use this OCP? [Y/n] "
|
|
local oc_answer
|
|
{ read -r oc_answer </dev/tty; } 2>/dev/null || oc_answer="y"
|
|
oc_answer="${oc_answer:-y}"
|
|
|
|
if [[ "$oc_answer" =~ ^[Yy]$ ]]; then
|
|
# Ask for provider name
|
|
printf " Provider name (models show as <name>/model-id) [ocp]: "
|
|
local provider_name
|
|
{ read -r provider_name </dev/tty; } 2>/dev/null || provider_name=""
|
|
provider_name="${provider_name:-ocp}"
|
|
# Sanitize: only allow alphanumeric, dash, underscore
|
|
provider_name=$(echo "$provider_name" | tr -cd 'a-zA-Z0-9_-')
|
|
[[ -z "$provider_name" ]] && provider_name="ocp"
|
|
|
|
# Ask for priority
|
|
echo ""
|
|
echo " How should OCP models be configured?"
|
|
echo " 1) Primary — use OCP by default, keep existing models as backup"
|
|
echo " 2) Backup — keep current primary, add OCP as additional option"
|
|
echo ""
|
|
printf " Choice [1]: "
|
|
local priority_choice
|
|
{ read -r priority_choice </dev/tty; } 2>/dev/null || priority_choice="1"
|
|
priority_choice="${priority_choice:-1}"
|
|
|
|
echo ""
|
|
echo " Writing OpenClaw config..."
|
|
|
|
# Use python3 to safely manipulate JSON
|
|
local py_ok=0
|
|
python3 - "$oc_config" "$base_url" "$key" "$provider_name" "$priority_choice" "$models_out" <<'PYEOF' && py_ok=1
|
|
import sys, json, os
|
|
|
|
config_path = sys.argv[1]
|
|
base_url = sys.argv[2]
|
|
api_key = sys.argv[3]
|
|
provider_name = sys.argv[4]
|
|
priority = sys.argv[5] # "1" = primary, "2" = backup
|
|
models_json_str = sys.argv[6]
|
|
|
|
# Parse models from OCP /v1/models response
|
|
try:
|
|
models_data = json.loads(models_json_str)
|
|
model_ids = [m["id"] for m in models_data.get("data", [])]
|
|
except:
|
|
model_ids = ["claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4"]
|
|
|
|
# Build provider entry
|
|
provider = {
|
|
"baseUrl": base_url + "/v1",
|
|
"api": "openai-completions",
|
|
"authHeader": bool(api_key),
|
|
"models": []
|
|
}
|
|
|
|
# Model metadata mapping. Prefix match on the model FAMILY (claude-opus / -sonnet /
|
|
# -haiku), not a pinned version. A version-pinned prefix like "claude-sonnet-4"
|
|
# silently misses "claude-sonnet-5" and falls through to the non-reasoning /
|
|
# 8k-output default (PR #152 review) — every future Sonnet/Opus/Haiku bump would
|
|
# re-trip it. Family prefixes classify any versioned ID correctly with no per-model
|
|
# edit. (ADR 0003: models.json is the SPOT for model existence; /v1/models does not
|
|
# expose reasoning/maxTokens, so family classification stays here.)
|
|
# maxTokens values are the FAMILY FLOOR of the CLI registry max_output_tokens.default
|
|
# (opus family 64000; sonnet 32000 because sonnet-4-6 is 32000 while sonnet-5 is 64000;
|
|
# haiku 32000). Family prefixes cannot distinguish versions, so the floor is used
|
|
# deliberately: under-advertising caps a client lower than the model allows, which is safe,
|
|
# whereas over-advertising would promise capacity a member of the family does not have.
|
|
# models.json is authoritative per ADR 0003; this table only exists because /v1/models does
|
|
# not expose maxTokens.
|
|
model_meta = {
|
|
"claude-opus": {"name": "Claude Opus (OCP)", "reasoning": True, "maxTokens": 64000},
|
|
"claude-sonnet": {"name": "Claude Sonnet (OCP)", "reasoning": True, "maxTokens": 32000},
|
|
"claude-haiku": {"name": "Claude Haiku (OCP)", "reasoning": False, "maxTokens": 32000},
|
|
}
|
|
|
|
def get_model_meta(mid):
|
|
"""Match model metadata by prefix."""
|
|
for prefix, meta in sorted(model_meta.items(), key=lambda x: -len(x[0])):
|
|
if mid.startswith(prefix):
|
|
return meta
|
|
return {"name": mid + " (OCP)", "reasoning": False, "maxTokens": 8192}
|
|
|
|
for mid in model_ids:
|
|
meta = get_model_meta(mid)
|
|
provider["models"].append({
|
|
"id": mid,
|
|
"name": meta["name"],
|
|
"reasoning": meta["reasoning"],
|
|
"input": ["text"],
|
|
"cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0},
|
|
"contextWindow": 200000,
|
|
"maxTokens": meta["maxTokens"]
|
|
})
|
|
|
|
# Load or create config
|
|
if os.path.exists(config_path):
|
|
with open(config_path, "r") as f:
|
|
config = json.load(f)
|
|
else:
|
|
os.makedirs(os.path.dirname(config_path), exist_ok=True)
|
|
config = {}
|
|
|
|
# Ensure models.providers exists
|
|
config.setdefault("models", {})
|
|
config["models"].setdefault("mode", "merge")
|
|
config["models"].setdefault("providers", {})
|
|
|
|
# Remove any previous OCP provider with the same name
|
|
config["models"]["providers"][provider_name] = provider
|
|
|
|
# Set up auth profile if key is provided
|
|
if api_key:
|
|
config.setdefault("auth", {})
|
|
config["auth"].setdefault("profiles", {})
|
|
config["auth"]["profiles"][provider_name + ":default"] = {
|
|
"provider": provider_name,
|
|
"mode": "api_key"
|
|
}
|
|
|
|
# Configure agent defaults — add model aliases
|
|
config.setdefault("agents", {})
|
|
config["agents"].setdefault("defaults", {})
|
|
config["agents"]["defaults"].setdefault("models", {})
|
|
|
|
# Build alias map (family prefix match — version-agnostic, see model_meta note)
|
|
alias_prefixes = {
|
|
"claude-opus": "Claude Opus",
|
|
"claude-sonnet": "Claude Sonnet",
|
|
"claude-haiku": "Claude Haiku",
|
|
}
|
|
|
|
for mid in model_ids:
|
|
full_id = provider_name + "/" + mid
|
|
alias = mid # fallback
|
|
for prefix, name in sorted(alias_prefixes.items(), key=lambda x: -len(x[0])):
|
|
if mid.startswith(prefix):
|
|
alias = name
|
|
break
|
|
config["agents"]["defaults"]["models"][full_id] = {"alias": alias}
|
|
|
|
# Handle primary/backup
|
|
if priority == "1":
|
|
# OCP as primary — pick the best model (prefer the latest Sonnet for daily use,
|
|
# tracking the `sonnet` alias default in models.json; fall back across versions).
|
|
_sonnet_pref = ["claude-sonnet-5", "claude-sonnet-4-6"]
|
|
primary_model = next(
|
|
(provider_name + "/" + m for m in _sonnet_pref if m in model_ids),
|
|
provider_name + "/" + model_ids[0],
|
|
)
|
|
config["agents"]["defaults"].setdefault("model", {})
|
|
config["agents"]["defaults"]["model"]["primary"] = primary_model
|
|
# Keep existing fallbacks
|
|
config["agents"]["defaults"]["model"].setdefault("fallbacks", [])
|
|
|
|
# If backup (priority == "2"), don't change the primary — just add models to the list
|
|
|
|
# Update agent list entries that use old provider name patterns
|
|
# (only update if agents.list exists and has entries using old OCP-like providers)
|
|
if "list" in config.get("agents", {}):
|
|
for agent in config["agents"]["list"]:
|
|
agent_model = agent.get("model", {})
|
|
if priority == "1" and agent_model.get("primary", "").startswith("claude-local/"):
|
|
# Migrate from claude-local to new provider
|
|
old_model_id = agent_model["primary"].split("/", 1)[1]
|
|
if old_model_id in model_ids:
|
|
agent_model["primary"] = provider_name + "/" + old_model_id
|
|
# Also update subagents if they use claude-local
|
|
sub = agent.get("subagents", config["agents"]["defaults"].get("subagents", {}))
|
|
# Don't modify subagents in agent entries — they inherit from defaults
|
|
|
|
# Update defaults subagents model if using claude-local
|
|
if priority == "1":
|
|
sub = config["agents"]["defaults"].get("subagents", {})
|
|
if sub.get("model", "").startswith("claude-local/"):
|
|
old_id = sub["model"].split("/", 1)[1]
|
|
if old_id in model_ids:
|
|
sub["model"] = provider_name + "/" + old_id
|
|
|
|
with open(config_path, "w") as f:
|
|
json.dump(config, f, indent=2, ensure_ascii=False)
|
|
f.write("\n")
|
|
|
|
# === B1 fix: seed per-agent auth-profiles.json for OpenClaw multi-agent setups ===
|
|
# OpenClaw's per-agent auth loader reads <agentDir>/auth-profiles.json (NOT the
|
|
# root openclaw.json's auth.profiles section). Without a real key in each agent's
|
|
# agentDir, OpenClaw rejects the lane with "No API key found for provider X".
|
|
# See https://github.com/dtzp555-max/ocp/issues/12 for the full investigation.
|
|
_seeded = []
|
|
_failed = []
|
|
_skipped_anonymous = []
|
|
_profile_key = provider_name + ":default"
|
|
# OpenClaw stores per-agent auth in <openclaw_root>/agents/<id>/agent/ when
|
|
# the agent has no explicit `agentDir` field — derive that path so the
|
|
# default `main` agent (which never sets agentDir) is also seeded.
|
|
_openclaw_root = os.path.dirname(config_path)
|
|
for _agent in config.get("agents", {}).get("list", []):
|
|
_agent_dir = _agent.get("agentDir")
|
|
if not _agent_dir:
|
|
_agent_id = _agent.get("id")
|
|
if not _agent_id:
|
|
continue
|
|
_agent_dir = os.path.join(_openclaw_root, "agents", _agent_id, "agent")
|
|
if not api_key:
|
|
# Anonymous mode is incompatible with OpenClaw per-agent auth (empty key
|
|
# is dropped by OpenClaw's pi-auth-credentials). Per OCP issue #12 §14
|
|
# decision: take Path C (require --key for OpenClaw multi-agent setups).
|
|
_skipped_anonymous.append(_agent_dir)
|
|
continue
|
|
_profiles_path = os.path.join(_agent_dir, "auth-profiles.json")
|
|
try:
|
|
os.makedirs(_agent_dir, exist_ok=True)
|
|
except OSError as _e:
|
|
_failed.append((_profiles_path, "mkdir: " + str(_e)))
|
|
continue
|
|
if os.path.exists(_profiles_path):
|
|
try:
|
|
with open(_profiles_path) as _f:
|
|
_ap = json.load(_f)
|
|
except json.JSONDecodeError:
|
|
# Corrupted JSON — back up and rebuild from scratch.
|
|
_bak = _profiles_path + ".bak"
|
|
try:
|
|
os.rename(_profiles_path, _bak)
|
|
print(f" ⚠ {_profiles_path} was corrupt; backed up to {_bak}")
|
|
except OSError:
|
|
pass
|
|
_ap = {"version": 1, "profiles": {}}
|
|
except OSError as _e:
|
|
# Read failure (permissions, disk) — skip to AVOID clobbering
|
|
# the user's existing profile (which may hold other providers' keys).
|
|
_failed.append((_profiles_path, "read: " + str(_e)))
|
|
continue
|
|
else:
|
|
_ap = {"version": 1, "profiles": {}}
|
|
_ap.setdefault("version", 1)
|
|
_ap.setdefault("profiles", {})
|
|
_ap["profiles"][_profile_key] = {
|
|
"type": "api_key",
|
|
"provider": provider_name,
|
|
"key": api_key
|
|
}
|
|
try:
|
|
with open(_profiles_path, "w") as _f:
|
|
json.dump(_ap, _f, indent=2, ensure_ascii=False)
|
|
_f.write("\n")
|
|
os.chmod(_profiles_path, 0o600)
|
|
_seeded.append(_profiles_path)
|
|
except OSError as _e:
|
|
_failed.append((_profiles_path, "write: " + str(_e)))
|
|
|
|
# Report — all three sections are independent (mixed scenarios are surfaced).
|
|
if _seeded:
|
|
print(f" ✓ Per-agent auth profile seeded ({len(_seeded)}):")
|
|
for _p in _seeded:
|
|
print(f" • {_p}")
|
|
if _failed:
|
|
print(f" ⚠ Per-agent auth profile FAILED ({len(_failed)}):")
|
|
for _p, _err in _failed:
|
|
print(f" • {_p}: {_err}")
|
|
print(" OpenClaw will report \"No API key found\" for the failed agents.")
|
|
print(" Fix the underlying error (permissions / disk) and re-run ocp-connect.")
|
|
if _skipped_anonymous:
|
|
print(f" ⚠ OpenClaw multi-agent mode detected ({len(_skipped_anonymous)} agents).")
|
|
print(" Anonymous mode does not work for OpenClaw per-agent auth.")
|
|
print(" Re-run with: ocp-connect <host> --key ocp_xxx")
|
|
PYEOF
|
|
|
|
if [[ $py_ok -eq 1 ]]; then
|
|
echo " ✓ OpenClaw configured"
|
|
echo " Provider: $provider_name"
|
|
echo " Models:"
|
|
# List models from the already-fetched models_out
|
|
echo "$models_out" | python3 -c "
|
|
import sys,json
|
|
d=json.loads(sys.stdin.read())
|
|
pn='$provider_name'
|
|
for m in d.get('data',[]):
|
|
print(' • ' + pn + '/' + m['id'])
|
|
" 2>/dev/null
|
|
if [[ "$priority_choice" == "1" ]]; then
|
|
echo " Priority: PRIMARY (default model)"
|
|
else
|
|
echo " Priority: BACKUP (available in model selector)"
|
|
fi
|
|
echo ""
|
|
echo " Restart OpenClaw to apply: openclaw gateway restart"
|
|
else
|
|
echo " ⚠ Failed to write OpenClaw config. You can configure it manually."
|
|
fi
|
|
else
|
|
echo " Skipped OpenClaw configuration."
|
|
fi
|
|
echo ""
|
|
fi
|
|
|
|
# --- Other IDEs: print manual instructions ---
|
|
local other_ides_shown=false
|
|
|
|
# Collect VS Code extension list once (reused by Cline and Continue.dev checks)
|
|
local _vscode_exts=""
|
|
if command -v code &>/dev/null; then
|
|
_vscode_exts=$(code --list-extensions 2>/dev/null || true)
|
|
fi
|
|
if [[ -z "$_vscode_exts" && -d "$HOME/.vscode/extensions" ]]; then
|
|
_vscode_exts=$(ls "$HOME/.vscode/extensions/" 2>/dev/null || true)
|
|
fi
|
|
|
|
# Extract model IDs from /v1/models response for use in hints
|
|
local _model_ids
|
|
_model_ids=$(echo "$models_out" | python3 -c "
|
|
import sys,json
|
|
try:
|
|
d=json.loads(sys.stdin.read())
|
|
print(', '.join(m['id'] for m in d.get('data', [])))
|
|
except Exception:
|
|
print('claude-sonnet-4-6, claude-opus-4-6, claude-haiku-4-5-20251001')
|
|
" 2>/dev/null || echo "claude-sonnet-4-6, claude-opus-4-6, claude-haiku-4-5-20251001")
|
|
|
|
# Key display: truncate if longer than 16 chars to avoid screenshot leakage,
|
|
# show explicit "(none)" in anonymous mode so users don't paste blank fields.
|
|
local _key_display
|
|
if [[ -z "$key" ]]; then
|
|
_key_display="(none — anonymous mode; most external IDEs require a non-empty API Key)"
|
|
elif [[ ${#key} -gt 16 ]]; then
|
|
_key_display="${key:0:8}...${key: -4}"
|
|
else
|
|
_key_display="$key"
|
|
fi
|
|
|
|
# Detect Cline (VS Code extension saoudrizwan.claude-dev, see issue #12)
|
|
if echo "$_vscode_exts" | grep -qiE 'cline|saoudrizwan\.claude-dev'; then
|
|
if ! $other_ides_shown; then
|
|
echo " Other IDEs detected:"
|
|
other_ides_shown=true
|
|
fi
|
|
echo " • Cline: VSCode → Cline panel → Settings → API Provider = \"OpenAI Compatible\""
|
|
echo " Base URL: $base_url/v1"
|
|
echo " API Key: $_key_display"
|
|
echo " Model ID: $_model_ids"
|
|
fi
|
|
|
|
# Detect Continue.dev (extension ID continue.continue or config file, see issue #12)
|
|
if echo "$_vscode_exts" | grep -qi 'continue\.continue' \
|
|
|| [[ -f "$HOME/.continue/config.yaml" ]] \
|
|
|| [[ -f "$HOME/.continue/config.json" ]]; then
|
|
if ! $other_ides_shown; then
|
|
echo " Other IDEs detected:"
|
|
other_ides_shown=true
|
|
fi
|
|
echo " • Continue.dev: edit ~/.continue/config.yaml — add under \`models:\` (top-level key):"
|
|
echo " models:"
|
|
echo " - name: OCP Sonnet"
|
|
echo " provider: openai"
|
|
echo " model: claude-sonnet-4-6"
|
|
echo " apiBase: $base_url/v1"
|
|
echo " apiKey: $_key_display"
|
|
echo " (other model IDs: $_model_ids)"
|
|
fi
|
|
|
|
# Detect Cursor (command, ~/.cursor dir, or /Applications/Cursor.app, see issue #12)
|
|
if command -v cursor &>/dev/null \
|
|
|| [[ -d "$HOME/.cursor" ]] \
|
|
|| [[ -d "/Applications/Cursor.app" ]]; then
|
|
if ! $other_ides_shown; then
|
|
echo " Other IDEs detected:"
|
|
other_ides_shown=true
|
|
fi
|
|
echo " • Cursor: Cmd+Shift+P → 'Cursor Settings' → Models →"
|
|
echo " OpenAI API Key: $_key_display"
|
|
echo " Override OpenAI Base URL: $base_url/v1"
|
|
echo " Custom OpenAI Models: $_model_ids"
|
|
fi
|
|
|
|
# Detect opencode (https://opencode.ai, SST team CLI, see issue #12)
|
|
if command -v opencode &>/dev/null \
|
|
|| [[ -x "$HOME/.opencode/bin/opencode" ]] \
|
|
|| [[ -d "$HOME/.local/share/opencode" ]]; then
|
|
if ! $other_ides_shown; then
|
|
echo " Other IDEs detected:"
|
|
other_ides_shown=true
|
|
fi
|
|
echo " • opencode: not yet auto-configured by ocp-connect (PR follow-up)."
|
|
echo " Run \`opencode providers login openai\` and provide:"
|
|
echo " Base URL: $base_url/v1"
|
|
echo " API Key: $_key_display"
|
|
echo " Available model IDs: $_model_ids"
|
|
fi
|
|
|
|
if $other_ides_shown; then
|
|
echo ""
|
|
fi
|
|
}
|
|
|
|
main() {
|
|
local host="" port=3456 key=""
|
|
|
|
# Parse args
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--port) port="${2:?'--port requires a value'}"; shift 2 ;;
|
|
--key) key="${2:?'--key requires a value'}"
|
|
[[ -z "$key" ]] && { echo "Error: --key cannot be empty (omit --key entirely for anonymous mode)"; exit 1; }
|
|
shift 2 ;;
|
|
--version) show_version; exit 0 ;;
|
|
--help|-h) show_help; exit 0 ;;
|
|
-*) echo "Unknown option: $1"; show_help; exit 1 ;;
|
|
*) host="$1"; shift ;;
|
|
esac
|
|
done
|
|
|
|
if [[ -z "$host" ]]; then
|
|
echo "Error: host IP is required."
|
|
echo ""
|
|
show_help
|
|
exit 1
|
|
fi
|
|
|
|
if ! [[ "$host" =~ ^[a-zA-Z0-9._-]+$ ]]; then
|
|
echo "Error: invalid host '$host'"
|
|
exit 1
|
|
fi
|
|
|
|
# Check dependencies
|
|
for cmd in curl python3; do
|
|
if ! command -v "$cmd" &>/dev/null; then
|
|
echo "Error: '$cmd' is required but not found."
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
local base_url="http://$host:$port"
|
|
|
|
echo "OCP Connect v$OCP_CONNECT_VERSION"
|
|
echo "─────────────────────────────────────"
|
|
echo " Remote: $base_url"
|
|
echo ""
|
|
|
|
# Step 1: Test connectivity via /health
|
|
echo " Checking connectivity..."
|
|
local health_json
|
|
health_json=$(curl -sf --max-time 10 "$base_url/health" 2>/dev/null) || {
|
|
echo " ✗ Cannot reach $base_url/health"
|
|
echo " Make sure OCP is running on $host and bound to 0.0.0.0 (LAN mode)."
|
|
exit 1
|
|
}
|
|
echo " ✓ Connected"
|
|
echo ""
|
|
|
|
# Step 2: Show remote info
|
|
local remote_version auth_mode
|
|
remote_version=$(echo "$health_json" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(d.get('version','?'))" 2>/dev/null || echo "?")
|
|
auth_mode=$(echo "$health_json" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(d.get('authMode','none'))" 2>/dev/null || echo "none")
|
|
|
|
echo " Remote OCP v$remote_version (auth: $auth_mode)"
|
|
echo ""
|
|
|
|
# Step 2.5: auto-discover anonymous key from /health (issue #12 §14 Path A).
|
|
# The server advertises anonymousKey in /health ONLY when the admin has set
|
|
# PROXY_ADVERTISE_ANON_KEY=1 (default off — /health is unauthenticated, so
|
|
# advertising exposes the shared key to any LAN-reachable device; issue #109).
|
|
# Localhost callers always receive it regardless. When the field is absent,
|
|
# ocp-connect falls back to anonymous access / interactive --key (step 3 below).
|
|
if [[ -z "$key" ]]; then
|
|
local anon_key
|
|
anon_key=$(echo "$health_json" | python3 -c "
|
|
import sys, json
|
|
try:
|
|
d = json.loads(sys.stdin.read())
|
|
k = d.get('anonymousKey')
|
|
except Exception:
|
|
k = None
|
|
print(k if k else '')
|
|
" 2>/dev/null || echo "")
|
|
if [[ -n "$anon_key" ]]; then
|
|
key="$anon_key"
|
|
local _anon_display="$anon_key"
|
|
if [[ ${#anon_key} -gt 16 ]]; then
|
|
_anon_display="${anon_key:0:8}...${anon_key: -4}"
|
|
fi
|
|
echo " ⓘ Using server-advertised anonymous key: $_anon_display"
|
|
echo " (set by admin via PROXY_ANONYMOUS_KEY; see issue #12 §14 Path A)"
|
|
echo ""
|
|
fi
|
|
fi
|
|
|
|
# Step 3: Determine if key is needed
|
|
if [[ "$auth_mode" != "none" && -z "$key" ]]; then
|
|
# Try anonymous access first (zero-config: server may allow it)
|
|
if curl -sf --max-time 5 "$base_url/v1/models" >/dev/null 2>&1; then
|
|
echo " Server allows anonymous access — no key needed."
|
|
echo ""
|
|
else
|
|
echo " Remote requires authentication."
|
|
echo " Ask the OCP admin to run: ocp keys add <name>"
|
|
printf " Enter API key (or press Enter to skip): "
|
|
{ read -rs key </dev/tty; } 2>/dev/null || key=""
|
|
echo
|
|
if [[ -z "$key" ]]; then
|
|
echo ""
|
|
echo " ✗ No key provided — cannot connect to an auth-required remote without a key."
|
|
echo " Use: ocp-connect $host --key <key>"
|
|
exit 1
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# Step 4: Test API access via /v1/models
|
|
echo " Testing API access..."
|
|
local models_out models_ok=0
|
|
if [[ -n "$key" ]]; then
|
|
models_out=$(curl -sf --max-time 10 \
|
|
-H "Authorization: Bearer $key" \
|
|
"$base_url/v1/models" 2>/dev/null) && models_ok=1
|
|
else
|
|
models_out=$(curl -sf --max-time 10 "$base_url/v1/models" 2>/dev/null) && models_ok=1
|
|
fi
|
|
|
|
if [[ $models_ok -eq 0 ]]; then
|
|
echo " ✗ API access failed — key may be invalid or revoked."
|
|
exit 1
|
|
fi
|
|
local model_count
|
|
model_count=$(echo "$models_out" | python3 -c "import sys,json; print(len(json.loads(sys.stdin.read()).get('data',[])))" 2>/dev/null || echo "?")
|
|
echo " ✓ API accessible ($model_count models available)"
|
|
echo ""
|
|
|
|
# Step 5: Detect shell rc files and OS
|
|
local rc_files=()
|
|
local is_mac=false
|
|
[[ "$(uname)" == "Darwin" ]] && is_mac=true
|
|
|
|
# Write to all relevant rc files
|
|
if [[ "${SHELL:-}" == */fish ]]; then
|
|
echo " Note: fish shell detected. Writing to ~/.bashrc — add to fish config manually."
|
|
rc_files+=("$HOME/.bashrc")
|
|
elif $is_mac; then
|
|
# macOS: default shell since Catalina (2019) is zsh.
|
|
# Always write ~/.zshrc (create if absent — zsh tolerates an empty file).
|
|
# Only write ~/.bashrc if it already exists (don't surprise users with new files).
|
|
[[ -f "$HOME/.bashrc" ]] && rc_files+=("$HOME/.bashrc")
|
|
# zshrc: always include on macOS; create the file if it doesn't exist yet
|
|
[[ -f "$HOME/.zshrc" ]] || touch "$HOME/.zshrc"
|
|
rc_files+=("$HOME/.zshrc")
|
|
else
|
|
# Linux / other: write to whichever rc files already exist or match current shell
|
|
[[ -f "$HOME/.bashrc" || "${SHELL:-}" == */bash ]] && rc_files+=("$HOME/.bashrc")
|
|
[[ -f "$HOME/.zshrc" || "${SHELL:-}" == */zsh ]] && rc_files+=("$HOME/.zshrc")
|
|
# If neither exists, fall back to creating one for the current shell
|
|
[[ ${#rc_files[@]} -eq 0 ]] && rc_files+=("$HOME/.${SHELL##*/}rc")
|
|
fi
|
|
|
|
# Step 6: Remove any previously written OCP LAN lines (idempotent) from all rc files
|
|
for rc_file in "${rc_files[@]}"; do
|
|
if [[ -f "$rc_file" ]]; then
|
|
local tmp_rc
|
|
tmp_rc=$(mktemp)
|
|
if python3 - "$rc_file" "$tmp_rc" <<'PYEOF'
|
|
import sys
|
|
src, dst = sys.argv[1], sys.argv[2]
|
|
lines = open(src).readlines()
|
|
out = []
|
|
skip = False
|
|
for line in lines:
|
|
s = line.rstrip('\n')
|
|
if s == '# OCP LAN (added by ocp connect)':
|
|
skip = True
|
|
continue
|
|
if skip and (s == '' or s.startswith('export OPENAI_BASE_URL=') or s.startswith('export OPENAI_API_KEY=')):
|
|
continue
|
|
skip = False
|
|
out.append(line)
|
|
open(dst, 'w').writelines(out)
|
|
PYEOF
|
|
then
|
|
cp "$tmp_rc" "$rc_file"
|
|
fi
|
|
rm -f "$tmp_rc"
|
|
fi
|
|
done
|
|
|
|
# Step 7: Append new config to all rc files
|
|
for rc_file in "${rc_files[@]}"; do
|
|
{
|
|
echo ""
|
|
echo "# OCP LAN (added by ocp connect)"
|
|
echo "export OPENAI_BASE_URL='$base_url/v1'"
|
|
if [[ -n "$key" ]]; then
|
|
echo "export OPENAI_API_KEY='$key'"
|
|
fi
|
|
} >> "$rc_file"
|
|
chmod 600 "$rc_file" 2>/dev/null || true
|
|
done
|
|
|
|
echo " Shell config:"
|
|
for rc_file in "${rc_files[@]}"; do
|
|
echo " ✓ $(basename "$rc_file")"
|
|
done
|
|
echo " OPENAI_BASE_URL=$base_url/v1"
|
|
if [[ -n "$key" ]]; then
|
|
echo " OPENAI_API_KEY=${key:0:8}..."
|
|
fi
|
|
|
|
# Step 7b: System-level env vars (for IDEs, daemons, GUI apps)
|
|
if $is_mac; then
|
|
# macOS: launchctl setenv makes vars visible to all GUI apps and launchd services
|
|
launchctl setenv OPENAI_BASE_URL "$base_url/v1" 2>/dev/null
|
|
if [[ -n "$key" ]]; then
|
|
launchctl setenv OPENAI_API_KEY "$key" 2>/dev/null
|
|
fi
|
|
echo ""
|
|
echo " System-level (launchctl):"
|
|
echo " ✓ OPENAI_BASE_URL set for GUI apps and daemons"
|
|
echo " Note: launchctl vars reset on reboot. Add to Login Items or re-run ocp-connect."
|
|
else
|
|
# Linux: write to environment.d for systemd user services
|
|
local env_dir="$HOME/.config/environment.d"
|
|
mkdir -p "$env_dir" 2>/dev/null
|
|
{
|
|
echo "OPENAI_BASE_URL=$base_url/v1"
|
|
if [[ -n "$key" ]]; then
|
|
echo "OPENAI_API_KEY=$key"
|
|
fi
|
|
} > "$env_dir/ocp.conf"
|
|
chmod 600 "$env_dir/ocp.conf" 2>/dev/null || true
|
|
echo ""
|
|
echo " System-level (systemd):"
|
|
echo " ✓ $env_dir/ocp.conf"
|
|
echo " Applies to systemd user services after re-login."
|
|
fi
|
|
echo ""
|
|
|
|
# Step 7c: Interactive IDE configuration
|
|
configure_ides "$base_url" "$key" "$models_out"
|
|
|
|
# Step 8: Quick smoke test
|
|
echo " Running smoke test..."
|
|
# Pick the first available model from /v1/models
|
|
local smoke_model
|
|
smoke_model=$(echo "$models_out" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(d['data'][0]['id'])" 2>/dev/null || echo "claude-haiku-4-5-20251001")
|
|
local chat_payload="{\"model\":\"$smoke_model\",\"messages\":[{\"role\":\"user\",\"content\":\"Reply with OK only.\"}],\"max_tokens\":10}"
|
|
local chat_out chat_ok=0
|
|
if [[ -n "$key" ]]; then
|
|
chat_out=$(curl -sf --max-time 30 \
|
|
-H "Authorization: Bearer $key" \
|
|
-H "Content-Type: application/json" \
|
|
-d "$chat_payload" \
|
|
"$base_url/v1/chat/completions" 2>/dev/null) && chat_ok=1
|
|
else
|
|
chat_out=$(curl -sf --max-time 30 \
|
|
-H "Content-Type: application/json" \
|
|
-d "$chat_payload" \
|
|
"$base_url/v1/chat/completions" 2>/dev/null) && chat_ok=1
|
|
fi
|
|
|
|
if [[ $chat_ok -eq 1 ]]; then
|
|
local reply
|
|
reply=$(echo "$chat_out" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(d['choices'][0]['message']['content'].strip())" 2>/dev/null || echo "(response received)")
|
|
echo " ✓ Smoke test passed: $reply"
|
|
echo " Note: smoke test only verifies OCP is reachable and the key is valid."
|
|
echo " It does not verify your IDE/agent end-to-end. To verify OpenClaw works,"
|
|
echo " restart it (\`openclaw gateway restart\`) and send a test message to your bot."
|
|
else
|
|
echo " ⚠ Smoke test failed (proxy is reachable but chat completion did not succeed)."
|
|
echo " The env vars have still been written. Check the remote OCP logs."
|
|
fi
|
|
|
|
echo ""
|
|
echo " Done. Reload your shell to apply:"
|
|
for rc_file in "${rc_files[@]}"; do
|
|
echo " source $rc_file"
|
|
done
|
|
}
|
|
|
|
main "$@"
|