Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ config.json.example / .env.example # examples only, never real secrets
| Auth multi-key (A) | `OPENCODE_API_KEYS` canonical, `API_KEYS` legacy alias (merge; empty never blocks) | `API_KEYS` | `(none, merges with API_KEY)` |
| Fallback proxies | `OPENCODE_UPSTREAM_PROXIES` / `OPENCODE_UPSTREAM_PROXY_STRATEGY` / `OPENCODE_UPSTREAM_PROXY_COOLDOWN_MS` / `OPENCODE_UPSTREAM_PROXY_NO_PROXY` | `UPSTREAM_*` short forms | `(none)` / `failover-rr` / `300000` / `localhost,127.0.0.1,::1` |

- `DISABLE_TOOLS` resolution: `OPENCODE_DISABLE_TOOLS > DISABLE_TOOLS > file > true` via `resolveDisableTools` (`src/config/proxy-config.ts:47-57`); covered by `tests/env-alias.test.js`. Never `??`-chain booleans by hand; call the helper.
- `DISABLE_TOOLS` resolution: `OPENCODE_DISABLE_TOOLS > DISABLE_TOOLS > file > true` via `resolveDisableTools` (`src/config/proxy-config.ts:47-57`); covered by `tests/env-alias.test.js`. Never `??`-chain booleans by hand; call the helper. Note: `true` still resolves `true` — the free-tier strip (`selectPromptToolOverrides`, `src/proxy.ts:75-91`) is a post-resolution prompt-body filter for `isFreeTierSuspectModel` only (guard text + output markup stripping stay active).
- Out-of-matrix backend identity (do NOT add to the six-way matrix): `OPENCODE_CLIENT` is read directly in `src/backend/manager.ts:41-44` (allowlist `cli/desktop/acp/app`, default `cli`, foreign values overwritten), never enters `ProxyConfig`/`buildProxyConfig`; documented in `docs/configuration.md` backend-identity subsection + `CHANGELOG.md`. Same for the `OPENCODE_API_KEY="public"` strip and best-effort jail `git init`.
- `RETRY`: raw `env ?? file ?? 3`, normalized by `resolveMaxRetries` (`src/retry/policy.ts:51-58`, clamp 0–5, total attempts `1+n`, `2s×2ⁿ⁻¹` + 25% jitter, `retry-after` clamped to 30s).
- **Known dual-entry drifts (do NOT silently "fix" defaults — they change behavior; unify explicitly with tests):** `REQUEST_TIMEOUT_MS` prod `180000` (`index.ts:179`, Dockerfile/compose/docs) vs library `300000` (`src/config/proxy-config.ts:4`); `MANAGE_BACKEND` prod `false` (`index.ts:49`) vs library `true` (`src/config/proxy-config.ts:159`); `OMIT_SYSTEM_PROMPT` prod unconditional `false` (`index.ts:184-187`) vs library auto-`true` under `plugin-inject` (`src/config/proxy-config.ts:208-211`); bool parsing strictness differs (`index.ts:12-22` loose vs `src/config/proxy-config.ts:24-37` strict vs `DEBUG`/`ISOLATED` exact-match). Any new env must update all six: `.env.example`, `config.json.example`, `Dockerfile`, `docker-compose.yml`, `index.ts`, `docs/configuration.md` + this table.

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **Anonymous first-party backend posture + Zen free-tier gate handling**: spawned backend pins `OPENCODE_CLIENT` to a known first-party value (`cli` default; `desktop`/`acp`/`app` pass through), strips the `OPENCODE_API_KEY="public"` footgun (anonymous uses empty), and git-inits the jail workspace best-effort (5s timeout, warn-and-continue) as defense-in-depth for session-dir sensitivity. Prompt `tools` maps have `false` entries stripped (true-only sent; all-false omitted) only for likely-free `opencode` models (`-free` suffix, `big-pickle`, `union-alpha`) to dodge the `FreeTierError` 403 (any `false` gates); stripped tools fall back to agent defaults server-side while paid/other providers keep the exact hard-disable map. The gate (`...used from within OpenCode` / `FreeTierError`) now surfaces as 403 `permission_denied` fail-fast (no retry, no proxy fallback) with false-positive guards for plain quota text. Jail stdout no longer prints absolute paths; interactions route gains text-level tool-markup stripping parity with chat/responses/messages. New `tests/backend-identity.test.js` / `tests/first-party-gate.test.js` / `tests/prompt-tool-overrides.test.js` / `tests/session-ids.test.js`. No new proxy knob (no config matrix change).
- **Translator parity fixes (N×N matrix review)**: chat array `content` parts no longer silently dropped (extract `text`/`content`); `responses` `incomplete` with `content_filter` reason round-trips instead of collapsing to `length` (non-stream + stream); `is_error` now round-trips via the `ERROR: ` text prefix on chat/messages/responses tool legs (strict `true`/`"true"` check; success text literally starting with `ERROR: ` round-trips as `is_error` by design); named `custom` tool declarations are kept so `custom_tool_call` invocations always have a matching declaration; ID generation centralized on `makeId(prefix)` (`toolu_` fallback separator canonicalized, prefix-only checks unaffected). N×N 翻译矩阵对齐修复。New `tests/translator-phase-fixes.test.js` (13 cases).
- **后端错误透出**:`/v1/responses` 在后端 session 失败时曾 `throw` 纯对象,经 `transformUpstreamError` 被洗成 `500 Internal server error / code Object`、真实讯息丢失。新增 `normalizeBackendError`(纯对象→Error,保留 `data.message`/`name`/状态码推断),两处 `throw polled.error` 改用它;`transformUpstreamError` 纵深加固(默认分支改读 `data.message`/`name`,code 默认 `upstream_error` 不再用 `constructor.name`)。现 Credits 类错误正确回 `402 insufficient_quota` 并携带原文。新增 `tests/backend-error-surface.test.js`(4 例复现 LiteLLM 案例,含 messages)。
- **同族错误路径查漏**:responses 串流 idleTimeout 补 poll 失败检查(不再静默回空 `completed`);messages 非串流改用 transformed `status/type/code`(不再硬编码 `502/api_error`);messages 串流检查 `collected.error` 与 poll 失败(失败即抛正規化错误走 SSE error 事件,不再静默 200);`isTransientUpstreamError` 纳入 `name/code/type` 参与签名匹配(无 message 纯对象不再必判 false)。
Expand Down
8 changes: 7 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@

| 变量 | 默认值 | 说明 |
|:-----|:-------|:-----|
| `OPENCODE_DISABLE_TOOLS` / `DISABLE_TOOLS` | `true` | 禁用 OpenCode 工具调用(兼容别名;`OPENCODE_DISABLE_TOOLS` 优先,二者无效值都会让位给下一顺位:canonical env > legacy env > `config.json` > 默认) |
| `OPENCODE_DISABLE_TOOLS` / `DISABLE_TOOLS` | `true` | 禁用 OpenCode 工具调用(兼容别名;`OPENCODE_DISABLE_TOOLS` 优先,二者无效值都会让位给下一顺位:canonical env > legacy env > `config.json` > 默认)。例外:① 疑似免费 Zen 模型(`opencode` provider 且 `-free` 后缀、`big-pickle`、`union-alpha`)剔除 prompt `tools` 映射中的 `false` 项(仅保留 `true` 项;全 `false` 则省略整个映射)以避开上游 `FreeTierError` 403(任意 `false` 均触发,`{}`/省略/`true`-only 通过),被剔除的工具回落服务端 agent 默认,此时仅靠 system prompt 禁用语 + 输出侧 markup 剥离承载禁用姿态;② `/v1/responses` 的 `web_search` 与 Interactions 的 `google_search` 会以 `hosted-search-grant` 单独放行 `websearch`(输出仅 `web_search_call` + 引文,不经过外部桥接) |
| `OPENCODE_EXTERNAL_TOOLS_MODE` | `proxy-bridge` | 外部工具桥接模式;当前仅支持 `proxy-bridge` |
| `OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY` | `namespace` | 外部工具冲突隔离策略;当前仅支持 `namespace` |
| `OPENCODE_INTERNAL_WEB_FETCH_ENABLED` | `false` | 兼容旧开关;未显式配置 allowlist 时,启用后默认放行 `web_fetch` |
Expand Down Expand Up @@ -78,6 +78,12 @@
| `OPENCODE_PATH` | `opencode` | OpenCode 可执行文件路径 |
| `OPENCODE_ZEN_API_KEY` | - | Zen API Key 透传 |

### 后端 identity(非 proxy knob,不进六向矩阵)

- `OPENCODE_CLIENT`:被拉起后端向 Zen 声明的第一方身份。allowlist 为 `cli`/`desktop`/`acp`/`app`,默认 `cli`;未设置/空白/未知值一律回落 `cli`(allowlist clamp,非 bool fallthrough)。
- 从网关宿主环境继承的外来值会被覆写(如 `opencode2api` 会被重写为 `cli`),已知值透传。无 file key、无别名,不进 `ProxyConfig`/`buildProxyConfig`,故 `.env.example`/`config.json.example`/`Dockerfile`/`docker-compose.yml`/`index.ts` 均不声明。
- 附带:`OPENCODE_API_KEY="public"` 字面值会从后端环境删除(匿名应传空而非该字面);jail 工作目录会 best-effort `git init`(5s 超时,失败 warn 继续)。

---

## 📄 config.json 示例
Expand Down
78 changes: 71 additions & 7 deletions src/backend/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import fs from 'fs';
import path from 'path';
import os from 'os';
import http from 'http';
import { spawn } from 'child_process';
import { spawn, execFileSync } from 'child_process';
import type { ChildProcess } from 'child_process';
import type { ProxyConfig } from '../types/config.js';
import type { BackendState, OpencodeResolveResult } from '../types/backend.js';
Expand All @@ -25,6 +25,59 @@ const STARTING_WAIT_INTERVAL_MS = 1000;

const OPENCODE_BASENAME = 'opencode';

/**
* Stage-1 (mimic-first-party, anonymous by default):
* Pin the client identity the spawned backend advertises to Zen
* (`x-opencode-client` + `User-Agent: opencode/<version>` are minted inside
* the backend from this env; the SDK path has no per-request passthrough).
* Unknown/foreign values inherited from the gateway host env would make the
* free-tier gate classify us as "not from within OpenCode", so fall back to
* the real CLI default `cli` unless the operator explicitly set a known
* first-party value. No login required; anonymous (`auth.json` absent) stays
* anonymous — we never inject credentials here.
*/
const KNOWN_BACKEND_CLIENTS = new Set(['cli', 'desktop', 'acp', 'app']);

export function resolveBackendClient(): string {
const raw = (process.env['OPENCODE_CLIENT'] ?? '').trim();
if (raw && KNOWN_BACKEND_CLIENTS.has(raw)) return raw;
return 'cli';
}

/**
* Stage-6: `git init` the jail workspace so session directories resolve to a
* git-backed project root. Kept as defense-in-depth for backend versions
* whose free-tier gate is sensitive to non-git project roots (live round-2
* verification showed plain dirs also pass on the current backend, so this
* is compatibility insurance, not a requirement). Best-effort: warns and
* continues when git is unavailable.
*/
export function ensureJailGitRepo(workspace: string): boolean {
try {
if (fs.existsSync(path.join(workspace, '.git'))) return true;
execFileSync('git', ['init', '-q', workspace], { stdio: 'ignore', timeout: 5000 });
return fs.existsSync(path.join(workspace, '.git'));
} catch {
console.warn('[Proxy] git init failed for jail workspace; free-tier models may hit the upstream gate.');
return false;
}
}
/**
* Apply first-party identity to a spawned-backend env record in place.
* Also neutralizes the `OPENCODE_API_KEY="public"` footgun: the literal
* string disables the backend's local free-only filter but the server still
* treats it as anonymous, so paid models would 401. Empty/omitted is the
* correct anonymous signal.
*/
export function applyBackendIdentityEnv(env: Record<string, string | undefined>): Record<string, string | undefined> {
env['OPENCODE_CLIENT'] = resolveBackendClient();
if ((env['OPENCODE_API_KEY'] ?? '').trim() === 'public') {
delete env['OPENCODE_API_KEY'];
console.warn('[Proxy] OPENCODE_API_KEY="public" removed from backend env (anonymous uses empty, not the literal).');
}
return env;
}

export function splitPathEnv(): string[] {
const raw: string = process.env['PATH'] || '';
return raw.split(path.delimiter).filter(Boolean);
Expand Down Expand Up @@ -397,14 +450,16 @@ export async function ensureBackend(config: unknown): Promise<void> {
if (isWindows) {
// Windows: use normal user home to avoid opencode storage path issues
fs.mkdirSync(workspace, { recursive: true });
ensureJailGitRepo(workspace);
cwd = workspace;
envVars = {
envVars = applyBackendIdentityEnv({
...process.env,
OPENCODE_PROJECT_DIR: workspace,
};
});
console.log('[Proxy] Running on Windows, using standard user home directory');
} else {
fs.mkdirSync(workspace, { recursive: true });
ensureJailGitRepo(workspace);
cwd = workspace;

if (useIsolatedHome) {
Expand All @@ -421,12 +476,12 @@ export async function ensureBackend(config: unknown): Promise<void> {
if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
});

envVars = {
envVars = applyBackendIdentityEnv({
...process.env,
HOME: fakeHome,
USERPROFILE: fakeHome,
OPENCODE_PROJECT_DIR: workspace,
};
});

if (PROMPT_MODE === 'plugin-inject') {
const configDir = path.join(fakeHome, '.config', 'opencode');
Expand Down Expand Up @@ -454,10 +509,10 @@ export async function ensureBackend(config: unknown): Promise<void> {
}
console.log('[Proxy] Using isolated home for OpenCode');
} else {
envVars = {
envVars = applyBackendIdentityEnv({
...process.env,
OPENCODE_PROJECT_DIR: workspace,
};
});
console.log('[Proxy] Using real HOME for OpenCode (isolation disabled)');
}
}
Expand Down Expand Up @@ -486,6 +541,15 @@ export async function ensureBackend(config: unknown): Promise<void> {
if (ZEN_API_KEY) {
spawnArgs.push('--password', String(ZEN_API_KEY));
}
// Stage-3/6 note: the jail is kept for isolation but initialized as a git
// work tree (ensureJailGitRepo): the server only uses x-opencode-project
// for $project forwarding and telemetry — never gating — while Zen's
// free-tier gate requires a git-backed session directory. No identity is
// fabricated: the repo is real (may be empty), no remote is set.
console.log(`[Proxy] Backend identity: OPENCODE_CLIENT=${envVars['OPENCODE_CLIENT']} (anonymous free-tier, no login)`);
// Log hygiene: never print the absolute jail path (tmpdir + salt). The
// basename is enough to correlate; the full path stays out of stdout.
console.log(`[Proxy] Backend project dir: <jail>/${path.basename(cwd)} (isolated git-backed jail)`);
state.process = spawn(opencodeBin, spawnArgs, spawnOptions);

// Handle spawn errors
Expand Down
26 changes: 24 additions & 2 deletions src/errors/upstream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,14 @@ export function isTransientUpstreamError(error: unknown): boolean {
// A 500 carrying a context message must surface immediately, not burn retries.
if (/context.?length|context.?overflow|context_length_exceeded/i.test(message)) return false;

// First-party gate is permanent: retrying (or proxy-fallbacking) can never
// succeed, and a Python Traceback body might otherwise trip a generic
// transient signature. Fail fast. Anchored on "used from within" (not bare
// "from within") so incidental phrasing like "originated from within the
// model context" never matches; FreeTierError is the structured class seen
// live in Zen response bodies (collectMessageParts covers name/code/type).
if (/from within opencode|used from within|freetiererror/i.test(message)) return false;

const record = asRecord(error);
const data = asRecord(record['data']);
// Provider-marked retryable (SDK ApiError.data.isRetryable) is transient,
Expand Down Expand Up @@ -204,7 +212,18 @@ export function normalizeBackendError(raw: unknown): NormalizedUpstreamError {
}
if (typeof statusCode !== 'number') {
const s = messageStr.toLowerCase();
if (/insufficient balance|credits?error|insufficient credits|billing|quota exceeded|credit limit/.test(s)) statusCode = 402;
// First-party gate (server-side, closed-source inference service): the
// message carries no numeric status ("...can only be used from within
// OpenCode" + Python Traceback). Match it before the generic billing
// branch so it surfaces as 403, not 500/502. Anchored on "used from
// within" (not bare "free tier"/"from within") so plain quota text
// (e.g. "Free tier quota exceeded", "Free tier rate limit reached
// within 60 seconds") still falls through to 402/429 below.
// FreeTierError is the structured class (name/code/type fields and raw
// Zen response bodies carry it even when the prose is absent).
const gateHaystack = `${collectMessageParts(raw)} ${readResponseBodyText(raw)}`;
if (/from within opencode|used from within|freetiererror/i.test(gateHaystack)) statusCode = 403;
else if (/insufficient balance|credits?error|insufficient credits|billing|quota exceeded|credit limit/.test(s)) statusCode = 402;
else if (/rate.?limit|too many requests|worker request limit/.test(s)) statusCode = 429;
else if (/invalid api key|unauthorized|authentication/.test(s)) statusCode = 401;
}
Expand Down Expand Up @@ -309,9 +328,12 @@ export function transformUpstreamError(error: unknown): TransformedUpstreamError
message = upstreamMessage || 'Invalid API key';
} else if (
upstreamType === 'PermissionError' ||
upstreamType === 'FreeTierError' ||
statusCode === 403 ||
upstreamMessage.toLowerCase().includes('permission denied') ||
upstreamMessage.toLowerCase().includes('access denied')
upstreamMessage.toLowerCase().includes('access denied') ||
upstreamMessage.toLowerCase().includes('within opencode') ||
upstreamMessage.toLowerCase().includes('used from within')
) {
// Permission errors - map to 403
statusCode = 403;
Expand Down
Loading
Loading