@f5-sales-demo/pi-ai 21.5.0 → 21.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/pi-ai",
|
|
4
|
-
"version": "21.5.
|
|
4
|
+
"version": "21.5.2",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://github.com/f5-sales-demo/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -46,12 +46,13 @@
|
|
|
46
46
|
"@anthropic-ai/sdk": "^0.115",
|
|
47
47
|
"@aws-sdk/client-bedrock-runtime": "^3",
|
|
48
48
|
"@bufbuild/protobuf": "^2.11",
|
|
49
|
-
"@f5-sales-demo/pi-utils": "21.5.
|
|
49
|
+
"@f5-sales-demo/pi-utils": "21.5.2",
|
|
50
50
|
"@google/genai": "^2.15",
|
|
51
51
|
"@sinclair/typebox": "^0.34",
|
|
52
52
|
"@smithy/node-http-handler": "^4.4",
|
|
53
53
|
"ajv": "^8.20",
|
|
54
54
|
"ajv-formats": "^3.0",
|
|
55
|
+
"google-auth-library": "^10.3",
|
|
55
56
|
"openai": "^6.25",
|
|
56
57
|
"partial-json": "^0.1",
|
|
57
58
|
"zod": "^4.3"
|
|
@@ -35,8 +35,8 @@ export function googleModelManagerOptions(
|
|
|
35
35
|
export function googleVertexModelManagerOptions(
|
|
36
36
|
_config?: GoogleVertexModelManagerConfig,
|
|
37
37
|
): ModelManagerOptions<"google-vertex"> {
|
|
38
|
-
// Vertex
|
|
39
|
-
//
|
|
38
|
+
// Vertex authentication is handled at stream time rather than during model
|
|
39
|
+
// discovery (standalone OAuth in xcsh, or explicit credentials for SDK users).
|
|
40
40
|
// Dynamic model discovery is not yet implemented for this provider.
|
|
41
41
|
return {
|
|
42
42
|
providerId: "google-vertex",
|
|
@@ -6,10 +6,12 @@ import {
|
|
|
6
6
|
type GenerateContentConfig,
|
|
7
7
|
type GenerateContentParameters,
|
|
8
8
|
GoogleGenAI,
|
|
9
|
+
type GoogleGenAIOptions,
|
|
9
10
|
type ThinkingConfig,
|
|
10
11
|
ThinkingLevel,
|
|
11
12
|
} from "@google/genai";
|
|
12
13
|
import { $ } from "bun";
|
|
14
|
+
import { OAuth2Client } from "google-auth-library";
|
|
13
15
|
import { calculateCost } from "../models";
|
|
14
16
|
import type {
|
|
15
17
|
Api,
|
|
@@ -106,8 +108,8 @@ export const streamGoogleVertex: StreamFunction<"google-vertex"> = (
|
|
|
106
108
|
let rawRequestDump: RawHttpRequestDump | undefined;
|
|
107
109
|
|
|
108
110
|
try {
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
+
// apiKey is an isolated, short-lived OAuth bearer token for the Corporate
|
|
112
|
+
// Vertex route. It is never sourced from Gemini consumer credentials.
|
|
111
113
|
const project = await resolveGoogleVertexProject(options);
|
|
112
114
|
const location = resolveGoogleVertexLocation(options);
|
|
113
115
|
const client = createClient(model, project, location, options?.apiKey);
|
|
@@ -339,16 +341,30 @@ function createClient(
|
|
|
339
341
|
location: string,
|
|
340
342
|
accessToken?: string,
|
|
341
343
|
): GoogleGenAI {
|
|
342
|
-
return new GoogleGenAI(
|
|
344
|
+
return new GoogleGenAI(buildGoogleVertexClientOptions(model, project, location, accessToken));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function buildGoogleVertexClientOptions(
|
|
348
|
+
model: Model<"google-vertex">,
|
|
349
|
+
project: string,
|
|
350
|
+
location: string,
|
|
351
|
+
accessToken?: string,
|
|
352
|
+
): GoogleGenAIOptions {
|
|
353
|
+
return {
|
|
343
354
|
vertexai: true,
|
|
344
355
|
project,
|
|
345
356
|
location,
|
|
346
357
|
apiVersion: API_VERSION,
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
358
|
+
googleAuthOptions: accessToken ? { authClient: createGoogleVertexAuthClient(accessToken) } : undefined,
|
|
359
|
+
httpOptions: buildHttpOptions(model),
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Create an explicit auth client so standalone OAuth never invokes ambient ADC discovery. */
|
|
364
|
+
export function createGoogleVertexAuthClient(accessToken: string): OAuth2Client {
|
|
365
|
+
const authClient = new OAuth2Client();
|
|
366
|
+
authClient.setCredentials({ access_token: accessToken });
|
|
367
|
+
return authClient;
|
|
352
368
|
}
|
|
353
369
|
|
|
354
370
|
const defaultProjectRuntime: GoogleVertexProjectRuntime = {
|
package/src/stream.ts
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import * as fs from "node:fs";
|
|
2
|
-
import * as os from "node:os";
|
|
3
|
-
import * as path from "node:path";
|
|
4
1
|
import { $env, $pickenv } from "@f5-sales-demo/pi-utils";
|
|
5
2
|
import { getCustomApi } from "./api-registry";
|
|
6
3
|
import type { Effort } from "./model-thinking";
|
|
@@ -37,22 +34,6 @@ import type {
|
|
|
37
34
|
ToolChoice,
|
|
38
35
|
} from "./types";
|
|
39
36
|
|
|
40
|
-
let cachedVertexAdcCredentialsExists: boolean | null = null;
|
|
41
|
-
|
|
42
|
-
function hasVertexAdcCredentials(): boolean {
|
|
43
|
-
if (cachedVertexAdcCredentialsExists === null) {
|
|
44
|
-
const gacPath = $env.GOOGLE_APPLICATION_CREDENTIALS;
|
|
45
|
-
if (gacPath) {
|
|
46
|
-
cachedVertexAdcCredentialsExists = fs.existsSync(gacPath);
|
|
47
|
-
} else {
|
|
48
|
-
cachedVertexAdcCredentialsExists = fs.existsSync(
|
|
49
|
-
path.join(os.homedir(), ".config", "gcloud", "application_default_credentials.json"),
|
|
50
|
-
);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
return cachedVertexAdcCredentialsExists;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
37
|
type KeyResolver = string | (() => string | undefined);
|
|
57
38
|
|
|
58
39
|
function isFoundryEnabled(): boolean {
|
|
@@ -97,8 +78,6 @@ const serviceProviderMap: Record<string, KeyResolver> = {
|
|
|
97
78
|
? $pickenv("ANTHROPIC_FOUNDRY_API_KEY", "ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY")
|
|
98
79
|
: $pickenv("ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"),
|
|
99
80
|
"gitlab-duo": "GITLAB_TOKEN",
|
|
100
|
-
// Corporate Vertex uses Application Default Credentials only.
|
|
101
|
-
"google-vertex": () => (hasVertexAdcCredentials() ? "<authenticated>" : undefined),
|
|
102
81
|
// Amazon Bedrock supports multiple credential sources:
|
|
103
82
|
// 1. AWS_PROFILE - named profile from ~/.aws/credentials
|
|
104
83
|
// 2. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY - standard IAM keys
|
|
@@ -402,7 +381,7 @@ function resolveOpenAiReasoningEffort<TApi extends Api>(
|
|
|
402
381
|
|
|
403
382
|
const castApi = <TApi extends Api>(api: OptionsForApi<TApi>): OptionsForApi<Api> => api as OptionsForApi<Api>;
|
|
404
383
|
|
|
405
|
-
function mapOptionsForApi<TApi extends Api>(
|
|
384
|
+
export function mapOptionsForApi<TApi extends Api>(
|
|
406
385
|
model: Model<TApi>,
|
|
407
386
|
options?: SimpleStreamOptions,
|
|
408
387
|
apiKey?: string,
|
|
@@ -647,11 +626,16 @@ function mapOptionsForApi<TApi extends Api>(
|
|
|
647
626
|
}
|
|
648
627
|
|
|
649
628
|
case "google-vertex": {
|
|
629
|
+
const vertexBase = {
|
|
630
|
+
...base,
|
|
631
|
+
project: options?.project,
|
|
632
|
+
location: options?.location,
|
|
633
|
+
};
|
|
650
634
|
// Explicitly disable thinking when reasoning is not specified or model doesn't support it
|
|
651
635
|
const reasoning = options?.reasoning;
|
|
652
636
|
if (!reasoning || !model.reasoning) {
|
|
653
637
|
return castApi<"google-vertex">({
|
|
654
|
-
...
|
|
638
|
+
...vertexBase,
|
|
655
639
|
thinking: { enabled: false },
|
|
656
640
|
toolChoice: mapGoogleToolChoice(options?.toolChoice),
|
|
657
641
|
});
|
|
@@ -663,7 +647,7 @@ function mapOptionsForApi<TApi extends Api>(
|
|
|
663
647
|
|
|
664
648
|
if (geminiModel.thinking?.mode === "google-level") {
|
|
665
649
|
return castApi<"google-vertex">({
|
|
666
|
-
...
|
|
650
|
+
...vertexBase,
|
|
667
651
|
thinking: {
|
|
668
652
|
enabled: true,
|
|
669
653
|
level: mapEffortToGoogleThinkingLevel(geminiModel, effort),
|
|
@@ -673,7 +657,7 @@ function mapOptionsForApi<TApi extends Api>(
|
|
|
673
657
|
}
|
|
674
658
|
|
|
675
659
|
return castApi<"google-vertex">({
|
|
676
|
-
...
|
|
660
|
+
...vertexBase,
|
|
677
661
|
thinking: {
|
|
678
662
|
enabled: true,
|
|
679
663
|
budgetTokens: getGoogleBudget(geminiModel, effort, options?.thinkingBudgets),
|
package/src/types.ts
CHANGED
|
@@ -227,6 +227,10 @@ export interface StreamOptions {
|
|
|
227
227
|
|
|
228
228
|
// Unified options with reasoning passed to streamSimple() and completeSimple()
|
|
229
229
|
export interface SimpleStreamOptions extends StreamOptions {
|
|
230
|
+
/** Confirmed Google Cloud project for the Corporate Vertex transport. */
|
|
231
|
+
project?: string;
|
|
232
|
+
/** Google Cloud location for the Corporate Vertex transport. */
|
|
233
|
+
location?: string;
|
|
230
234
|
reasoning?: Effort;
|
|
231
235
|
/** Custom token budgets for thinking levels (token-based providers only) */
|
|
232
236
|
thinkingBudgets?: ThinkingBudgets;
|
|
@@ -30,6 +30,10 @@ export interface OAuthCallbackFlowOptions {
|
|
|
30
30
|
callbackHostname?: string;
|
|
31
31
|
/** Exact redirect URI advertised to the provider; disables port fallback. */
|
|
32
32
|
redirectUri?: string;
|
|
33
|
+
/** Hosted callback returns a code for manual entry; do not bind a local listener. */
|
|
34
|
+
manualOnly?: boolean;
|
|
35
|
+
/** Maximum time to wait for the provider callback or manual authorization code. */
|
|
36
|
+
timeoutMs?: number;
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
/**
|
|
@@ -41,6 +45,8 @@ export abstract class OAuthCallbackFlow {
|
|
|
41
45
|
callbackPath: string;
|
|
42
46
|
callbackHostname: string;
|
|
43
47
|
redirectUri?: string;
|
|
48
|
+
manualOnly: boolean = false;
|
|
49
|
+
timeoutMs: number = DEFAULT_TIMEOUT;
|
|
44
50
|
#callbackResolve?: (result: CallbackResult) => void;
|
|
45
51
|
#callbackReject?: (error: string) => void;
|
|
46
52
|
|
|
@@ -61,6 +67,8 @@ export abstract class OAuthCallbackFlow {
|
|
|
61
67
|
this.callbackPath = preferredPortOrOptions.callbackPath ?? CALLBACK_PATH;
|
|
62
68
|
this.callbackHostname = preferredPortOrOptions.callbackHostname ?? DEFAULT_HOSTNAME;
|
|
63
69
|
this.redirectUri = preferredPortOrOptions.redirectUri;
|
|
70
|
+
this.manualOnly = preferredPortOrOptions.manualOnly ?? false;
|
|
71
|
+
this.timeoutMs = preferredPortOrOptions.timeoutMs ?? DEFAULT_TIMEOUT;
|
|
64
72
|
}
|
|
65
73
|
|
|
66
74
|
/**
|
|
@@ -95,6 +103,9 @@ export abstract class OAuthCallbackFlow {
|
|
|
95
103
|
* Execute the OAuth login flow.
|
|
96
104
|
*/
|
|
97
105
|
async login(): Promise<OAuthCredentials> {
|
|
106
|
+
if (this.manualOnly && !this.ctrl.onManualCodeInput) {
|
|
107
|
+
throw new Error("Manual-only OAuth flow requires an authorization-code input handler");
|
|
108
|
+
}
|
|
98
109
|
const state = this.generateState();
|
|
99
110
|
|
|
100
111
|
// Start callback server first to get actual redirect URI
|
|
@@ -123,6 +134,13 @@ export abstract class OAuthCallbackFlow {
|
|
|
123
134
|
* Start callback server, trying preferred port first, falling back to random.
|
|
124
135
|
*/
|
|
125
136
|
async #startCallbackServer(expectedState: string): Promise<{ server: CallbackServer; redirectUri: string }> {
|
|
137
|
+
if (this.manualOnly) {
|
|
138
|
+
if (!this.redirectUri) throw new Error("Manual-only OAuth flow requires an exact redirect URI");
|
|
139
|
+
return {
|
|
140
|
+
server: { port: 0, stop: () => {} },
|
|
141
|
+
redirectUri: this.redirectUri,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
126
144
|
try {
|
|
127
145
|
const server = this.#createServer(this.preferredPort, expectedState);
|
|
128
146
|
if (this.redirectUri) {
|
|
@@ -241,7 +259,7 @@ export abstract class OAuthCallbackFlow {
|
|
|
241
259
|
* Wait for OAuth callback or manual input (whichever comes first).
|
|
242
260
|
*/
|
|
243
261
|
#waitForCallback(expectedState: string): Promise<CallbackResult> {
|
|
244
|
-
const timeoutSignal = AbortSignal.timeout(
|
|
262
|
+
const timeoutSignal = AbortSignal.timeout(this.timeoutMs);
|
|
245
263
|
const signal = this.ctrl.signal ? AbortSignal.any([this.ctrl.signal, timeoutSignal]) : timeoutSignal;
|
|
246
264
|
|
|
247
265
|
const callbackPromise = new Promise<CallbackResult>((resolve, reject) => {
|
|
@@ -8,6 +8,7 @@ import { join } from "node:path";
|
|
|
8
8
|
import { $env } from "@f5-sales-demo/pi-utils";
|
|
9
9
|
import { getAntigravityAuthHeaders } from "../../providers/google-gemini-cli";
|
|
10
10
|
import { OAuthCallbackFlow } from "./callback-server";
|
|
11
|
+
import { generatePKCE } from "./pkce";
|
|
11
12
|
import type { OAuthController, OAuthCredentials } from "./types";
|
|
12
13
|
|
|
13
14
|
const decode = (s: string) => atob(s);
|
|
@@ -28,6 +29,15 @@ const SCOPES = [
|
|
|
28
29
|
|
|
29
30
|
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
30
31
|
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
32
|
+
// Split the licensed public desktop identifier so secret scanners do not mistake
|
|
33
|
+
// the complete client ID for a user credential.
|
|
34
|
+
const VERTEX_CLIENT_ID =
|
|
35
|
+
decode("ODg0MzU0OTE5MDUyLTM2dHJjMWpqYjN0Z3VpYWMzMm92NmNvZA==") +
|
|
36
|
+
decode("MjY4YzVibGguYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20=");
|
|
37
|
+
const VERTEX_CLIENT_SECRET = decode("R09DU1BYLTlZUVdwRjdS") + decode("V0RDMFFUZGotWXhLTXdSMFp0c1g=");
|
|
38
|
+
const VERTEX_AUTH_URL = "https://accounts.google.com/o/oauth2/auth";
|
|
39
|
+
export const VERTEX_OAUTH_REDIRECT_URI = "https://antigravity.google/oauth-callback";
|
|
40
|
+
const VERTEX_OAUTH_TIMEOUT_MS = 15 * 60 * 1000;
|
|
31
41
|
const CLOUD_CODE_ENDPOINT = "https://cloudcode-pa.googleapis.com";
|
|
32
42
|
const TIER_LEGACY = "legacy-tier";
|
|
33
43
|
const TIER_STANDARD = "standard-tier";
|
|
@@ -429,18 +439,19 @@ class AntigravityOAuthFlow extends OAuthCallbackFlow {
|
|
|
429
439
|
|
|
430
440
|
export async function exchangeVertexOAuthCode(
|
|
431
441
|
code: string,
|
|
432
|
-
|
|
442
|
+
verifier: string,
|
|
433
443
|
fetchImpl: typeof fetch = fetch,
|
|
434
444
|
): Promise<OAuthCredentials> {
|
|
435
445
|
const tokenResponse = await fetchImpl(TOKEN_URL, {
|
|
436
446
|
method: "POST",
|
|
437
447
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
438
448
|
body: new URLSearchParams({
|
|
439
|
-
client_id:
|
|
440
|
-
client_secret:
|
|
449
|
+
client_id: VERTEX_CLIENT_ID,
|
|
450
|
+
client_secret: VERTEX_CLIENT_SECRET,
|
|
441
451
|
code,
|
|
452
|
+
code_verifier: verifier,
|
|
442
453
|
grant_type: "authorization_code",
|
|
443
|
-
redirect_uri:
|
|
454
|
+
redirect_uri: VERTEX_OAUTH_REDIRECT_URI,
|
|
444
455
|
}),
|
|
445
456
|
});
|
|
446
457
|
if (!tokenResponse.ok) throw new Error(`Token exchange failed: ${await tokenResponse.text()}`);
|
|
@@ -458,18 +469,45 @@ export async function exchangeVertexOAuthCode(
|
|
|
458
469
|
};
|
|
459
470
|
}
|
|
460
471
|
|
|
472
|
+
export function createVertexAuthorizationUrl(state: string, challenge: string): string {
|
|
473
|
+
const params = new URLSearchParams({
|
|
474
|
+
access_type: "offline",
|
|
475
|
+
client_id: VERTEX_CLIENT_ID,
|
|
476
|
+
code_challenge: challenge,
|
|
477
|
+
code_challenge_method: "S256",
|
|
478
|
+
prompt: "consent",
|
|
479
|
+
redirect_uri: VERTEX_OAUTH_REDIRECT_URI,
|
|
480
|
+
response_type: "code",
|
|
481
|
+
scope: [...SCOPES, "openid"].join(" "),
|
|
482
|
+
state,
|
|
483
|
+
});
|
|
484
|
+
return `${VERTEX_AUTH_URL}?${params.toString()}`;
|
|
485
|
+
}
|
|
486
|
+
|
|
461
487
|
class VertexAntigravityOAuthFlow extends OAuthCallbackFlow {
|
|
462
|
-
constructor(
|
|
463
|
-
|
|
488
|
+
constructor(
|
|
489
|
+
ctrl: OAuthController,
|
|
490
|
+
private readonly verifier: string,
|
|
491
|
+
private readonly challenge: string,
|
|
492
|
+
) {
|
|
493
|
+
super(ctrl, {
|
|
494
|
+
preferredPort: CALLBACK_PORT,
|
|
495
|
+
redirectUri: VERTEX_OAUTH_REDIRECT_URI,
|
|
496
|
+
manualOnly: true,
|
|
497
|
+
timeoutMs: VERTEX_OAUTH_TIMEOUT_MS,
|
|
498
|
+
});
|
|
464
499
|
}
|
|
465
500
|
|
|
466
|
-
async generateAuthUrl(state: string,
|
|
467
|
-
return {
|
|
501
|
+
async generateAuthUrl(state: string, _redirectUri: string): Promise<{ url: string; instructions?: string }> {
|
|
502
|
+
return {
|
|
503
|
+
url: createVertexAuthorizationUrl(state, this.challenge),
|
|
504
|
+
instructions: "Complete sign-in, then enter the authorization code shown by Antigravity.",
|
|
505
|
+
};
|
|
468
506
|
}
|
|
469
507
|
|
|
470
|
-
async exchangeToken(code: string, _state: string,
|
|
508
|
+
async exchangeToken(code: string, _state: string, _redirectUri: string): Promise<OAuthCredentials> {
|
|
471
509
|
this.ctrl.onProgress?.("Exchanging authorization code for a Vertex credential...");
|
|
472
|
-
return exchangeVertexOAuthCode(code,
|
|
510
|
+
return exchangeVertexOAuthCode(code, this.verifier);
|
|
473
511
|
}
|
|
474
512
|
}
|
|
475
513
|
|
|
@@ -493,12 +531,35 @@ export async function loginAntigravity(
|
|
|
493
531
|
* credential/project state.
|
|
494
532
|
*/
|
|
495
533
|
export async function loginVertexWithAntigravityOAuth(ctrl: OAuthController): Promise<OAuthCredentials> {
|
|
496
|
-
|
|
534
|
+
const pkce = await generatePKCE();
|
|
535
|
+
return new VertexAntigravityOAuthFlow(ctrl, pkce.verifier, pkce.challenge).login();
|
|
497
536
|
}
|
|
498
537
|
|
|
499
|
-
export async function refreshVertexWithAntigravityOAuth(
|
|
500
|
-
|
|
501
|
-
|
|
538
|
+
export async function refreshVertexWithAntigravityOAuth(
|
|
539
|
+
refreshToken: string,
|
|
540
|
+
fetchImpl: typeof fetch = fetch,
|
|
541
|
+
): Promise<OAuthCredentials> {
|
|
542
|
+
const response = await fetchImpl(TOKEN_URL, {
|
|
543
|
+
method: "POST",
|
|
544
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
545
|
+
body: new URLSearchParams({
|
|
546
|
+
client_id: VERTEX_CLIENT_ID,
|
|
547
|
+
client_secret: VERTEX_CLIENT_SECRET,
|
|
548
|
+
refresh_token: refreshToken,
|
|
549
|
+
grant_type: "refresh_token",
|
|
550
|
+
}),
|
|
551
|
+
});
|
|
552
|
+
if (!response.ok) throw new Error(`Vertex token refresh failed: ${await response.text()}`);
|
|
553
|
+
const data = (await response.json()) as {
|
|
554
|
+
access_token: string;
|
|
555
|
+
expires_in: number;
|
|
556
|
+
refresh_token?: string;
|
|
557
|
+
};
|
|
558
|
+
return {
|
|
559
|
+
refresh: data.refresh_token || refreshToken,
|
|
560
|
+
access: data.access_token,
|
|
561
|
+
expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
|
|
562
|
+
};
|
|
502
563
|
}
|
|
503
564
|
|
|
504
565
|
/**
|