mirror of
https://github.com/Heretek-AI/opencode-4-openclaude.git
synced 2026-11-07 02:45:58 -10:00
feat: implement Opencode Unlocked plugin for OpenClaude with harness emulation and protocol translation
This commit is contained in:
commit
acfd3caf59
22 files changed
+2114
No files matched your search
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
Executable
+222
@@ -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
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Executable
+56
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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"
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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]`
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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!");
|
||||
@@ -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!");
|
||||
Reference in new issue
Block a user