@tyvm/knowhow 0.0.156 → 0.0.157
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/package.json +1 -1
- package/src/agents/base/base.ts +15 -5
- package/src/chat/modules/AgentModule.ts +3 -3
- package/src/chat/modules/RemoteSyncModule.ts +45 -17
- package/src/clients/anthropic.ts +156 -79
- package/src/clients/attribution.ts +53 -0
- package/src/clients/http.ts +211 -84
- package/src/clients/index.ts +21 -9
- package/src/clients/openai.ts +441 -220
- package/src/clients/openrouter.ts +10 -3
- package/src/clients/pricing/openai.ts +15 -1
- package/src/clients/pricing/types.ts +15 -0
- package/src/clients/responseMetadata.ts +221 -0
- package/src/clients/types.ts +49 -4
- package/tests/unit/clients/anthropic.responseMetadata.test.ts +81 -0
- package/tests/unit/clients/attribution.test.ts +185 -0
- package/tests/unit/clients/http.responseMetadata.test.ts +86 -0
- package/tests/unit/clients/openai.responseMetadata.test.ts +90 -0
- package/tests/unit/clients/responseMetadata.test.ts +123 -0
- package/ts_build/package.json +1 -1
- package/ts_build/src/agents/base/base.js +14 -4
- package/ts_build/src/agents/base/base.js.map +1 -1
- package/ts_build/src/chat/modules/AgentModule.js +3 -3
- package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
- package/ts_build/src/chat/modules/RemoteSyncModule.d.ts +1 -0
- package/ts_build/src/chat/modules/RemoteSyncModule.js +19 -7
- package/ts_build/src/chat/modules/RemoteSyncModule.js.map +1 -1
- package/ts_build/src/clients/anthropic.d.ts +1 -0
- package/ts_build/src/clients/anthropic.js +83 -38
- package/ts_build/src/clients/anthropic.js.map +1 -1
- package/ts_build/src/clients/attribution.d.ts +2 -0
- package/ts_build/src/clients/attribution.js +32 -0
- package/ts_build/src/clients/attribution.js.map +1 -0
- package/ts_build/src/clients/http.js +100 -20
- package/ts_build/src/clients/http.js.map +1 -1
- package/ts_build/src/clients/index.d.ts +2 -1
- package/ts_build/src/clients/index.js +6 -2
- package/ts_build/src/clients/index.js.map +1 -1
- package/ts_build/src/clients/openai.d.ts +2 -1
- package/ts_build/src/clients/openai.js +225 -99
- package/ts_build/src/clients/openai.js.map +1 -1
- package/ts_build/src/clients/openrouter.d.ts +2 -2
- package/ts_build/src/clients/openrouter.js +6 -2
- package/ts_build/src/clients/openrouter.js.map +1 -1
- package/ts_build/src/clients/pricing/openai.js +15 -1
- package/ts_build/src/clients/pricing/openai.js.map +1 -1
- package/ts_build/src/clients/pricing/types.d.ts +6 -0
- package/ts_build/src/clients/pricing/types.js.map +1 -1
- package/ts_build/src/clients/responseMetadata.d.ts +15 -0
- package/ts_build/src/clients/responseMetadata.js +141 -0
- package/ts_build/src/clients/responseMetadata.js.map +1 -0
- package/ts_build/src/clients/types.d.ts +18 -1
- package/ts_build/src/clients/types.js +2 -1
- package/ts_build/src/clients/types.js.map +1 -1
- package/ts_build/tests/unit/clients/anthropic.responseMetadata.test.d.ts +1 -0
- package/ts_build/tests/unit/clients/anthropic.responseMetadata.test.js +77 -0
- package/ts_build/tests/unit/clients/anthropic.responseMetadata.test.js.map +1 -0
- package/ts_build/tests/unit/clients/attribution.test.d.ts +1 -0
- package/ts_build/tests/unit/clients/attribution.test.js +153 -0
- package/ts_build/tests/unit/clients/attribution.test.js.map +1 -0
- package/ts_build/tests/unit/clients/http.responseMetadata.test.d.ts +1 -0
- package/ts_build/tests/unit/clients/http.responseMetadata.test.js +77 -0
- package/ts_build/tests/unit/clients/http.responseMetadata.test.js.map +1 -0
- package/ts_build/tests/unit/clients/openai.responseMetadata.test.d.ts +1 -0
- package/ts_build/tests/unit/clients/openai.responseMetadata.test.js +86 -0
- package/ts_build/tests/unit/clients/openai.responseMetadata.test.js.map +1 -0
- package/ts_build/tests/unit/clients/responseMetadata.test.d.ts +1 -0
- package/ts_build/tests/unit/clients/responseMetadata.test.js +96 -0
- package/ts_build/tests/unit/clients/responseMetadata.test.js.map +1 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { HttpClient } from "./http";
|
|
1
|
+
import { HttpClient, HttpClientOptions } from "./http";
|
|
2
|
+
import { withKnowhowAttribution } from "./attribution";
|
|
2
3
|
import { OpenRouterTextPricing } from "./pricing/openrouter";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -9,8 +10,14 @@ import { OpenRouterTextPricing } from "./pricing/openrouter";
|
|
|
9
10
|
* Set env var OPENROUTER_API_KEY to enable.
|
|
10
11
|
*/
|
|
11
12
|
export class GenericOpenRouterClient extends HttpClient {
|
|
12
|
-
constructor(
|
|
13
|
-
|
|
13
|
+
constructor(
|
|
14
|
+
apiKey = process.env.OPENROUTER_API_KEY,
|
|
15
|
+
transportOptions: HttpClientOptions = {}
|
|
16
|
+
) {
|
|
17
|
+
super("https://openrouter.ai/api", {
|
|
18
|
+
...transportOptions,
|
|
19
|
+
headers: withKnowhowAttribution(transportOptions.headers),
|
|
20
|
+
});
|
|
14
21
|
if (apiKey) this.setJwt(apiKey);
|
|
15
22
|
this.setPrices(OpenRouterTextPricing);
|
|
16
23
|
}
|
|
@@ -231,7 +231,21 @@ export const OpenAiSearchModels: string[] = [
|
|
|
231
231
|
|
|
232
232
|
export const OpenAiTextPricing: Record<string, ModelPricing> = {
|
|
233
233
|
// gpt-5.6 series — short context / long context pricing
|
|
234
|
-
[OpenAiModels.GPT_56_Sol]:
|
|
234
|
+
[OpenAiModels.GPT_56_Sol]: {
|
|
235
|
+
input: 4.0,
|
|
236
|
+
cached_input: 0.40,
|
|
237
|
+
cache_write: 5.00,
|
|
238
|
+
output: 30.0,
|
|
239
|
+
input_gt_200k: 10.0,
|
|
240
|
+
output_gt_200k: 30.0,
|
|
241
|
+
changelog: [
|
|
242
|
+
{
|
|
243
|
+
date: "2026-08-01",
|
|
244
|
+
note: "Launch pricing — promotional rates available through November 21, 2026",
|
|
245
|
+
pricing: { input: 5.0, cached_input: 0.5, cache_write: 6.25, output: 30.0, input_gt_200k: 10.0, output_gt_200k: 45.0 },
|
|
246
|
+
},
|
|
247
|
+
],
|
|
248
|
+
},
|
|
235
249
|
[OpenAiModels.GPT_56_Terra]: { input: 2.0, cached_input: 0.20, cache_write: 2.5, output: 12.0, input_gt_200k: 4.0, output_gt_200k: 18.0 },
|
|
236
250
|
[OpenAiModels.GPT_56_Luna]: { input: 0.2, cached_input: 0.02, cache_write: 0.25, output: 1.2, input_gt_200k: 0.4, output_gt_200k: 1.8 },
|
|
237
251
|
[OpenAiModels.GPT_55]: { input: 5.0, cached_input: 0.5, output: 30.0 },
|
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A historical record of a model's pricing at a point in time.
|
|
3
|
+
* Use this to track price changes over time.
|
|
4
|
+
*/
|
|
5
|
+
export interface PricingChangelogEntry {
|
|
6
|
+
/** ISO date string when this pricing became effective (e.g. "2025-04-01") */
|
|
7
|
+
date: string;
|
|
8
|
+
/** Optional human-readable note about the change (e.g. "Promotional pricing announcement") */
|
|
9
|
+
note?: string;
|
|
10
|
+
/** The pricing values that were in effect from this date */
|
|
11
|
+
pricing: Omit<ModelPricing, "deprecated" | "deprecationDate" | "limitedAvailability" | "replacedBy" | "reasoningLevels" | "useResponsesApi" | "changelog">;
|
|
12
|
+
}
|
|
13
|
+
|
|
1
14
|
export type ModelType =
|
|
2
15
|
| "completion"
|
|
3
16
|
| "embedding"
|
|
@@ -30,6 +43,8 @@ export interface ModelPricing {
|
|
|
30
43
|
reasoningLevels?: string[];
|
|
31
44
|
/** If true, this model must be called via the Responses API (/v1/responses) instead of /v1/chat/completions */
|
|
32
45
|
useResponsesApi?: boolean;
|
|
46
|
+
/** Optional changelog of historical pricing entries, ordered from oldest to newest */
|
|
47
|
+
changelog?: PricingChangelogEntry[];
|
|
33
48
|
}
|
|
34
49
|
|
|
35
50
|
export interface ModelCatalogEntry {
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ProviderRateLimitMetadata,
|
|
3
|
+
ProviderResponseMetadata,
|
|
4
|
+
RetryOptions,
|
|
5
|
+
} from "./types";
|
|
6
|
+
|
|
7
|
+
const MAX_HEADER_VALUE_LENGTH = 256;
|
|
8
|
+
const MAX_HEADERS = 24;
|
|
9
|
+
const MAX_TOTAL_HEADER_CHARS = 4096;
|
|
10
|
+
const SAFE_HEADER_NAMES = new Set([
|
|
11
|
+
"retry-after",
|
|
12
|
+
"ratelimit-limit",
|
|
13
|
+
"ratelimit-remaining",
|
|
14
|
+
"ratelimit-reset",
|
|
15
|
+
"request-id",
|
|
16
|
+
"x-request-id",
|
|
17
|
+
"cf-ray",
|
|
18
|
+
"x-amzn-requestid",
|
|
19
|
+
"x-amz-request-id",
|
|
20
|
+
]);
|
|
21
|
+
const SAFE_HEADER_PREFIXES = ["x-ratelimit-", "anthropic-ratelimit-"];
|
|
22
|
+
|
|
23
|
+
type HeaderSource =
|
|
24
|
+
| Headers
|
|
25
|
+
| Record<string, unknown>
|
|
26
|
+
| Iterable<[string, unknown]>
|
|
27
|
+
| undefined;
|
|
28
|
+
|
|
29
|
+
function isSafeHeader(name: string) {
|
|
30
|
+
return (
|
|
31
|
+
SAFE_HEADER_NAMES.has(name) ||
|
|
32
|
+
SAFE_HEADER_PREFIXES.some((prefix) => name.startsWith(prefix))
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function entriesFor(headers: HeaderSource): Iterable<[string, unknown]> {
|
|
37
|
+
if (!headers) return [];
|
|
38
|
+
if (Symbol.iterator in Object(headers)) {
|
|
39
|
+
return headers as Iterable<[string, unknown]>;
|
|
40
|
+
}
|
|
41
|
+
if (typeof (headers as Headers).forEach === "function") {
|
|
42
|
+
const entries: Array<[string, unknown]> = [];
|
|
43
|
+
(headers as Headers).forEach((value, name) => entries.push([name, value]));
|
|
44
|
+
return entries;
|
|
45
|
+
}
|
|
46
|
+
return Object.entries(headers as Record<string, unknown>);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function nonNegativeNumber(value: string | undefined) {
|
|
50
|
+
if (value === undefined || value.trim() === "") return undefined;
|
|
51
|
+
const number = Number(value);
|
|
52
|
+
return Number.isFinite(number) && number >= 0 ? number : undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function resetAt(value: string | undefined, now: Date) {
|
|
56
|
+
if (!value) return undefined;
|
|
57
|
+
const seconds = nonNegativeNumber(value);
|
|
58
|
+
if (seconds !== undefined)
|
|
59
|
+
return new Date(now.getTime() + seconds * 1000).toISOString();
|
|
60
|
+
|
|
61
|
+
// OpenAI-compatible APIs commonly return compact durations such as `6m0s`.
|
|
62
|
+
const duration = [...value.trim().matchAll(/(\d+(?:\.\d+)?)(ms|s|m|h|d)/gi)];
|
|
63
|
+
if (
|
|
64
|
+
duration.length &&
|
|
65
|
+
duration.map((part) => part[0]).join("") === value.trim()
|
|
66
|
+
) {
|
|
67
|
+
const milliseconds = duration.reduce((total, [, amount, unit]) => {
|
|
68
|
+
const multiplier = {
|
|
69
|
+
ms: 1,
|
|
70
|
+
s: 1_000,
|
|
71
|
+
m: 60_000,
|
|
72
|
+
h: 3_600_000,
|
|
73
|
+
d: 86_400_000,
|
|
74
|
+
}[unit.toLowerCase()];
|
|
75
|
+
return total + Number(amount) * multiplier;
|
|
76
|
+
}, 0);
|
|
77
|
+
return new Date(now.getTime() + milliseconds).toISOString();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const timestamp = Date.parse(value);
|
|
81
|
+
return Number.isNaN(timestamp)
|
|
82
|
+
? undefined
|
|
83
|
+
: new Date(timestamp).toISOString();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function retryAfterSeconds(value: string | undefined, now: Date) {
|
|
87
|
+
const seconds = nonNegativeNumber(value);
|
|
88
|
+
if (seconds !== undefined) return seconds;
|
|
89
|
+
if (!value) return undefined;
|
|
90
|
+
const timestamp = Date.parse(value);
|
|
91
|
+
return Number.isNaN(timestamp)
|
|
92
|
+
? undefined
|
|
93
|
+
: Math.max(0, (timestamp - now.getTime()) / 1000);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Derives one JSON-safe shape from standard, OpenAI-compatible, and Anthropic
|
|
98
|
+
* rate-limit headers. Callers receive this even when a provider supplied none.
|
|
99
|
+
*/
|
|
100
|
+
export function normalizeRateLimitMetadata(
|
|
101
|
+
headers: Record<string, string>,
|
|
102
|
+
now = new Date()
|
|
103
|
+
): ProviderRateLimitMetadata {
|
|
104
|
+
const header = (...names: string[]) =>
|
|
105
|
+
names.map((name) => headers[name]).find((value) => value !== undefined);
|
|
106
|
+
const limit = nonNegativeNumber(
|
|
107
|
+
header(
|
|
108
|
+
"anthropic-ratelimit-requests-limit",
|
|
109
|
+
"ratelimit-limit",
|
|
110
|
+
"x-ratelimit-limit-requests",
|
|
111
|
+
"x-ratelimit-limit"
|
|
112
|
+
)
|
|
113
|
+
);
|
|
114
|
+
const remaining = nonNegativeNumber(
|
|
115
|
+
header(
|
|
116
|
+
"anthropic-ratelimit-requests-remaining",
|
|
117
|
+
"ratelimit-remaining",
|
|
118
|
+
"x-ratelimit-remaining-requests",
|
|
119
|
+
"x-ratelimit-remaining"
|
|
120
|
+
)
|
|
121
|
+
);
|
|
122
|
+
const requestResetAt = resetAt(
|
|
123
|
+
header(
|
|
124
|
+
"anthropic-ratelimit-requests-reset",
|
|
125
|
+
"ratelimit-reset",
|
|
126
|
+
"x-ratelimit-reset-requests",
|
|
127
|
+
"x-ratelimit-reset"
|
|
128
|
+
),
|
|
129
|
+
now
|
|
130
|
+
);
|
|
131
|
+
const tokenLimit = nonNegativeNumber(
|
|
132
|
+
header("anthropic-ratelimit-tokens-limit", "x-ratelimit-limit-tokens")
|
|
133
|
+
);
|
|
134
|
+
const tokenRemaining = nonNegativeNumber(
|
|
135
|
+
header(
|
|
136
|
+
"anthropic-ratelimit-tokens-remaining",
|
|
137
|
+
"x-ratelimit-remaining-tokens"
|
|
138
|
+
)
|
|
139
|
+
);
|
|
140
|
+
const tokenResetAt = resetAt(
|
|
141
|
+
header("anthropic-ratelimit-tokens-reset", "x-ratelimit-reset-tokens"),
|
|
142
|
+
now
|
|
143
|
+
);
|
|
144
|
+
const retryAfter = retryAfterSeconds(header("retry-after"), now);
|
|
145
|
+
return {
|
|
146
|
+
...(limit !== undefined && { limit }),
|
|
147
|
+
...(remaining !== undefined && { remaining }),
|
|
148
|
+
...(requestResetAt && { resetAt: requestResetAt }),
|
|
149
|
+
...(retryAfter !== undefined && { retryAfterSeconds: retryAfter }),
|
|
150
|
+
...(tokenLimit !== undefined && { tokenLimit }),
|
|
151
|
+
...(tokenRemaining !== undefined && { tokenRemaining }),
|
|
152
|
+
...(tokenResetAt && { tokenResetAt }),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Converts provider headers to the deliberately small telemetry-safe surface.
|
|
158
|
+
* This function never returns request bodies, credentials, cookies, or arbitrary
|
|
159
|
+
* provider headers.
|
|
160
|
+
*/
|
|
161
|
+
export function normalizeResponseMetadata(input: {
|
|
162
|
+
statusCode?: number;
|
|
163
|
+
headers?: HeaderSource;
|
|
164
|
+
requestId?: string | null;
|
|
165
|
+
observedAt?: Date;
|
|
166
|
+
}): ProviderResponseMetadata {
|
|
167
|
+
const headers: Record<string, string> = {};
|
|
168
|
+
let totalChars = 0;
|
|
169
|
+
|
|
170
|
+
for (const [rawName, rawValue] of entriesFor(input.headers)) {
|
|
171
|
+
const name = String(rawName).toLowerCase();
|
|
172
|
+
if (
|
|
173
|
+
!isSafeHeader(name) ||
|
|
174
|
+
Object.prototype.hasOwnProperty.call(headers, name)
|
|
175
|
+
)
|
|
176
|
+
continue;
|
|
177
|
+
const value = String(rawValue).slice(0, MAX_HEADER_VALUE_LENGTH);
|
|
178
|
+
if (
|
|
179
|
+
Object.keys(headers).length >= MAX_HEADERS ||
|
|
180
|
+
totalChars + name.length + value.length > MAX_TOTAL_HEADER_CHARS
|
|
181
|
+
)
|
|
182
|
+
break;
|
|
183
|
+
headers[name] = value;
|
|
184
|
+
totalChars += name.length + value.length;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const requestId =
|
|
188
|
+
input.requestId ??
|
|
189
|
+
headers["request-id"] ??
|
|
190
|
+
headers["x-request-id"] ??
|
|
191
|
+
headers["x-amzn-requestid"] ??
|
|
192
|
+
headers["x-amz-request-id"];
|
|
193
|
+
return {
|
|
194
|
+
...(typeof input.statusCode === "number" && {
|
|
195
|
+
statusCode: input.statusCode,
|
|
196
|
+
}),
|
|
197
|
+
rateLimit: normalizeRateLimitMetadata(headers, input.observedAt),
|
|
198
|
+
...(Object.keys(headers).length > 0 && { headers }),
|
|
199
|
+
...(requestId && {
|
|
200
|
+
requestId: String(requestId).slice(0, MAX_HEADER_VALUE_LENGTH),
|
|
201
|
+
}),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Emits one visible HTTP response synchronously and isolates observer failures. */
|
|
206
|
+
export function emitResponseMetadata(
|
|
207
|
+
options: Pick<RetryOptions, "onResponseMetadata"> | undefined,
|
|
208
|
+
input: {
|
|
209
|
+
statusCode?: number;
|
|
210
|
+
headers?: HeaderSource;
|
|
211
|
+
requestId?: string | null;
|
|
212
|
+
}
|
|
213
|
+
) {
|
|
214
|
+
const observer = options?.onResponseMetadata;
|
|
215
|
+
if (!observer) return;
|
|
216
|
+
try {
|
|
217
|
+
observer(normalizeResponseMetadata(input));
|
|
218
|
+
} catch {
|
|
219
|
+
// Observability must never affect a provider request or its original error.
|
|
220
|
+
}
|
|
221
|
+
}
|
package/src/clients/types.ts
CHANGED
|
@@ -104,6 +104,36 @@ export interface Tool {
|
|
|
104
104
|
};
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Provider-neutral rate-limit information derived from response headers.
|
|
109
|
+
* Reset values are ISO-8601 strings so callback payloads remain JSON-safe.
|
|
110
|
+
*/
|
|
111
|
+
export interface ProviderRateLimitMetadata {
|
|
112
|
+
limit?: number;
|
|
113
|
+
remaining?: number;
|
|
114
|
+
resetAt?: string;
|
|
115
|
+
retryAfterSeconds?: number;
|
|
116
|
+
tokenLimit?: number;
|
|
117
|
+
tokenRemaining?: number;
|
|
118
|
+
tokenResetAt?: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Safe telemetry details for one provider response. `rateLimit` is normalized
|
|
123
|
+
* by the client for every observed response. `headers`, when present, contains
|
|
124
|
+
* only the bounded allowlisted raw headers used to derive it.
|
|
125
|
+
*/
|
|
126
|
+
export interface ProviderResponseMetadata {
|
|
127
|
+
statusCode?: number;
|
|
128
|
+
rateLimit: ProviderRateLimitMetadata;
|
|
129
|
+
headers?: Record<string, string>;
|
|
130
|
+
requestId?: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export type ProviderResponseMetadataObserver = (
|
|
134
|
+
metadata: ProviderResponseMetadata
|
|
135
|
+
) => void;
|
|
136
|
+
|
|
107
137
|
export interface ToolCall {
|
|
108
138
|
id: string;
|
|
109
139
|
type: "function";
|
|
@@ -134,6 +164,11 @@ export interface RetryOptions {
|
|
|
134
164
|
* attempt is cancelled immediately and no further retries are made.
|
|
135
165
|
*/
|
|
136
166
|
signal?: AbortSignal;
|
|
167
|
+
/**
|
|
168
|
+
* Synchronous observer invoked once for each provider HTTP response visible to
|
|
169
|
+
* this client, including errors and streaming handshakes.
|
|
170
|
+
*/
|
|
171
|
+
onResponseMetadata?: ProviderResponseMetadataObserver;
|
|
137
172
|
}
|
|
138
173
|
|
|
139
174
|
/**
|
|
@@ -150,10 +185,13 @@ export const REASONING_EFFORTS = [
|
|
|
150
185
|
"xhigh",
|
|
151
186
|
"max",
|
|
152
187
|
] as const;
|
|
153
|
-
export type ReasoningEffort =
|
|
188
|
+
export type ReasoningEffort = typeof REASONING_EFFORTS[number];
|
|
154
189
|
|
|
155
190
|
export function isReasoningEffort(value: unknown): value is ReasoningEffort {
|
|
156
|
-
return
|
|
191
|
+
return (
|
|
192
|
+
typeof value === "string" &&
|
|
193
|
+
REASONING_EFFORTS.includes(value as ReasoningEffort)
|
|
194
|
+
);
|
|
157
195
|
}
|
|
158
196
|
|
|
159
197
|
export interface CompletionOptions extends RetryOptions {
|
|
@@ -438,7 +476,9 @@ export interface GenericClient {
|
|
|
438
476
|
setKey(key: string): void;
|
|
439
477
|
createChatCompletion(options: CompletionOptions): Promise<CompletionResponse>;
|
|
440
478
|
/** Optional streaming variant — yields incremental tokens then a final done chunk. */
|
|
441
|
-
createChatCompletionStream?(
|
|
479
|
+
createChatCompletionStream?(
|
|
480
|
+
options: CompletionOptions
|
|
481
|
+
): AsyncGenerator<StreamChunk>;
|
|
442
482
|
createEmbedding(options: EmbeddingOptions): Promise<EmbeddingResponse>;
|
|
443
483
|
createAudioTranscription?(
|
|
444
484
|
options: AudioTranscriptionOptions
|
|
@@ -482,5 +522,10 @@ export interface GenericClient {
|
|
|
482
522
|
* Returns undefined for a specific model if no pricing is known.
|
|
483
523
|
* Only implemented by HttpClient-based providers that have been given a pricing map via setPrices().
|
|
484
524
|
*/
|
|
485
|
-
getPricing?(
|
|
525
|
+
getPricing?(
|
|
526
|
+
model?: string
|
|
527
|
+
):
|
|
528
|
+
| import("./pricing/types").ModelPricing
|
|
529
|
+
| Record<string, import("./pricing/types").ModelPricing>
|
|
530
|
+
| undefined;
|
|
486
531
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { GenericAnthropicClient } from "../../../src/clients/anthropic";
|
|
2
|
+
|
|
3
|
+
const options = {
|
|
4
|
+
model: "claude-sonnet-4-5",
|
|
5
|
+
messages: [{ role: "user" as const, content: "hello" }],
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
describe("Anthropic response metadata", () => {
|
|
9
|
+
it("emits native message response metadata", async () => {
|
|
10
|
+
const client = new GenericAnthropicClient("test-key");
|
|
11
|
+
const observer = jest.fn();
|
|
12
|
+
(client as any).client = {
|
|
13
|
+
messages: {
|
|
14
|
+
create: jest.fn(() => ({
|
|
15
|
+
withResponse: async () => ({
|
|
16
|
+
data: {
|
|
17
|
+
content: [{ type: "text", text: "hi" }],
|
|
18
|
+
usage: { input_tokens: 3, output_tokens: 2 },
|
|
19
|
+
},
|
|
20
|
+
response: new Response(null, {
|
|
21
|
+
status: 200,
|
|
22
|
+
headers: {
|
|
23
|
+
"request-id": "anthropic-request",
|
|
24
|
+
"anthropic-ratelimit-requests-limit": "50",
|
|
25
|
+
"anthropic-ratelimit-requests-remaining": "49",
|
|
26
|
+
"anthropic-ratelimit-tokens-remaining": "900",
|
|
27
|
+
},
|
|
28
|
+
}),
|
|
29
|
+
request_id: "anthropic-request",
|
|
30
|
+
}),
|
|
31
|
+
})),
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const result = await client.createChatCompletion({
|
|
36
|
+
...options,
|
|
37
|
+
onResponseMetadata: observer,
|
|
38
|
+
});
|
|
39
|
+
expect(result.choices[0].message.content).toBe("hi");
|
|
40
|
+
expect(observer).toHaveBeenCalledWith({
|
|
41
|
+
statusCode: 200,
|
|
42
|
+
requestId: "anthropic-request",
|
|
43
|
+
rateLimit: { limit: 50, remaining: 49, tokenRemaining: 900 },
|
|
44
|
+
headers: {
|
|
45
|
+
"request-id": "anthropic-request",
|
|
46
|
+
"anthropic-ratelimit-requests-limit": "50",
|
|
47
|
+
"anthropic-ratelimit-requests-remaining": "49",
|
|
48
|
+
"anthropic-ratelimit-tokens-remaining": "900",
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("emits API error metadata and preserves error identity", async () => {
|
|
54
|
+
const client = new GenericAnthropicClient("test-key");
|
|
55
|
+
const observer = jest.fn();
|
|
56
|
+
const error = Object.assign(new Error("rate limited"), {
|
|
57
|
+
status: 429,
|
|
58
|
+
headers: { "retry-after": "1", cookie: "private" },
|
|
59
|
+
request_id: "anthropic-rate-request",
|
|
60
|
+
});
|
|
61
|
+
(client as any).client = {
|
|
62
|
+
messages: {
|
|
63
|
+
create: jest.fn(() => ({
|
|
64
|
+
withResponse: async () => {
|
|
65
|
+
throw error;
|
|
66
|
+
},
|
|
67
|
+
})),
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
await expect(
|
|
72
|
+
client.createChatCompletion({ ...options, onResponseMetadata: observer })
|
|
73
|
+
).rejects.toBe(error);
|
|
74
|
+
expect(observer).toHaveBeenCalledWith({
|
|
75
|
+
statusCode: 429,
|
|
76
|
+
requestId: "anthropic-rate-request",
|
|
77
|
+
rateLimit: { retryAfterSeconds: 1 },
|
|
78
|
+
headers: { "retry-after": "1" },
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
const post = jest.fn();
|
|
2
|
+
|
|
3
|
+
jest.mock("../../../src/utils/http", () => ({
|
|
4
|
+
__esModule: true,
|
|
5
|
+
default: { post },
|
|
6
|
+
HTTP_UNAUTHORIZED_HANDLER: Symbol("refresh"),
|
|
7
|
+
HttpError: class HttpError extends Error {
|
|
8
|
+
constructor(
|
|
9
|
+
public status: number,
|
|
10
|
+
public response: Response,
|
|
11
|
+
message: string
|
|
12
|
+
) {
|
|
13
|
+
super(message);
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
import packageJson from "../../../package.json";
|
|
19
|
+
import { HttpClient } from "../../../src/clients/http";
|
|
20
|
+
import { GenericOpenRouterClient } from "../../../src/clients/openrouter";
|
|
21
|
+
import {
|
|
22
|
+
KNOWHOW_ATTRIBUTION_HEADERS,
|
|
23
|
+
withKnowhowAttribution,
|
|
24
|
+
} from "../../../src/clients/attribution";
|
|
25
|
+
|
|
26
|
+
describe("OpenRouter attribution", () => {
|
|
27
|
+
beforeEach(() => post.mockReset());
|
|
28
|
+
afterEach(() => jest.restoreAllMocks());
|
|
29
|
+
|
|
30
|
+
it("uses the Knowhow application identity and current package version", () => {
|
|
31
|
+
expect(KNOWHOW_ATTRIBUTION_HEADERS).toEqual({
|
|
32
|
+
"HTTP-Referer": "https://knowhow.tyvm.ai",
|
|
33
|
+
"X-Title": "Knowhow",
|
|
34
|
+
"X-OpenRouter-Title": "Knowhow",
|
|
35
|
+
"User-Agent": `knowhow/${packageJson.version}`,
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("applies caller overrides case-insensitively without duplicates", () => {
|
|
40
|
+
const headers = withKnowhowAttribution({
|
|
41
|
+
"user-agent": "custom-agent",
|
|
42
|
+
"HTTP-referer": "https://caller.example",
|
|
43
|
+
"x-title": "Caller",
|
|
44
|
+
"x-openrouter-title": "Caller",
|
|
45
|
+
"X-Custom": "preserved",
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(headers).toEqual({
|
|
49
|
+
"User-Agent": "custom-agent",
|
|
50
|
+
"HTTP-Referer": "https://caller.example",
|
|
51
|
+
"X-Title": "Caller",
|
|
52
|
+
"X-OpenRouter-Title": "Caller",
|
|
53
|
+
"X-Custom": "preserved",
|
|
54
|
+
});
|
|
55
|
+
for (const name of ["user-agent", "http-referer", "x-title", "x-openrouter-title"]) {
|
|
56
|
+
expect(
|
|
57
|
+
Object.keys(headers).filter((key) => key.toLowerCase() === name)
|
|
58
|
+
).toHaveLength(1);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("sends attribution only as OpenRouter request headers", async () => {
|
|
63
|
+
post.mockResolvedValueOnce({
|
|
64
|
+
status: 200,
|
|
65
|
+
headers: new Headers(),
|
|
66
|
+
data: {
|
|
67
|
+
choices: [{ message: { role: "assistant", content: "ok" } }],
|
|
68
|
+
model: "test",
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
const client = new GenericOpenRouterClient("test-key");
|
|
72
|
+
client.setOptions({
|
|
73
|
+
headers: {
|
|
74
|
+
"user-agent": "caller-agent",
|
|
75
|
+
"http-referer": "https://caller.example",
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await client.createChatCompletion({
|
|
80
|
+
model: "test",
|
|
81
|
+
messages: [{ role: "user", content: "hello" }],
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const body = post.mock.calls[0][1];
|
|
85
|
+
const headers = post.mock.calls[0][2].headers;
|
|
86
|
+
expect(headers).toMatchObject({
|
|
87
|
+
"HTTP-Referer": "https://caller.example",
|
|
88
|
+
"X-Title": "Knowhow",
|
|
89
|
+
"X-OpenRouter-Title": "Knowhow",
|
|
90
|
+
"User-Agent": "caller-agent",
|
|
91
|
+
Authorization: "Bearer test-key",
|
|
92
|
+
});
|
|
93
|
+
expect(
|
|
94
|
+
Object.keys(headers).filter(
|
|
95
|
+
(name) => name.toLowerCase() === "http-referer"
|
|
96
|
+
)
|
|
97
|
+
).toHaveLength(1);
|
|
98
|
+
expect(
|
|
99
|
+
Object.keys(headers).filter((name) => name.toLowerCase() === "x-title")
|
|
100
|
+
).toHaveLength(1);
|
|
101
|
+
const serializedBody = JSON.stringify(body);
|
|
102
|
+
expect(serializedBody).not.toContain("HTTP-Referer");
|
|
103
|
+
expect(serializedBody).not.toContain("X-OpenRouter-Title");
|
|
104
|
+
expect(serializedBody).not.toContain("caller-agent");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("does not add OpenRouter attribution to generic provider traffic", async () => {
|
|
108
|
+
post.mockResolvedValueOnce({
|
|
109
|
+
status: 200,
|
|
110
|
+
headers: new Headers(),
|
|
111
|
+
data: {
|
|
112
|
+
choices: [{ message: { role: "assistant", content: "ok" } }],
|
|
113
|
+
model: "test",
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
const client = new HttpClient("https://provider.example");
|
|
117
|
+
|
|
118
|
+
await client.createChatCompletion({
|
|
119
|
+
model: "test",
|
|
120
|
+
messages: [{ role: "user", content: "hello" }],
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(post.mock.calls[0][2].headers).toEqual({});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("sends attribution on OpenRouter embedding requests", async () => {
|
|
127
|
+
post.mockResolvedValueOnce({
|
|
128
|
+
status: 200,
|
|
129
|
+
headers: new Headers(),
|
|
130
|
+
data: { data: [{ embedding: [1] }], usage: {} },
|
|
131
|
+
});
|
|
132
|
+
const client = new GenericOpenRouterClient("initial-key", {
|
|
133
|
+
headers: { "x-openrouter-title": "Embedding caller" },
|
|
134
|
+
});
|
|
135
|
+
client.setKey("replacement-key");
|
|
136
|
+
|
|
137
|
+
await client.createEmbedding({ model: "embedding-test", input: "hello" });
|
|
138
|
+
|
|
139
|
+
expect(post.mock.calls[0][2].headers).toMatchObject({
|
|
140
|
+
"HTTP-Referer": "https://knowhow.tyvm.ai",
|
|
141
|
+
"X-OpenRouter-Title": "Embedding caller",
|
|
142
|
+
// X-Title was not overridden, so it keeps the default "Knowhow"
|
|
143
|
+
"X-Title": "Knowhow",
|
|
144
|
+
"User-Agent": `knowhow/${packageJson.version}`,
|
|
145
|
+
Authorization: "Bearer replacement-key",
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("sends attribution on OpenRouter streaming requests", async () => {
|
|
150
|
+
const fetchMock = jest.spyOn(global, "fetch").mockResolvedValueOnce(
|
|
151
|
+
new Response(
|
|
152
|
+
'data: {"choices":[{"delta":{"content":"ok"}}]}\n\n' +
|
|
153
|
+
"data: [DONE]\n\n",
|
|
154
|
+
{
|
|
155
|
+
status: 200,
|
|
156
|
+
headers: { "Content-Type": "text/event-stream" },
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
);
|
|
160
|
+
const client = new GenericOpenRouterClient("test-key", {
|
|
161
|
+
headers: { "HTTP-referer": "https://stream.example" },
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const chunks = [];
|
|
165
|
+
for await (const chunk of client.createChatCompletionStream({
|
|
166
|
+
model: "test",
|
|
167
|
+
messages: [{ role: "user", content: "hello" }],
|
|
168
|
+
})) {
|
|
169
|
+
chunks.push(chunk);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const headers = fetchMock.mock.calls[0][1]?.headers as Record<
|
|
173
|
+
string,
|
|
174
|
+
string
|
|
175
|
+
>;
|
|
176
|
+
expect(headers).toMatchObject({
|
|
177
|
+
"HTTP-Referer": "https://stream.example",
|
|
178
|
+
"X-OpenRouter-Title": "Knowhow",
|
|
179
|
+
"X-Title": "Knowhow",
|
|
180
|
+
"User-Agent": `knowhow/${packageJson.version}`,
|
|
181
|
+
Authorization: "Bearer test-key",
|
|
182
|
+
});
|
|
183
|
+
expect(chunks.length).toBeGreaterThan(0);
|
|
184
|
+
});
|
|
185
|
+
});
|