Skip to content

Commit ca4ab41

Browse files
committed
fix(review): PR #23 round-2 — true-only doc sync, strip tests, strip typing
Stage 1 (docs/comments, zero behavior change): sync all-false -> strip-false/true-only semantics across docs/configuration.md, CHANGELOG, 5 route call-site comments; soften jail git-init comment per round-2 (plain dirs pass); add out-of-matrix OPENCODE_CLIENT backend-identity docs + AGENTS.md notes (no six-way matrix change by design). Stage 2 (tests): DISABLE_TOOLS:true response-stripping cases for chat + responses; interactions stream + non-stream strip cases; fail-closed pin for the wider transform gate anchor. Stage 3 (type hygiene): stripFunctionCalls unknown -> string, drop redundant casts; session-ids upstream-pin + explicit fixture constants.
1 parent 73122e6 commit ca4ab41

15 files changed

Lines changed: 176 additions & 31 deletions

‎AGENTS.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ config.json.example / .env.example # examples only, never real secrets
7272
| Auth multi-key (A) | `OPENCODE_API_KEYS` canonical, `API_KEYS` legacy alias (merge; empty never blocks) | `API_KEYS` | `(none, merges with API_KEY)` |
7373
| 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` |
7474

75-
- `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.
75+
- `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).
76+
- 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`.
7677
- `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).
7778
- **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.
7879

‎CHANGELOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3131

3232
### Fixed
3333

34-
- **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) so session dirs resolve to a git work tree. All-false prompt `tools` maps are omitted only for likely-free `opencode` models (`-free` suffix, `big-pickle`, `union-alpha`) to dodge the `FreeTierError` 403; 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).
34+
- **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).
3535
- **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).
3636
- **后端错误透出**:`/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)。
3737
- **同族错误路径查漏**: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)。

‎docs/configuration.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232

3333
| 变量 | 默认值 | 说明 |
3434
|:-----|:-------|:-----|
35-
| `OPENCODE_DISABLE_TOOLS` / `DISABLE_TOOLS` | `true` | 禁用 OpenCode 工具调用(兼容别名;`OPENCODE_DISABLE_TOOLS` 优先,二者无效值都会让位给下一顺位:canonical env > legacy env > `config.json` > 默认)。例外:① 疑似免费 Zen 模型(`opencode` provider 且 `-free` 后缀、`big-pickle`、`union-alpha`)省略全 `false` 的 prompt `tools` 映射以避开上游 `FreeTierError` 403,此时仅靠 system prompt 禁用语 + 无 tool 定义 + 输出侧 markup 剥离承载禁用姿态;② `/v1/responses` 的 `web_search` 与 Interactions 的 `google_search` 会以 `hosted-search-grant` 单独放行 `websearch`(输出仅 `web_search_call` + 引文,不经过外部桥接) |
35+
| `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` + 引文,不经过外部桥接) |
3636
| `OPENCODE_EXTERNAL_TOOLS_MODE` | `proxy-bridge` | 外部工具桥接模式;当前仅支持 `proxy-bridge` |
3737
| `OPENCODE_EXTERNAL_TOOLS_CONFLICT_POLICY` | `namespace` | 外部工具冲突隔离策略;当前仅支持 `namespace` |
3838
| `OPENCODE_INTERNAL_WEB_FETCH_ENABLED` | `false` | 兼容旧开关;未显式配置 allowlist 时,启用后默认放行 `web_fetch` |
@@ -78,6 +78,12 @@
7878
| `OPENCODE_PATH` | `opencode` | OpenCode 可执行文件路径 |
7979
| `OPENCODE_ZEN_API_KEY` | - | Zen API Key 透传 |
8080

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

8389
## 📄 config.json 示例

‎src/backend/manager.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,11 @@ export function resolveBackendClient(): string {
4646

4747
/**
4848
* Stage-6: `git init` the jail workspace so session directories resolve to a
49-
* git-backed project root. Zen's free-tier gate answers non-git project roots
50-
* with FreeTierError 403 even when everything else mimics first-party
51-
* (verified live: identical backend+prompt returns 200 the moment the session
52-
* directory sits inside a git work tree — even an empty repo with no commit
53-
* or remote). Best-effort: warns and continues when git is unavailable.
49+
* git-backed project root. Kept as defense-in-depth for backend versions
50+
* whose free-tier gate is sensitive to non-git project roots (live round-2
51+
* verification showed plain dirs also pass on the current backend, so this
52+
* is compatibility insurance, not a requirement). Best-effort: warns and
53+
* continues when git is unavailable.
5454
*/
5555
export function ensureJailGitRepo(workspace: string): boolean {
5656
try {

‎src/proxy.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,8 @@ export function createApp(config: ProxyConfig): CreateAppResult {
371371
return effortMap[value.toLowerCase()] ?? (fallback as string | null);
372372
};
373373

374-
const stripFunctionCalls = (text: unknown, trim: boolean = true): unknown => {
374+
const stripFunctionCalls = (text: unknown, trim: boolean = true): string => {
375+
if (typeof text !== 'string') return String(text ?? '');
375376
if (!DISABLE_TOOLS || !text) return text;
376377
return stripFunctionCallMarkup(String(text), trim);
377378
};
@@ -818,7 +819,7 @@ export function createApp(config: ProxyConfig): CreateAppResult {
818819
],
819820
},
820821
};
821-
// Stage-5: drop all-disabled maps for free-tier suspects (Zen gate).
822+
// Stage-5: strip false entries for free-tier suspects (any false gates; true-only sent, all-false omitted).
822823
const forcedToolOverrides = selectPromptToolOverrides(toolOverrides, providerID, modelID);
823824
if (forcedToolOverrides) {
824825
forcedPromptParams.body['tools'] = forcedToolOverrides;

‎src/routes/chat.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,7 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void {
401401
REQUEST_TIMEOUT_MS,
402402
'load tool overrides',
403403
)) as Record<string, boolean> | null;
404-
// Stage-5: drop all-disabled maps for free-tier suspects (Zen gate).
404+
// Stage-5: strip false entries for free-tier suspects (any false gates; true-only sent, all-false omitted).
405405
const promptToolOverrides = selectPromptToolOverrides(toolOverrides, pID, mID);
406406
if (promptToolOverrides) {
407407
promptParams.body['tools'] = promptToolOverrides;
@@ -868,8 +868,8 @@ export function registerChatRoutes(app: Application, ctx: AppContext): void {
868868
}
869869
}
870870
const { validCalls: validatedToolCalls } = finalizeValidatedToolCalls(parsedToolCalls, externalToolRegistry);
871-
const safeContent = stripFunctionCallMarkup(stripFunctionCalls(content) as string) as string;
872-
const safeReasoning = stripFunctionCallMarkup(stripFunctionCalls(reasoning) as string) as string;
871+
const safeContent = stripFunctionCallMarkup(stripFunctionCalls(content));
872+
const safeReasoning = stripFunctionCallMarkup(stripFunctionCalls(reasoning));
873873

874874
const promptTokens = Math.ceil((fullPromptText || '').length / 4);
875875
const completionTokensCalc = Math.ceil((content || '').length / 4);

‎src/routes/interactions.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,7 @@ export function registerInteractionsRoutes(app: Application, ctx: AppContext): v
275275
parts,
276276
},
277277
};
278-
// Stage-5: drop all-disabled maps for free-tier suspects (Zen gate).
278+
// Stage-5: strip false entries for free-tier suspects (any false gates; true-only sent, all-false omitted).
279279
const promptToolOverrides = selectPromptToolOverrides(toolOverrides, pID, mID);
280280
if (promptToolOverrides) {
281281
promptParams.body['tools'] = promptToolOverrides;
@@ -358,10 +358,10 @@ export function registerInteractionsRoutes(app: Application, ctx: AppContext): v
358358
return;
359359
}
360360
// Parity with chat/responses/messages: strip tool-call markup when
361-
// DISABLE_TOOLS so a free-tier omission (no all-false tools map,
361+
// DISABLE_TOOLS so a free-tier strip (no false entries in the tools map,
362362
// see selectPromptToolOverrides) cannot leak call syntax to clients.
363363
// stripFunctionCalls is a no-op unless DISABLE_TOOLS=true.
364-
const text = String(stripFunctionCalls(polled.content || '') ?? '');
364+
const text = stripFunctionCalls(polled.content || '');
365365
// Chunked deltas keep clients alive on long answers.
366366
for (let i = 0; i < text.length; i += 500) {
367367
emit({ type: 'step.delta', interaction_id: interactionId, delta: text.slice(i, i + 500) });
@@ -393,7 +393,7 @@ export function registerInteractionsRoutes(app: Application, ctx: AppContext): v
393393

394394
const polled = await promptAndPoll();
395395
// Same parity strip as the stream path above.
396-
const text = String(stripFunctionCalls(polled.content || '') ?? '');
396+
const text = stripFunctionCalls(polled.content || '');
397397
const evidence = hostedSearch.requested ? extractSearchEvidence(polled.toolParts ?? []) : { queries: [] as string[], sources: [] as { url: string; title: string }[] };
398398
const searchCalls = buildWebSearchCallItems(evidence);
399399
const annotations = buildCitationAnnotations(text, evidence.sources);

‎src/routes/messages.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,7 @@ export function registerMessagesRoutes(app: Application, ctx: AppContext): void
311311
REQUEST_TIMEOUT_MS,
312312
'load tool overrides',
313313
)) as Record<string, boolean> | null;
314-
// Stage-5: drop all-disabled maps for free-tier suspects (Zen gate).
314+
// Stage-5: strip false entries for free-tier suspects (any false gates; true-only sent, all-false omitted).
315315
const promptToolOverrides = selectPromptToolOverrides(toolOverrides, pID, mID);
316316
if (promptToolOverrides) promptParams.body['tools'] = promptToolOverrides;
317317

@@ -338,8 +338,8 @@ export function registerMessagesRoutes(app: Application, ctx: AppContext): void
338338
reasoning: unknown,
339339
validatedToolCalls: unknown,
340340
): Record<string, unknown> => {
341-
const safeContent = stripFunctionCallMarkup(stripFunctionCalls(content) as string) as string;
342-
const safeReasoning = stripFunctionCallMarkup(stripFunctionCalls(reasoning) as string) as string;
341+
const safeContent = stripFunctionCallMarkup(stripFunctionCalls(content));
342+
const safeReasoning = stripFunctionCallMarkup(stripFunctionCalls(reasoning));
343343
const publicCalls = toPublicToolCalls(validatedToolCalls);
344344
const anthropicTools = publicCalls.map((tc) => {
345345
const tcr = tc as unknown as Record<string, unknown>;

‎src/routes/responses.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,7 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void
461461
...(top_p !== undefined ? { top_p } : {}),
462462
},
463463
};
464-
// Stage-5: drop all-disabled maps for free-tier suspects (Zen gate).
464+
// Stage-5: strip false entries for free-tier suspects (any false gates; true-only sent, all-false omitted).
465465
const promptToolOverrides = selectPromptToolOverrides(toolOverrides, pID, mID);
466466
if (promptToolOverrides) {
467467
promptParams.body['tools'] = promptToolOverrides;
@@ -861,8 +861,8 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void
861861
}
862862
}
863863
const { validCalls: validatedStreamedToolCalls } = finalizeValidatedToolCalls(parsedToolCalls, externalToolRegistry);
864-
const safeContent = stripFunctionCallMarkup(stripFunctionCalls(content) as string) as string;
865-
const safeReasoning = stripFunctionCallMarkup(stripFunctionCalls(reasoning) as string) as string;
864+
const safeContent = stripFunctionCallMarkup(stripFunctionCalls(content));
865+
const safeReasoning = stripFunctionCallMarkup(stripFunctionCalls(reasoning));
866866
if (streamedToolCalls.length === 0) {
867867
validatedStreamedToolCalls.forEach((toolCall) => {
868868
const record = toolCall as unknown as Record<string, unknown>;
@@ -1063,8 +1063,8 @@ export function registerResponsesRoutes(app: Application, ctx: AppContext): void
10631063
}
10641064
}
10651065
const { validCalls: validatedToolCalls } = finalizeValidatedToolCalls(parsedToolCalls, externalToolRegistry);
1066-
const safeContent = stripFunctionCallMarkup(stripFunctionCalls(content) as string) as string;
1067-
const safeReasoning = stripFunctionCallMarkup(stripFunctionCalls(reasoning) as string) as string;
1066+
const safeContent = stripFunctionCallMarkup(stripFunctionCalls(content));
1067+
const safeReasoning = stripFunctionCallMarkup(stripFunctionCalls(reasoning));
10681068

10691069
// P4: server-side grounding evidence → web_search_call items + citations.
10701070
const searchEvidence = hostedSearch.requested

‎src/session/ids.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Session/message ID format guardrails (Stage-2 mimic work).
22
//
3-
// Upstream facts (opencode@5a8335857b):
3+
// Upstream facts as of opencode@5a8335857b (re-verify on backend bump):
44
// - `x-opencode-session` is minted inside the backend:
55
// `packages/schema/src/session-id.ts` = `"ses_" + descending()`,
66
// where descending = 12 lowercase hex + 14 base62
@@ -15,7 +15,8 @@
1515
// - The SDK path (`session.create()` with no params) offers no per-request
1616
// header passthrough: the gateway CANNOT and MUST NOT forge these IDs.
1717
// They are validated here only as a debug drift signal (e.g. backend
18-
// version change), never to block a request.
18+
// version change), never to block a request. The regexes below are
19+
// intentionally strict: a backend format change only emits a debug log.
1920
const SESSION_SUFFIX = '[0-9a-f]{12}[0-9A-Za-z]{14}';
2021

2122
export const SESSION_ID_RE = new RegExp(`^ses_${SESSION_SUFFIX}$`);

0 commit comments

Comments
 (0)