From 1787d3be0793dd2a71a6f7818fcdc451d25eae20 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 21:45:33 +0100 Subject: [PATCH] fix(gateway): scope startup provider discovery --- CHANGELOG.md | 1 + ...els-config.applies-config-env-vars.test.ts | 28 +++++ src/agents/models-config.plan.ts | 18 ++++ ...providers.implicit.discovery-scope.test.ts | 101 ++++++++++++++++++ .../models-config.providers.implicit.ts | 63 +++++++++-- ...odels-config.providers.live-filter.test.ts | 35 ++++++ src/agents/models-config.ts | 18 ++++ .../models-config.write-serialization.test.ts | 23 ++++ src/gateway/server-startup-post-attach.ts | 20 ++-- src/gateway/server-startup.test.ts | 28 ++++- 10 files changed, 314 insertions(+), 21 deletions(-) create mode 100644 src/agents/models-config.providers.implicit.discovery-scope.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 31d34fb8a6e..c767c359d06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Providers/Cloudflare AI Gateway: strip assistant prefill turns from Anthropic Messages payloads when thinking is enabled, so Claude requests through Cloudflare AI Gateway no longer fail Anthropic conversation-ending validation. Fixes #72905; carries forward #73005. Thanks @AaronFaby and @sahilsatralkar. +- Gateway/startup: scope primary-model provider discovery during channel prewarm to the configured provider owner and add split startup trace timings, so boot avoids staging unrelated bundled provider dependencies while setup discovery remains broad. Fixes #73002. Thanks @Schnup03. - Channels/sessions: prevent guarded inbound session recording from creating route-only phantom sessions while still allowing last-route updates for sessions that already exist. Carries forward #73009. Thanks @jzakirov. - Cron: accept `delivery.threadId` in Gateway cron add/update schemas so scheduled announce delivery can target Telegram forum topics and other threaded channel destinations through the documented delivery path. Fixes #73017. Thanks @coachsootz. - Plugins/runtime deps: stage bundled plugin dependencies imported by mirrored root dist chunks, so packaged memory and status commands do not miss `chokidar` or similar root-chunk dependencies after update. Fixes #72882 and #72970; carries forward #72992. Thanks @shrimpy8, @colin-chang, and @Schnup03. diff --git a/src/agents/models-config.applies-config-env-vars.test.ts b/src/agents/models-config.applies-config-env-vars.test.ts index 789e3c7520b..544cf8d4688 100644 --- a/src/agents/models-config.applies-config-env-vars.test.ts +++ b/src/agents/models-config.applies-config-env-vars.test.ts @@ -125,6 +125,34 @@ describe("models-config", () => { expect(observedWorkspaceDir).toBe("/tmp/openclaw-workspace"); }); + it("threads startup provider discovery scope into implicit provider discovery", async () => { + let observedProviderIds: readonly string[] | undefined; + let observedTimeoutMs: number | undefined; + + await resolveProvidersForModelsJsonWithDeps( + { + cfg: { models: { providers: {} } }, + agentDir: "/tmp/openclaw-models-config-env-vars-test", + env: {}, + providerDiscoveryProviderIds: ["openai"], + providerDiscoveryTimeoutMs: 5000, + }, + { + resolveImplicitProviders: async ({ + providerDiscoveryProviderIds, + providerDiscoveryTimeoutMs, + }) => { + observedProviderIds = providerDiscoveryProviderIds; + observedTimeoutMs = providerDiscoveryTimeoutMs; + return {}; + }, + }, + ); + + expect(observedProviderIds).toEqual(["openai"]); + expect(observedTimeoutMs).toBe(5000); + }); + it("threads plugin metadata snapshots through models.json planning", async () => { const pluginMetadataSnapshot = { index: { plugins: [] }, diff --git a/src/agents/models-config.plan.ts b/src/agents/models-config.plan.ts index 8e955e7795e..9218692699d 100644 --- a/src/agents/models-config.plan.ts +++ b/src/agents/models-config.plan.ts @@ -22,6 +22,8 @@ export type ResolveImplicitProvidersForModelsJson = (params: { workspaceDir?: string; explicitProviders: Record; pluginMetadataSnapshot?: Pick; + providerDiscoveryProviderIds?: readonly string[]; + providerDiscoveryTimeoutMs?: number; }) => Promise>; export type ModelsJsonPlan = @@ -43,6 +45,8 @@ export async function resolveProvidersForModelsJsonWithDeps( env: NodeJS.ProcessEnv; workspaceDir?: string; pluginMetadataSnapshot?: Pick; + providerDiscoveryProviderIds?: readonly string[]; + providerDiscoveryTimeoutMs?: number; }, deps?: { resolveImplicitProviders?: ResolveImplicitProvidersForModelsJson; @@ -60,6 +64,12 @@ export async function resolveProvidersForModelsJsonWithDeps( ...(params.pluginMetadataSnapshot ? { pluginMetadataSnapshot: params.pluginMetadataSnapshot } : {}), + ...(params.providerDiscoveryProviderIds + ? { providerDiscoveryProviderIds: params.providerDiscoveryProviderIds } + : {}), + ...(params.providerDiscoveryTimeoutMs !== undefined + ? { providerDiscoveryTimeoutMs: params.providerDiscoveryTimeoutMs } + : {}), }); return mergeProviders({ implicit: implicitProviders, @@ -101,6 +111,8 @@ export async function planOpenClawModelsJsonWithDeps( existingRaw: string; existingParsed: unknown; pluginMetadataSnapshot?: Pick; + providerDiscoveryProviderIds?: readonly string[]; + providerDiscoveryTimeoutMs?: number; }, deps?: { resolveImplicitProviders?: ResolveImplicitProvidersForModelsJson; @@ -116,6 +128,12 @@ export async function planOpenClawModelsJsonWithDeps( ...(params.pluginMetadataSnapshot ? { pluginMetadataSnapshot: params.pluginMetadataSnapshot } : {}), + ...(params.providerDiscoveryProviderIds + ? { providerDiscoveryProviderIds: params.providerDiscoveryProviderIds } + : {}), + ...(params.providerDiscoveryTimeoutMs !== undefined + ? { providerDiscoveryTimeoutMs: params.providerDiscoveryTimeoutMs } + : {}), }, deps, ); diff --git a/src/agents/models-config.providers.implicit.discovery-scope.test.ts b/src/agents/models-config.providers.implicit.discovery-scope.test.ts new file mode 100644 index 00000000000..47c16ce0cde --- /dev/null +++ b/src/agents/models-config.providers.implicit.discovery-scope.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PluginMetadataSnapshotOwnerMaps } from "../plugins/plugin-metadata-snapshot.js"; +import type { ProviderPlugin } from "../plugins/types.js"; + +const mocks = vi.hoisted(() => ({ + resolveRuntimePluginDiscoveryProviders: vi.fn(), + runProviderCatalog: vi.fn(), +})); + +vi.mock("../plugins/provider-discovery.js", () => ({ + resolveRuntimePluginDiscoveryProviders: mocks.resolveRuntimePluginDiscoveryProviders, + runProviderCatalog: mocks.runProviderCatalog, + groupPluginDiscoveryProvidersByOrder: (providers: ProviderPlugin[]) => ({ + simple: providers, + profile: [], + paired: [], + late: [], + }), + normalizePluginDiscoveryResult: ({ + provider, + result, + }: { + provider: ProviderPlugin; + result?: { provider?: unknown; providers?: Record } | null; + }) => result?.providers ?? (result?.provider ? { [provider.id]: result.provider } : {}), +})); + +import { resolveImplicitProviders } from "./models-config.providers.implicit.js"; + +function metadataOwners( + overrides: Partial, +): PluginMetadataSnapshotOwnerMaps { + return { + channels: new Map(), + channelConfigs: new Map(), + providers: new Map(), + modelCatalogProviders: new Map(), + cliBackends: new Map(), + setupProviders: new Map(), + commandAliases: new Map(), + contracts: new Map(), + ...overrides, + }; +} + +function createProvider(id: string): ProviderPlugin { + return { + id, + label: id, + auth: [], + catalog: { + order: "simple", + run: async () => null, + }, + }; +} + +describe("resolveImplicitProviders startup discovery scope", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.resolveRuntimePluginDiscoveryProviders.mockResolvedValue([createProvider("openai")]); + mocks.runProviderCatalog.mockResolvedValue({ + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + api: "openai-responses", + models: [], + }, + }, + }); + }); + + it("passes startup provider scopes as plugin owner filters", async () => { + await resolveImplicitProviders({ + agentDir: "/tmp/openclaw-agent", + config: {}, + env: {} as NodeJS.ProcessEnv, + explicitProviders: {}, + pluginMetadataSnapshot: { + index: { plugins: [] } as never, + manifestRegistry: { plugins: [], diagnostics: [] }, + owners: metadataOwners({ + providers: new Map([["openai", ["openai"]]]), + }), + }, + providerDiscoveryProviderIds: ["openai"], + providerDiscoveryTimeoutMs: 1234, + }); + + expect(mocks.resolveRuntimePluginDiscoveryProviders).toHaveBeenCalledWith( + expect.objectContaining({ + onlyPluginIds: ["openai"], + }), + ); + expect(mocks.runProviderCatalog).toHaveBeenCalledWith( + expect.objectContaining({ + timeoutMs: 1234, + }), + ); + }); +}); diff --git a/src/agents/models-config.providers.implicit.ts b/src/agents/models-config.providers.implicit.ts index e6426737887..9ea7c5ee1a6 100644 --- a/src/agents/models-config.providers.implicit.ts +++ b/src/agents/models-config.providers.implicit.ts @@ -45,6 +45,8 @@ type ImplicitProviderParams = { workspaceDir?: string; explicitProviders?: Record | null; pluginMetadataSnapshot?: Pick; + providerDiscoveryProviderIds?: readonly string[]; + providerDiscoveryTimeoutMs?: number; }; type ImplicitProviderContext = ImplicitProviderParams & { @@ -73,6 +75,7 @@ function resolveProviderDiscoveryFilter(params: { workspaceDir?: string; env: NodeJS.ProcessEnv; resolveOwners?: (provider: string) => readonly string[] | undefined; + providerIds?: readonly string[]; }): string[] | undefined { const { config, workspaceDir, env } = params; const testRaw = env.OPENCLAW_TEST_ONLY_PROVIDER_PLUGIN_IDS?.trim(); @@ -83,6 +86,18 @@ function resolveProviderDiscoveryFilter(params: { .filter(Boolean); return ids.length > 0 ? [...new Set(ids)] : undefined; } + const scopedProviderIds = params.providerIds + ?.map((value) => value.trim()) + .filter((value) => value.length > 0); + if (scopedProviderIds) { + return resolveProviderPluginScopeFromProviderIds({ + providerIds: scopedProviderIds, + config, + workspaceDir, + env, + resolveOwners: params.resolveOwners, + }); + } const live = env.OPENCLAW_LIVE_TEST === "1" || env.OPENCLAW_LIVE_GATEWAY === "1" || env.LIVE === "1"; if (!live) { @@ -102,15 +117,31 @@ function resolveProviderDiscoveryFilter(params: { if (ids.length === 0) { return undefined; } + return resolveProviderPluginScopeFromProviderIds({ + providerIds: ids, + config, + workspaceDir, + env, + resolveOwners: params.resolveOwners, + }); +} + +function resolveProviderPluginScopeFromProviderIds(params: { + providerIds: readonly string[]; + config?: OpenClawConfig; + workspaceDir?: string; + env: NodeJS.ProcessEnv; + resolveOwners?: (provider: string) => readonly string[] | undefined; +}): string[] { const pluginIds = new Set(); - for (const id of ids) { + for (const id of params.providerIds) { const owners = params.resolveOwners?.(id) ?? resolveOwningPluginIdsForProvider({ provider: id, - config, - workspaceDir, - env, + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, }) ?? []; if (owners.length > 0) { @@ -121,9 +152,7 @@ function resolveProviderDiscoveryFilter(params: { } pluginIds.add(id); } - return pluginIds.size > 0 - ? [...pluginIds].toSorted((left, right) => left.localeCompare(right)) - : undefined; + return [...pluginIds].toSorted((left, right) => left.localeCompare(right)); } function resolvePluginMetadataProviderOwners( @@ -140,13 +169,25 @@ function resolvePluginMetadataProviderOwners( const owners = new Set(); appendNormalizedPluginMetadataOwners( owners, - pluginMetadataSnapshot.owners.providers, + pluginMetadataSnapshot.owners.providers ?? new Map(), provider, normalizedProvider, ); appendNormalizedPluginMetadataOwners( owners, - pluginMetadataSnapshot.owners.cliBackends, + pluginMetadataSnapshot.owners.modelCatalogProviders ?? new Map(), + provider, + normalizedProvider, + ); + appendNormalizedPluginMetadataOwners( + owners, + pluginMetadataSnapshot.owners.setupProviders ?? new Map(), + provider, + normalizedProvider, + ); + appendNormalizedPluginMetadataOwners( + owners, + pluginMetadataSnapshot.owners.cliBackends ?? new Map(), provider, normalizedProvider, ); @@ -187,6 +228,7 @@ export function resolveProviderDiscoveryFilterForTest(params: { workspaceDir?: string; env: NodeJS.ProcessEnv; resolveOwners?: (provider: string) => readonly string[] | undefined; + providerIds?: readonly string[]; }): string[] | undefined { return resolveProviderDiscoveryFilter(params); } @@ -321,7 +363,7 @@ async function resolvePluginImplicitProviders( resolveProviderApiKey: resolveCatalogProviderApiKey, resolveProviderAuth: (providerId, options) => ctx.resolveProviderAuth(providerId?.trim() || provider.id, options), - timeoutMs: resolveLiveProviderCatalogTimeoutMs(ctx.env), + timeoutMs: ctx.providerDiscoveryTimeoutMs ?? resolveLiveProviderCatalogTimeoutMs(ctx.env), }); if (!result) { continue; @@ -435,6 +477,7 @@ export async function resolveImplicitProviders( resolveOwners: params.pluginMetadataSnapshot ? (provider) => resolvePluginMetadataProviderOwners(params.pluginMetadataSnapshot, provider) : undefined, + providerIds: params.providerDiscoveryProviderIds, }), ...(params.pluginMetadataSnapshot ? { pluginMetadataSnapshot: params.pluginMetadataSnapshot } diff --git a/src/agents/models-config.providers.live-filter.test.ts b/src/agents/models-config.providers.live-filter.test.ts index dca331b083e..debede39cae 100644 --- a/src/agents/models-config.providers.live-filter.test.ts +++ b/src/agents/models-config.providers.live-filter.test.ts @@ -132,4 +132,39 @@ describe("resolveProviderDiscoveryFilterForTest", () => { }), ).toEqual(["volcengine"]); }); + + it("scopes normal startup discovery to requested provider owners", () => { + const snapshot = { + owners: metadataOwners({ + providers: new Map([ + ["openai", ["openai"]], + ["anthropic", ["anthropic"]], + ]), + }), + }; + + expect( + resolveProviderDiscoveryFilterForTest({ + env: liveFilterEnv({}), + providerIds: ["openai"], + resolveOwners: (provider) => resolvePluginMetadataProviderOwnersForTest(snapshot, provider), + }), + ).toEqual(["openai"]); + }); + + it("maps scoped startup provider aliases through model catalog owners", () => { + const snapshot = { + owners: metadataOwners({ + modelCatalogProviders: new Map([["openai-codex", ["codex"]]]), + }), + }; + + expect( + resolveProviderDiscoveryFilterForTest({ + env: liveFilterEnv({}), + providerIds: ["OpenAI-Codex"], + resolveOwners: (provider) => resolvePluginMetadataProviderOwnersForTest(snapshot, provider), + }), + ).toEqual(["codex"]); + }); }); diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index 675da2de4e3..569c5808a44 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -47,6 +47,8 @@ async function buildModelsJsonFingerprint(params: { agentDir: string; workspaceDir?: string; pluginMetadataSnapshot?: Pick; + providerDiscoveryProviderIds?: readonly string[]; + providerDiscoveryTimeoutMs?: number; }): Promise { const authProfilesMtimeMs = await readFileMtimeMs( path.join(params.agentDir, "auth-profiles.json"), @@ -64,6 +66,8 @@ async function buildModelsJsonFingerprint(params: { modelsFileMtimeMs, workspaceDir: params.workspaceDir, pluginMetadataSnapshotIndexFingerprint, + providerDiscoveryProviderIds: params.providerDiscoveryProviderIds, + providerDiscoveryTimeoutMs: params.providerDiscoveryTimeoutMs, }); } @@ -152,6 +156,8 @@ export async function ensureOpenClawModelsJson( options: { pluginMetadataSnapshot?: Pick; workspaceDir?: string; + providerDiscoveryProviderIds?: readonly string[]; + providerDiscoveryTimeoutMs?: number; } = {}, ): Promise<{ agentDir: string; wrote: boolean }> { const resolved = resolveModelsConfigInput(config); @@ -175,6 +181,12 @@ export async function ensureOpenClawModelsJson( agentDir, ...(workspaceDir ? { workspaceDir } : {}), ...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}), + ...(options.providerDiscoveryProviderIds + ? { providerDiscoveryProviderIds: options.providerDiscoveryProviderIds } + : {}), + ...(options.providerDiscoveryTimeoutMs !== undefined + ? { providerDiscoveryTimeoutMs: options.providerDiscoveryTimeoutMs } + : {}), }); const cached = MODELS_JSON_STATE.readyCache.get(targetPath); if (cached) { @@ -199,6 +211,12 @@ export async function ensureOpenClawModelsJson( existingRaw: existingModelsFile.raw, existingParsed: existingModelsFile.parsed, ...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}), + ...(options.providerDiscoveryProviderIds + ? { providerDiscoveryProviderIds: options.providerDiscoveryProviderIds } + : {}), + ...(options.providerDiscoveryTimeoutMs !== undefined + ? { providerDiscoveryTimeoutMs: options.providerDiscoveryTimeoutMs } + : {}), }); if (plan.action === "skip") { diff --git a/src/agents/models-config.write-serialization.test.ts b/src/agents/models-config.write-serialization.test.ts index d10f68d870b..9790a339e3d 100644 --- a/src/agents/models-config.write-serialization.test.ts +++ b/src/agents/models-config.write-serialization.test.ts @@ -113,6 +113,29 @@ describe("models-config write serialization", () => { }); }); + it("does not reuse scoped startup discovery cache for a different provider scope", async () => { + await withModelsTempHome(async (home) => { + planOpenClawModelsJsonMock.mockImplementation(async () => ({ action: "skip" })); + const agentDir = path.join(home, "agent"); + await ensureOpenClawModelsJson({}, agentDir, { + providerDiscoveryProviderIds: ["openai"], + providerDiscoveryTimeoutMs: 5000, + }); + await ensureOpenClawModelsJson({}, agentDir, { + providerDiscoveryProviderIds: ["anthropic"], + providerDiscoveryTimeoutMs: 5000, + }); + + expect(planOpenClawModelsJsonMock).toHaveBeenCalledTimes(2); + expect(planOpenClawModelsJsonMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + providerDiscoveryProviderIds: ["anthropic"], + providerDiscoveryTimeoutMs: 5000, + }), + ); + }); + }); + it("serializes concurrent models.json writes to avoid overlap", async () => { await withModelsTempHome(async () => { const first = structuredClone(CUSTOM_PROXY_MODELS_CONFIG); diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index d9008f4e779..5314c2c25dc 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -22,6 +22,7 @@ const SESSION_LOCK_STALE_MS = 30 * 60 * 1000; const ACP_BACKEND_READY_TIMEOUT_MS = 5_000; const ACP_BACKEND_READY_POLL_MS = 50; const PRIMARY_MODEL_PREWARM_TIMEOUT_MS = 5_000; +const STARTUP_PROVIDER_DISCOVERY_TIMEOUT_MS = 5_000; type Awaitable = T | Promise; @@ -146,7 +147,10 @@ async function prewarmConfiguredPrimaryModel(params: { } const agentDir = resolveOpenClawAgentDir(); try { - await ensureOpenClawModelsJson(params.cfg, agentDir); + await ensureOpenClawModelsJson(params.cfg, agentDir, { + providerDiscoveryProviderIds: [provider], + providerDiscoveryTimeoutMs: STARTUP_PROVIDER_DISCOVERY_TIMEOUT_MS, + }); const resolved = resolveModel(provider, model, agentDir, params.cfg, { skipProviderRuntimeHooks: true, }); @@ -318,11 +322,15 @@ export async function startGatewaySidecars(params: { await measureStartup(params.startupTrace, "sidecars.channels", async () => { if (!skipChannels) { try { - await prewarmConfiguredPrimaryModelWithTimeout({ - cfg: params.cfg, - log: params.log, - }); - await params.startChannels(); + await measureStartup(params.startupTrace, "sidecars.model-prewarm", () => + prewarmConfiguredPrimaryModelWithTimeout({ + cfg: params.cfg, + log: params.log, + }), + ); + await measureStartup(params.startupTrace, "sidecars.channel-start", () => + params.startChannels(), + ); } catch (err) { params.logChannels.error(`channel startup failed: ${String(err)}`); } diff --git a/src/gateway/server-startup.test.ts b/src/gateway/server-startup.test.ts index 29f4e8c3783..e2d057c4da9 100644 --- a/src/gateway/server-startup.test.ts +++ b/src/gateway/server-startup.test.ts @@ -2,7 +2,11 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; const ensureOpenClawModelsJsonMock = vi.fn< - (config: unknown, agentDir: unknown) => Promise<{ agentDir: string; wrote: boolean }> + ( + config: unknown, + agentDir: unknown, + options?: unknown, + ) => Promise<{ agentDir: string; wrote: boolean }> >(async () => ({ agentDir: "/tmp/agent", wrote: false })); const resolveModelMock = vi.fn< ( @@ -41,8 +45,8 @@ vi.mock("../agents/agent-paths.js", () => ({ })); vi.mock("../agents/models-config.js", () => ({ - ensureOpenClawModelsJson: (config: unknown, agentDir: unknown) => - ensureOpenClawModelsJsonMock(config, agentDir), + ensureOpenClawModelsJson: (config: unknown, agentDir: unknown, options?: unknown) => + ensureOpenClawModelsJsonMock(config, agentDir, options), })); vi.mock("../agents/harness/selection.js", () => ({ @@ -100,7 +104,14 @@ describe("gateway startup primary model warmup", () => { log: { warn: vi.fn() }, }); - expect(ensureOpenClawModelsJsonMock).toHaveBeenCalledWith(cfg, "/tmp/agent"); + expect(ensureOpenClawModelsJsonMock).toHaveBeenCalledWith( + cfg, + "/tmp/agent", + expect.objectContaining({ + providerDiscoveryProviderIds: ["openai-codex"], + providerDiscoveryTimeoutMs: 5000, + }), + ); expect(resolveModelMock).toHaveBeenCalledWith("openai-codex", "gpt-5.4", "/tmp/agent", cfg, { skipProviderRuntimeHooks: true, }); @@ -208,7 +219,14 @@ describe("gateway startup primary model warmup", () => { modelId: "gpt-5.4", config: cfg, }); - expect(ensureOpenClawModelsJsonMock).toHaveBeenCalledWith(cfg, "/tmp/agent"); + expect(ensureOpenClawModelsJsonMock).toHaveBeenCalledWith( + cfg, + "/tmp/agent", + expect.objectContaining({ + providerDiscoveryProviderIds: ["openai-codex"], + providerDiscoveryTimeoutMs: 5000, + }), + ); expect(resolveModelMock).toHaveBeenCalled(); });