@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
|
@@ -24,12 +24,16 @@ jest.mock("../../../src/auth/browserLogin", () => ({
|
|
|
24
24
|
getCliUserAgent: () => "test-agent",
|
|
25
25
|
}));
|
|
26
26
|
|
|
27
|
+
import fs from "fs";
|
|
28
|
+
import os from "os";
|
|
29
|
+
import path from "path";
|
|
27
30
|
import {
|
|
28
31
|
authenticateWithKey,
|
|
29
32
|
exchangeAvailablePublicKeyJwt,
|
|
30
33
|
exchangePublicKeyJwt,
|
|
31
34
|
hasKeyPair,
|
|
32
35
|
registerPublicKey,
|
|
36
|
+
storeJwt,
|
|
33
37
|
} from "../../../src/auth/keyAuth";
|
|
34
38
|
|
|
35
39
|
describe("keyAuth", () => {
|
|
@@ -109,6 +113,23 @@ describe("keyAuth", () => {
|
|
|
109
113
|
expect(signMessage).not.toHaveBeenCalled();
|
|
110
114
|
});
|
|
111
115
|
|
|
116
|
+
it("stores a refreshed JWT in the active isolated project", () => {
|
|
117
|
+
const originalCwd = process.cwd();
|
|
118
|
+
const temporaryProject = fs.mkdtempSync(path.join(os.tmpdir(), "knowhow-key-auth-"));
|
|
119
|
+
try {
|
|
120
|
+
process.chdir(temporaryProject);
|
|
121
|
+
|
|
122
|
+
storeJwt("fresh-project-jwt");
|
|
123
|
+
|
|
124
|
+
const jwtPath = path.join(temporaryProject, ".knowhow", ".jwt");
|
|
125
|
+
expect(fs.readFileSync(jwtPath, "utf8")).toBe("fresh-project-jwt");
|
|
126
|
+
expect(fs.statSync(jwtPath).mode & 0o777).toBe(0o600);
|
|
127
|
+
} finally {
|
|
128
|
+
process.chdir(originalCwd);
|
|
129
|
+
fs.rmSync(temporaryProject, { recursive: true, force: true });
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
112
133
|
it("registers only server-owned Ed25519 key fields in the selected environment", async () => {
|
|
113
134
|
post.mockResolvedValueOnce({ data: {}, status: 201 });
|
|
114
135
|
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
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 { HttpClient } from "../../../src/clients/http";
|
|
19
|
+
|
|
20
|
+
describe("HttpClient response metadata", () => {
|
|
21
|
+
beforeEach(() => post.mockReset());
|
|
22
|
+
|
|
23
|
+
it("emits chat metadata and excludes transport-only options from its body", async () => {
|
|
24
|
+
post.mockResolvedValue({
|
|
25
|
+
status: 200,
|
|
26
|
+
headers: new Headers({
|
|
27
|
+
"x-request-id": "http-request",
|
|
28
|
+
"x-ratelimit-limit-requests": "20",
|
|
29
|
+
"x-ratelimit-remaining-requests": "19",
|
|
30
|
+
}),
|
|
31
|
+
data: {
|
|
32
|
+
choices: [{ message: { role: "assistant", content: "hi" } }],
|
|
33
|
+
model: "test",
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
const observer = jest.fn();
|
|
37
|
+
const client = new HttpClient("https://provider.example");
|
|
38
|
+
const controller = new AbortController();
|
|
39
|
+
await client.createChatCompletion({
|
|
40
|
+
model: "test",
|
|
41
|
+
messages: [{ role: "user", content: "hello" }],
|
|
42
|
+
signal: controller.signal,
|
|
43
|
+
timeout: 1,
|
|
44
|
+
maxRetries: 0,
|
|
45
|
+
backoffMs: 1,
|
|
46
|
+
onResponseMetadata: observer,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(post.mock.calls[0][1]).not.toHaveProperty("onResponseMetadata");
|
|
50
|
+
expect(post.mock.calls[0][1]).not.toHaveProperty("signal");
|
|
51
|
+
expect(post.mock.calls[0][1]).not.toHaveProperty("timeout");
|
|
52
|
+
expect(observer).toHaveBeenCalledWith({
|
|
53
|
+
statusCode: 200,
|
|
54
|
+
requestId: "http-request",
|
|
55
|
+
rateLimit: { limit: 20, remaining: 19 },
|
|
56
|
+
headers: {
|
|
57
|
+
"x-request-id": "http-request",
|
|
58
|
+
"x-ratelimit-limit-requests": "20",
|
|
59
|
+
"x-ratelimit-remaining-requests": "19",
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("emits embedding error metadata and preserves the original HttpError", async () => {
|
|
65
|
+
const { HttpError } = require("../../../src/utils/http");
|
|
66
|
+
const error = new HttpError(
|
|
67
|
+
429,
|
|
68
|
+
new Response(null, { headers: { "retry-after": "3" } }),
|
|
69
|
+
"rate limited"
|
|
70
|
+
);
|
|
71
|
+
post.mockRejectedValue(error);
|
|
72
|
+
const observer = jest.fn();
|
|
73
|
+
await expect(
|
|
74
|
+
new HttpClient("https://provider.example").createEmbedding({
|
|
75
|
+
model: "test",
|
|
76
|
+
input: "hello",
|
|
77
|
+
onResponseMetadata: observer,
|
|
78
|
+
})
|
|
79
|
+
).rejects.toBe(error);
|
|
80
|
+
expect(observer).toHaveBeenCalledWith({
|
|
81
|
+
statusCode: 429,
|
|
82
|
+
rateLimit: { retryAfterSeconds: 3 },
|
|
83
|
+
headers: { "retry-after": "3" },
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { GenericOpenAiClient } from "../../../src/clients/openai";
|
|
2
|
+
|
|
3
|
+
const completionOptions = {
|
|
4
|
+
model: "gpt-4o-mini",
|
|
5
|
+
messages: [{ role: "user" as const, content: "hello" }],
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
describe("OpenAI response metadata", () => {
|
|
9
|
+
it("emits native chat response metadata before mapping", async () => {
|
|
10
|
+
const client = new GenericOpenAiClient("test-key");
|
|
11
|
+
const observer = jest.fn();
|
|
12
|
+
(client as any).client = {
|
|
13
|
+
chat: {
|
|
14
|
+
completions: {
|
|
15
|
+
create: jest.fn(() => ({
|
|
16
|
+
withResponse: async () => ({
|
|
17
|
+
data: {
|
|
18
|
+
choices: [{ message: { role: "assistant", content: "hi" } }],
|
|
19
|
+
usage: undefined,
|
|
20
|
+
},
|
|
21
|
+
response: new Response(null, {
|
|
22
|
+
status: 200,
|
|
23
|
+
headers: {
|
|
24
|
+
"x-request-id": "chat-request",
|
|
25
|
+
"x-ratelimit-limit-requests": "100",
|
|
26
|
+
"x-ratelimit-remaining-requests": "99",
|
|
27
|
+
"x-ratelimit-limit-tokens": "1000",
|
|
28
|
+
},
|
|
29
|
+
}),
|
|
30
|
+
request_id: "chat-request",
|
|
31
|
+
}),
|
|
32
|
+
})),
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const result = await client.createChatCompletion({
|
|
38
|
+
...completionOptions,
|
|
39
|
+
onResponseMetadata: observer,
|
|
40
|
+
});
|
|
41
|
+
expect(result.choices[0].message.content).toBe("hi");
|
|
42
|
+
expect(observer).toHaveBeenCalledWith({
|
|
43
|
+
statusCode: 200,
|
|
44
|
+
requestId: "chat-request",
|
|
45
|
+
rateLimit: { limit: 100, remaining: 99, tokenLimit: 1000 },
|
|
46
|
+
headers: {
|
|
47
|
+
"x-request-id": "chat-request",
|
|
48
|
+
"x-ratelimit-limit-requests": "100",
|
|
49
|
+
"x-ratelimit-remaining-requests": "99",
|
|
50
|
+
"x-ratelimit-limit-tokens": "1000",
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("emits API error metadata and preserves the original error", async () => {
|
|
56
|
+
const client = new GenericOpenAiClient("test-key");
|
|
57
|
+
const observer = jest.fn(() => {
|
|
58
|
+
throw new Error("observer");
|
|
59
|
+
});
|
|
60
|
+
const error = Object.assign(new Error("rate limited"), {
|
|
61
|
+
status: 429,
|
|
62
|
+
headers: { "retry-after": "2", authorization: "secret" },
|
|
63
|
+
request_id: "rate-request",
|
|
64
|
+
});
|
|
65
|
+
(client as any).client = {
|
|
66
|
+
chat: {
|
|
67
|
+
completions: {
|
|
68
|
+
create: jest.fn(() => ({
|
|
69
|
+
withResponse: async () => {
|
|
70
|
+
throw error;
|
|
71
|
+
},
|
|
72
|
+
})),
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
await expect(
|
|
78
|
+
client.createChatCompletion({
|
|
79
|
+
...completionOptions,
|
|
80
|
+
onResponseMetadata: observer,
|
|
81
|
+
})
|
|
82
|
+
).rejects.toBe(error);
|
|
83
|
+
expect(observer).toHaveBeenCalledWith({
|
|
84
|
+
statusCode: 429,
|
|
85
|
+
requestId: "rate-request",
|
|
86
|
+
rateLimit: { retryAfterSeconds: 2 },
|
|
87
|
+
headers: { "retry-after": "2" },
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import {
|
|
2
|
+
emitResponseMetadata,
|
|
3
|
+
normalizeRateLimitMetadata,
|
|
4
|
+
normalizeResponseMetadata,
|
|
5
|
+
} from "../../../src/clients/responseMetadata";
|
|
6
|
+
|
|
7
|
+
describe("response metadata", () => {
|
|
8
|
+
it("normalizes and allowlists safe headers and derives a request id", () => {
|
|
9
|
+
const metadata = normalizeResponseMetadata({
|
|
10
|
+
statusCode: 429,
|
|
11
|
+
headers: {
|
|
12
|
+
"X-RateLimit-Remaining": "0",
|
|
13
|
+
"X-Request-Id": "request-123",
|
|
14
|
+
Authorization: "Bearer secret",
|
|
15
|
+
Cookie: "session=secret",
|
|
16
|
+
"X-Private-Value": "never expose",
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
expect(metadata).toEqual({
|
|
21
|
+
statusCode: 429,
|
|
22
|
+
requestId: "request-123",
|
|
23
|
+
rateLimit: { remaining: 0 },
|
|
24
|
+
headers: {
|
|
25
|
+
"x-ratelimit-remaining": "0",
|
|
26
|
+
"x-request-id": "request-123",
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("uses an explicit request id and retains only the first duplicate header", () => {
|
|
32
|
+
const metadata = normalizeResponseMetadata({
|
|
33
|
+
requestId: "explicit-id",
|
|
34
|
+
headers: [
|
|
35
|
+
["Retry-After", "1"],
|
|
36
|
+
["retry-after", "2"],
|
|
37
|
+
["CF-Ray", "ray-id"],
|
|
38
|
+
],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(metadata.requestId).toBe("explicit-id");
|
|
42
|
+
expect(metadata.headers).toEqual({
|
|
43
|
+
"retry-after": "1",
|
|
44
|
+
"cf-ray": "ray-id",
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("normalizes standard and OpenAI-compatible limits into JSON-safe values", () => {
|
|
49
|
+
const now = new Date("2026-08-23T00:00:00.000Z");
|
|
50
|
+
expect(
|
|
51
|
+
normalizeRateLimitMetadata(
|
|
52
|
+
{
|
|
53
|
+
"ratelimit-limit": "100",
|
|
54
|
+
"ratelimit-remaining": "9",
|
|
55
|
+
"ratelimit-reset": "60",
|
|
56
|
+
"retry-after": "2",
|
|
57
|
+
"x-ratelimit-limit-tokens": "2000",
|
|
58
|
+
"x-ratelimit-remaining-tokens": "100",
|
|
59
|
+
"x-ratelimit-reset-tokens": "1m30s",
|
|
60
|
+
},
|
|
61
|
+
now
|
|
62
|
+
)
|
|
63
|
+
).toEqual({
|
|
64
|
+
limit: 100,
|
|
65
|
+
remaining: 9,
|
|
66
|
+
resetAt: "2026-08-23T00:01:00.000Z",
|
|
67
|
+
retryAfterSeconds: 2,
|
|
68
|
+
tokenLimit: 2000,
|
|
69
|
+
tokenRemaining: 100,
|
|
70
|
+
tokenResetAt: "2026-08-23T00:01:30.000Z",
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("normalizes Anthropic request and token windows", () => {
|
|
75
|
+
expect(
|
|
76
|
+
normalizeRateLimitMetadata(
|
|
77
|
+
{
|
|
78
|
+
"anthropic-ratelimit-requests-limit": "50",
|
|
79
|
+
"anthropic-ratelimit-requests-remaining": "10",
|
|
80
|
+
"anthropic-ratelimit-requests-reset": "2026-08-23T00:01:00Z",
|
|
81
|
+
"anthropic-ratelimit-tokens-limit": "40000",
|
|
82
|
+
"anthropic-ratelimit-tokens-remaining": "5000",
|
|
83
|
+
"anthropic-ratelimit-tokens-reset": "2026-08-23T00:02:00Z",
|
|
84
|
+
},
|
|
85
|
+
new Date("2026-08-23T00:00:00Z")
|
|
86
|
+
)
|
|
87
|
+
).toEqual({
|
|
88
|
+
limit: 50,
|
|
89
|
+
remaining: 10,
|
|
90
|
+
resetAt: "2026-08-23T00:01:00.000Z",
|
|
91
|
+
tokenLimit: 40000,
|
|
92
|
+
tokenRemaining: 5000,
|
|
93
|
+
tokenResetAt: "2026-08-23T00:02:00.000Z",
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("bounds retained values and isolates observer failures", () => {
|
|
98
|
+
const value = "x".repeat(300);
|
|
99
|
+
const observer = jest.fn(() => {
|
|
100
|
+
throw new Error("telemetry failure");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(() =>
|
|
104
|
+
emitResponseMetadata(
|
|
105
|
+
{ onResponseMetadata: observer },
|
|
106
|
+
{ headers: { "x-ratelimit-reset": value } }
|
|
107
|
+
)
|
|
108
|
+
).not.toThrow();
|
|
109
|
+
expect(observer).toHaveBeenCalledWith({
|
|
110
|
+
rateLimit: {},
|
|
111
|
+
headers: { "x-ratelimit-reset": "x".repeat(256) },
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("limits retained header count and aggregate data", () => {
|
|
116
|
+
const headers: Record<string, string> = {};
|
|
117
|
+
for (let index = 0; index < 30; index++)
|
|
118
|
+
headers[`x-ratelimit-test-${index}`] = String(index);
|
|
119
|
+
expect(
|
|
120
|
+
Object.keys(normalizeResponseMetadata({ headers }).headers)
|
|
121
|
+
).toHaveLength(24);
|
|
122
|
+
});
|
|
123
|
+
});
|
package/tests/unit/login.test.ts
CHANGED
|
@@ -26,7 +26,10 @@ jest.mock("../../src/auth/keyAuth", () => ({
|
|
|
26
26
|
registerPublicKey,
|
|
27
27
|
}));
|
|
28
28
|
|
|
29
|
-
jest.mock("../../src/auth/keyManager", () => ({
|
|
29
|
+
jest.mock("../../src/auth/keyManager", () => ({
|
|
30
|
+
getDefaultPrivateKeyPath: () => "/keys/default",
|
|
31
|
+
getOrCreatePublicKey,
|
|
32
|
+
}));
|
|
30
33
|
jest.mock("../../src/config", () => ({ getConfig, updateConfig }));
|
|
31
34
|
jest.mock("../../src/utils", () => ({ ask: jest.fn() }));
|
|
32
35
|
|
|
@@ -88,6 +91,41 @@ describe("login public-key bootstrap", () => {
|
|
|
88
91
|
);
|
|
89
92
|
});
|
|
90
93
|
|
|
94
|
+
it("does not persist a temporary identity path into project configuration", async () => {
|
|
95
|
+
const temporaryIdentity = path.join(os.tmpdir(), "knowhow-login-test-key");
|
|
96
|
+
getConfig.mockResolvedValue({
|
|
97
|
+
orgId: "org-1",
|
|
98
|
+
cliIdentityPath: "/tmp/knowhow-csrf-e2e-key",
|
|
99
|
+
modelProviders: [],
|
|
100
|
+
});
|
|
101
|
+
registerPublicKey.mockResolvedValueOnce(undefined);
|
|
102
|
+
|
|
103
|
+
await expect(login(false, temporaryIdentity)).resolves.toBeUndefined();
|
|
104
|
+
|
|
105
|
+
expect(getOrCreatePublicKey).toHaveBeenCalledWith(temporaryIdentity);
|
|
106
|
+
expect(updateConfig).toHaveBeenCalledTimes(1);
|
|
107
|
+
expect(updateConfig.mock.calls[0][0]).toEqual(
|
|
108
|
+
expect.not.objectContaining({
|
|
109
|
+
cliIdentityPath: expect.any(String),
|
|
110
|
+
})
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("persists the default identity selected during browser bootstrap", async () => {
|
|
115
|
+
registerPublicKey.mockResolvedValueOnce(undefined);
|
|
116
|
+
|
|
117
|
+
await expect(login(false)).resolves.toBeUndefined();
|
|
118
|
+
|
|
119
|
+
expect(getOrCreatePublicKey).toHaveBeenCalledWith("/keys/default");
|
|
120
|
+
expect(updateConfig).toHaveBeenCalledWith(
|
|
121
|
+
expect.objectContaining({
|
|
122
|
+
orgId: "org-1",
|
|
123
|
+
cliIdentityPath: "/keys/default",
|
|
124
|
+
modelProviders: [{ provider: "knowhow" }],
|
|
125
|
+
})
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
91
129
|
it("safely falls back to browser PKCE when the selected key cannot authenticate", async () => {
|
|
92
130
|
hasKeyPair.mockReturnValue(true);
|
|
93
131
|
getConfig.mockResolvedValue({ orgId: "org-1", modelProviders: [] });
|