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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 41 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ JVM 共享库,用于与 OpenCode Zen 交互:协议常量、OpenCode 2.x 客
| `opencode-zen-spring-boot-starter` | 聚合入口(消费方只需要这一个坐标) |

- group:`com.sharedlibs`
- 当前版本:`1.3.0`
- 当前版本:`1.3.1`

---

Expand Down Expand Up @@ -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")
}
```

Expand Down Expand Up @@ -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。**

Expand Down Expand Up @@ -151,4 +151,41 @@ HttpResponse<InputStream> 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 版本。


### 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<InputStream> 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。
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ plugins {
}

group = "com.sharedlibs"
version = "1.3.0"
version = "1.3.1"

subprojects {
group = rootProject.group
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,22 @@ private static JsonNode load() {
}
}

/** All named tools from the captured profile, preserving source order. */
public static java.util.Set<String> pinnedToolNames() {
java.util.Set<String> 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<String, Object> toolDeclaration(String name, ZenModelProtocol protocol) {
JsonNode node = PROFILE.path("tools").path(name);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,8 @@
* {@code https://opencode.ai/inference/v1}. {@code https://opencode.ai/zen/v1} is a
* legacy <i>discovery-only</i> 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 {

Expand Down Expand Up @@ -129,24 +127,7 @@ public static void applyRequiredChatEnvelope(Map<String, Object> payload, boolea
* not carry the chat {@code function.name} shape are passed through untouched.
*/
public static List<Object> appendReservedTools(Collection<?> applicationTools) {
List<Object> tools = new ArrayList<>();
Set<String> 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) {
Expand Down Expand Up @@ -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<String> callerDeclaredToolNames(
Map<String, Object> payload, ZenModelProtocol protocol) {
return OpenCodeZenV24ToolNormalizer.callerDeclaredNames(payload, protocol);
}

public static List<Object> appendRequiredTools(
Collection<?> applicationTools, ZenModelProtocol protocol) {
List<Object> tools = new ArrayList<>();
Set<String> 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<String, Object> 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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Object> normalize(Map<String, Object> 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<Map<String, Object>> legacy = new ArrayList<>();
Set<String> 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<String, Object> copy = stringMap(map);
fillDescription(copy);
legacy.add(copy);
given.add(name);
}
}

List<Object> result = new ArrayList<>();
Map<String, Map<String, Object>> 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<String, Object> outer = stringMap(map);
Map<String, Object> 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<String, Object> 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<String> callerDeclaredNames(Map<String, Object> body, ZenModelProtocol protocol) {
Objects.requireNonNull(body, "body");
checkProtocol(protocol);
Set<String> 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<String, Object> stringMap(Map<?, ?> raw) {
Map<String, Object> 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<String, Object> 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);
}
}
}
Loading
Loading