fix(opencode): cloak Responses requests that already have tools
Free-tier Zen models reject Responses requests with 403 FreeTierError when client tools are present but the fingerprint quartet is missing. Apply the fingerprint tools to every OpenCode request, canonicalise case variants of the quartet (Bash->bash) without duplication, and restore the caller's original spellings on the response side via a request-local WeakMap threaded through the existing toolNameMap.
This commit is contained in:
@@ -6,6 +6,7 @@ import { getThinkingLevels } from "../providers/thinkingLevels.js";
|
|||||||
import { injectReasoningContent } from "../utils/reasoningContentInjector.js";
|
import { injectReasoningContent } from "../utils/reasoningContentInjector.js";
|
||||||
import { resolveSessionId } from "../utils/sessionManager.js";
|
import { resolveSessionId } from "../utils/sessionManager.js";
|
||||||
import { isMuseSparkModel } from "../providers/models/helpers.js";
|
import { isMuseSparkModel } from "../providers/models/helpers.js";
|
||||||
|
import { applyFingerprintTools } from "../utils/opencodeFingerprint.js";
|
||||||
import { ANTHROPIC_API_VERSION } from "../providers/shared.js";
|
import { ANTHROPIC_API_VERSION } from "../providers/shared.js";
|
||||||
import {
|
import {
|
||||||
normalizeResponsesInput,
|
normalizeResponsesInput,
|
||||||
@@ -24,68 +25,6 @@ export const OPENCODE_SESSION_RE = /^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/;
|
|||||||
export const OPENCODE_REQUEST_RE = /^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/;
|
export const OPENCODE_REQUEST_RE = /^msg_[0-9a-f]{12}[0-9A-Za-z]{14}$/;
|
||||||
const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
const BASE62_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||||
|
|
||||||
// OpenCode free tier requires both 'bash' and 'read' in tools payload.
|
|
||||||
// Injected as cloaked decoy tools so external CLI tools (e.g. Claude Code's Bash/Read)
|
|
||||||
// take precedence while satisfying upstream verification.
|
|
||||||
const OPENCODE_DECOY_CHAT_TOOLS = [
|
|
||||||
{
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: "bash",
|
|
||||||
description: "This tool is currently unavailable and must not be used.",
|
|
||||||
parameters: { type: "object", properties: {} },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: "read",
|
|
||||||
description: "This tool is currently unavailable and must not be used.",
|
|
||||||
parameters: { type: "object", properties: {} },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const OPENCODE_DECOY_RESPONSES_TOOLS = [
|
|
||||||
{
|
|
||||||
type: "function",
|
|
||||||
name: "bash",
|
|
||||||
description: "This tool is currently unavailable and must not be used.",
|
|
||||||
parameters: { type: "object", properties: {} },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "function",
|
|
||||||
name: "read",
|
|
||||||
description: "This tool is currently unavailable and must not be used.",
|
|
||||||
parameters: { type: "object", properties: {} },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function cloakOpencodeTools(body, isResponses) {
|
|
||||||
if (!body || typeof body !== "object") return;
|
|
||||||
if (isResponses) {
|
|
||||||
if (!Array.isArray(body.tools)) body.tools = [];
|
|
||||||
const names = new Set(body.tools.map((t) => t.name || t.function?.name));
|
|
||||||
for (const tool of OPENCODE_DECOY_RESPONSES_TOOLS) {
|
|
||||||
if (!names.has(tool.name)) body.tools.push({ ...tool });
|
|
||||||
}
|
|
||||||
if (!body.tool_choice) body.tool_choice = "auto";
|
|
||||||
} else {
|
|
||||||
const hasTools = Array.isArray(body.tools) && body.tools.length > 0;
|
|
||||||
if (!hasTools) {
|
|
||||||
body.tools = OPENCODE_DECOY_CHAT_TOOLS.map((t) => ({ ...t, function: { ...t.function } }));
|
|
||||||
if (!body.tool_choice) body.tool_choice = "none";
|
|
||||||
} else {
|
|
||||||
const names = new Set(body.tools.map((t) => t.function?.name || t.name));
|
|
||||||
for (const tool of OPENCODE_DECOY_CHAT_TOOLS) {
|
|
||||||
if (!names.has(tool.function.name)) {
|
|
||||||
body.tools.push({ ...tool, function: { ...tool.function } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasValidOpencodeVersion(ua) {
|
function hasValidOpencodeVersion(ua) {
|
||||||
const m = String(ua || "").match(/opencode\/(\d+)\.(\d+)(?:\.(\d+))?/i);
|
const m = String(ua || "").match(/opencode\/(\d+)\.(\d+)(?:\.(\d+))?/i);
|
||||||
if (!m) return false;
|
if (!m) return false;
|
||||||
@@ -499,11 +438,12 @@ export class OpenCodeExecutor extends BaseExecutor {
|
|||||||
body.store = false;
|
body.store = false;
|
||||||
normalizeResponsesTools(body);
|
normalizeResponsesTools(body);
|
||||||
sanitizeResponsesItems(body);
|
sanitizeResponsesItems(body);
|
||||||
if (!Array.isArray(body.tools) || body.tools.length === 0) {
|
// Free-tier fingerprint tools are required even when an agent client
|
||||||
cloakOpencodeTools(body, true);
|
// already supplied tools. ZCode/Claude Code requests normally have
|
||||||
}
|
// non-empty tool arrays; skipping cloaking here triggers 403 FreeTierError.
|
||||||
|
applyFingerprintTools(body, true);
|
||||||
} else if (body && typeof body === "object") {
|
} else if (body && typeof body === "object") {
|
||||||
cloakOpencodeTools(body, false);
|
applyFingerprintTools(body, false);
|
||||||
}
|
}
|
||||||
return injectReasoningContent({ provider: this.provider, model, body });
|
return injectReasoningContent({ provider: this.provider, model, body });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { handleNonStreamingResponse } from "./chatCore/nonStreamingHandler.js";
|
|||||||
import { handleStreamingResponse, buildOnStreamComplete } from "./chatCore/streamingHandler.js";
|
import { handleStreamingResponse, buildOnStreamComplete } from "./chatCore/streamingHandler.js";
|
||||||
import { detectClientTool, isNativePassthrough } from "../utils/clientDetector.js";
|
import { detectClientTool, isNativePassthrough } from "../utils/clientDetector.js";
|
||||||
import { dedupeTools } from "../utils/toolDeduper.js";
|
import { dedupeTools } from "../utils/toolDeduper.js";
|
||||||
|
import { takeRenamedToolNames } from "../utils/opencodeFingerprint.js";
|
||||||
import { injectCaveman } from "../rtk/caveman.js";
|
import { injectCaveman } from "../rtk/caveman.js";
|
||||||
import { injectPonytail } from "../rtk/ponytail.js";
|
import { injectPonytail } from "../rtk/ponytail.js";
|
||||||
import { compressMessages, formatRtkLog } from "../rtk/index.js";
|
import { compressMessages, formatRtkLog } from "../rtk/index.js";
|
||||||
@@ -376,6 +377,10 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
providerHeaders = result.headers;
|
providerHeaders = result.headers;
|
||||||
finalBody = result.transformedBody;
|
finalBody = result.transformedBody;
|
||||||
providerResponseFormat = result.responseFormat || targetFormat;
|
providerResponseFormat = result.responseFormat || targetFormat;
|
||||||
|
const renamedToolNames = takeRenamedToolNames(translatedBody);
|
||||||
|
if (renamedToolNames?.size) {
|
||||||
|
toolNameMap = new Map([...(toolNameMap || []), ...renamedToolNames]);
|
||||||
|
}
|
||||||
reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
|
reqLogger.logTargetRequest(providerUrl, providerHeaders, finalBody);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
trackPendingRequest(model, provider, connectionId, false, true);
|
trackPendingRequest(model, provider, connectionId, false, true);
|
||||||
@@ -480,7 +485,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
|
|
||||||
// Provider forced streaming but client wants JSON
|
// Provider forced streaming but client wants JSON
|
||||||
if (!clientRequestedStreaming && providerRequiresStreaming) {
|
if (!clientRequestedStreaming && providerRequiresStreaming) {
|
||||||
const result = await handleForcedSSEToJson({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, customToolNames, trackDone, appendLog });
|
const result = await handleForcedSSEToJson({ ...sharedCtx, providerResponse, sourceFormat, targetFormat: providerResponseFormat, customToolNames, toolNameMap, trackDone, appendLog });
|
||||||
if (result) { streamController.handleComplete(); return result; }
|
if (result) { streamController.handleComplete(); return result; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { unwrapClineEnvelope } from "../../shared/clineEnvelope.js";
|
|||||||
import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, saveUsageStats, formatDoneLine } from "./requestDetail.js";
|
import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, saveUsageStats, formatDoneLine } from "./requestDetail.js";
|
||||||
import { appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
|
import { appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
|
||||||
import { decloakToolNames } from "../../utils/claudeCloaking.js";
|
import { decloakToolNames } from "../../utils/claudeCloaking.js";
|
||||||
|
import { restoreToolNames } from "../../utils/opencodeFingerprint.js";
|
||||||
import { ROLE, RESPONSES_ITEM } from "../../translator/schema/index.js";
|
import { ROLE, RESPONSES_ITEM } from "../../translator/schema/index.js";
|
||||||
|
|
||||||
function parseToolArguments(value) {
|
function parseToolArguments(value) {
|
||||||
@@ -397,7 +398,7 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
response: new Response(JSON.stringify(translatedResponse), {
|
response: new Response(JSON.stringify(restoreToolNames(translatedResponse, toolNameMap)), {
|
||||||
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }
|
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { convertResponsesStreamToJson } from "../../transformer/streamToJsonConverter.js";
|
import { convertResponsesStreamToJson } from "../../transformer/streamToJsonConverter.js";
|
||||||
|
import { restoreToolNames } from "../../utils/opencodeFingerprint.js";
|
||||||
import { createErrorResult } from "../../utils/error.js";
|
import { createErrorResult } from "../../utils/error.js";
|
||||||
import { HTTP_STATUS } from "../../config/runtimeConfig.js";
|
import { HTTP_STATUS } from "../../config/runtimeConfig.js";
|
||||||
import { FORMATS } from "../../translator/formats.js";
|
import { FORMATS } from "../../translator/formats.js";
|
||||||
@@ -179,7 +180,7 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) {
|
|||||||
* Handle case: provider forced streaming but client wants JSON.
|
* Handle case: provider forced streaming but client wants JSON.
|
||||||
* Supports both Codex/Responses API SSE and standard Chat Completions SSE.
|
* Supports both Codex/Responses API SSE and standard Chat Completions SSE.
|
||||||
*/
|
*/
|
||||||
export async function handleForcedSSEToJson({ providerResponse, sourceFormat, targetFormat, provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, customToolNames, trackDone, appendLog, reqTag, log }) {
|
export async function handleForcedSSEToJson({ providerResponse, sourceFormat, targetFormat, provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, customToolNames, toolNameMap, trackDone, appendLog, reqTag, log }) {
|
||||||
const contentType = providerResponse.headers.get("content-type") || "";
|
const contentType = providerResponse.headers.get("content-type") || "";
|
||||||
const isSSE = contentType.includes("text/event-stream") || (contentType === "" && isResponsesProvider(provider));
|
const isSSE = contentType.includes("text/event-stream") || (contentType === "" && isResponsesProvider(provider));
|
||||||
if (!isSSE) return null; // not handled here
|
if (!isSSE) return null; // not handled here
|
||||||
@@ -225,7 +226,7 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, ta
|
|||||||
|
|
||||||
// Client is Responses API → return as-is
|
// Client is Responses API → return as-is
|
||||||
if (sourceFormat === FORMATS.OPENAI_RESPONSES) {
|
if (sourceFormat === FORMATS.OPENAI_RESPONSES) {
|
||||||
return { success: true, response: new Response(JSON.stringify(jsonResponse), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) };
|
return { success: true, response: new Response(JSON.stringify(restoreToolNames(jsonResponse, toolNameMap)), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build client-format response.
|
// Build client-format response.
|
||||||
@@ -281,7 +282,7 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, ta
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, response: new Response(JSON.stringify(finalResp), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) };
|
return { success: true, response: new Response(JSON.stringify(restoreToolNames(finalResp, toolNameMap)), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[ChatCore] Responses API SSE→JSON failed:", err);
|
console.error("[ChatCore] Responses API SSE→JSON failed:", err);
|
||||||
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Failed to convert streaming response to JSON");
|
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Failed to convert streaming response to JSON");
|
||||||
@@ -351,7 +352,7 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, ta
|
|||||||
? chatCompletionToResponses(parsed, customToolNames)
|
? chatCompletionToResponses(parsed, customToolNames)
|
||||||
: parsed;
|
: parsed;
|
||||||
|
|
||||||
return { success: true, response: new Response(JSON.stringify(finalBody), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) };
|
return { success: true, response: new Response(JSON.stringify(restoreToolNames(finalBody, toolNameMap)), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[ChatCore] Chat Completions SSE→JSON failed:", err);
|
console.error("[ChatCore] Chat Completions SSE→JSON failed:", err);
|
||||||
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Failed to convert streaming response to JSON");
|
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, "Failed to convert streaming response to JSON");
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { FORMATS } from "./formats.js";
|
|||||||
import { ensureToolCallIds, fixMissingToolResponses } from "./concerns/toolCall.js";
|
import { ensureToolCallIds, fixMissingToolResponses } from "./concerns/toolCall.js";
|
||||||
import { prepareClaudeRequest } from "./formats/claude.js";
|
import { prepareClaudeRequest } from "./formats/claude.js";
|
||||||
import { cloakClaudeTools, decloakStreamChunk } from "../utils/claudeCloaking.js";
|
import { cloakClaudeTools, decloakStreamChunk } from "../utils/claudeCloaking.js";
|
||||||
|
import { restoreToolNames } from "../utils/opencodeFingerprint.js";
|
||||||
import { filterToOpenAIFormat } from "./formats/openai.js";
|
import { filterToOpenAIFormat } from "./formats/openai.js";
|
||||||
import { normalizeThinkingConfig } from "../services/provider.js";
|
import { normalizeThinkingConfig } from "../services/provider.js";
|
||||||
import { applyThinking, captureThinking } from "./concerns/thinkingUnified.js";
|
import { applyThinking, captureThinking } from "./concerns/thinkingUnified.js";
|
||||||
@@ -166,7 +167,7 @@ export function translateResponse(targetFormat, sourceFormat, chunk, state) {
|
|||||||
// even when no format conversion is needed, so streamed tool_use blocks must
|
// even when no format conversion is needed, so streamed tool_use blocks must
|
||||||
// be decloaked here or the client sees an unknown ("_ide"-suffixed) tool.
|
// be decloaked here or the client sees an unknown ("_ide"-suffixed) tool.
|
||||||
if (sourceFormat === targetFormat) {
|
if (sourceFormat === targetFormat) {
|
||||||
return [decloakStreamChunk(chunk, state?.toolNameMap)];
|
return [restoreToolNames(decloakStreamChunk(chunk, state?.toolNameMap), state?.toolNameMap)];
|
||||||
}
|
}
|
||||||
|
|
||||||
let results = [chunk];
|
let results = [chunk];
|
||||||
@@ -179,7 +180,8 @@ export function translateResponse(targetFormat, sourceFormat, chunk, state) {
|
|||||||
const directFn = responseRegistry.get(`${targetFormat}:${sourceFormat}`);
|
const directFn = responseRegistry.get(`${targetFormat}:${sourceFormat}`);
|
||||||
if (directFn) {
|
if (directFn) {
|
||||||
const converted = directFn(chunk, state);
|
const converted = directFn(chunk, state);
|
||||||
return converted ? (Array.isArray(converted) ? converted : [converted]) : [];
|
const directResults = converted ? (Array.isArray(converted) ? converted : [converted]) : [];
|
||||||
|
return restoreToolNames(directResults, state?.toolNameMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: target -> openai (if target is not openai)
|
// Step 1: target -> openai (if target is not openai)
|
||||||
@@ -210,6 +212,8 @@ export function translateResponse(targetFormat, sourceFormat, chunk, state) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
results = restoreToolNames(results, state?.toolNameMap);
|
||||||
|
|
||||||
// Attach OpenAI intermediate results for logging
|
// Attach OpenAI intermediate results for logging
|
||||||
if (openaiResults && sourceFormat !== FORMATS.OPENAI && targetFormat !== FORMATS.OPENAI) {
|
if (openaiResults && sourceFormat !== FORMATS.OPENAI && targetFormat !== FORMATS.OPENAI) {
|
||||||
results._openaiIntermediate = openaiResults;
|
results._openaiIntermediate = openaiResults;
|
||||||
|
|||||||
232
open-sse/utils/opencodeFingerprint.js
Normal file
232
open-sse/utils/opencodeFingerprint.js
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
/**
|
||||||
|
* Helpers for the OpenCode Zen free-tier client fingerprint.
|
||||||
|
*
|
||||||
|
* Live upstream probes show that free-tier requests must include the lowercase
|
||||||
|
* file-search quartet (bash/glob/grep/read). Agent clients such as Claude Code
|
||||||
|
* may declare the same tools with different casing, so those case variants must
|
||||||
|
* be renamed instead of duplicated. The response side restores the caller's
|
||||||
|
* original spelling so downstream clients still recognise their own tool calls.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Canonical names required by the upstream free-tier gate. */
|
||||||
|
export const OPENCODE_FINGERPRINT_TOOLS = ["bash", "glob", "grep", "read"];
|
||||||
|
|
||||||
|
// Request body -> names renamed for that request. transformRequest() mutates the
|
||||||
|
// same body object that chatCore passed into the executor, so a WeakMap keeps the
|
||||||
|
// mapping request-local without putting transport metadata on the wire.
|
||||||
|
const renamedToolNames = new WeakMap();
|
||||||
|
|
||||||
|
/** Canonical lowercase name when `name` is a quartet member; "" otherwise. */
|
||||||
|
export function fingerprintToolKey(name) {
|
||||||
|
const lower = String(name ?? "").trim().toLowerCase();
|
||||||
|
return OPENCODE_FINGERPRINT_TOOLS.includes(lower) ? lower : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a tool name from either flat ({name}) or chat ({function:{name}}) shape. */
|
||||||
|
function toolNameOf(tool) {
|
||||||
|
if (!tool || typeof tool !== "object" || Array.isArray(tool)) return "";
|
||||||
|
if (typeof tool.name === "string" && tool.name.trim()) return tool.name.trim();
|
||||||
|
const fn = tool.function;
|
||||||
|
if (fn && typeof fn === "object" && !Array.isArray(fn) && typeof fn.name === "string") {
|
||||||
|
return fn.name.trim();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonicalise only the fingerprint quartet and remove duplicate quartet
|
||||||
|
* variants. Non-fingerprint tools are preserved verbatim, including tools whose
|
||||||
|
* names differ only by case; they are outside OpenCode's fingerprint contract.
|
||||||
|
*
|
||||||
|
* @param {Array} tools
|
||||||
|
* @returns {{ tools: Array, map: Map<string,string> }} map: sent name -> original name
|
||||||
|
*/
|
||||||
|
export function concealFingerprintToolNames(tools) {
|
||||||
|
const map = new Map();
|
||||||
|
if (!Array.isArray(tools) || tools.length === 0) return { tools, map };
|
||||||
|
|
||||||
|
const seenQuartet = new Set();
|
||||||
|
const out = [];
|
||||||
|
for (const tool of tools) {
|
||||||
|
if (!tool || typeof tool !== "object" || Array.isArray(tool)) {
|
||||||
|
out.push(tool);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = toolNameOf(tool);
|
||||||
|
const key = fingerprintToolKey(current);
|
||||||
|
if (!key) {
|
||||||
|
out.push(tool);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `Bash` + `bash` is rejected upstream as a duplicate. Keep exactly one
|
||||||
|
// declaration for each quartet member.
|
||||||
|
if (seenQuartet.has(key)) continue;
|
||||||
|
seenQuartet.add(key);
|
||||||
|
|
||||||
|
if (current !== key) {
|
||||||
|
map.set(key, current);
|
||||||
|
const fn = tool.function && typeof tool.function === "object" && !Array.isArray(tool.function)
|
||||||
|
? tool.function
|
||||||
|
: null;
|
||||||
|
out.push(fn ? { ...tool, function: { ...fn, name: key } } : { ...tool, name: key });
|
||||||
|
} else {
|
||||||
|
out.push(tool);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { tools: out, map };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Append only genuinely missing quartet declarations. */
|
||||||
|
export function appendMissingFingerprintTools(tools, flat) {
|
||||||
|
const list = Array.isArray(tools) ? tools : [];
|
||||||
|
for (const name of OPENCODE_FINGERPRINT_TOOLS) {
|
||||||
|
if (list.some((tool) => fingerprintToolKey(toolNameOf(tool)) === name)) continue;
|
||||||
|
list.push(flat ? {
|
||||||
|
type: "function",
|
||||||
|
name,
|
||||||
|
description: "This tool is currently unavailable and must not be used.",
|
||||||
|
parameters: { type: "object", properties: {} },
|
||||||
|
} : {
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name,
|
||||||
|
description: "This tool is currently unavailable and must not be used.",
|
||||||
|
parameters: { type: "object", properties: {} },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Point an explicit tool_choice at a quartet member after canonicalisation. */
|
||||||
|
export function retargetToolChoice(body, map) {
|
||||||
|
if (!body || typeof body !== "object" || !map?.size) return;
|
||||||
|
const choice = body.tool_choice;
|
||||||
|
if (!choice || typeof choice !== "object" || Array.isArray(choice)) return;
|
||||||
|
|
||||||
|
if (typeof choice.name === "string") {
|
||||||
|
const key = fingerprintToolKey(choice.name);
|
||||||
|
if (key && map.has(key)) body.tool_choice = { ...choice, name: key };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn = choice.function;
|
||||||
|
if (fn && typeof fn === "object" && !Array.isArray(fn) && typeof fn.name === "string") {
|
||||||
|
const key = fingerprintToolKey(fn.name);
|
||||||
|
if (key && map.has(key)) {
|
||||||
|
body.tool_choice = { ...choice, function: { ...fn, name: key } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full request-side pass: canonicalise quartet case variants, remove duplicate
|
||||||
|
* quartet declarations, append missing members and preserve the legacy
|
||||||
|
* tool_choice defaults used by the OpenCode executor.
|
||||||
|
*
|
||||||
|
* @param {object} body
|
||||||
|
* @param {boolean} flat - true for Responses tools ({name}), false for chat tools
|
||||||
|
* @returns {Map<string,string>} map: sent name -> original name
|
||||||
|
*/
|
||||||
|
export function applyFingerprintTools(body, flat) {
|
||||||
|
if (!body || typeof body !== "object") return new Map();
|
||||||
|
|
||||||
|
const hadClientTools = Array.isArray(body.tools) && body.tools.length > 0;
|
||||||
|
const { tools, map } = concealFingerprintToolNames(body.tools);
|
||||||
|
body.tools = appendMissingFingerprintTools(tools, flat);
|
||||||
|
retargetToolChoice(body, map);
|
||||||
|
|
||||||
|
// Preserve the existing executor semantics. Responses uses auto when the
|
||||||
|
// fingerprint helper supplies tools; chat requests with no caller tools use
|
||||||
|
// none so the injected decoys cannot be selected.
|
||||||
|
if (!body.tool_choice) {
|
||||||
|
if (flat) body.tool_choice = "auto";
|
||||||
|
else if (!hadClientTools) body.tool_choice = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
recordRenamedToolNames(body, map);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Store the rename map for `body`. */
|
||||||
|
export function recordRenamedToolNames(body, map) {
|
||||||
|
if (!body || typeof body !== "object" || !map?.size) return;
|
||||||
|
renamedToolNames.set(body, map);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retrieve the rename map for `body`. */
|
||||||
|
export function takeRenamedToolNames(body) {
|
||||||
|
if (!body || typeof body !== "object") return null;
|
||||||
|
return renamedToolNames.get(body) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response side -------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Restore caller tool spellings in supported response/event shapes. */
|
||||||
|
export function restoreToolNames(payload, map) {
|
||||||
|
if (!map?.size || !payload) return payload;
|
||||||
|
if (Array.isArray(payload)) return payload.map((item) => restoreToolNames(item, map));
|
||||||
|
if (typeof payload !== "object") return payload;
|
||||||
|
|
||||||
|
let out = payload;
|
||||||
|
const put = (key, value) => {
|
||||||
|
if (out === payload) out = { ...payload };
|
||||||
|
out[key] = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Claude streaming content_block_start event.
|
||||||
|
if (payload.type === "content_block_start") {
|
||||||
|
const block = payload.content_block;
|
||||||
|
if (block?.type === "tool_use" && typeof block.name === "string" && map.has(block.name)) {
|
||||||
|
put("content_block", { ...block, name: map.get(block.name) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claude non-streaming message body.
|
||||||
|
if (Array.isArray(payload.content)) {
|
||||||
|
put("content", payload.content.map((block) =>
|
||||||
|
block?.type === "tool_use" && typeof block.name === "string" && map.has(block.name)
|
||||||
|
? { ...block, name: map.get(block.name) }
|
||||||
|
: block));
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI Chat Completions, both streaming delta and JSON message shapes.
|
||||||
|
if (Array.isArray(payload.choices)) {
|
||||||
|
put("choices", payload.choices.map((choice) => {
|
||||||
|
let changed = false;
|
||||||
|
const next = { ...choice };
|
||||||
|
for (const holder of ["delta", "message"]) {
|
||||||
|
const value = choice?.[holder];
|
||||||
|
if (!value || !Array.isArray(value.tool_calls) || value.tool_calls.length === 0) continue;
|
||||||
|
const calls = value.tool_calls.map((call) => {
|
||||||
|
const name = call?.function?.name;
|
||||||
|
if (typeof name === "string" && map.has(name)) {
|
||||||
|
changed = true;
|
||||||
|
return { ...call, function: { ...call.function, name: map.get(name) } };
|
||||||
|
}
|
||||||
|
return call;
|
||||||
|
});
|
||||||
|
next[holder] = { ...value, tool_calls: calls };
|
||||||
|
}
|
||||||
|
return changed ? next : choice;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI Responses final JSON body.
|
||||||
|
if (Array.isArray(payload.output)) {
|
||||||
|
put("output", payload.output.map((item) =>
|
||||||
|
item?.type === "function_call" && typeof item.name === "string" && map.has(item.name)
|
||||||
|
? { ...item, name: map.get(item.name) }
|
||||||
|
: item));
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenAI Responses SSE events such as response.output_item.added/done.
|
||||||
|
const item = payload.item;
|
||||||
|
if (item?.type === "function_call" && typeof item.name === "string" && map.has(item.name)) {
|
||||||
|
put("item", { ...item, name: map.get(item.name) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
191
tests/unit/opencode-fingerprint.test.js
Normal file
191
tests/unit/opencode-fingerprint.test.js
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
applyFingerprintTools,
|
||||||
|
concealFingerprintToolNames,
|
||||||
|
appendMissingFingerprintTools,
|
||||||
|
fingerprintToolKey,
|
||||||
|
restoreToolNames,
|
||||||
|
takeRenamedToolNames,
|
||||||
|
OPENCODE_FINGERPRINT_TOOLS,
|
||||||
|
} from "open-sse/utils/opencodeFingerprint.js";
|
||||||
|
|
||||||
|
const CC_TOOLS = ["Task", "Bash", "Glob", "Grep", "Read", "Edit", "Write", "WebFetch"];
|
||||||
|
const flat = (names) => names.map((name) => ({ type: "function", name }));
|
||||||
|
const chat = (names) => names.map((name) => ({ type: "function", function: { name } }));
|
||||||
|
|
||||||
|
describe("opencodeFingerprint — request side", () => {
|
||||||
|
it("renames capitalised quartet members to lowercase", () => {
|
||||||
|
const body = { tools: flat(CC_TOOLS) };
|
||||||
|
const map = applyFingerprintTools(body, true);
|
||||||
|
const names = body.tools.map((tool) => tool.name);
|
||||||
|
|
||||||
|
expect(names).toContain("bash");
|
||||||
|
expect(names).not.toContain("Bash");
|
||||||
|
expect(names).toContain("Edit");
|
||||||
|
expect(map.get("bash")).toBe("Bash");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes quartet case duplicates without dropping unrelated case variants", () => {
|
||||||
|
const body = { tools: flat(["Bash", "bash", "Glob", "grep", "Read", "Foo", "foo"]) };
|
||||||
|
applyFingerprintTools(body, true);
|
||||||
|
|
||||||
|
const names = body.tools.map((tool) => tool.name);
|
||||||
|
expect(names.filter((name) => name === "bash")).toHaveLength(1);
|
||||||
|
expect(names).toContain("Foo");
|
||||||
|
expect(names).toContain("foo");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves tool count when a complete quartet is only renamed", () => {
|
||||||
|
const body = { tools: flat(CC_TOOLS) };
|
||||||
|
applyFingerprintTools(body, true);
|
||||||
|
expect(body.tools).toHaveLength(CC_TOOLS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles the nested chat shape without dropping .function", () => {
|
||||||
|
const body = { tools: chat(CC_TOOLS) };
|
||||||
|
applyFingerprintTools(body, false);
|
||||||
|
|
||||||
|
const names = body.tools.map((tool) => tool.function.name);
|
||||||
|
expect(names).toContain("bash");
|
||||||
|
expect(names).not.toContain("Bash");
|
||||||
|
expect(body.tools[1].function.name).toBe("bash");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("injects all four fingerprint tools when the body carries no tools", () => {
|
||||||
|
const body = { tools: [] };
|
||||||
|
applyFingerprintTools(body, true);
|
||||||
|
|
||||||
|
expect(body.tools.map((tool) => tool.name).sort()).toEqual([...OPENCODE_FINGERPRINT_TOOLS].sort());
|
||||||
|
expect(body.tool_choice).toBe("auto");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the chat no-tool default tool_choice=none", () => {
|
||||||
|
const body = {};
|
||||||
|
applyFingerprintTools(body, false);
|
||||||
|
|
||||||
|
expect(body.tools.map((tool) => tool.function.name)).toEqual(OPENCODE_FINGERPRINT_TOOLS);
|
||||||
|
expect(body.tool_choice).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not invent a chat tool_choice when the caller already supplied tools", () => {
|
||||||
|
const body = { tools: chat(["Edit"]) };
|
||||||
|
applyFingerprintTools(body, false);
|
||||||
|
expect(body.tool_choice).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends only genuinely missing quartet members", () => {
|
||||||
|
const body = { tools: flat(["Bash", "Read", "terminal"]) };
|
||||||
|
applyFingerprintTools(body, true);
|
||||||
|
|
||||||
|
const names = body.tools.map((tool) => tool.name);
|
||||||
|
expect(names).toContain("glob");
|
||||||
|
expect(names).toContain("grep");
|
||||||
|
expect(names).toContain("terminal");
|
||||||
|
expect(body.tools).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retargets flat tool_choice that points at a renamed tool", () => {
|
||||||
|
const body = { tools: flat(CC_TOOLS), tool_choice: { type: "function", name: "Bash" } };
|
||||||
|
applyFingerprintTools(body, true);
|
||||||
|
expect(body.tool_choice.name).toBe("bash");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retargets nested tool_choice that points at a renamed tool", () => {
|
||||||
|
const body = {
|
||||||
|
tools: chat(CC_TOOLS),
|
||||||
|
tool_choice: { type: "function", function: { name: "Read" } },
|
||||||
|
};
|
||||||
|
applyFingerprintTools(body, false);
|
||||||
|
expect(body.tool_choice.function.name).toBe("read");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records the rename map against the body for the response side", () => {
|
||||||
|
const body = { tools: flat(CC_TOOLS) };
|
||||||
|
const map = applyFingerprintTools(body, true);
|
||||||
|
expect(takeRenamedToolNames(body)).toBe(map);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never throws on malformed tools", () => {
|
||||||
|
for (const tools of [null, undefined, "nope", [null, 42, []], [{}, { name: "" }]]) {
|
||||||
|
expect(() => concealFingerprintToolNames(tools)).not.toThrow();
|
||||||
|
expect(() => appendMissingFingerprintTools(tools, true)).not.toThrow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("opencodeFingerprint — response side", () => {
|
||||||
|
const map = new Map([["bash", "Bash"], ["grep", "Grep"], ["read", "Read"]]);
|
||||||
|
|
||||||
|
it("restores names in Claude content_block_start chunks", () => {
|
||||||
|
const chunk = {
|
||||||
|
type: "content_block_start",
|
||||||
|
content_block: { type: "tool_use", name: "bash", id: "t1" },
|
||||||
|
};
|
||||||
|
const out = restoreToolNames(chunk, map);
|
||||||
|
|
||||||
|
expect(out.content_block.name).toBe("Bash");
|
||||||
|
expect(chunk.content_block.name).toBe("bash");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recursively restores streaming chunks inside arrays", () => {
|
||||||
|
const chunks = [{
|
||||||
|
choices: [{ delta: { tool_calls: [{ function: { name: "grep", arguments: "{}" } }] } }],
|
||||||
|
}];
|
||||||
|
const out = restoreToolNames(chunks, map);
|
||||||
|
expect(out[0].choices[0].delta.tool_calls[0].function.name).toBe("Grep");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores names in Claude non-streaming bodies", () => {
|
||||||
|
const body = { type: "message", content: [{ type: "tool_use", name: "bash", input: {} }] };
|
||||||
|
expect(restoreToolNames(body, map).content[0].name).toBe("Bash");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores names in Chat Completions message and delta shapes", () => {
|
||||||
|
const body = {
|
||||||
|
choices: [
|
||||||
|
{ message: { tool_calls: [{ function: { name: "grep", arguments: "{}" } }] } },
|
||||||
|
{ delta: { tool_calls: [{ function: { name: "read", arguments: "{}" } }] } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const out = restoreToolNames(body, map);
|
||||||
|
|
||||||
|
expect(out.choices[0].message.tool_calls[0].function.name).toBe("Grep");
|
||||||
|
expect(out.choices[1].delta.tool_calls[0].function.name).toBe("Read");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores names in Responses final output items", () => {
|
||||||
|
const body = { output: [{ type: "function_call", name: "bash", call_id: "c1" }] };
|
||||||
|
expect(restoreToolNames(body, map).output[0].name).toBe("Bash");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores names in Responses streaming output_item events", () => {
|
||||||
|
const event = {
|
||||||
|
type: "response.output_item.added",
|
||||||
|
item: { type: "function_call", name: "read", call_id: "c1" },
|
||||||
|
};
|
||||||
|
expect(restoreToolNames(event, map).item.name).toBe("Read");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op without a map or with an empty map", () => {
|
||||||
|
const body = { choices: [{ message: { tool_calls: [{ function: { name: "bash" } }] } }] };
|
||||||
|
expect(restoreToolNames(body, null)).toBe(body);
|
||||||
|
expect(restoreToolNames(body, new Map())).toBe(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves unknown tool names untouched", () => {
|
||||||
|
const body = { output: [{ type: "function_call", name: "Edit" }] };
|
||||||
|
expect(restoreToolNames(body, map).output[0].name).toBe("Edit");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fingerprintToolKey", () => {
|
||||||
|
it("maps quartet case/whitespace variants and rejects other tools", () => {
|
||||||
|
expect(fingerprintToolKey("Bash")).toBe("bash");
|
||||||
|
expect(fingerprintToolKey(" bash ")).toBe("bash");
|
||||||
|
expect(fingerprintToolKey("GLOB")).toBe("glob");
|
||||||
|
expect(fingerprintToolKey("Read")).toBe("read");
|
||||||
|
expect(fingerprintToolKey("Edit")).toBe("");
|
||||||
|
expect(fingerprintToolKey("terminal")).toBe("");
|
||||||
|
expect(fingerprintToolKey(null)).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -277,39 +277,68 @@ describe("OpenCode Stable Session Reuse (429 follow-up)", () => {
|
|||||||
expect(second).toBe(first);
|
expect(second).toBe(first);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cloaks free-tier requests with bash and read decoy tools", () => {
|
it("applies the full lowercase free-tier fingerprint quartet", () => {
|
||||||
const executor = getExecutor("opencode");
|
const executor = getExecutor("opencode");
|
||||||
|
|
||||||
// Case 1: no tools sent by client -> injects bash + read with tool_choice none
|
|
||||||
const chatNoTools = executor.transformRequest("nemotron-3-ultra-free", {
|
const chatNoTools = executor.transformRequest("nemotron-3-ultra-free", {
|
||||||
messages: [{ role: "user", content: "hi" }],
|
messages: [{ role: "user", content: "hi" }],
|
||||||
});
|
});
|
||||||
expect(chatNoTools.stream).toBe(true);
|
expect(chatNoTools.stream).toBe(true);
|
||||||
expect(chatNoTools.tool_choice).toBe("none");
|
expect(chatNoTools.tool_choice).toBe("none");
|
||||||
expect(chatNoTools.tools.map((t) => t.function?.name)).toEqual(["bash", "read"]);
|
expect(chatNoTools.tools.map((t) => t.function?.name)).toEqual([
|
||||||
|
"bash", "glob", "grep", "read",
|
||||||
|
]);
|
||||||
|
|
||||||
// Case 2: external CLI tools (e.g. Claude Code Bash) -> preserves Bash, appends read
|
|
||||||
const chatWithTools = executor.transformRequest("nemotron-3-ultra-free", {
|
const chatWithTools = executor.transformRequest("nemotron-3-ultra-free", {
|
||||||
messages: [{ role: "user", content: "hi" }],
|
messages: [{ role: "user", content: "hi" }],
|
||||||
tools: [{ type: "function", function: { name: "Bash", description: "Claude Code tool" } }],
|
tools: [
|
||||||
|
{ type: "function", function: { name: "Bash", description: "Claude Code tool" } },
|
||||||
|
{ type: "function", function: { name: "Glob", description: "Claude Code tool" } },
|
||||||
|
{ type: "function", function: { name: "Grep", description: "Claude Code tool" } },
|
||||||
|
{ type: "function", function: { name: "Read", description: "Claude Code tool" } },
|
||||||
|
],
|
||||||
tool_choice: "auto",
|
tool_choice: "auto",
|
||||||
});
|
});
|
||||||
expect(chatWithTools.tool_choice).toBe("auto");
|
expect(chatWithTools.tool_choice).toBe("auto");
|
||||||
const names = chatWithTools.tools.map((t) => t.function?.name);
|
expect(chatWithTools.tools.map((t) => t.function?.name)).toEqual([
|
||||||
expect(names).toContain("Bash");
|
"bash", "glob", "grep", "read",
|
||||||
expect(names).toContain("bash");
|
]);
|
||||||
expect(names).toContain("read");
|
|
||||||
|
|
||||||
// Case 3: already has both bash and read -> do not insert anything
|
const chatPartial = executor.transformRequest("nemotron-3-ultra-free", {
|
||||||
const chatFull = executor.transformRequest("nemotron-3-ultra-free", {
|
|
||||||
messages: [{ role: "user", content: "hi" }],
|
messages: [{ role: "user", content: "hi" }],
|
||||||
tools: [
|
tools: [
|
||||||
{ type: "function", function: { name: "bash", description: "existing" } },
|
{ type: "function", function: { name: "bash", description: "existing" } },
|
||||||
{ type: "function", function: { name: "read", description: "existing" } },
|
{ type: "function", function: { name: "read", description: "existing" } },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(chatFull.tools.length).toBe(2);
|
expect(chatPartial.tools.map((t) => t.function?.name)).toEqual([
|
||||||
expect(chatFull.tools[0].function.description).toBe("existing");
|
"bash", "read", "glob", "grep",
|
||||||
|
]);
|
||||||
|
expect(chatPartial.tools[0].function.description).toBe("existing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cloaks Muse Responses requests even when the client already supplies tools", () => {
|
||||||
|
const executor = getExecutor("opencode");
|
||||||
|
const transformed = executor.transformRequest("muse-spark-1.3-contributor-free(xhigh)", {
|
||||||
|
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }],
|
||||||
|
tools: [{
|
||||||
|
type: "function",
|
||||||
|
name: "zcode_search",
|
||||||
|
description: "client-provided tool",
|
||||||
|
parameters: { type: "object", properties: {} },
|
||||||
|
}],
|
||||||
|
tool_choice: "auto",
|
||||||
|
reasoning_effort: "xhigh",
|
||||||
|
}, true, {});
|
||||||
|
|
||||||
|
expect(transformed.stream).toBe(true);
|
||||||
|
expect(transformed.reasoning?.effort).toBe("xhigh");
|
||||||
|
const names = transformed.tools.map((tool) => tool.name);
|
||||||
|
expect(names).toContain("zcode_search");
|
||||||
|
expect(names).toContain("bash");
|
||||||
|
expect(names).toContain("read");
|
||||||
|
expect(names.filter((name) => name === "bash")).toHaveLength(1);
|
||||||
|
expect(names.filter((name) => name === "read")).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("declares forceStream on the opencode transport so chatCore serves SSE upstream", async () => {
|
it("declares forceStream on the opencode transport so chatCore serves SSE upstream", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user