feat: implement Opencode Unlocked plugin for OpenClaude with harness emulation and protocol translation

This commit is contained in:
John Smith committed 2026-10-06 23:35:53 -04:00
commit acfd3caf59
22 files changed
+2114

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
{
"name": "opencode-unlocked",
"owner": {
"name": "Opencode Unlocked Contributors",
"url": "https://github.com/opencode-unlocked"
},
"plugins": [
{
"name": "opencode-unlocked",
"source": "./",
"description": "Seamless OpenClaude provider plugin and authentic harness bridge for OpenCode Go models (default: Muse Spark 1.3 Contributor, Space Bunny, and more).",
"version": "1.0.0",
"category": "ai-providers",
"tags": ["opencode", "models", "provider", "muse-spark", "space-bunny"]
}
]
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "opencode-unlocked",
"version": "1.0.0",
"description": "Seamless OpenClaude provider plugin and authentic harness bridge for OpenCode Go models.",
"author": {
"name": "Opencode Unlocked Contributors"
},
"license": "MIT",
"keywords": [
"opencode",
"opencode-go",
"muse-spark",
"space-bunny",
"provider",
"bridge"
]
}
+23
View File
@@ -0,0 +1,23 @@
# Environment & Secrets (NEVER COMMIT)
.env
.env.*
*.key
*.token
credentials.json
# Local review repositories
review/
# Dependencies
node_modules/
package-lock.json
# Runtime & Process State
*.log
*.pid
.openclaude/
.cache/
# OS
.DS_Store
Thumbs.db
+148
View File
@@ -0,0 +1,148 @@
# Opencode Unlocked (OpenClaude Plugin)
A standalone plugin and native provider bridge for [OpenClaude](https://github.com/anthropics/openclaude) that unlocks full access to **OpenCode Go** subscription models (`https://opencode.ai/zen/go/v1`).
Features:
- **Authentic OpenCode v2 Harness Reverse Engineering**: Generates authentic 30-character descending session identifiers (`ses_<timestamp_base62>`), client affinity tracking, and full harness request headers (`User-Agent`, `x-opencode-client`, `x-opencode-session`, `x-opencode-project`, `x-session-affinity`).
- **Seamless Protocol Translation**: OpenClaude talks standard OpenAI `/v1/chat/completions`. Upstream models like **`muse-spark-1.3-contributor`** require OpenAI `/responses` API. The bridge transparently translates requests and bi-directionally streams Server-Sent Events (SSE).
- **Target Default Model**: Defaults to **`muse-spark-1.3-contributor`** with verified support for **`space-bunny`** and all 36+ OpenCode Go models.
- **Model Name Normalizer**: Strips any client prefixes (`opencode-unlocked-*`, `opencode-go-*`) to prevent upstream rejection.
- **Zero-Secret Distribution**: The repository contains no hardcoded keys or `.env` files. Users provide their key via environment variable, interactive command, or local config.
- **Marketplace Ready**: Fully compatible with OpenClaude's plugin and marketplace system (`marketplace.json` + `plugin.json`).
---
## Quick Start
### 1. Installation
#### Option A: OpenClaude Marketplace
In OpenClaude, add the repository marketplace and install the plugin:
```bash
/plugin marketplace add /path/to/opencode-4-openclaude
/plugin install opencode-unlocked@opencode-unlocked
```
#### Option B: Direct Plugin Flag
Launch OpenClaude directly with the plugin:
```bash
openclaude --plugin-dir /path/to/opencode-4-openclaude
```
---
### 2. Configure Your API Key
Provide your OpenCode Go API key (`oc_sk_...`) using any of the following methods:
1. **CLI Command**:
```bash
./bin/opencode-unlocked set-key oc_sk_your_key_here
```
2. **Environment Variable**:
```bash
export OPENCODE_UNLOCKED_API_KEY="oc_sk_your_key_here"
# or
export GO_KEY="oc_sk_your_key_here"
```
---
### 3. Provider Setup for OpenClaude
Run the setup utility to configure OpenClaude's profile:
```bash
node bin/setup.mjs
```
This automatically backs up your existing profile and sets:
- `OPENAI_BASE_URL`: `http://127.0.0.1:8788/v1`
- `OPENAI_MODEL`: `muse-spark-1.3-contributor`
To restore your previous profile at any time:
```bash
node bin/setup.mjs restore
```
---
## Daemon & CLI Management
Manage the background bridge daemon using `./bin/opencode-unlocked`:
```bash
# Check status, active model, and token consumption
./bin/opencode-unlocked status
# Start daemon
./bin/opencode-unlocked start
# Stop daemon
./bin/opencode-unlocked stop
# Restart daemon
./bin/opencode-unlocked restart
# List all 36+ available models
./bin/opencode-unlocked models
# Switch active default model
./bin/opencode-unlocked switch space-bunny
# Run live connectivity verification
./bin/opencode-unlocked test
```
---
## Supported Models
All 36+ OpenCode Go models are available, including:
| Model ID | Protocol | Specialization |
| :--- | :--- | :--- |
| **`muse-spark-1.3-contributor`** | `/responses` (Translated) | **Target Default**: Deep Reasoning & Coding |
| **`space-bunny`** | `/chat/completions` (Native) | Fast General & Code |
| **`gpt-5.6-luna`** | `/responses` (Translated) | High-Capability Reasoning |
| **`deepseek-v4-flash`** | `/chat/completions` (Native) | High-Throughput Flash |
| **`glm-5.3`** | `/chat/completions` (Native) | General Language |
| **`kimi-k2.5`** | `/chat/completions` (Native) | Long Context |
| **`minimax-01`** | `/chat/completions` (Native) | Conversational |
| **`qwen3.5-max`** | `/chat/completions` (Native) | General Capabilities |
---
## Architecture
```
OpenClaude CLI
│ (OpenAI /v1/chat/completions)
▼
Opencode Unlocked Gateway (127.0.0.1:8788)
├── Harness Fingerprint (ses_ + descending base62 session ID)
├── Session Affinity Cache (prompt caching preservation)
├── Protocol Translation Engine
│ ├── muse-spark-1.3-contributor -> POST /responses
│ └── space-bunny / others -> POST /chat/completions
└── Dynamic Model Catalog & Prefix Normalizer
│
▼
OpenCode Go Cloudflare Edge (https://opencode.ai/zen/go/v1)
```
---
## Testing
Run the test suite:
```bash
# Unit & integration tests
npm test
# Live model verification (requires API key)
npm run test:live
```
---
## License
MIT
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ROOT_DIR = path.resolve(__dirname, "..");
const PID_FILE = path.join(ROOT_DIR, ".gateway.pid");
const LOG_FILE = "/tmp/opencode-unlocked.log";
const { CONFIG, getApiKey, saveUserConfig, loadUserConfig, getUserConfigPath } = await import("../server/config.mjs");
const { catalog } = await import("../server/catalog.mjs");
const args = process.argv.slice(2);
const command = args[0] || "status";
async function isGatewayRunning() {
try {
const res = await fetch(`http://${CONFIG.HOST}:${CONFIG.PORT}/health`, { signal: AbortSignal.timeout(1000) });
return res.ok;
} catch {
return false;
}
}
function getStoredPid() {
if (fs.existsSync(PID_FILE)) {
try {
const pid = parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10);
return isNaN(pid) ? null : pid;
} catch {
return null;
}
}
return null;
}
async function startDaemon() {
if (await isGatewayRunning()) {
console.log(`✔ Opencode Unlocked gateway is already running on http://${CONFIG.HOST}:${CONFIG.PORT}`);
return;
}
const out = fs.openSync(LOG_FILE, "a");
const err = fs.openSync(LOG_FILE, "a");
const child = spawn(process.execPath, [path.join(ROOT_DIR, "server", "gateway.mjs")], {
detached: true,
stdio: ["ignore", out, err],
cwd: ROOT_DIR,
env: process.env
});
child.unref();
fs.writeFileSync(PID_FILE, String(child.pid), "utf-8");
// Wait for health
for (let i = 0; i < 30; i++) {
await new Promise((r) => setTimeout(r, 100));
if (await isGatewayRunning()) {
console.log(`✔ Opencode Unlocked gateway started (PID: ${child.pid}) on http://${CONFIG.HOST}:${CONFIG.PORT}`);
console.log(` Default Model: ${CONFIG.DEFAULT_MODEL}`);
console.log(` Logs: ${LOG_FILE}`);
return;
}
}
console.error("❌ Failed to verify gateway startup. Check logs at:", LOG_FILE);
process.exit(1);
}
async function stopDaemon() {
const pid = getStoredPid();
if (pid) {
try {
process.kill(pid, "SIGTERM");
console.log(`✔ Sent stop signal to process ${pid}`);
} catch (e) {
if (e.code !== "ESRCH") console.error("Error stopping process:", e.message);
}
if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE);
}
if (await isGatewayRunning()) {
console.log("Waiting for gateway to shut down...");
for (let i = 0; i < 20; i++) {
await new Promise((r) => setTimeout(r, 100));
if (!(await isGatewayRunning())) break;
}
}
if (!(await isGatewayRunning())) {
console.log("✔ Gateway stopped successfully.");
} else {
console.warn("⚠ Gateway is still responding on the port.");
}
}
async function printStatus() {
const running = await isGatewayRunning();
const apiKey = getApiKey();
const config = loadUserConfig();
console.log("==========================================");
console.log(" Opencode Unlocked Status ");
console.log("==========================================");
console.log(`Gateway Status : ${running ? "🟢 ONLINE" : "🔴 OFFLINE"}`);
console.log(`Gateway URL : http://${CONFIG.HOST}:${CONFIG.PORT}/v1`);
console.log(`Default Model : ${config.defaultModel || CONFIG.DEFAULT_MODEL}`);
console.log(`API Key Loaded : ${apiKey ? `✔ Configured (${apiKey.slice(0, 8)}...)` : "❌ Missing"}`);
console.log(`Config Path : ${getUserConfigPath()}`);
if (running) {
try {
const res = await fetch(`http://${CONFIG.HOST}:${CONFIG.PORT}/status`);
if (res.ok) {
const data = await res.json();
console.log("------------------------------------------");
console.log(`Session Req's : ${data.stats.sessionRequests}`);
console.log(`Tokens Consumed: ${data.stats.sessionTokens.total} (in: ${data.stats.sessionTokens.input}, out: ${data.stats.sessionTokens.output})`);
}
} catch {
// ignore
}
}
console.log("==========================================");
}
async function listModels() {
console.log("Fetching available models...");
const models = await catalog.getModels(true);
console.log(`\nAvailable Models (${models.length}):`);
for (const m of models) {
const isDefault = m.id === CONFIG.DEFAULT_MODEL;
console.log(` ${isDefault ? "★ [DEFAULT] " : " - "}${m.id}`);
}
}
async function setKey(key) {
if (!key) {
console.error("Usage: opencode-unlocked set-key <OPENCODE_API_KEY>");
process.exit(1);
}
saveUserConfig({ apiKey: key });
console.log("✔ API key saved securely to:", getUserConfigPath());
}
async function setModel(modelName) {
if (!modelName) {
console.error("Usage: opencode-unlocked switch <MODEL_NAME>");
process.exit(1);
}
saveUserConfig({ defaultModel: modelName });
console.log(`✔ Default model updated to: ${modelName}`);
}
async function runTests() {
console.log("Running live verification suite...");
const child = spawn(process.execPath, [path.join(ROOT_DIR, "test", "live-models.test.mjs")], {
stdio: "inherit",
cwd: ROOT_DIR
});
child.on("exit", (code) => {
process.exit(code || 0);
});
}
switch (command) {
case "start":
await startDaemon();
break;
case "stop":
await stopDaemon();
break;
case "restart":
await stopDaemon();
await startDaemon();
break;
case "status":
await printStatus();
break;
case "models":
await listModels();
break;
case "set-key":
case "key":
await setKey(args[1]);
break;
case "switch":
case "set-model":
await setModel(args[1]);
break;
case "test":
await runTests();
break;
case "setup":
await import("./setup.mjs");
break;
case "help":
case "--help":
default:
console.log(`
Opencode Unlocked CLI Management
Usage: opencode-unlocked <command> [arguments]
Commands:
start Start the local bridge daemon
stop Stop the running daemon
restart Restart the daemon
status Display daemon health, active model, and token usage
models List all 36+ available OpenCode Go models
switch <model> Change default model (e.g. space-bunny, muse-spark-1.3-contributor)
set-key <key> Save OpenCode API key securely to user configuration
test Run live verification on muse-spark and space-bunny
setup Configure OpenClaude provider profile to use this gateway
help Show this help dialog
`);
break;
}
Executable
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env node
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const homeDir = os.homedir();
const openclaudeDir = path.join(homeDir, ".openclaude");
const profilePath = path.join(openclaudeDir, ".openclaude-profile.json");
const profileBackupPath = path.join(openclaudeDir, ".openclaude-profile.json.backup");
const globalConfigPath = path.join(homeDir, ".openclaude.json");
const globalConfigBackupPath = path.join(homeDir, ".openclaude.json.backup");
const { CONFIG, loadUserConfig } = await import("../server/config.mjs");
const PROVIDER_ID = "provider_opencode_unlocked";
const PROVIDER_NAME = "Opencode Unlocked";
export function configureOpenClaudeProfile(options = {}) {
const userConfig = loadUserConfig();
const targetModel = options.model || userConfig.defaultModel || CONFIG.DEFAULT_MODEL;
const targetPort = options.port || CONFIG.PORT;
const targetHost = options.host || CONFIG.HOST;
const baseUrl = `http://${targetHost}:${targetPort}/v1`;
// 1. Configure plural provider profile in ~/.openclaude.json
if (fs.existsSync(globalConfigPath)) {
if (!fs.existsSync(globalConfigBackupPath)) {
fs.copyFileSync(globalConfigPath, globalConfigBackupPath);
console.log(`✔ Backed up existing ~/.openclaude.json to: ${globalConfigBackupPath}`);
}
try {
const globalConfig = JSON.parse(fs.readFileSync(globalConfigPath, "utf-8"));
globalConfig.providerProfiles = Array.isArray(globalConfig.providerProfiles) ? globalConfig.providerProfiles : [];
const existingIndex = globalConfig.providerProfiles.findIndex(p => p.id === PROVIDER_ID || p.name === PROVIDER_NAME);
const unlockedProfile = {
id: PROVIDER_ID,
name: PROVIDER_NAME,
provider: "openai",
baseUrl,
model: targetModel,
apiKey: "opencode-unlocked"
};
if (existingIndex !== -1) {
globalConfig.providerProfiles[existingIndex] = unlockedProfile;
} else {
globalConfig.providerProfiles.push(unlockedProfile);
}
globalConfig.activeProviderProfileId = PROVIDER_ID;
// Register model options cache for picker UI
globalConfig.openaiAdditionalModelOptionsCacheByProfile = globalConfig.openaiAdditionalModelOptionsCacheByProfile || {};
globalConfig.openaiAdditionalModelOptionsCacheByProfile[PROVIDER_ID] = [
{ value: "muse-spark-1.3-contributor", label: "Muse Spark 1.3 Contributor", description: "Default Reasoning Model" },
{ value: "space-bunny", label: "Space Bunny", description: "Lightweight Chat & Code Model" },
{ value: "deepseek-v4-flash", label: "DeepSeek V4 Flash", description: "High-Throughput Model" },
{ value: "glm-5.3", label: "GLM 5.3", description: "General Language Model" }
];
fs.writeFileSync(globalConfigPath, JSON.stringify(globalConfig, null, 2), "utf-8");
console.log(`✔ Updated ~/.openclaude.json (active provider set to: ${PROVIDER_NAME})`);
} catch (err) {
console.warn(`⚠ Could not update ~/.openclaude.json: ${err.message}`);
}
}
// 2. Configure legacy mirror ~/.openclaude/.openclaude-profile.json
if (!fs.existsSync(openclaudeDir)) {
fs.mkdirSync(openclaudeDir, { recursive: true });
}
if (fs.existsSync(profilePath) && !fs.existsSync(profileBackupPath)) {
fs.copyFileSync(profilePath, profileBackupPath);
console.log(`✔ Backed up existing .openclaude-profile.json to: ${profileBackupPath}`);
}
const legacyProfile = {
profile: "openai",
env: {
OPENAI_BASE_URL: baseUrl,
OPENAI_MODEL: targetModel,
OPENAI_API_KEY: "opencode-unlocked"
},
displayName: PROVIDER_NAME,
createdAt: new Date().toISOString()
};
fs.writeFileSync(profilePath, JSON.stringify(legacyProfile, null, 2), "utf-8");
console.log(`✔ OpenClaude provider profile configured successfully!`);
console.log(` Provider Name : ${PROVIDER_NAME}`);
console.log(` OPENAI_BASE_URL: ${baseUrl}`);
console.log(` OPENAI_MODEL : ${targetModel}`);
return legacyProfile;
}
export function restoreOriginalProfile() {
let restored = false;
if (fs.existsSync(globalConfigBackupPath)) {
fs.copyFileSync(globalConfigBackupPath, globalConfigPath);
console.log(`✔ Restored ~/.openclaude.json from: ${globalConfigBackupPath}`);
restored = true;
}
if (fs.existsSync(profileBackupPath)) {
fs.copyFileSync(profileBackupPath, profilePath);
console.log(`✔ Restored .openclaude-profile.json from: ${profileBackupPath}`);
restored = true;
}
return restored;
}
// CLI Execution
if (process.argv[1] && process.argv[1].endsWith("setup.mjs")) {
const arg = process.argv[2];
if (arg === "restore" || arg === "--restore") {
restoreOriginalProfile();
} else {
configureOpenClaudeProfile({ model: arg });
}
}
+8
View File
@@ -0,0 +1,8 @@
---
description: Check Opencode Unlocked status, active model, and quota statistics
argument-hint: ""
---
Check the status of the Opencode Unlocked bridge and show connection health:
1. Query `http://127.0.0.1:8788/status` or execute `${CLAUDE_PLUGIN_ROOT}/bin/opencode-unlocked status`.
2. Report the active default model (Muse Spark 1.3 Contributor), upstream connectivity, and session request/token usage statistics.
+11
View File
@@ -0,0 +1,11 @@
---
description: Manage Opencode Unlocked bridge, switch models, or configure keys
argument-hint: "[models|switch <model>|key <key>|test]"
---
Manage Opencode Unlocked:
- If argument is `models`: Run `${CLAUDE_PLUGIN_ROOT}/bin/opencode-unlocked models` to list all 36+ available models.
- If argument is `switch <model>`: Run `${CLAUDE_PLUGIN_ROOT}/bin/opencode-unlocked switch $1` to change the default model.
- If argument is `key <key>`: Run `${CLAUDE_PLUGIN_ROOT}/bin/opencode-unlocked set-key $1` to securely store your OpenCode API key.
- If argument is `test`: Run `${CLAUDE_PLUGIN_ROOT}/bin/opencode-unlocked test` to test live connectivity to Muse Spark 1.3 Contributor and Space Bunny.
- Otherwise, run `${CLAUDE_PLUGIN_ROOT}/bin/opencode-unlocked status`.
+16
View File
@@ -0,0 +1,16 @@
{
"description": "Opencode Unlocked session lifecycle hooks",
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh\"",
"async": false
}
]
}
]
}
}
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -e
# Resolve plugin root if not set
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
PID_FILE="${PLUGIN_ROOT}/.gateway.pid"
PORT="${OPENCODE_UNLOCKED_PORT:-8788}"
GATEWAY_URL="http://127.0.0.1:${PORT}"
# Check if gateway is already responding
is_gateway_running() {
curl -s -f -m 1 "${GATEWAY_URL}/health" >/dev/null 2>&1
}
if ! is_gateway_running; then
# If a dead pid file exists, clean it up
if [ -f "$PID_FILE" ]; then
OLD_PID=$(cat "$PID_FILE" 2>/dev/null || true)
if [ -n "$OLD_PID" ] && ! kill -0 "$OLD_PID" 2>/dev/null; then
rm -f "$PID_FILE"
fi
fi
# Start daemon
nohup node "${PLUGIN_ROOT}/server/gateway.mjs" >/tmp/opencode-unlocked.log 2>&1 &
DAEMON_PID=$!
echo "$DAEMON_PID" > "$PID_FILE"
# Wait up to 3 seconds for gateway to become healthy
for _ in {1..30}; do
if is_gateway_running; then
break
fi
sleep 0.1
done
fi
# If OpenClaude passed an environment file, inject provider env vars
if [ -n "$CLAUDE_ENV_FILE" ]; then
cat << EOF >> "$CLAUDE_ENV_FILE"
export OPENAI_BASE_URL="${GATEWAY_URL}/v1"
if [ -z "\$OPENAI_MODEL" ]; then
export OPENAI_MODEL="muse-spark-1.3-contributor"
fi
EOF
fi
# Emit JSON output for OpenClaude SessionStart hook
cat << EOF
{
"hookSpecificOutput": {
"additionalContext": "Opencode Unlocked active at ${GATEWAY_URL}/v1. Default model: muse-spark-1.3-contributor (Space Bunny and 36+ models available)."
}
}
EOF
exit 0
+27
View File
@@ -0,0 +1,27 @@
{
"name": "opencode-unlocked",
"version": "1.0.0",
"description": "Seamless OpenClaude provider plugin and authentic harness bridge for OpenCode Go models.",
"type": "module",
"main": "server/gateway.mjs",
"bin": {
"opencode-unlocked": "./bin/opencode-unlocked"
},
"scripts": {
"start": "node server/gateway.mjs",
"setup": "node bin/setup.mjs",
"test": "node test/session.test.mjs && node test/translator.test.mjs && node test/gateway.test.mjs",
"test:live": "node test/live-models.test.mjs"
},
"keywords": [
"openclaude",
"openclaude-plugin",
"opencode",
"opencode-go",
"muse-spark",
"space-bunny",
"ai-bridge"
],
"author": "Opencode Unlocked Contributors",
"license": "MIT"
}
+115
View File
@@ -0,0 +1,115 @@
import { CONFIG, getApiKey } from "./config.mjs";
import { generateSessionId } from "./session.mjs";
import crypto from "node:crypto";
class ModelCatalog {
constructor() {
this.models = [];
this.lastFetched = 0;
this.ttlMs = 15 * 60 * 1000; // 15 minutes cache
}
/**
* Normalizes model ID by stripping any plugin or harness prefixes.
*/
normalizeModelId(modelId) {
if (!modelId || modelId === "default" || modelId === "auto") {
return CONFIG.DEFAULT_MODEL;
}
return modelId
.replace(/^opencode-unlocked-/, "")
.replace(/^opencode-go-/, "")
.trim();
}
/**
* Fetches models dynamically from upstream OpenCode Go /models endpoint.
*/
async getModels(forceRefresh = false) {
const now = Date.now();
if (!forceRefresh && this.models.length > 0 && now - this.lastFetched < this.ttlMs) {
return this.models;
}
const apiKey = getApiKey();
if (!apiKey) {
// Return static fallback if no key is configured yet
return this.getFallbackModels();
}
const sessionId = generateSessionId();
const requestId = crypto.randomUUID();
try {
const resp = await fetch(`${CONFIG.UPSTREAM_BASE_URL}/models`, {
method: "GET",
headers: {
"Authorization": `Bearer ${apiKey}`,
"User-Agent": CONFIG.USER_AGENT,
"x-opencode-client": CONFIG.CLIENT,
"x-opencode-project": CONFIG.PROJECT,
"x-opencode-session": sessionId,
"x-opencode-session-id": sessionId,
"x-session-affinity": sessionId,
"X-Session-Id": sessionId,
"x-opencode-request": requestId,
"Accept": "application/json"
}
});
if (!resp.ok) {
console.error(`[Opencode Unlocked] Failed to fetch upstream models: ${resp.status}`);
return this.models.length > 0 ? this.models : this.getFallbackModels();
}
const data = await resp.json();
const rawList = Array.isArray(data) ? data : (Array.isArray(data.data) ? data.data : []);
this.models = rawList.map((m) => {
const id = typeof m === "string" ? m : m.id;
const name = typeof m === "string" ? m : (m.name || m.id);
return {
id,
object: "model",
created: 1700000000,
owned_by: "opencode",
name,
is_default: id === CONFIG.DEFAULT_MODEL
};
});
// Ensure default model is in the catalog
if (!this.models.some((m) => m.id === CONFIG.DEFAULT_MODEL)) {
this.models.unshift({
id: CONFIG.DEFAULT_MODEL,
object: "model",
created: 1700000000,
owned_by: "opencode",
name: "Muse Spark 1.3 Contributor",
is_default: true
});
}
this.lastFetched = now;
return this.models;
} catch (err) {
console.error(`[Opencode Unlocked] Error fetching models: ${err.message}`);
return this.models.length > 0 ? this.models : this.getFallbackModels();
}
}
getFallbackModels() {
return [
{ id: "muse-spark-1.3-contributor", name: "Muse Spark 1.3 Contributor", is_default: true },
{ id: "space-bunny", name: "Space Bunny", is_default: false },
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", is_default: false },
{ id: "glm-5.3", name: "GLM 5.3", is_default: false },
{ id: "gpt-5.6-luna", name: "GPT 5.6 Luna", is_default: false },
{ id: "kimi-k2.5", name: "Kimi K2.5", is_default: false },
{ id: "minimax-01", name: "MiniMax 01", is_default: false },
{ id: "qwen3.5-max", name: "Qwen 3.5 Max", is_default: false }
].map(m => ({ ...m, object: "model", created: 1700000000, owned_by: "opencode" }));
}
}
export const catalog = new ModelCatalog();
+100
View File
@@ -0,0 +1,100 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
function parseEnvFile(filePath) {
if (!fs.existsSync(filePath)) return {};
try {
const content = fs.readFileSync(filePath, "utf-8");
const result = {};
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eqIdx = trimmed.indexOf("=");
if (eqIdx !== -1) {
const key = trimmed.slice(0, eqIdx).trim();
let val = trimmed.slice(eqIdx + 1).trim();
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
}
result[key] = val;
}
}
return result;
} catch {
return {};
}
}
export function getUserConfigPath() {
const home = os.homedir();
const primaryDir = path.join(home, ".openclaude");
if (!fs.existsSync(primaryDir)) {
try {
fs.mkdirSync(primaryDir, { recursive: true });
} catch {
// ignore
}
}
return path.join(primaryDir, "opencode-unlocked.json");
}
export function loadUserConfig() {
const configPath = getUserConfigPath();
if (fs.existsSync(configPath)) {
try {
return JSON.parse(fs.readFileSync(configPath, "utf-8"));
} catch {
return {};
}
}
return {};
}
export function saveUserConfig(data) {
const configPath = getUserConfigPath();
const dir = path.dirname(configPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const current = loadUserConfig();
const updated = { ...current, ...data };
fs.writeFileSync(configPath, JSON.stringify(updated, null, 2), "utf-8");
return updated;
}
export function getApiKey() {
// 1. Process environment variables
if (process.env.OPENCODE_UNLOCKED_API_KEY) return process.env.OPENCODE_UNLOCKED_API_KEY;
if (process.env.OPENCODE_API_KEY) return process.env.OPENCODE_API_KEY;
if (process.env.GO_KEY) return process.env.GO_KEY;
// 2. User config (~/.openclaude/opencode-unlocked.json)
const userConfig = loadUserConfig();
if (userConfig.apiKey) return userConfig.apiKey;
if (userConfig.GO_KEY) return userConfig.GO_KEY;
// 3. Optional local .env (checked in CWD and repo root, strictly ignored by git)
const cwdEnv = parseEnvFile(path.join(process.cwd(), ".env"));
if (cwdEnv.OPENCODE_UNLOCKED_API_KEY) return cwdEnv.OPENCODE_UNLOCKED_API_KEY;
if (cwdEnv.OPENCODE_API_KEY) return cwdEnv.OPENCODE_API_KEY;
if (cwdEnv.GO_KEY) return cwdEnv.GO_KEY;
const repoEnv = parseEnvFile(path.join(import.meta.dirname, "..", ".env"));
if (repoEnv.OPENCODE_UNLOCKED_API_KEY) return repoEnv.OPENCODE_UNLOCKED_API_KEY;
if (repoEnv.OPENCODE_API_KEY) return repoEnv.OPENCODE_API_KEY;
if (repoEnv.GO_KEY) return repoEnv.GO_KEY;
return null;
}
export const CONFIG = {
PORT: parseInt(process.env.OPENCODE_UNLOCKED_PORT || "8788", 10),
HOST: process.env.OPENCODE_UNLOCKED_HOST || "127.0.0.1",
UPSTREAM_BASE_URL: (process.env.OPENCODE_BASE_URL || "https://opencode.ai/zen/go/v1").replace(/\/+$/, ""),
DEFAULT_MODEL: process.env.OPENCODE_UNLOCKED_DEFAULT_MODEL || "muse-spark-1.3-contributor",
USER_AGENT: "opencode/stable/2.0.24/cli",
CLIENT: "cli",
PROJECT: "default",
VERSION: "1.0.0"
};
+395
View File
@@ -0,0 +1,395 @@
import http from "node:http";
import crypto from "node:crypto";
import { CONFIG, getApiKey } from "./config.mjs";
import { sessionAffinity, generateSessionId } from "./session.mjs";
import { catalog } from "./catalog.mjs";
import { usageTracker } from "./usage.mjs";
import {
requiresResponsesApi,
chatCompletionsToResponsesBody,
responsesToChatCompletionsResponse,
translateResponsesEventToOpenAiChunk
} from "./translator.mjs";
function sendJson(res, statusCode, data) {
const jsonStr = JSON.stringify(data);
res.writeHead(statusCode, {
"Content-Type": "application/json; charset=utf-8",
"Content-Length": Buffer.byteLength(jsonStr),
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS, HEAD",
"Access-Control-Allow-Headers": "*"
});
res.end(jsonStr);
}
function parseBody(req) {
return new Promise((resolve, reject) => {
let body = "";
req.on("data", (chunk) => {
body += chunk;
if (body.length > 50 * 1024 * 1024) { // 50MB protection
reject(new Error("Request body too large"));
}
});
req.on("end", () => {
if (!body) return resolve({});
try {
resolve(JSON.parse(body));
} catch (err) {
reject(new Error(`Invalid JSON body: ${err.message}`));
}
});
req.on("error", reject);
});
}
export function createGatewayServer() {
const server = http.createServer(async (req, res) => {
// Enable CORS preflight
if (req.method === "OPTIONS") {
res.writeHead(204, {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS, HEAD",
"Access-Control-Allow-Headers": "*",
"Access-Control-Max-Age": "86400"
});
res.end();
return;
}
const parsedUrl = new URL(req.url, `http://${req.headers.host || "127.0.0.1"}`);
const pathname = parsedUrl.pathname.replace(/\/+$/, "") || "/";
// Health Check
if (pathname === "/health" || pathname === "/") {
const apiKey = getApiKey();
sendJson(res, 200, {
status: "ok",
name: "Opencode Unlocked Gateway",
version: CONFIG.VERSION,
hasKey: !!apiKey,
defaultModel: CONFIG.DEFAULT_MODEL
});
return;
}
// Status / Quota
if (pathname === "/status") {
const apiKey = getApiKey();
const upstreamUsage = await usageTracker.getUpstreamUsage();
sendJson(res, 200, {
status: "ok",
keyConfigured: !!apiKey,
defaultModel: CONFIG.DEFAULT_MODEL,
stats: usageTracker.getStatus(),
upstreamUsage
});
return;
}
// Models List (/v1/models and /models)
if (req.method === "GET" && (pathname === "/v1/models" || pathname === "/models")) {
try {
const models = await catalog.getModels();
sendJson(res, 200, {
object: "list",
data: models
});
} catch (err) {
sendJson(res, 500, { error: { message: err.message, type: "internal_error" } });
}
return;
}
// Chat Completions (/v1/chat/completions and /chat/completions)
if (req.method === "POST" && (pathname === "/v1/chat/completions" || pathname === "/chat/completions")) {
await handleChatCompletions(req, res);
return;
}
// Direct Responses endpoint (/v1/responses and /responses)
if (req.method === "POST" && (pathname === "/v1/responses" || pathname === "/responses")) {
await handleDirectResponses(req, res);
return;
}
sendJson(res, 404, {
error: {
message: `Endpoint not found: ${req.method} ${pathname}`,
type: "invalid_request_error"
}
});
});
return server;
}
async function handleChatCompletions(req, res) {
let body;
try {
body = await parseBody(req);
} catch (err) {
sendJson(res, 400, { error: { message: err.message, type: "invalid_request_error" } });
return;
}
// Resolve API Key
let apiKey = getApiKey();
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith("Bearer ")) {
const passedKey = authHeader.slice(7).trim();
if (passedKey.startsWith("oc_sk_")) {
apiKey = passedKey;
}
}
if (!apiKey) {
sendJson(res, 401, {
error: {
message: "No OpenCode API key configured. Provide OPENCODE_UNLOCKED_API_KEY, GO_KEY, or run `opencode-unlocked key <key>`.",
type: "authentication_error"
}
});
return;
}
const rawModel = body.model || CONFIG.DEFAULT_MODEL;
const normalizedModel = catalog.normalizeModelId(rawModel);
body.model = normalizedModel;
// Derive Session ID with Affinity
const clientIdentifier =
req.headers["x-session-id"] ||
req.headers["x-opencode-session"] ||
req.headers["x-conversation-id"] ||
req.socket.remoteAddress ||
"default";
const sessionId = sessionAffinity.getOrCreate(clientIdentifier);
const requestId = crypto.randomUUID();
const useResponses = requiresResponsesApi(normalizedModel);
const isStream = !!body.stream;
const harnessHeaders = {
"Authorization": `Bearer ${apiKey}`,
"User-Agent": CONFIG.USER_AGENT,
"x-opencode-client": CONFIG.CLIENT,
"x-opencode-project": CONFIG.PROJECT,
"x-opencode-session": sessionId,
"x-opencode-session-id": sessionId,
"x-session-affinity": sessionId,
"X-Session-Id": sessionId,
"x-opencode-request": requestId,
"Content-Type": "application/json",
"Accept": isStream ? "text/event-stream" : "application/json"
};
try {
if (useResponses) {
// Protocol Translation: ChatCompletions -> Responses API
const responsesPayload = chatCompletionsToResponsesBody(body);
const upstreamResp = await fetch(`${CONFIG.UPSTREAM_BASE_URL}/responses`, {
method: "POST",
headers: harnessHeaders,
body: JSON.stringify(responsesPayload)
});
if (!upstreamResp.ok) {
const errorText = await upstreamResp.text();
let parsedErr;
try {
parsedErr = JSON.parse(errorText);
} catch {
parsedErr = { error: { message: errorText, type: "upstream_error" } };
}
sendJson(res, upstreamResp.status, parsedErr);
return;
}
if (isStream) {
res.writeHead(200, {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*"
});
const state = { id: "", model: normalizedModel };
const reader = upstreamResp.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split("\n\n");
buffer = parts.pop() || "";
for (const block of parts) {
const trimmed = block.trim();
if (!trimmed) continue;
const convertedChunk = translateResponsesEventToOpenAiChunk(trimmed, state);
if (convertedChunk) {
res.write(convertedChunk);
}
}
}
if (buffer.trim()) {
const convertedChunk = translateResponsesEventToOpenAiChunk(buffer.trim(), state);
if (convertedChunk) res.write(convertedChunk);
}
res.write("data: [DONE]\n\n");
res.end();
usageTracker.recordUsage(state.usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
} else {
const responseData = await upstreamResp.json();
const chatCompletionData = responsesToChatCompletionsResponse(responseData, normalizedModel);
usageTracker.recordUsage(chatCompletionData.usage);
sendJson(res, 200, chatCompletionData);
}
} else {
// Standard Chat Completions Pass-Through
const upstreamResp = await fetch(`${CONFIG.UPSTREAM_BASE_URL}/chat/completions`, {
method: "POST",
headers: harnessHeaders,
body: JSON.stringify(body)
});
if (!upstreamResp.ok) {
const errorText = await upstreamResp.text();
let parsedErr;
try {
parsedErr = JSON.parse(errorText);
} catch {
parsedErr = { error: { message: errorText, type: "upstream_error" } };
}
sendJson(res, upstreamResp.status, parsedErr);
return;
}
if (isStream) {
res.writeHead(200, {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*"
});
const reader = upstreamResp.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
res.write(value);
}
res.end();
usageTracker.recordUsage({ prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
} else {
const json = await upstreamResp.json();
usageTracker.recordUsage(json.usage);
sendJson(res, 200, json);
}
}
} catch (err) {
console.error("[Opencode Unlocked] Upstream relay error:", err);
sendJson(res, 502, {
error: {
message: `Gateway relay error: ${err.message}`,
type: "bad_gateway"
}
});
}
}
async function handleDirectResponses(req, res) {
let body;
try {
body = await parseBody(req);
} catch (err) {
sendJson(res, 400, { error: { message: err.message, type: "invalid_request_error" } });
return;
}
const apiKey = getApiKey();
if (!apiKey) {
sendJson(res, 401, { error: { message: "No OpenCode API key configured.", type: "authentication_error" } });
return;
}
const rawModel = body.model || CONFIG.DEFAULT_MODEL;
body.model = catalog.normalizeModelId(rawModel);
const sessionId = generateSessionId();
const isStream = !!body.stream;
const harnessHeaders = {
"Authorization": `Bearer ${apiKey}`,
"User-Agent": CONFIG.USER_AGENT,
"x-opencode-client": CONFIG.CLIENT,
"x-opencode-project": CONFIG.PROJECT,
"x-opencode-session": sessionId,
"x-opencode-session-id": sessionId,
"x-session-affinity": sessionId,
"X-Session-Id": sessionId,
"x-opencode-request": crypto.randomUUID(),
"Content-Type": "application/json",
"Accept": isStream ? "text/event-stream" : "application/json"
};
try {
const upstreamResp = await fetch(`${CONFIG.UPSTREAM_BASE_URL}/responses`, {
method: "POST",
headers: harnessHeaders,
body: JSON.stringify(body)
});
if (!upstreamResp.ok) {
const errText = await upstreamResp.text();
let json;
try { json = JSON.parse(errText); } catch { json = { error: { message: errText } }; }
sendJson(res, upstreamResp.status, json);
return;
}
if (isStream) {
res.writeHead(200, {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache",
"Connection": "keep-alive"
});
const reader = upstreamResp.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
res.write(value);
}
res.end();
} else {
const json = await upstreamResp.json();
sendJson(res, 200, json);
}
} catch (err) {
sendJson(res, 502, { error: { message: err.message, type: "bad_gateway" } });
}
}
// CLI Execution entrypoint
if (process.argv[1] && process.argv[1].endsWith("gateway.mjs")) {
const server = createGatewayServer();
server.listen(CONFIG.PORT, CONFIG.HOST, () => {
console.log(`[Opencode Unlocked] Bridge Gateway running on http://${CONFIG.HOST}:${CONFIG.PORT}`);
console.log(`[Opencode Unlocked] Default model: ${CONFIG.DEFAULT_MODEL}`);
console.log(`[Opencode Unlocked] API key resolved: ${!!getApiKey()}`);
});
const shutdown = () => {
console.log("\n[Opencode Unlocked] Shutting down gateway...");
server.close(() => process.exit(0));
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
}
+89
View File
@@ -0,0 +1,89 @@
import crypto from "node:crypto";
const ID_LENGTH = 26;
const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
let lastTimestamp = 0;
let counter = 0;
/**
* OpenCode v2 descending identifier generator.
* Produces a time-ordered sortable 26-character identifier.
* First 12 characters: hex-encoded bitwise-inverted timestamp (48 bits).
* Remaining 14 characters: cryptographically random base62 characters.
*/
export function generateDescendingIdentifier(timestamp = Date.now()) {
if (timestamp !== lastTimestamp) {
lastTimestamp = timestamp;
counter = 0;
}
counter++;
const current = BigInt(timestamp) * 0x1000n + BigInt(counter);
const value = ~current; // Descending sort order
const timeHex = Array.from({ length: 6 }, (_, index) =>
Number((value >> BigInt(40 - 8 * index)) & 0xffn)
.toString(16)
.padStart(2, "0"),
).join("");
const randomBytes = crypto.getRandomValues(new Uint8Array(ID_LENGTH - 12));
const randomChars = Array.from(randomBytes, (b) => BASE62_CHARS[b % 62]).join("");
return timeHex + randomChars;
}
/**
* Generates an authentic OpenCode v2 Session ID.
* Format: "ses_" + 26 chars = exactly 30 characters.
* Matches packages/schema/src/session-id.ts exactly.
*/
export function generateSessionId() {
return `ses_${generateDescendingIdentifier()}`;
}
/**
* Validates whether a given string matches the authentic OpenCode v2 Session ID format.
*/
export function isValidSessionId(id) {
if (typeof id !== "string" || id.length !== 30) return false;
return /^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/.test(id);
}
/**
* Session Affinity Cache
* Maps client conversation IDs (or IP/caller hashes) to persistent OpenCode session IDs
* to ensure prompt-cache affinity across turns.
*/
class SessionAffinityManager {
constructor(ttlMs = 4 * 60 * 60 * 1000) { // 4 hours default TTL
this.sessions = new Map();
this.ttlMs = ttlMs;
}
getOrCreate(clientIdentifier) {
const key = clientIdentifier || "default";
const now = Date.now();
const existing = this.sessions.get(key);
if (existing && now - existing.timestamp < this.ttlMs) {
existing.timestamp = now; // Slide TTL window
return existing.sessionId;
}
const sessionId = generateSessionId();
this.sessions.set(key, { sessionId, timestamp: now });
return sessionId;
}
set(clientIdentifier, sessionId) {
const key = clientIdentifier || "default";
this.sessions.set(key, { sessionId, timestamp: Date.now() });
}
clear() {
this.sessions.clear();
}
}
export const sessionAffinity = new SessionAffinityManager();
+373
View File
@@ -0,0 +1,373 @@
/**
* Protocol Translation Layer
*
* OpenCode Go hosts models across two primary OpenAI API protocols:
* 1. Standard Chat Completions (/chat/completions): space-bunny, deepseek-v4-flash, glm-5.3, etc.
* 2. Responses API (/responses): muse-spark-1.3-contributor, gpt-5.6-luna, gpt-5.*, grok-*
*
* OpenClaude sends standard /v1/chat/completions.
* This module transparently adapts requests and streaming responses for models requiring /responses.
*/
// Models that strictly mandate the OpenAI Responses API protocol (/responses)
const RESPONSES_API_MODELS = new Set([
"muse-spark-1.3-contributor",
"gpt-5.6-luna",
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.5",
"gpt-5.5-pro",
"gpt-5.4",
"gpt-5.4-pro",
"gpt-5.4-mini",
"gpt-5.4-nano",
"gpt-5.3-codex",
"gpt-5.3-codex-spark",
"gpt-5.2",
"gpt-5.2-codex",
"gpt-5.1",
"gpt-5.1-codex",
"gpt-5.1-codex-max",
"gpt-5.1-codex-mini",
"gpt-5",
"gpt-5-codex",
"gpt-5-nano",
"grok-4.5",
"grok-build-0.1",
]);
/**
* Checks whether a given model requires the /responses endpoint.
*/
export function requiresResponsesApi(modelName) {
if (!modelName) return false;
const normalized = modelName.toLowerCase().replace(/^(opencode-unlocked-|opencode-go-)/, "");
if (RESPONSES_API_MODELS.has(normalized)) return true;
// Prefix matching for gpt-5.* family if newer variants appear
if (normalized.startsWith("gpt-5") || normalized.startsWith("muse-spark")) return true;
return false;
}
/**
* Translates an OpenAI Chat Completions request body into an OpenAI Responses API body.
*/
export function chatCompletionsToResponsesBody(body) {
const { messages, input, model, stream, temperature, max_tokens, max_output_tokens, tools, tool_choice, stop } = body;
const rawMessages = Array.isArray(input) ? input : Array.isArray(messages) ? messages : [];
// Map messages to Responses API input format
const formattedInput = rawMessages.map((msg) => {
if (!msg || typeof msg !== "object") return msg;
// Direct Responses API items pass through
if (!msg.role && msg.type) return msg;
// Standard Chat Completion messages
const role = msg.role;
const content = msg.content;
if (role === "system" || role === "developer") {
return {
role: "system",
content: typeof content === "string" ? content : (Array.isArray(content) ? content.map(c => c.text || "").join("") : "")
};
}
if (role === "user") {
if (typeof content === "string") {
return { role: "user", content };
}
if (Array.isArray(content)) {
return {
role: "user",
content: content.map((part) => {
if (part.type === "text" || part.type === "input_text") {
return { type: "text", text: part.text || "" };
}
if (part.type === "image_url") {
return { type: "image_url", image_url: part.image_url };
}
return part;
})
};
}
return { role: "user", content: String(content || "") };
}
if (role === "assistant") {
const out = { role: "assistant" };
if (typeof content === "string") out.content = content;
if (Array.isArray(msg.tool_calls)) out.tool_calls = msg.tool_calls;
return out;
}
if (role === "tool") {
return {
role: "tool",
tool_call_id: msg.tool_call_id,
content: typeof content === "string" ? content : JSON.stringify(content || "")
};
}
return msg;
});
const responsesPayload = {
model: model.replace(/^(opencode-unlocked-|opencode-go-)/, ""),
input: formattedInput,
stream: !!stream,
};
if (typeof temperature === "number") responsesPayload.temperature = temperature;
if (typeof max_output_tokens === "number") responsesPayload.max_output_tokens = max_output_tokens;
else if (typeof max_tokens === "number") responsesPayload.max_output_tokens = max_tokens;
if (Array.isArray(tools)) {
responsesPayload.tools = tools.map((t) => {
if (t.type === "function" && t.function) {
return {
type: "function",
name: t.function.name,
description: t.function.description,
parameters: t.function.parameters,
...(t.function.strict !== undefined ? { strict: t.function.strict } : {})
};
}
return t;
});
}
if (tool_choice) {
if (tool_choice === "auto" || tool_choice === "required" || tool_choice === "none") {
responsesPayload.tool_choice = tool_choice;
} else if (typeof tool_choice === "object" && tool_choice.type === "function" && tool_choice.function?.name) {
responsesPayload.tool_choice = { type: "function", name: tool_choice.function.name };
} else {
responsesPayload.tool_choice = tool_choice;
}
}
if (stop) responsesPayload.stop = stop;
return responsesPayload;
}
/**
* Translates a non-streaming Responses API JSON response to an OpenAI Chat Completions response.
*/
export function responsesToChatCompletionsResponse(responsesData, requestedModel) {
if (!responsesData || typeof responsesData !== "object") return responsesData;
const id = responsesData.id?.replace(/^resp_/, "chatcmpl_") || `chatcmpl_${Math.random().toString(36).slice(2)}`;
const model = responsesData.model || requestedModel;
let textContent = "";
const toolCalls = [];
const output = Array.isArray(responsesData.output) ? responsesData.output : [];
for (const item of output) {
if (item.type === "message") {
if (Array.isArray(item.content)) {
for (const part of item.content) {
if ((part.type === "output_text" || part.type === "text") && typeof part.text === "string") {
textContent += part.text;
}
}
} else if (typeof item.content === "string") {
textContent += item.content;
}
} else if (item.type === "function_call") {
toolCalls.push({
id: item.call_id || item.id,
type: "function",
function: {
name: item.name,
arguments: typeof item.arguments === "string" ? item.arguments : JSON.stringify(item.arguments || {})
}
});
}
}
const stopReason = responsesData.stop_reason;
let finishReason = "stop";
if (stopReason === "tool_call" || stopReason === "tool_calls" || toolCalls.length > 0) finishReason = "tool_calls";
else if (stopReason === "max_output_tokens" || stopReason === "length") finishReason = "length";
else if (stopReason === "content_filter") finishReason = "content_filter";
const message = {
role: "assistant",
content: textContent || (toolCalls.length ? null : "")
};
if (toolCalls.length > 0) {
message.tool_calls = toolCalls;
}
const usage = responsesData.usage ? {
prompt_tokens: responsesData.usage.input_tokens || 0,
completion_tokens: responsesData.usage.output_tokens || 0,
total_tokens: (responsesData.usage.input_tokens || 0) + (responsesData.usage.output_tokens || 0),
...(responsesData.usage.input_tokens_details?.cached_tokens ? {
prompt_tokens_details: { cached_tokens: responsesData.usage.input_tokens_details.cached_tokens }
} : {})
} : undefined;
return {
id,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model,
choices: [
{
index: 0,
message,
finish_reason: finishReason
}
],
...(usage ? { usage } : {})
};
}
/**
* Transforms an SSE event line or block from Responses API format
* into OpenAI chat.completion.chunk SSE format.
*/
export function translateResponsesEventToOpenAiChunk(eventString, state = { id: "", model: "" }) {
if (!eventString || typeof eventString !== "string") return null;
const lines = eventString.split("\n");
let eventType = "";
let dataStr = "";
for (const line of lines) {
if (line.startsWith("event:")) {
eventType = line.slice(6).trim();
} else if (line.startsWith("data:")) {
dataStr = line.slice(5).trim();
}
}
if (!dataStr) return null;
if (dataStr === "[DONE]") return "data: [DONE]\n\n";
let data;
try {
data = JSON.parse(dataStr);
} catch {
return null;
}
const respObj = data.response || {};
const id = respObj.id || data.id || state.id || `chatcmpl_${Math.random().toString(36).slice(2)}`;
const model = respObj.model || data.model || state.model || "opencode-unlocked";
state.id = id;
state.model = model;
const baseChunk = {
id,
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model,
choices: []
};
// Text Delta
if (
eventType === "response.output_text.delta" ||
eventType === "response.text.delta" ||
data.type === "response.output_text.delta"
) {
const text = data.delta ?? data.text ?? data.output_text_delta ?? "";
if (text) {
baseChunk.choices.push({
index: 0,
delta: { content: text },
finish_reason: null
});
return `data: ${JSON.stringify(baseChunk)}\n\n`;
}
}
// Function Call Added
if (
(eventType === "response.output_item.added" || data.type === "response.output_item.added") &&
data.item?.type === "function_call"
) {
baseChunk.choices.push({
index: 0,
delta: {
tool_calls: [
{
index: 0,
id: data.item.id || data.item.call_id,
type: "function",
function: {
name: data.item.name || "",
arguments: ""
}
}
]
},
finish_reason: null
});
return `data: ${JSON.stringify(baseChunk)}\n\n`;
}
// Function Call Arguments Delta
if (
eventType === "response.function_call_arguments.delta" ||
data.type === "response.function_call_arguments.delta"
) {
const argsDelta = data.delta ?? data.arguments_delta ?? "";
if (argsDelta) {
baseChunk.choices.push({
index: 0,
delta: {
tool_calls: [
{
index: 0,
function: {
arguments: argsDelta
}
}
]
},
finish_reason: null
});
return `data: ${JSON.stringify(baseChunk)}\n\n`;
}
}
// Response Completed
if (eventType === "response.completed" || data.type === "response.completed") {
const sr = respObj.stop_reason ?? data.stop_reason;
let fr = "stop";
if (sr === "tool_call" || sr === "tool_calls") fr = "tool_calls";
else if (sr === "max_output_tokens" || sr === "length") fr = "length";
else if (sr === "content_filter") fr = "content_filter";
baseChunk.choices.push({
index: 0,
delta: {},
finish_reason: fr
});
const u = respObj.usage ?? data.response?.usage ?? data.usage;
if (u) {
baseChunk.usage = {
prompt_tokens: u.input_tokens || 0,
completion_tokens: u.output_tokens || 0,
total_tokens: (u.input_tokens || 0) + (u.output_tokens || 0),
...(u.input_tokens_details?.cached_tokens ? {
prompt_tokens_details: { cached_tokens: u.input_tokens_details.cached_tokens }
} : {})
};
state.usage = baseChunk.usage;
}
return `data: ${JSON.stringify(baseChunk)}\n\n`;
}
return null;
}
+64
View File
@@ -0,0 +1,64 @@
import { CONFIG, getApiKey } from "./config.mjs";
import { generateSessionId } from "./session.mjs";
import crypto from "node:crypto";
class UsageTracker {
constructor() {
this.sessionRequests = 0;
this.sessionTokens = { input: 0, output: 0, total: 0 };
this.lastChecked = 0;
this.cachedUpstreamUsage = null;
}
recordUsage(usage) {
if (!usage) return;
this.sessionRequests++;
this.sessionTokens.input += usage.prompt_tokens || usage.input_tokens || 0;
this.sessionTokens.output += usage.completion_tokens || usage.output_tokens || 0;
this.sessionTokens.total += usage.total_tokens || 0;
}
async getUpstreamUsage() {
const apiKey = getApiKey();
if (!apiKey) return null;
const now = Date.now();
if (this.cachedUpstreamUsage && now - this.lastChecked < 60000) {
return this.cachedUpstreamUsage;
}
const sessionId = generateSessionId();
try {
const resp = await fetch(`${CONFIG.UPSTREAM_BASE_URL}/usage`, {
method: "GET",
headers: {
"Authorization": `Bearer ${apiKey}`,
"User-Agent": CONFIG.USER_AGENT,
"x-opencode-client": CONFIG.CLIENT,
"x-opencode-session": sessionId,
"x-opencode-request": crypto.randomUUID()
}
});
if (resp.ok) {
this.cachedUpstreamUsage = await resp.json();
this.lastChecked = now;
return this.cachedUpstreamUsage;
}
} catch {
// Endpoint may not be implemented upstream or require workspace ID
}
return null;
}
getStatus() {
return {
sessionRequests: this.sessionRequests,
sessionTokens: this.sessionTokens,
upstream: this.cachedUpstreamUsage
};
}
}
export const usageTracker = new UsageTracker();
+29
View File
@@ -0,0 +1,29 @@
---
name: opencode-unlocked
description: Guide and operational reference for using OpenCode Go models via the Opencode Unlocked bridge in OpenClaude.
---
# Opencode Unlocked Skill
This skill explains how OpenClaude interacts with OpenCode Go models through the **Opencode Unlocked** plugin and local harness bridge.
## Overview
OpenCode Go hosts 36+ cutting-edge models behind strict harness fingerprinting. The **Opencode Unlocked** plugin runs a zero-dependency local bridge daemon on `http://127.0.0.1:8788/v1` that handles:
1. **Authentic Harness Headers**: Injects exact 30-character OpenCode v2 session IDs (`ses_<timestamp_base62>`), client affinity, and harness metadata.
2. **Transparent Protocol Translation**: Translates `/v1/chat/completions` into the OpenAI `/responses` API for models that require it (such as **`muse-spark-1.3-contributor`** and **`gpt-5.6-luna`**), while natively passing standard Chat Completions for models like **`space-bunny`**.
3. **Model Prefix Stripping**: Normalizes prefix variations (`opencode-unlocked-*` -> `*`).
## Primary Models
- **`muse-spark-1.3-contributor`** (Default): High-capability reasoning and coding model. Requires `/responses` protocol; translated automatically by the bridge.
- **`space-bunny`**: Fast, lightweight general and coding model. Native `/chat/completions`.
- **`deepseek-v4-flash`**: High-throughput flash model.
- **`glm-5.3`**: General language model.
- **`gpt-5.6-luna`**: High-tier reasoning model.
- **`kimi-k2.5`**: Long-context reasoning model.
- **`minimax-01`**: Fast conversational model.
- **`qwen3.5-max`**: Large general capability model.
## Operational Commands
- Check daemon health and usage: `/opencode-unlocked:status`
- Manage models and keys: `/opencode-unlocked:unlocked`
- CLI Management: `opencode-unlocked [start|stop|restart|status|models|test|set-key]`
+93
View File
@@ -0,0 +1,93 @@
import assert from "node:assert/strict";
import { createGatewayServer } from "../server/gateway.mjs";
import { getApiKey } from "../server/config.mjs";
console.log("=== Running Gateway Integration Tests ===");
const TEST_PORT = 8799;
const server = createGatewayServer();
server.listen(TEST_PORT, "127.0.0.1", async () => {
const baseUrl = `http://127.0.0.1:${TEST_PORT}`;
const apiKey = getApiKey();
try {
// 1. Health check
const healthRes = await fetch(`${baseUrl}/health`);
assert.equal(healthRes.status, 200);
const healthData = await healthRes.json();
assert.equal(healthData.status, "ok");
console.log("✔ Health check passed");
// 2. Models list
const modelsRes = await fetch(`${baseUrl}/v1/models`);
assert.equal(modelsRes.status, 200);
const modelsData = await modelsRes.json();
assert.ok(Array.isArray(modelsData.data));
assert.ok(modelsData.data.length > 0);
console.log(`✔ Models list passed (${modelsData.data.length} models)`);
if (apiKey) {
// 3. Test space-bunny non-streaming
console.log("Testing space-bunny non-streaming...");
const spaceRes = await fetch(`${baseUrl}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "space-bunny",
messages: [{ role: "user", content: "Reply with exactly 'OK-SPACE'" }],
stream: false,
max_tokens: 20
})
});
assert.equal(spaceRes.status, 200, "space-bunny should return 200");
const spaceData = await spaceRes.json();
assert.ok(spaceData.choices?.[0]?.message?.content);
console.log(`✔ space-bunny non-streaming passed: "${spaceData.choices[0].message.content.trim()}"`);
// 4. Test muse-spark-1.3-contributor non-streaming (Responses API translation)
console.log("Testing muse-spark-1.3-contributor non-streaming (translated to /responses)...");
const museRes = await fetch(`${baseUrl}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "muse-spark-1.3-contributor",
messages: [{ role: "user", content: "Reply with exactly 'OK-MUSE'" }],
stream: false,
max_tokens: 1000
})
});
assert.equal(museRes.status, 200, "muse-spark-1.3-contributor should return 200");
const museData = await museRes.json();
assert.ok(museData.choices?.[0]?.message?.content);
console.log(`✔ muse-spark-1.3-contributor non-streaming passed: "${museData.choices[0].message.content.trim()}"`);
// 5. Test muse-spark-1.3-contributor streaming (SSE translation)
console.log("Testing muse-spark-1.3-contributor streaming (translated to SSE)...");
const streamRes = await fetch(`${baseUrl}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "muse-spark-1.3-contributor",
messages: [{ role: "user", content: "Reply with 'PING'" }],
stream: true,
max_tokens: 1000
})
});
assert.equal(streamRes.status, 200, "Streaming should return 200");
const streamText = await streamRes.text();
assert.ok(streamText.includes("data: "), "Stream must contain SSE data blocks");
assert.ok(streamText.includes("[DONE]"), "Stream must conclude with [DONE]");
console.log("✔ muse-spark-1.3-contributor streaming passed");
} else {
console.log("⚠ Skipping live API requests (no key configured)");
}
console.log("✔ All Gateway Integration Tests Passed!");
} catch (err) {
console.error("❌ Gateway integration test failed:", err);
process.exitCode = 1;
} finally {
server.close();
}
});
+79
View File
@@ -0,0 +1,79 @@
import assert from "node:assert/strict";
import { createGatewayServer } from "../server/gateway.mjs";
import { getApiKey } from "../server/config.mjs";
console.log("==================================================");
console.log(" Opencode Unlocked: Live Models Verification ");
console.log("==================================================");
const apiKey = getApiKey();
if (!apiKey) {
console.error("❌ No API key available for live test.");
process.exit(1);
}
const server = createGatewayServer();
const TEST_PORT = 8797;
server.listen(TEST_PORT, "127.0.0.1", async () => {
const baseUrl = `http://127.0.0.1:${TEST_PORT}/v1`;
try {
// 1. Verify Default Model: Muse Spark 1.3 Contributor
console.log("\n[1/2] Verifying Target Default Model: Muse Spark 1.3 Contributor...");
const musePrompt = "What is 7 * 8? Reply with only the number.";
const museStart = Date.now();
const museRes = await fetch(`${baseUrl}/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "muse-spark-1.3-contributor",
messages: [{ role: "user", content: musePrompt }],
stream: false
})
});
assert.equal(museRes.status, 200, `Muse Spark HTTP status should be 200 (was ${museRes.status})`);
const museJson = await museRes.json();
const museReply = museJson.choices?.[0]?.message?.content?.trim();
const museDuration = ((Date.now() - museStart) / 1000).toFixed(2);
console.log(`✔ Muse Spark 1.3 Contributor responded in ${museDuration}s:`);
console.log(` Output: "${museReply}"`);
console.log(` Usage: ${JSON.stringify(museJson.usage)}`);
assert.ok(museReply && museReply.includes("56"), "Muse Spark should correctly calculate 7 * 8 = 56");
// 2. Verify Alternative Model: Space Bunny
console.log("\n[2/2] Verifying Target Model: Space Bunny...");
const spacePrompt = "What color is a ripe banana? Reply in one word.";
const spaceStart = Date.now();
const spaceRes = await fetch(`${baseUrl}/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "space-bunny",
messages: [{ role: "user", content: spacePrompt }],
stream: false
})
});
assert.equal(spaceRes.status, 200, `Space Bunny HTTP status should be 200 (was ${spaceRes.status})`);
const spaceJson = await spaceRes.json();
const spaceReply = spaceJson.choices?.[0]?.message?.content?.trim();
const spaceDuration = ((Date.now() - spaceStart) / 1000).toFixed(2);
console.log(`✔ Space Bunny responded in ${spaceDuration}s:`);
console.log(` Output: "${spaceReply}"`);
console.log(` Usage: ${JSON.stringify(spaceJson.usage)}`);
assert.ok(spaceReply && /yellow/i.test(spaceReply), "Space Bunny should correctly reply with yellow");
console.log("\n==================================================");
console.log("✔ LIVE VERIFICATION PASSED FOR BOTH MODELS!");
console.log("==================================================");
} catch (err) {
console.error("\n❌ Live model verification failed:", err);
process.exitCode = 1;
} finally {
server.close();
}
});
+32
View File
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import { generateDescendingIdentifier, generateSessionId, isValidSessionId, sessionAffinity } from "../server/session.mjs";
console.log("=== Running Session Tests ===");
// 1. Validate session ID length and regex
const id1 = generateSessionId();
console.log(`Generated Session ID: ${id1}`);
assert.equal(id1.length, 30, "Session ID must be exactly 30 characters");
assert.equal(id1.startsWith("ses_"), true, "Session ID must start with 'ses_'");
assert.equal(isValidSessionId(id1), true, "isValidSessionId must return true for generated ID");
// 2. Validate descending time property
const now = Date.now();
const earlierId = generateDescendingIdentifier(now - 100000);
const laterId = generateDescendingIdentifier(now);
assert.ok(
earlierId > laterId,
"Earlier timestamp must have greater string value in descending sort order"
);
// 3. Validate affinity manager
const clientA = "conv-123";
const sidA1 = sessionAffinity.getOrCreate(clientA);
const sidA2 = sessionAffinity.getOrCreate(clientA);
assert.equal(sidA1, sidA2, "Same client identifier must receive identical session ID");
const clientB = "conv-456";
const sidB = sessionAffinity.getOrCreate(clientB);
assert.notEqual(sidA1, sidB, "Different clients must receive distinct session IDs");
console.log("✔ All Session Tests Passed!");
+76
View File
@@ -0,0 +1,76 @@
import assert from "node:assert/strict";
import {
requiresResponsesApi,
chatCompletionsToResponsesBody,
responsesToChatCompletionsResponse,
translateResponsesEventToOpenAiChunk
} from "../server/translator.mjs";
console.log("=== Running Protocol Translator Tests ===");
// 1. Verify requiresResponsesApi logic
assert.equal(requiresResponsesApi("muse-spark-1.3-contributor"), true);
assert.equal(requiresResponsesApi("opencode-unlocked-muse-spark-1.3-contributor"), true);
assert.equal(requiresResponsesApi("gpt-5.6-luna"), true);
assert.equal(requiresResponsesApi("space-bunny"), false);
assert.equal(requiresResponsesApi("deepseek-v4-flash"), false);
// 2. Chat Completions -> Responses payload conversion
const openAiReq = {
model: "opencode-unlocked-muse-spark-1.3-contributor",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "Hello world" }
],
stream: true,
temperature: 0.5,
max_tokens: 1000
};
const responsesPayload = chatCompletionsToResponsesBody(openAiReq);
assert.equal(responsesPayload.model, "muse-spark-1.3-contributor");
assert.equal(responsesPayload.stream, true);
assert.equal(responsesPayload.temperature, 0.5);
assert.equal(responsesPayload.max_output_tokens, 1000);
assert.equal(responsesPayload.input.length, 2);
assert.equal(responsesPayload.input[0].role, "system");
assert.equal(responsesPayload.input[1].content, "Hello world");
// 3. Responses API non-streaming JSON -> Chat Completions JSON
const rawResponsesResp = {
id: "resp_1234567890",
model: "muse-spark-1.3-contributor",
output: [
{
type: "message",
role: "assistant",
content: [{ type: "output_text", text: "Hello from Responses API!" }]
}
],
stop_reason: "stop",
usage: {
input_tokens: 15,
output_tokens: 8
}
};
const chatResp = responsesToChatCompletionsResponse(rawResponsesResp, "muse-spark-1.3-contributor");
assert.equal(chatResp.id, "chatcmpl_1234567890");
assert.equal(chatResp.choices[0].message.content, "Hello from Responses API!");
assert.equal(chatResp.choices[0].finish_reason, "stop");
assert.equal(chatResp.usage.prompt_tokens, 15);
assert.equal(chatResp.usage.completion_tokens, 8);
assert.equal(chatResp.usage.total_tokens, 23);
// 4. SSE translation
const state = { id: "", model: "muse-spark-1.3-contributor" };
const textChunkRaw = `event: response.output_text.delta\ndata: {"type":"response.output_text.delta","delta":"Hello "}\n\n`;
const textChunkOpenAi = translateResponsesEventToOpenAiChunk(textChunkRaw, state);
assert.ok(textChunkOpenAi.startsWith("data: "));
const parsedChunk = JSON.parse(textChunkOpenAi.replace("data: ", "").trim());
assert.equal(parsedChunk.choices[0].delta.content, "Hello ");
const doneChunk = translateResponsesEventToOpenAiChunk("data: [DONE]\n\n", state);
assert.equal(doneChunk, "data: [DONE]\n\n");
console.log("✔ All Protocol Translator Tests Passed!");