Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 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
11c89d1
fix(stream): scope recovery snapshots to current turn
samson910022 Oct 23, 2026
ed37308
feat(api): fork continuation sessions from saved state
samson910022 Oct 23, 2026
46858c7
feat(api): add scoped idempotency and stream replay
samson910022 Oct 24, 2026
758cf03
fix(api): harden request errors and server lifecycle
samson910022 Oct 24, 2026
d9df7ae
fix(api): close final stream and idempotency edge cases
samson910022 Oct 24, 2026
fa9ee31
fix(idempotency): abandon pre-submit transient failures and stabilize…
samson910022 Oct 24, 2026
94e89f4
fix(stream): enforce Anthropic block order, preserve Responses leadin…
samson910022 Oct 24, 2026
fd87bf8
fix(errors): enforce fail-fast 4xx ordering in transient filter and m…
samson910022 Oct 24, 2026
2b8dcbe
docs: document continuation idempotency, fast-fail 404, and X-Accel-B…
samson910022 Oct 24, 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
13 changes: 13 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Turn a local [OpenCode](https://opencode.ai) runtime into an OpenAI-, Anthropic-
| 🔑 **Multi-key auth** | `API_KEY` + `OPENCODE_API_KEYS` merge; `Bearer` or `x-api-key`; empty skips auth |
| 🔀 **Fallback proxy** | free-limit 429-only `OPENCODE_UPSTREAM_PROXIES` rotation |
| 📡 **Streaming** | SSE for Chat, Responses, Messages, Interactions |
| 🔄 **Continuation idempotency** | Scoped `Idempotency-Key` replay for `/v1/responses` and `/v1/interactions` |
| 🧠 **Reasoning control** | `reasoning_effort` / `reasoning: {"effort": "high"}` |
| 🐳 **Docker deploy** | One-command with auto backend |
| 🛡️ **Tool safety** | Disabled by default |
Expand Down Expand Up @@ -141,13 +142,13 @@ Use each client's native endpoint; cross-protocol conversion is best-effort —

### Model name formats

Formats: `opencode/big-pickle`, `gpt5-nano` (auto-resolved) and `opencode/gpt5-nano` — see [API Reference](./docs/api-reference.md).
Formats: `opencode/big-pickle`, `gpt5-nano` (auto-resolved) and `opencode/gpt5-nano` — see [API Reference](./docs/api-reference.md). When a requested model is not found on the upstream provider, the proxy fails fast with HTTP 404 (`model_not_found`) and returns `available_models` without waiting on downstream request queues.

---

## 🔧 Troubleshooting

Hangs: try `OPENCODE_USE_ISOLATED_HOME=false`. Missing model: check `/v1/models`. No reasoning: use Responses API with `stream: true` + `reasoning.effort`.
Hangs: try `OPENCODE_USE_ISOLATED_HOME=false`. Missing model: check `/v1/models` (requests for unknown models fail fast with HTTP 404 `model_not_found`). No reasoning: use Responses API with `stream: true` + `reasoning.effort`.

> 📖 Full guide: [Troubleshooting](./docs/troubleshooting.md)

Expand Down
5 changes: 3 additions & 2 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
| 🔑 **多 Key 认证** | `API_KEY` 与 `OPENCODE_API_KEYS` 合并;`Bearer` 或 `x-api-key`;为空免认证 |
| 🔀 **免费限流 fallback 代理** | 仅免费限流 429 时轮换 `OPENCODE_UPSTREAM_PROXIES` |
| 📡 **流式输出** | Chat、Responses、Messages、Interactions 全支持 SSE |
| 🔄 **Continuation 幂等重播** | `/v1/responses` 与 `/v1/interactions` 支持作用域隔离的 `Idempotency-Key` |
| 🧠 **推理控制** | `reasoning_effort` / `reasoning: {"effort": "high"}` |
| 🐳 **Docker 部署** | 一键部署,自动启动后端 |
| 🛡️ **工具安全** | 默认禁用工具调用 |
Expand Down Expand Up @@ -141,13 +142,13 @@ curl -N -X POST http://127.0.0.1:10000/v1/responses \

### 模型名称格式

支持 `opencode/big-pickle`、`gpt5-nano`(自动解析)与 `opencode/gpt5-nano`;详见 [API 参考文档](./docs/api-reference.md)。
支持 `opencode/big-pickle`、`gpt5-nano`(自动解析)与 `opencode/gpt5-nano`;详见 [API 参考文档](./docs/api-reference.md)。若请求的上游 Provider 中未找到指定模型,代理会毫秒级快速失败并返回 HTTP 404(`model_not_found`)及可用模型列表(`available_models`),避免排队阻塞后续请求。

---

## 🔧 故障排查

卡住:试 `OPENCODE_USE_ISOLATED_HOME=false`。模型缺失:检查 `/v1/models`。无推理:用 `stream: true` + `reasoning.effort` 的 Responses API。
卡住:试 `OPENCODE_USE_ISOLATED_HOME=false`。模型缺失:检查 `/v1/models`(未知模型会快速返回 HTTP 404 `model_not_found`)。无推理:用 `stream: true` + `reasoning.effort` 的 Responses API。

> 📖 完整指南:[故障排查](./docs/troubleshooting.md)

Expand Down
31 changes: 28 additions & 3 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ curl -X POST http://127.0.0.1:10000/v1/responses \
}'
```

非流式 `responses` 响应会在 `response.output` 中返回 `type: "function_call"` 项;流式模式会发送 function_call 生命周期和参数增量事件。`previous_response_id` 续写只保留最近 128 条工具调用元数据(超出时丢弃最旧、保留最新;按 response ID 链作用域保存,内存态、30min TTL + 60s 清扫,重启动失);`function_call_output` 的 `call_id` 不在 state 且无 `name` 时返回 400 `unknown_tool_call_id`。
非流式 `responses` 响应会在 `response.output` 中返回 `type: "function_call"` 项;流式模式会发送 function_call 生命周期和参数增量事件。`previous_response_id` 续写会将完整父 session fork 为隔离子 session,不会向父 session 追加本轮消息;工具调用元数据只保留最近 128 条(超出时丢弃最旧、保留最新;按 response ID 链作用域保存,内存态、30min TTL + 60s 清扫,重启动失)。`function_call_output` 的 `call_id` 不在 state 且无 `name` 时返回 400 `unknown_tool_call_id`。

**服务端联网搜索(`web_search`):**

Expand Down Expand Up @@ -237,10 +237,28 @@ POST /v1/interactions
| `tools` | array | - | 仅支持 `{type: "google_search"}`(等价 `web_search`),其余 function 工具返回 400 |
| `previous_interaction_id` | string | - | 续写上轮会话(等价 responses 的 `previous_response_id`) |
| `stream` | boolean | - | SSE:`interaction.created` / `step.delta` / `interaction.completed`(无 `[DONE]`,错误为 `{type:'error'}` 事件;15s heartbeat) |
| `store` | boolean | - | `false` 则不持久化;仅删除本请求新建的会话,复用的父会话保留(注意:带 `previous_interaction_id` 的 `store:false` 轮次仍会追加到父会话历史,并非完全无痕) |
| `store` | boolean | - | `false` 则不持久化;仅删除本请求新建或 fork 的子会话,父会话保留且不会被本轮修改 |

响应为 `Interaction` 资源:`{id, status, model, output_text, steps[], usage: {grounding_tool_count}}`,其中 `steps` 含 `google_search_call{queries}`、`google_search_result{sources}`(标注为 opencode websearch 代理结果,非 Google 原生)、`model_output{text, annotations}`。文本往返与 `web_search` 接地逻辑复用 Responses 管线;限流错误的重试与代理 fallback 和其余路由一致(engage + 换 session,最多 `maxAttempts` 次),普通错误直接返回。

### Continuation Idempotency and Retries / Continuation 幂等与重试

`POST /v1/responses`, `POST /v1beta/interactions`, and alias `POST /v1/interactions` support an optional `Idempotency-Key` request header. Clients should supply a unique key per logical operation and retry after timeouts or disconnections using the **same key and identical JSON request body**. The idempotency scope is isolated by authenticated tenant (a cryptographic digest of the API key; unauthenticated requests share an anonymous tenant when auth is disabled), target endpoint, parent continuation ID (`previous_response_id` / `previous_interaction_id`), and key. The two Interactions paths (`/v1beta/interactions` and `/v1/interactions`) map to the same endpoint and share idempotency records. Records are never shared between Responses and Interactions, across distinct parent IDs, or between different tenant API keys.

- **Payload conflicts & in-progress handling**: Reusing an existing key in the same scope with a different payload returns HTTP `409` with error code `idempotency_key_reused`. Concurrent duplicate requests for an in-flight prompt return a retryable HTTP `409` with code `idempotency_in_progress`. Request bodies are compared via canonical JSON normalization, so object property order does not affect the payload hash.
- **Exact replay from beginning**: Confirmed completed JSON and SSE responses are cached and **replayed exactly from the beginning**, preserving the original HTTP status code, relevant headers, and the full response body. SSE retry replay is not resumable delta streaming—it replays the complete wire byte stream and terminal events from start to finish.
- **Unconfirmed prompt submission & reconciliation**: If a prompt was submitted to the upstream backend but the outcome has not yet been confirmed, the record is marked as unknown. Retrying with the same key will only poll and reconcile the recorded child session and message; it will never resubmit the prompt or spawn an unintended child session. If confirmation is still pending during a retry, the proxy returns HTTP `503 idempotency_reconciliation_pending`; clients should retry later with the same key and payload.
- **Isolated child session fork**: Continuation turns specifying a parent continuation ID fork the entire saved parent session into an isolated child session. Turns are appended to the child session while the parent session remains completely unmodified, allowing multiple independent conversational branches.
- **In-memory process-local ledger**: The idempotency ledger is held **strictly in process memory**. Completed or definitively failed replayable records are retained for a 60-minute default TTL (minimum retention floor is 30 minutes; currently not user-configurable). In-progress records do not expire via TTL; disconnections prior to prompt submission are abandoned, while disconnections after prompt submission transition to `unknown`. `unknown` entries never expire automatically and are freed only upon successful reconciliation or process restart. Persistent unreconciled entries may count against per-tenant capacity limits until restart. All records are discarded on server restart and are not shared across multi-process clusters or replicas. This mechanism prevents duplicate execution within a single server process lifecycle, but does not replace persistent distributed idempotency storage.

`POST /v1/responses`、`POST /v1beta/interactions` 与别名 `POST /v1/interactions` 支持可选的 `Idempotency-Key` 请求头。建议每个逻辑操作使用唯一 key,并在超时或断线后用**相同 key 与相同 JSON 请求体**重试。作用域按认证租户(API key 的摘要;未启用鉴权时共用匿名租户)、端点、父 ID(`previous_response_id` / `previous_interaction_id`)及 key 隔离;两个 Interactions 路径是同一端点,故共享幂等记录。Responses 与 Interactions 之间、不同父 ID 之间、不同 API key 租户之间不会共享记录。

- 同一作用域内 key 对应不同 payload 时返回 `409`,错误码 `idempotency_key_reused`;同一请求仍在处理中时返回可重试的 `409 idempotency_in_progress`。请求体按 JSON 规范化后比较,对象属性顺序不影响 payload hash。
- 已确认完成的 JSON 或 SSE 响应会缓存并**从头精确重播**:包括原 HTTP 状态、相关响应头与完整响应 body。SSE 重试不是从断点续传,而是重放原始流的全部字节及终止事件。
- 如果 prompt 已提交但结果尚未确认,结果会标记为未知。使用相同 key 重试只会轮询/调和已记录的子 session 与消息;不会再次提交 prompt,也不会因为未确认而创建另一个子 session。尚未确认时可能收到 `503 idempotency_reconciliation_pending`,稍后继续用原 key 和原 payload 查询。
- 带 continuation parent ID 的新操作会从完整父 session fork 一个隔离子 session;本轮写入 child,不修改 parent。这样允许多个分支独立续写。
- Idempotency ledger 是**进程内存态**。已完成/确定失败的可重播记录目前固定保留 60 分钟(代码保留时长下限为 30 分钟,当前没有配置项);未完成请求中的 `in_progress` 记录不会按 TTL 淘汰,断线且尚未提交 prompt 时会被放弃,已提交但结果未确认时则转为 `unknown`。`unknown` 记录不会自动过期或淘汰,只能在成功调和后释放,无法调和时会保留到服务重启,并可能使该租户后续新 key 遇到容量限制。服务重启会丢失全部记录;多进程/多副本之间不共享记录。因此它能防止同一进程生命周期内的重复执行,不能替代跨重启或分布式的持久化幂等存储。

### 🧭 推荐提示模板(OpenClaw / Claude Code)

在真实运行环境中,如果你希望第一跳**稳定先产出 tool call**,推荐把“调用工具”和“基于工具结果继续回答”拆成两步,而不是混在同一句里。
Expand Down Expand Up @@ -284,7 +302,13 @@ Great, now answer the original request using the tool result.

> 这个两段式模式是当前 `opencode2api` 外部工具桥接的推荐集成方式。

### 🌊 流式工具调用
### 🌊 流式输出与反向代理缓冲 / Streaming & Reverse Proxy Buffering

所有流式接口(`POST /v1/chat/completions`、`POST /v1/messages`、`POST /v1/responses`、`POST /v1/interactions` 及别名 `POST /v1beta/interactions`)在开启 `stream: true` 时,均会自动附加 `X-Accel-Buffering: no` 响应头。该响应头指示反向代理(如 Nginx、Cloudflare、Caddy 等)立即关闭响应缓冲(response buffering),防止中间代理积攒数据块,确保 Server-Sent Events (SSE) 分块数据与保活注释行(`: comment\n`)能够实时无延迟地推送至客户端。

All streaming routes (`POST /v1/chat/completions`, `POST /v1/messages`, `POST /v1/responses`, `POST /v1/interactions`, and alias `POST /v1beta/interactions`) automatically emit the `X-Accel-Buffering: no` HTTP header when `stream: true` is enabled. This header instructs reverse proxies (such as Nginx, Cloudflare, Caddy) to disable response buffering so Server-Sent Events (SSE) chunks and keepalive comments are delivered to clients immediately without intermediate buffering lag.

#### 流式工具调用示例 / Streaming Tool Call Example

```bash
curl -N -X POST http://127.0.0.1:10000/v1/responses \
Expand Down Expand Up @@ -318,6 +342,7 @@ curl -N -X POST http://127.0.0.1:10000/v1/responses \
- Chat Completions 会在 `chat.completion.chunk` 中返回 `delta.tool_calls`
- Responses API 会返回 `response.output_item.added`、`response.function_call_arguments.delta`、`response.function_call_arguments.done`、`response.output_item.done` 等事件
- Messages API 会返回 `message_start`、`content_block_start`、`content_block_delta`(`text_delta`/`thinking_delta`/`input_json_delta`)、`content_block_stop`、`message_delta`、`message_stop`(无 `[DONE]`)
- Interactions API 会在 SSE 中返回 `interaction.created`、`step.delta`、`interaction.completed`(无 `[DONE]`)

> 注意:代理内部会使用命名空间隔离同名工具,但这些内部名称不会作为公开 API 返回给客户端。

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
30 changes: 30 additions & 0 deletions src/backend/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,35 @@ 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);
};
}

/** Exposed for narrow lifecycle assertions; listeners remain owned by their app instances. */
export function getBackendReconnectListenerCount(): number {
return backendReconnectListeners.size;
}

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 +601,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
12 changes: 12 additions & 0 deletions src/converters/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,3 +396,15 @@ 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';
}
Loading
Loading