This document was originally written as a design proposal. Every item it called out as missing or to-be-built has since been implemented and verified end-to-end (see §11). The "what was built" wording in §4 and §8 reflects the delivered state; future-tense language has been kept only where it explains historical context.
NeuroLink's proxy currently supports Claude Code as a client — when neurolink proxy start runs, it automatically configures Claude Code by writing ANTHROPIC_BASE_URL to ~/.claude/settings.json. Claude Code then sends Anthropic Messages API requests to the proxy, which routes them to any provider.
This document describes adding OpenCode as a second supported client with the same zero-config experience: neurolink proxy start should auto-configure OpenCode so it connects to the proxy with no manual setup.
OpenCode (github.com/sst/opencode) is an open-source AI coding agent built by the SST team. It is a TypeScript monorepo that shares the same technology stack as NeuroLink:
- Vercel AI SDK (
aipackage) withstreamText(),Tooltypes,ModelMessage - Provider SDKs:
@ai-sdk/openai,@ai-sdk/anthropic,@ai-sdk/openai-compatible,@ai-sdk/google,@ai-sdk/amazon-bedrock,@ai-sdk/azure,@ai-sdk/xai,@ai-sdk/mistral,@ai-sdk/groq,@openrouter/ai-sdk-provider, and more - MCP:
@modelcontextprotocol/sdkfor tool integration - Zod: for tool parameter schemas
OpenCode is provider-agnostic. It supports Claude, OpenAI, Google, Bedrock, Groq, Azure, xAI, Mistral, Cohere, and any OpenAI-compatible endpoint via @ai-sdk/openai-compatible.
When neurolink proxy start runs:
- Server starts on configured port (default 4141)
- Accounts are loaded from proxy config + OAuth credentials
- Claude Code settings are auto-configured:
- Writes to
~/.claude/settings.json - Sets
env.ANTHROPIC_BASE_URL = "http://localhost:<port>" - Sets
env.ENABLE_TOOL_SEARCH = "true" - Preserves original values in
__proxy_original_envfor restoration
- Writes to
- On proxy stop: restores original Claude Code settings
Key code (src/cli/commands/proxy.ts):
const CLAUDE_SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
const PROXY_MANAGED_KEYS = ["ANTHROPIC_BASE_URL", "ENABLE_TOOL_SEARCH"];
async function setClaudeProxySettings(baseUrl: string): Promise<void> {
// Reads ~/.claude/settings.json
// Snapshots original env values
// Sets ANTHROPIC_BASE_URL to proxy URL
// Writes back
}Claude Code then sends all requests to the proxy's /v1/messages endpoint (Anthropic Messages API format).
OpenCode uses XDG base directories via the xdg-basedir npm package, which
resolves XDG_CONFIG_HOME || ~/.config on every platform — there is no
macOS special case. (OpenCode's binary does contain a
/Library/Application Support/opencode literal, but that is
systemManagedConfigDir(), an MDM policy directory at the filesystem root
with no $HOME prefix — not the per-user config path.)
| Platform | Global Config Path |
|---|---|
| macOS | ~/.config/opencode/opencode.json |
| Linux | ~/.config/opencode/opencode.json |
| Windows | ~/.config/opencode/opencode.json (unverified) |
The macOS row was verified empirically against OpenCode 1.3.13 (embedded
xdg-basedir source in the shipped binary, plus opencode models confirming
which file is actually loaded). The Windows row follows from the same
branch-free resolution code but has not been checked on a Windows machine.
Project-level config: .opencode/opencode.json in any parent directory.
From packages/opencode/src/config/config.ts (line 787-846):
Config.Provider = ModelsDev.Provider.partial().extend({
models: z.record(z.string(), ModelsDev.Model.partial()).optional(),
options: z
.object({
apiKey: z.string().optional(),
baseURL: z.string().optional(),
// ... timeout, chunkTimeout, etc.
})
.catchall(z.any())
.optional(),
});
Config.Info = z.object({
// ...
provider: z.record(z.string(), Config.Provider).optional(),
model: z.string().optional(), // format: "provider/model"
// ...
});From packages/opencode/src/provider/provider.ts:
-
Bundled providers are imported directly (line 127-150):
const BUNDLED_PROVIDERS = { "@ai-sdk/openai": createOpenAI, "@ai-sdk/anthropic": createAnthropic, "@ai-sdk/openai-compatible": createOpenAICompatible, // ... 20+ providers };
-
Custom providers from config's
providerfield get initialized with theiroptions(includingbaseURL,apiKey) -
Model definitions come from
models.devAPI (fetched and cached) + config overrides -
Auto-discovery: if env vars for a provider are set (e.g.,
ANTHROPIC_API_KEY), that provider loads automatically
When OpenCode uses a custom provider with npm: "@ai-sdk/openai-compatible", it:
- Creates an SDK instance via
createOpenAICompatible({ baseURL, apiKey }) - Sends requests to
{baseURL}/chat/completions(the AI SDK appends the path) - Uses standard OpenAI Chat Completions wire format
- Handles streaming via SSE (
data: {"choices":[...]}format)
The proxy now exposes both shapes:
| Endpoint | Format | For client | Status |
|---|---|---|---|
POST /v1/messages |
Anthropic Messages API | Claude Code | Pre-existing |
POST /v1/messages/count_tokens |
Anthropic | Claude Code | Pre-existing |
GET /v1/models (Anthropic format) |
Anthropic | Claude Code | Pre-existing |
POST /v1/chat/completions |
OpenAI Chat Completions | OpenCode | Added (openaiProxyRoutes.ts) |
GET /v1/models (OpenAI list format) |
OpenAI | OpenCode | Added (buildModelsListResponse) |
src/cli/commands/proxy.ts gained the symmetric helpers used during proxy start / proxy stop:
setOpenCodeProxySettings(baseUrl)— line 293, writes theprovider.neurolinkblock to OpenCode'sopencode.json(XDG-resolved path)clearOpenCodeProxySettings(baseUrl)— line 341, removes only entries whosebaseURLmatches the proxy's- Wired into the start path (line 1431) and stop/uninstall paths (lines 1292, 2357)
- Skipped automatically under
--devso isolated dev instances never touch the user's OpenCode config
OpenCode NeuroLink Proxy Any Provider
─────── ────────────── ────────────
@ai-sdk/openai-compatible
createOpenAICompatible({
baseURL: "http://localhost:4141/v1",
apiKey: "proxy-key"
})
│
▼
POST /v1/chat/completions ────▶ openaiProxyRoutes.ts
{ │
model: "claude-sonnet-4", ├─ parseOpenAIRequest()
messages: [...], │ → Extract system prompt
tools: [...], │ → Flatten messages
stream: true │ → Convert tools (function → AI SDK)
} │ → Map tool_choice
│
├─ ModelRouter.resolve(model)
│ → claude-* → anthropic
│ → gemini-* → vertex
│ → custom mappings
│
├─ buildProxyTranslationPlan()
│ → Classify request
│ → Build fallback chain
│
├─ ctx.neurolink.stream(options) ──────▶ Any Provider
│ (Anthropic, Google,
│ OpenAI, Bedrock, etc.)
▼
serializeOpenAIResponse()
OpenAIStreamSerializer
│
SSE Response ◀────────────────────┘
data: {"choices":[{"delta":{"content":"..."}}]}
data: {"choices":[{"delta":{"tool_calls":[...]}}]}
data: {"choices":[{"delta":{},"finish_reason":"stop"}]}
data: [DONE]
| Aspect | Claude Code Path | OpenCode Path |
|---|---|---|
| Wire format | Anthropic Messages API | OpenAI Chat Completions |
| Endpoint | POST /v1/messages |
POST /v1/chat/completions |
| Format translator | claudeFormat.ts |
openaiFormat.ts (NEW) |
| Route handler | claudeProxyRoutes.ts |
openaiProxyRoutes.ts (NEW) |
| Stream serializer | ClaudeStreamSerializer |
OpenAIStreamSerializer (NEW) |
| Auto-config target | ~/.claude/settings.json |
XDG opencode.json |
| Auto-config key | env.ANTHROPIC_BASE_URL |
provider.neurolink.options.baseURL |
| Internal pipeline | Same ctx.neurolink.stream() |
Same ctx.neurolink.stream() |
| Model routing | Same ModelRouter |
Same ModelRouter |
| Account management | Same accounts, cooldowns, fallbacks | Same accounts, cooldowns, fallbacks |
| OpenAI Field | NeuroLink Internal | Notes |
|---|---|---|
messages[role="system"].content |
systemPrompt |
Concatenate multiple system messages |
messages[role="user/assistant/tool"] |
conversationMessages[] |
Flatten to {role, content} |
Last user message .content |
prompt |
Extracted as string |
messages[role="assistant"].tool_calls |
Inline as [tool_use:id:name] {args} |
Same pattern as claudeFormat.ts |
messages[role="tool"] |
Inline as [tool_result:tool_call_id] content |
Same pattern as claudeFormat.ts |
content[type="image_url"].image_url.url |
images[] |
From latest user message only |
tools[].function.{name,description,parameters} |
tools{} via jsonSchema() |
AI SDK format |
tool_choice: "auto"/"required"/"none" |
toolChoice |
Direct mapping |
tool_choice: {type:"function",function:{name}} |
toolChoice: "required" + toolChoiceName |
Named tool |
max_tokens / max_completion_tokens |
maxTokens |
Default 4096 if unset |
temperature, top_p |
temperature, topP |
Direct |
stop |
stopSequences |
Direct |
stream |
stream |
Direct |
NeuroLink InternalResult |
OpenAI Response |
|---|---|
content |
choices[0].message.content |
toolCalls[].toolName |
choices[0].message.tool_calls[].function.name |
toolCalls[].args |
choices[0].message.tool_calls[].function.arguments (stringified!) |
toolCalls[].toolCallId |
choices[0].message.tool_calls[].id |
finishReason: "end_turn"/"stop" |
finish_reason: "stop" |
finishReason: "tool-calls"/"tool_use" |
finish_reason: "tool_calls" |
finishReason: "length"/"max_tokens" |
finish_reason: "length" |
usage.input |
usage.prompt_tokens |
usage.output |
usage.completion_tokens |
usage.total |
usage.total_tokens |
model |
model |
reasoning |
Not standard — drop or use custom field |
| Event | SSE Frame |
|---|---|
| Stream start | data: {"choices":[{"delta":{"role":"assistant"}}]} |
| Text chunk | data: {"choices":[{"delta":{"content":"text"}}]} |
| Tool call start | data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_xx","type":"function","function":{"name":"Read","arguments":""}}]}}]} |
| Tool call args | data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"chunk"}}]}}]} |
| Finish | data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{...}} |
| Done | data: [DONE] |
neurolink proxy start
→ setClaudeProxySettings("http://localhost:4141")
→ writes ~/.claude/settings.json
→ env.ANTHROPIC_BASE_URL = "http://localhost:4141"
neurolink proxy stop
→ clearClaudeProxySettings()
→ restores original ~/.claude/settings.json values
neurolink proxy start
→ setClaudeProxySettings(...) // existing
→ setOpenCodeProxySettings("http://localhost:4141/v1", "proxy-key") // NEW
→ detects OpenCode config path (XDG)
→ reads existing opencode.json (or creates)
→ adds provider.neurolink = {
npm: "@ai-sdk/openai-compatible",
name: "NeuroLink Proxy",
env: [],
models: { /* available models from proxy config */ },
options: {
baseURL: "http://localhost:4141/v1",
apiKey: "proxy-key"
}
}
→ preserves original values for restoration
→ writes back
neurolink proxy stop
→ clearClaudeProxySettings() // existing
→ clearOpenCodeProxySettings() // NEW
→ removes provider.neurolink from opencode.json
→ restores original values
// Same logic as OpenCode's global/index.ts
import { xdgConfig } from "xdg-basedir";
const OPENCODE_CONFIG_DIR = join(
xdgConfig ?? join(homedir(), ".config"),
"opencode",
);
const OPENCODE_CONFIG_PATH = join(OPENCODE_CONFIG_DIR, "opencode.json");On macOS and Linux alike: ~/.config/opencode/opencode.json
(XDG_CONFIG_HOME wins when set)
The proxy should detect whether OpenCode is installed before writing config:
async function isOpenCodeInstalled(): Promise<boolean> {
// Check if opencode config directory exists (XDG path)
// Or check if opencode binary exists in PATH (which opencode)
// Or check if ~/.opencode/ exists in any parent directory
}If OpenCode is not installed, skip auto-configuration silently (same behavior as Claude Code — if ~/.claude/ doesn't exist, the proxy doesn't fail).
OpenCode's provider system has an autoload mechanism. Each provider's custom loader checks for env vars (via input.env.some((item) => env[item])). If the provider's required env vars are present, it autoloads.
For providers using @ai-sdk/openai-compatible, the env vars are typically:
OPENAI_COMPATIBLE_BASE_URL— the endpoint URLOPENAI_COMPATIBLE_API_KEY— the API key
This means an even simpler auto-config path: instead of writing to opencode.json, the proxy could write env vars to a shared .env file or inject them into the process environment. However, the config-file approach is more reliable and matches the Claude Code pattern.
The proxy should use both approaches for maximum compatibility:
- Config file (primary): Write
provider.neurolinktoopencode.json— this gives users a visible, editable config entry with model definitions - Env vars (fallback): If the config file approach fails (permissions, etc.), fall back to writing env vars that
@ai-sdk/openai-compatibleauto-detects
| File | Purpose |
|---|---|
src/lib/proxy/openaiFormat.ts |
OpenAI ↔ Internal translator (parser, serializer, SSE transform) |
src/lib/server/routes/openaiProxyRoutes.ts |
/v1/chat/completions + /v1/models + Anthropic loopback bridge |
src/lib/proxy/proxyTranslationEngine.ts |
Unified translation engine shared with the Claude route (refactor) |
test/fixtures/opencode-local-proxy.json |
OpenCode fixture pointing at the dev proxy |
docs/features/opencode-proxy-support.md |
This document (design + manual testing playbook) |
OpenAI wire types and ProxyFormat / StreamSerializerAdapter live in src/lib/types/proxy.ts (the canonical types barrel; the original design-time path proxyTypes.ts was renamed during the release-line refactor).
| File | Change (delivered) |
|---|---|
src/lib/server/routes/index.ts |
Adds openaiProxy flag + unified proxy flag; registers createOpenAIProxyRoutes |
src/cli/commands/proxy.ts |
Adds setOpenCodeProxySettings() and clearOpenCodeProxySettings(); wired into start/stop |
src/lib/proxy/modelRouter.ts |
Surfaces getModelMappings() / getPassthroughModels() for buildModelsListResponse |
src/lib/types/proxy.ts |
OpenAI wire-format types + ProxyFormat + StreamSerializerAdapter |
src/lib/types/server.ts |
CreateRoutesOptions gains proxy and openaiProxy flags |
src/lib/server/routes/claudeProxyRoutes.ts |
Refactored to share the new translation engine; /v1/models returns the unified list |
| Component | Why it works |
|---|---|
ModelRouter |
resolve() is format-agnostic |
routingPolicy.ts |
Request classification works on internal format |
proxyConfig.ts |
Account pools, model mappings — format-agnostic |
proxyTracer.ts |
OTel tracing — format-agnostic |
requestLogger.ts |
Structured logging — format-agnostic |
usageStats.ts |
Per-account stats — format-agnostic |
NeuroLink generate()/stream() |
The entire backend — unchanged |
- ✅ OpenAI wire types added to the proxy types barrel
- ✅
openaiFormat.ts— parser, response serializer, streaming SSE serializer, error builder - ✅
openaiProxyRoutes.ts—/v1/chat/completions+/v1/models+ Anthropic loopback bridge - ✅
routes/index.tsupdated withopenaiProxyand unifiedproxyflags - ✅
setOpenCodeProxySettings()/clearOpenCodeProxySettings()added toproxy.ts - ✅
neurolink proxy startauto-configures OpenCode (skipped under--dev) - ✅ Manual test plan in §11; verified end-to-end against OpenCode 1.3.13
User must manually edit OpenCode config to add a custom provider. No auto-detection.
neurolink proxy start
# Output:
# NeuroLink Proxy v9.47.0 listening on http://localhost:4141
# Claude Code: configured (ANTHROPIC_BASE_URL → http://localhost:4141)
# OpenCode: configured (provider.neurolink → http://localhost:4141/v1)
# 3 accounts loaded (2 anthropic, 1 vertex)Both Claude Code and OpenCode immediately connect to the proxy. No manual configuration needed.
After auto-config, users select models in OpenCode's TUI model picker. Available models come from the proxy's model mappings + available accounts. The proxy serves them via GET /v1/models.
| Case | Handling |
|---|---|
| OpenCode not installed | Skip auto-config silently |
Existing provider.neurolink in config |
Update baseURL/apiKey, preserve other fields |
| Proxy stops unexpectedly | OpenCode falls back to its other configured providers |
n > 1 in request |
Ignore — return single choice (NeuroLink generates n=1) |
response_format: json_object |
Map to structuredOutput where provider supports it |
| Reasoning/thinking content | Not in standard OpenAI format — drop (OpenCode handles this per-provider via ProviderTransform) |
| Image content in messages | image_url blocks → extract to images[] |
Legacy functions field |
Not supported — tools only (matches OpenCode's behavior) |
stream_options: {include_usage: true} |
Include usage in final streaming chunk |
Auth (Authorization: Bearer) |
Accept any token (validate against proxy config if auth is enabled) |
This section is a self-contained playbook for verifying the OpenCode proxy support end to end. It assumes you are on the feat/opencode-support-for-proxy branch and the global proxy on :55669 should remain untouched throughout.
- Node 20+ and
pnpmavailable opencodeCLI installed (opencode --versionshould print 1.3.x or newer)jqandcurlavailable- A working
~/.neurolink/proxy-config.yamland~/.neurolink/.env(used by the global proxy) - The
dist/directory built from this branch (pnpm run build:cliif not built)
You are starting a second proxy instance, isolated from the global one:
| Global proxy | Dev proxy under test | |
|---|---|---|
| Port | 55669 | 5555 |
| State dir | ~/.neurolink/ |
<repo>/.neurolink-dev/ |
| Managed by | launchd | foreground process |
| Touched by these tests | Never | Yes |
Two safety invariants checked throughout:
curl http://localhost:55669/health→ 200 (global never goes down)- The dev PID from
.neurolink-dev/proxy-state.json≠ the global PID from~/.neurolink/proxy-state.json
OpenCode 1.3.13 hardcodes the Anthropic SDK whenever the model name contains claude — it bypasses /v1/chat/completions and posts directly to /v1/messages. To exercise this branch's new OpenAI endpoint end-to-end, the OpenCode fixture must use a non-claude alias (we use gpt-4o) and the proxy must be told to map that alias to a real Anthropic model. We map to Haiku because Sonnet aggressively rate-limits 150-KB requests with OpenCode's full tool set and produces noisy 429s during testing.
cd /path/to/neurolink/feat/opencode-support-for-proxy
# (a) Build CLI if needed
pnpm run build:cli
# (b) Start fresh — wipe any prior dev state. Global state untouched.
rm -rf .neurolink-dev
mkdir -p .neurolink-dev
# (c) Routing config: extend your global mappings with the gpt-4o alias.
# Output goes only to .neurolink-dev/proxy-config.yaml — global config is unchanged.
jq '.routing["model-mappings"] += [{"from":"gpt-4o","to":"claude-haiku-4-5","provider":"anthropic"}]' \
~/.neurolink/proxy-config.yaml > .neurolink-dev/proxy-config.yaml
# (d) OpenCode fixture in an isolated workspace.
# The repo ships two fixtures:
# - test/fixtures/opencode-local-proxy.json (claude-sonnet-4-6, hits /v1/messages — exercises Claude passthrough)
# - test/fixtures/opencode-local-proxy-openai-route.json (gpt-4o alias, hits /v1/chat/completions — exercises this branch's new code)
# For end-to-end verification of this branch you want the second one.
mkdir -p /tmp/opencode-proxy-test
cp test/fixtures/opencode-local-proxy-openai-route.json /tmp/opencode-proxy-test/opencode.jsoncd /path/to/neurolink/feat/opencode-support-for-proxy
node dist/cli/index.js proxy start --dev --port 5555 --config .neurolink-dev/proxy-config.yaml--dev scopes all state to .neurolink-dev/, skips launchd, and skips client auto-configuration. Wait for Server is ready.
In a third terminal, tail the proxy lifecycle log:
cd /path/to/neurolink/feat/opencode-support-for-proxy
tail -f .neurolink-dev/logs/proxy-$(date -u +%F).jsonl | jq| # | Check | Command | Pass criteria |
|---|---|---|---|
| S1 | Dev proxy up | curl -s http://localhost:5555/health | jq |
status: "ok", ready: true |
| S2 | Global untouched | curl -s -o /dev/null -w "%{http_code}\n" http://localhost:55669/health |
200 |
| S3 | Routing alias visible | curl -s http://localhost:5555/v1/models | jq '.data | map(.id)' |
List contains gpt-4o |
| S4 | PIDs distinct | jq -r .pid .neurolink-dev/proxy-state.json && jq -r .pid ~/.neurolink/proxy-state.json |
Two different numbers |
These prove the proxy code is correct in isolation. Each test should print HTTP 200 and a sane response.
PROXY=http://localhost:5555
# W1 Non-streaming
curl -s $PROXY/v1/chat/completions -H 'content-type: application/json' \
-d '{"model":"claude-sonnet-4-6","messages":[{"role":"user","content":"Reply with one word: hello."}],"max_tokens":20}' \
| jq '.choices[0].message.content'
# W2 Streaming
curl -N -s $PROXY/v1/chat/completions -H 'content-type: application/json' \
-d '{"model":"claude-sonnet-4-6","messages":[{"role":"user","content":"Count to 3."}],"max_tokens":30,"stream":true}'
# W3 Tool call (request)
curl -s $PROXY/v1/chat/completions -H 'content-type: application/json' \
-d '{"model":"claude-sonnet-4-6","max_tokens":200,
"messages":[{"role":"user","content":"What is the weather in Tokyo? Use the tool."}],
"tools":[{"type":"function","function":{"name":"get_weather","description":"Get current weather",
"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}}]}' \
| jq '{finish: .choices[0].finish_reason, tool_calls: .choices[0].message.tool_calls}'
# W4 Tool result round-trip — multi-turn with assistant.tool_calls + role:tool message
curl -s $PROXY/v1/chat/completions -H 'content-type: application/json' \
-d '{"model":"claude-sonnet-4-6","max_tokens":100,
"messages":[
{"role":"user","content":"What is the weather in Tokyo?"},
{"role":"assistant","content":null,"tool_calls":[{"id":"toolu_X","type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"Tokyo\"}"}}]},
{"role":"tool","tool_call_id":"toolu_X","content":"{\"temp_celsius\":18,\"condition\":\"cloudy\"}"}
],
"tools":[{"type":"function","function":{"name":"get_weather","description":"Get current weather",
"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}}]}' \
| jq '.choices[0].message.content'
# W5 Streaming tool call
curl -N -s $PROXY/v1/chat/completions -H 'content-type: application/json' \
-d '{"model":"claude-sonnet-4-6","max_tokens":100,"stream":true,
"messages":[{"role":"user","content":"Get weather in Paris."}],
"tools":[{"type":"function","function":{"name":"get_weather","description":"Get weather",
"parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}}]}'Expected:
- W1: a one-word reply
- W2: SSE chunks ending with
data: [DONE], each chunk ischatcmpl-...shape - W3:
finish: "tool_calls",tool_calls[0].function.name == "get_weather", args{"city":"Tokyo"} - W4: a sentence describing 18°C cloudy weather in Tokyo
- W5: deltas containing
delta.tool_calls[0]with progressively concatenatingfunction.arguments, ending withfinish_reason: "tool_calls"
Run from the OpenCode workspace so it picks up the fixture:
cd /tmp/opencode-proxy-test| # | Command | Pass criteria |
|---|---|---|
| E1 | opencode run "Reply with exactly one word: hello" |
Output contains hello |
| E2 | opencode run "What is 17 multiplied by 23? Reply with just the number." |
Output contains 391 |
| E3 | echo "test content $(date)" > marker.txt && opencode run "Read the file marker.txt and tell me what's in it. Just the content." |
Output contains test content |
| E4 | opencode run "Run the bash command 'echo PROXY_E2E_OK' and report its output. Just the output." |
Output contains PROXY_E2E_OK |
| E5 | rm -f output.txt && opencode run "Create a file output.txt with the text 'success token 7426'. Then read it back." |
cat output.txt shows success token 7426 |
| E6 | mkdir -p src && echo "function alpha() { return 1; }" > src/a.js && echo "function alpha2() { return 3; }" > src/b.js && opencode run "Use grep to find all functions whose name starts with 'alpha' in src/." |
Output mentions both alpha and alpha2 |
| E7 | opencode run --format json "Reply with one word: jsontest" |
Output is JSON-line stream including step_start, text, step_finish events |
| E8 | opencode run "Write a TypeScript function called add that takes two numbers and returns their sum. Reply with just the code." |
Output contains function add (or const add =) |
| E9 | Multi-turn: capture session id from a first JSON run, then continue (snippet below) | Second turn recalls the number from the first |
| E10 | LONG=$(seq 1 200 | sed 's/^/item_/' | tr '\n' ' '); opencode run "Items: $LONG. Reply with only the integer count." |
Output contains 200 |
Snippet for E9:
SID=$(opencode run --format json "Remember the number 8472. Reply 'noted'." 2>&1 \
| jq -r 'select(.sessionID)|.sessionID' | head -1)
opencode run --session "$SID" "What number did I tell you to remember? Just the number."
# expected: 8472Run this immediately after any OpenCode test above:
cd /path/to/neurolink/feat/opencode-support-for-proxy
DATE=$(date -u +%F)
# (a) Body captures appeared on disk for that request.
ls -td .neurolink-dev/logs/bodies/$DATE/*/ | head -2
# (b) The captured body contains the exact prompt you typed and was tagged with the alias.
DIR=$(ls -td .neurolink-dev/logs/bodies/$DATE/*/ | head -1)
REQ=$(ls "$DIR" | grep client_request | head -1)
gunzip -c "$DIR$REQ" | jq '{
user_agent: .headers["user-agent"],
content_length: .headers["content-length"],
body_model: (.body | (if type=="string" then fromjson else . end) | .model),
user_msg: (.body | (if type=="string" then fromjson else . end) | .messages[-1].content)
}'Pass criteria for (b):
user_agent: "node"— OpenCode runs on Nodecontent_length≥ 100000 — only OpenCode sends bodies that large (114 tool defs)body_model: "claude-haiku-4-5"— the proxy's router rewrotegpt-4o→claude-haiku-4-5before this capture, proving the request passed throughopenaiProxyRoutes.tsandroutingPolicy.tsuser_msgis the exact string you sent OpenCode
Independent confirmation from OpenCode's own logs:
cd /tmp/opencode-proxy-test
opencode run --print-logs --log-level INFO "ping" 2>&1 \
| grep -E "providerID=neurolink|pkg=@ai-sdk/openai-compatible"Pass: two lines including pkg=@ai-sdk/openai-compatible using bundled provider — proves the OpenAI-compatible SDK was used, not the bundled Anthropic SDK.
Stop the dev proxy and verify OpenCode hangs/errors. This rules out any "OpenCode silently bypassed the proxy and went straight to Anthropic" hypothesis.
cd /path/to/neurolink/feat/opencode-support-for-proxy
DEV_PID=$(jq -r '.pid' .neurolink-dev/proxy-state.json)
GLOBAL_PID=$(jq -r '.pid' ~/.neurolink/proxy-state.json)
[ "$DEV_PID" = "$GLOBAL_PID" ] && echo "ABORT: PIDs match — refusing to kill global" && exit 1
kill -TERM "$DEV_PID" && sleep 2
curl -s -o /dev/null -w "dev :5555 after stop: %{http_code}\n" http://localhost:5555/health # expect 000
curl -s -o /dev/null -w "global :55669 still: %{http_code}\n" http://localhost:55669/health # expect 200
cd /tmp/opencode-proxy-test
timeout 30 opencode run "ping" ; echo "exit=$?"
# Pass: exit 124 (timeout) and no LLM reply printed → OpenCode could not reach any model.Then restart the proxy and rerun any E1–E10 to confirm recovery.
cd /path/to/neurolink/feat/opencode-support-for-proxy
# Global proxy unaffected
curl -s -o /dev/null -w "global :55669 health: %{http_code}\n" http://localhost:55669/health # 200
# Dev state lives only in repo-local dir, never in HOME
ls .neurolink-dev/ # has proxy-state.json, logs/, account-quotas.json
[ -f ~/.neurolink/proxy-state-dev.json ] && echo "BAD: dev leaked into HOME" || echo "no leakage"
# Dev proxy is bound only to 5555
lsof -nP -iTCP:5555 -sTCP:LISTEN | head -3cd /path/to/neurolink/feat/opencode-support-for-proxy
DEV_PID=$(jq -r '.pid' .neurolink-dev/proxy-state.json)
GLOBAL_PID=$(jq -r '.pid' ~/.neurolink/proxy-state.json)
[ "$DEV_PID" != "$GLOBAL_PID" ] && kill -TERM "$DEV_PID"
# Optional: remove dev state
# rm -rf .neurolink-dev
# Optional: remove test workspace
# rm -rf /tmp/opencode-proxy-testA clean run looks like this:
- S1–S4 all pass (proxy up, global untouched, alias visible, PIDs distinct)
- W1–W5 all return HTTP 200 with the expected fields
- E1–E10 all produce the expected output strings
- §11.9 (a) shows ≥1 capture per OpenCode run; (b) shows your prompt verbatim and
body_model: claude-haiku-4-5 - §11.9 OpenCode debug log shows
pkg=@ai-sdk/openai-compatible - §11.10 OpenCode times out / errors out when proxy is killed; resumes when restarted
- §11.11 global :55669 health is 200 throughout
If any step deviates, the directory .neurolink-dev/logs/bodies/$(date -u +%F)/<requestId>/ for the failing request contains the exact request body, every retry attempt, and the upstream response — open the matching *-upstream_response-attempt-N.json.gz for the upstream error message.
- OpenCode 1.3.13 always sends
claude-*model names with Anthropic format (bypassing/v1/chat/completions). Tests must use the alias indirection described above. - Anthropic Sonnet aggressively 429s 150-KB requests when its per-account burst budget is in cooldown. The alias points to Haiku to avoid this. If you need to test Sonnet specifically, expect intermittent 429s.
- Both the parent
/v1/chat/completionsrequest and (for Anthropic-routed traffic) the inner loopback/v1/messagesrequest now emit lifecycle entries inproxy-*.jsonl. The parent line carriesaccountType: "openai-bridge"; the child carries the OAuth account details from the Claude passthrough path. Filter onrequestIdto correlate them, or filter onpath: /v1/chat/completionsto see only the parent entries.