commit acfd3caf591a9178bdb50da8fb35a46855e703a7 Author: John Smith Date: Tue Oct 6 23:35:53 2026 -0400 feat: implement Opencode Unlocked plugin for OpenClaude with harness emulation and protocol translation diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..db186c6 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -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"] + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..7df3e10 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -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" + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0611a5c --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..9e01e2f --- /dev/null +++ b/README.md @@ -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_`), 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 diff --git a/bin/opencode-unlocked b/bin/opencode-unlocked new file mode 100755 index 0000000..e3116e9 --- /dev/null +++ b/bin/opencode-unlocked @@ -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 "); + 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 "); + 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 [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 Change default model (e.g. space-bunny, muse-spark-1.3-contributor) + set-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; +} diff --git a/bin/setup.mjs b/bin/setup.mjs new file mode 100755 index 0000000..7bd1e02 --- /dev/null +++ b/bin/setup.mjs @@ -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 }); + } +} diff --git a/commands/status.md b/commands/status.md new file mode 100644 index 0000000..d2110a6 --- /dev/null +++ b/commands/status.md @@ -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. diff --git a/commands/unlocked.md b/commands/unlocked.md new file mode 100644 index 0000000..bd8c0e8 --- /dev/null +++ b/commands/unlocked.md @@ -0,0 +1,11 @@ +--- +description: Manage Opencode Unlocked bridge, switch models, or configure keys +argument-hint: "[models|switch |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 `: Run `${CLAUDE_PLUGIN_ROOT}/bin/opencode-unlocked switch $1` to change the default model. +- If argument is `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`. diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..b8950fb --- /dev/null +++ b/hooks/hooks.json @@ -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 + } + ] + } + ] + } +} diff --git a/hooks/session-start.sh b/hooks/session-start.sh new file mode 100755 index 0000000..86eb717 --- /dev/null +++ b/hooks/session-start.sh @@ -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 diff --git a/package.json b/package.json new file mode 100644 index 0000000..8d1a8b9 --- /dev/null +++ b/package.json @@ -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" +} diff --git a/server/catalog.mjs b/server/catalog.mjs new file mode 100644 index 0000000..d84c5c8 --- /dev/null +++ b/server/catalog.mjs @@ -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(); diff --git a/server/config.mjs b/server/config.mjs new file mode 100644 index 0000000..30fb5a6 --- /dev/null +++ b/server/config.mjs @@ -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" +}; diff --git a/server/gateway.mjs b/server/gateway.mjs new file mode 100644 index 0000000..e0e6048 --- /dev/null +++ b/server/gateway.mjs @@ -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 `.", + 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); +} diff --git a/server/session.mjs b/server/session.mjs new file mode 100644 index 0000000..41e9c88 --- /dev/null +++ b/server/session.mjs @@ -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(); diff --git a/server/translator.mjs b/server/translator.mjs new file mode 100644 index 0000000..a0b5298 --- /dev/null +++ b/server/translator.mjs @@ -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; +} diff --git a/server/usage.mjs b/server/usage.mjs new file mode 100644 index 0000000..f6b0753 --- /dev/null +++ b/server/usage.mjs @@ -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(); diff --git a/skills/opencode-unlocked/SKILL.md b/skills/opencode-unlocked/SKILL.md new file mode 100644 index 0000000..1ee824e --- /dev/null +++ b/skills/opencode-unlocked/SKILL.md @@ -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_`), 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]` diff --git a/test/gateway.test.mjs b/test/gateway.test.mjs new file mode 100644 index 0000000..c697519 --- /dev/null +++ b/test/gateway.test.mjs @@ -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(); + } +}); diff --git a/test/live-models.test.mjs b/test/live-models.test.mjs new file mode 100644 index 0000000..f02c822 --- /dev/null +++ b/test/live-models.test.mjs @@ -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(); + } +}); diff --git a/test/session.test.mjs b/test/session.test.mjs new file mode 100644 index 0000000..5e80fb2 --- /dev/null +++ b/test/session.test.mjs @@ -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!"); diff --git a/test/translator.test.mjs b/test/translator.test.mjs new file mode 100644 index 0000000..aa18f42 --- /dev/null +++ b/test/translator.test.mjs @@ -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!");