Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
7add0b7
feat(stream): reconcile authoritative snapshots for streaming externa…
samson910022 Oct 21, 2026
191a1af
feat(stream): harden SSE keepalives, block lifecycle, and stream erro…
samson910022 Oct 21, 2026
40fbded
feat(http): add body-parser error handler, auth-before-parse ordering…
samson910022 Oct 21, 2026
07808c8
feat(retry): close 503 streaming retry gaps, cache provider list, and…
samson910022 Oct 21, 2026
8335296
chore(ci): align workflow backend pins to Dockerfile and document rel…
samson910022 Oct 21, 2026
737b084
fix(stream): reconcile interleaved snapshots and harden stream cut de…
samson910022 Oct 21, 2026
8be0cbf
fix(tool-runtime): enforce strict authoritative snapshot isolation an…
samson910022 Oct 21, 2026
417a726
fix(retry): preserve continuation session history and harden proxy fa…
samson910022 Oct 21, 2026
0aa96d6
fix(api): align Anthropic error types, deduplicate interactions route…
samson910022 Oct 21, 2026
3ea6ab9
docs(changelog): document stream hardening, continuation preservation…
samson910022 Oct 21, 2026
c50cff2
fix(deps): bump undici to 8.12.3 for October 2026 security releases
samson910022 Oct 22, 2026
84df79f
fix(stream): fail closed on premature cut with poll failure and unbuf…
samson910022 Oct 22, 2026
c1b35a9
fix(retry): guarantee continuation prompt idempotency and prevent dup…
samson910022 Oct 22, 2026
96d4dec
fix(proxy): add epoch guard to prevent provider cache race on reconne…
samson910022 Oct 22, 2026
67fb556
fix(stream): enforce strict poll completion validation and fail close…
samson910022 Oct 22, 2026
54f73ec
fix(retry): fail closed on continuation terminal provider errors to p…
samson910022 Oct 22, 2026
82a2f48
fix(retry): harden continuation prompt failure fail-closed to prevent…
samson910022 Oct 23, 2026
8b3f1b5
fix(stream): preserve whitespace deltas, reconcile search snapshots, …
samson910022 Oct 23, 2026
b12a85a
fix(proxy): normalize trailing slashes on auth bypass, harden body pa…
samson910022 Oct 23, 2026
630205f
fix(backend): protect cold-start model resolution, resync active mode…
samson910022 Oct 23, 2026
17d1f13
docs(changelog): document continuation fail-closed, whitespace preser…
samson910022 Oct 23, 2026
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
4 changes: 2 additions & 2 deletions .github/workflows/ai-fix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ jobs:
set -euo pipefail
npm ci
npm run build
# Pinned to track @opencode-ai/sdk ^1.18.29 used by this repo.
npm i -g opencode-ai@1.18.30
# Pinned to match Dockerfile ARG OPENCODE_VERSION.
npm i -g opencode-ai@1.18.35
# No-auth localhost gateway: empty API_KEY = no auth; the gateway
# spawns its own backend (anonymous out-of-box free quota).
API_KEY= OPENCODE_PROXY_MANAGE_BACKEND=true OPENCODE_SERVER_PASSWORD=ci-local-only \
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/ai-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,8 @@ jobs:
set -euo pipefail
npm ci
npm run build
# Pinned to track @opencode-ai/sdk ^1.18.29 used by this repo.
npm i -g opencode-ai@1.18.30
# Pinned to match Dockerfile ARG OPENCODE_VERSION.
npm i -g opencode-ai@1.18.35
# No-auth localhost gateway: empty API_KEY = no auth; the gateway
# spawns its own backend (anonymous out-of-box free quota).
API_KEY= OPENCODE_PROXY_MANAGE_BACKEND=true OPENCODE_SERVER_PASSWORD=ci-local-only \
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/ai-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,8 @@ jobs:
set -euo pipefail
npm ci
npm run build
# Pinned to track @opencode-ai/sdk ^1.18.29 used by this repo.
npm i -g opencode-ai@1.18.30
# Pinned to match Dockerfile ARG OPENCODE_VERSION.
npm i -g opencode-ai@1.18.35
# No-auth localhost gateway: empty API_KEY = no auth; the gateway
# spawns its own backend (anonymous out-of-box free quota).
API_KEY= OPENCODE_PROXY_MANAGE_BACKEND=true OPENCODE_SERVER_PASSWORD=ci-local-only \
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **Undici security upgrade (`undici@8.12.3`)**: Aligned with Node.js October 2026 Security Releases (October 21, 2026), upgrading `undici` from `^8.10.2` to `^8.12.3` to fix unbounded buffering SSE stream resource exhaustion DoS and TLS certificate verification bypass; runtime deployment recommended on Node.js `v24.22.1+` (LTS) / `v22.23.4+` (LTS) / `v26.12.1+` (fixing Brotli RCE CVE-2026-111497, TLS hostname verification bypass CVE-2026-106069 / CVE-2026-105483 / CVE-2026-106072, Keep-alive socket out-of-order response confusion CVE-2026-105481, and dns.resolve NUL truncation CVE-2026-105482). Undici 安全升级 (`undici@8.12.3`):對齊 Node.js 2026 年 10 月安全通告(October 21, 2026 Security Releases),升級 `undici` 從 `^8.10.2` 至 `^8.12.3`(修復 SSE 串流未含換行符時的無限緩衝資源耗盡 DoS 漏洞與 TLS 憑證驗證旁路問題)。執行環境建議升級至修復版 Node.js `v24.22.1+` (LTS) / `v22.23.4+` (LTS) / `v26.12.1+`(修復 Brotli RCE CVE-2026-111497、TLS 主機名稱校驗旁路 CVE-2026-106069 / CVE-2026-105483 / CVE-2026-106072、Keep-alive socket 錯序回應混淆 CVE-2026-105481 與 dns.resolve NUL 截斷 CVE-2026-105482)。
- **独立维护声明**:README 致谢明确直接上游 `TiaraBasori/opencode2api`,LICENSE 追加上游归属,CHANGELOG 声明分叉点。
- **仓库去耦合**:文档克隆/Issue 链接与 Docker 镜像指向 `samson910022/OpenCode2API`,`package.json` 补全维护元数据。
- **Responses 示例模型**:`README`、`docs/api-reference.md`、`docs/getting-started.md` 的 `/v1/responses` 示例统一为 `opencode/muse-spark-1.3-contributor-free`(解析逻辑无需改动,裸名与带前缀均兼容)。
Expand All @@ -33,6 +34,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **Streaming external tool call reliability**: replaced lossy delta-only assembly with sequence-based per-part reconciliation (`deltasAfterSnapshot`) in `src/stream/collector.ts` between streaming deltas and authoritative snapshots (`part.text`, `info.parts`), resolving a single authoritative source across Chat, Messages, and Responses without unioning stale raw delta artifacts (`callsFromText` and `repairFromSnapshot`), so lossy deltas never falsely trigger `malformed_external_tool_call` or duplicate/parallel tool errors. Routes perform targeted single snapshot repair, derive visible text in one pass under external tool buffering to eliminate tool-call JSON leaks, preserve whitespace-only deltas in unbuffered responses streaming, reconcile hosted search output with authoritative snapshots when deltas are dropped, and prevent legacy co-occurring snapshot/delta duplication. Replaced invalid JSON `{...}` in router reminder with `{}`. 流式外部工具呼叫可靠性重構:修復分塊丟失與快照交錯導致的文本重複與工具呼叫誤報,單一權威來源徹底隔離過期分塊,緩衝模式一次性移除工具標記避免 JSON 洩漏至文字,保留 Responses 串流純空格分塊,補齊託管搜尋快照調和,避免舊版後端並發 delta 重複文字。
- **SSE framing, keepalives, Anthropic error mapping, and block lifecycle**: keepalive comments across Chat, Messages, Responses, and Interactions now write single newline `: comment\n` (without `\n\n`) to eliminate phantom empty SSE events; added `X-Accel-Buffering: no` header across all streaming routes; open Anthropic `text` and `thinking` blocks are cleanly closed via `content_block_stop` before emitting `event: error`; mapped Messages 404 to Anthropic-compliant `not_found_error` and 415 to `invalid_request_error`; deduplicated Interactions routes and cleaned up client-close listeners; all outer catch handlers close open sockets via `res.end()` with wire-compliant framing; stop keepalive intervals cleanly on stream abort/finally. SSE 幀格式硬化:keepalive 修正為單換行避免產生空事件,新增 `X-Accel-Buffering: no`,Anthropic 區塊在報錯前確實關閉,Messages 404 對齊 `not_found_error` 且 415 對齊 `invalid_request_error`,Interactions 路由去重與監聽清理,外層 catch 確保斷開連線不卡死,串流結束或中斷時確實清理 keepalive 計時器。
- **Body-parser error handling, Express hardening, and provider coalescing**: added pure HTTP error classifier (`src/errors/http.ts`) and terminal 4-argument Express error middleware to return protocol-shaped JSON (400 for bad JSON, 413 for oversized body, 415 for unsupported media type) instead of HTML stack traces; mounted auth middleware before body parsing with trailing slash normalization (allowing `/health/`, `/health/details/`, `/metrics/` probes to bypass auth) to block unauthenticated payload processing; hardened urlencoded body parser to 1MB with `extended: false` against prototype pollution; disabled `X-Powered-By` header; added `ENV NODE_ENV=production` in container runtime stage; coalesced in-flight `getProvidersList` requests to prevent cache stampedes; ensured `getProvidersList` invokes `ensureBackend` when `MANAGE_BACKEND: true` so cold-start or auto-restart is never blocked by pre-lock model resolution; exported `clearBackendReconnectListeners` and exposed `ctx.dispose()`; explicitly return 404 for unknown model resolution. 請求主體解析錯誤處理與安全加固:終端錯誤中介軟體統一回傳協定專用 JSON 格式(杜絕 HTML 堆疊洩漏),先鑑權再解析主體(支援結尾斜槓探測放行),收緊 urlencoded 解析器至 1MB 且關閉 extended,移除 X-Powered-By,provider 清單請求合併防擊穿並於冷啟動時自動調用 ensureBackend,提供重連監聽清理介面,未知模型顯式回傳 404。
- **Streaming 503 retry gaps, continuation history retention, and proxy fallback**: added transient 503 retry loops on Responses stream, Messages stream, and Interactions before output starts, strictly failing closed after partial output; continuation multi-turn sessions (`previous_response_id` / `parent_interaction_id`) preserve the parent session and conversation history across retries without creating blank sessions; Responses proxy fallback properly resets attempt error state and polls snapshot output; in-attempt snapshot recovery catches transient 503 errors within the retry budget; strict 4xx filtering (recognizing HTTP 408 Request Timeout alongside 401, 402, and 429 as retryable) and anchored pattern in `isTransientUpstreamError`; continuation terminal fail-closed protection (failing closed when provider inference or prompt call throws/fails on continuation turns to prevent parent transcript duplication in OpenCode SQLite persistence); re-synchronized `ensureActiveModel` on retry when proxy fallback switches `activeClient` across all routes; moved model resolution outside the process-global `lock()` in Chat and Messages so unknown models fail fast with 404 in milliseconds without waiting on concurrent streaming requests. 串流 503 重試填補與鎖分離:Responses 串流、Messages 串流與 Interactions 補齊 503 暫態重試,多回合續答母 session 完整保留歷史上下文;Responses 代理回退清理前次錯誤,上游暫態錯誤嚴格過濾 4xx(將 HTTP 408 請求超時與 401、402、429 共同納入可重試狀態碼);續答回合在 prompt 調用或上游推論失敗時嚴格 fail-closed 終止,防止在 OpenCode SQLite 重複寫入使用者對話回合;重試切換代理時跨路由重新同步 activeModel;模型解析移出全域請求鎖,未知模型毫秒級回傳 404 不再排隊阻塞。
- **CI backend pins alignment (`opencode-ai@1.18.35`)**: aligned `.github/workflows/ai-fix.yml`, `ai-review.yml`, and `ai-scan.yml` to `opencode-ai@1.18.35` matching `Dockerfile` `ARG OPENCODE_VERSION=1.18.35`, with automated lock tests in `tests/dockerfile-pin.test.js`. CI 工作流與 Dockerfile 固定版本一致對齊至 1.18.35。
- **Zen free-tier `read` gate (`FreeTierError` 403)**: the default backend `read` permission is now an inert ordered gate (`{ '*': 'deny', [FREE_TIER_READ_GATE_PATH]: 'allow' }`, NUL-terminated sentinel) instead of plain `deny`, so OpenCode `Permission.disabled()`'s findLast-by-tool-name check keeps `read` in the upstream `tools[]` (the free tier requires it) while execution-time path matching still denies every real path; the NUL sentinel closes the `Wildcard.match` backslash-normalization bypass, `bash` remains `deny`, and explicit allowlist `read` still emits a real `allow`. The data-level contract is locked by `tests/free-tier-gate.test.js` + `tests/backend-permission.test.js`; source/live verification is still required when bumping the backend pin because unit tests cannot execute OpenCode's installed backend. 默认后端 `read` 权限改为有序惰性闸门(NUL 哨兵 allow 让 `read` 保留在上游 `tools[]`),真实路径仍全部拒绝,`bash` 默认仍为 deny,显式 allowlist `read` 仍是真 allow;该数据层契约由 `tests/free-tier-gate.test.js` 与 `tests/backend-permission.test.js` 锁定,升级后端固定版本时仍需源码与真实 Zen 验证(单测无法执行镜像内安装的后端)。
- **Backend tool-hang + per-request dispose fixes**: the proxy no longer `PUT config {activeModel}` on every request — native `ConfigHttpApi.update` disposes the whole backend project instance unconditionally (no changed-check), aborting in-flight sessions into the `Aborted` retry-loop storm; `ensureActiveModel` (`src/backend/active-model.ts`) PUTs only on actual model change per backend client (failures never cached, existing catch semantics preserved). Separately, the headless backend can no longer `ask` (nobody approves, so prompts hung until the 180s timeout) nor silently execute unlisted tools (the free-tier strip omits the prompt tools map, so the backend fell back to agent defaults = all on): backend `opencode.json` now carries a generated deny-all `permission` map (`src/backend/backend-permission.ts`, single source for `entrypoint.sh` via `dist` with static fallback and `src/backend/manager.ts` isolated path) allowing only explicitly configured internal tools, with `external_directory` always jailed to `/home/node/project/**`. 后端不再每请求 dispose(模型变更才 PUT activeModel);headless 后端不再 `ask` 卡死、不再静默执行未授权工具(deny-all,仅放行明确配置的 internal 工具)。Locked by `tests/active-model.test.js` + `tests/backend-permission.test.js` + `tests/tool-protocol-parity.test.js`.
- **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).
Expand Down
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ RUN npm ci --omit=dev
COPY --from=builder /build/dist ./dist
RUN chown -R node:node /home/node/project

ENV NODE_ENV=production

EXPOSE 10000 10001

# Config env defaults stay empty on purpose: a non-empty default would win over
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
"body-parser": "^1.20.3",
"cors": "^2.8.5",
"express": "^4.21.2",
"undici": "^8.10.2"
"undici": "^8.12.3"
},
"devDependencies": {
"@swc/core": "^1.16.2",
Expand Down
25 changes: 25 additions & 0 deletions src/backend/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,30 @@ if (process.platform !== 'win32') {
// Backend management state (per-instance)
export const backendState = new Map<string, BackendState>();

type BackendReconnectListener = () => void;
const backendReconnectListeners = new Set<BackendReconnectListener>();

export function onBackendReconnect(listener: BackendReconnectListener): () => void {
backendReconnectListeners.add(listener);
return () => {
backendReconnectListeners.delete(listener);
};
}

export function notifyBackendReconnect(): void {
for (const listener of backendReconnectListeners) {
try {
listener();
} catch {
// ignore
}
}
}

export function clearBackendReconnectListeners(): void {
backendReconnectListeners.clear();
}

/**
* Backend Lifecycle Management
*/
Expand Down Expand Up @@ -572,6 +596,7 @@ export async function ensureBackend(config: unknown): Promise<void> {
// A fresh backend reverts to its default model: drop cached
// set-active-model state so the next request PUTs again.
resetActiveModelCache();
notifyBackendReconnect();

// Handle spawn errors
state.process.on('error', (spawnErr: unknown) => {
Expand Down
13 changes: 13 additions & 0 deletions src/converters/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,3 +396,16 @@ export function sseEvent(event: string, payload: unknown): string {
export function estimateTokens(text: unknown): number {
return Math.ceil(String(text || '').length / 4);
}

export function toAnthropicErrorType(statusCode: number, upstreamType?: string): string {
if (statusCode === 404 || upstreamType === 'model_not_found') return 'not_found_error';
if (statusCode === 400 || upstreamType === 'invalid_request_error') return 'invalid_request_error';
if (statusCode === 401 || upstreamType === 'invalid_api_key') return 'authentication_error';
if (statusCode === 403 || upstreamType === 'permission_denied') return 'permission_error';
if (statusCode === 413) return 'request_too_large';
if (statusCode === 415) return 'invalid_request_error';
if (statusCode === 429 || upstreamType === 'rate_limit_exceeded') return 'rate_limit_error';
if (statusCode === 529) return 'overloaded_error';
return 'api_error';
}

100 changes: 100 additions & 0 deletions src/errors/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// HTTP error classification and protocol-shaped error bodies for request/body-parser errors.
import { asRecord } from '../utils/guards.js';

export interface AnthropicHttpErrorBody {
type: 'error';
error: {
type: 'invalid_request_error';
message: string;
code?: string;
};
}

export interface OpenAiHttpErrorBody {
error: {
message: string;
type: 'invalid_request_error';
code?: string;
};
}

export interface ClassifiedHttpError {
status: number;
statusCode: number;
body: AnthropicHttpErrorBody | OpenAiHttpErrorBody;
}

export function isAnthropicMessagesPath(path?: string): boolean {
if (!path || typeof path !== 'string') return false;
const normalized = (path.split('?')[0]?.replace(/\/+$/, '') ?? '').toLowerCase();
return normalized === '/v1/messages';
}

/**
* Pure HTTP error classifier.
* Maps body-parser and request-level errors to safe HTTP statuses and protocol-shaped bodies.
* NEVER echoes err.body, err.message, stack traces, internal class names, or filesystem paths.
*/
export function classifyHttpError(err: unknown, path?: string): ClassifiedHttpError {
const record = asRecord(err);
const rawType = record['type'];
const errType = typeof rawType === 'string' ? rawType : '';
const rawStatus = record['status'] ?? record['statusCode'];
const statusNum =
typeof rawStatus === 'number'
? rawStatus
: typeof rawStatus === 'string'
? Number(rawStatus)
: null;
const isSyntaxError = err instanceof SyntaxError || record['name'] === 'SyntaxError';

let status: number;
let message: string;
let code: string | undefined;

if (errType === 'entity.parse.failed' || isSyntaxError || (statusNum === 400 && 'body' in record)) {
status = 400;
message = 'Malformed JSON in request body';
code = 'invalid_json';
} else if (errType === 'entity.too.large' || statusNum === 413) {
status = 413;
message = 'Request body exceeds the maximum permitted size';
code = 'entity_too_large';
} else if (errType === 'charset.unsupported' || errType === 'encoding.unsupported' || statusNum === 415) {
status = 415;
message = 'Unsupported body encoding or charset';
code = 'unsupported_media_type';
} else if (errType === 'entity.verify.failed') {
status = 400;
message = 'Request body verification failed';
code = undefined;
} else {
status = 500;
message = 'Internal server error';
code = undefined;
}

const isAnthropic = isAnthropicMessagesPath(path);
const body: AnthropicHttpErrorBody | OpenAiHttpErrorBody = isAnthropic
? {
type: 'error',
error: {
type: 'invalid_request_error',
message,
...(code ? { code } : {}),
},
}
: {
error: {
message,
type: 'invalid_request_error',
...(code ? { code } : {}),
},
};

return {
status,
statusCode: status,
body,
};
}
Loading
Loading