@tyvm/knowhow 0.0.155 → 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/agents/tools/index.ts +1 -0
- package/src/agents/tools/list.ts +2 -0
- package/src/agents/tools/loadImageAsBase64.ts +83 -0
- package/src/auth/keyAuth.ts +5 -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/src/commands/services.ts +23 -18
- package/src/login.ts +22 -6
- package/src/processors/Base64ImageDetector.ts +12 -11
- package/src/tunnel.ts +13 -3
- package/tests/processors/Base64ImageDetector.test.ts +32 -0
- package/tests/unit/agents/tools/loadImageAsBase64.test.ts +44 -0
- package/tests/unit/auth/keyAuth.test.ts +21 -0
- 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/tests/unit/login.test.ts +39 -1
- package/tests/unit/services/Mcp.test.ts +56 -0
- package/tests/unit/tunnel.test.ts +51 -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/agents/tools/index.d.ts +1 -0
- package/ts_build/src/agents/tools/index.js +1 -0
- package/ts_build/src/agents/tools/index.js.map +1 -1
- package/ts_build/src/agents/tools/list.js +2 -0
- package/ts_build/src/agents/tools/list.js.map +1 -1
- package/ts_build/src/agents/tools/loadImageAsBase64.d.ts +3 -0
- package/ts_build/src/agents/tools/loadImageAsBase64.js +79 -0
- package/ts_build/src/agents/tools/loadImageAsBase64.js.map +1 -0
- package/ts_build/src/auth/keyAuth.js +5 -5
- package/ts_build/src/auth/keyAuth.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/src/commands/services.js +15 -8
- package/ts_build/src/commands/services.js.map +1 -1
- package/ts_build/src/login.js +19 -5
- package/ts_build/src/login.js.map +1 -1
- package/ts_build/src/processors/Base64ImageDetector.js +7 -3
- package/ts_build/src/processors/Base64ImageDetector.js.map +1 -1
- package/ts_build/src/tunnel.js +9 -1
- package/ts_build/src/tunnel.js.map +1 -1
- package/ts_build/tests/processors/Base64ImageDetector.test.js +28 -0
- package/ts_build/tests/processors/Base64ImageDetector.test.js.map +1 -1
- package/ts_build/tests/unit/agents/tools/loadImageAsBase64.test.d.ts +1 -0
- package/ts_build/tests/unit/agents/tools/loadImageAsBase64.test.js +67 -0
- package/ts_build/tests/unit/agents/tools/loadImageAsBase64.test.js.map +1 -0
- package/ts_build/tests/unit/auth/keyAuth.test.js +21 -0
- package/ts_build/tests/unit/auth/keyAuth.test.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
- package/ts_build/tests/unit/login.test.js +29 -1
- package/ts_build/tests/unit/login.test.js.map +1 -1
- package/ts_build/tests/unit/services/Mcp.test.d.ts +1 -0
- package/ts_build/tests/unit/services/Mcp.test.js +45 -0
- package/ts_build/tests/unit/services/Mcp.test.js.map +1 -0
- package/ts_build/tests/unit/tunnel.test.d.ts +1 -0
- package/ts_build/tests/unit/tunnel.test.js +47 -0
- package/ts_build/tests/unit/tunnel.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
|
}
|
package/src/commands/services.ts
CHANGED
|
@@ -69,32 +69,37 @@ export async function setupServices() {
|
|
|
69
69
|
|
|
70
70
|
Agents.setAgentContext(agentContext);
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
await Mcp.connectToConfigured(Tools);
|
|
75
|
-
} catch (mcpError) {
|
|
76
|
-
const msg = mcpError instanceof Error ? mcpError.message : String(mcpError);
|
|
77
|
-
console.warn(
|
|
78
|
-
`⚠ Some MCP servers failed to connect (continuing without them): ${msg}`
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
// Attempt silent JWT refresh via SSH identity before registering models.
|
|
82
|
-
// This handles the case where the JWT has expired but the user has a valid
|
|
83
|
-
// SSH identity registered — they shouldn't need to run `knowhow login` again
|
|
84
|
-
// unless they want to switch organizations.
|
|
72
|
+
// Refresh authentication before MCP transports, model clients, or remote
|
|
73
|
+
// sync modules can read the project JWT file.
|
|
85
74
|
try {
|
|
86
75
|
const startupConfig = getConfigSync();
|
|
87
76
|
if (startupConfig.orgId && !process.env.KNOWHOW_JWT) {
|
|
88
|
-
await authenticateWithKey(
|
|
77
|
+
const refreshed = await authenticateWithKey(
|
|
89
78
|
startupConfig.orgId,
|
|
90
79
|
process.env.KNOWHOW_API_URL || "https://api.knowhow.tyvm.ai",
|
|
91
80
|
startupConfig.cliIdentityPath
|
|
92
81
|
);
|
|
82
|
+
if (!refreshed) {
|
|
83
|
+
console.warn(
|
|
84
|
+
`⚠ Could not renew the Knowhow session: no available CLI identity is registered ` +
|
|
85
|
+
`for configured organization ${startupConfig.orgId}. Run \`knowhow login\` to select ` +
|
|
86
|
+
`an organization and register the global identity.`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
93
89
|
}
|
|
94
|
-
} catch (
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
90
|
+
} catch (error) {
|
|
91
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
92
|
+
console.warn(`⚠ Could not renew the Knowhow session before startup: ${message}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
console.log("🔌 Connecting to MCP...");
|
|
96
|
+
try {
|
|
97
|
+
await Mcp.connectToConfigured(Tools);
|
|
98
|
+
} catch (mcpError) {
|
|
99
|
+
const msg = mcpError instanceof Error ? mcpError.message : String(mcpError);
|
|
100
|
+
console.warn(
|
|
101
|
+
`⚠ Some MCP servers failed to connect (continuing without them): ${msg}`
|
|
102
|
+
);
|
|
98
103
|
}
|
|
99
104
|
|
|
100
105
|
console.log("Connecting to clients...");
|
package/src/login.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import http from "./utils/http";
|
|
2
2
|
import fs from "fs";
|
|
3
|
+
import os from "os";
|
|
3
4
|
import path from "path";
|
|
4
5
|
import { ask } from "./utils";
|
|
5
6
|
import { getConfig, updateConfig } from "./config";
|
|
6
7
|
import { KNOWHOW_API_URL } from "./services/KnowhowClient";
|
|
7
8
|
import { BrowserLoginService } from "./auth/browserLogin";
|
|
8
9
|
import { authenticateWithKey, hasKeyPair, registerPublicKey } from "./auth/keyAuth";
|
|
9
|
-
import { getOrCreatePublicKey } from "./auth/keyManager";
|
|
10
|
+
import { getDefaultPrivateKeyPath, getOrCreatePublicKey } from "./auth/keyManager";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Log in to Knowhow.
|
|
@@ -40,18 +41,19 @@ export async function login(jwtFlag?: boolean, identityPath?: string): Promise<v
|
|
|
40
41
|
fs.chmodSync(jwtFile, 0o600);
|
|
41
42
|
console.log("JWT updated successfully.");
|
|
42
43
|
} else {
|
|
44
|
+
const selectedIdentityPath = identityPath ?? getDefaultPrivateKeyPath();
|
|
43
45
|
const config = await getConfig();
|
|
44
46
|
const orgId = config.orgId;
|
|
45
47
|
|
|
46
48
|
// A local key is not proof of registration. Let the selected environment
|
|
47
49
|
// check it, but only when an organization is available to bind the attempt.
|
|
48
|
-
if (orgId && hasKeyPair(
|
|
50
|
+
if (orgId && hasKeyPair(selectedIdentityPath)) {
|
|
49
51
|
console.log("Found CLI identity — authenticating with public key...");
|
|
50
52
|
try {
|
|
51
|
-
const success = await authenticateWithKey(orgId, KNOWHOW_API_URL,
|
|
53
|
+
const success = await authenticateWithKey(orgId, KNOWHOW_API_URL, selectedIdentityPath);
|
|
52
54
|
if (success) {
|
|
53
55
|
console.log("✅ Successfully authenticated via public key!");
|
|
54
|
-
return await postLoginConfigUpdate(
|
|
56
|
+
return await postLoginConfigUpdate(selectedIdentityPath);
|
|
55
57
|
}
|
|
56
58
|
console.warn("Key authentication returned no JWT, falling back to browser login...");
|
|
57
59
|
} catch (error: unknown) {
|
|
@@ -61,7 +63,8 @@ export async function login(jwtFlag?: boolean, identityPath?: string): Promise<v
|
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
// Fall back when no org/key is configured or when key auth is unavailable.
|
|
64
|
-
await doBrowserLogin(
|
|
66
|
+
await doBrowserLogin(selectedIdentityPath);
|
|
67
|
+
identityPath = selectedIdentityPath;
|
|
65
68
|
}
|
|
66
69
|
|
|
67
70
|
await postLoginConfigUpdate(identityPath);
|
|
@@ -101,6 +104,15 @@ async function doBrowserLogin(identityPath?: string): Promise<void> {
|
|
|
101
104
|
}
|
|
102
105
|
}
|
|
103
106
|
|
|
107
|
+
function isTemporaryIdentityPath(identityPath: string): boolean {
|
|
108
|
+
const resolvedIdentity = path.resolve(identityPath);
|
|
109
|
+
const temporaryRoots = new Set([os.tmpdir(), "/tmp", "/var/tmp"]);
|
|
110
|
+
return Array.from(temporaryRoots).some((temporaryRoot) => {
|
|
111
|
+
const relativePath = path.relative(path.resolve(temporaryRoot), resolvedIdentity);
|
|
112
|
+
return relativePath !== "" && !relativePath.startsWith("..") && !path.isAbsolute(relativePath);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
104
116
|
/** After any successful login, update the local config with the current user/org. */
|
|
105
117
|
async function postLoginConfigUpdate(identityPath?: string): Promise<void> {
|
|
106
118
|
try {
|
|
@@ -129,8 +141,12 @@ async function postLoginConfigUpdate(identityPath?: string): Promise<void> {
|
|
|
129
141
|
config.orgId = orgId;
|
|
130
142
|
}
|
|
131
143
|
|
|
132
|
-
if (identityPath) {
|
|
144
|
+
if (identityPath && !isTemporaryIdentityPath(identityPath)) {
|
|
133
145
|
config.cliIdentityPath = path.resolve(identityPath);
|
|
146
|
+
} else if (
|
|
147
|
+
config.cliIdentityPath && isTemporaryIdentityPath(config.cliIdentityPath)
|
|
148
|
+
) {
|
|
149
|
+
delete config.cliIdentityPath;
|
|
134
150
|
}
|
|
135
151
|
|
|
136
152
|
await updateConfig(config);
|
|
@@ -316,25 +316,26 @@ export class Base64ImageProcessor {
|
|
|
316
316
|
|
|
317
317
|
createProcessor(): MessageProcessorFunction {
|
|
318
318
|
return (originalMessages: Message[], modifiedMessages: Message[]) => {
|
|
319
|
-
// Only process the last (newest) message for hint injection.
|
|
320
|
-
// Processing all historical messages on every call would re-append hints
|
|
321
|
-
// to already-processed messages, causing the hint to multiply and busting
|
|
322
|
-
// Anthropic's prefix cache (which requires byte-identical prior messages).
|
|
323
319
|
const lastIndex = modifiedMessages.length - 1;
|
|
324
320
|
if (lastIndex < 0) return;
|
|
325
321
|
|
|
326
322
|
const lastMessage = modifiedMessages[lastIndex];
|
|
327
323
|
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
324
|
+
// Every request starts with a fresh clone of the persisted thread. Normalize
|
|
325
|
+
// all images: parallel calls can place another tool result after an image,
|
|
326
|
+
// while historical image results remain persisted as JSON strings.
|
|
327
|
+
for (const message of modifiedMessages) {
|
|
328
|
+
if (message.role === "user") {
|
|
329
|
+
this.processMessageContent(message);
|
|
330
|
+
}
|
|
331
|
+
if (message.role === "tool") {
|
|
332
|
+
this.processToolMessageContent(message);
|
|
333
|
+
}
|
|
331
334
|
}
|
|
332
335
|
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
// and converted to proper image content before the agent sees them
|
|
336
|
+
// Add path hints only to the newest message. Re-appending hints to history
|
|
337
|
+
// would multiply them and invalidate the provider's prompt cache.
|
|
336
338
|
if (lastMessage.role === "tool") {
|
|
337
|
-
this.processToolMessageContent(lastMessage);
|
|
338
339
|
// After processing tool content (which may not convert to image if it's plain text
|
|
339
340
|
// describing a screenshot path), add hints for any image file paths found in the text.
|
|
340
341
|
this.applyImagePathHintsToMessage(lastMessage);
|
package/src/tunnel.ts
CHANGED
|
@@ -14,8 +14,9 @@ const PROD_TUNNEL_WORKER_DOMAIN = "worker.tyvm-apps.com";
|
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Resolve the tunnel domain and protocol from the environment and API URL.
|
|
17
|
-
* TUNNEL_WORKER_DOMAIN takes precedence. Otherwise
|
|
18
|
-
* tunnel
|
|
17
|
+
* TUNNEL_WORKER_DOMAIN takes precedence. Otherwise localhost API URLs use the
|
|
18
|
+
* local tunnel proxy, dev API URLs use the dev tunnel domain, and all other API
|
|
19
|
+
* URLs use the production tunnel domain.
|
|
19
20
|
*/
|
|
20
21
|
export function extractTunnelDomain(apiUrl: string): {
|
|
21
22
|
domain: string;
|
|
@@ -26,10 +27,19 @@ export function extractTunnelDomain(apiUrl: string): {
|
|
|
26
27
|
const useHttps = url.protocol === "https:";
|
|
27
28
|
const configuredDomain = process.env.TUNNEL_WORKER_DOMAIN?.trim();
|
|
28
29
|
|
|
30
|
+
const isLocalApi =
|
|
31
|
+
url.hostname === "localhost" ||
|
|
32
|
+
url.hostname.endsWith(".localhost") ||
|
|
33
|
+
url.hostname === "127.0.0.1" ||
|
|
34
|
+
url.hostname === "[::1]";
|
|
29
35
|
const isDevApi = url.hostname.split(".").includes("dev");
|
|
30
36
|
const domain =
|
|
31
37
|
configuredDomain ||
|
|
32
|
-
(
|
|
38
|
+
(isLocalApi
|
|
39
|
+
? `worker.localhost:${url.port || (useHttps ? "443" : "80")}`
|
|
40
|
+
: isDevApi
|
|
41
|
+
? `worker.dev.tyvm-apps.com`
|
|
42
|
+
: PROD_TUNNEL_WORKER_DOMAIN);
|
|
33
43
|
|
|
34
44
|
return { domain, useHttps };
|
|
35
45
|
} catch (err) {
|
|
@@ -220,6 +220,38 @@ describe("Base64ImageDetector", () => {
|
|
|
220
220
|
expect(content[0].image_url.url).toBe(imageDataUrl);
|
|
221
221
|
});
|
|
222
222
|
|
|
223
|
+
it("should process an image result that is not the newest parallel tool result", () => {
|
|
224
|
+
const imageDataUrl =
|
|
225
|
+
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==";
|
|
226
|
+
const modifiedMessages: Message[] = [
|
|
227
|
+
{
|
|
228
|
+
role: "tool",
|
|
229
|
+
content: JSON.stringify({
|
|
230
|
+
type: "image_url",
|
|
231
|
+
image_url: { url: imageDataUrl, detail: "high" },
|
|
232
|
+
}),
|
|
233
|
+
tool_call_id: "call_image",
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
role: "tool",
|
|
237
|
+
content: "A later readFile result",
|
|
238
|
+
tool_call_id: "call_file",
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
role: "user",
|
|
242
|
+
content: "A pending workflow update",
|
|
243
|
+
},
|
|
244
|
+
];
|
|
245
|
+
|
|
246
|
+
detector.createProcessor()([], modifiedMessages);
|
|
247
|
+
|
|
248
|
+
expect(Array.isArray(modifiedMessages[0].content)).toBe(true);
|
|
249
|
+
expect((modifiedMessages[0].content as any[])[0]).toEqual({
|
|
250
|
+
type: "image_url",
|
|
251
|
+
image_url: { url: imageDataUrl, detail: "high" },
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
223
255
|
it("should not promote a compressed image URL placeholder to image content", () => {
|
|
224
256
|
const toolResponseJson = JSON.stringify({
|
|
225
257
|
type: "image_url",
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import {
|
|
5
|
+
loadImageAsBase64,
|
|
6
|
+
loadImageAsBase64Definition,
|
|
7
|
+
} from "../../../../src/agents/tools/loadImageAsBase64";
|
|
8
|
+
import { Base64ImageProcessor } from "../../../../src/processors/Base64ImageDetector";
|
|
9
|
+
import { ToolsService } from "../../../../src/services/Tools";
|
|
10
|
+
|
|
11
|
+
describe("loadImageAsBase64 tool", () => {
|
|
12
|
+
it("returns an image data URL with a MIME type detected from its bytes", async () => {
|
|
13
|
+
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "knowhow-image-"));
|
|
14
|
+
const filePath = path.join(directory, "incorrectly-named.jpg");
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
await fs.promises.writeFile(filePath, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
|
|
18
|
+
const result = JSON.parse(await loadImageAsBase64(filePath));
|
|
19
|
+
|
|
20
|
+
expect(result).toMatchObject({
|
|
21
|
+
type: "image_url",
|
|
22
|
+
image_url: { detail: "auto" },
|
|
23
|
+
});
|
|
24
|
+
expect(result.image_url.url).toMatch(/^data:image\/png;base64,/);
|
|
25
|
+
} finally {
|
|
26
|
+
await fs.promises.rm(directory, { recursive: true, force: true });
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("can also be registered by the image message processor without duplication", () => {
|
|
31
|
+
const toolsService = new ToolsService();
|
|
32
|
+
toolsService.defineTools(
|
|
33
|
+
[loadImageAsBase64Definition],
|
|
34
|
+
{ loadImageAsBase64 }
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
new Base64ImageProcessor(toolsService);
|
|
38
|
+
|
|
39
|
+
expect(
|
|
40
|
+
toolsService.getToolNames().filter((name) => name === "loadImageAsBase64")
|
|
41
|
+
).toHaveLength(1);
|
|
42
|
+
expect(typeof toolsService.getFunction("loadImageAsBase64")).toBe("function");
|
|
43
|
+
});
|
|
44
|
+
});
|