@velum-labs/routekit-gateway 1.5.1 → 1.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/responses-usage.d.ts +5 -2
- package/dist/adapters/responses-usage.js +15 -6
- package/dist/test/anthropic-stream-usage.test.d.ts +1 -0
- package/dist/test/anthropic-stream-usage.test.js +172 -0
- package/dist/test/claude-responses-refusal.test.d.ts +1 -0
- package/dist/test/claude-responses-refusal.test.js +107 -0
- package/dist/test/responses-usage.test.d.ts +1 -0
- package/dist/test/responses-usage.test.js +213 -0
- package/package.json +8 -8
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { type Usage } from "@velum-labs/routekit-contracts/protocol-ir";
|
|
2
2
|
/**
|
|
3
3
|
* Translate Chat Completions token accounting to the Responses vocabulary.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Codex requires cached_tokens and reasoning_tokens when their optional detail
|
|
5
|
+
* objects are present. Emit details only when those counters are known, so an
|
|
6
|
+
* empty provider object cannot break completion parsing and unknown accounting
|
|
7
|
+
* is not replaced by a measured zero. Complete detail objects retain compatible
|
|
8
|
+
* current and future counters.
|
|
6
9
|
*/
|
|
7
10
|
export declare function chatUsageToResponses(usage: Usage | null | undefined): Record<string, unknown> | null;
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { extensionValue } from "@velum-labs/routekit-contracts/protocol-ir";
|
|
2
2
|
/**
|
|
3
3
|
* Translate Chat Completions token accounting to the Responses vocabulary.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Codex requires cached_tokens and reasoning_tokens when their optional detail
|
|
5
|
+
* objects are present. Emit details only when those counters are known, so an
|
|
6
|
+
* empty provider object cannot break completion parsing and unknown accounting
|
|
7
|
+
* is not replaced by a measured zero. Complete detail objects retain compatible
|
|
8
|
+
* current and future counters.
|
|
6
9
|
*/
|
|
7
10
|
export function chatUsageToResponses(usage) {
|
|
8
11
|
if (usage == null)
|
|
@@ -16,18 +19,24 @@ export function chatUsageToResponses(usage) {
|
|
|
16
19
|
const details = extensionValue(usage.extensions, "openai.chat.usage-details");
|
|
17
20
|
const inputDetails = details?.promptTokens;
|
|
18
21
|
const outputDetails = details?.completionTokens;
|
|
22
|
+
const cachedTokens = inputDetails?.cached_tokens ?? usage.cachedInputTokens;
|
|
23
|
+
const reasoningTokens = outputDetails?.reasoning_tokens;
|
|
19
24
|
if (inputTokens === undefined &&
|
|
20
25
|
outputTokens === undefined &&
|
|
21
26
|
totalTokens === undefined &&
|
|
22
|
-
|
|
23
|
-
|
|
27
|
+
cachedTokens == null &&
|
|
28
|
+
reasoningTokens == null) {
|
|
24
29
|
return null;
|
|
25
30
|
}
|
|
26
31
|
return {
|
|
27
32
|
...(inputTokens !== undefined ? { input_tokens: inputTokens } : {}),
|
|
28
33
|
...(outputTokens !== undefined ? { output_tokens: outputTokens } : {}),
|
|
29
34
|
...(totalTokens !== undefined ? { total_tokens: totalTokens } : {}),
|
|
30
|
-
...(
|
|
31
|
-
|
|
35
|
+
...(cachedTokens != null
|
|
36
|
+
? { input_tokens_details: { ...inputDetails, cached_tokens: cachedTokens } }
|
|
37
|
+
: {}),
|
|
38
|
+
...(reasoningTokens != null
|
|
39
|
+
? { output_tokens_details: { ...outputDetails, reasoning_tokens: reasoningTokens } }
|
|
40
|
+
: {})
|
|
32
41
|
};
|
|
33
42
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { AnthropicBackend, decodeBufferedSse } from "@velum-labs/routekit-provider-core";
|
|
4
|
+
import { Effect } from "effect";
|
|
5
|
+
import { startGateway } from "../gateway-service.js";
|
|
6
|
+
import { parseUsageFromSse } from "../observability/cost.js";
|
|
7
|
+
import { asTransport, sse } from "./provider-backends-fixtures.js";
|
|
8
|
+
const MODEL = "claude-stream-usage-test";
|
|
9
|
+
const INITIAL_USAGE = {
|
|
10
|
+
input_tokens: 64,
|
|
11
|
+
output_tokens: 0,
|
|
12
|
+
cache_read_input_tokens: 8,
|
|
13
|
+
cache_creation_input_tokens: 2
|
|
14
|
+
};
|
|
15
|
+
const cases = [
|
|
16
|
+
{ name: "split counters", initial: INITIAL_USAGE, terminal: { output_tokens: 26 }, total: 100 },
|
|
17
|
+
{
|
|
18
|
+
name: "full terminal counters",
|
|
19
|
+
initial: INITIAL_USAGE,
|
|
20
|
+
terminal: { ...INITIAL_USAGE, output_tokens: 26 },
|
|
21
|
+
total: 100
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: "terminal null input and cache counters",
|
|
25
|
+
initial: INITIAL_USAGE,
|
|
26
|
+
terminal: {
|
|
27
|
+
input_tokens: null,
|
|
28
|
+
output_tokens: 26,
|
|
29
|
+
cache_read_input_tokens: null,
|
|
30
|
+
cache_creation_input_tokens: null
|
|
31
|
+
},
|
|
32
|
+
total: 100
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: "provider-reported initial total",
|
|
36
|
+
initial: { ...INITIAL_USAGE, total_tokens: 123 },
|
|
37
|
+
terminal: { output_tokens: 26 },
|
|
38
|
+
total: 123
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: "provider-reported terminal total replaces the initial total",
|
|
42
|
+
initial: { ...INITIAL_USAGE, total_tokens: 123 },
|
|
43
|
+
terminal: { output_tokens: 26, total_tokens: 124 },
|
|
44
|
+
total: 124
|
|
45
|
+
}
|
|
46
|
+
];
|
|
47
|
+
function nativeStream(initial, terminal) {
|
|
48
|
+
return sse([
|
|
49
|
+
{
|
|
50
|
+
event: "message_start",
|
|
51
|
+
data: {
|
|
52
|
+
type: "message_start",
|
|
53
|
+
message: {
|
|
54
|
+
id: "msg_stream_usage",
|
|
55
|
+
type: "message",
|
|
56
|
+
role: "assistant",
|
|
57
|
+
model: MODEL,
|
|
58
|
+
content: [],
|
|
59
|
+
usage: initial
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
event: "content_block_start",
|
|
65
|
+
data: {
|
|
66
|
+
type: "content_block_start",
|
|
67
|
+
index: 0,
|
|
68
|
+
content_block: { type: "text", text: "" }
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
event: "content_block_delta",
|
|
73
|
+
data: {
|
|
74
|
+
type: "content_block_delta",
|
|
75
|
+
index: 0,
|
|
76
|
+
delta: { type: "text_delta", text: "STREAM_USAGE_OK" }
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
{ event: "content_block_stop", data: { type: "content_block_stop", index: 0 } },
|
|
80
|
+
{
|
|
81
|
+
event: "message_delta",
|
|
82
|
+
data: { type: "message_delta", delta: { stop_reason: "end_turn" }, usage: terminal }
|
|
83
|
+
},
|
|
84
|
+
{ event: "message_stop", data: { type: "message_stop" } }
|
|
85
|
+
]);
|
|
86
|
+
}
|
|
87
|
+
for (const endpoint of ["chat/completions", "responses"]) {
|
|
88
|
+
for (const fixture of cases) {
|
|
89
|
+
test(`Anthropic ${endpoint} streaming usage keeps ${fixture.name} complete and request-local`, async () => {
|
|
90
|
+
let submissions = 0;
|
|
91
|
+
const backend = new AnthropicBackend({
|
|
92
|
+
baseUrl: "https://api.anthropic.test/v1",
|
|
93
|
+
apiKey: "test-only",
|
|
94
|
+
defaultModel: MODEL,
|
|
95
|
+
transport: asTransport(async (_url, init) => {
|
|
96
|
+
const body = JSON.parse(String(init.body));
|
|
97
|
+
assert.equal(body.stream, true);
|
|
98
|
+
submissions += 1;
|
|
99
|
+
assert.ok(submissions <= 2, "each gateway request must submit upstream exactly once");
|
|
100
|
+
return submissions === 1
|
|
101
|
+
? nativeStream(fixture.initial, fixture.terminal)
|
|
102
|
+
: nativeStream({ input_tokens: 5, output_tokens: 0 }, { output_tokens: 3 });
|
|
103
|
+
})
|
|
104
|
+
});
|
|
105
|
+
const gateway = await startGateway({ backend });
|
|
106
|
+
try {
|
|
107
|
+
for (const requestNumber of [1, 2]) {
|
|
108
|
+
const response = await fetch(`${gateway.url()}/v1/${endpoint}`, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: { "content-type": "application/json" },
|
|
111
|
+
body: JSON.stringify({
|
|
112
|
+
model: MODEL,
|
|
113
|
+
stream: true,
|
|
114
|
+
...(endpoint === "responses"
|
|
115
|
+
? { input: "hello" }
|
|
116
|
+
: {
|
|
117
|
+
messages: [{ role: "user", content: "hello" }],
|
|
118
|
+
stream_options: { include_usage: true }
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
});
|
|
122
|
+
assert.equal(response.status, 200);
|
|
123
|
+
const wire = await response.text();
|
|
124
|
+
assert.match(wire, /STREAM_USAGE_OK/);
|
|
125
|
+
const frames = decodeBufferedSse(wire)
|
|
126
|
+
.filter((event) => event.data.trim() !== "[DONE]")
|
|
127
|
+
.map((event) => JSON.parse(event.data));
|
|
128
|
+
let finalUsage;
|
|
129
|
+
if (endpoint === "responses") {
|
|
130
|
+
const completed = frames.filter((frame) => frame.type === "response.completed");
|
|
131
|
+
assert.equal(completed.length, 1);
|
|
132
|
+
assert.doesNotMatch(wire, /event: response\.(incomplete|failed)/);
|
|
133
|
+
const finalResponse = completed[0]?.response;
|
|
134
|
+
assert.equal(finalResponse.status, "completed");
|
|
135
|
+
finalUsage = finalResponse.usage;
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
const terminal = frames.filter((frame) => frame.choices.some((choice) => choice.finish_reason != null));
|
|
139
|
+
assert.equal(terminal.length, 1);
|
|
140
|
+
finalUsage = terminal[0]?.usage;
|
|
141
|
+
const initialUsage = frames.find((frame) => frame.usage != null)?.usage;
|
|
142
|
+
assert.equal(initialUsage?.total_tokens, requestNumber === 1
|
|
143
|
+
? "total_tokens" in fixture.initial
|
|
144
|
+
? fixture.initial.total_tokens
|
|
145
|
+
: 74
|
|
146
|
+
: 5, "each usage snapshot reports the current cumulative total");
|
|
147
|
+
}
|
|
148
|
+
const firstRequest = requestNumber === 1;
|
|
149
|
+
const input = firstRequest ? 74 : 5;
|
|
150
|
+
const output = firstRequest ? 26 : 3;
|
|
151
|
+
const total = firstRequest ? fixture.total : 8;
|
|
152
|
+
assert.equal(finalUsage[endpoint === "responses" ? "input_tokens" : "prompt_tokens"], input);
|
|
153
|
+
assert.equal(finalUsage[endpoint === "responses" ? "output_tokens" : "completion_tokens"], output);
|
|
154
|
+
assert.equal(finalUsage.total_tokens, total);
|
|
155
|
+
const observed = parseUsageFromSse(wire);
|
|
156
|
+
assert.equal(observed?.promptTokens, input);
|
|
157
|
+
assert.equal(observed?.completionTokens, output);
|
|
158
|
+
assert.equal(observed?.totalTokens, total);
|
|
159
|
+
assert.equal(observed?.cachedInputTokens, firstRequest ? 8 : undefined);
|
|
160
|
+
assert.equal(observed?.reasoningTokens, undefined);
|
|
161
|
+
if (endpoint === "chat/completions") {
|
|
162
|
+
assert.equal(observed?.cacheCreationInputTokens, firstRequest ? 2 : undefined);
|
|
163
|
+
}
|
|
164
|
+
assert.equal(submissions, requestNumber);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
await Effect.runPromise(gateway.close);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { AnthropicBackend } from "@velum-labs/routekit-provider-core";
|
|
4
|
+
import { runRouteKitEffect } from "@velum-labs/routekit-runtime/effect";
|
|
5
|
+
import { Effect } from "effect";
|
|
6
|
+
import { handleResponses } from "../adapters/responses.js";
|
|
7
|
+
const MODEL = "claude-fable-5-1";
|
|
8
|
+
const TOOL = "staging_subscription_echo";
|
|
9
|
+
const PARTIAL_ARGUMENTS = { marker: "RO" };
|
|
10
|
+
const COMPLETE_MARKER = "ROUTEKIT_CLAUDE_TOOL_OK";
|
|
11
|
+
function refusalResponse(stream) {
|
|
12
|
+
const tool = { type: "tool_use", id: "toolu_refused", name: TOOL };
|
|
13
|
+
const usage = { input_tokens: 467, output_tokens: 36 };
|
|
14
|
+
if (!stream) {
|
|
15
|
+
return Response.json({
|
|
16
|
+
id: "msg_refused",
|
|
17
|
+
type: "message",
|
|
18
|
+
role: "assistant",
|
|
19
|
+
model: MODEL,
|
|
20
|
+
content: [{ ...tool, input: PARTIAL_ARGUMENTS }],
|
|
21
|
+
stop_reason: "refusal",
|
|
22
|
+
usage
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
const events = [
|
|
26
|
+
{
|
|
27
|
+
type: "message_start",
|
|
28
|
+
message: { id: "msg_refused", content: [], usage: { input_tokens: 467, output_tokens: 0 } }
|
|
29
|
+
},
|
|
30
|
+
{ type: "content_block_start", index: 0, content_block: { ...tool, input: {} } },
|
|
31
|
+
{
|
|
32
|
+
type: "content_block_delta",
|
|
33
|
+
index: 0,
|
|
34
|
+
delta: { type: "input_json_delta", partial_json: '{"marker":"' }
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
type: "content_block_delta",
|
|
38
|
+
index: 0,
|
|
39
|
+
delta: { type: "input_json_delta", partial_json: 'RO"}' }
|
|
40
|
+
},
|
|
41
|
+
{ type: "content_block_stop", index: 0 },
|
|
42
|
+
{ type: "message_delta", delta: { stop_reason: "refusal" }, usage },
|
|
43
|
+
{ type: "message_stop" }
|
|
44
|
+
];
|
|
45
|
+
return new Response(events.map((event) => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join(""), { headers: { "content-type": "text/event-stream" } });
|
|
46
|
+
}
|
|
47
|
+
for (const stream of [false, true]) {
|
|
48
|
+
test(`Claude ${stream ? "streamed" : "buffered"} refusal remains an incomplete Responses tool turn`, async () => {
|
|
49
|
+
const submitted = [];
|
|
50
|
+
const backend = new AnthropicBackend({
|
|
51
|
+
baseUrl: "https://anthropic.test/v1",
|
|
52
|
+
apiKey: "test-only",
|
|
53
|
+
defaultModel: MODEL,
|
|
54
|
+
transport: (_url, init) => {
|
|
55
|
+
submitted.push(JSON.parse(String(init.body)));
|
|
56
|
+
return Effect.succeed(refusalResponse(stream));
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
const response = await runRouteKitEffect(handleResponses(backend, {
|
|
60
|
+
model: MODEL,
|
|
61
|
+
stream,
|
|
62
|
+
input: `Call ${TOOL} with marker ${COMPLETE_MARKER}.`,
|
|
63
|
+
tool_choice: "auto",
|
|
64
|
+
tools: [
|
|
65
|
+
{
|
|
66
|
+
type: "function",
|
|
67
|
+
name: TOOL,
|
|
68
|
+
parameters: {
|
|
69
|
+
type: "object",
|
|
70
|
+
properties: { marker: { type: "string" } },
|
|
71
|
+
required: ["marker"]
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
]
|
|
75
|
+
}));
|
|
76
|
+
assert.equal(response.status, 200, "provider refusal is a terminal model outcome");
|
|
77
|
+
assert.equal(submitted.length, 1, "the gateway must not issue a follow-up call after refusal");
|
|
78
|
+
assert.equal(submitted[0]?.stream, stream);
|
|
79
|
+
assert.deepEqual(submitted[0]?.tool_choice, { type: "auto" });
|
|
80
|
+
let terminal;
|
|
81
|
+
if (stream) {
|
|
82
|
+
const wire = await response.text();
|
|
83
|
+
const events = wire
|
|
84
|
+
.split("\n\n")
|
|
85
|
+
.flatMap((frame) => {
|
|
86
|
+
const data = frame.split("\n").find((line) => line.startsWith("data: "));
|
|
87
|
+
return data === undefined ? [] : [JSON.parse(data.slice(6))];
|
|
88
|
+
});
|
|
89
|
+
assert.equal(events.filter((event) => event.type === "response.completed").length, 0);
|
|
90
|
+
const incomplete = events.filter((event) => event.type === "response.incomplete");
|
|
91
|
+
assert.equal(incomplete.length, 1);
|
|
92
|
+
terminal = incomplete[0]?.response;
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
terminal = (await response.json());
|
|
96
|
+
}
|
|
97
|
+
assert.equal(terminal.status, "incomplete");
|
|
98
|
+
assert.deepEqual(terminal.incomplete_details, { reason: "content_filter" });
|
|
99
|
+
const output = terminal.output;
|
|
100
|
+
assert.equal(output.length, 1);
|
|
101
|
+
assert.equal(output[0]?.type, "function_call");
|
|
102
|
+
assert.equal(output[0]?.call_id, "toolu_refused");
|
|
103
|
+
assert.equal(output[0]?.name, TOOL);
|
|
104
|
+
assert.deepEqual(JSON.parse(String(output[0]?.arguments)), PARTIAL_ARGUMENTS);
|
|
105
|
+
assert.notEqual(JSON.parse(String(output[0]?.arguments)).marker, COMPLETE_MARKER);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { AnthropicBackend } from "@velum-labs/routekit-provider-core";
|
|
4
|
+
import { Effect } from "effect";
|
|
5
|
+
import { chatUsageToResponses } from "../adapters/responses-usage.js";
|
|
6
|
+
import { startGateway } from "../gateway-service.js";
|
|
7
|
+
import { parseUsage, parseUsageFromSse } from "../observability/cost.js";
|
|
8
|
+
import { asTransport, sse } from "./provider-backends-fixtures.js";
|
|
9
|
+
test("generated Responses usage omits unknown details without changing provider accounting", () => {
|
|
10
|
+
for (const details of [
|
|
11
|
+
undefined,
|
|
12
|
+
{ promptTokens: {}, completionTokens: {} },
|
|
13
|
+
{ promptTokens: { audio_tokens: 1 }, completionTokens: { accepted_prediction_tokens: 2 } }
|
|
14
|
+
]) {
|
|
15
|
+
const usage = Object.freeze({
|
|
16
|
+
inputTokens: 64,
|
|
17
|
+
outputTokens: 26,
|
|
18
|
+
...(details === undefined
|
|
19
|
+
? {}
|
|
20
|
+
: {
|
|
21
|
+
extensions: Object.freeze([
|
|
22
|
+
{
|
|
23
|
+
namespace: "openai.chat.usage-details",
|
|
24
|
+
value: Object.freeze({
|
|
25
|
+
promptTokens: Object.freeze(details.promptTokens),
|
|
26
|
+
completionTokens: Object.freeze(details.completionTokens)
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
])
|
|
30
|
+
})
|
|
31
|
+
});
|
|
32
|
+
const before = structuredClone(usage);
|
|
33
|
+
const wire = chatUsageToResponses(usage);
|
|
34
|
+
assert.equal(wire?.input_tokens, 64);
|
|
35
|
+
assert.equal(wire?.output_tokens, 26);
|
|
36
|
+
assert.equal(wire?.total_tokens, 90);
|
|
37
|
+
assert.equal(wire?.input_tokens_details, undefined);
|
|
38
|
+
assert.equal(wire?.output_tokens_details, undefined);
|
|
39
|
+
assert.deepEqual(parseUsage(wire), { promptTokens: 64, completionTokens: 26, totalTokens: 90 });
|
|
40
|
+
assert.deepEqual(usage, before);
|
|
41
|
+
}
|
|
42
|
+
assert.equal(chatUsageToResponses(undefined), null);
|
|
43
|
+
assert.equal(chatUsageToResponses(null), null);
|
|
44
|
+
assert.equal(chatUsageToResponses({}), null);
|
|
45
|
+
assert.equal(chatUsageToResponses({
|
|
46
|
+
extensions: [
|
|
47
|
+
{
|
|
48
|
+
namespace: "openai.chat.usage-details",
|
|
49
|
+
value: { promptTokens: {}, completionTokens: { accepted_prediction_tokens: 2 } }
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
}), null);
|
|
53
|
+
});
|
|
54
|
+
test("generated Responses usage preserves measured details and cached-input accounting", () => {
|
|
55
|
+
const usage = {
|
|
56
|
+
inputTokens: 12,
|
|
57
|
+
outputTokens: 7,
|
|
58
|
+
cachedInputTokens: 6,
|
|
59
|
+
extensions: [
|
|
60
|
+
{
|
|
61
|
+
namespace: "openai.chat.usage-details",
|
|
62
|
+
value: {
|
|
63
|
+
promptTokens: { cached_tokens: 8, cache_write_tokens: 2, audio_tokens: 1 },
|
|
64
|
+
completionTokens: { reasoning_tokens: 5, accepted_prediction_tokens: 2 }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
};
|
|
69
|
+
assert.deepEqual(chatUsageToResponses(usage), {
|
|
70
|
+
input_tokens: 12,
|
|
71
|
+
output_tokens: 7,
|
|
72
|
+
total_tokens: 19,
|
|
73
|
+
input_tokens_details: { cached_tokens: 8, cache_write_tokens: 2, audio_tokens: 1 },
|
|
74
|
+
output_tokens_details: { reasoning_tokens: 5, accepted_prediction_tokens: 2 }
|
|
75
|
+
});
|
|
76
|
+
assert.deepEqual(parseUsage(chatUsageToResponses(usage)), {
|
|
77
|
+
promptTokens: 12,
|
|
78
|
+
cachedInputTokens: 8,
|
|
79
|
+
completionTokens: 7,
|
|
80
|
+
reasoningTokens: 5,
|
|
81
|
+
totalTokens: 19
|
|
82
|
+
});
|
|
83
|
+
assert.deepEqual(chatUsageToResponses({
|
|
84
|
+
inputTokens: 12,
|
|
85
|
+
outputTokens: 7,
|
|
86
|
+
extensions: [
|
|
87
|
+
{
|
|
88
|
+
namespace: "openai.chat.usage-details",
|
|
89
|
+
value: { promptTokens: { cached_tokens: 0 }, completionTokens: { reasoning_tokens: 0 } }
|
|
90
|
+
}
|
|
91
|
+
]
|
|
92
|
+
})?.output_tokens_details, { reasoning_tokens: 0 });
|
|
93
|
+
assert.deepEqual(chatUsageToResponses({ inputTokens: 12, outputTokens: 7, cachedInputTokens: 6 })
|
|
94
|
+
?.input_tokens_details, {
|
|
95
|
+
cached_tokens: 6
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
for (const { stream, providerTotal } of [
|
|
99
|
+
{ stream: false, providerTotal: undefined },
|
|
100
|
+
{ stream: true, providerTotal: undefined },
|
|
101
|
+
{ stream: true, providerTotal: 123 }
|
|
102
|
+
]) {
|
|
103
|
+
test(`Claude Messages ${stream ? "SSE" : "JSON"} completes with Codex-compatible Responses usage${providerTotal === undefined ? "" : " and a provider-reported total"}`, async () => {
|
|
104
|
+
let submissions = 0;
|
|
105
|
+
const backend = new AnthropicBackend({
|
|
106
|
+
baseUrl: "https://api.anthropic.test/v1",
|
|
107
|
+
apiKey: "unused",
|
|
108
|
+
defaultModel: "claude-fable-5.1",
|
|
109
|
+
transport: asTransport(async (_url, init) => {
|
|
110
|
+
submissions += 1;
|
|
111
|
+
const body = JSON.parse(String(init.body));
|
|
112
|
+
assert.equal(body.stream === true, stream);
|
|
113
|
+
return stream
|
|
114
|
+
? sse([
|
|
115
|
+
{
|
|
116
|
+
event: "message_start",
|
|
117
|
+
data: {
|
|
118
|
+
type: "message_start",
|
|
119
|
+
message: {
|
|
120
|
+
id: "msg_claude_usage",
|
|
121
|
+
type: "message",
|
|
122
|
+
role: "assistant",
|
|
123
|
+
model: "claude-fable-5.1",
|
|
124
|
+
content: [],
|
|
125
|
+
usage: {
|
|
126
|
+
input_tokens: 64,
|
|
127
|
+
output_tokens: 0,
|
|
128
|
+
cache_read_input_tokens: 0,
|
|
129
|
+
cache_creation_input_tokens: 0,
|
|
130
|
+
...(providerTotal === undefined ? {} : { total_tokens: providerTotal })
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
event: "content_block_start",
|
|
137
|
+
data: {
|
|
138
|
+
type: "content_block_start",
|
|
139
|
+
index: 0,
|
|
140
|
+
content_block: { type: "text", text: "" }
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
event: "content_block_delta",
|
|
145
|
+
data: {
|
|
146
|
+
type: "content_block_delta",
|
|
147
|
+
index: 0,
|
|
148
|
+
delta: { type: "text_delta", text: "CLAUDE_USAGE_OK" }
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
{ event: "content_block_stop", data: { type: "content_block_stop", index: 0 } },
|
|
152
|
+
{
|
|
153
|
+
event: "message_delta",
|
|
154
|
+
data: {
|
|
155
|
+
type: "message_delta",
|
|
156
|
+
delta: { stop_reason: "end_turn" },
|
|
157
|
+
usage: { output_tokens: 26 }
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
{ event: "message_stop", data: { type: "message_stop" } }
|
|
161
|
+
])
|
|
162
|
+
: Response.json({
|
|
163
|
+
id: "msg_claude_usage",
|
|
164
|
+
type: "message",
|
|
165
|
+
role: "assistant",
|
|
166
|
+
model: "claude-fable-5.1",
|
|
167
|
+
content: [{ type: "text", text: "CLAUDE_USAGE_OK" }],
|
|
168
|
+
stop_reason: "end_turn",
|
|
169
|
+
usage: { input_tokens: 64, output_tokens: 26, cache_read_input_tokens: 0 }
|
|
170
|
+
});
|
|
171
|
+
})
|
|
172
|
+
});
|
|
173
|
+
const gateway = await startGateway({ backend });
|
|
174
|
+
try {
|
|
175
|
+
const response = await fetch(`${gateway.url()}/v1/responses`, {
|
|
176
|
+
method: "POST",
|
|
177
|
+
headers: { "content-type": "application/json" },
|
|
178
|
+
body: JSON.stringify({ input: "hello", stream })
|
|
179
|
+
});
|
|
180
|
+
assert.equal(response.status, 200);
|
|
181
|
+
const text = await response.text();
|
|
182
|
+
assert.match(text, /CLAUDE_USAGE_OK/);
|
|
183
|
+
const terminal = stream
|
|
184
|
+
? text.split("\n\n").filter((event) => event.startsWith("event: response.completed"))
|
|
185
|
+
: [];
|
|
186
|
+
if (stream) {
|
|
187
|
+
assert.equal(terminal.length, 1);
|
|
188
|
+
assert.doesNotMatch(text, /event: response\.(incomplete|failed)/);
|
|
189
|
+
}
|
|
190
|
+
const completed = stream
|
|
191
|
+
? JSON.parse(terminal[0].slice(terminal[0].indexOf("data:") + 5)).response
|
|
192
|
+
: JSON.parse(text);
|
|
193
|
+
assert.equal(completed.status, "completed");
|
|
194
|
+
const usage = completed.usage;
|
|
195
|
+
assert.equal(usage.input_tokens, 64);
|
|
196
|
+
assert.equal(usage.output_tokens, 26);
|
|
197
|
+
assert.equal(usage.total_tokens, providerTotal ?? 90);
|
|
198
|
+
assert.deepEqual(usage.input_tokens_details, { cached_tokens: 0 });
|
|
199
|
+
assert.equal(usage.output_tokens_details, undefined);
|
|
200
|
+
const observed = stream ? parseUsageFromSse(text) : parseUsage(usage);
|
|
201
|
+
assert.deepEqual(observed, {
|
|
202
|
+
promptTokens: 64,
|
|
203
|
+
cachedInputTokens: 0,
|
|
204
|
+
completionTokens: 26,
|
|
205
|
+
totalTokens: providerTotal ?? 90
|
|
206
|
+
});
|
|
207
|
+
assert.equal(submissions, 1);
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
await Effect.runPromise(gateway.close);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@velum-labs/routekit-gateway",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "1.5.
|
|
4
|
+
"version": "1.5.2",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/velum-labs/routekit.git",
|
|
@@ -43,13 +43,13 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"effect": "4.0.0-rc.108",
|
|
46
|
-
"@velum-labs/routekit-config-core": "1.5.
|
|
47
|
-
"@velum-labs/routekit-contracts": "1.5.
|
|
48
|
-
"@velum-labs/routekit-eval-contracts": "1.5.
|
|
49
|
-
"@velum-labs/routekit-eval-core": "1.5.
|
|
50
|
-
"@velum-labs/routekit-registry": "1.5.
|
|
51
|
-
"@velum-labs/routekit-runtime": "1.5.
|
|
52
|
-
"@velum-labs/routekit-provider-core": "1.5.
|
|
46
|
+
"@velum-labs/routekit-config-core": "1.5.2",
|
|
47
|
+
"@velum-labs/routekit-contracts": "1.5.2",
|
|
48
|
+
"@velum-labs/routekit-eval-contracts": "1.5.2",
|
|
49
|
+
"@velum-labs/routekit-eval-core": "1.5.2",
|
|
50
|
+
"@velum-labs/routekit-registry": "1.5.2",
|
|
51
|
+
"@velum-labs/routekit-runtime": "1.5.2",
|
|
52
|
+
"@velum-labs/routekit-provider-core": "1.5.2"
|
|
53
53
|
},
|
|
54
54
|
"keywords": [
|
|
55
55
|
"routekit",
|