Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions core/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ function build({
logLevel: 'info',
};

if (platform === 'browser' && bundle) {
buildOptions.alias = {
'node:async_hooks': './src/utils/async_hooks_shim.ts',
};
}

// Prepend license header to the top of the file
if (format === 'cjs' || bundle) {
buildOptions.banner = {js: licenseHeaderText};
Expand Down
1 change: 1 addition & 0 deletions core/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ export type {
VertexAiSearchToolParams,
} from './tools/vertex_ai_search_tool.js';
export {VertexRagRetrievalTool} from './tools/vertex_rag_retrieval_tool.js';
export {getClientLabels, runWithClientLabel} from './utils/client_labels.js';
export {LogLevel, getLogger, setLogLevel, setLogger} from './utils/logger.js';
export type {Logger} from './utils/logger.js';
export {isGemini2OrAbove, isGemini3xFlashLive} from './utils/model_name.js';
Expand Down
21 changes: 21 additions & 0 deletions core/src/utils/async_hooks_shim.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

export class AsyncLocalStorage<T> {
private store: T | undefined;
run<R>(store: T, callback: () => R): R {
const previous = this.store;
this.store = store;
try {
return callback();
} finally {
this.store = previous;
}
}
getStore(): T | undefined {
return this.store;
}
}
52 changes: 43 additions & 9 deletions core/src/utils/client_labels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,36 @@
* SPDX-License-Identifier: Apache-2.0
*/

import {AsyncLocalStorage} from 'node:async_hooks';
import {version} from '../version.js';

import {isBrowser} from './env_aware_utils.js';

const ADK_LABEL = 'google-adk';
const LANGUAGE_LABEL = 'gl-typescript';
const AGENT_ENGINE_TELEMETRY_TAG = 'remote_reasoning_engine';
const AGENT_ENGINE_TELEMETRY_ENV_VARIABLE_NAME = 'GOOGLE_CLOUD_AGENT_ENGINE_ID';

// TODO: b/468053794 - Configurable client labels in AsyncLocalStorage and/or
// browser equivalent
const clientLabelLocalStorage = new AsyncLocalStorage<string>();

export function parseUserAgent(userAgent: string): string {
if (!userAgent) {
return 'Browser';
}

for (const [name, regex] of [
['Edge', /(?:Edg|Edge|EdgA)\/([0-9.]+)/i],
['Firefox', /(?:Firefox|FxiOS)\/([0-9.]+)/i],
['Chrome', /(?:Chrome|CriOS)\/([0-9.]+)/i],
['Safari', /Version\/([0-9.]+).*Safari/i],
] as const) {
const match = userAgent.match(regex);
if (match) {
return `${name}/${match[1]}`;
}
}

return 'Browser';
}

function _getDefaultLabels(): string[] {
let frameworkLabel = `${ADK_LABEL}/${version}`;
Expand All @@ -23,19 +42,34 @@ function _getDefaultLabels(): string[] {
frameworkLabel = `${frameworkLabel}+${AGENT_ENGINE_TELEMETRY_TAG}`;
}

// TODO: b/468051563 - Consider extracting browser name and version from
// userAgent string
// eslint-disable-next-line no-undef
const languageLabel = `${LANGUAGE_LABEL}/${isBrowser() ? window.navigator.userAgent : process.version}`;
const languageLabelDetail = isBrowser()
? // eslint-disable-next-line no-undef
parseUserAgent(window.navigator.userAgent)
: process.version;

const languageLabel = `${LANGUAGE_LABEL}/${languageLabelDetail}`;
return [frameworkLabel, languageLabel];
}

export function runWithClientLabel<R>(
clientLabel: string,
callback: () => R,
): R {
if (typeof clientLabel !== 'string' || clientLabel.trim() === '') {
throw new Error('Client label must be a non-empty string.');
}

return clientLabelLocalStorage.run(clientLabel, callback);
}

/**
* Returns the current list of client labels that can be added to HTTP Headers.
*/
export function getClientLabels(): string[] {
const labels = _getDefaultLabels();
// TODO: b/468053794 - Configurable client labels in AsyncLocalStorage and/or
// browser equivalent
const contextLabel = clientLabelLocalStorage.getStore();
if (contextLabel) {
labels.push(contextLabel);
}
return labels;
}
11 changes: 11 additions & 0 deletions core/test/models/base_llm_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
LlmRequest,
LlmResponse,
isBaseLlm,
runWithClientLabel,
version,
} from '@google/adk';
import {describe, expect, it} from 'vitest';
Expand Down Expand Up @@ -66,6 +67,16 @@ describe('BaseLlm', () => {
expect(headers['x-goog-api-client']).toEqual(expectedValue);
expect(headers['user-agent']).toEqual(expectedValue);
});

it('should include context client label in tracking headers when run within runWithClientLabel', () => {
const llm = new TestLlm();
const customLabel = 'my-custom-label';
runWithClientLabel(customLabel, () => {
const headers = llm.getTrackingHeaders();
expect(headers['x-goog-api-client']).toContain(customLabel);
expect(headers['user-agent']).toContain(customLabel);
});
});
});

describe('isBaseLlm', () => {
Expand Down
92 changes: 90 additions & 2 deletions core/test/utils/client_labels_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,54 @@
* SPDX-License-Identifier: Apache-2.0
*/

import {getClientLabels, runWithClientLabel} from '@google/adk';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {getClientLabels} from '../../src/utils/client_labels.js';
import {parseUserAgent} from '../../src/utils/client_labels.js';

describe('client_labels', () => {
describe('parseUserAgent', () => {
it('should parse Chrome UA', () => {
const ua =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36';
expect(parseUserAgent(ua)).toBe('Chrome/123.0.0.0');
});

it('should parse Chrome iOS UA', () => {
const ua =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/123.0.6312.52 Mobile/15E148 Safari/604.1';
expect(parseUserAgent(ua)).toBe('Chrome/123.0.6312.52');
});

it('should parse Firefox UA', () => {
const ua =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0';
expect(parseUserAgent(ua)).toBe('Firefox/123.0');
});

it('should parse Firefox iOS UA', () => {
const ua =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/123.0 Mobile/15E148 Safari/605.1.15';
expect(parseUserAgent(ua)).toBe('Firefox/123.0');
});

it('should parse Edge UA', () => {
const ua =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0';
expect(parseUserAgent(ua)).toBe('Edge/123.0.0.0');
});

it('should parse Safari UA', () => {
const ua =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Safari/605.1.15';
expect(parseUserAgent(ua)).toBe('Safari/17.3');
});

it('should fallback to Browser for unknown UA', () => {
expect(parseUserAgent('Unknown UA')).toBe('Browser');
expect(parseUserAgent('')).toBe('Browser');
});
});

describe('getClientLabels', () => {
const originalEnv = process.env;

Expand Down Expand Up @@ -52,9 +96,53 @@ describe('client_labels', () => {
expect(adkLabel).not.toContain('remote_reasoning_engine');
});

it('should return exactly two labels in Node.js environment', () => {
it('should return exactly two labels in Node.js environment by default', () => {
const labels = getClientLabels();
expect(labels).toHaveLength(2);
});
});

describe('runWithClientLabel', () => {
it('should append custom label in context', () => {
const customLabel = 'my-custom-label';
runWithClientLabel(customLabel, () => {
const labels = getClientLabels();
expect(labels).toContain(customLabel);
expect(labels).toHaveLength(3);
});
});

it('should clean up custom label after callback', () => {
const customLabel = 'my-custom-label';
runWithClientLabel(customLabel, () => {
// inside
});
const labels = getClientLabels();
expect(labels).not.toContain(customLabel);
expect(labels).toHaveLength(2);
});

it('should propagate label across async hops', async () => {
const customLabel = 'async-label';
await runWithClientLabel(customLabel, async () => {
expect(getClientLabels()).toContain(customLabel);

await new Promise((resolve) => setTimeout(resolve, 10));
expect(getClientLabels()).toContain(customLabel);

await Promise.resolve();
expect(getClientLabels()).toContain(customLabel);
});
});

it('should throw error for empty label', () => {
expect(() => {
runWithClientLabel('', () => {});
}).toThrow('Client label must be a non-empty string.');

expect(() => {
runWithClientLabel(' ', () => {});
}).toThrow('Client label must be a non-empty string.');
});
});
});
1 change: 0 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading