assistant-cloud 0.1.42 → 0.1.43
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/dist/AssistantCloudAuthStrategy.d.ts.map +1 -1
- package/dist/AssistantCloudAuthStrategy.js +68 -17
- package/dist/AssistantCloudAuthStrategy.js.map +1 -1
- package/dist/CloudMessagePersistence.d.ts.map +1 -1
- package/dist/CloudMessagePersistence.js +19 -11
- package/dist/CloudMessagePersistence.js.map +1 -1
- package/dist/runTelemetry.d.ts +9 -2
- package/dist/runTelemetry.d.ts.map +1 -1
- package/dist/runTelemetry.js +8 -5
- package/dist/runTelemetry.js.map +1 -1
- package/package.json +4 -4
- package/src/AssistantCloudAuthStrategy.ts +140 -46
- package/src/CloudMessagePersistence.ts +23 -19
- package/src/runTelemetry.test.ts +41 -0
- package/src/runTelemetry.ts +14 -10
- package/src/tests/AssistantCloudAuthStrategy.test.ts +284 -10
- package/src/tests/CloudMessagePersistence.test.ts +93 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AssistantCloudAuthStrategy.d.ts","names":[],"sources":["../src/AssistantCloudAuthStrategy.ts"],"mappings":";
|
|
1
|
+
{"version":3,"file":"AssistantCloudAuthStrategy.d.ts","names":[],"sources":["../src/AssistantCloudAuthStrategy.ts"],"mappings":";KAkCY;WACD;EACT,kBAAkB,QAAQ;EAC1B,gBAAgB,SAAS;;cAmFd,yCAAyC;;WACpC;UAER;UACA;UACA;EAGI,YAAA,yBAAyB;EAIxB,kBAAkB,QAAQ;UA0BzB;EAUP,gBAAgB,SAAS;;cAcrB,4CAA4C;;WACvC;EAMJ,YAAA,gBAAgB,gBAAgB;EAM/B,kBAAkB,QAAQ;EAQhC;;cAwHI,+CAA+C;WAC1C;UAER;UACA;EAEI,YAAA;EAwFC,kBAAkB,QAAQ;EAIhC,gBAAgB,SAAS"}
|
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
import { CloudResponseError, readCloudRecord, readCloudString } from "./cloudResponse.js";
|
|
2
2
|
//#region src/AssistantCloudAuthStrategy.ts
|
|
3
|
+
const AUTH_TOKEN_REQUEST_TIMEOUT_MS = 3e4;
|
|
4
|
+
const withAuthTokenDeadline = async (operation, run) => {
|
|
5
|
+
const controller = new AbortController();
|
|
6
|
+
let timedOut = false;
|
|
7
|
+
const timeout = setTimeout(() => {
|
|
8
|
+
timedOut = true;
|
|
9
|
+
controller.abort();
|
|
10
|
+
}, AUTH_TOKEN_REQUEST_TIMEOUT_MS);
|
|
11
|
+
try {
|
|
12
|
+
return await run(controller.signal);
|
|
13
|
+
} catch (error) {
|
|
14
|
+
if (timedOut) throw new Error(`Assistant Cloud ${operation} timed out after ${AUTH_TOKEN_REQUEST_TIMEOUT_MS}ms`, { cause: error });
|
|
15
|
+
throw error;
|
|
16
|
+
} finally {
|
|
17
|
+
clearTimeout(timeout);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
3
20
|
const getJwtExpiry = (jwt) => {
|
|
4
21
|
try {
|
|
5
22
|
const bodyPart = jwt.split(".")[1];
|
|
@@ -149,36 +166,70 @@ const removeRefreshToken = (baseUrl) => {
|
|
|
149
166
|
storage.removeItem(getRefreshTokenName(baseUrl));
|
|
150
167
|
} catch {}
|
|
151
168
|
};
|
|
169
|
+
const anonymousAuthTokenRequests = /* @__PURE__ */ new WeakMap();
|
|
170
|
+
const getWebLockManager = () => {
|
|
171
|
+
if (!("navigator" in globalThis)) return null;
|
|
172
|
+
return globalThis.navigator?.locks ?? null;
|
|
173
|
+
};
|
|
174
|
+
const getAnonymousAuthLockName = (baseUrl) => `assistant-cloud:anonymous-auth:${baseUrl}`;
|
|
175
|
+
const getSharedAnonymousAuthToken = (baseUrl, requestToken) => {
|
|
176
|
+
const storage = getLocalStorage();
|
|
177
|
+
if (!storage) return requestToken();
|
|
178
|
+
let storageRequests = anonymousAuthTokenRequests.get(storage);
|
|
179
|
+
if (!storageRequests) {
|
|
180
|
+
storageRequests = /* @__PURE__ */ new Map();
|
|
181
|
+
anonymousAuthTokenRequests.set(storage, storageRequests);
|
|
182
|
+
}
|
|
183
|
+
const activeRequest = storageRequests.get(baseUrl);
|
|
184
|
+
if (activeRequest) return activeRequest;
|
|
185
|
+
const locks = getWebLockManager();
|
|
186
|
+
const sharedRequest = (locks ? locks.request(getAnonymousAuthLockName(baseUrl), requestToken) : requestToken()).finally(() => {
|
|
187
|
+
if (storageRequests.get(baseUrl) === sharedRequest) storageRequests.delete(baseUrl);
|
|
188
|
+
});
|
|
189
|
+
storageRequests.set(baseUrl, sharedRequest);
|
|
190
|
+
return sharedRequest;
|
|
191
|
+
};
|
|
152
192
|
var AssistantCloudAnonymousAuthStrategy = class {
|
|
153
193
|
strategy = "anon";
|
|
154
194
|
baseUrl;
|
|
155
195
|
jwtStrategy;
|
|
156
196
|
constructor(baseUrl) {
|
|
157
197
|
this.baseUrl = baseUrl;
|
|
158
|
-
|
|
198
|
+
const requestAuthToken = async () => {
|
|
159
199
|
const currentTime = Date.now();
|
|
160
200
|
const storedRefreshToken = readRefreshToken(this.baseUrl);
|
|
161
201
|
if (storedRefreshToken) {
|
|
162
202
|
if (new Date(storedRefreshToken.expires_at).getTime() - currentTime > 3e4) {
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
203
|
+
const refreshedAccessToken = await withAuthTokenDeadline("refresh token request", async (signal) => {
|
|
204
|
+
const response = await fetch(`${this.baseUrl}/v1/auth/tokens/refresh`, {
|
|
205
|
+
method: "POST",
|
|
206
|
+
headers: { "Content-Type": "application/json" },
|
|
207
|
+
body: JSON.stringify({ refresh_token: storedRefreshToken.token }),
|
|
208
|
+
signal
|
|
209
|
+
});
|
|
210
|
+
if (response.ok) {
|
|
211
|
+
const { data, accessToken } = await readAuthTokenResponse(response, "refresh auth token response");
|
|
212
|
+
if (data.refresh_token != null) writeRefreshToken(this.baseUrl, readRefreshTokenResponse(data.refresh_token, "refresh auth token response.refresh_token"));
|
|
213
|
+
return accessToken;
|
|
214
|
+
}
|
|
215
|
+
if (response.status === 429 || response.status >= 500) throw new Error(`Assistant Cloud token refresh failed with status ${response.status}`);
|
|
216
|
+
return null;
|
|
167
217
|
});
|
|
168
|
-
if (
|
|
169
|
-
const { data, accessToken } = await readAuthTokenResponse(response, "refresh auth token response");
|
|
170
|
-
if (data.refresh_token != null) writeRefreshToken(this.baseUrl, readRefreshTokenResponse(data.refresh_token, "refresh auth token response.refresh_token"));
|
|
171
|
-
return accessToken;
|
|
172
|
-
}
|
|
173
|
-
if (response.status === 429 || response.status >= 500) throw new Error(`Assistant Cloud token refresh failed with status ${response.status}`);
|
|
218
|
+
if (refreshedAccessToken !== null) return refreshedAccessToken;
|
|
174
219
|
} else removeRefreshToken(this.baseUrl);
|
|
175
220
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
221
|
+
return withAuthTokenDeadline("anonymous token request", async (signal) => {
|
|
222
|
+
const response = await fetch(`${this.baseUrl}/v1/auth/tokens/anonymous`, {
|
|
223
|
+
method: "POST",
|
|
224
|
+
signal
|
|
225
|
+
});
|
|
226
|
+
if (!response.ok) return null;
|
|
227
|
+
const { data, accessToken } = await readAuthTokenResponse(response, "anonymous auth token response");
|
|
228
|
+
writeRefreshToken(this.baseUrl, readRefreshTokenResponse(data.refresh_token, "anonymous auth token response.refresh_token"));
|
|
229
|
+
return accessToken;
|
|
230
|
+
});
|
|
231
|
+
};
|
|
232
|
+
this.jwtStrategy = new AssistantCloudJWTAuthStrategy(() => getSharedAnonymousAuthToken(this.baseUrl, requestAuthToken));
|
|
182
233
|
}
|
|
183
234
|
async getAuthHeaders() {
|
|
184
235
|
return this.jwtStrategy.getAuthHeaders();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AssistantCloudAuthStrategy.js","names":["#authTokenCallback","#apiKey","#userId","#workspaceId"],"sources":["../src/AssistantCloudAuthStrategy.ts"],"sourcesContent":["import {\n CloudResponseError,\n readCloudRecord,\n readCloudString,\n} from \"./cloudResponse\";\n\nexport type AssistantCloudAuthStrategy = {\n readonly strategy: \"anon\" | \"jwt\" | \"api-key\";\n getAuthHeaders(): Promise<Record<string, string> | false>;\n readAuthHeaders(headers: Headers): void;\n};\n\nconst getJwtExpiry = (jwt: string): number => {\n try {\n const parts = jwt.split(\".\");\n const bodyPart = parts[1];\n if (!bodyPart) {\n throw new Error(\"Invalid JWT format\");\n }\n\n // Convert from Base64Url to Base64 and add padding if necessary\n let base64 = bodyPart.replace(/-/g, \"+\").replace(/_/g, \"/\");\n while (base64.length % 4 !== 0) {\n base64 += \"=\";\n }\n\n // Decode the Base64 string and parse the payload\n const payload = atob(base64);\n const payloadObj = JSON.parse(payload);\n const exp = payloadObj.exp;\n\n if (!exp || typeof exp !== \"number\") {\n throw new Error('JWT does not contain a valid \"exp\" field');\n }\n\n // Convert expiration time to milliseconds\n return exp * 1000;\n } catch (error) {\n throw new Error(`Unable to determine the token expiry: ${error}`);\n }\n};\n\ntype RefreshToken = {\n token: string;\n expires_at: string;\n};\n\nconst readNonEmptyCloudString = (value: unknown, field: string): string => {\n const result = readCloudString(value, field);\n if (result.length === 0) {\n throw new CloudResponseError(\n `Invalid Assistant Cloud response for \"${field}\": expected a non-empty string`,\n );\n }\n return result;\n};\n\nconst readRefreshTokenResponse = (\n value: unknown,\n field: string,\n): RefreshToken => {\n const refreshToken = readCloudRecord(value, field);\n return {\n token: readNonEmptyCloudString(refreshToken.token, `${field}.token`),\n expires_at: readNonEmptyCloudString(\n refreshToken.expires_at,\n `${field}.expires_at`,\n ),\n };\n};\n\nconst readAuthTokenResponse = async (\n response: Response,\n field: string,\n): Promise<{ data: Record<string, unknown>; accessToken: string }> => {\n let value: unknown;\n try {\n value = await response.json();\n } catch {\n throw new CloudResponseError(\n `Invalid Assistant Cloud response for \"${field}\": expected valid JSON`,\n );\n }\n\n const data = readCloudRecord(value, field);\n const accessToken = readNonEmptyCloudString(\n data.access_token,\n `${field}.access_token`,\n );\n return { data, accessToken };\n};\n\nexport class AssistantCloudJWTAuthStrategy implements AssistantCloudAuthStrategy {\n public readonly strategy = \"jwt\";\n\n private cachedToken: string | null = null;\n private tokenExpiry: number | null = null;\n private tokenRequest: Promise<Record<string, string> | false> | null = null;\n #authTokenCallback: () => Promise<string | null>;\n\n constructor(authTokenCallback: () => Promise<string | null>) {\n this.#authTokenCallback = authTokenCallback;\n }\n\n public async getAuthHeaders(): Promise<Record<string, string> | false> {\n const currentTime = Date.now();\n\n // Use cached token if it's valid for at least 30 more seconds\n if (\n this.cachedToken &&\n this.tokenExpiry &&\n this.tokenExpiry - currentTime > 30 * 1000\n ) {\n return { Authorization: `Bearer ${this.cachedToken}` };\n }\n\n if (!this.tokenRequest) {\n this.tokenRequest = this.fetchAuthHeaders();\n }\n\n const tokenRequest = this.tokenRequest;\n try {\n return await tokenRequest;\n } finally {\n if (this.tokenRequest === tokenRequest) {\n this.tokenRequest = null;\n }\n }\n }\n\n private async fetchAuthHeaders(): Promise<Record<string, string> | false> {\n const token = await this.#authTokenCallback();\n if (!token) return false;\n\n this.cachedToken = token;\n this.tokenExpiry = getJwtExpiry(token);\n\n return { Authorization: `Bearer ${token}` };\n }\n\n public readAuthHeaders(headers: Headers) {\n const authHeader = headers.get(\"Authorization\");\n if (!authHeader) return;\n\n const [scheme, token] = authHeader.split(\" \");\n if (scheme !== \"Bearer\" || !token) {\n throw new Error(\"Invalid auth header received\");\n }\n\n this.cachedToken = token;\n this.tokenExpiry = getJwtExpiry(token);\n }\n}\n\nexport class AssistantCloudAPIKeyAuthStrategy implements AssistantCloudAuthStrategy {\n public readonly strategy = \"api-key\";\n\n #apiKey: string;\n #userId: string;\n #workspaceId: string;\n\n constructor(apiKey: string, userId: string, workspaceId: string) {\n this.#apiKey = apiKey;\n this.#userId = userId;\n this.#workspaceId = workspaceId;\n }\n\n public async getAuthHeaders(): Promise<Record<string, string>> {\n return {\n Authorization: `Bearer ${this.#apiKey}`,\n \"Aui-User-Id\": this.#userId,\n \"Aui-Workspace-Id\": this.#workspaceId,\n };\n }\n\n public readAuthHeaders() {\n // No operation needed for API key auth\n }\n}\n\nconst LEGACY_AUI_REFRESH_TOKEN_NAME = \"aui:refresh_token\";\n\nconst getRefreshTokenName = (baseUrl: string): string =>\n `${LEGACY_AUI_REFRESH_TOKEN_NAME}:${baseUrl}`;\n\nconst removeLegacyRefreshToken = (storage: Storage): void => {\n try {\n storage.removeItem(LEGACY_AUI_REFRESH_TOKEN_NAME);\n } catch {}\n};\n\nconst getLocalStorage = (): Storage | null => {\n if (!(\"localStorage\" in globalThis)) return null;\n try {\n return (globalThis as { localStorage: Storage }).localStorage;\n } catch {\n return null;\n }\n};\n\nconst readRefreshToken = (baseUrl: string): RefreshToken | undefined => {\n const storage = getLocalStorage();\n if (!storage) return undefined;\n try {\n const name = getRefreshTokenName(baseUrl);\n const value = storage.getItem(name);\n if (value) {\n removeLegacyRefreshToken(storage);\n return JSON.parse(value) as RefreshToken;\n }\n\n const legacyValue = storage.getItem(LEGACY_AUI_REFRESH_TOKEN_NAME);\n if (!legacyValue) return undefined;\n\n let refreshToken: RefreshToken;\n try {\n refreshToken = JSON.parse(legacyValue) as RefreshToken;\n } catch {\n removeLegacyRefreshToken(storage);\n return undefined;\n }\n\n storage.setItem(name, legacyValue);\n removeLegacyRefreshToken(storage);\n return refreshToken;\n } catch {\n return undefined;\n }\n};\n\nconst writeRefreshToken = (\n baseUrl: string,\n refreshToken: RefreshToken,\n): void => {\n const storage = getLocalStorage();\n if (!storage) return;\n try {\n storage.setItem(getRefreshTokenName(baseUrl), JSON.stringify(refreshToken));\n } catch {}\n};\n\nconst removeRefreshToken = (baseUrl: string): void => {\n const storage = getLocalStorage();\n if (!storage) return;\n try {\n storage.removeItem(getRefreshTokenName(baseUrl));\n } catch {}\n};\n\nexport class AssistantCloudAnonymousAuthStrategy implements AssistantCloudAuthStrategy {\n public readonly strategy = \"anon\";\n\n private baseUrl: string;\n private jwtStrategy: AssistantCloudJWTAuthStrategy;\n\n constructor(baseUrl: string) {\n this.baseUrl = baseUrl;\n this.jwtStrategy = new AssistantCloudJWTAuthStrategy(async () => {\n const currentTime = Date.now();\n const storedRefreshToken = readRefreshToken(this.baseUrl);\n\n if (storedRefreshToken) {\n const refreshExpiry = new Date(storedRefreshToken.expires_at).getTime();\n if (refreshExpiry - currentTime > 30 * 1000) {\n const response = await fetch(\n `${this.baseUrl}/v1/auth/tokens/refresh`,\n {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ refresh_token: storedRefreshToken.token }),\n },\n );\n\n if (response.ok) {\n const { data, accessToken } = await readAuthTokenResponse(\n response,\n \"refresh auth token response\",\n );\n if (data.refresh_token != null) {\n writeRefreshToken(\n this.baseUrl,\n readRefreshTokenResponse(\n data.refresh_token,\n \"refresh auth token response.refresh_token\",\n ),\n );\n }\n return accessToken;\n }\n\n if (response.status === 429 || response.status >= 500) {\n throw new Error(\n `Assistant Cloud token refresh failed with status ${response.status}`,\n );\n }\n } else {\n removeRefreshToken(this.baseUrl);\n }\n }\n\n // No valid refresh token; request a new anonymous token\n const response = await fetch(`${this.baseUrl}/v1/auth/tokens/anonymous`, {\n method: \"POST\",\n });\n\n if (!response.ok) return null;\n\n const { data, accessToken } = await readAuthTokenResponse(\n response,\n \"anonymous auth token response\",\n );\n\n writeRefreshToken(\n this.baseUrl,\n readRefreshTokenResponse(\n data.refresh_token,\n \"anonymous auth token response.refresh_token\",\n ),\n );\n return accessToken;\n });\n }\n\n public async getAuthHeaders(): Promise<Record<string, string> | false> {\n return this.jwtStrategy.getAuthHeaders();\n }\n\n public readAuthHeaders(headers: Headers): void {\n this.jwtStrategy.readAuthHeaders(headers);\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"AssistantCloudAuthStrategy.js","names":[],"sources":["../src/AssistantCloudAuthStrategy.ts"],"sourcesContent":["import {\n CloudResponseError,\n readCloudRecord,\n readCloudString,\n} from \"./cloudResponse\";\n\nconst AUTH_TOKEN_REQUEST_TIMEOUT_MS = 30_000;\n\nconst withAuthTokenDeadline = async <T>(\n operation: string,\n run: (signal: AbortSignal) => Promise<T>,\n): Promise<T> => {\n const controller = new AbortController();\n let timedOut = false;\n const timeout = setTimeout(() => {\n timedOut = true;\n controller.abort();\n }, AUTH_TOKEN_REQUEST_TIMEOUT_MS);\n\n try {\n return await run(controller.signal);\n } catch (error) {\n if (timedOut) {\n throw new Error(\n `Assistant Cloud ${operation} timed out after ${AUTH_TOKEN_REQUEST_TIMEOUT_MS}ms`,\n { cause: error },\n );\n }\n throw error;\n } finally {\n clearTimeout(timeout);\n }\n};\n\nexport type AssistantCloudAuthStrategy = {\n readonly strategy: \"anon\" | \"jwt\" | \"api-key\";\n getAuthHeaders(): Promise<Record<string, string> | false>;\n readAuthHeaders(headers: Headers): void;\n};\n\nconst getJwtExpiry = (jwt: string): number => {\n try {\n const parts = jwt.split(\".\");\n const bodyPart = parts[1];\n if (!bodyPart) {\n throw new Error(\"Invalid JWT format\");\n }\n\n // Convert from Base64Url to Base64 and add padding if necessary\n let base64 = bodyPart.replace(/-/g, \"+\").replace(/_/g, \"/\");\n while (base64.length % 4 !== 0) {\n base64 += \"=\";\n }\n\n // Decode the Base64 string and parse the payload\n const payload = atob(base64);\n const payloadObj = JSON.parse(payload);\n const exp = payloadObj.exp;\n\n if (!exp || typeof exp !== \"number\") {\n throw new Error('JWT does not contain a valid \"exp\" field');\n }\n\n // Convert expiration time to milliseconds\n return exp * 1000;\n } catch (error) {\n throw new Error(`Unable to determine the token expiry: ${error}`);\n }\n};\n\ntype RefreshToken = {\n token: string;\n expires_at: string;\n};\n\nconst readNonEmptyCloudString = (value: unknown, field: string): string => {\n const result = readCloudString(value, field);\n if (result.length === 0) {\n throw new CloudResponseError(\n `Invalid Assistant Cloud response for \"${field}\": expected a non-empty string`,\n );\n }\n return result;\n};\n\nconst readRefreshTokenResponse = (\n value: unknown,\n field: string,\n): RefreshToken => {\n const refreshToken = readCloudRecord(value, field);\n return {\n token: readNonEmptyCloudString(refreshToken.token, `${field}.token`),\n expires_at: readNonEmptyCloudString(\n refreshToken.expires_at,\n `${field}.expires_at`,\n ),\n };\n};\n\nconst readAuthTokenResponse = async (\n response: Response,\n field: string,\n): Promise<{ data: Record<string, unknown>; accessToken: string }> => {\n let value: unknown;\n try {\n value = await response.json();\n } catch {\n throw new CloudResponseError(\n `Invalid Assistant Cloud response for \"${field}\": expected valid JSON`,\n );\n }\n\n const data = readCloudRecord(value, field);\n const accessToken = readNonEmptyCloudString(\n data.access_token,\n `${field}.access_token`,\n );\n return { data, accessToken };\n};\n\nexport class AssistantCloudJWTAuthStrategy implements AssistantCloudAuthStrategy {\n public readonly strategy = \"jwt\";\n\n private cachedToken: string | null = null;\n private tokenExpiry: number | null = null;\n private tokenRequest: Promise<Record<string, string> | false> | null = null;\n #authTokenCallback: () => Promise<string | null>;\n\n constructor(authTokenCallback: () => Promise<string | null>) {\n this.#authTokenCallback = authTokenCallback;\n }\n\n public async getAuthHeaders(): Promise<Record<string, string> | false> {\n const currentTime = Date.now();\n\n // Use cached token if it's valid for at least 30 more seconds\n if (\n this.cachedToken &&\n this.tokenExpiry &&\n this.tokenExpiry - currentTime > 30 * 1000\n ) {\n return { Authorization: `Bearer ${this.cachedToken}` };\n }\n\n if (!this.tokenRequest) {\n this.tokenRequest = this.fetchAuthHeaders();\n }\n\n const tokenRequest = this.tokenRequest;\n try {\n return await tokenRequest;\n } finally {\n if (this.tokenRequest === tokenRequest) {\n this.tokenRequest = null;\n }\n }\n }\n\n private async fetchAuthHeaders(): Promise<Record<string, string> | false> {\n const token = await this.#authTokenCallback();\n if (!token) return false;\n\n this.cachedToken = token;\n this.tokenExpiry = getJwtExpiry(token);\n\n return { Authorization: `Bearer ${token}` };\n }\n\n public readAuthHeaders(headers: Headers) {\n const authHeader = headers.get(\"Authorization\");\n if (!authHeader) return;\n\n const [scheme, token] = authHeader.split(\" \");\n if (scheme !== \"Bearer\" || !token) {\n throw new Error(\"Invalid auth header received\");\n }\n\n this.cachedToken = token;\n this.tokenExpiry = getJwtExpiry(token);\n }\n}\n\nexport class AssistantCloudAPIKeyAuthStrategy implements AssistantCloudAuthStrategy {\n public readonly strategy = \"api-key\";\n\n #apiKey: string;\n #userId: string;\n #workspaceId: string;\n\n constructor(apiKey: string, userId: string, workspaceId: string) {\n this.#apiKey = apiKey;\n this.#userId = userId;\n this.#workspaceId = workspaceId;\n }\n\n public async getAuthHeaders(): Promise<Record<string, string>> {\n return {\n Authorization: `Bearer ${this.#apiKey}`,\n \"Aui-User-Id\": this.#userId,\n \"Aui-Workspace-Id\": this.#workspaceId,\n };\n }\n\n public readAuthHeaders() {\n // No operation needed for API key auth\n }\n}\n\nconst LEGACY_AUI_REFRESH_TOKEN_NAME = \"aui:refresh_token\";\n\nconst getRefreshTokenName = (baseUrl: string): string =>\n `${LEGACY_AUI_REFRESH_TOKEN_NAME}:${baseUrl}`;\n\nconst removeLegacyRefreshToken = (storage: Storage): void => {\n try {\n storage.removeItem(LEGACY_AUI_REFRESH_TOKEN_NAME);\n } catch {}\n};\n\nconst getLocalStorage = (): Storage | null => {\n if (!(\"localStorage\" in globalThis)) return null;\n try {\n return (globalThis as { localStorage: Storage }).localStorage;\n } catch {\n return null;\n }\n};\n\nconst readRefreshToken = (baseUrl: string): RefreshToken | undefined => {\n const storage = getLocalStorage();\n if (!storage) return undefined;\n try {\n const name = getRefreshTokenName(baseUrl);\n const value = storage.getItem(name);\n if (value) {\n removeLegacyRefreshToken(storage);\n return JSON.parse(value) as RefreshToken;\n }\n\n const legacyValue = storage.getItem(LEGACY_AUI_REFRESH_TOKEN_NAME);\n if (!legacyValue) return undefined;\n\n let refreshToken: RefreshToken;\n try {\n refreshToken = JSON.parse(legacyValue) as RefreshToken;\n } catch {\n removeLegacyRefreshToken(storage);\n return undefined;\n }\n\n storage.setItem(name, legacyValue);\n removeLegacyRefreshToken(storage);\n return refreshToken;\n } catch {\n return undefined;\n }\n};\n\nconst writeRefreshToken = (\n baseUrl: string,\n refreshToken: RefreshToken,\n): void => {\n const storage = getLocalStorage();\n if (!storage) return;\n try {\n storage.setItem(getRefreshTokenName(baseUrl), JSON.stringify(refreshToken));\n } catch {}\n};\n\nconst removeRefreshToken = (baseUrl: string): void => {\n const storage = getLocalStorage();\n if (!storage) return;\n try {\n storage.removeItem(getRefreshTokenName(baseUrl));\n } catch {}\n};\n\n// In-flight sharing follows refresh-token storage scope to isolate server requests.\nconst anonymousAuthTokenRequests = new WeakMap<\n Storage,\n Map<string, Promise<string | null>>\n>();\n\nconst getWebLockManager = (): LockManager | null => {\n if (!(\"navigator\" in globalThis)) return null;\n return (\n (globalThis as { navigator?: { locks?: LockManager } }).navigator?.locks ??\n null\n );\n};\n\nconst getAnonymousAuthLockName = (baseUrl: string): string =>\n `assistant-cloud:anonymous-auth:${baseUrl}`;\n\nconst getSharedAnonymousAuthToken = (\n baseUrl: string,\n requestToken: () => Promise<string | null>,\n): Promise<string | null> => {\n const storage = getLocalStorage();\n if (!storage) return requestToken();\n\n let storageRequests = anonymousAuthTokenRequests.get(storage);\n if (!storageRequests) {\n storageRequests = new Map();\n anonymousAuthTokenRequests.set(storage, storageRequests);\n }\n\n const activeRequest = storageRequests.get(baseUrl);\n if (activeRequest) return activeRequest;\n\n const locks = getWebLockManager();\n const request = locks\n ? locks.request(getAnonymousAuthLockName(baseUrl), requestToken)\n : requestToken();\n const sharedRequest = request.finally(() => {\n if (storageRequests.get(baseUrl) === sharedRequest) {\n storageRequests.delete(baseUrl);\n }\n });\n storageRequests.set(baseUrl, sharedRequest);\n return sharedRequest;\n};\n\nexport class AssistantCloudAnonymousAuthStrategy implements AssistantCloudAuthStrategy {\n public readonly strategy = \"anon\";\n\n private baseUrl: string;\n private jwtStrategy: AssistantCloudJWTAuthStrategy;\n\n constructor(baseUrl: string) {\n this.baseUrl = baseUrl;\n const requestAuthToken = async (): Promise<string | null> => {\n const currentTime = Date.now();\n const storedRefreshToken = readRefreshToken(this.baseUrl);\n\n if (storedRefreshToken) {\n const refreshExpiry = new Date(storedRefreshToken.expires_at).getTime();\n if (refreshExpiry - currentTime > 30 * 1000) {\n const refreshedAccessToken = await withAuthTokenDeadline(\n \"refresh token request\",\n async (signal) => {\n const response = await fetch(\n `${this.baseUrl}/v1/auth/tokens/refresh`,\n {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n refresh_token: storedRefreshToken.token,\n }),\n signal,\n },\n );\n\n if (response.ok) {\n const { data, accessToken } = await readAuthTokenResponse(\n response,\n \"refresh auth token response\",\n );\n if (data.refresh_token != null) {\n writeRefreshToken(\n this.baseUrl,\n readRefreshTokenResponse(\n data.refresh_token,\n \"refresh auth token response.refresh_token\",\n ),\n );\n }\n return accessToken;\n }\n\n if (response.status === 429 || response.status >= 500) {\n throw new Error(\n `Assistant Cloud token refresh failed with status ${response.status}`,\n );\n }\n\n return null;\n },\n );\n if (refreshedAccessToken !== null) return refreshedAccessToken;\n } else {\n removeRefreshToken(this.baseUrl);\n }\n }\n\n // No valid refresh token; request a new anonymous token\n return withAuthTokenDeadline(\n \"anonymous token request\",\n async (signal) => {\n const response = await fetch(\n `${this.baseUrl}/v1/auth/tokens/anonymous`,\n { method: \"POST\", signal },\n );\n\n if (!response.ok) return null;\n\n const { data, accessToken } = await readAuthTokenResponse(\n response,\n \"anonymous auth token response\",\n );\n\n writeRefreshToken(\n this.baseUrl,\n readRefreshTokenResponse(\n data.refresh_token,\n \"anonymous auth token response.refresh_token\",\n ),\n );\n return accessToken;\n },\n );\n };\n this.jwtStrategy = new AssistantCloudJWTAuthStrategy(() =>\n getSharedAnonymousAuthToken(this.baseUrl, requestAuthToken),\n );\n }\n\n public async getAuthHeaders(): Promise<Record<string, string> | false> {\n return this.jwtStrategy.getAuthHeaders();\n }\n\n public readAuthHeaders(headers: Headers): void {\n this.jwtStrategy.readAuthHeaders(headers);\n }\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CloudMessagePersistence.d.ts","names":[],"sources":["../src/CloudMessagePersistence.ts"],"mappings":";;;;;;;;;;;;;;;cAca;UACH;UACA;EAEI,YAAA,OAAO;EACP,YAAA,gBAAgB;;;;;;;;;;EActB,OACJ,kBACA,mBACA,yBACA,gBACA,SAAS,qBACR;;;;
|
|
1
|
+
{"version":3,"file":"CloudMessagePersistence.d.ts","names":[],"sources":["../src/CloudMessagePersistence.ts"],"mappings":";;;;;;;;;;;;;;;cAca;UACH;UACA;EAEI,YAAA,OAAO;EACP,YAAA,gBAAgB;;;;;;;;;;EActB,OACJ,kBACA,mBACA,yBACA,gBACA,SAAS,qBACR;;;;EAqCG,OACJ,kBACA,mBACA,iBACA,SAAS,qBACR;;;;EAeH,YAAY;;;;;EAQN,YAAY,oBAAoB;;;;;;;;;;;EAgBhC,KAAK,kBAAkB,kBAAe,QAhBN;;;;EAgCtC"}
|
|
@@ -27,20 +27,28 @@ var CloudMessagePersistence = class {
|
|
|
27
27
|
*/
|
|
28
28
|
async append(threadId, messageId, parentId, format, content) {
|
|
29
29
|
const cloud = this.getCloud();
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
this.idMapping[
|
|
30
|
+
const existing = this.idMapping[messageId];
|
|
31
|
+
if (existing instanceof Promise) {
|
|
32
|
+
await existing;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const task = (async () => {
|
|
36
|
+
const resolvedParentId = parentId ? await this.idMapping[parentId] ?? parentId : null;
|
|
37
|
+
const { message_id } = await cloud.threads.messages.create(threadId, {
|
|
38
|
+
parent_id: resolvedParentId,
|
|
39
|
+
format,
|
|
40
|
+
content
|
|
41
|
+
});
|
|
37
42
|
return message_id;
|
|
38
|
-
})
|
|
43
|
+
})();
|
|
44
|
+
this.idMapping[messageId] = task;
|
|
45
|
+
try {
|
|
46
|
+
const remoteId = await task;
|
|
47
|
+
if (this.idMapping[messageId] === task) this.idMapping[messageId] = remoteId;
|
|
48
|
+
} catch (err) {
|
|
39
49
|
if (this.idMapping[messageId] === task) delete this.idMapping[messageId];
|
|
40
50
|
throw err;
|
|
41
|
-
}
|
|
42
|
-
this.idMapping[messageId] = task;
|
|
43
|
-
return task.then(() => {});
|
|
51
|
+
}
|
|
44
52
|
}
|
|
45
53
|
/**
|
|
46
54
|
* Update an already-persisted message in the cloud.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CloudMessagePersistence.js","names":[],"sources":["../src/CloudMessagePersistence.ts"],"sourcesContent":["import type { ReadonlyJSONObject } from \"assistant-stream/utils\";\nimport type { AssistantCloud } from \"./AssistantCloud\";\n\n/**\n * Shared persistence logic for cloud message storage.\n *\n * Handles ID mapping (local → remote) and parent_id chaining for both:\n * - AssistantCloudThreadHistoryAdapter (assistant-ui runtime)\n * - useCloudChat (standalone AI SDK hook)\n *\n * The promise-based ID resolution handles concurrent appends — if message B's\n * parent is message A, and A is still being created, we await A's promise\n * to get its remote ID before creating B.\n */\nexport class CloudMessagePersistence {\n private idMapping: Record<string, string | Promise<string>> = {};\n private getCloud: () => AssistantCloud;\n\n constructor(cloud: AssistantCloud);\n constructor(getCloud: () => AssistantCloud);\n constructor(cloud: AssistantCloud | (() => AssistantCloud)) {\n this.getCloud = typeof cloud === \"function\" ? cloud : () => cloud;\n }\n\n /**\n * Persist a message to the cloud.\n *\n * @param threadId - Remote thread ID\n * @param messageId - Local message ID (used for tracking)\n * @param parentId - Local parent message ID (or null for first message)\n * @param format - Message format (e.g., \"aui/v0\", \"ai-sdk/v6\")\n * @param content - Message content (format-specific)\n */\n async append(\n threadId: string,\n messageId: string,\n parentId: string | null,\n format: string,\n content: ReadonlyJSONObject,\n ): Promise<void> {\n const cloud = this.getCloud();\n
|
|
1
|
+
{"version":3,"file":"CloudMessagePersistence.js","names":[],"sources":["../src/CloudMessagePersistence.ts"],"sourcesContent":["import type { ReadonlyJSONObject } from \"assistant-stream/utils\";\nimport type { AssistantCloud } from \"./AssistantCloud\";\n\n/**\n * Shared persistence logic for cloud message storage.\n *\n * Handles ID mapping (local → remote) and parent_id chaining for both:\n * - AssistantCloudThreadHistoryAdapter (assistant-ui runtime)\n * - useCloudChat (standalone AI SDK hook)\n *\n * The promise-based ID resolution handles concurrent appends — if message B's\n * parent is message A, and A is still being created, we await A's promise\n * to get its remote ID before creating B.\n */\nexport class CloudMessagePersistence {\n private idMapping: Record<string, string | Promise<string>> = {};\n private getCloud: () => AssistantCloud;\n\n constructor(cloud: AssistantCloud);\n constructor(getCloud: () => AssistantCloud);\n constructor(cloud: AssistantCloud | (() => AssistantCloud)) {\n this.getCloud = typeof cloud === \"function\" ? cloud : () => cloud;\n }\n\n /**\n * Persist a message to the cloud.\n *\n * @param threadId - Remote thread ID\n * @param messageId - Local message ID (used for tracking)\n * @param parentId - Local parent message ID (or null for first message)\n * @param format - Message format (e.g., \"aui/v0\", \"ai-sdk/v6\")\n * @param content - Message content (format-specific)\n */\n async append(\n threadId: string,\n messageId: string,\n parentId: string | null,\n format: string,\n content: ReadonlyJSONObject,\n ): Promise<void> {\n const cloud = this.getCloud();\n const existing = this.idMapping[messageId];\n if (existing instanceof Promise) {\n await existing;\n return;\n }\n\n const task = (async () => {\n const resolvedParentId = parentId\n ? ((await this.idMapping[parentId]) ?? parentId)\n : null;\n const { message_id } = await cloud.threads.messages.create(threadId, {\n parent_id: resolvedParentId,\n format,\n content,\n });\n return message_id;\n })();\n\n this.idMapping[messageId] = task;\n try {\n const remoteId = await task;\n if (this.idMapping[messageId] === task) {\n this.idMapping[messageId] = remoteId;\n }\n } catch (err) {\n if (this.idMapping[messageId] === task) {\n delete this.idMapping[messageId];\n }\n throw err;\n }\n }\n\n /**\n * Update an already-persisted message in the cloud.\n */\n async update(\n threadId: string,\n messageId: string,\n _format: string,\n content: ReadonlyJSONObject,\n ): Promise<void> {\n const cloud = this.getCloud();\n const remoteId = await this.getRemoteId(messageId);\n if (!remoteId) {\n console.warn(\n `Skipping update for message ${messageId}: no remote id is mapped.`,\n );\n return;\n }\n await cloud.threads.messages.update(threadId, remoteId, { content });\n }\n\n /**\n * Check if a message has been persisted (or is currently being persisted).\n */\n isPersisted(messageId: string): boolean {\n return messageId in this.idMapping;\n }\n\n /**\n * Get the remote ID for a local message ID (resolved).\n * Returns undefined if not persisted.\n */\n async getRemoteId(messageId: string): Promise<string | undefined> {\n const entry = this.idMapping[messageId];\n if (!entry) return undefined;\n return entry;\n }\n\n /**\n * Load messages from the cloud and populate the ID mapping.\n *\n * The ID mapping is populated so that `isPersisted()` returns true for\n * loaded messages, preventing re-persistence of already-stored messages.\n *\n * @param threadId - Remote thread ID\n * @param format - Optional format filter\n * @returns Array of cloud messages\n */\n async load(threadId: string, format?: string) {\n const cloud = this.getCloud();\n const { messages } = await cloud.threads.messages.list(\n threadId,\n format ? { format } : undefined,\n );\n // Populate ID mapping so isPersisted() recognizes loaded messages\n for (const m of messages) {\n this.idMapping[m.id] = m.id;\n }\n return messages;\n }\n\n /**\n * Reset the ID mapping (call when switching threads).\n */\n reset() {\n this.idMapping = {};\n }\n}\n"],"mappings":";;;;;;;;;;;;AAcA,IAAa,0BAAb,MAAqC;CACnC,YAA8D,CAAC;CAC/D;CAIA,YAAY,OAAgD;EAC1D,KAAK,WAAW,OAAO,UAAU,aAAa,cAAc;CAC9D;;;;;;;;;;CAWA,MAAM,OACJ,UACA,WACA,UACA,QACA,SACe;EACf,MAAM,QAAQ,KAAK,SAAS;EAC5B,MAAM,WAAW,KAAK,UAAU;EAChC,IAAI,oBAAoB,SAAS;GAC/B,MAAM;GACN;EACF;EAEA,MAAM,QAAQ,YAAY;GACxB,MAAM,mBAAmB,WACnB,MAAM,KAAK,UAAU,aAAc,WACrC;GACJ,MAAM,EAAE,eAAe,MAAM,MAAM,QAAQ,SAAS,OAAO,UAAU;IACnE,WAAW;IACX;IACA;GACF,CAAC;GACD,OAAO;EACT,EAAA,CAAG;EAEH,KAAK,UAAU,aAAa;EAC5B,IAAI;GACF,MAAM,WAAW,MAAM;GACvB,IAAI,KAAK,UAAU,eAAe,MAChC,KAAK,UAAU,aAAa;EAEhC,SAAS,KAAK;GACZ,IAAI,KAAK,UAAU,eAAe,MAChC,OAAO,KAAK,UAAU;GAExB,MAAM;EACR;CACF;;;;CAKA,MAAM,OACJ,UACA,WACA,SACA,SACe;EACf,MAAM,QAAQ,KAAK,SAAS;EAC5B,MAAM,WAAW,MAAM,KAAK,YAAY,SAAS;EACjD,IAAI,CAAC,UAAU;GACb,QAAQ,KACN,+BAA+B,UAAU,0BAC3C;GACA;EACF;EACA,MAAM,MAAM,QAAQ,SAAS,OAAO,UAAU,UAAU,EAAE,QAAQ,CAAC;CACrE;;;;CAKA,YAAY,WAA4B;EACtC,OAAO,aAAa,KAAK;CAC3B;;;;;CAMA,MAAM,YAAY,WAAgD;EAChE,MAAM,QAAQ,KAAK,UAAU;EAC7B,IAAI,CAAC,OAAO,OAAO,KAAA;EACnB,OAAO;CACT;;;;;;;;;;;CAYA,MAAM,KAAK,UAAkB,QAAiB;EAE5C,MAAM,EAAE,aAAa,MADP,KAAK,SACY,CAAC,CAAC,QAAQ,SAAS,KAChD,UACA,SAAS,EAAE,OAAO,IAAI,KAAA,CACxB;EAEA,KAAK,MAAM,KAAK,UACd,KAAK,UAAU,EAAE,MAAM,EAAE;EAE3B,OAAO;CACT;;;;CAKA,QAAQ;EACN,KAAK,YAAY,CAAC;CACpB;AACF"}
|
package/dist/runTelemetry.d.ts
CHANGED
|
@@ -42,10 +42,17 @@ type RunTelemetryUsage = {
|
|
|
42
42
|
type RunTelemetryUsageInit = RunTelemetryUsage & {
|
|
43
43
|
promptTokens?: number;
|
|
44
44
|
completionTokens?: number;
|
|
45
|
+
inputTokenDetails?: {
|
|
46
|
+
cacheReadTokens?: number;
|
|
47
|
+
};
|
|
48
|
+
outputTokenDetails?: {
|
|
49
|
+
reasoningTokens?: number;
|
|
50
|
+
};
|
|
45
51
|
};
|
|
46
52
|
/**
|
|
47
|
-
* Resolves the token counts a provider reports under
|
|
48
|
-
*
|
|
53
|
+
* Resolves the token counts a provider reports under any of the names the AI
|
|
54
|
+
* SDK has used: the current top-level ones, the legacy prompt/completion pair,
|
|
55
|
+
* and the v7 token detail objects. Returns undefined when no count is present,
|
|
49
56
|
* so callers can tell an empty usage object from a zeroed one.
|
|
50
57
|
*/
|
|
51
58
|
declare function normalizeRunTelemetryUsage(usage: RunTelemetryUsageInit): RunTelemetryUsage | undefined;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runTelemetry.d.ts","names":[],"sources":["../src/runTelemetry.ts"],"mappings":";;KAMY;EACV;EACA;EACA;EACA;EACA;EACA;EACA;EACA,iBAAiB;;;;;;iBAOH,yBAAyB;KAwC7B;EACV;EACA;EACA;;;;;EAKA;EACA;EACA;;;;;;;iBAQc,2BACd,MAAM,2BACL;KAgBS;EACV;EACA;EACA;EACA;;KAGU,wBAAwB;EAClC;EACA
|
|
1
|
+
{"version":3,"file":"runTelemetry.d.ts","names":[],"sources":["../src/runTelemetry.ts"],"mappings":";;KAMY;EACV;EACA;EACA;EACA;EACA;EACA;EACA;EACA,iBAAiB;;;;;;iBAOH,yBAAyB;KAwC7B;EACV;EACA;EACA;;;;;EAKA;EACA;EACA;;;;;;;iBAQc,2BACd,MAAM,2BACL;KAgBS;EACV;EACA;EACA;EACA;;KAGU,wBAAwB;EAClC;EACA;EACA;IAAsB;;EACtB;IAAuB;;;;;;;;;iBAST,2BACd,OAAO,wBACN"}
|
package/dist/runTelemetry.js
CHANGED
|
@@ -58,19 +58,22 @@ function createRunTelemetryToolCall(init) {
|
|
|
58
58
|
return call;
|
|
59
59
|
}
|
|
60
60
|
/**
|
|
61
|
-
* Resolves the token counts a provider reports under
|
|
62
|
-
*
|
|
61
|
+
* Resolves the token counts a provider reports under any of the names the AI
|
|
62
|
+
* SDK has used: the current top-level ones, the legacy prompt/completion pair,
|
|
63
|
+
* and the v7 token detail objects. Returns undefined when no count is present,
|
|
63
64
|
* so callers can tell an empty usage object from a zeroed one.
|
|
64
65
|
*/
|
|
65
66
|
function normalizeRunTelemetryUsage(usage) {
|
|
66
67
|
const inputTokens = usage.inputTokens ?? usage.promptTokens;
|
|
67
68
|
const outputTokens = usage.outputTokens ?? usage.completionTokens;
|
|
68
|
-
|
|
69
|
+
const reasoningTokens = usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens;
|
|
70
|
+
const cachedInputTokens = usage.cachedInputTokens ?? usage.inputTokenDetails?.cacheReadTokens;
|
|
71
|
+
if (inputTokens == null && outputTokens == null && reasoningTokens == null && cachedInputTokens == null) return;
|
|
69
72
|
return {
|
|
70
73
|
...inputTokens != null ? { inputTokens } : void 0,
|
|
71
74
|
...outputTokens != null ? { outputTokens } : void 0,
|
|
72
|
-
...
|
|
73
|
-
...
|
|
75
|
+
...reasoningTokens != null ? { reasoningTokens } : void 0,
|
|
76
|
+
...cachedInputTokens != null ? { cachedInputTokens } : void 0
|
|
74
77
|
};
|
|
75
78
|
}
|
|
76
79
|
//#endregion
|
package/dist/runTelemetry.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runTelemetry.js","names":[],"sources":["../src/runTelemetry.ts"],"sourcesContent":["import type { SamplingCallData } from \"./instrumentMcpSampling\";\n\nconst MAX_TELEMETRY_TEXT_LENGTH = 50_000;\n\nconst BASE64_PATTERN = /^[A-Za-z0-9+/]{100,}={0,2}$/;\n\nexport type AssistantCloudRunReportToolCall = {\n tool_name: string;\n tool_call_id: string;\n tool_args?: string;\n tool_result?: string;\n tool_source?: \"mcp\" | \"frontend\" | \"backend\";\n start_ms?: number;\n end_ms?: number;\n sampling_calls?: SamplingCallData[];\n};\n\n/**\n * Clamps a string to the size the runs endpoint accepts for a single span\n * field.\n */\nexport function truncateRunTelemetryText(value: string): string {\n if (value.length <= MAX_TELEMETRY_TEXT_LENGTH) return value;\n return value.slice(0, MAX_TELEMETRY_TEXT_LENGTH);\n}\n\nfunction safeStringify(value: unknown): string | undefined {\n if (value == null) return undefined;\n try {\n return truncateRunTelemetryText(JSON.stringify(value));\n } catch {\n return undefined;\n }\n}\n\nfunction summarizeMcpResult(value: unknown): string | undefined {\n if (value == null) return undefined;\n try {\n const parsed = typeof value === \"string\" ? JSON.parse(value) : value;\n if (Array.isArray(parsed)) {\n const summarized = parsed.map((item) => {\n if (item && typeof item === \"object\" && item.type) {\n if (\n (item.type === \"image\" || item.type === \"audio\") &&\n typeof item.data === \"string\" &&\n BASE64_PATTERN.test(item.data.slice(0, 200))\n ) {\n const sizeKB = ((item.data.length * 3) / 4 / 1024).toFixed(1);\n return { ...item, data: `[${item.type}: ${sizeKB}KB]` };\n }\n }\n return item;\n });\n return truncateRunTelemetryText(JSON.stringify(summarized));\n }\n } catch {\n // not JSON array, fall through\n }\n return safeStringify(value);\n}\n\nexport type RunTelemetryToolCallInit = {\n toolName: string;\n toolCallId: string;\n args?: unknown;\n /**\n * Pre-serialized arguments, used in place of serializing `args`. Values over\n * the span size are clamped before they are included in the report.\n */\n argsText?: string | undefined;\n result?: unknown;\n toolSource?: \"mcp\" | \"frontend\" | \"backend\" | undefined;\n};\n\n/**\n * Serializes one tool call into the shape the runs endpoint accepts. An `mcp`\n * source has its result summarized, because MCP content blocks carry inline\n * base64 image and audio payloads that would otherwise dominate the report.\n */\nexport function createRunTelemetryToolCall(\n init: RunTelemetryToolCallInit,\n): AssistantCloudRunReportToolCall {\n const { toolName, toolCallId, args, argsText, result, toolSource } = init;\n const call: AssistantCloudRunReportToolCall = {\n tool_name: toolName,\n tool_call_id: toolCallId,\n };\n const toolArgs =\n argsText != null ? truncateRunTelemetryText(argsText) : safeStringify(args);\n if (toolArgs !== undefined) call.tool_args = toolArgs;\n const toolResult =\n toolSource === \"mcp\" ? summarizeMcpResult(result) : safeStringify(result);\n if (toolResult !== undefined) call.tool_result = toolResult;\n if (toolSource) call.tool_source = toolSource;\n return call;\n}\n\nexport type RunTelemetryUsage = {\n inputTokens?: number;\n outputTokens?: number;\n reasoningTokens?: number;\n cachedInputTokens?: number;\n};\n\nexport type RunTelemetryUsageInit = RunTelemetryUsage & {\n promptTokens?: number;\n completionTokens?: number;\n};\n\n/**\n * Resolves the token counts a provider reports under
|
|
1
|
+
{"version":3,"file":"runTelemetry.js","names":[],"sources":["../src/runTelemetry.ts"],"sourcesContent":["import type { SamplingCallData } from \"./instrumentMcpSampling\";\n\nconst MAX_TELEMETRY_TEXT_LENGTH = 50_000;\n\nconst BASE64_PATTERN = /^[A-Za-z0-9+/]{100,}={0,2}$/;\n\nexport type AssistantCloudRunReportToolCall = {\n tool_name: string;\n tool_call_id: string;\n tool_args?: string;\n tool_result?: string;\n tool_source?: \"mcp\" | \"frontend\" | \"backend\";\n start_ms?: number;\n end_ms?: number;\n sampling_calls?: SamplingCallData[];\n};\n\n/**\n * Clamps a string to the size the runs endpoint accepts for a single span\n * field.\n */\nexport function truncateRunTelemetryText(value: string): string {\n if (value.length <= MAX_TELEMETRY_TEXT_LENGTH) return value;\n return value.slice(0, MAX_TELEMETRY_TEXT_LENGTH);\n}\n\nfunction safeStringify(value: unknown): string | undefined {\n if (value == null) return undefined;\n try {\n return truncateRunTelemetryText(JSON.stringify(value));\n } catch {\n return undefined;\n }\n}\n\nfunction summarizeMcpResult(value: unknown): string | undefined {\n if (value == null) return undefined;\n try {\n const parsed = typeof value === \"string\" ? JSON.parse(value) : value;\n if (Array.isArray(parsed)) {\n const summarized = parsed.map((item) => {\n if (item && typeof item === \"object\" && item.type) {\n if (\n (item.type === \"image\" || item.type === \"audio\") &&\n typeof item.data === \"string\" &&\n BASE64_PATTERN.test(item.data.slice(0, 200))\n ) {\n const sizeKB = ((item.data.length * 3) / 4 / 1024).toFixed(1);\n return { ...item, data: `[${item.type}: ${sizeKB}KB]` };\n }\n }\n return item;\n });\n return truncateRunTelemetryText(JSON.stringify(summarized));\n }\n } catch {\n // not JSON array, fall through\n }\n return safeStringify(value);\n}\n\nexport type RunTelemetryToolCallInit = {\n toolName: string;\n toolCallId: string;\n args?: unknown;\n /**\n * Pre-serialized arguments, used in place of serializing `args`. Values over\n * the span size are clamped before they are included in the report.\n */\n argsText?: string | undefined;\n result?: unknown;\n toolSource?: \"mcp\" | \"frontend\" | \"backend\" | undefined;\n};\n\n/**\n * Serializes one tool call into the shape the runs endpoint accepts. An `mcp`\n * source has its result summarized, because MCP content blocks carry inline\n * base64 image and audio payloads that would otherwise dominate the report.\n */\nexport function createRunTelemetryToolCall(\n init: RunTelemetryToolCallInit,\n): AssistantCloudRunReportToolCall {\n const { toolName, toolCallId, args, argsText, result, toolSource } = init;\n const call: AssistantCloudRunReportToolCall = {\n tool_name: toolName,\n tool_call_id: toolCallId,\n };\n const toolArgs =\n argsText != null ? truncateRunTelemetryText(argsText) : safeStringify(args);\n if (toolArgs !== undefined) call.tool_args = toolArgs;\n const toolResult =\n toolSource === \"mcp\" ? summarizeMcpResult(result) : safeStringify(result);\n if (toolResult !== undefined) call.tool_result = toolResult;\n if (toolSource) call.tool_source = toolSource;\n return call;\n}\n\nexport type RunTelemetryUsage = {\n inputTokens?: number;\n outputTokens?: number;\n reasoningTokens?: number;\n cachedInputTokens?: number;\n};\n\nexport type RunTelemetryUsageInit = RunTelemetryUsage & {\n promptTokens?: number;\n completionTokens?: number;\n inputTokenDetails?: { cacheReadTokens?: number };\n outputTokenDetails?: { reasoningTokens?: number };\n};\n\n/**\n * Resolves the token counts a provider reports under any of the names the AI\n * SDK has used: the current top-level ones, the legacy prompt/completion pair,\n * and the v7 token detail objects. Returns undefined when no count is present,\n * so callers can tell an empty usage object from a zeroed one.\n */\nexport function normalizeRunTelemetryUsage(\n usage: RunTelemetryUsageInit,\n): RunTelemetryUsage | undefined {\n const inputTokens = usage.inputTokens ?? usage.promptTokens;\n const outputTokens = usage.outputTokens ?? usage.completionTokens;\n // AI SDK v7 moved these under token detail objects; v6 kept them top-level.\n const reasoningTokens =\n usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens;\n const cachedInputTokens =\n usage.cachedInputTokens ?? usage.inputTokenDetails?.cacheReadTokens;\n\n if (\n inputTokens == null &&\n outputTokens == null &&\n reasoningTokens == null &&\n cachedInputTokens == null\n ) {\n return undefined;\n }\n\n return {\n ...(inputTokens != null ? { inputTokens } : undefined),\n ...(outputTokens != null ? { outputTokens } : undefined),\n ...(reasoningTokens != null ? { reasoningTokens } : undefined),\n ...(cachedInputTokens != null ? { cachedInputTokens } : undefined),\n };\n}\n"],"mappings":";AAEA,MAAM,4BAA4B;AAElC,MAAM,iBAAiB;;;;;AAiBvB,SAAgB,yBAAyB,OAAuB;CAC9D,IAAI,MAAM,UAAU,2BAA2B,OAAO;CACtD,OAAO,MAAM,MAAM,GAAG,yBAAyB;AACjD;AAEA,SAAS,cAAc,OAAoC;CACzD,IAAI,SAAS,MAAM,OAAO,KAAA;CAC1B,IAAI;EACF,OAAO,yBAAyB,KAAK,UAAU,KAAK,CAAC;CACvD,QAAQ;EACN;CACF;AACF;AAEA,SAAS,mBAAmB,OAAoC;CAC9D,IAAI,SAAS,MAAM,OAAO,KAAA;CAC1B,IAAI;EACF,MAAM,SAAS,OAAO,UAAU,WAAW,KAAK,MAAM,KAAK,IAAI;EAC/D,IAAI,MAAM,QAAQ,MAAM,GAAG;GACzB,MAAM,aAAa,OAAO,KAAK,SAAS;IACtC,IAAI,QAAQ,OAAO,SAAS,YAAY,KAAK,MAExC;UAAA,KAAK,SAAS,WAAW,KAAK,SAAS,YACxC,OAAO,KAAK,SAAS,YACrB,eAAe,KAAK,KAAK,KAAK,MAAM,GAAG,GAAG,CAAC,GAC3C;MACA,MAAM,UAAW,KAAK,KAAK,SAAS,IAAK,IAAI,KAAA,CAAM,QAAQ,CAAC;MAC5D,OAAO;OAAE,GAAG;OAAM,MAAM,IAAI,KAAK,KAAK,IAAI,OAAO;MAAK;KACxD;;IAEF,OAAO;GACT,CAAC;GACD,OAAO,yBAAyB,KAAK,UAAU,UAAU,CAAC;EAC5D;CACF,QAAQ,CAER;CACA,OAAO,cAAc,KAAK;AAC5B;;;;;;AAoBA,SAAgB,2BACd,MACiC;CACjC,MAAM,EAAE,UAAU,YAAY,MAAM,UAAU,QAAQ,eAAe;CACrE,MAAM,OAAwC;EAC5C,WAAW;EACX,cAAc;CAChB;CACA,MAAM,WACJ,YAAY,OAAO,yBAAyB,QAAQ,IAAI,cAAc,IAAI;CAC5E,IAAI,aAAa,KAAA,GAAW,KAAK,YAAY;CAC7C,MAAM,aACJ,eAAe,QAAQ,mBAAmB,MAAM,IAAI,cAAc,MAAM;CAC1E,IAAI,eAAe,KAAA,GAAW,KAAK,cAAc;CACjD,IAAI,YAAY,KAAK,cAAc;CACnC,OAAO;AACT;;;;;;;AAsBA,SAAgB,2BACd,OAC+B;CAC/B,MAAM,cAAc,MAAM,eAAe,MAAM;CAC/C,MAAM,eAAe,MAAM,gBAAgB,MAAM;CAEjD,MAAM,kBACJ,MAAM,mBAAmB,MAAM,oBAAoB;CACrD,MAAM,oBACJ,MAAM,qBAAqB,MAAM,mBAAmB;CAEtD,IACE,eAAe,QACf,gBAAgB,QAChB,mBAAmB,QACnB,qBAAqB,MAErB;CAGF,OAAO;EACL,GAAI,eAAe,OAAO,EAAE,YAAY,IAAI,KAAA;EAC5C,GAAI,gBAAgB,OAAO,EAAE,aAAa,IAAI,KAAA;EAC9C,GAAI,mBAAmB,OAAO,EAAE,gBAAgB,IAAI,KAAA;EACpD,GAAI,qBAAqB,OAAO,EAAE,kBAAkB,IAAI,KAAA;CAC1D;AACF"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "assistant-cloud",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.43",
|
|
4
4
|
"description": "Cloud integration for assistant-ui",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"assistant",
|
|
@@ -27,11 +27,11 @@
|
|
|
27
27
|
],
|
|
28
28
|
"sideEffects": false,
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"assistant-stream": "^0.3.
|
|
30
|
+
"assistant-stream": "^0.3.41"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
|
-
"@assistant-ui/x-buildutils": "0.0.
|
|
34
|
-
"@types/node": "^26.
|
|
33
|
+
"@assistant-ui/x-buildutils": "0.0.25",
|
|
34
|
+
"@types/node": "^26.4.0",
|
|
35
35
|
"vitest": "^4.1.11"
|
|
36
36
|
},
|
|
37
37
|
"publishConfig": {
|
|
@@ -4,6 +4,34 @@ import {
|
|
|
4
4
|
readCloudString,
|
|
5
5
|
} from "./cloudResponse";
|
|
6
6
|
|
|
7
|
+
const AUTH_TOKEN_REQUEST_TIMEOUT_MS = 30_000;
|
|
8
|
+
|
|
9
|
+
const withAuthTokenDeadline = async <T>(
|
|
10
|
+
operation: string,
|
|
11
|
+
run: (signal: AbortSignal) => Promise<T>,
|
|
12
|
+
): Promise<T> => {
|
|
13
|
+
const controller = new AbortController();
|
|
14
|
+
let timedOut = false;
|
|
15
|
+
const timeout = setTimeout(() => {
|
|
16
|
+
timedOut = true;
|
|
17
|
+
controller.abort();
|
|
18
|
+
}, AUTH_TOKEN_REQUEST_TIMEOUT_MS);
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
return await run(controller.signal);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (timedOut) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`Assistant Cloud ${operation} timed out after ${AUTH_TOKEN_REQUEST_TIMEOUT_MS}ms`,
|
|
26
|
+
{ cause: error },
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
throw error;
|
|
30
|
+
} finally {
|
|
31
|
+
clearTimeout(timeout);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
7
35
|
export type AssistantCloudAuthStrategy = {
|
|
8
36
|
readonly strategy: "anon" | "jwt" | "api-key";
|
|
9
37
|
getAuthHeaders(): Promise<Record<string, string> | false>;
|
|
@@ -247,6 +275,52 @@ const removeRefreshToken = (baseUrl: string): void => {
|
|
|
247
275
|
} catch {}
|
|
248
276
|
};
|
|
249
277
|
|
|
278
|
+
// In-flight sharing follows refresh-token storage scope to isolate server requests.
|
|
279
|
+
const anonymousAuthTokenRequests = new WeakMap<
|
|
280
|
+
Storage,
|
|
281
|
+
Map<string, Promise<string | null>>
|
|
282
|
+
>();
|
|
283
|
+
|
|
284
|
+
const getWebLockManager = (): LockManager | null => {
|
|
285
|
+
if (!("navigator" in globalThis)) return null;
|
|
286
|
+
return (
|
|
287
|
+
(globalThis as { navigator?: { locks?: LockManager } }).navigator?.locks ??
|
|
288
|
+
null
|
|
289
|
+
);
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const getAnonymousAuthLockName = (baseUrl: string): string =>
|
|
293
|
+
`assistant-cloud:anonymous-auth:${baseUrl}`;
|
|
294
|
+
|
|
295
|
+
const getSharedAnonymousAuthToken = (
|
|
296
|
+
baseUrl: string,
|
|
297
|
+
requestToken: () => Promise<string | null>,
|
|
298
|
+
): Promise<string | null> => {
|
|
299
|
+
const storage = getLocalStorage();
|
|
300
|
+
if (!storage) return requestToken();
|
|
301
|
+
|
|
302
|
+
let storageRequests = anonymousAuthTokenRequests.get(storage);
|
|
303
|
+
if (!storageRequests) {
|
|
304
|
+
storageRequests = new Map();
|
|
305
|
+
anonymousAuthTokenRequests.set(storage, storageRequests);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const activeRequest = storageRequests.get(baseUrl);
|
|
309
|
+
if (activeRequest) return activeRequest;
|
|
310
|
+
|
|
311
|
+
const locks = getWebLockManager();
|
|
312
|
+
const request = locks
|
|
313
|
+
? locks.request(getAnonymousAuthLockName(baseUrl), requestToken)
|
|
314
|
+
: requestToken();
|
|
315
|
+
const sharedRequest = request.finally(() => {
|
|
316
|
+
if (storageRequests.get(baseUrl) === sharedRequest) {
|
|
317
|
+
storageRequests.delete(baseUrl);
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
storageRequests.set(baseUrl, sharedRequest);
|
|
321
|
+
return sharedRequest;
|
|
322
|
+
};
|
|
323
|
+
|
|
250
324
|
export class AssistantCloudAnonymousAuthStrategy implements AssistantCloudAuthStrategy {
|
|
251
325
|
public readonly strategy = "anon";
|
|
252
326
|
|
|
@@ -255,70 +329,90 @@ export class AssistantCloudAnonymousAuthStrategy implements AssistantCloudAuthSt
|
|
|
255
329
|
|
|
256
330
|
constructor(baseUrl: string) {
|
|
257
331
|
this.baseUrl = baseUrl;
|
|
258
|
-
|
|
332
|
+
const requestAuthToken = async (): Promise<string | null> => {
|
|
259
333
|
const currentTime = Date.now();
|
|
260
334
|
const storedRefreshToken = readRefreshToken(this.baseUrl);
|
|
261
335
|
|
|
262
336
|
if (storedRefreshToken) {
|
|
263
337
|
const refreshExpiry = new Date(storedRefreshToken.expires_at).getTime();
|
|
264
338
|
if (refreshExpiry - currentTime > 30 * 1000) {
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
{
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
339
|
+
const refreshedAccessToken = await withAuthTokenDeadline(
|
|
340
|
+
"refresh token request",
|
|
341
|
+
async (signal) => {
|
|
342
|
+
const response = await fetch(
|
|
343
|
+
`${this.baseUrl}/v1/auth/tokens/refresh`,
|
|
344
|
+
{
|
|
345
|
+
method: "POST",
|
|
346
|
+
headers: { "Content-Type": "application/json" },
|
|
347
|
+
body: JSON.stringify({
|
|
348
|
+
refresh_token: storedRefreshToken.token,
|
|
349
|
+
}),
|
|
350
|
+
signal,
|
|
351
|
+
},
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
if (response.ok) {
|
|
355
|
+
const { data, accessToken } = await readAuthTokenResponse(
|
|
356
|
+
response,
|
|
357
|
+
"refresh auth token response",
|
|
358
|
+
);
|
|
359
|
+
if (data.refresh_token != null) {
|
|
360
|
+
writeRefreshToken(
|
|
361
|
+
this.baseUrl,
|
|
362
|
+
readRefreshTokenResponse(
|
|
363
|
+
data.refresh_token,
|
|
364
|
+
"refresh auth token response.refresh_token",
|
|
365
|
+
),
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
return accessToken;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (response.status === 429 || response.status >= 500) {
|
|
372
|
+
throw new Error(
|
|
373
|
+
`Assistant Cloud token refresh failed with status ${response.status}`,
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return null;
|
|
271
378
|
},
|
|
272
379
|
);
|
|
273
|
-
|
|
274
|
-
if (response.ok) {
|
|
275
|
-
const { data, accessToken } = await readAuthTokenResponse(
|
|
276
|
-
response,
|
|
277
|
-
"refresh auth token response",
|
|
278
|
-
);
|
|
279
|
-
if (data.refresh_token != null) {
|
|
280
|
-
writeRefreshToken(
|
|
281
|
-
this.baseUrl,
|
|
282
|
-
readRefreshTokenResponse(
|
|
283
|
-
data.refresh_token,
|
|
284
|
-
"refresh auth token response.refresh_token",
|
|
285
|
-
),
|
|
286
|
-
);
|
|
287
|
-
}
|
|
288
|
-
return accessToken;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
if (response.status === 429 || response.status >= 500) {
|
|
292
|
-
throw new Error(
|
|
293
|
-
`Assistant Cloud token refresh failed with status ${response.status}`,
|
|
294
|
-
);
|
|
295
|
-
}
|
|
380
|
+
if (refreshedAccessToken !== null) return refreshedAccessToken;
|
|
296
381
|
} else {
|
|
297
382
|
removeRefreshToken(this.baseUrl);
|
|
298
383
|
}
|
|
299
384
|
}
|
|
300
385
|
|
|
301
386
|
// No valid refresh token; request a new anonymous token
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
387
|
+
return withAuthTokenDeadline(
|
|
388
|
+
"anonymous token request",
|
|
389
|
+
async (signal) => {
|
|
390
|
+
const response = await fetch(
|
|
391
|
+
`${this.baseUrl}/v1/auth/tokens/anonymous`,
|
|
392
|
+
{ method: "POST", signal },
|
|
393
|
+
);
|
|
305
394
|
|
|
306
|
-
|
|
395
|
+
if (!response.ok) return null;
|
|
307
396
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
397
|
+
const { data, accessToken } = await readAuthTokenResponse(
|
|
398
|
+
response,
|
|
399
|
+
"anonymous auth token response",
|
|
400
|
+
);
|
|
312
401
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
402
|
+
writeRefreshToken(
|
|
403
|
+
this.baseUrl,
|
|
404
|
+
readRefreshTokenResponse(
|
|
405
|
+
data.refresh_token,
|
|
406
|
+
"anonymous auth token response.refresh_token",
|
|
407
|
+
),
|
|
408
|
+
);
|
|
409
|
+
return accessToken;
|
|
410
|
+
},
|
|
319
411
|
);
|
|
320
|
-
|
|
321
|
-
|
|
412
|
+
};
|
|
413
|
+
this.jwtStrategy = new AssistantCloudJWTAuthStrategy(() =>
|
|
414
|
+
getSharedAnonymousAuthToken(this.baseUrl, requestAuthToken),
|
|
415
|
+
);
|
|
322
416
|
}
|
|
323
417
|
|
|
324
418
|
public async getAuthHeaders(): Promise<Record<string, string> | false> {
|
|
@@ -39,32 +39,36 @@ export class CloudMessagePersistence {
|
|
|
39
39
|
content: ReadonlyJSONObject,
|
|
40
40
|
): Promise<void> {
|
|
41
41
|
const cloud = this.getCloud();
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
const existing = this.idMapping[messageId];
|
|
43
|
+
if (existing instanceof Promise) {
|
|
44
|
+
await existing;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
46
47
|
|
|
47
|
-
const task =
|
|
48
|
-
|
|
48
|
+
const task = (async () => {
|
|
49
|
+
const resolvedParentId = parentId
|
|
50
|
+
? ((await this.idMapping[parentId]) ?? parentId)
|
|
51
|
+
: null;
|
|
52
|
+
const { message_id } = await cloud.threads.messages.create(threadId, {
|
|
49
53
|
parent_id: resolvedParentId,
|
|
50
54
|
format,
|
|
51
55
|
content,
|
|
52
|
-
})
|
|
53
|
-
.then(({ message_id }) => {
|
|
54
|
-
this.idMapping[messageId] = message_id;
|
|
55
|
-
return message_id;
|
|
56
|
-
})
|
|
57
|
-
.catch((err) => {
|
|
58
|
-
// Only delete if we're still the active task (avoids clobbering a retry)
|
|
59
|
-
if (this.idMapping[messageId] === task) {
|
|
60
|
-
delete this.idMapping[messageId];
|
|
61
|
-
}
|
|
62
|
-
throw err;
|
|
63
56
|
});
|
|
57
|
+
return message_id;
|
|
58
|
+
})();
|
|
64
59
|
|
|
65
|
-
// Store the promise immediately so concurrent appends can await it
|
|
66
60
|
this.idMapping[messageId] = task;
|
|
67
|
-
|
|
61
|
+
try {
|
|
62
|
+
const remoteId = await task;
|
|
63
|
+
if (this.idMapping[messageId] === task) {
|
|
64
|
+
this.idMapping[messageId] = remoteId;
|
|
65
|
+
}
|
|
66
|
+
} catch (err) {
|
|
67
|
+
if (this.idMapping[messageId] === task) {
|
|
68
|
+
delete this.idMapping[messageId];
|
|
69
|
+
}
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
/**
|
package/src/runTelemetry.test.ts
CHANGED
|
@@ -124,7 +124,48 @@ describe("normalizeRunTelemetryUsage", () => {
|
|
|
124
124
|
).toEqual({ inputTokens: 0, cachedInputTokens: 5 });
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
+
it("reads the AI SDK v7 token detail objects", () => {
|
|
128
|
+
expect(
|
|
129
|
+
normalizeRunTelemetryUsage({
|
|
130
|
+
inputTokens: 12,
|
|
131
|
+
outputTokens: 7,
|
|
132
|
+
inputTokenDetails: { cacheReadTokens: 5 },
|
|
133
|
+
outputTokenDetails: { reasoningTokens: 3 },
|
|
134
|
+
}),
|
|
135
|
+
).toEqual({
|
|
136
|
+
inputTokens: 12,
|
|
137
|
+
outputTokens: 7,
|
|
138
|
+
reasoningTokens: 3,
|
|
139
|
+
cachedInputTokens: 5,
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("prefers the top-level detail counts over the nested ones", () => {
|
|
144
|
+
expect(
|
|
145
|
+
normalizeRunTelemetryUsage({
|
|
146
|
+
reasoningTokens: 3,
|
|
147
|
+
cachedInputTokens: 5,
|
|
148
|
+
inputTokenDetails: { cacheReadTokens: 90 },
|
|
149
|
+
outputTokenDetails: { reasoningTokens: 90 },
|
|
150
|
+
}),
|
|
151
|
+
).toEqual({ reasoningTokens: 3, cachedInputTokens: 5 });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("returns a usage object when only the nested counts are present", () => {
|
|
155
|
+
expect(
|
|
156
|
+
normalizeRunTelemetryUsage({
|
|
157
|
+
inputTokenDetails: { cacheReadTokens: 5 },
|
|
158
|
+
}),
|
|
159
|
+
).toEqual({ cachedInputTokens: 5 });
|
|
160
|
+
});
|
|
161
|
+
|
|
127
162
|
it("returns undefined when no count is present", () => {
|
|
128
163
|
expect(normalizeRunTelemetryUsage({})).toBeUndefined();
|
|
164
|
+
expect(
|
|
165
|
+
normalizeRunTelemetryUsage({
|
|
166
|
+
inputTokenDetails: {},
|
|
167
|
+
outputTokenDetails: {},
|
|
168
|
+
}),
|
|
169
|
+
).toBeUndefined();
|
|
129
170
|
});
|
|
130
171
|
});
|
package/src/runTelemetry.ts
CHANGED
|
@@ -105,11 +105,14 @@ export type RunTelemetryUsage = {
|
|
|
105
105
|
export type RunTelemetryUsageInit = RunTelemetryUsage & {
|
|
106
106
|
promptTokens?: number;
|
|
107
107
|
completionTokens?: number;
|
|
108
|
+
inputTokenDetails?: { cacheReadTokens?: number };
|
|
109
|
+
outputTokenDetails?: { reasoningTokens?: number };
|
|
108
110
|
};
|
|
109
111
|
|
|
110
112
|
/**
|
|
111
|
-
* Resolves the token counts a provider reports under
|
|
112
|
-
*
|
|
113
|
+
* Resolves the token counts a provider reports under any of the names the AI
|
|
114
|
+
* SDK has used: the current top-level ones, the legacy prompt/completion pair,
|
|
115
|
+
* and the v7 token detail objects. Returns undefined when no count is present,
|
|
113
116
|
* so callers can tell an empty usage object from a zeroed one.
|
|
114
117
|
*/
|
|
115
118
|
export function normalizeRunTelemetryUsage(
|
|
@@ -117,12 +120,17 @@ export function normalizeRunTelemetryUsage(
|
|
|
117
120
|
): RunTelemetryUsage | undefined {
|
|
118
121
|
const inputTokens = usage.inputTokens ?? usage.promptTokens;
|
|
119
122
|
const outputTokens = usage.outputTokens ?? usage.completionTokens;
|
|
123
|
+
// AI SDK v7 moved these under token detail objects; v6 kept them top-level.
|
|
124
|
+
const reasoningTokens =
|
|
125
|
+
usage.reasoningTokens ?? usage.outputTokenDetails?.reasoningTokens;
|
|
126
|
+
const cachedInputTokens =
|
|
127
|
+
usage.cachedInputTokens ?? usage.inputTokenDetails?.cacheReadTokens;
|
|
120
128
|
|
|
121
129
|
if (
|
|
122
130
|
inputTokens == null &&
|
|
123
131
|
outputTokens == null &&
|
|
124
|
-
|
|
125
|
-
|
|
132
|
+
reasoningTokens == null &&
|
|
133
|
+
cachedInputTokens == null
|
|
126
134
|
) {
|
|
127
135
|
return undefined;
|
|
128
136
|
}
|
|
@@ -130,11 +138,7 @@ export function normalizeRunTelemetryUsage(
|
|
|
130
138
|
return {
|
|
131
139
|
...(inputTokens != null ? { inputTokens } : undefined),
|
|
132
140
|
...(outputTokens != null ? { outputTokens } : undefined),
|
|
133
|
-
...(
|
|
134
|
-
|
|
135
|
-
: undefined),
|
|
136
|
-
...(usage.cachedInputTokens != null
|
|
137
|
-
? { cachedInputTokens: usage.cachedInputTokens }
|
|
138
|
-
: undefined),
|
|
141
|
+
...(reasoningTokens != null ? { reasoningTokens } : undefined),
|
|
142
|
+
...(cachedInputTokens != null ? { cachedInputTokens } : undefined),
|
|
139
143
|
};
|
|
140
144
|
}
|
|
@@ -43,6 +43,7 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
|
|
|
43
43
|
});
|
|
44
44
|
|
|
45
45
|
afterEach(() => {
|
|
46
|
+
vi.useRealTimers();
|
|
46
47
|
vi.unstubAllGlobals();
|
|
47
48
|
if (originalLocalStorageDescriptor) {
|
|
48
49
|
Object.defineProperty(
|
|
@@ -83,7 +84,7 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
|
|
|
83
84
|
expect(values.get(refreshTokenKey)).toBe(JSON.stringify(nextRefreshToken));
|
|
84
85
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
85
86
|
`${baseUrl}/v1/auth/tokens/anonymous`,
|
|
86
|
-
{ method: "POST" },
|
|
87
|
+
{ method: "POST", signal: expect.any(AbortSignal) },
|
|
87
88
|
);
|
|
88
89
|
});
|
|
89
90
|
|
|
@@ -106,6 +107,276 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
|
|
|
106
107
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
107
108
|
});
|
|
108
109
|
|
|
110
|
+
it("deduplicates anonymous token requests across strategy instances", async () => {
|
|
111
|
+
const values = new Map<string, string>();
|
|
112
|
+
installLocalStorage({
|
|
113
|
+
getItem: (key) => values.get(key) ?? null,
|
|
114
|
+
setItem: (key, value) => {
|
|
115
|
+
values.set(key, value);
|
|
116
|
+
},
|
|
117
|
+
removeItem: (key) => {
|
|
118
|
+
values.delete(key);
|
|
119
|
+
},
|
|
120
|
+
} as Storage);
|
|
121
|
+
const fetchMock = mockAnonymousTokenFetch();
|
|
122
|
+
const first = new AssistantCloudAnonymousAuthStrategy(baseUrl);
|
|
123
|
+
const second = new AssistantCloudAnonymousAuthStrategy(baseUrl);
|
|
124
|
+
|
|
125
|
+
await expect(
|
|
126
|
+
Promise.all([first.getAuthHeaders(), second.getAuthHeaders()]),
|
|
127
|
+
).resolves.toEqual([
|
|
128
|
+
{ Authorization: `Bearer ${accessToken}` },
|
|
129
|
+
{ Authorization: `Bearer ${accessToken}` },
|
|
130
|
+
]);
|
|
131
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
132
|
+
expect(values.get(refreshTokenKey)).toBe(JSON.stringify(refreshToken));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("coordinates anonymous token requests across realms", async () => {
|
|
136
|
+
const values = new Map<string, string>();
|
|
137
|
+
installLocalStorage({
|
|
138
|
+
getItem: (key) => values.get(key) ?? null,
|
|
139
|
+
setItem: (key, value) => {
|
|
140
|
+
values.set(key, value);
|
|
141
|
+
},
|
|
142
|
+
removeItem: (key) => {
|
|
143
|
+
values.delete(key);
|
|
144
|
+
},
|
|
145
|
+
} as Storage);
|
|
146
|
+
let lockTail: Promise<unknown> = Promise.resolve();
|
|
147
|
+
const lockRequest = vi.fn(
|
|
148
|
+
(_name: string, callback: () => Promise<string | null>) => {
|
|
149
|
+
const request = lockTail.then(callback);
|
|
150
|
+
lockTail = request.then(
|
|
151
|
+
() => undefined,
|
|
152
|
+
() => undefined,
|
|
153
|
+
);
|
|
154
|
+
return request;
|
|
155
|
+
},
|
|
156
|
+
);
|
|
157
|
+
vi.stubGlobal("navigator", { locks: { request: lockRequest } });
|
|
158
|
+
const rotatedRefreshToken = { token: "r2", expires_at: "2099-01-01" };
|
|
159
|
+
const response = (refreshTokenValue: typeof refreshToken) => ({
|
|
160
|
+
ok: true,
|
|
161
|
+
json: vi.fn().mockResolvedValue({
|
|
162
|
+
access_token: accessToken,
|
|
163
|
+
refresh_token: refreshTokenValue,
|
|
164
|
+
}),
|
|
165
|
+
});
|
|
166
|
+
const fetchMock = vi
|
|
167
|
+
.fn()
|
|
168
|
+
.mockResolvedValueOnce(response(refreshToken))
|
|
169
|
+
.mockResolvedValueOnce(response(rotatedRefreshToken));
|
|
170
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
171
|
+
|
|
172
|
+
const first = new AssistantCloudAnonymousAuthStrategy(baseUrl);
|
|
173
|
+
vi.resetModules();
|
|
174
|
+
const { AssistantCloudAnonymousAuthStrategy: Second } =
|
|
175
|
+
await import("../AssistantCloudAuthStrategy");
|
|
176
|
+
|
|
177
|
+
await expect(
|
|
178
|
+
Promise.all([
|
|
179
|
+
first.getAuthHeaders(),
|
|
180
|
+
new Second(baseUrl).getAuthHeaders(),
|
|
181
|
+
]),
|
|
182
|
+
).resolves.toEqual([
|
|
183
|
+
{ Authorization: `Bearer ${accessToken}` },
|
|
184
|
+
{ Authorization: `Bearer ${accessToken}` },
|
|
185
|
+
]);
|
|
186
|
+
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
187
|
+
1,
|
|
188
|
+
`${baseUrl}/v1/auth/tokens/anonymous`,
|
|
189
|
+
{ method: "POST", signal: expect.any(AbortSignal) },
|
|
190
|
+
);
|
|
191
|
+
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
192
|
+
2,
|
|
193
|
+
`${baseUrl}/v1/auth/tokens/refresh`,
|
|
194
|
+
{
|
|
195
|
+
method: "POST",
|
|
196
|
+
headers: { "Content-Type": "application/json" },
|
|
197
|
+
body: JSON.stringify({ refresh_token: refreshToken.token }),
|
|
198
|
+
signal: expect.any(AbortSignal),
|
|
199
|
+
},
|
|
200
|
+
);
|
|
201
|
+
expect(lockRequest).toHaveBeenCalledTimes(2);
|
|
202
|
+
expect(values.get(refreshTokenKey)).toBe(
|
|
203
|
+
JSON.stringify(rotatedRefreshToken),
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("retries shared anonymous token requests after a failure", async () => {
|
|
208
|
+
const values = new Map<string, string>();
|
|
209
|
+
installLocalStorage({
|
|
210
|
+
getItem: (key) => values.get(key) ?? null,
|
|
211
|
+
setItem: (key, value) => {
|
|
212
|
+
values.set(key, value);
|
|
213
|
+
},
|
|
214
|
+
removeItem: (key) => {
|
|
215
|
+
values.delete(key);
|
|
216
|
+
},
|
|
217
|
+
} as Storage);
|
|
218
|
+
const failure = new Error("authentication unavailable");
|
|
219
|
+
const fetchMock = vi
|
|
220
|
+
.fn()
|
|
221
|
+
.mockRejectedValueOnce(failure)
|
|
222
|
+
.mockResolvedValueOnce({
|
|
223
|
+
ok: true,
|
|
224
|
+
json: vi.fn().mockResolvedValue({
|
|
225
|
+
access_token: accessToken,
|
|
226
|
+
refresh_token: refreshToken,
|
|
227
|
+
}),
|
|
228
|
+
});
|
|
229
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
230
|
+
const first = new AssistantCloudAnonymousAuthStrategy(baseUrl);
|
|
231
|
+
const second = new AssistantCloudAnonymousAuthStrategy(baseUrl);
|
|
232
|
+
|
|
233
|
+
await Promise.all([
|
|
234
|
+
expect(first.getAuthHeaders()).rejects.toBe(failure),
|
|
235
|
+
expect(second.getAuthHeaders()).rejects.toBe(failure),
|
|
236
|
+
]);
|
|
237
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
238
|
+
|
|
239
|
+
await expect(
|
|
240
|
+
new AssistantCloudAnonymousAuthStrategy(baseUrl).getAuthHeaders(),
|
|
241
|
+
).resolves.toEqual({ Authorization: `Bearer ${accessToken}` });
|
|
242
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("aborts timed out shared anonymous token requests before retrying", async () => {
|
|
246
|
+
vi.useFakeTimers();
|
|
247
|
+
const values = new Map<string, string>();
|
|
248
|
+
installLocalStorage({
|
|
249
|
+
getItem: (key) => values.get(key) ?? null,
|
|
250
|
+
setItem: (key, value) => {
|
|
251
|
+
values.set(key, value);
|
|
252
|
+
},
|
|
253
|
+
removeItem: (key) => {
|
|
254
|
+
values.delete(key);
|
|
255
|
+
},
|
|
256
|
+
} as Storage);
|
|
257
|
+
let requestSignal: AbortSignal | null | undefined;
|
|
258
|
+
const fetchMock = vi
|
|
259
|
+
.fn()
|
|
260
|
+
.mockImplementationOnce((_input: RequestInfo | URL, init?: RequestInit) =>
|
|
261
|
+
Promise.resolve({
|
|
262
|
+
ok: true,
|
|
263
|
+
json: () =>
|
|
264
|
+
new Promise<never>((_resolve, reject) => {
|
|
265
|
+
requestSignal = init?.signal;
|
|
266
|
+
requestSignal?.addEventListener(
|
|
267
|
+
"abort",
|
|
268
|
+
() => reject(requestSignal?.reason),
|
|
269
|
+
{ once: true },
|
|
270
|
+
);
|
|
271
|
+
}),
|
|
272
|
+
} as Response),
|
|
273
|
+
)
|
|
274
|
+
.mockResolvedValueOnce({
|
|
275
|
+
ok: true,
|
|
276
|
+
json: vi.fn().mockResolvedValue({
|
|
277
|
+
access_token: accessToken,
|
|
278
|
+
refresh_token: refreshToken,
|
|
279
|
+
}),
|
|
280
|
+
});
|
|
281
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
282
|
+
const first = new AssistantCloudAnonymousAuthStrategy(baseUrl);
|
|
283
|
+
const second = new AssistantCloudAnonymousAuthStrategy(baseUrl);
|
|
284
|
+
|
|
285
|
+
const firstRequest = expect(first.getAuthHeaders()).rejects.toThrow(
|
|
286
|
+
"Assistant Cloud anonymous token request timed out after 30000ms",
|
|
287
|
+
);
|
|
288
|
+
const secondRequest = expect(second.getAuthHeaders()).rejects.toThrow(
|
|
289
|
+
"Assistant Cloud anonymous token request timed out after 30000ms",
|
|
290
|
+
);
|
|
291
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
292
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
293
|
+
expect(requestSignal).toBeInstanceOf(AbortSignal);
|
|
294
|
+
|
|
295
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
296
|
+
await Promise.all([firstRequest, secondRequest]);
|
|
297
|
+
expect(requestSignal?.aborted).toBe(true);
|
|
298
|
+
expect(values.has(refreshTokenKey)).toBe(false);
|
|
299
|
+
|
|
300
|
+
await expect(
|
|
301
|
+
new AssistantCloudAnonymousAuthStrategy(baseUrl).getAuthHeaders(),
|
|
302
|
+
).resolves.toEqual({ Authorization: `Bearer ${accessToken}` });
|
|
303
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("aborts timed out refresh requests without replacing the identity", async () => {
|
|
307
|
+
vi.useFakeTimers();
|
|
308
|
+
const values = new Map([[refreshTokenKey, JSON.stringify(refreshToken)]]);
|
|
309
|
+
installLocalStorage({
|
|
310
|
+
getItem: (key) => values.get(key) ?? null,
|
|
311
|
+
setItem: (key, value) => {
|
|
312
|
+
values.set(key, value);
|
|
313
|
+
},
|
|
314
|
+
removeItem: (key) => {
|
|
315
|
+
values.delete(key);
|
|
316
|
+
},
|
|
317
|
+
} as Storage);
|
|
318
|
+
let requestSignal: AbortSignal | null | undefined;
|
|
319
|
+
const rotatedRefreshToken = { token: "r2", expires_at: "2099-02-01" };
|
|
320
|
+
const fetchMock = vi
|
|
321
|
+
.fn()
|
|
322
|
+
.mockImplementationOnce(
|
|
323
|
+
(_input: RequestInfo | URL, init?: RequestInit) =>
|
|
324
|
+
new Promise<Response>((_resolve, reject) => {
|
|
325
|
+
requestSignal = init?.signal;
|
|
326
|
+
requestSignal?.addEventListener(
|
|
327
|
+
"abort",
|
|
328
|
+
() => reject(requestSignal?.reason),
|
|
329
|
+
{ once: true },
|
|
330
|
+
);
|
|
331
|
+
}),
|
|
332
|
+
)
|
|
333
|
+
.mockResolvedValueOnce({
|
|
334
|
+
ok: true,
|
|
335
|
+
json: vi.fn().mockResolvedValue({
|
|
336
|
+
access_token: accessToken,
|
|
337
|
+
refresh_token: rotatedRefreshToken,
|
|
338
|
+
}),
|
|
339
|
+
});
|
|
340
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
341
|
+
const strategy = new AssistantCloudAnonymousAuthStrategy(baseUrl);
|
|
342
|
+
|
|
343
|
+
const request = expect(strategy.getAuthHeaders()).rejects.toThrow(
|
|
344
|
+
"Assistant Cloud refresh token request timed out after 30000ms",
|
|
345
|
+
);
|
|
346
|
+
expect(requestSignal).toBeInstanceOf(AbortSignal);
|
|
347
|
+
|
|
348
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
349
|
+
await request;
|
|
350
|
+
expect(requestSignal?.aborted).toBe(true);
|
|
351
|
+
expect(values.get(refreshTokenKey)).toBe(JSON.stringify(refreshToken));
|
|
352
|
+
|
|
353
|
+
await expect(strategy.getAuthHeaders()).resolves.toEqual({
|
|
354
|
+
Authorization: `Bearer ${accessToken}`,
|
|
355
|
+
});
|
|
356
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
357
|
+
expect(values.get(refreshTokenKey)).toBe(
|
|
358
|
+
JSON.stringify(rotatedRefreshToken),
|
|
359
|
+
);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("keeps anonymous token requests independent without localStorage", async () => {
|
|
363
|
+
delete (globalThis as { localStorage?: Storage }).localStorage;
|
|
364
|
+
const lockRequest = vi.fn();
|
|
365
|
+
vi.stubGlobal("navigator", { locks: { request: lockRequest } });
|
|
366
|
+
const fetchMock = mockAnonymousTokenFetch();
|
|
367
|
+
const first = new AssistantCloudAnonymousAuthStrategy(baseUrl);
|
|
368
|
+
const second = new AssistantCloudAnonymousAuthStrategy(baseUrl);
|
|
369
|
+
|
|
370
|
+
await expect(
|
|
371
|
+
Promise.all([first.getAuthHeaders(), second.getAuthHeaders()]),
|
|
372
|
+
).resolves.toEqual([
|
|
373
|
+
{ Authorization: `Bearer ${accessToken}` },
|
|
374
|
+
{ Authorization: `Bearer ${accessToken}` },
|
|
375
|
+
]);
|
|
376
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
377
|
+
expect(lockRequest).not.toHaveBeenCalled();
|
|
378
|
+
});
|
|
379
|
+
|
|
109
380
|
it("scopes anonymous refresh tokens by backend", async () => {
|
|
110
381
|
const secondBaseUrl = "https://other.example.com";
|
|
111
382
|
const values = new Map<string, string>();
|
|
@@ -140,20 +411,20 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
|
|
|
140
411
|
});
|
|
141
412
|
vi.stubGlobal("fetch", fetchMock);
|
|
142
413
|
|
|
143
|
-
await
|
|
144
|
-
|
|
145
|
-
secondBaseUrl,
|
|
146
|
-
)
|
|
414
|
+
await Promise.all([
|
|
415
|
+
new AssistantCloudAnonymousAuthStrategy(baseUrl).getAuthHeaders(),
|
|
416
|
+
new AssistantCloudAnonymousAuthStrategy(secondBaseUrl).getAuthHeaders(),
|
|
417
|
+
]);
|
|
147
418
|
|
|
148
419
|
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
149
420
|
1,
|
|
150
421
|
`${baseUrl}/v1/auth/tokens/anonymous`,
|
|
151
|
-
{ method: "POST" },
|
|
422
|
+
{ method: "POST", signal: expect.any(AbortSignal) },
|
|
152
423
|
);
|
|
153
424
|
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
154
425
|
2,
|
|
155
426
|
`${secondBaseUrl}/v1/auth/tokens/anonymous`,
|
|
156
|
-
{ method: "POST" },
|
|
427
|
+
{ method: "POST", signal: expect.any(AbortSignal) },
|
|
157
428
|
);
|
|
158
429
|
expect(values.get(refreshTokenKey)).toBe(JSON.stringify(refreshToken));
|
|
159
430
|
expect(values.get(`aui:refresh_token:${secondBaseUrl}`)).toBe(
|
|
@@ -206,12 +477,13 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
|
|
|
206
477
|
method: "POST",
|
|
207
478
|
headers: { "Content-Type": "application/json" },
|
|
208
479
|
body: JSON.stringify({ refresh_token: refreshToken.token }),
|
|
480
|
+
signal: expect.any(AbortSignal),
|
|
209
481
|
},
|
|
210
482
|
);
|
|
211
483
|
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
212
484
|
2,
|
|
213
485
|
`${secondBaseUrl}/v1/auth/tokens/anonymous`,
|
|
214
|
-
{ method: "POST" },
|
|
486
|
+
{ method: "POST", signal: expect.any(AbortSignal) },
|
|
215
487
|
);
|
|
216
488
|
expect(values.get(refreshTokenKey)).toBe(JSON.stringify(refreshToken));
|
|
217
489
|
expect(values.get(`aui:refresh_token:${secondBaseUrl}`)).toBe(
|
|
@@ -270,12 +542,13 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
|
|
|
270
542
|
method: "POST",
|
|
271
543
|
headers: { "Content-Type": "application/json" },
|
|
272
544
|
body: JSON.stringify({ refresh_token: scopedRefreshToken.token }),
|
|
545
|
+
signal: expect.any(AbortSignal),
|
|
273
546
|
},
|
|
274
547
|
);
|
|
275
548
|
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
276
549
|
2,
|
|
277
550
|
`${secondBaseUrl}/v1/auth/tokens/anonymous`,
|
|
278
|
-
{ method: "POST" },
|
|
551
|
+
{ method: "POST", signal: expect.any(AbortSignal) },
|
|
279
552
|
);
|
|
280
553
|
expect(values.has("aui:refresh_token")).toBe(false);
|
|
281
554
|
expect(values.get(`aui:refresh_token:${secondBaseUrl}`)).toBe(
|
|
@@ -470,6 +743,7 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
|
|
|
470
743
|
method: "POST",
|
|
471
744
|
headers: { "Content-Type": "application/json" },
|
|
472
745
|
body: JSON.stringify({ refresh_token: refreshToken.token }),
|
|
746
|
+
signal: expect.any(AbortSignal),
|
|
473
747
|
},
|
|
474
748
|
);
|
|
475
749
|
expect(setItem).not.toHaveBeenCalled();
|
|
@@ -538,7 +812,7 @@ describe("AssistantCloudAnonymousAuthStrategy", () => {
|
|
|
538
812
|
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
539
813
|
2,
|
|
540
814
|
`${baseUrl}/v1/auth/tokens/anonymous`,
|
|
541
|
-
{ method: "POST" },
|
|
815
|
+
{ method: "POST", signal: expect.any(AbortSignal) },
|
|
542
816
|
);
|
|
543
817
|
expect(values.get(refreshTokenKey)).toBe(
|
|
544
818
|
JSON.stringify(replacementRefreshToken),
|
|
@@ -108,6 +108,99 @@ describe("CloudMessagePersistence", () => {
|
|
|
108
108
|
});
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
+
it("deduplicates concurrent child appends while the parent is pending", async () => {
|
|
112
|
+
let resolveParent!: (value: { message_id: string }) => void;
|
|
113
|
+
vi.mocked(cloud.threads.messages.create)
|
|
114
|
+
.mockImplementationOnce(
|
|
115
|
+
() =>
|
|
116
|
+
new Promise((resolve) => {
|
|
117
|
+
resolveParent = resolve;
|
|
118
|
+
}),
|
|
119
|
+
)
|
|
120
|
+
.mockResolvedValue({ message_id: "remote-child" });
|
|
121
|
+
|
|
122
|
+
const parent = persistence.append("thread-1", "parent", null, "aui/v0", {
|
|
123
|
+
text: "parent",
|
|
124
|
+
});
|
|
125
|
+
const firstChild = persistence.append(
|
|
126
|
+
"thread-1",
|
|
127
|
+
"child",
|
|
128
|
+
"parent",
|
|
129
|
+
"aui/v0",
|
|
130
|
+
{ text: "child" },
|
|
131
|
+
);
|
|
132
|
+
const secondChild = persistence.append(
|
|
133
|
+
"thread-1",
|
|
134
|
+
"child",
|
|
135
|
+
"parent",
|
|
136
|
+
"aui/v0",
|
|
137
|
+
{ text: "child" },
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
expect(persistence.isPersisted("child")).toBe(true);
|
|
141
|
+
resolveParent({ message_id: "remote-parent" });
|
|
142
|
+
await Promise.all([parent, firstChild, secondChild]);
|
|
143
|
+
|
|
144
|
+
expect(cloud.threads.messages.create).toHaveBeenCalledTimes(2);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("resolves remote IDs throughout a concurrent message chain", async () => {
|
|
148
|
+
let resolveParent!: (value: { message_id: string }) => void;
|
|
149
|
+
vi.mocked(cloud.threads.messages.create)
|
|
150
|
+
.mockImplementationOnce(
|
|
151
|
+
() =>
|
|
152
|
+
new Promise((resolve) => {
|
|
153
|
+
resolveParent = resolve;
|
|
154
|
+
}),
|
|
155
|
+
)
|
|
156
|
+
.mockResolvedValueOnce({ message_id: "remote-child" })
|
|
157
|
+
.mockResolvedValueOnce({ message_id: "remote-grandchild" });
|
|
158
|
+
|
|
159
|
+
const parent = persistence.append("thread-1", "parent", null, "aui/v0", {
|
|
160
|
+
text: "parent",
|
|
161
|
+
});
|
|
162
|
+
const child = persistence.append("thread-1", "child", "parent", "aui/v0", {
|
|
163
|
+
text: "child",
|
|
164
|
+
});
|
|
165
|
+
const grandchild = persistence.append(
|
|
166
|
+
"thread-1",
|
|
167
|
+
"grandchild",
|
|
168
|
+
"child",
|
|
169
|
+
"aui/v0",
|
|
170
|
+
{ text: "grandchild" },
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
resolveParent({ message_id: "remote-parent" });
|
|
174
|
+
await Promise.all([parent, child, grandchild]);
|
|
175
|
+
|
|
176
|
+
expect(cloud.threads.messages.create).toHaveBeenCalledWith("thread-1", {
|
|
177
|
+
parent_id: "remote-parent",
|
|
178
|
+
format: "aui/v0",
|
|
179
|
+
content: { text: "child" },
|
|
180
|
+
});
|
|
181
|
+
expect(cloud.threads.messages.create).toHaveBeenCalledWith("thread-1", {
|
|
182
|
+
parent_id: "remote-child",
|
|
183
|
+
format: "aui/v0",
|
|
184
|
+
content: { text: "grandchild" },
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("re-appends a message after its mapping has settled", async () => {
|
|
189
|
+
vi.mocked(cloud.threads.messages.create)
|
|
190
|
+
.mockResolvedValueOnce({ message_id: "remote-1" })
|
|
191
|
+
.mockResolvedValueOnce({ message_id: "remote-2" });
|
|
192
|
+
|
|
193
|
+
await persistence.append("thread-1", "local-1", null, "aui/v0", {
|
|
194
|
+
text: "first",
|
|
195
|
+
});
|
|
196
|
+
await persistence.append("thread-1", "local-1", null, "aui/v0", {
|
|
197
|
+
text: "second",
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(cloud.threads.messages.create).toHaveBeenCalledTimes(2);
|
|
201
|
+
expect(await persistence.getRemoteId("local-1")).toBe("remote-2");
|
|
202
|
+
});
|
|
203
|
+
|
|
111
204
|
it("loaded messages are marked as persisted and not re-created", async () => {
|
|
112
205
|
vi.mocked(cloud.threads.messages.list).mockResolvedValue({
|
|
113
206
|
messages: [
|