From 5d60867bba4745c87b78bcde7c046e832a5918e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=AF=97=E9=80=94=E4=B9=90=E6=97=85?= <3296249574@qq.com> Date: Mon, 19 Oct 2026 14:35:29 +0800 Subject: [PATCH 1/4] fix: match Python V2.4 tool normalization and legacy functions semantics --- README.md | 29 +++- build.gradle.kts | 2 +- .../opencodezen/OpenCodeZenClientProfile.java | 16 ++ .../OpenCodeZenProtocolPolicy.java | 60 ++----- .../OpenCodeZenV24ToolNormalizer.java | 144 +++++++++++++++++ .../OpenCodeZenRealV24ToolParityTest.java | 153 ++++++++++++++++++ 6 files changed, 351 insertions(+), 53 deletions(-) create mode 100644 opencode-zen-spring-boot-autoconfigure/src/main/java/com/sharedlibs/opencodezen/OpenCodeZenV24ToolNormalizer.java create mode 100644 opencode-zen-spring-boot-autoconfigure/src/test/java/com/sharedlibs/opencodezen/OpenCodeZenRealV24ToolParityTest.java diff --git a/README.md b/README.md index b169065..3e892f4 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ JVM 共享库,用于与 OpenCode Zen 交互:协议常量、OpenCode 2.x 客 | `opencode-zen-spring-boot-starter` | 聚合入口(消费方只需要这一个坐标) | - group:`com.sharedlibs` -- 当前版本:`1.3.0` +- 当前版本:`1.3.1` --- @@ -46,7 +46,7 @@ repositories { } dependencies { - implementation("com.sharedlibs:opencode-zen-spring-boot-starter:1.3.0") + implementation("com.sharedlibs:opencode-zen-spring-boot-starter:1.3.1") } ``` @@ -113,7 +113,7 @@ $env:OPENCODE_ZEN_LOCAL="E:\project\shared-libs\opencode-zen" # 改完库立 --- -## 1.3.0:根据已可用的 Python V2.4 修复 Java 共享库 +## 1.3.1:根据已可用的 Python V2.4 修复 Java 共享库 **注意:本次修改的是 Java 共享库,不是 Python V2.4 代理,也不是 Python V2.5。** @@ -151,4 +151,25 @@ HttpResponse upstream = HttpClient.newHttpClient().send( - **旧方法仍保留**:`newConversationRequest` / `newEphemeralRequest` 只返回附带 Header 的 `HttpRequest.Builder`。若消费方仍手写 Body,必须自行调用 `applyRequiredEnvelope`,或迁移到新方法。 - Java 库仍只是共享协议/请求工具,并不是完整 HTTP 代理;SSE 拼包、工具调用过滤和上游错误透传由消费方处理。Synthetic 工具**只能作为占位声明,不能执行**。 - 本次用 Python V2.4 作协议参照,**不意味着真实 Zen 网关已经验证成功**。上游 403/429 要尊重、原样处理,不得通过自动轮换网络出口绕过服务端限制。 -- 1.3.0 用于新的 Maven 发布,原有 1.2.0 产物不受影响;下游需要显式升级依赖。 +- 1.3.1 用于新的 Maven 发布,原有 1.2.0 产物不受影响;下游需要显式升级依赖。 + + +## 1.3.1:Python V2.4 工具声明语义修复(未触碰 Python V2.4) + +这次实际对比了上传的 Python V2.4 中的 `_names_for_tools()` 与其 63 项离线测试。 + +- 已有的 shell/read 工具:使用 Profile 原始描述覆盖旧描述,保留调用方 schema;新追加的合成工具则带有不可执行的 placeholder 提示。 +- 未设置 description 的普通工具补全 `Tool function`。 +- Chat 的旧 `functions` 数组参与已声明工具判断,避免重复追加 shell/read;保留旧字段供下游处理。 +- 不拒绝 V2.4 可以接纳的重复工具声明,但客户端负责校验最终工具是否符合提供方要求。 +- `callerDeclaredToolNames(payload, protocol)` 可在提交给上游之前记录真实调用方工具;返回结果不包括追加的 synthetic 工具。**不得执行任何非调用方授权的工具。** +- 按以上规则增加 Java 离线请求结构回归测试。 + +### 403 的验证边界 + +Python V2.4 的 `_gateway_rejection()` 会把含 `Detect Abuse Traffic` 的 403 标记为 `abuse_rejected`,并停止重试;代码未声明此错误可通过修改 Header 绕过。 +此前 Java 真正发出的请求收到了 HTTP 403,不能由此确定真实原因是 Body、Header、IP 或策略中的哪一项。上游拒绝需要原样处理,不得轮换出口继续重试。 +Java `HttpRequest` Factory 并不会配置 `HttpClient` 的代理:`127.0.0.1:7897` 若要用于 Java HTTP 连接,必须在创建 `HttpClient` 时按实际网络需要显式配置。 +本次结果只保证离线行为对齐;不代表真实 Zen 推理已经得到 200。 + +升级消费者至 `com.sharedlibs:opencode-zen-spring-boot-starter:1.3.1` 前需发布新 Maven 版本。 diff --git a/build.gradle.kts b/build.gradle.kts index a01ab94..96182c8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } group = "com.sharedlibs" -version = "1.3.0" +version = "1.3.1" subprojects { group = rootProject.group diff --git a/opencode-zen-spring-boot-autoconfigure/src/main/java/com/sharedlibs/opencodezen/OpenCodeZenClientProfile.java b/opencode-zen-spring-boot-autoconfigure/src/main/java/com/sharedlibs/opencodezen/OpenCodeZenClientProfile.java index 0ffd9b6..984c19f 100644 --- a/opencode-zen-spring-boot-autoconfigure/src/main/java/com/sharedlibs/opencodezen/OpenCodeZenClientProfile.java +++ b/opencode-zen-spring-boot-autoconfigure/src/main/java/com/sharedlibs/opencodezen/OpenCodeZenClientProfile.java @@ -32,6 +32,22 @@ private static JsonNode load() { } } + /** All named tools from the captured profile, preserving source order. */ + public static java.util.Set pinnedToolNames() { + java.util.Set names = new java.util.LinkedHashSet<>(); + PROFILE.path("tools").fieldNames().forEachRemaining(names::add); + return java.util.Collections.unmodifiableSet(names); + } + + /** Unmodified captured description, without the synthetic tool disclaimer. */ + public static String capturedDescription(String name) { + JsonNode entry = PROFILE.path("tools").path(name); + if (!entry.isObject() || !entry.path("description").isTextual()) { + throw new IllegalArgumentException("Unknown captured tool: " + name); + } + return entry.path("description").asText(); + } + /** Synthetic declarations are protocol placeholders, not executable host tools. */ public static Map toolDeclaration(String name, ZenModelProtocol protocol) { JsonNode node = PROFILE.path("tools").path(name); diff --git a/opencode-zen-spring-boot-autoconfigure/src/main/java/com/sharedlibs/opencodezen/OpenCodeZenProtocolPolicy.java b/opencode-zen-spring-boot-autoconfigure/src/main/java/com/sharedlibs/opencodezen/OpenCodeZenProtocolPolicy.java index b486cd0..97a48e0 100644 --- a/opencode-zen-spring-boot-autoconfigure/src/main/java/com/sharedlibs/opencodezen/OpenCodeZenProtocolPolicy.java +++ b/opencode-zen-spring-boot-autoconfigure/src/main/java/com/sharedlibs/opencodezen/OpenCodeZenProtocolPolicy.java @@ -18,10 +18,8 @@ * {@code https://opencode.ai/inference/v1}. {@code https://opencode.ai/zen/v1} is a * legacy discovery-only endpoint: its {@code /models} listing may still be used as a * catalogue fallback, but inference traffic must never be sent there. The request must also - * carry the full CLI core tool envelope — {@code bash, edit, glob, grep, read} — otherwise the - * free tier answers {@code 403 FreeTierError}. With that envelope and the shaped - * {@code ses_}/{@code msg_} identifiers the upstream answers {@code 200} (or {@code 429} - * when the free quota is exhausted), which is the acceptance contract consumers rely on. + * carry the pinned compatibility envelope — {@code shell, read} — for request-format compatibility. Passing offline compatibility checks + * does not imply upstream admission. The upstream may independently return 403 or 429. */ public final class OpenCodeZenProtocolPolicy { @@ -129,24 +127,7 @@ public static void applyRequiredChatEnvelope(Map payload, boolea * not carry the chat {@code function.name} shape are passed through untouched. */ public static List appendReservedTools(Collection applicationTools) { - List tools = new ArrayList<>(); - Set present = new java.util.HashSet<>(); - if (applicationTools != null) { - for (Object tool : applicationTools) { - tools.add(tool); - String name = chatToolName(tool); - if (name != null) { - present.add(name); - } - } - } - for (int index = 0; index < RESERVED_TOOL_NAMES.size(); index++) { - if (present.contains(RESERVED_TOOL_NAMES.get(index))) { - continue; - } - tools.add(RESERVED_TOOLS.get(index)); - } - return List.copyOf(tools); + return appendRequiredTools(applicationTools, ZenModelProtocol.CHAT); } private static String chatToolName(Object tool) { @@ -210,36 +191,19 @@ public static void applyRequiredEnvelope( options.put("include_usage", true); payload.put("stream_options", options); } - Object original = payload.get("tools"); - if (original != null && !(original instanceof Collection)) { - throw new IllegalArgumentException("tools must be an array"); - } - payload.put("tools", appendRequiredTools( - original == null ? List.of() : (Collection) original, protocol)); + payload.put("tools", OpenCodeZenV24ToolNormalizer.normalize(payload, protocol)); + } + + public static java.util.Set callerDeclaredToolNames( + Map payload, ZenModelProtocol protocol) { + return OpenCodeZenV24ToolNormalizer.callerDeclaredNames(payload, protocol); } public static List appendRequiredTools( Collection applicationTools, ZenModelProtocol protocol) { - List tools = new ArrayList<>(); - Set present = new java.util.HashSet<>(); - if (applicationTools != null) { - for (Object tool : applicationTools) { - String name = chatToolName(tool); - if (name == null) { - throw new IllegalArgumentException("Invalid tool declaration: missing name"); - } - if (!present.add(name)) { - throw new IllegalArgumentException("Duplicate tool declaration: " + name); - } - tools.add(tool); - } - } - for (String name : RESERVED_TOOL_NAMES) { - if (!present.contains(name)) { - tools.add(OpenCodeZenClientProfile.toolDeclaration(name, protocol)); - } - } - return List.copyOf(tools); + Map body = new java.util.LinkedHashMap<>(); + body.put("tools", applicationTools == null ? List.of() : new ArrayList<>(applicationTools)); + return OpenCodeZenV24ToolNormalizer.normalize(body, protocol); } public static String inferencePath(ZenModelProtocol protocol) { diff --git a/opencode-zen-spring-boot-autoconfigure/src/main/java/com/sharedlibs/opencodezen/OpenCodeZenV24ToolNormalizer.java b/opencode-zen-spring-boot-autoconfigure/src/main/java/com/sharedlibs/opencodezen/OpenCodeZenV24ToolNormalizer.java new file mode 100644 index 0000000..3b32be8 --- /dev/null +++ b/opencode-zen-spring-boot-autoconfigure/src/main/java/com/sharedlibs/opencodezen/OpenCodeZenV24ToolNormalizer.java @@ -0,0 +1,144 @@ +package com.sharedlibs.opencodezen; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Mirrors Python V2.4 _names_for_tools without changing the caller's objects. + * Synthetic placeholders never become authorized tool implementations. + */ +final class OpenCodeZenV24ToolNormalizer { + private OpenCodeZenV24ToolNormalizer() { } + + static List normalize(Map body, ZenModelProtocol protocol) { + Objects.requireNonNull(body, "body"); + checkProtocol(protocol); + Object raw = body.get("tools"); + if (raw != null && !(raw instanceof List)) { + throw new IllegalArgumentException("tools must be an array"); + } + Object rawLegacy = protocol == ZenModelProtocol.CHAT ? body.get("functions") : null; + if (rawLegacy != null && !(rawLegacy instanceof List)) { + throw new IllegalArgumentException("functions must be an array"); + } + + List> legacy = new ArrayList<>(); + Set given = new LinkedHashSet<>(); + if (rawLegacy instanceof List list) { + for (Object item : list) { + if (!(item instanceof Map map) || !(map.get("name") instanceof String name)) { + continue; // Python V2.4 ignores invalid legacy function entries + } + Map copy = stringMap(map); + fillDescription(copy); + legacy.add(copy); + given.add(name); + } + } + + List result = new ArrayList<>(); + Map> firstPositions = new LinkedHashMap<>(); + if (raw instanceof List list) { + for (Object tool : list) { + if (!(tool instanceof Map map)) { + throw new IllegalArgumentException("Invalid tool definition"); + } + if (map.containsKey("function") && !(map.get("function") instanceof Map)) { + throw new IllegalArgumentException("function must be an object"); + } + Map outer = stringMap(map); + Map details = outer; + if (map.get("function") instanceof Map function) { + details = stringMap(function); + outer.put("function", details); + } + Object rawName = details.get("name"); + if (!(rawName instanceof String name)) { + throw new IllegalArgumentException("Invalid tool definition"); + } + fillDescription(details); + given.add(name); + firstPositions.putIfAbsent(name, details); + result.add(outer); + } + } + + for (String name : OpenCodeZenClientProfile.pinnedToolNames()) { + String captured = OpenCodeZenClientProfile.capturedDescription(name); + if (firstPositions.containsKey(name)) { + firstPositions.get(name).put("description", captured); + } else if (given.contains(name)) { + for (Map function : legacy) { + if (name.equals(function.get("name"))) { + function.put("description", captured); + } + } + } else if (OpenCodeZenProtocolPolicy.reservedToolNames().contains(name)) { + result.add(OpenCodeZenClientProfile.toolDeclaration(name, protocol)); + } + } + if (rawLegacy != null && protocol == ZenModelProtocol.CHAT) { + body.put("functions", legacy); + } + return result; + } + + static Set callerDeclaredNames(Map body, ZenModelProtocol protocol) { + Objects.requireNonNull(body, "body"); + checkProtocol(protocol); + Set result = new LinkedHashSet<>(); + Object tools = body.get("tools"); + if (tools instanceof Collection list) { + for (Object tool : list) { + if (tool instanceof Map map) { + Object function = map.get("function"); + Object name = function instanceof Map nested + ? nested.get("name") : map.get("name"); + if (name instanceof String text) { + result.add(text); + } + } + } + } + if (protocol == ZenModelProtocol.CHAT && body.get("functions") instanceof Collection list) { + for (Object fn : list) { + if (fn instanceof Map map && map.get("name") instanceof String text) { + result.add(text); + } + } + } + return Set.copyOf(result); + } + + private static Map stringMap(Map raw) { + Map copy = new LinkedHashMap<>(); + for (Map.Entry entry : raw.entrySet()) { + if (!(entry.getKey() instanceof String key)) { + throw new IllegalArgumentException("tool property key must be a string"); + } + copy.put(key, entry.getValue()); + } + return copy; + } + + private static void fillDescription(Map fn) { + Object value = fn.get("description"); + if (value == null || Boolean.FALSE.equals(value) || "".equals(value)) { + fn.put("description", "Tool function"); + } + } + + private static void checkProtocol(ZenModelProtocol protocol) { + if (protocol != ZenModelProtocol.CHAT + && protocol != ZenModelProtocol.RESPONSES + && protocol != ZenModelProtocol.ANTHROPIC) { + throw new IllegalArgumentException("Unsupported inference protocol: " + protocol); + } + } +} diff --git a/opencode-zen-spring-boot-autoconfigure/src/test/java/com/sharedlibs/opencodezen/OpenCodeZenRealV24ToolParityTest.java b/opencode-zen-spring-boot-autoconfigure/src/test/java/com/sharedlibs/opencodezen/OpenCodeZenRealV24ToolParityTest.java new file mode 100644 index 0000000..fb6e420 --- /dev/null +++ b/opencode-zen-spring-boot-autoconfigure/src/test/java/com/sharedlibs/opencodezen/OpenCodeZenRealV24ToolParityTest.java @@ -0,0 +1,153 @@ +package com.sharedlibs.opencodezen; + +import org.junit.jupiter.api.Test; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Reference: OpenCode-Zen-Proxy-V2.4.zip, Python _names_for_tools and its 63 tests. + * These are offline wire-format tests; they are not an upstream access guarantee. + */ +class OpenCodeZenRealV24ToolParityTest { + + private static Map chat(Map additional) { + Map body = new LinkedHashMap<>(); + body.put("model", "big-pickle"); + body.putAll(additional); + return body; + } + + @SuppressWarnings("unchecked") + private static Map entry(Map body, int index) { + return (Map) ((List) body.get("tools")).get(index); + } + + @SuppressWarnings("unchecked") + private static Map chatFunction(Map body, int index) { + return (Map) entry(body, index).get("function"); + } + + @Test + void chatExistingShellKeepsSchemaButReceivesCapturedDescription() { + Map schema = Map.of( + "type", "object", "properties", Map.of("command", Map.of("type", "string")), + "required", List.of("command")); + Map function = new LinkedHashMap<>(); + function.put("name", "shell"); + function.put("description", "User wording"); + function.put("parameters", schema); + Map tool = Map.of("type", "function", "function", function); + Map body = chat(Map.of("tools", List.of(tool))); + assertThat(OpenCodeZenProtocolPolicy.callerDeclaredToolNames(body, ZenModelProtocol.CHAT)) + .isEqualTo(Set.of("shell")); + OpenCodeZenProtocolPolicy.applyRequiredEnvelope(body, ZenModelProtocol.CHAT, true); + assertThat((List) body.get("tools")).hasSize(2); + assertThat(chatFunction(body, 0)).containsEntry("name", "shell") + .containsEntry("parameters", schema) + .containsEntry("description", OpenCodeZenClientProfile.capturedDescription("shell")); + assertThat(chatFunction(body, 1).get("description").toString()).contains("placeholder"); + assertThat(function.get("description")).isEqualTo("User wording"); + } + + @Test + void existingNativeReadPreservesSchemaWithCapturedDescription() { + for (ZenModelProtocol protocol : List.of(ZenModelProtocol.RESPONSES, ZenModelProtocol.ANTHROPIC)) { + Map schema = Map.of("type", "object", + "properties", Map.of("path", Map.of("type", "string"))); + Map tool = new LinkedHashMap<>(); + tool.put("name", "read"); + tool.put("description", "custom"); + if (protocol == ZenModelProtocol.RESPONSES) { + tool.put("type", "function"); + tool.put("parameters", schema); + } else { + tool.put("input_schema", schema); + } + Map body = chat(Map.of("tools", List.of(tool))); + OpenCodeZenProtocolPolicy.applyRequiredEnvelope(body, protocol, false); + assertThat((List) body.get("tools")).hasSize(2); + assertThat(entry(body, 0).get("description")) + .isEqualTo(OpenCodeZenClientProfile.capturedDescription("read")); + assertThat(entry(body, 0).get(protocol == ZenModelProtocol.RESPONSES + ? "parameters" : "input_schema")).isEqualTo(schema); + assertThat(entry(body, 1).get("name")).isEqualTo("shell"); + assertThat(tool.get("description")).isEqualTo("custom"); + } + } + + @SuppressWarnings("unchecked") + @Test + void legacyChatFunctionIsNotDuplicatedAsSyntheticTool() { + Map legacy = Map.of("name", "shell", + "description", "old desc", "parameters", Map.of("type", "object")); + Map body = chat(Map.of("functions", List.of(legacy))); + assertThat(OpenCodeZenProtocolPolicy.callerDeclaredToolNames(body, ZenModelProtocol.CHAT)) + .isEqualTo(Set.of("shell")); + OpenCodeZenProtocolPolicy.applyRequiredEnvelope(body, ZenModelProtocol.CHAT, true); + assertThat((List) body.get("tools")).hasSize(1); + assertThat(chatFunction(body, 0).get("name")).isEqualTo("read"); + List> normalized = (List>) body.get("functions"); + assertThat(normalized.get(0)).containsEntry( + "description", OpenCodeZenClientProfile.capturedDescription("shell")); + assertThat(legacy.get("description")).isEqualTo("old desc"); + } + + @Test + void unknownToolsGainDefaultDescriptionAndCallerOrderIsPreserved() { + Map unknown = Map.of("type", "function", + "function", Map.of("name", "Bash", "parameters", Map.of("type", "object"))); + Map body = chat(Map.of("tools", List.of(unknown))); + OpenCodeZenProtocolPolicy.applyRequiredEnvelope(body, ZenModelProtocol.CHAT, true); + assertThat((List) body.get("tools")).hasSize(3); + assertThat(chatFunction(body, 0)).containsEntry("name", "Bash") + .containsEntry("description", "Tool function"); + assertThat(chatFunction(body, 1).get("name")).isEqualTo("shell"); + assertThat(chatFunction(body, 2).get("name")).isEqualTo("read"); + } + + @Test + void duplicatesAreHandledLikePythonV24NotRejected() { + Map one = Map.of("type", "function", + "function", Map.of("name", "read")); + Map body = chat(Map.of("tools", List.of(one, one))); + OpenCodeZenProtocolPolicy.applyRequiredEnvelope(body, ZenModelProtocol.CHAT, true); + assertThat((List) body.get("tools")).hasSize(3); + assertThat(chatFunction(body, 0).get("description")) + .isEqualTo(OpenCodeZenClientProfile.capturedDescription("read")); + assertThat(chatFunction(body, 1).get("description")).isEqualTo("Tool function"); + } + + @Test + void invalidToolsAndLegacyFieldsRejectLocally() { + Map bad = chat(Map.of("tools", "wrong")); + assertThatThrownBy(() -> OpenCodeZenProtocolPolicy.applyRequiredEnvelope( + bad, ZenModelProtocol.CHAT, true)) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("array"); + Map badFunction = chat(Map.of("tools", List.of( + Map.of("type", "function", "function", "wrong")))); + assertThatThrownBy(() -> OpenCodeZenProtocolPolicy.applyRequiredEnvelope( + badFunction, ZenModelProtocol.CHAT, true)) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("function"); + Map badLegacy = chat(Map.of("functions", "wrong")); + assertThatThrownBy(() -> OpenCodeZenProtocolPolicy.applyRequiredEnvelope( + badLegacy, ZenModelProtocol.CHAT, true)) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("functions"); + } + + @Test + void callerDeclaredSetExcludesSyntheticPlaceholders() { + Map input = chat(Map.of("messages", List.of( + Map.of("role", "user", "content", "hello")))); + Set before = OpenCodeZenProtocolPolicy.callerDeclaredToolNames( + input, ZenModelProtocol.CHAT); + assertThat(before).isEmpty(); + OpenCodeZenProtocolPolicy.applyRequiredEnvelope(input, ZenModelProtocol.CHAT, true); + assertThat((List) input.get("tools")).hasSize(2); + assertThat(before).isEmpty(); + } +} From 15caa4819a2a7777c2b6f7c8b0abb0568ff6108c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=AF=97=E9=80=94=E4=B9=90=E6=97=85?= <3296249574@qq.com> Date: Mon, 19 Oct 2026 14:37:20 +0800 Subject: [PATCH 2/4] test: update old 1.3.0 expectation to Python V2.4 captured description behavior --- .../com/sharedlibs/opencodezen/OpenCodeZenV24ParityTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opencode-zen-spring-boot-autoconfigure/src/test/java/com/sharedlibs/opencodezen/OpenCodeZenV24ParityTest.java b/opencode-zen-spring-boot-autoconfigure/src/test/java/com/sharedlibs/opencodezen/OpenCodeZenV24ParityTest.java index 2a390ab..7039b1f 100644 --- a/opencode-zen-spring-boot-autoconfigure/src/test/java/com/sharedlibs/opencodezen/OpenCodeZenV24ParityTest.java +++ b/opencode-zen-spring-boot-autoconfigure/src/test/java/com/sharedlibs/opencodezen/OpenCodeZenV24ParityTest.java @@ -81,7 +81,7 @@ void preservesClientToolAndDoesNotMutateCallersPayload() throws Exception { assertThat(body.path("tools").size()).isEqualTo(2); assertThat(body.path("tools").get(0).path("function").path("name").asText()).isEqualTo("read"); assertThat(body.path("tools").get(0).path("function").path("description").asText()) - .isEqualTo("real tool"); + .isEqualTo(com.sharedlibs.opencodezen.OpenCodeZenClientProfile.capturedDescription("read")); assertThat(body.path("stream_options").path("trace").asBoolean()).isTrue(); assertThat(original).doesNotContainKey("stream"); } From 2e98950e324bfb013d2e6c7216d99b84af7f401a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=AF=97=E9=80=94=E4=B9=90=E6=97=85?= <3296249574@qq.com> Date: Mon, 19 Oct 2026 14:42:16 +0800 Subject: [PATCH 3/4] feat: explicit Java transport selection and safe Python V2.4 header forwarding --- README.md | 16 ++++ .../opencodezen/OpenCodeZenIdGenerator.java | 13 ++++ .../autoconfigure/OpenCodeZenHttpClients.java | 55 +++++++++++++ .../OpenCodeZenRequestFactory.java | 78 +++++++++++++++++++ ...OpenCodeZenHeadersTransportParityTest.java | 78 +++++++++++++++++++ 5 files changed, 240 insertions(+) create mode 100644 opencode-zen-spring-boot-autoconfigure/src/main/java/com/sharedlibs/opencodezen/autoconfigure/OpenCodeZenHttpClients.java create mode 100644 opencode-zen-spring-boot-autoconfigure/src/test/java/com/sharedlibs/opencodezen/OpenCodeZenHeadersTransportParityTest.java diff --git a/README.md b/README.md index 3e892f4..5f0f1ea 100644 --- a/README.md +++ b/README.md @@ -173,3 +173,19 @@ Java `HttpRequest` Factory 并不会配置 `HttpClient` 的代理:`127.0.0.1:7 本次结果只保证离线行为对齐;不代表真实 Zen 推理已经得到 200。 升级消费者至 `com.sharedlibs:opencode-zen-spring-boot-starter:1.3.1` 前需发布新 Maven 版本。 + + +### Java 调用端如何明确选择网络连接(不自动切换出口) + +Git 仓库通过 `127.0.0.1:7897` 拉取成功不代表 Java HttpClient 的请求使用了同一个代理。可选择显式配置: + +```java +HttpClient http = OpenCodeZenHttpClients.explicitHttpProxy("127.0.0.1", 7897); +HttpRequest request = factory.newConversationChatRequest(true, null, conversationId, payload); +HttpResponse result = http.send(request, HttpResponse.BodyHandlers.ofInputStream()); +// 按用户要求检查 statusCode;403/429 不自动换出口、不偷偷重试。 +``` + +`OpenCodeZenHttpClients.direct()` 禁用代理;`system()` 使用 JVM 默认 ProxySelector(不能保证自动读取 HTTPS_PROXY 环境变量)。 +如果 Java 项目属于转发网关,可选使用 Factory 的 `newConversationInferenceRequest(..., payload, incomingHeaders)` 或 `newEphemeralInferenceRequest(..., payload, incomingHeaders)` 重载:仅透传项目名、经 Python V2.4 会话 counter 校验通过的 session,以及 Anthropic beta/version;不接受从该 map 覆盖 Authorization / x-api-key。 +请勿以 HTTP 403 是否变为 200 来判断工具描述归一化是否正确;先在离线测试中比较 Header 和 JSON Body。 diff --git a/opencode-zen-spring-boot-autoconfigure/src/main/java/com/sharedlibs/opencodezen/OpenCodeZenIdGenerator.java b/opencode-zen-spring-boot-autoconfigure/src/main/java/com/sharedlibs/opencodezen/OpenCodeZenIdGenerator.java index 6bd40f9..a5c32c2 100644 --- a/opencode-zen-spring-boot-autoconfigure/src/main/java/com/sharedlibs/opencodezen/OpenCodeZenIdGenerator.java +++ b/opencode-zen-spring-boot-autoconfigure/src/main/java/com/sharedlibs/opencodezen/OpenCodeZenIdGenerator.java @@ -31,6 +31,19 @@ public static boolean isRequestShaped(String id) { return id != null && id.matches("msg_" + PAYLOAD_SHAPE); } + /** + * Python V2.4 also validates the three trailing timestamp/counter hex digits + * for an inbound x-opencode-session. Do not treat a regex-only match as an + * existing authenticated session. New sessions created here already pass. + */ + public static boolean isV24SessionAffinityValid(String sessionId) { + if (!isSessionShaped(sessionId)) { + return false; + } + int counterHex = Integer.parseInt(sessionId.substring(13, 16), 16); + return 0xFFF - counterHex < 16; + } + /** Creates a descending session id; callers should cache it for a conversation. */ public static String newSessionId() { return "ses_" + generate(true); diff --git a/opencode-zen-spring-boot-autoconfigure/src/main/java/com/sharedlibs/opencodezen/autoconfigure/OpenCodeZenHttpClients.java b/opencode-zen-spring-boot-autoconfigure/src/main/java/com/sharedlibs/opencodezen/autoconfigure/OpenCodeZenHttpClients.java new file mode 100644 index 0000000..17f8c2a --- /dev/null +++ b/opencode-zen-spring-boot-autoconfigure/src/main/java/com/sharedlibs/opencodezen/autoconfigure/OpenCodeZenHttpClients.java @@ -0,0 +1,55 @@ +package com.sharedlibs.opencodezen.autoconfigure; + +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.ProxySelector; +import java.net.URI; +import java.net.http.HttpClient; +import java.time.Duration; +import java.util.List; + +/** + * Explicit Java transport selection. Building an HttpRequest alone does not + * configure the proxy used by the sender. No retries on 403/429 are performed. + */ +public final class OpenCodeZenHttpClients { + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(20); + + private OpenCodeZenHttpClients() { } + + /** Opt into a direct, proxy-free connection. */ + public static HttpClient direct() { + ProxySelector none = new ProxySelector() { + @Override public List select(URI uri) { + return List.of(Proxy.NO_PROXY); + } + @Override public void connectFailed(URI uri, java.net.SocketAddress sa, + java.io.IOException error) { } + }; + return HttpClient.newBuilder().proxy(none) + .connectTimeout(CONNECT_TIMEOUT).build(); + } + + /** Use the JVM's configured proxy selector; unlike Python urllib, + * this does not promise that HTTPS_PROXY/HTTP_PROXY are automatically read. + */ + public static HttpClient system() { + HttpClient.Builder builder = HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT); + ProxySelector selector = ProxySelector.getDefault(); + if (selector != null) { + builder.proxy(selector); + } + return builder.build(); + } + + /** Explicit HTTP proxy for comparison with an authorized Python proxy route. */ + public static HttpClient explicitHttpProxy(String host, int port) { + if (host == null || host.isBlank() || port < 1 || port > 65535) { + throw new IllegalArgumentException("Valid proxy host and port required"); + } + return HttpClient.newBuilder() + .connectTimeout(CONNECT_TIMEOUT) + .proxy(ProxySelector.of(new InetSocketAddress(host, port))).build(); + } +} diff --git a/opencode-zen-spring-boot-autoconfigure/src/main/java/com/sharedlibs/opencodezen/autoconfigure/OpenCodeZenRequestFactory.java b/opencode-zen-spring-boot-autoconfigure/src/main/java/com/sharedlibs/opencodezen/autoconfigure/OpenCodeZenRequestFactory.java index 63bc5cd..c7e2c42 100644 --- a/opencode-zen-spring-boot-autoconfigure/src/main/java/com/sharedlibs/opencodezen/autoconfigure/OpenCodeZenRequestFactory.java +++ b/opencode-zen-spring-boot-autoconfigure/src/main/java/com/sharedlibs/opencodezen/autoconfigure/OpenCodeZenRequestFactory.java @@ -121,6 +121,23 @@ public HttpRequest newEphemeralInferenceRequest( endpoint, freeModel, apiKey), endpoint, protocol, payload); } + /** For proxy adapters that receive real OpenCode session/project/Anthropic headers. */ + public HttpRequest newConversationInferenceRequest( + URI endpoint, ZenModelProtocol protocol, boolean freeModel, String apiKey, + UUID conversationKey, Map payload, + Map incomingHeaders) { + return postWithEnvelope(newConversationRequest( + endpoint, freeModel, apiKey, conversationKey), + endpoint, protocol, payload, incomingHeaders); + } + + public HttpRequest newEphemeralInferenceRequest( + URI endpoint, ZenModelProtocol protocol, boolean freeModel, String apiKey, + Map payload, Map incomingHeaders) { + return postWithEnvelope(newEphemeralRequest(endpoint, freeModel, apiKey), + endpoint, protocol, payload, incomingHeaders); + } + /** Convenience method for standard Chat Completions inference. */ public HttpRequest newConversationChatRequest( boolean freeModel, String apiKey, UUID conversationKey, Map payload) { @@ -133,7 +150,14 @@ public HttpRequest newConversationChatRequest( private static HttpRequest postWithEnvelope( HttpRequest.Builder builder, URI endpoint, ZenModelProtocol protocol, Map payload) { + return postWithEnvelope(builder, endpoint, protocol, payload, Map.of()); + } + + private static HttpRequest postWithEnvelope( + HttpRequest.Builder builder, URI endpoint, ZenModelProtocol protocol, + Map payload, Map incomingHeaders) { Objects.requireNonNull(payload, "payload"); + applySafeIncomingHeaders(builder, protocol, incomingHeaders); String expectedPath = OpenCodeZenProtocolPolicy.inferencePath(protocol); if (!endpoint.getPath().endsWith(expectedPath)) { throw new IllegalArgumentException( @@ -154,6 +178,60 @@ private static HttpRequest postWithEnvelope( } } + private static void applySafeIncomingHeaders( + HttpRequest.Builder builder, ZenModelProtocol protocol, + Map incoming) { + if (incoming == null || incoming.isEmpty()) { + return; + } + String project = header(incoming, "x-opencode-project"); + if (project != null) { + builder.setHeader("x-opencode-project", singleLine(project, "project")); + } + // V2.4 accepts a supplied session ID only when its counter gate passes; + // otherwise it constructs a fresh session. + String session = null; + for (String header : new String[] {"x-opencode-session", "x-opencode-session-id", + "x-session-affinity", "x-session-id", "conversation-id"}) { + session = header(incoming, header); + if (session != null) { + break; + } + } + if (OpenCodeZenIdGenerator.isV24SessionAffinityValid(session)) { + builder.setHeader("x-opencode-session", session); + builder.setHeader("x-opencode-session-id", session); + } + if (protocol == ZenModelProtocol.ANTHROPIC) { + String version = header(incoming, "anthropic-version"); + if (version != null) { + builder.setHeader("anthropic-version", singleLine(version, "anthropic-version")); + } + String beta = header(incoming, "anthropic-beta"); + if (beta != null) { + builder.setHeader("anthropic-beta", singleLine(beta, "anthropic-beta")); + } + } + } + + private static String header(Map headers, String name) { + for (Map.Entry item : headers.entrySet()) { + if (item.getKey() != null && item.getKey().equalsIgnoreCase(name) + && item.getValue() != null && !item.getValue().isBlank()) { + return item.getValue(); + } + } + return null; + } + + private static String singleLine(String value, String label) { + if (value == null || value.isBlank() + || value.indexOf('\n') >= 0 || value.indexOf('\r') >= 0) { + throw new IllegalArgumentException("Invalid " + label + " header"); + } + return value; + } + public String userAgent() { return userAgent; } diff --git a/opencode-zen-spring-boot-autoconfigure/src/test/java/com/sharedlibs/opencodezen/OpenCodeZenHeadersTransportParityTest.java b/opencode-zen-spring-boot-autoconfigure/src/test/java/com/sharedlibs/opencodezen/OpenCodeZenHeadersTransportParityTest.java new file mode 100644 index 0000000..1c0e6ef --- /dev/null +++ b/opencode-zen-spring-boot-autoconfigure/src/test/java/com/sharedlibs/opencodezen/OpenCodeZenHeadersTransportParityTest.java @@ -0,0 +1,78 @@ +package com.sharedlibs.opencodezen; + +import com.sharedlibs.opencodezen.autoconfigure.OpenCodeZenHttpClients; +import com.sharedlibs.opencodezen.autoconfigure.OpenCodeZenRequestFactory; +import org.junit.jupiter.api.Test; +import java.net.Proxy; +import java.net.URI; +import java.net.http.HttpRequest; +import java.util.List; +import java.util.Map; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OpenCodeZenHeadersTransportParityTest { + private final OpenCodeZenRequestFactory requests = + new OpenCodeZenRequestFactory(OpenCodeZenProtocolPolicy.USER_AGENT); + + @Test + void forwardsOnlyKnownV24HeadersAndVerifiedSession() { + String realSession = OpenCodeZenIdGenerator.newSessionId(); + assertThat(OpenCodeZenIdGenerator.isV24SessionAffinityValid(realSession)).isTrue(); + HttpRequest request = requests.newEphemeralInferenceRequest( + URI.create(OpenCodeZenProtocolPolicy.DEFAULT_BASE_URL + "/messages"), + ZenModelProtocol.ANTHROPIC, true, null, + Map.of("model", "example-model", "max_tokens", 16, + "messages", List.of(Map.of("role", "user", "content", "hello"))), + Map.of("X-OpenCode-Project", "project-x", + "X-OpenCode-Session", realSession, + "Anthropic-Version", "2023-06-01", + "Anthropic-Beta", "example-feature", + "Authorization", "DO_NOT_FORWARD", + "x-api-key", "NOT_AUTHORIZED")); + assertThat(request.headers().firstValue("x-opencode-project")).contains("project-x"); + assertThat(request.headers().firstValue("x-opencode-session")).contains(realSession); + assertThat(request.headers().firstValue("x-opencode-session-id")).contains(realSession); + assertThat(request.headers().firstValue("anthropic-beta")).contains("example-feature"); + assertThat(request.headers().firstValue("anthropic-version")).contains("2023-06-01"); + assertThat(request.headers().firstValue("x-api-key")).contains("public"); + assertThat(request.headers().firstValue("Authorization")).isEmpty(); + } + + @Test + void declinesSessionAffinityThatFailsPythonCounterGate() { + String invalidCounter = "ses_0123456780000123456789abcd"; + assertThat(OpenCodeZenIdGenerator.isSessionShaped(invalidCounter)).isTrue(); + assertThat(OpenCodeZenIdGenerator.isV24SessionAffinityValid(invalidCounter)).isFalse(); + HttpRequest request = requests.newEphemeralInferenceRequest( + URI.create(OpenCodeZenProtocolPolicy.DEFAULT_BASE_URL + "/chat/completions"), + ZenModelProtocol.CHAT, true, null, Map.of("model", "example-model"), + Map.of("x-opencode-session", invalidCounter)); + assertThat(request.headers().firstValue("x-opencode-session")).isPresent() + .doesNotContain(invalidCounter); + } + + @Test + void blocksHeaderInjection() { + assertThatThrownBy(() -> requests.newEphemeralInferenceRequest( + URI.create(OpenCodeZenProtocolPolicy.DEFAULT_BASE_URL + "/chat/completions"), + ZenModelProtocol.CHAT, true, null, Map.of("model", "example-model"), + Map.of("x-opencode-project", "bad\ninjected"))) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("project"); + } + + @Test + void explicitProxyMustBeSelectedByJavaSenderNotByGit() { + var client = OpenCodeZenHttpClients.explicitHttpProxy("127.0.0.1", 7897); + var selection = client.proxy().orElseThrow() + .select(URI.create("https://opencode.ai/inference/v1/models")); + assertThat(selection).hasSize(1); + assertThat(selection.get(0).type()).isEqualTo(Proxy.Type.HTTP); + assertThat(selection.get(0).address().toString()).contains("127.0.0.1:7897"); + assertThat(OpenCodeZenHttpClients.direct().proxy().orElseThrow().select( + URI.create("https://opencode.ai/inference/v1/models"))) + .containsExactly(Proxy.NO_PROXY); + assertThatThrownBy(() -> OpenCodeZenHttpClients.explicitHttpProxy("", 7897)) + .isInstanceOf(IllegalArgumentException.class); + } +} From 27a1066b0ac5014b4ab4ca4dcbf47692bc6c68c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=AF=97=E9=80=94=E4=B9=90=E6=97=85?= <3296249574@qq.com> Date: Mon, 19 Oct 2026 14:44:02 +0800 Subject: [PATCH 4/4] test: use AssertJ Optional assertion supported by current dependency --- .../opencodezen/OpenCodeZenHeadersTransportParityTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/opencode-zen-spring-boot-autoconfigure/src/test/java/com/sharedlibs/opencodezen/OpenCodeZenHeadersTransportParityTest.java b/opencode-zen-spring-boot-autoconfigure/src/test/java/com/sharedlibs/opencodezen/OpenCodeZenHeadersTransportParityTest.java index 1c0e6ef..ff475cd 100644 --- a/opencode-zen-spring-boot-autoconfigure/src/test/java/com/sharedlibs/opencodezen/OpenCodeZenHeadersTransportParityTest.java +++ b/opencode-zen-spring-boot-autoconfigure/src/test/java/com/sharedlibs/opencodezen/OpenCodeZenHeadersTransportParityTest.java @@ -48,8 +48,8 @@ void declinesSessionAffinityThatFailsPythonCounterGate() { URI.create(OpenCodeZenProtocolPolicy.DEFAULT_BASE_URL + "/chat/completions"), ZenModelProtocol.CHAT, true, null, Map.of("model", "example-model"), Map.of("x-opencode-session", invalidCounter)); - assertThat(request.headers().firstValue("x-opencode-session")).isPresent() - .doesNotContain(invalidCounter); + assertThat(request.headers().firstValue("x-opencode-session")) + .hasValueSatisfying(value -> assertThat(value).isNotEqualTo(invalidCounter)); } @Test