@tyvm/knowhow 0.0.156 → 0.0.158

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.
Files changed (155) hide show
  1. package/README.md +17 -1
  2. package/package.json +1 -1
  3. package/src/agents/base/base.ts +176 -39
  4. package/src/auth/browserLogin.ts +2 -13
  5. package/src/auth/environmentAuth.ts +51 -0
  6. package/src/auth/jwtStore.ts +70 -0
  7. package/src/auth/keyAuth.ts +4 -15
  8. package/src/auth/registerIdentity.ts +8 -8
  9. package/src/chat/modules/AgentModule.ts +3 -3
  10. package/src/chat/modules/RemoteSyncModule.ts +45 -17
  11. package/src/cli.ts +2 -0
  12. package/src/clients/anthropic.ts +156 -79
  13. package/src/clients/attribution.ts +53 -0
  14. package/src/clients/http.ts +211 -84
  15. package/src/clients/index.ts +45 -13
  16. package/src/clients/openai.ts +441 -220
  17. package/src/clients/openrouter.ts +10 -3
  18. package/src/clients/pricing/fireworks.ts +32 -2
  19. package/src/clients/pricing/openai.ts +15 -1
  20. package/src/clients/pricing/types.ts +15 -0
  21. package/src/clients/responseMetadata.ts +221 -0
  22. package/src/clients/types.ts +49 -4
  23. package/src/commands/mcp.ts +1 -0
  24. package/src/commands/misc.ts +1 -1
  25. package/src/commands/remote.ts +77 -0
  26. package/src/config.ts +9 -1
  27. package/src/login.ts +54 -42
  28. package/src/remotes.ts +110 -0
  29. package/src/services/KnowhowClient.ts +12 -16
  30. package/src/services/Mcp.ts +94 -7
  31. package/src/services/McpWebsocketTransport.ts +14 -11
  32. package/src/types.ts +12 -0
  33. package/tests/unit/McpWebsocketTransport.test.ts +56 -0
  34. package/tests/unit/auth/jwtStore.test.ts +55 -0
  35. package/tests/unit/auth/registerIdentity.test.ts +8 -2
  36. package/tests/unit/clients/anthropic.responseMetadata.test.ts +81 -0
  37. package/tests/unit/clients/attribution.test.ts +185 -0
  38. package/tests/unit/clients/http.responseMetadata.test.ts +86 -0
  39. package/tests/unit/clients/openai.responseMetadata.test.ts +90 -0
  40. package/tests/unit/clients/responseMetadata.test.ts +123 -0
  41. package/tests/unit/commands/remote.test.ts +79 -0
  42. package/tests/unit/login.test.ts +64 -3
  43. package/tests/unit/services/KnowhowClient.test.ts +2 -2
  44. package/tests/unit/services/Mcp.test.ts +2 -2
  45. package/ts_build/package.json +1 -1
  46. package/ts_build/src/agents/base/base.d.ts +8 -3
  47. package/ts_build/src/agents/base/base.js +112 -26
  48. package/ts_build/src/agents/base/base.js.map +1 -1
  49. package/ts_build/src/auth/browserLogin.js +2 -7
  50. package/ts_build/src/auth/browserLogin.js.map +1 -1
  51. package/ts_build/src/auth/environmentAuth.d.ts +5 -0
  52. package/ts_build/src/auth/environmentAuth.js +48 -0
  53. package/ts_build/src/auth/environmentAuth.js.map +1 -0
  54. package/ts_build/src/auth/jwtStore.d.ts +4 -0
  55. package/ts_build/src/auth/jwtStore.js +65 -0
  56. package/ts_build/src/auth/jwtStore.js.map +1 -0
  57. package/ts_build/src/auth/keyAuth.d.ts +1 -1
  58. package/ts_build/src/auth/keyAuth.js +4 -16
  59. package/ts_build/src/auth/keyAuth.js.map +1 -1
  60. package/ts_build/src/auth/registerIdentity.js +6 -7
  61. package/ts_build/src/auth/registerIdentity.js.map +1 -1
  62. package/ts_build/src/chat/modules/AgentModule.js +3 -3
  63. package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
  64. package/ts_build/src/chat/modules/RemoteSyncModule.d.ts +1 -0
  65. package/ts_build/src/chat/modules/RemoteSyncModule.js +19 -7
  66. package/ts_build/src/chat/modules/RemoteSyncModule.js.map +1 -1
  67. package/ts_build/src/cli.js +2 -0
  68. package/ts_build/src/cli.js.map +1 -1
  69. package/ts_build/src/clients/anthropic.d.ts +1 -0
  70. package/ts_build/src/clients/anthropic.js +83 -38
  71. package/ts_build/src/clients/anthropic.js.map +1 -1
  72. package/ts_build/src/clients/attribution.d.ts +2 -0
  73. package/ts_build/src/clients/attribution.js +32 -0
  74. package/ts_build/src/clients/attribution.js.map +1 -0
  75. package/ts_build/src/clients/http.js +100 -20
  76. package/ts_build/src/clients/http.js.map +1 -1
  77. package/ts_build/src/clients/index.d.ts +2 -1
  78. package/ts_build/src/clients/index.js +24 -6
  79. package/ts_build/src/clients/index.js.map +1 -1
  80. package/ts_build/src/clients/openai.d.ts +2 -1
  81. package/ts_build/src/clients/openai.js +225 -99
  82. package/ts_build/src/clients/openai.js.map +1 -1
  83. package/ts_build/src/clients/openrouter.d.ts +2 -2
  84. package/ts_build/src/clients/openrouter.js +6 -2
  85. package/ts_build/src/clients/openrouter.js.map +1 -1
  86. package/ts_build/src/clients/pricing/fireworks.d.ts +4 -0
  87. package/ts_build/src/clients/pricing/fireworks.js +14 -2
  88. package/ts_build/src/clients/pricing/fireworks.js.map +1 -1
  89. package/ts_build/src/clients/pricing/openai.js +15 -1
  90. package/ts_build/src/clients/pricing/openai.js.map +1 -1
  91. package/ts_build/src/clients/pricing/types.d.ts +6 -0
  92. package/ts_build/src/clients/pricing/types.js.map +1 -1
  93. package/ts_build/src/clients/responseMetadata.d.ts +15 -0
  94. package/ts_build/src/clients/responseMetadata.js +141 -0
  95. package/ts_build/src/clients/responseMetadata.js.map +1 -0
  96. package/ts_build/src/clients/types.d.ts +18 -1
  97. package/ts_build/src/clients/types.js +2 -1
  98. package/ts_build/src/clients/types.js.map +1 -1
  99. package/ts_build/src/commands/mcp.js.map +1 -1
  100. package/ts_build/src/commands/misc.js +1 -1
  101. package/ts_build/src/commands/misc.js.map +1 -1
  102. package/ts_build/src/commands/remote.d.ts +2 -0
  103. package/ts_build/src/commands/remote.js +70 -0
  104. package/ts_build/src/commands/remote.js.map +1 -0
  105. package/ts_build/src/config.js +8 -0
  106. package/ts_build/src/config.js.map +1 -1
  107. package/ts_build/src/login.d.ts +4 -3
  108. package/ts_build/src/login.js +34 -35
  109. package/ts_build/src/login.js.map +1 -1
  110. package/ts_build/src/remotes.d.ts +14 -0
  111. package/ts_build/src/remotes.js +117 -0
  112. package/ts_build/src/remotes.js.map +1 -0
  113. package/ts_build/src/services/KnowhowClient.d.ts +1 -1
  114. package/ts_build/src/services/KnowhowClient.js +12 -14
  115. package/ts_build/src/services/KnowhowClient.js.map +1 -1
  116. package/ts_build/src/services/Mcp.d.ts +1 -1
  117. package/ts_build/src/services/Mcp.js +68 -7
  118. package/ts_build/src/services/Mcp.js.map +1 -1
  119. package/ts_build/src/services/McpWebsocketTransport.d.ts +1 -0
  120. package/ts_build/src/services/McpWebsocketTransport.js +11 -6
  121. package/ts_build/src/services/McpWebsocketTransport.js.map +1 -1
  122. package/ts_build/src/types.d.ts +13 -0
  123. package/ts_build/src/types.js.map +1 -1
  124. package/ts_build/tests/unit/McpWebsocketTransport.test.d.ts +1 -0
  125. package/ts_build/tests/unit/McpWebsocketTransport.test.js +47 -0
  126. package/ts_build/tests/unit/McpWebsocketTransport.test.js.map +1 -0
  127. package/ts_build/tests/unit/auth/jwtStore.test.d.ts +1 -0
  128. package/ts_build/tests/unit/auth/jwtStore.test.js +46 -0
  129. package/ts_build/tests/unit/auth/jwtStore.test.js.map +1 -0
  130. package/ts_build/tests/unit/auth/registerIdentity.test.js +8 -2
  131. package/ts_build/tests/unit/auth/registerIdentity.test.js.map +1 -1
  132. package/ts_build/tests/unit/clients/anthropic.responseMetadata.test.d.ts +1 -0
  133. package/ts_build/tests/unit/clients/anthropic.responseMetadata.test.js +77 -0
  134. package/ts_build/tests/unit/clients/anthropic.responseMetadata.test.js.map +1 -0
  135. package/ts_build/tests/unit/clients/attribution.test.d.ts +1 -0
  136. package/ts_build/tests/unit/clients/attribution.test.js +153 -0
  137. package/ts_build/tests/unit/clients/attribution.test.js.map +1 -0
  138. package/ts_build/tests/unit/clients/http.responseMetadata.test.d.ts +1 -0
  139. package/ts_build/tests/unit/clients/http.responseMetadata.test.js +77 -0
  140. package/ts_build/tests/unit/clients/http.responseMetadata.test.js.map +1 -0
  141. package/ts_build/tests/unit/clients/openai.responseMetadata.test.d.ts +1 -0
  142. package/ts_build/tests/unit/clients/openai.responseMetadata.test.js +86 -0
  143. package/ts_build/tests/unit/clients/openai.responseMetadata.test.js.map +1 -0
  144. package/ts_build/tests/unit/clients/responseMetadata.test.d.ts +1 -0
  145. package/ts_build/tests/unit/clients/responseMetadata.test.js +96 -0
  146. package/ts_build/tests/unit/clients/responseMetadata.test.js.map +1 -0
  147. package/ts_build/tests/unit/commands/remote.test.d.ts +1 -0
  148. package/ts_build/tests/unit/commands/remote.test.js +70 -0
  149. package/ts_build/tests/unit/commands/remote.test.js.map +1 -0
  150. package/ts_build/tests/unit/login.test.js +44 -3
  151. package/ts_build/tests/unit/login.test.js.map +1 -1
  152. package/ts_build/tests/unit/services/KnowhowClient.test.js +2 -2
  153. package/ts_build/tests/unit/services/KnowhowClient.test.js.map +1 -1
  154. package/ts_build/tests/unit/services/Mcp.test.js +2 -2
  155. package/ts_build/tests/unit/services/Mcp.test.js.map +1 -1
@@ -0,0 +1,55 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import {
5
+ getJwtFileName,
6
+ loadJwtFromDisk,
7
+ storeJwtForApi,
8
+ } from "../../../src/auth/jwtStore";
9
+
10
+ describe("environment-specific JWT storage", () => {
11
+ const originalCwd = process.cwd();
12
+ let workDir: string;
13
+
14
+ beforeEach(() => {
15
+ workDir = fs.mkdtempSync(path.join(os.tmpdir(), "knowhow-jwt-store-"));
16
+ process.chdir(workDir);
17
+ });
18
+
19
+ afterEach(() => {
20
+ process.chdir(originalCwd);
21
+ fs.rmSync(workDir, { recursive: true, force: true });
22
+ });
23
+
24
+ it("uses readable names for production, development, and local APIs", () => {
25
+ expect(getJwtFileName("https://api.knowhow.tyvm.ai")).toBe(".jwt");
26
+ expect(getJwtFileName("https://api.dev.knowhow.tyvm.ai")).toBe(".jwt.dev");
27
+ expect(getJwtFileName("http://localhost:4000")).toBe(".jwt.local");
28
+ });
29
+
30
+ it("keeps tokens for each API environment independent", () => {
31
+ storeJwtForApi("prod-token", "https://api.knowhow.tyvm.ai");
32
+ storeJwtForApi("dev-token", "https://api.dev.knowhow.tyvm.ai");
33
+ storeJwtForApi("local-token", "http://localhost:4000");
34
+
35
+ expect(loadJwtFromDisk("https://api.knowhow.tyvm.ai")).toBe("prod-token");
36
+ expect(loadJwtFromDisk("https://api.dev.knowhow.tyvm.ai")).toBe("dev-token");
37
+ expect(loadJwtFromDisk("http://localhost:4000")).toBe("local-token");
38
+ expect(fs.statSync(path.join(workDir, ".knowhow", ".jwt.dev")).mode & 0o777).toBe(0o600);
39
+ });
40
+
41
+ it("does not send the legacy production token to another environment", () => {
42
+ const configDir = path.join(workDir, ".knowhow");
43
+ fs.mkdirSync(configDir, { recursive: true });
44
+ fs.writeFileSync(path.join(configDir, ".jwt"), "production-token");
45
+
46
+ expect(loadJwtFromDisk("https://api.dev.knowhow.tyvm.ai")).toBe("");
47
+ expect(loadJwtFromDisk("https://api.knowhow.tyvm.ai")).toBe("production-token");
48
+ });
49
+
50
+ it("uses a host-specific file for additional Knowhow instances", () => {
51
+ expect(getJwtFileName("https://api.staging.example.com:8443")).toBe(
52
+ ".jwt.api.staging.example.com-8443"
53
+ );
54
+ });
55
+ });
@@ -31,6 +31,7 @@ describe("registerIdentity", () => {
31
31
  workDir = fs.mkdtempSync(path.join(os.tmpdir(), "knowhow-register-identity-"));
32
32
  fs.mkdirSync(path.join(workDir, ".knowhow"), { recursive: true });
33
33
  fs.writeFileSync(path.join(workDir, ".knowhow", ".jwt"), sourceJwt);
34
+ fs.writeFileSync(path.join(workDir, ".knowhow", ".jwt.api.example"), sourceJwt);
34
35
  process.chdir(workDir);
35
36
  getOrCreatePublicKey.mockReturnValue({
36
37
  publicKeyBase64,
@@ -53,11 +54,16 @@ describe("registerIdentity", () => {
53
54
  await registerIdentity({ apiUrl: "https://api.example" });
54
55
  expect(registerPublicKey).toHaveBeenCalledWith(sourceJwt, publicKeyBase64, "https://api.example");
55
56
  expect(exchangePublicKeyJwt).toHaveBeenCalledWith("org-test", "https://api.example", undefined);
56
- expect(storeJwt).toHaveBeenCalledWith("identity.jwt.token");
57
+ expect(storeJwt).toHaveBeenCalledWith("identity.jwt.token", "https://api.example");
57
58
  expect(updateConfig).toHaveBeenCalledWith(expect.objectContaining({
58
- orgId: "org-test",
59
59
  modelProviders: [{ provider: "knowhow" }],
60
60
  cliIdentityPath: expect.stringContaining("id_ed25519"),
61
+ remotes: {
62
+ origin: expect.objectContaining({
63
+ apiUrl: "https://api.example",
64
+ orgId: "org-test",
65
+ }),
66
+ },
61
67
  }));
62
68
  });
63
69
 
@@ -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
+ });