mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-10-31 03:52:16 +08:00
Support DeepSeek cache usage for OpenCode Go
This commit is contained in:
@@ -72,7 +72,8 @@ After import:
|
||||
2. OpenCode Go uses its own catalog entry and endpoint, defaulting to `https://opencode.ai/zen/go/v1`, and uses the `opencode-go` credential resolved as described above.
|
||||
3. Model IDs and display names come from the matching OpenCode provider catalog. Each model is grouped under the protocol declared by its AI SDK package, including mixed OpenAI Responses, Chat Completions, Anthropic, and Gemini catalogs.
|
||||
4. For requests sent to the official OpenCode Go endpoint, CCR forwards `x-claude-code-session-id` (or the legacy `x-claude-session-id`) as `x-opencode-session`. If the client supplies neither header, CCR uses the request's `metadata.user_id` when present and otherwise a stable `ccr-<uuid>` generated once per process, which is a last-resort value for clients that send no session identity; explicit `x-opencode-session` configuration takes precedence. This header is not added to Zen or unrelated providers.
|
||||
5. OpenCode Go account usage reads `GET https://opencode.ai/zen/go/v1/usage` with the imported `opencode-go` key and shows the 5-hour, weekly, and monthly windows as remaining percent. Zen has no equivalent account usage endpoint, so only Go is fetched automatically.
|
||||
5. CCR preserves the coding agent's `User-Agent` and accepts both OpenAI-compatible `prompt_tokens_details.cached_tokens` and DeepSeek-native `prompt_cache_hit_tokens` on Go Chat responses. If the upstream omits both fields, cache-read usage is unavailable and CCR does not estimate it from the prompt size.
|
||||
6. OpenCode Go account usage reads `GET https://opencode.ai/zen/go/v1/usage` with the imported `opencode-go` key and shows the 5-hour, weekly, and monthly windows as remaining percent. Zen has no equivalent account usage endpoint, so only Go is fetched automatically.
|
||||
|
||||
If OpenCode Go is unavailable or does not appear as an import option, connect `OpenCode Go` in OpenCode first and let OpenCode refresh its local model cache, then return to CCR and rescan.
|
||||
|
||||
|
||||
@@ -72,7 +72,8 @@ OpenCode 导入会同时读取本机认证文件、OpenCode 配置和模型目
|
||||
2. OpenCode Go 使用自己的目录条目和 API 地址,缺省为 `https://opencode.ai/zen/go/v1`,并使用按上述顺序解析出的 `opencode-go` 凭据。
|
||||
3. 模型 ID 和显示名来自对应的 OpenCode 供应商目录。CCR 按每个模型声明的 AI SDK 包分别归入 OpenAI Responses、Chat Completions、Anthropic 或 Gemini 协议,支持同一目录中混合多种协议。
|
||||
4. 请求发往 OpenCode Go 官方地址时,CCR 会把 `x-claude-code-session-id`(或旧版 `x-claude-session-id`)转发为 `x-opencode-session`。客户端两者都未提供时,CCR 会优先使用请求中的 `metadata.user_id`,否则使用进程内生成一次的稳定 `ccr-<uuid>`,这是为不发送会话标识的客户端提供的最后兜底;显式配置的 `x-opencode-session` 优先。Zen 和其他无关供应商不会被添加此请求头。
|
||||
5. OpenCode Go 的账户用量会使用导入的 `opencode-go` 密钥请求 `GET https://opencode.ai/zen/go/v1/usage`,把 5 小时、每周和每月窗口显示为剩余百分比。Zen 没有对应的账户用量端点,因此只会自动读取 Go。
|
||||
5. CCR 会保留编程 Agent 的 `User-Agent`,并在 Go Chat 响应中同时识别 OpenAI 兼容的 `prompt_tokens_details.cached_tokens` 与 DeepSeek 原生的 `prompt_cache_hit_tokens`。如果上游没有返回这两个字段,缓存读取量就是不可用状态,CCR 不会根据提示词长度进行猜测。
|
||||
6. OpenCode Go 的账户用量会使用导入的 `opencode-go` 密钥请求 `GET https://opencode.ai/zen/go/v1/usage`,把 5 小时、每周和每月窗口显示为剩余百分比。Zen 没有对应的账户用量端点,因此只会自动读取 Go。
|
||||
|
||||
如果 OpenCode Go 不可用,或添加供应商时没有出现 Go 导入项,请先在 OpenCode 中连接 `OpenCode Go` 并让 OpenCode 刷新本机模型缓存,然后回到 CCR 重新扫描。
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@ import { applyResponsesSessionAffinity, inboundMetadataUserId, resolveResponsesS
|
||||
import type { ResponsesSessionAffinityInput } from "@ccr/core/gateway/core-runtime/responses-session-affinity";
|
||||
import { applyResponsesToolStrictness } from "@ccr/core/gateway/core-runtime/responses-tool-strictness";
|
||||
import type { ResponsesToolStrictnessInput } from "@ccr/core/gateway/core-runtime/responses-tool-strictness";
|
||||
import {
|
||||
normalizeDeepSeekCacheUsage,
|
||||
normalizeDeepSeekCacheUsageStream
|
||||
} from "@ccr/core/gateway/features/opencode-cache-usage";
|
||||
import { sdkCompatibleTokenHeaderNames } from "@ccr/core/gateway/internal/shared";
|
||||
|
||||
type UpstreamRequest = {
|
||||
@@ -31,6 +35,22 @@ type ProviderPluginRequestInput = {
|
||||
upstreamRequest: UpstreamRequest;
|
||||
};
|
||||
|
||||
type ProviderPluginResponseInput = {
|
||||
targetProviderConfig?: {
|
||||
baseurl?: string;
|
||||
};
|
||||
upstreamPayload: unknown;
|
||||
upstreamRequest: UpstreamRequest;
|
||||
};
|
||||
|
||||
type GatewayPluginStreamInput = {
|
||||
targetProviderConfig?: {
|
||||
baseurl?: string;
|
||||
};
|
||||
upstreamRequest?: UpstreamRequest;
|
||||
upstreamResponse: Response;
|
||||
};
|
||||
|
||||
const ccrAuthHeaderNames = new Set([
|
||||
"x-auth-api-key-id",
|
||||
"x-auth-sub"
|
||||
@@ -109,6 +129,40 @@ function requestHeaderValue(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isOfficialOpenCodeGoUrl(value: string | undefined): boolean {
|
||||
if (!value) return false;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (
|
||||
url.protocol === "https:" &&
|
||||
url.hostname === "opencode.ai" &&
|
||||
(url.port === "" || url.port === "443") &&
|
||||
/^\/zen\/go\/v1(?:\/|$)/.test(url.pathname)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isOfficialOpenCodeGoChatUrl(value: string | undefined): boolean {
|
||||
if (!value || !isOfficialOpenCodeGoUrl(value)) return false;
|
||||
const url = new URL(value);
|
||||
return /^\/zen\/go\/v1\/chat\/completions\/?$/.test(url.pathname);
|
||||
}
|
||||
|
||||
function isOfficialOpenCodeGoChatRequest(
|
||||
upstreamUrl: string | undefined,
|
||||
configuredBaseUrl: string | undefined
|
||||
): boolean {
|
||||
if (isOfficialOpenCodeGoChatUrl(upstreamUrl)) return true;
|
||||
if (!upstreamUrl || !isOfficialOpenCodeGoUrl(configuredBaseUrl)) return false;
|
||||
try {
|
||||
return /^\/zen\/go\/v1\/chat\/completions\/?$/.test(new URL(upstreamUrl).pathname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes CCR-owned routing, authentication and observability metadata at the
|
||||
* final provider boundary. Provider credentials and non-CCR custom X-Auth
|
||||
@@ -244,13 +298,7 @@ export function createGatewayPlugin() {
|
||||
const apiKey = input.targetProviderConfig?.apikey?.trim();
|
||||
if (!upstreamRequest.headers["x-opencode-session"]?.trim()) {
|
||||
try {
|
||||
const url = new URL(upstreamRequest.url);
|
||||
if (
|
||||
url.protocol === "https:" &&
|
||||
url.hostname === "opencode.ai" &&
|
||||
(url.port === "" || url.port === "443") &&
|
||||
/^\/zen\/go\/v1(?:\/|$)/.test(url.pathname)
|
||||
) {
|
||||
if (isOfficialOpenCodeGoUrl(upstreamRequest.url)) {
|
||||
const explicitClientSession = sanitizeOpenCodeSessionHeaderValue(
|
||||
requestHeaderValue(input.request?.headers, "x-opencode-session")
|
||||
);
|
||||
@@ -283,6 +331,18 @@ export function createGatewayPlugin() {
|
||||
ok: true as const,
|
||||
value: applyMetaTokenFloor(upstreamRequest)
|
||||
};
|
||||
},
|
||||
transformResponse(input: ProviderPluginResponseInput) {
|
||||
const transformed = isOfficialOpenCodeGoChatRequest(
|
||||
input.upstreamRequest.url,
|
||||
input.targetProviderConfig?.baseurl
|
||||
)
|
||||
? normalizeDeepSeekCacheUsage(input.upstreamPayload)
|
||||
: { changed: false, value: input.upstreamPayload };
|
||||
return {
|
||||
ok: true as const,
|
||||
value: transformed.value
|
||||
};
|
||||
}
|
||||
}, {
|
||||
key: "ccr-responses-session-affinity",
|
||||
@@ -300,6 +360,16 @@ export function createGatewayPlugin() {
|
||||
value: applyResponsesToolStrictness(input)
|
||||
};
|
||||
}
|
||||
}],
|
||||
streamHooks: [{
|
||||
key: "ccr-opencode-go-cache-usage-stream",
|
||||
transformResponse(input: GatewayPluginStreamInput) {
|
||||
if (!isOfficialOpenCodeGoChatRequest(
|
||||
input.upstreamRequest?.url,
|
||||
input.targetProviderConfig?.baseurl
|
||||
)) return undefined;
|
||||
return normalizeDeepSeekCacheUsageStream(input.upstreamResponse);
|
||||
}
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Readable, Transform } from "node:stream";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import { isRecord } from "@ccr/core/gateway/internal/value";
|
||||
|
||||
export type CacheUsageNormalization = {
|
||||
changed: boolean;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* DeepSeek-compatible chat endpoints can report prompt cache reads in the
|
||||
* top-level `prompt_cache_hit_tokens` usage field. Normalize that field to the
|
||||
* OpenAI-compatible shape consumed by the gateway's protocol adapters.
|
||||
*
|
||||
* A standard `prompt_tokens_details.cached_tokens` value always wins. Cache
|
||||
* usage cannot be reconstructed when the upstream omits both fields.
|
||||
*/
|
||||
export function normalizeDeepSeekCacheUsage(value: unknown): CacheUsageNormalization {
|
||||
if (!isRecord(value) || !isRecord(value.usage)) {
|
||||
return { changed: false, value };
|
||||
}
|
||||
|
||||
const usage = value.usage;
|
||||
const promptTokenDetails = isRecord(usage.prompt_tokens_details)
|
||||
? usage.prompt_tokens_details
|
||||
: undefined;
|
||||
if (nonNegativeTokenCount(promptTokenDetails?.cached_tokens) !== undefined) {
|
||||
return { changed: false, value };
|
||||
}
|
||||
|
||||
const cacheReadTokens = nonNegativeTokenCount(usage.prompt_cache_hit_tokens);
|
||||
if (cacheReadTokens === undefined) {
|
||||
return { changed: false, value };
|
||||
}
|
||||
|
||||
return {
|
||||
changed: true,
|
||||
value: {
|
||||
...value,
|
||||
usage: {
|
||||
...usage,
|
||||
prompt_tokens_details: {
|
||||
...promptTokenDetails,
|
||||
cached_tokens: cacheReadTokens
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Rewrites cache usage in an OpenAI-compatible SSE response without buffering it. */
|
||||
export function normalizeDeepSeekCacheUsageStream(response: Response): Response | undefined {
|
||||
if (!response.body || !response.headers.get("content-type")?.toLowerCase().includes("text/event-stream")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const source = Readable.fromWeb(response.body as unknown as Parameters<typeof Readable.fromWeb>[0]);
|
||||
const normalized = source.pipe(new CacheUsageSseTransform());
|
||||
const headers = new Headers(response.headers);
|
||||
headers.delete("content-encoding");
|
||||
headers.delete("content-length");
|
||||
return new Response(Readable.toWeb(normalized) as ReadableStream<Uint8Array>, {
|
||||
headers,
|
||||
status: response.status,
|
||||
statusText: response.statusText
|
||||
});
|
||||
}
|
||||
|
||||
class CacheUsageSseTransform extends Transform {
|
||||
private readonly decoder = new StringDecoder("utf8");
|
||||
private pending = "";
|
||||
|
||||
override _transform(
|
||||
chunk: Buffer | string,
|
||||
_encoding: BufferEncoding,
|
||||
callback: (error?: Error | null) => void
|
||||
): void {
|
||||
try {
|
||||
this.pending += typeof chunk === "string" ? chunk : this.decoder.write(chunk);
|
||||
this.flushCompleteEvents();
|
||||
callback();
|
||||
} catch (error) {
|
||||
callback(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
override _flush(callback: (error?: Error | null) => void): void {
|
||||
try {
|
||||
this.pending += this.decoder.end();
|
||||
if (this.pending) this.push(rewriteSseEvent(this.pending));
|
||||
this.pending = "";
|
||||
callback();
|
||||
} catch (error) {
|
||||
callback(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
private flushCompleteEvents(): void {
|
||||
while (true) {
|
||||
const delimiter = /\r?\n\r?\n/.exec(this.pending);
|
||||
if (!delimiter || delimiter.index === undefined) return;
|
||||
const end = delimiter.index + delimiter[0].length;
|
||||
this.push(rewriteSseEvent(this.pending.slice(0, delimiter.index)) + delimiter[0]);
|
||||
this.pending = this.pending.slice(end);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rewriteSseEvent(event: string): string {
|
||||
const lines = event.split(/\r?\n/);
|
||||
const dataIndexes: number[] = [];
|
||||
const dataParts: string[] = [];
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const match = /^data:(?: )?(.*)$/.exec(lines[index] ?? "");
|
||||
if (!match) continue;
|
||||
dataIndexes.push(index);
|
||||
dataParts.push(match[1] ?? "");
|
||||
}
|
||||
if (dataIndexes.length === 0) return event;
|
||||
|
||||
const data = dataParts.join("\n").trim();
|
||||
if (!data || data === "[DONE]") return event;
|
||||
|
||||
try {
|
||||
const transformed = normalizeDeepSeekCacheUsage(JSON.parse(data));
|
||||
if (!transformed.changed) return event;
|
||||
const firstDataIndex = dataIndexes[0];
|
||||
const remainingDataIndexes = new Set(dataIndexes.slice(1));
|
||||
return lines
|
||||
.filter((_, index) => !remainingDataIndexes.has(index))
|
||||
.map((line, index) => index === firstDataIndex ? `data: ${JSON.stringify(transformed.value)}` : line)
|
||||
.join("\n");
|
||||
} catch {
|
||||
return event;
|
||||
}
|
||||
}
|
||||
|
||||
function nonNegativeTokenCount(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
||||
? Math.trunc(value)
|
||||
: undefined;
|
||||
}
|
||||
@@ -558,7 +558,8 @@ function readVisionUsage(response: Response | undefined, payload: unknown): Reco
|
||||
headerNumber("x-gateway-billing-cache-read-tokens") ??
|
||||
readNumber(usage.cache_read_tokens) ??
|
||||
readNumber(usage.cache_read_input_tokens) ??
|
||||
readNumber(inputDetails.cached_tokens),
|
||||
readNumber(inputDetails.cached_tokens) ??
|
||||
readNumber(usage.prompt_cache_hit_tokens),
|
||||
cache_write_tokens:
|
||||
headerNumber("x-gateway-billing-cache-write-tokens") ??
|
||||
readNumber(usage.cache_write_tokens) ??
|
||||
|
||||
@@ -6389,6 +6389,7 @@ function extractUsageSnapshot(payload: unknown): UsageSnapshot | undefined {
|
||||
inputDetails?.cached_tokens !== undefined ||
|
||||
inputDetails?.cache_creation_tokens !== undefined ||
|
||||
usage.cached_tokens !== undefined ||
|
||||
usage.prompt_cache_hit_tokens !== undefined ||
|
||||
usage.prompt_tokens !== undefined;
|
||||
const cacheCreation = isRecord(usage.cache_creation) ? usage.cache_creation : undefined;
|
||||
const cacheWrite5mTokens = asNumber(cacheCreation?.ephemeral_5m_input_tokens);
|
||||
@@ -6399,7 +6400,8 @@ function extractUsageSnapshot(payload: unknown): UsageSnapshot | undefined {
|
||||
asNumber(usage.cache_read_tokens) ??
|
||||
asNumber(usage.cache_read_input_tokens) ??
|
||||
asNumber(usage.cached_tokens) ??
|
||||
asNumber(inputDetails?.cached_tokens),
|
||||
asNumber(inputDetails?.cached_tokens) ??
|
||||
asNumber(usage.prompt_cache_hit_tokens),
|
||||
cacheWrite1hTokens,
|
||||
cacheWrite5mTokens,
|
||||
cacheWriteTokens:
|
||||
|
||||
@@ -1178,6 +1178,7 @@ function extractUsageSnapshot(payload: unknown): UsageSnapshot | undefined {
|
||||
inputDetails?.cached_tokens !== undefined ||
|
||||
inputDetails?.cache_creation_tokens !== undefined ||
|
||||
usage.cached_tokens !== undefined ||
|
||||
usage.prompt_cache_hit_tokens !== undefined ||
|
||||
usage.prompt_tokens !== undefined;
|
||||
const cacheCreation = isRecord(usage.cache_creation) ? usage.cache_creation : undefined;
|
||||
const cacheWrite5mTokens = asNumber(cacheCreation?.ephemeral_5m_input_tokens);
|
||||
@@ -1188,7 +1189,8 @@ function extractUsageSnapshot(payload: unknown): UsageSnapshot | undefined {
|
||||
asNumber(usage.cache_read_tokens) ??
|
||||
asNumber(usage.cache_read_input_tokens) ??
|
||||
asNumber(usage.cached_tokens) ??
|
||||
asNumber(inputDetails?.cached_tokens),
|
||||
asNumber(inputDetails?.cached_tokens) ??
|
||||
asNumber(usage.prompt_cache_hit_tokens),
|
||||
cacheWrite1hTokens,
|
||||
cacheWrite5mTokens,
|
||||
cacheWriteTokens:
|
||||
|
||||
@@ -60,7 +60,9 @@ test("#1791 actual gateway reports OpenCode Go cache reads to Anthropic clients"
|
||||
prompt_tokens: promptTokens,
|
||||
completion_tokens: 1,
|
||||
total_tokens: promptTokens + 1,
|
||||
prompt_tokens_details: { cached_tokens: cacheReadTokens }
|
||||
prompt_cache_hit_tokens: cacheReadTokens,
|
||||
prompt_cache_miss_tokens: promptTokens - cacheReadTokens,
|
||||
prompt_tokens_details: {}
|
||||
}
|
||||
})}\n\n`);
|
||||
response.end("data: [DONE]\n\n");
|
||||
@@ -78,7 +80,9 @@ test("#1791 actual gateway reports OpenCode Go cache reads to Anthropic clients"
|
||||
prompt_tokens: promptTokens,
|
||||
completion_tokens: 1,
|
||||
total_tokens: promptTokens + 1,
|
||||
prompt_tokens_details: { cached_tokens: cacheReadTokens }
|
||||
prompt_cache_hit_tokens: cacheReadTokens,
|
||||
prompt_cache_miss_tokens: promptTokens - cacheReadTokens,
|
||||
prompt_tokens_details: {}
|
||||
}
|
||||
}));
|
||||
});
|
||||
@@ -161,6 +165,7 @@ test("#1791 actual gateway reports OpenCode Go cache reads to Anthropic clients"
|
||||
};
|
||||
const headers = {
|
||||
"content-type": "application/json",
|
||||
"user-agent": "claude-cli/2.1.220",
|
||||
"x-claude-code-session-id": sessionId,
|
||||
"x-target-provider": "opencode-go-chat"
|
||||
};
|
||||
@@ -205,6 +210,7 @@ test("#1791 actual gateway reports OpenCode Go cache reads to Anthropic clients"
|
||||
assert.equal(captured.length, 3);
|
||||
for (const request of captured) {
|
||||
assert.equal(request.headers["x-opencode-session"], sessionId);
|
||||
assert.equal(request.headers["user-agent"], "claude-cli/2.1.220");
|
||||
}
|
||||
assert.equal(captured[0].path, "/zen/go/v1/chat/completions");
|
||||
assert.equal(captured[1].path, "/zen/go/v1/chat/completions");
|
||||
|
||||
@@ -696,6 +696,51 @@ test("RequestLogStore redacts secrets and records CCR metadata", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("#1791 RequestLogStore records DeepSeek-native prompt cache hits", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-deepseek-cache-test-"));
|
||||
let store;
|
||||
try {
|
||||
store = new RequestLogStore(path.join(dir, "request-logs.sqlite"));
|
||||
const startedAt = new Date().toISOString();
|
||||
await store.record({
|
||||
completedAt: startedAt,
|
||||
durationMs: 75,
|
||||
method: "POST",
|
||||
path: "/v1/messages",
|
||||
providerName: "OpenCode Go",
|
||||
providerProtocol: "openai_chat_completions",
|
||||
requestBody: Buffer.from(JSON.stringify({ model: "deepseek-v4.1-flash" }), "utf8"),
|
||||
requestHeaders: { "content-type": "application/json" },
|
||||
requestId: "request-log-deepseek-cache",
|
||||
responseBodyText: JSON.stringify({
|
||||
model: "deepseek-v4.1-flash",
|
||||
usage: {
|
||||
completion_tokens: 10,
|
||||
prompt_cache_hit_tokens: 800,
|
||||
prompt_cache_miss_tokens: 200,
|
||||
prompt_tokens: 1000,
|
||||
prompt_tokens_details: {},
|
||||
total_tokens: 1010
|
||||
}
|
||||
}),
|
||||
responseHeaders: { "content-type": "application/json" },
|
||||
startedAt,
|
||||
statusCode: 200,
|
||||
url: "http://127.0.0.1:3456/v1/messages"
|
||||
});
|
||||
|
||||
const page = await store.list({ pageSize: 25 });
|
||||
const detail = await store.getDetail({ id: page.items[0].id });
|
||||
assert.equal(detail?.inputTokens, 200);
|
||||
assert.equal(detail?.cacheReadTokens, 800);
|
||||
assert.equal(detail?.outputTokens, 10);
|
||||
assert.equal(detail?.totalTokens, 1010);
|
||||
} finally {
|
||||
await store?.close();
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("RequestLogStore marks interrupted successful-status streams as errors", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-interrupted-stream-test-"));
|
||||
let store;
|
||||
|
||||
@@ -358,6 +358,43 @@ test("UsageStore prices the routed upstream model when the response echoes a rul
|
||||
}
|
||||
});
|
||||
|
||||
test("#1791 UsageStore records DeepSeek-native prompt cache hits", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "ccr-usage-deepseek-cache-test-"));
|
||||
try {
|
||||
const store = new UsageStore(path.join(dir, "usage.sqlite"));
|
||||
await store.recordCapture({
|
||||
bodyText: JSON.stringify({
|
||||
model: "deepseek-v4.1-flash",
|
||||
usage: {
|
||||
completion_tokens: 10,
|
||||
prompt_cache_hit_tokens: 800,
|
||||
prompt_cache_miss_tokens: 200,
|
||||
prompt_tokens: 1000,
|
||||
prompt_tokens_details: {},
|
||||
total_tokens: 1010
|
||||
}
|
||||
}),
|
||||
durationMs: 40,
|
||||
fallbackModel: "deepseek-v4.1-flash",
|
||||
method: "POST",
|
||||
path: "/v1/messages",
|
||||
providerName: "OpenCode Go",
|
||||
providerProtocol: "openai_chat_completions",
|
||||
requestId: "deepseek-native-cache-usage",
|
||||
responseHeaders: new Headers({ "content-type": "application/json" }),
|
||||
statusCode: 200
|
||||
});
|
||||
|
||||
const stats = await store.getStats("today", { includeProxy: true });
|
||||
assert.equal(stats.totals.inputTokens, 200);
|
||||
assert.equal(stats.totals.cacheTokens, 800);
|
||||
assert.equal(stats.totals.outputTokens, 10);
|
||||
assert.equal(stats.totals.totalTokens, 1010);
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("UsageStore attributes Claude App encoded response model IDs to the routed upstream model", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "ccr-usage-claude-app-encoded-model-test-"));
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
normalizeDeepSeekCacheUsage,
|
||||
normalizeDeepSeekCacheUsageStream
|
||||
} from "@ccr/core/gateway/features/opencode-cache-usage.ts";
|
||||
|
||||
test("#1791 normalizes DeepSeek-native cache hits without overriding standard usage", () => {
|
||||
const native = {
|
||||
choices: [],
|
||||
usage: {
|
||||
prompt_cache_hit_tokens: 800,
|
||||
prompt_cache_miss_tokens: 200,
|
||||
prompt_tokens: 1000,
|
||||
prompt_tokens_details: {}
|
||||
}
|
||||
};
|
||||
const normalized = normalizeDeepSeekCacheUsage(native);
|
||||
assert.equal(normalized.changed, true);
|
||||
assert.equal(normalized.value.usage.prompt_tokens_details.cached_tokens, 800);
|
||||
assert.deepEqual(native.usage.prompt_tokens_details, {});
|
||||
|
||||
const standard = {
|
||||
usage: {
|
||||
prompt_cache_hit_tokens: 800,
|
||||
prompt_tokens_details: { cached_tokens: 700 }
|
||||
}
|
||||
};
|
||||
assert.deepEqual(normalizeDeepSeekCacheUsage(standard), {
|
||||
changed: false,
|
||||
value: standard
|
||||
});
|
||||
|
||||
for (const value of [
|
||||
{ usage: { prompt_tokens_details: {} } },
|
||||
{ usage: { prompt_cache_hit_tokens: -1 } },
|
||||
{ usage: { prompt_cache_hit_tokens: "800" } },
|
||||
{ usage: null }
|
||||
]) {
|
||||
assert.equal(normalizeDeepSeekCacheUsage(value).changed, false);
|
||||
}
|
||||
});
|
||||
|
||||
test("#1791 normalizes DeepSeek-native cache hits in split SSE chunks", async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode("data: {\"choices\":[],\"usage\":{\"prompt_tokens\":1000,"));
|
||||
controller.enqueue(encoder.encode("\"prompt_cache_hit_tokens\":800,\"prompt_tokens_details\":{}}}\n\n"));
|
||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
||||
controller.close();
|
||||
}
|
||||
});
|
||||
const response = new Response(body, {
|
||||
headers: {
|
||||
"content-length": "1",
|
||||
"content-type": "text/event-stream"
|
||||
}
|
||||
});
|
||||
|
||||
const normalized = normalizeDeepSeekCacheUsageStream(response);
|
||||
assert.ok(normalized);
|
||||
assert.equal(normalized.headers.has("content-length"), false);
|
||||
const text = await normalized.text();
|
||||
const firstPayload = JSON.parse(text.split("\n")[0].slice("data: ".length));
|
||||
assert.equal(firstPayload.usage.prompt_tokens_details.cached_tokens, 800);
|
||||
assert.match(text, /data: \[DONE\]/);
|
||||
});
|
||||
|
||||
test("#1791 leaves non-SSE responses untouched", () => {
|
||||
const response = new Response("{}", { headers: { "content-type": "application/json" } });
|
||||
assert.equal(normalizeDeepSeekCacheUsageStream(response), undefined);
|
||||
});
|
||||
@@ -160,3 +160,28 @@ test("OpenCode Go session injection is scoped to the official Go endpoint", () =
|
||||
}).value;
|
||||
assert.equal(clientSession.headers["x-opencode-session"], "client-session");
|
||||
});
|
||||
|
||||
test("#1791 DeepSeek-native cache usage normalization is scoped to official OpenCode Go Chat", () => {
|
||||
const [hook] = createGatewayPlugin().providerHooks;
|
||||
const payload = {
|
||||
usage: {
|
||||
prompt_cache_hit_tokens: 800,
|
||||
prompt_tokens_details: {}
|
||||
}
|
||||
};
|
||||
|
||||
const official = hook.transformResponse({
|
||||
targetProviderConfig: { baseurl: "https://opencode.ai/zen/go/v1" },
|
||||
upstreamPayload: payload,
|
||||
upstreamRequest: { body: {}, headers: {}, url: "http://127.0.0.1/zen/go/v1/chat/completions" }
|
||||
}).value;
|
||||
assert.equal(official.usage.prompt_tokens_details.cached_tokens, 800);
|
||||
|
||||
const unrelated = hook.transformResponse({
|
||||
targetProviderConfig: { baseurl: "https://other.test/v1" },
|
||||
upstreamPayload: payload,
|
||||
upstreamRequest: { body: {}, headers: {}, url: "https://other.test/v1/chat/completions" }
|
||||
}).value;
|
||||
assert.equal(unrelated, payload);
|
||||
assert.deepEqual(unrelated.usage.prompt_tokens_details, {});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user