@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.
Files changed (116) hide show
  1. package/package.json +1 -1
  2. package/src/agents/base/base.ts +15 -5
  3. package/src/agents/tools/index.ts +1 -0
  4. package/src/agents/tools/list.ts +2 -0
  5. package/src/agents/tools/loadImageAsBase64.ts +83 -0
  6. package/src/auth/keyAuth.ts +5 -5
  7. package/src/chat/modules/AgentModule.ts +3 -3
  8. package/src/chat/modules/RemoteSyncModule.ts +45 -17
  9. package/src/clients/anthropic.ts +156 -79
  10. package/src/clients/attribution.ts +53 -0
  11. package/src/clients/http.ts +211 -84
  12. package/src/clients/index.ts +21 -9
  13. package/src/clients/openai.ts +441 -220
  14. package/src/clients/openrouter.ts +10 -3
  15. package/src/clients/pricing/openai.ts +15 -1
  16. package/src/clients/pricing/types.ts +15 -0
  17. package/src/clients/responseMetadata.ts +221 -0
  18. package/src/clients/types.ts +49 -4
  19. package/src/commands/services.ts +23 -18
  20. package/src/login.ts +22 -6
  21. package/src/processors/Base64ImageDetector.ts +12 -11
  22. package/src/tunnel.ts +13 -3
  23. package/tests/processors/Base64ImageDetector.test.ts +32 -0
  24. package/tests/unit/agents/tools/loadImageAsBase64.test.ts +44 -0
  25. package/tests/unit/auth/keyAuth.test.ts +21 -0
  26. package/tests/unit/clients/anthropic.responseMetadata.test.ts +81 -0
  27. package/tests/unit/clients/attribution.test.ts +185 -0
  28. package/tests/unit/clients/http.responseMetadata.test.ts +86 -0
  29. package/tests/unit/clients/openai.responseMetadata.test.ts +90 -0
  30. package/tests/unit/clients/responseMetadata.test.ts +123 -0
  31. package/tests/unit/login.test.ts +39 -1
  32. package/tests/unit/services/Mcp.test.ts +56 -0
  33. package/tests/unit/tunnel.test.ts +51 -0
  34. package/ts_build/package.json +1 -1
  35. package/ts_build/src/agents/base/base.js +14 -4
  36. package/ts_build/src/agents/base/base.js.map +1 -1
  37. package/ts_build/src/agents/tools/index.d.ts +1 -0
  38. package/ts_build/src/agents/tools/index.js +1 -0
  39. package/ts_build/src/agents/tools/index.js.map +1 -1
  40. package/ts_build/src/agents/tools/list.js +2 -0
  41. package/ts_build/src/agents/tools/list.js.map +1 -1
  42. package/ts_build/src/agents/tools/loadImageAsBase64.d.ts +3 -0
  43. package/ts_build/src/agents/tools/loadImageAsBase64.js +79 -0
  44. package/ts_build/src/agents/tools/loadImageAsBase64.js.map +1 -0
  45. package/ts_build/src/auth/keyAuth.js +5 -5
  46. package/ts_build/src/auth/keyAuth.js.map +1 -1
  47. package/ts_build/src/chat/modules/AgentModule.js +3 -3
  48. package/ts_build/src/chat/modules/AgentModule.js.map +1 -1
  49. package/ts_build/src/chat/modules/RemoteSyncModule.d.ts +1 -0
  50. package/ts_build/src/chat/modules/RemoteSyncModule.js +19 -7
  51. package/ts_build/src/chat/modules/RemoteSyncModule.js.map +1 -1
  52. package/ts_build/src/clients/anthropic.d.ts +1 -0
  53. package/ts_build/src/clients/anthropic.js +83 -38
  54. package/ts_build/src/clients/anthropic.js.map +1 -1
  55. package/ts_build/src/clients/attribution.d.ts +2 -0
  56. package/ts_build/src/clients/attribution.js +32 -0
  57. package/ts_build/src/clients/attribution.js.map +1 -0
  58. package/ts_build/src/clients/http.js +100 -20
  59. package/ts_build/src/clients/http.js.map +1 -1
  60. package/ts_build/src/clients/index.d.ts +2 -1
  61. package/ts_build/src/clients/index.js +6 -2
  62. package/ts_build/src/clients/index.js.map +1 -1
  63. package/ts_build/src/clients/openai.d.ts +2 -1
  64. package/ts_build/src/clients/openai.js +225 -99
  65. package/ts_build/src/clients/openai.js.map +1 -1
  66. package/ts_build/src/clients/openrouter.d.ts +2 -2
  67. package/ts_build/src/clients/openrouter.js +6 -2
  68. package/ts_build/src/clients/openrouter.js.map +1 -1
  69. package/ts_build/src/clients/pricing/openai.js +15 -1
  70. package/ts_build/src/clients/pricing/openai.js.map +1 -1
  71. package/ts_build/src/clients/pricing/types.d.ts +6 -0
  72. package/ts_build/src/clients/pricing/types.js.map +1 -1
  73. package/ts_build/src/clients/responseMetadata.d.ts +15 -0
  74. package/ts_build/src/clients/responseMetadata.js +141 -0
  75. package/ts_build/src/clients/responseMetadata.js.map +1 -0
  76. package/ts_build/src/clients/types.d.ts +18 -1
  77. package/ts_build/src/clients/types.js +2 -1
  78. package/ts_build/src/clients/types.js.map +1 -1
  79. package/ts_build/src/commands/services.js +15 -8
  80. package/ts_build/src/commands/services.js.map +1 -1
  81. package/ts_build/src/login.js +19 -5
  82. package/ts_build/src/login.js.map +1 -1
  83. package/ts_build/src/processors/Base64ImageDetector.js +7 -3
  84. package/ts_build/src/processors/Base64ImageDetector.js.map +1 -1
  85. package/ts_build/src/tunnel.js +9 -1
  86. package/ts_build/src/tunnel.js.map +1 -1
  87. package/ts_build/tests/processors/Base64ImageDetector.test.js +28 -0
  88. package/ts_build/tests/processors/Base64ImageDetector.test.js.map +1 -1
  89. package/ts_build/tests/unit/agents/tools/loadImageAsBase64.test.d.ts +1 -0
  90. package/ts_build/tests/unit/agents/tools/loadImageAsBase64.test.js +67 -0
  91. package/ts_build/tests/unit/agents/tools/loadImageAsBase64.test.js.map +1 -0
  92. package/ts_build/tests/unit/auth/keyAuth.test.js +21 -0
  93. package/ts_build/tests/unit/auth/keyAuth.test.js.map +1 -1
  94. package/ts_build/tests/unit/clients/anthropic.responseMetadata.test.d.ts +1 -0
  95. package/ts_build/tests/unit/clients/anthropic.responseMetadata.test.js +77 -0
  96. package/ts_build/tests/unit/clients/anthropic.responseMetadata.test.js.map +1 -0
  97. package/ts_build/tests/unit/clients/attribution.test.d.ts +1 -0
  98. package/ts_build/tests/unit/clients/attribution.test.js +153 -0
  99. package/ts_build/tests/unit/clients/attribution.test.js.map +1 -0
  100. package/ts_build/tests/unit/clients/http.responseMetadata.test.d.ts +1 -0
  101. package/ts_build/tests/unit/clients/http.responseMetadata.test.js +77 -0
  102. package/ts_build/tests/unit/clients/http.responseMetadata.test.js.map +1 -0
  103. package/ts_build/tests/unit/clients/openai.responseMetadata.test.d.ts +1 -0
  104. package/ts_build/tests/unit/clients/openai.responseMetadata.test.js +86 -0
  105. package/ts_build/tests/unit/clients/openai.responseMetadata.test.js.map +1 -0
  106. package/ts_build/tests/unit/clients/responseMetadata.test.d.ts +1 -0
  107. package/ts_build/tests/unit/clients/responseMetadata.test.js +96 -0
  108. package/ts_build/tests/unit/clients/responseMetadata.test.js.map +1 -0
  109. package/ts_build/tests/unit/login.test.js +29 -1
  110. package/ts_build/tests/unit/login.test.js.map +1 -1
  111. package/ts_build/tests/unit/services/Mcp.test.d.ts +1 -0
  112. package/ts_build/tests/unit/services/Mcp.test.js +45 -0
  113. package/ts_build/tests/unit/services/Mcp.test.js.map +1 -0
  114. package/ts_build/tests/unit/tunnel.test.d.ts +1 -0
  115. package/ts_build/tests/unit/tunnel.test.js +47 -0
  116. 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(apiKey = process.env.OPENROUTER_API_KEY) {
13
- super("https://openrouter.ai/api");
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]: { input: 5.0, cached_input: 0.5, cache_write: 6.25, output: 30.0, input_gt_200k: 10.0, output_gt_200k: 45.0 },
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
+ }
@@ -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 = (typeof REASONING_EFFORTS)[number];
188
+ export type ReasoningEffort = typeof REASONING_EFFORTS[number];
154
189
 
155
190
  export function isReasoningEffort(value: unknown): value is ReasoningEffort {
156
- return typeof value === "string" && REASONING_EFFORTS.includes(value as ReasoningEffort);
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?(options: CompletionOptions): AsyncGenerator<StreamChunk>;
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?(model?: string): import("./pricing/types").ModelPricing | Record<string, import("./pricing/types").ModelPricing> | undefined;
525
+ getPricing?(
526
+ model?: string
527
+ ):
528
+ | import("./pricing/types").ModelPricing
529
+ | Record<string, import("./pricing/types").ModelPricing>
530
+ | undefined;
486
531
  }
@@ -69,32 +69,37 @@ export async function setupServices() {
69
69
 
70
70
  Agents.setAgentContext(agentContext);
71
71
 
72
- console.log("🔌 Connecting to MCP...");
73
- try {
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 (_err) {
95
- // Non-fatal: if SSH refresh fails (no key, revoked, network issue), we
96
- // continue with the existing JWT (or no JWT). The user will see model
97
- // warnings if no valid JWT is available.
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(identityPath)) {
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, identityPath);
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(identityPath);
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(identityPath);
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
- // Process user messages (images from user input)
329
- if (lastMessage.role === "user") {
330
- this.processMessageContent(lastMessage);
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
- // Process tool messages (images from loadImageAsBase64 tool)
334
- // Tool responses come back as JSON strings that need to be parsed
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 dev API URLs use the dev
18
- * tunnel domain and all other API URLs use the production tunnel domain.
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
- (isDevApi ? `worker.dev.tyvm-apps.com` : PROD_TUNNEL_WORKER_DOMAIN);
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
+ });