From 47b23298fb06392660e5375a8b35ded36603dcc1 Mon Sep 17 00:00:00 2001 From: musistudio Date: Fri, 7 Aug 2026 22:49:28 +0800 Subject: [PATCH 01/18] Implement requested changes --- .../core/src/agents/claude-code/auth-mode.ts | 82 +++++ .../agents/codex/cli-middleware-runtime.ts | 9 + .../src/gateway/auth/api-key-authorizer.ts | 91 ++++- .../src/gateway/features/model-discovery.ts | 159 +++++++- .../core/src/gateway/http/request-handler.ts | 16 +- packages/core/src/profiles/launch-service.ts | 77 +++- packages/core/src/profiles/service.ts | 344 ++++++++++++++++-- .../profiles/profile-service.test.mjs | 154 +++++++- .../agents/claude-app-gateway-models.test.mjs | 158 ++++++++ .../unit/gateway/api-key-authorizer.test.mjs | 36 +- 10 files changed, 1068 insertions(+), 58 deletions(-) create mode 100644 packages/core/src/agents/claude-code/auth-mode.ts diff --git a/packages/core/src/agents/claude-code/auth-mode.ts b/packages/core/src/agents/claude-code/auth-mode.ts new file mode 100644 index 00000000..1b473d1b --- /dev/null +++ b/packages/core/src/agents/claude-code/auth-mode.ts @@ -0,0 +1,82 @@ +import { spawnSync } from "node:child_process"; + +export const CLAUDE_CODE_AUTH_MODE_ENV = "CCR_CLAUDE_CODE_AUTH_MODE"; + +export type ClaudeCodeGatewayAuthMode = "api-key-helper" | "wif"; +export type ClaudeCodeGatewayAuthModePreference = ClaudeCodeGatewayAuthMode | "auto"; + +type ClaudeCodeAuthProfile = { + env?: Record; +}; + +const claudeCodeWifMinimumVersion = [2, 1, 200] as const; + +export function resolveClaudeCodeGatewayAuthMode(profile: ClaudeCodeAuthProfile): ClaudeCodeGatewayAuthMode { + const preference = claudeCodeGatewayAuthModePreference(profile); + if (preference === "wif" || preference === "api-key-helper") { + return preference; + } + + const version = detectClaudeCodeCliVersion(profile); + return version && compareVersions(version, claudeCodeWifMinimumVersion) >= 0 + ? "wif" + : "api-key-helper"; +} + +export function claudeCodeGatewayAuthModePreference(profile: ClaudeCodeAuthProfile): ClaudeCodeGatewayAuthModePreference { + return normalizeClaudeCodeGatewayAuthMode( + profile.env?.[CLAUDE_CODE_AUTH_MODE_ENV] || + process.env[CLAUDE_CODE_AUTH_MODE_ENV] + ); +} + +export function normalizeClaudeCodeGatewayAuthMode(value: string | undefined): ClaudeCodeGatewayAuthModePreference { + const normalized = value?.trim().toLowerCase().replace(/_/g, "-") || ""; + if (normalized === "wif" || normalized === "workload-identity" || normalized === "workload-identity-federation") { + return "wif"; + } + if (normalized === "helper" || normalized === "api-key-helper" || normalized === "apikey-helper" || normalized === "legacy") { + return "api-key-helper"; + } + return "auto"; +} + +function detectClaudeCodeCliVersion(profile: ClaudeCodeAuthProfile): [number, number, number] | undefined { + const command = profile.env?.CCR_CLAUDE_CODE_BIN?.trim() || "claude"; + try { + const result = spawnSync(command, ["--version"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 1500, + windowsHide: true + }); + if (result.error) { + return undefined; + } + return parseVersion(`${result.stdout || ""}\n${result.stderr || ""}`); + } catch { + return undefined; + } +} + +function parseVersion(value: string): [number, number, number] | undefined { + const match = value.match(/\b(\d+)\.(\d+)(?:\.(\d+))?\b/); + if (!match) { + return undefined; + } + return [ + Number.parseInt(match[1] ?? "0", 10), + Number.parseInt(match[2] ?? "0", 10), + Number.parseInt(match[3] ?? "0", 10) + ]; +} + +function compareVersions(left: readonly number[], right: readonly number[]): number { + for (let index = 0; index < 3; index += 1) { + const delta = (left[index] ?? 0) - (right[index] ?? 0); + if (delta !== 0) { + return delta; + } + } + return 0; +} diff --git a/packages/core/src/agents/codex/cli-middleware-runtime.ts b/packages/core/src/agents/codex/cli-middleware-runtime.ts index 7eed4f61..2c991c5e 100644 --- a/packages/core/src/agents/codex/cli-middleware-runtime.ts +++ b/packages/core/src/agents/codex/cli-middleware-runtime.ts @@ -4941,6 +4941,15 @@ class RemoteSyncClient { async function readRemoteSyncApiKey() { const direct = nonEmptyEnv("CCR_REMOTE_SYNC_API_KEY"); if (direct) return direct; + const file = nonEmptyEnv("CCR_REMOTE_SYNC_API_KEY_FILE"); + if (file) { + try { + const content = fs.readFileSync(expandHome(file), "utf8"); + return String(content || "").split(/\r?\n/).map((line) => line.trim()).find(Boolean) || ""; + } catch (error) { + log("remote_sync_api_key_file_failed", { error: formatError(error), file }); + } + } const helper = nonEmptyEnv("CCR_REMOTE_SYNC_API_KEY_HELPER"); if (!helper) return ""; return new Promise((resolve) => { diff --git a/packages/core/src/gateway/auth/api-key-authorizer.ts b/packages/core/src/gateway/auth/api-key-authorizer.ts index df28ac7c..cbc407a0 100644 --- a/packages/core/src/gateway/auth/api-key-authorizer.ts +++ b/packages/core/src/gateway/auth/api-key-authorizer.ts @@ -2,10 +2,13 @@ import { timingSafeEqual } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; import type { ApiKeyConfig, AppConfig } from "@ccr/core/contracts/app"; import { loadPersistedApiKeys } from "@ccr/core/config/config-repository"; -import { formatError, readAuthToken, readRemoteControlQueryAuthToken, sendJson } from "@ccr/core/gateway/http/io"; +import { formatError, readAuthToken, readRemoteControlQueryAuthToken, readRequestBody, sendJson } from "@ccr/core/gateway/http/io"; import { estimateLimitUsage, limitRules, readWindowCounter } from "@ccr/core/gateway/limits/window-limiter"; import type { ApiKeyAuthorizationResult, ApiKeyLimitRule, ApiKeyLimitUsage } from "@ccr/core/gateway/internal/shared"; +export const claudeCodeWifTokenPath = "/v1/oauth/token"; +const claudeCodeWifGrantType = "urn:ietf:params:oauth:grant-type:jwt-bearer"; +const claudeCodeWifTokenExpiresInSeconds = 3600; const persistedApiKeyCacheTtlMs = 1000; let persistedApiKeyCache: { loadedAt: number; values: ApiKeyConfig[] } | undefined; @@ -42,6 +45,50 @@ export async function authorize( return { ok: false }; } +export async function handleClaudeCodeWifTokenRequest( + request: IncomingMessage, + response: ServerResponse, + config: AppConfig +): Promise { + const result = await exchangeClaudeCodeWifToken(config, await readRequestBody(request)); + sendJson(response, result.statusCode, result.payload); +} + +export async function exchangeClaudeCodeWifToken( + config: AppConfig, + requestBody: Buffer +): Promise<{ payload: Record; statusCode: number }> { + const body = parseClaudeCodeWifTokenRequestBody(requestBody); + const grantType = stringField(body.grant_type); + if (grantType !== claudeCodeWifGrantType) { + return oauthTokenError(400, "unsupported_grant_type", "Claude Code WIF requires the JWT bearer grant type."); + } + + const assertion = stringField(body.assertion); + if (!assertion) { + return oauthTokenError(400, "invalid_request", "Claude Code WIF assertion is missing."); + } + + let apiKeys = await configuredApiKeys(config); + let apiKey = findApiKeyByToken(apiKeys, assertion); + if (!apiKey) { + apiKeys = await configuredApiKeys(config, { refresh: true }); + apiKey = findApiKeyByToken(apiKeys, assertion); + } + if (!apiKey || isApiKeyExpired(apiKey)) { + return oauthTokenError(401, "invalid_grant", "Claude Code WIF assertion is invalid or expired."); + } + + return { + payload: { + access_token: apiKey.key, + expires_in: claudeCodeWifTokenExpiresInSeconds, + token_type: "Bearer" + }, + statusCode: 200 + }; +} + export function reserveApiKeyLimits( apiKey: ApiKeyConfig | undefined, request: IncomingMessage, @@ -107,6 +154,48 @@ async function configuredApiKeys(config: AppConfig, options: { refresh?: boolean return result; } +function parseClaudeCodeWifTokenRequestBody(buffer: Buffer): Record { + const text = buffer.toString("utf8").trim(); + if (!text) { + return {}; + } + if (text.startsWith("{")) { + try { + const parsed = JSON.parse(text) as unknown; + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) + ? parsed as Record + : {}; + } catch { + return {}; + } + } + + const params = new URLSearchParams(text); + const result: Record = {}; + params.forEach((value, key) => { + result[key] = value; + }); + return result; +} + +function stringField(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function oauthTokenError( + statusCode: number, + error: string, + errorDescription: string +): { payload: Record; statusCode: number } { + return { + payload: { + error, + error_description: errorDescription + }, + statusCode + }; +} + async function loadPersistedApiKeysCached(options: { refresh?: boolean } = {}): Promise { const now = Date.now(); if (!options.refresh && persistedApiKeyCache && now - persistedApiKeyCache.loadedAt < persistedApiKeyCacheTtlMs) { diff --git a/packages/core/src/gateway/features/model-discovery.ts b/packages/core/src/gateway/features/model-discovery.ts index 8a9e4eff..c8082c7a 100644 --- a/packages/core/src/gateway/features/model-discovery.ts +++ b/packages/core/src/gateway/features/model-discovery.ts @@ -24,6 +24,12 @@ export function shouldServeGatewayModelsResponse(method: string, path: string): } +export function shouldServeClaudeCliBootstrapResponse(method: string, path: string): boolean { + return (method || "GET").toUpperCase() === "GET" && + normalizeGatewayPathname(path) === "/api/claude_cli/bootstrap"; +} + + export function prepareClaudeCodeDiscoveredModelRequest( config: AppConfig, headers: IncomingHttpHeaders, @@ -102,6 +108,95 @@ export function createGatewayModelsResponse(config: AppConfig, headers: Incoming } +export function createClaudeCliBootstrapResponse(config: AppConfig): Record { + const windows = createClaudeCliAutoCompactWindows(config); + return { + additional_model_options: createClaudeCliAdditionalModelOptions(config), + auto_compact_windows: windows, + client_data: { + rowan_thicket: { ...windows } + } + }; +} + + +type ClaudeCliAdditionalModelOption = { + capabilities: Record; + created_at: string; + description: string; + display_name: string; + id: string; + max_input_tokens: number; + max_tokens: number; + model: string; + name: string; + type: "model"; +}; + + +function createClaudeCliAdditionalModelOptions(config: AppConfig): ClaudeCliAdditionalModelOption[] { + const options: ClaudeCliAdditionalModelOption[] = []; + const seen = new Set(); + const routes = buildClaudeAppGatewayModelRoutes(config, claudeAppGatewayModelRouteOptions); + for (const route of routes) { + const id = route.oneMillionContext ? claudeCodeOneMillionContextModelId(route.id) : route.id; + const normalized = id.trim(); + const key = normalized.toLowerCase(); + if (!normalized || seen.has(key)) { + continue; + } + seen.add(key); + + const catalogId = stripClaudeCodeOneMillionContextSuffix(route.targetModel); + const modelDiscovery = providerModelDiscoveryForSelector(config, catalogId); + const maxInputTokens = claudeGatewayModelContextWindow( + modelDiscovery.catalogEntry, + route.oneMillionContext, + modelDiscovery.metadata + ); + const maxOutputTokens = claudeGatewayModelMaxOutputTokens(modelDiscovery.catalogEntry, modelDiscovery.metadata); + const name = route.oneMillionContext ? `${route.displayName} (1M context)` : route.displayName; + options.push({ + capabilities: createClaudeCodeModelCapabilities(modelDiscovery.catalogEntry, { + maxInputTokens, + oneMillionContext: route.oneMillionContext, + ...providerModelCapabilityOverrides(modelDiscovery.metadata) + }), + created_at: "1970-01-01T00:00:00Z", + description: formatClaudeCliAdditionalModelDescription(maxInputTokens), + display_name: name, + id: normalized, + max_input_tokens: maxInputTokens, + max_tokens: maxOutputTokens, + model: normalized, + name, + type: "model" + }); + } + return options; +} + + +function formatClaudeCliAdditionalModelDescription(maxInputTokens: number): string { + const tokens = positiveInteger(maxInputTokens); + if (!tokens) { + return "CCR gateway model"; + } + return `${formatCompactTokenCount(tokens)} context window`; +} + + +function formatCompactTokenCount(tokens: number): string { + if (tokens >= 1_000_000 && tokens % 1_000_000 === 0) { + return `${tokens / 1_000_000}M`; + } + if (tokens >= 1_000 && tokens % 1_000 === 0) { + return `${tokens / 1_000}k`; + } + return `${tokens.toLocaleString("en-US")} tokens`; +} + + function createOpenAICompatibleGatewayModelsResponse(config: AppConfig): Record { const data = buildGatewayDiscoverableModelIds(config).map((id) => { const catalogEntry = findModelCatalogEntry(id); @@ -122,6 +217,58 @@ function createOpenAICompatibleGatewayModelsResponse(config: AppConfig): Record< } +export function createClaudeCliAutoCompactWindows(config: AppConfig): Record { + const windows: Record = {}; + const routes = buildClaudeAppGatewayModelRoutes(config, claudeAppGatewayModelRouteOptions); + for (const route of routes) { + const catalogId = stripClaudeCodeOneMillionContextSuffix(route.targetModel); + const modelDiscovery = providerModelDiscoveryForSelector(config, catalogId); + const maxInputTokens = claudeGatewayModelContextWindow( + modelDiscovery.catalogEntry, + route.oneMillionContext, + modelDiscovery.metadata + ); + const compactWindow = claudeCliAutoCompactWindow(maxInputTokens); + if (!compactWindow) { + continue; + } + + const routeId = route.oneMillionContext ? claudeCodeOneMillionContextModelId(route.id) : route.id; + for (const id of uniqueStrings([routeId, route.id, route.legacyId, ...(route.legacyIds ?? [])])) { + const key = id.trim(); + if (key) { + assignClaudeCliAutoCompactWindow(windows, key, compactWindow); + if (compactWindow > claudeCodeUnknownModelDefaultContextWindow) { + assignClaudeCliAutoCompactWindow(windows, claudeCodeOneMillionContextModelId(key), compactWindow); + } + } + } + } + return windows; +} + + +function assignClaudeCliAutoCompactWindow(windows: Record, key: string, compactWindow: number): void { + windows[key] = compactWindow; + const lowerKey = key.toLowerCase(); + if (lowerKey !== key) { + windows[lowerKey] = compactWindow; + } +} + + +const claudeCodeUnknownModelDefaultContextWindow = 200_000; + + +function claudeCliAutoCompactWindow(maxInputTokens: number): number | undefined { + const window = positiveInteger(maxInputTokens); + if (!window) { + return undefined; + } + return Math.min(1_000_000, Math.max(100_000, window)); +} + + function createClaudeAppGatewayModelsResponse( config: AppConfig, options: { claudeCode?: boolean; contextArchiveCompact?: boolean } = {} @@ -133,7 +280,7 @@ function createClaudeAppGatewayModelsResponse( const catalogEntry = modelDiscovery.catalogEntry; const modelMetadata = modelDiscovery.metadata; const maxInputTokens = claudeGatewayModelContextWindow(catalogEntry, route.oneMillionContext, modelMetadata); - const maxOutputTokens = modelCatalogMaxOutputTokens(catalogEntry); + const maxOutputTokens = claudeGatewayModelMaxOutputTokens(catalogEntry, modelMetadata); const exposeOneMillionContextVariant = options.claudeCode && route.oneMillionContext; return { id: exposeOneMillionContextVariant ? claudeCodeOneMillionContextModelId(route.id) : route.id, @@ -171,7 +318,7 @@ function createClaudeCodeModelsResponse(config: AppConfig, contextArchiveCompact const catalogEntry = modelDiscovery.catalogEntry; const modelMetadata = modelDiscovery.metadata; const maxInputTokens = claudeGatewayModelContextWindow(catalogEntry, model.oneMillionContext, modelMetadata); - const maxOutputTokens = modelCatalogMaxOutputTokens(catalogEntry); + const maxOutputTokens = claudeGatewayModelMaxOutputTokens(catalogEntry, modelMetadata); return { id: claudeId, capabilities: createClaudeCodeModelCapabilities(catalogEntry, { @@ -219,6 +366,14 @@ function claudeGatewayModelContextWindow( } +function claudeGatewayModelMaxOutputTokens( + entry: ModelCatalogEntry | undefined, + metadata?: ProviderModelMetadata +): number { + return positiveInteger(metadata?.maxOutputTokens) ?? modelCatalogMaxOutputTokens(entry); +} + + function effectiveProviderContextWindow(metadata: ProviderModelMetadata | undefined): number | undefined { const contextWindow = positiveInteger(metadata?.contextWindow) ?? positiveInteger(metadata?.maxContextWindow); if (!contextWindow) { diff --git a/packages/core/src/gateway/http/request-handler.ts b/packages/core/src/gateway/http/request-handler.ts index 734bbe15..0cb400f6 100644 --- a/packages/core/src/gateway/http/request-handler.ts +++ b/packages/core/src/gateway/http/request-handler.ts @@ -6,6 +6,7 @@ import { LEGACY_GROK_MEDIA_MCP_PATH, MEDIA_TOOLS_MCP_PATH } from "@ccr/core/mcp/ import { BROWSER_AUTOMATION_MCP_PATH, browserAutomationMcpEnabled } from "@ccr/core/mcp/toolhub-config"; import { pluginService } from "@ccr/core/plugins/service"; import { ClaudeCodeRouterPlugin } from "@ccr/core/gateway/claude-code-router-plugin"; +import { createClaudeCliBootstrapResponse, shouldServeClaudeCliBootstrapResponse } from "@ccr/core/gateway/features/model-discovery"; import { contextArchiveConfigForApiKey, handleContextArchiveMcpRequest, @@ -13,7 +14,7 @@ import { type ContextArchiveReplayExecutor } from "@ccr/core/gateway/context-archive"; import { ccrRemoteControlPathPrefix, ccrRemoteControlService } from "@ccr/core/gateway/remote-control-service"; -import { authorize, reserveApiKeyLimits } from "@ccr/core/gateway/auth/api-key-authorizer"; +import { authorize, claudeCodeWifTokenPath, handleClaudeCodeWifTokenRequest, reserveApiKeyLimits } from "@ccr/core/gateway/auth/api-key-authorizer"; import { parseJsonObject, readRequestBody, sendJson } from "@ccr/core/gateway/http/io"; import { shouldRecordRequestLogs } from "@ccr/core/observability/raw-trace-sync"; import { applyCors, shouldServeGatewayRequest } from "@ccr/core/gateway/core-runtime/supervisor"; @@ -191,7 +192,7 @@ export class GatewayHttpRequestHandler { if (path === "/") { sendJson(response, 200, { core: "next-ai-gateway", - endpoints: ["POST /mcp", "POST /v1/messages", "POST /v1/messages/count_tokens", "GET /models", "GET /v1/models"], + endpoints: ["POST /v1/oauth/token", "GET /api/claude_cli/bootstrap", "POST /mcp", "POST /v1/messages", "POST /v1/messages/count_tokens", "GET /models", "GET /v1/models"], name: "claude-code-router", plugin: "claude-code-router", wrapperPlugins: this.config.plugins.filter((plugin) => plugin.enabled !== false).map((plugin) => plugin.id) @@ -199,11 +200,22 @@ export class GatewayHttpRequestHandler { return; } + if (request.method === "POST" && (path === claudeCodeWifTokenPath || path === `${claudeCodeWifTokenPath}/`)) { + await handleClaudeCodeWifTokenRequest(request, response, this.config); + return; + } + + const claudeCliBootstrapRequest = shouldServeClaudeCliBootstrapResponse(request.method ?? "GET", path); const authorization = await authorize(request, response, this.config); if (!authorization.ok) { return; } + if (claudeCliBootstrapRequest) { + sendJson(response, 200, createClaudeCliBootstrapResponse(this.config)); + return; + } + if (request.method === "POST" && path === "/v1/messages/count_tokens") { const requestBody = await readRequestBody(request); const body = parseJsonObject(requestBody); diff --git a/packages/core/src/profiles/launch-service.ts b/packages/core/src/profiles/launch-service.ts index e1c1db2b..e98862dc 100644 --- a/packages/core/src/profiles/launch-service.ts +++ b/packages/core/src/profiles/launch-service.ts @@ -6,6 +6,7 @@ import { assertAvailableGatewayModels, type AppConfig, type ProfileConfig, type import { botGatewayProfileEnv } from "@ccr/core/agents/bot-gateway/env"; import { applyClaudeAppGatewayConfig, readClaudeAppGatewayApiKeyCandidates } from "@ccr/core/agents/claude-app/gateway-service"; import { launchClaudeAppProfile, resolveClaudeAppProfileUserDataDir } from "@ccr/core/agents/claude-app/launch"; +import { resolveClaudeCodeGatewayAuthMode } from "@ccr/core/agents/claude-code/auth-mode"; import { claudeCodeUtcTimezoneEnvOverride } from "@ccr/core/agents/claude-code/environment"; import { codexDesktopAppName, launchCodexAppProfile, launchZcodeAppProfile, refreshCodexCompatibleAppProfileFiles } from "@ccr/core/agents/codex/app-launch"; import { CodexAppMediaPreviewBridge, shouldEnableCodexMediaPreviewBridge } from "@ccr/core/agents/codex/media-preview-bridge"; @@ -427,6 +428,7 @@ async function ensureGatewayConfigRunning( type ExistingProfileGatewayProbe = | { endpoint: string; reason?: string; state: "unavailable" } + | { endpoint: string; message: string; state: "incompatible" } | { endpoint: string; status?: number; state: "not-ccr" } | { endpoint: string; message?: string; status: number; state: "unauthorized" } | { endpoint: string; status: number; state: "unusable" } @@ -470,6 +472,16 @@ async function probeExistingProfileGateway( } const models = await fetchExistingGateway(endpoint, "/v1/models", { headers }); if (models.status === 200) { + if (requiresClaudeCodeWifGateway(profile)) { + const rootProbe = root ?? await fetchExistingGateway(endpoint, "/"); + if (!rootSupportsClaudeCodeWif(rootProbe.payload)) { + return { + endpoint, + message: "The running CCR gateway does not advertise the Claude Code WIF token endpoint. Restart CCR Desktop or run ccr start to use WIF authentication.", + state: "incompatible" + }; + } + } return { apiKey, endpoint, state: "usable" }; } if (models.status === 401 || models.status === 403) { @@ -543,6 +555,19 @@ function isCcrGatewayHealth(value: unknown): boolean { return typeof value.core === "string" && typeof value.status === "string" && typeof value.timestamp === "string"; } +function rootSupportsClaudeCodeWif(value: unknown): boolean { + if (!isRecord(value) || !Array.isArray(value.endpoints)) { + return false; + } + return value.endpoints.some((endpoint) => + typeof endpoint === "string" && endpoint.toLowerCase().includes("/v1/oauth/token") + ); +} + +function requiresClaudeCodeWifGateway(profile: ReturnType): boolean { + return profile.agent === "claude-code" && resolveClaudeCodeGatewayAuthMode(profile) === "wif"; +} + function readGatewayErrorMessage(value: unknown): string | undefined { if (!isRecord(value) || !isRecord(value.error)) { return undefined; @@ -561,7 +586,7 @@ function existingGatewayApiKeyCandidates( candidateConfig.APIKEY, ...(Array.isArray(candidateConfig.APIKEYS) ? candidateConfig.APIKEYS.map((apiKey) => apiKey.key) : []), ...readClaudeAppGatewayApiKeyCandidates(), - ...readClaudeCodeApiKeyHelperCandidates(profile) + ...readClaudeCodeProfileTokenCandidates(profile) ]; const seen = new Set(); const result: string[] = []; @@ -576,16 +601,39 @@ function existingGatewayApiKeyCandidates( return result.length > 0 ? result : [undefined]; } -function readClaudeCodeApiKeyHelperCandidates(profile: ReturnType): string[] { - const file = path.join(CONFIGDIR, "bin", claudeCodeApiKeyHelperFilename(profile)); +function readClaudeCodeProfileTokenCandidates(profile: ReturnType): string[] { + return uniqueStrings([ + ...readClaudeCodeWifTokenCandidates(profile), + ...readClaudeCodeLegacyApiKeyHelperCandidates(profile) + ]); +} + +function readClaudeCodeWifTokenCandidates(profile: ReturnType): string[] { + const file = path.join(CONFIGDIR, "bin", claudeCodeWifIdentityTokenFilename(profile)); const files = [ file, ...readBackupFiles(file) ]; - return uniqueStrings(files.map(readClaudeCodeApiKeyHelperToken)); + return uniqueStrings(files.map(readFirstLineToken)); } -function claudeCodeApiKeyHelperFilename(profile: ReturnType): string { +function claudeCodeWifIdentityTokenFilename(profile: ReturnType): string { + const slug = sanitizeProfilePathSegment(profile.id || profile.name || profile.agent) || "claude-code"; + return process.platform === "win32" + ? `ccr-claude-code-wif-token-${slug}.txt` + : `ccr-claude-code-wif-token-${slug}`; +} + +function readClaudeCodeLegacyApiKeyHelperCandidates(profile: ReturnType): string[] { + const file = path.join(CONFIGDIR, "bin", claudeCodeLegacyApiKeyHelperFilename(profile)); + const files = [ + file, + ...readBackupFiles(file) + ]; + return uniqueStrings(files.map(readClaudeCodeLegacyApiKeyHelperToken)); +} + +function claudeCodeLegacyApiKeyHelperFilename(profile: ReturnType): string { const slug = sanitizeProfilePathSegment(profile.id || profile.name || profile.agent) || "claude-code"; return process.platform === "win32" ? `ccr-claude-code-api-key-${slug}.cmd` @@ -606,7 +654,21 @@ function readBackupFiles(file: string): string[] { } } -function readClaudeCodeApiKeyHelperToken(file: string): string { +function readFirstLineToken(file: string): string { + if (!existsSync(file)) { + return ""; + } + try { + return readFileSync(file, "utf8") + .split(/\r?\n/g) + .map((line) => line.trim()) + .find(Boolean) || ""; + } catch { + return ""; + } +} + +function readClaudeCodeLegacyApiKeyHelperToken(file: string): string { if (!existsSync(file)) { return ""; } @@ -651,6 +713,9 @@ function existingGatewayConflictMessage(probe: ExistingProfileGatewayProbe, appN if (probe.state === "unusable") { return `CCR gateway is already running at ${probe.endpoint}, but it cannot serve ${appName} right now (HTTP ${probe.status}). Restart CCR Desktop or run ccr start to refresh the gateway before opening this profile.`; } + if (probe.state === "incompatible") { + return `CCR gateway is already running at ${probe.endpoint}, but it is not compatible with ${appName}. ${probe.message}`; + } if (probe.state === "not-ccr") { return `Port ${probe.endpoint} is already in use by a non-CCR service. Stop that process or change the CCR gateway port.`; } diff --git a/packages/core/src/profiles/service.ts b/packages/core/src/profiles/service.ts index dbf627fa..86d34a30 100644 --- a/packages/core/src/profiles/service.ts +++ b/packages/core/src/profiles/service.ts @@ -2,16 +2,19 @@ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readlinkSync import os from "node:os"; import path from "node:path"; import { CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV, NO_AVAILABLE_GATEWAY_MODELS_MESSAGE, availableGatewayModelIds, enforceSingleEnabledGlobalProfilePerAgent, hasAvailableGatewayModels, isGatewayProviderEnabled, type AppConfig, type ProfileApplyResult, type ProfileClientApplyStatus, type ProfileClientKind, type ProfileConfig } from "@ccr/core/contracts/app"; +import { CLAUDE_CODE_AUTH_MODE_ENV, resolveClaudeCodeGatewayAuthMode, type ClaudeCodeGatewayAuthMode } from "@ccr/core/agents/claude-code/auth-mode"; import { replacePersistedApiKeys } from "@ccr/core/config/config-repository"; import { botGatewayProfileEnv } from "@ccr/core/agents/bot-gateway/env"; import { CLAUDE_CODE_MCP_CONFIG_ENV, CODEXL_CLAUDE_CODE_MCP_CONFIG_ENV, + type ClaudeCodeModelSelection, claudeCodeModelEnv, claudeCodeMcpConfigEnv, claudeCodeUtcTimezoneEnvOverride, clearClaudeCodeManagedModelEnv, - isClaudeCodeManagedModelEnvKey + isClaudeCodeManagedModelEnvKey, + normalizeClaudeCodeClientModel } from "@ccr/core/agents/claude-code/environment"; import { writeCodexCompatibleAppModelCatalog } from "@ccr/core/agents/codex/app-launch"; import { codexCliMiddlewareRuntimeScript } from "@ccr/core/agents/codex/cli-middleware-runtime"; @@ -37,6 +40,8 @@ import { CONFIGDIR } from "@ccr/core/config/constants"; import { pruneInactiveProfileApiKeysFromList, syncProfileApiKeys } from "@ccr/core/profiles/api-key"; import { resolveZcodeConfigFile, writeZcodeGatewayConfig, zcodeHomeFromConfigFile } from "@ccr/core/agents/zcode/profile-config"; import { CONTEXT_ARCHIVE_MCP_SERVER_NAME, contextArchiveConfigForProfile, contextArchiveMcpServer } from "@ccr/core/gateway/context-archive"; +import { createClaudeCliAutoCompactWindows } from "@ccr/core/gateway/features/model-discovery"; +import { claudeCodeOneMillionContextSuffix } from "@ccr/core/gateway/internal/shared"; import { normalizeRouteSelector } from "@ccr/core/gateway/claude-code-router-plugin"; import { findModelCatalogEntry, modelCatalogMaxInputTokens, readCatalogCapability, type ModelCatalogEntry } from "@ccr/core/gateway/model-catalog"; import { @@ -68,6 +73,8 @@ const privateDirMode = 0o700; const privateExecutableMode = 0o700; const privateFileMode = 0o600; const publicExecutableMode = 0o755; +const claudeCodeWifFederationRuleId = "ccr-local"; +const claudeCodeWifOrganizationId = "ccr-local"; const claudeCodeGatewayEnvKeys = [ "ANTHROPIC_BASE_URL", "ANTHROPIC_API_BASE_URL", @@ -77,6 +84,26 @@ const claudeCodeRemovedAuthEnvKeys = [ "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY" ] as const; +const claudeCodeFirstPartyProviderEnvKeys = [ + "CLAUDE_CODE_USE_BEDROCK", + "CLAUDE_CODE_USE_VERTEX", + "CLAUDE_CODE_USE_FOUNDRY", + "CLAUDE_CODE_USE_GATEWAY", + "CLAUDE_CODE_USE_MANTLE", + "CLAUDE_CODE_USE_ANTHROPIC_AWS", + "CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD", + ...claudeCodeRemovedAuthEnvKeys +] as const; +const claudeCodeWifEnvKeys = [ + "ANTHROPIC_FEDERATION_RULE_ID", + "ANTHROPIC_ORGANIZATION_ID", + "ANTHROPIC_IDENTITY_TOKEN", + "ANTHROPIC_IDENTITY_TOKEN_FILE", + "ANTHROPIC_SERVICE_ACCOUNT_ID", + "ANTHROPIC_WORKSPACE_ID", + "ANTHROPIC_SCOPE", + "ANTHROPIC_PROFILE" +] as const; let ownedGlobalProfileTakeovers: GlobalProfileTakeoverRecord[] | undefined; type CodexContextArchiveMcpConfig = { @@ -111,6 +138,7 @@ export async function applyProfileConfig( const profiles = allProfiles.filter((profile) => !excludedAgents.has(profile.agent)); cleanupInactiveOpenCodeWrappers(allProfiles); cleanupInactiveKiloWrappers(allProfiles); + cleanupInactiveClaudeCodeGeneratedFiles(allProfiles); await pruneInactiveProfileApiKeys(config, allProfiles); const result: ProfileApplyResult = { appliedAt, @@ -134,7 +162,7 @@ export async function applyProfileConfig( const managedCleanupResult = cleanupManagedClaudeCodeToolHubArtifacts(profiles, { includeActive: true }); result.clients = profiles.map((profile) => { const cleanupResult = profile.agent === "claude-code" - ? cleanupClaudeCodeToolHubArtifacts(profile) + ? cleanupClaudeCodeUnavailableProfileArtifacts(profile) : { ok: true }; const status = profile.enabled ? unavailableModelStatus(profile, profilePath(profile)) @@ -308,6 +336,16 @@ function cleanupClaudeCodeToolHubArtifacts(profile: ProfileConfig): { changed?: } } +function cleanupClaudeCodeUnavailableProfileArtifacts(profile: ProfileConfig): { changed?: boolean; message?: string; ok: boolean } { + const toolHubResult = cleanupClaudeCodeToolHubArtifacts(profile); + const generatedResult = cleanupClaudeCodeGeneratedFiles(profile); + return { + changed: Boolean(toolHubResult.changed || generatedResult.changed), + message: toolHubResult.message, + ok: toolHubResult.ok + }; +} + export function applyProfileRuntimeConfig(config: AppConfig, profile: ProfileConfig, token: string): ProfileClientApplyStatus { cleanupGeneratedBinBackups(); const appliedAt = new Date().toISOString(); @@ -344,6 +382,7 @@ function applyClaudeDesignProfile(profile: ProfileConfig, appliedAt: string): Pr function applyClaudeCodeProfile(config: AppConfig, profile: ProfileConfig, token: string, appliedAt: string): ProfileClientApplyStatus { const settingsFile = resolveClaudeCodeSettingsFile(profile); if (!profile.enabled) { + cleanupClaudeCodeGeneratedFiles(profile); return restoreDisabledGlobalProfile(profile, settingsFile, "Claude Code profile is disabled.", isManagedClaudeCodeSettingsContent); } @@ -361,28 +400,44 @@ function applyClaudeCodeProfile(config: AppConfig, profile: ProfileConfig, token env.ANTHROPIC_BASE_URL = endpoint; env.ANTHROPIC_API_BASE_URL = endpoint; env.CLAUDE_AGENT_API_BASE_URL = endpoint; - delete env.ANTHROPIC_AUTH_TOKEN; - delete env.ANTHROPIC_API_KEY; + for (const key of claudeCodeFirstPartyProviderEnvKeys) { + delete env[key]; + } + for (const key of claudeCodeWifEnvKeys) { + delete env[key]; + } clearClaudeCodeManagedModelEnv(env); - Object.assign(env, claudeCodeModelEnv(profile)); + Object.assign(env, claudeCodeProfileModelEnv(config, profile)); const toolHubMcpConfigResult = writeClaudeCodeToolHubMcpConfig(config, profile, token); const mcpConfigEnv = claudeCodeMcpConfigEnv(toolHubMcpConfigResult.file); const timezoneEnv = claudeCodeUtcTimezoneEnvOverride(); - Object.assign(env, mcpConfigEnv, timezoneEnv); + const authMode = resolveClaudeCodeGatewayAuthMode(profile); + const wifResult = writeClaudeCodeWifIdentityToken(profile, token); + const wifEnv = authMode === "wif" ? claudeCodeWifEnv(wifResult.file) : {}; + Object.assign(env, mcpConfigEnv, timezoneEnv, wifEnv); - const helperResult = writeClaudeCodeApiKeyHelper(profile, token); - const wrapperResult = writeClaudeCodeWrapper(config, profile, helperResult.file, toolHubMcpConfigResult.file); - const nextSettings = { - ...settings, - apiKeyHelper: process.platform === "win32" ? `"${helperResult.file}"` : helperResult.file, - env - }; - const managedEnvKeys = claudeCodeManagedSettingsEnvKeys(profileEnvValues, mcpConfigEnv, timezoneEnv); + const helperResult = authMode === "api-key-helper" ? writeClaudeCodeApiKeyHelper(profile, token) : undefined; + const legacyApiKeyHelperCleanupResult = authMode === "wif" + ? cleanupClaudeCodeLegacyApiKeyHelper(profile) + : { changed: false }; + const wrapperResult = writeClaudeCodeWrapper(config, profile, { + remoteSyncApiKeyFile: wifResult.file, + wifIdentityTokenFile: authMode === "wif" ? wifResult.file : undefined + }, toolHubMcpConfigResult.file); + const autoCompactResult = writeClaudeCodeAutoCompactWindowsCache(config, settingsFile); + const nextSettings = claudeCodeNextSettings(settings, env, authMode, helperResult?.file); + const managedEnvKeys = claudeCodeManagedSettingsEnvKeys(profileEnvValues, mcpConfigEnv, timezoneEnv, wifEnv); const writeResult = writeClaudeCodeSettingsIfManagedChanged(settingsFile, settings, nextSettings, managedEnvKeys); - const changed = writeResult.changed || helperResult.changed || wrapperResult.changed || toolHubMcpConfigResult.changed; + const changed = writeResult.changed || + Boolean(helperResult?.changed) || + wifResult.changed || + wrapperResult.changed || + toolHubMcpConfigResult.changed || + legacyApiKeyHelperCleanupResult.changed || + autoCompactResult.changed; return { appliedAt, - backupFile: writeResult.backupFile ?? helperResult.backupFile ?? wrapperResult.backupFile, + backupFile: writeResult.backupFile ?? wrapperResult.backupFile ?? autoCompactResult.backupFile, client: "claude-code", enabled: true, message: changed @@ -1054,10 +1109,47 @@ function buildSeparateCodexProfileToml( return ensureTrailingNewline(`${rootBlock}${trimLeadingBlankLines(cleanedRoot)}${restSource}`.replace(/\n{4,}/g, "\n\n\n")); } -function writeClaudeCodeApiKeyHelper(profile: ProfileConfig, token: string): { backupFile?: string; changed: boolean; file: string } { +function claudeCodeNextSettings( + settings: Record, + env: Record, + authMode: ClaudeCodeGatewayAuthMode, + apiKeyHelperFile: string | undefined +): Record { + if (authMode === "api-key-helper" && apiKeyHelperFile) { + return { + ...settings, + apiKeyHelper: process.platform === "win32" ? `"${apiKeyHelperFile}"` : apiKeyHelperFile, + env + }; + } + return { + ...withoutManagedClaudeCodeApiKeyHelper(settings), + env + }; +} + +function writeClaudeCodeWifIdentityToken(profile: ProfileConfig, token: string): { changed: boolean; file: string } { const binDir = path.join(CONFIGDIR, "bin"); mkdirSync(binDir, { mode: privateDirMode, recursive: true }); - const file = path.join(binDir, claudeCodeApiKeyHelperFilename(profile)); + const file = path.join(binDir, claudeCodeWifIdentityTokenFilename(profile)); + const writeResult = writeGeneratedFileIfChanged(file, `${token}\n`, { mode: privateFileMode }); + return { + changed: writeResult.changed, + file + }; +} + +function claudeCodeWifIdentityTokenFilename(profile: ProfileConfig): string { + const slug = sanitizeProfilePathSegment(profile.id || profile.name || profile.agent) || "claude-code"; + return process.platform === "win32" + ? `ccr-claude-code-wif-token-${slug}.txt` + : `ccr-claude-code-wif-token-${slug}`; +} + +function writeClaudeCodeApiKeyHelper(profile: ProfileConfig, token: string): { changed: boolean; file: string } { + const binDir = path.join(CONFIGDIR, "bin"); + mkdirSync(binDir, { mode: privateDirMode, recursive: true }); + const file = path.join(binDir, claudeCodeLegacyApiKeyHelperFilename(profile)); const content = process.platform === "win32" ? claudeCodeApiKeyHelperCmdScript(token) : claudeCodeApiKeyHelperShellScript(token); @@ -1068,7 +1160,16 @@ function writeClaudeCodeApiKeyHelper(profile: ProfileConfig, token: string): { b }; } -function claudeCodeApiKeyHelperFilename(profile: ProfileConfig): string { +function cleanupClaudeCodeLegacyApiKeyHelper(profile: ProfileConfig): { changed: boolean } { + const file = path.join(CONFIGDIR, "bin", claudeCodeLegacyApiKeyHelperFilename(profile)); + if (!existsSync(file)) { + return { changed: false }; + } + rmSync(file, { force: true }); + return { changed: true }; +} + +function claudeCodeLegacyApiKeyHelperFilename(profile: ProfileConfig): string { const slug = sanitizeProfilePathSegment(profile.id || profile.name || profile.agent) || "claude-code"; return process.platform === "win32" ? `ccr-claude-code-api-key-${slug}.cmd` @@ -1091,15 +1192,42 @@ function claudeCodeApiKeyHelperCmdScript(token: string): string { ].join("\r\n"); } -function writeClaudeCodeWrapper(config: AppConfig, profile: ProfileConfig, apiKeyHelperFile: string, mcpConfigFile: string | undefined): { backupFile?: string; changed: boolean; file: string } { +function cleanupClaudeCodeGeneratedFiles(profile: ProfileConfig): { changed: boolean } { + const binDir = path.join(CONFIGDIR, "bin"); + let changed = false; + for (const fileName of [claudeCodeLegacyApiKeyHelperFilename(profile), claudeCodeWifIdentityTokenFilename(profile), claudeCodeWrapperFilename(profile)]) { + const file = path.join(binDir, fileName); + if (!existsSync(file)) { + continue; + } + rmSync(file, { force: true }); + changed = true; + } + return { changed }; +} + +function claudeCodeWifEnv(identityTokenFile: string): Record { + return { + ANTHROPIC_FEDERATION_RULE_ID: claudeCodeWifFederationRuleId, + ANTHROPIC_IDENTITY_TOKEN_FILE: identityTokenFile, + ANTHROPIC_ORGANIZATION_ID: claudeCodeWifOrganizationId + }; +} + +function writeClaudeCodeWrapper( + config: AppConfig, + profile: ProfileConfig, + auth: { remoteSyncApiKeyFile: string; wifIdentityTokenFile?: string }, + mcpConfigFile: string | undefined +): { backupFile?: string; changed: boolean; file: string } { const binDir = path.join(CONFIGDIR, "bin"); mkdirSync(binDir, { mode: privateDirMode, recursive: true }); const runtimeFile = path.join(binDir, codexMiddlewareRuntimeFilename()); const runtimeResult = writeGeneratedFileIfChanged(runtimeFile, codexCliMiddlewareRuntimeScript(), { mode: publicExecutableMode }); const file = path.join(binDir, claudeCodeWrapperFilename(profile)); const content = process.platform === "win32" - ? claudeCodeWrapperCmdScript(config, profile, runtimeFile, apiKeyHelperFile, mcpConfigFile) - : claudeCodeWrapperShellScript(config, profile, runtimeFile, apiKeyHelperFile, mcpConfigFile); + ? claudeCodeWrapperCmdScript(config, profile, runtimeFile, auth, mcpConfigFile) + : claudeCodeWrapperShellScript(config, profile, runtimeFile, auth, mcpConfigFile); const writeResult = writeGeneratedFileIfChanged(file, content, { mode: privateExecutableMode }); return { changed: writeResult.changed || runtimeResult.changed, @@ -1114,19 +1242,26 @@ function claudeCodeWrapperFilename(profile: ProfileConfig): string { : `ccr-claude-code-wrapper-${slug}`; } -function claudeCodeWrapperShellScript(config: AppConfig, profile: ProfileConfig, runtimeFile: string, apiKeyHelperFile: string, mcpConfigFile: string | undefined): string { +function claudeCodeWrapperShellScript( + config: AppConfig, + profile: ProfileConfig, + runtimeFile: string, + auth: { remoteSyncApiKeyFile: string; wifIdentityTokenFile?: string }, + mcpConfigFile: string | undefined +): string { const realClaude = profile.env?.CCR_CLAUDE_CODE_BIN?.trim() || "claude"; const surface = normalizeProfileSurface(profile.surface); const remoteEndpoint = `${gatewayEndpoint(config)}/__ccr/remote`; const settingsDir = path.dirname(resolveClaudeCodeSettingsFile(profile)); const envExports = Object.entries(profileEnv(profile)) - .filter(([key]) => key !== "CCR_CLAUDE_CODE_BIN" && !isClaudeCodeManagedModelEnvKey(key)) + .filter(([key]) => key !== "CCR_CLAUDE_CODE_BIN" && !isClaudeCodeManagedModelEnvKey(key) && !isClaudeCodeFirstPartyProviderEnvKey(key) && !isClaudeCodeWifEnvKey(key)) .map(([key, value]) => `export ${key}=${shellQuote(value)}`); const botEnvExports = shellBotGatewayEnvExports(config, profile); return [ "#!/bin/sh", ...envExports, - ...shellEnvExports(claudeCodeRuntimeEnv(config, profile, settingsDir)), + ...claudeCodeFirstPartyProviderEnvKeys.map((key) => `unset ${key}`), + ...shellEnvExports(claudeCodeRuntimeEnv(config, profile, settingsDir, auth.wifIdentityTokenFile)), ...shellEnvExports(claudeCodeMcpConfigEnv(mcpConfigFile)), ...shellEnvExports(claudeCodeUtcTimezoneEnvOverride()), `: "\${CCR_PROFILE_SURFACE:=${surface}}"`, @@ -1137,28 +1272,35 @@ function claudeCodeWrapperShellScript(config: AppConfig, profile: ProfileConfig, `export CODEXL_CLAUDE_CODE_BIN=${shellQuote(realClaude)}`, `if [ -z "\${CCR_REMOTE_SYNC_ENABLED:-}" ]; then CCR_REMOTE_SYNC_ENABLED=1; fi`, `if [ -z "\${CCR_REMOTE_SYNC_ENDPOINT:-}" ]; then CCR_REMOTE_SYNC_ENDPOINT=${shellQuote(remoteEndpoint)}; fi`, - `if [ -z "\${CCR_REMOTE_SYNC_API_KEY_HELPER:-}" ]; then CCR_REMOTE_SYNC_API_KEY_HELPER=${shellQuote(apiKeyHelperFile)}; fi`, + `if [ -z "\${CCR_REMOTE_SYNC_API_KEY_FILE:-}" ]; then CCR_REMOTE_SYNC_API_KEY_FILE=${shellQuote(auth.remoteSyncApiKeyFile)}; fi`, `if [ -z "\${CCR_REMOTE_SYNC_PROFILE_ID:-}" ]; then CCR_REMOTE_SYNC_PROFILE_ID=${shellQuote(profile.id || profile.name || "claude-code")}; fi`, `if [ -z "\${CCR_REMOTE_SYNC_PROFILE_NAME:-}" ]; then CCR_REMOTE_SYNC_PROFILE_NAME=${shellQuote(profile.name || profile.id || "Claude Code")}; fi`, - "export CCR_REMOTE_SYNC_ENABLED CCR_REMOTE_SYNC_ENDPOINT CCR_REMOTE_SYNC_API_KEY_HELPER CCR_REMOTE_SYNC_PROFILE_ID CCR_REMOTE_SYNC_PROFILE_NAME", + "export CCR_REMOTE_SYNC_ENABLED CCR_REMOTE_SYNC_ENDPOINT CCR_REMOTE_SYNC_API_KEY_FILE CCR_REMOTE_SYNC_PROFILE_ID CCR_REMOTE_SYNC_PROFILE_NAME", ...nodeRuntimeShellExecLines(runtimeFile), "" ].join("\n"); } -function claudeCodeWrapperCmdScript(config: AppConfig, profile: ProfileConfig, runtimeFile: string, apiKeyHelperFile: string, mcpConfigFile: string | undefined): string { +function claudeCodeWrapperCmdScript( + config: AppConfig, + profile: ProfileConfig, + runtimeFile: string, + auth: { remoteSyncApiKeyFile: string; wifIdentityTokenFile?: string }, + mcpConfigFile: string | undefined +): string { const realClaude = profile.env?.CCR_CLAUDE_CODE_BIN?.trim() || "claude"; const surface = normalizeProfileSurface(profile.surface); const remoteEndpoint = `${gatewayEndpoint(config)}/__ccr/remote`; const settingsDir = path.dirname(resolveClaudeCodeSettingsFile(profile)); const envExports = Object.entries(profileEnv(profile)) - .filter(([key]) => key !== "CCR_CLAUDE_CODE_BIN" && !isClaudeCodeManagedModelEnvKey(key)) + .filter(([key]) => key !== "CCR_CLAUDE_CODE_BIN" && !isClaudeCodeManagedModelEnvKey(key) && !isClaudeCodeFirstPartyProviderEnvKey(key) && !isClaudeCodeWifEnvKey(key)) .map(([key, value]) => cmdSetLine(key, value)); const botEnvExports = cmdBotGatewayEnvExports(config, profile); return [ "@echo off", ...envExports, - ...cmdEnvExports(claudeCodeRuntimeEnv(config, profile, settingsDir)), + ...claudeCodeFirstPartyProviderEnvKeys.map((key) => cmdSetLine(key, "")), + ...cmdEnvExports(claudeCodeRuntimeEnv(config, profile, settingsDir, auth.wifIdentityTokenFile)), ...cmdEnvExports(claudeCodeMcpConfigEnv(mcpConfigFile)), ...cmdEnvExports(claudeCodeUtcTimezoneEnvOverride()), `if not defined CCR_PROFILE_SURFACE ${cmdSetLine("CCR_PROFILE_SURFACE", surface)}`, @@ -1168,7 +1310,7 @@ function claudeCodeWrapperCmdScript(config: AppConfig, profile: ProfileConfig, r cmdSetLine("CODEXL_CLAUDE_CODE_BIN", realClaude), `if not defined CCR_REMOTE_SYNC_ENABLED ${cmdSetLine("CCR_REMOTE_SYNC_ENABLED", "1")}`, `if not defined CCR_REMOTE_SYNC_ENDPOINT ${cmdSetLine("CCR_REMOTE_SYNC_ENDPOINT", remoteEndpoint)}`, - `if not defined CCR_REMOTE_SYNC_API_KEY_HELPER ${cmdSetLine("CCR_REMOTE_SYNC_API_KEY_HELPER", apiKeyHelperFile)}`, + `if not defined CCR_REMOTE_SYNC_API_KEY_FILE ${cmdSetLine("CCR_REMOTE_SYNC_API_KEY_FILE", auth.remoteSyncApiKeyFile)}`, `if not defined CCR_REMOTE_SYNC_PROFILE_ID ${cmdSetLine("CCR_REMOTE_SYNC_PROFILE_ID", profile.id || profile.name || "claude-code")}`, `if not defined CCR_REMOTE_SYNC_PROFILE_NAME ${cmdSetLine("CCR_REMOTE_SYNC_PROFILE_NAME", profile.name || profile.id || "Claude Code")}`, ...nodeRuntimeCmdExecLines(runtimeFile), @@ -1979,7 +2121,7 @@ function writeCodexCliMiddleware( }; } -function claudeCodeRuntimeEnv(config: AppConfig, profile: ProfileConfig, settingsDir: string): Record { +function claudeCodeRuntimeEnv(config: AppConfig, profile: ProfileConfig, settingsDir: string, wifIdentityTokenFile?: string): Record { const endpoint = gatewayEndpoint(config); const env: Record = { ANTHROPIC_API_BASE_URL: endpoint, @@ -1987,10 +2129,76 @@ function claudeCodeRuntimeEnv(config: AppConfig, profile: ProfileConfig, setting CLAUDE_AGENT_API_BASE_URL: endpoint, CLAUDE_CONFIG_DIR: settingsDir }; - Object.assign(env, claudeCodeModelEnv(profile)); + if (wifIdentityTokenFile) { + Object.assign(env, claudeCodeWifEnv(wifIdentityTokenFile)); + } + Object.assign(env, claudeCodeProfileModelEnv(config, profile)); return env; } +function claudeCodeProfileModelEnv(config: AppConfig, profile: ProfileConfig): Record { + return claudeCodeModelEnv(claudeCodeProfileModelSelection(config, profile)); +} + +function claudeCodeProfileModelSelection(config: AppConfig, profile: ProfileConfig): ClaudeCodeModelSelection { + const autoCompactWindows = createClaudeCliAutoCompactWindows(config); + return { + fableModel: claudeCodeOneMillionContextModel(autoCompactWindows, profile.fableModel), + haikuModel: claudeCodeOneMillionContextModel(autoCompactWindows, profile.haikuModel), + model: claudeCodeOneMillionContextModel(autoCompactWindows, profile.model), + opusModel: claudeCodeOneMillionContextModel(autoCompactWindows, profile.opusModel), + smallFastModel: claudeCodeOneMillionContextModel(autoCompactWindows, profile.smallFastModel), + sonnetModel: claudeCodeOneMillionContextModel(autoCompactWindows, profile.sonnetModel) + }; +} + +function claudeCodeOneMillionContextModel(autoCompactWindows: Record, model: string | undefined): string | undefined { + const normalized = normalizeClaudeCodeClientModel(model); + if (!normalized) { + return model; + } + if (hasClaudeCodeOneMillionContextSuffix(normalized)) { + return normalized; + } + const compactWindow = autoCompactWindows[normalized] ?? autoCompactWindows[normalized.toLowerCase()]; + return compactWindow !== undefined && compactWindow >= 1_000_000 + ? `${normalized}${claudeCodeOneMillionContextSuffix}` + : normalized; +} + +function hasClaudeCodeOneMillionContextSuffix(model: string): boolean { + return model.trim().toLowerCase().endsWith(claudeCodeOneMillionContextSuffix); +} + +function writeClaudeCodeAutoCompactWindowsCache( + config: AppConfig, + settingsFile: string +): { backupFile?: string; changed: boolean; file: string } { + const file = path.join(path.dirname(settingsFile), ".claude.json"); + const current = readClaudeCodeGlobalConfigObject(file); + const next = { + ...current, + autoCompactWindowsCache: createClaudeCliAutoCompactWindows(config) + }; + const writeResult = writeFileWithBackup(file, `${JSON.stringify(next, null, 2)}\n`, { mode: privateFileMode }); + return { ...writeResult, file }; +} + +function readClaudeCodeGlobalConfigObject(file: string): Record { + if (!existsSync(file)) { + return {}; + } + try { + const parsed = JSON.parse(readFileSync(file, "utf8")) as unknown; + if (isRecord(parsed)) { + return parsed; + } + throw new Error("root value is not an object"); + } catch (error) { + throw new Error(`Claude Code global config file is not valid JSON: ${file}. ${formatError(error)}`); + } +} + function codexMiddlewareRuntimeFilename(): string { return "ccr-codex-cli-middleware.js"; } @@ -2490,16 +2698,31 @@ function claudeCodeSettingsManagedFieldsChanged( return false; } +function withoutManagedClaudeCodeApiKeyHelper(settings: Record): Record { + const nextSettings = { ...settings }; + if (isManagedClaudeCodeApiKeyHelper(nextSettings.apiKeyHelper)) { + delete nextSettings.apiKeyHelper; + } + return nextSettings; +} + +function isManagedClaudeCodeApiKeyHelper(value: unknown): boolean { + return typeof value === "string" && value.includes("ccr-claude-code-api-key-"); +} + function claudeCodeManagedSettingsEnvKeys( profileEnvValues: Record, mcpConfigEnv: Record, - timezoneEnv: Record + timezoneEnv: Record, + wifEnv: Record ): Set { return new Set([ ...claudeCodeGatewayEnvKeys, - ...claudeCodeRemovedAuthEnvKeys, + ...claudeCodeFirstPartyProviderEnvKeys, + ...claudeCodeWifEnvKeys, ...Object.keys(profileEnvValues), ...Object.keys(mcpConfigEnv), + ...Object.keys(wifEnv), ...Object.keys(timezoneEnv), CLAUDE_CODE_MCP_CONFIG_ENV, CODEXL_CLAUDE_CODE_MCP_CONFIG_ENV @@ -2508,7 +2731,8 @@ function claudeCodeManagedSettingsEnvKeys( function isManagedClaudeCodeSettingsEnvKey(key: string): boolean { return (claudeCodeGatewayEnvKeys as readonly string[]).includes(key) || - (claudeCodeRemovedAuthEnvKeys as readonly string[]).includes(key) || + isClaudeCodeFirstPartyProviderEnvKey(key) || + isClaudeCodeWifEnvKey(key) || key === CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV || key === CLAUDE_CODE_MCP_CONFIG_ENV || key === CODEXL_CLAUDE_CODE_MCP_CONFIG_ENV || @@ -2516,6 +2740,14 @@ function isManagedClaudeCodeSettingsEnvKey(key: string): boolean { isBotGatewayEnvKey(key); } +function isClaudeCodeFirstPartyProviderEnvKey(key: string): boolean { + return (claudeCodeFirstPartyProviderEnvKeys as readonly string[]).includes(key); +} + +function isClaudeCodeWifEnvKey(key: string): boolean { + return (claudeCodeWifEnvKeys as readonly string[]).includes(key); +} + function writeFileWithBackup( file: string, content: string, @@ -2625,6 +2857,39 @@ function cleanupInactiveKiloWrappers(profiles: ProfileConfig[]): number { return removed; } +function cleanupInactiveClaudeCodeGeneratedFiles(profiles: ProfileConfig[]): number { + const binDir = path.join(CONFIGDIR, "bin"); + const activeFiles = new Set(profiles + .filter((profile) => profile.agent === "claude-code" && profile.enabled) + .flatMap((profile) => [ + ...(resolveClaudeCodeGatewayAuthMode(profile) === "api-key-helper" ? [claudeCodeLegacyApiKeyHelperFilename(profile)] : []), + claudeCodeWifIdentityTokenFilename(profile), + claudeCodeWrapperFilename(profile) + ])); + let entries: string[]; + try { + entries = readdirSync(binDir); + } catch { + return 0; + } + + let removed = 0; + for (const entry of entries) { + if (!isClaudeCodeGeneratedRuntimeFile(entry) || activeFiles.has(entry)) { + continue; + } + rmSync(path.join(binDir, entry), { force: true }); + removed += 1; + } + return removed; +} + +function isClaudeCodeGeneratedRuntimeFile(fileName: string): boolean { + return fileName.startsWith("ccr-claude-code-api-key-") || + fileName.startsWith("ccr-claude-code-wif-token-") || + fileName.startsWith("ccr-claude-code-wrapper-"); +} + function generatedBinBackupBaseName(entry: string): string | undefined { const backupMarker = ".ccr-backup-"; const backupIndex = entry.indexOf(backupMarker); @@ -2647,6 +2912,7 @@ function isManagedGeneratedBinFile(fileName: string): boolean { normalized === TOOL_HUB_MCP_RUNTIME_FILE_NAME || normalized === codexMiddlewareRuntimeFilename() || normalized.startsWith("ccr-claude-code-api-key-") || + normalized.startsWith("ccr-claude-code-wif-token-") || normalized.startsWith("ccr-claude-code-wrapper-") || normalized.startsWith("ccr-grok-cli-wrapper-") || normalized.startsWith("ccr-kimi-cli-wrapper-") || @@ -3213,8 +3479,7 @@ function isManagedClaudeCodeSettingsContent(content: string): boolean { if (!isPureManagedClaudeCodeSettings(settings)) { return false; } - const apiKeyHelper = typeof settings.apiKeyHelper === "string" ? settings.apiKeyHelper : ""; - if (apiKeyHelper.includes("ccr-claude-code-api-key-")) { + if (isManagedClaudeCodeApiKeyHelper(settings.apiKeyHelper)) { return true; } const env = isRecord(settings.env) ? settings.env : {}; @@ -3413,6 +3678,9 @@ function defaultCodexCompatibleHome(agent: ProfileConfig["agent"], configFile: s function profileEnv(profile: ProfileConfig): Record { return stringRecord(profile.env).filter(([key]) => isEnvName(key)).reduce>((result, [key, value]) => { + if (key === CLAUDE_CODE_AUTH_MODE_ENV) { + return result; + } if (profile.agent !== "claude-code" && key === CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY_ENV) { return result; } diff --git a/packages/core/test/integration/profiles/profile-service.test.mjs b/packages/core/test/integration/profiles/profile-service.test.mjs index 6143137a..da31543f 100644 --- a/packages/core/test/integration/profiles/profile-service.test.mjs +++ b/packages/core/test/integration/profiles/profile-service.test.mjs @@ -57,6 +57,7 @@ test("profile service cleans stale generated bin backups only", () => { mkdirSync(binDir, { recursive: true }); const deletedFiles = [ "ccr-claude-code-api-key-default.ccr-backup-2026-01-01T00-00-00-000Z", + "ccr-claude-code-wif-token-default.ccr-backup-2026-01-01T00-00-00-000Z", "ccr-claude-code-wrapper-default.ccr-original", "ccr-codex-cli-stdio-default.ccr-original-missing", "ccr-codex-cli-middleware.js.ccr-backup-2026-01-01T00-00-00-000Z", @@ -114,7 +115,9 @@ test("profile service preserves user statusLine when the active global Claude ta const profile = { agent: "claude-code", enabled: true, - env: {}, + env: { + CCR_CLAUDE_CODE_AUTH_MODE: "wif" + }, id: "default-claude-code", model: "Provider/model", name: "Claude Code", @@ -156,6 +159,7 @@ test("profile service preserves user statusLine when the active global Claude ta command: "ccstatusline", type: "command" }); + assert.equal(current.apiKeyHelper, undefined); assert.equal(current.currentOnly, "kept"); assert.equal(current.theme, undefined); assert.equal(current.env.USER_VALUE, "kept"); @@ -370,7 +374,9 @@ test("profile service does not rewrite Claude settings when only user-managed fi const profile = { agent: "claude-code", enabled: true, - env: {}, + env: { + CCR_CLAUDE_CODE_AUTH_MODE: "wif" + }, id: "user-fields-claude-settings", model: "Provider/model", name: "User Fields Claude Settings", @@ -398,6 +404,7 @@ test("profile service does not rewrite Claude settings when only user-managed fi const initialResult = await applyProfileConfig(config); assert.equal(initialResult.clients.some((client) => client.client === "claude-code" && client.ok), true); const settings = JSON.parse(readFileSync(settingsFile, "utf8")); + settings.apiKeyHelper = "/usr/local/bin/custom-claude-helper"; settings.disableAllHooks = true; settings.statusLine = { command: "ccstatusline", @@ -463,26 +470,46 @@ test("profile service can exclude ZCode from automatic synchronization", async ( test("profile service overwrites generated bin files without creating backups", { skip: !process.env.CCR_INTERNAL_HOME_DIR }, async () => { const profileId = "generated-bin-test"; const commandExtension = process.platform === "win32" ? ".cmd" : ""; + const wifTokenExtension = process.platform === "win32" ? ".txt" : ""; const binDir = path.join(CONFIGDIR, "bin"); mkdirSync(binDir, { recursive: true }); + const legacyApiKeyHelperFile = path.join(binDir, `ccr-claude-code-api-key-${profileId}${commandExtension}`); const generatedFiles = [ - path.join(binDir, `ccr-claude-code-api-key-${profileId}${commandExtension}`), + path.join(binDir, `ccr-claude-code-wif-token-${profileId}${wifTokenExtension}`), path.join(binDir, `ccr-claude-code-wrapper-${profileId}${commandExtension}`), path.join(binDir, "ccr-codex-cli-middleware.js"), path.join(binDir, "toolhub-mcp.js") ]; + const staleClaudeCodeGeneratedFiles = [ + path.join(binDir, `ccr-claude-code-wif-token-stale-claude-code${wifTokenExtension}`), + path.join(binDir, `ccr-claude-code-wrapper-stale-claude-code${commandExtension}`) + ]; + writeFileSync(legacyApiKeyHelperFile, "old generated content\n"); + writeFileSync(`${legacyApiKeyHelperFile}.ccr-backup-2026-01-01T00-00-00-000Z`, "old backup\n"); + writeFileSync(`${legacyApiKeyHelperFile}.ccr-original`, "old original\n"); for (const file of generatedFiles) { writeFileSync(file, "old generated content\n"); writeFileSync(`${file}.ccr-backup-2026-01-01T00-00-00-000Z`, "old backup\n"); writeFileSync(`${file}.ccr-original`, "old original\n"); } + for (const file of staleClaudeCodeGeneratedFiles) { + writeFileSync(file, "stale generated content\n"); + } const config = createDefaultAppConfig(); config.Providers = [ { api_base_url: "https://example.test/v1", api_key: "provider-key", - models: ["model"], + modelMetadata: { + model: { + contextWindow: 258400 + }, + long: { + contextWindow: 1_000_000 + } + }, + models: ["model", "long"], name: "Provider" } ]; @@ -521,11 +548,16 @@ test("profile service overwrites generated bin files without creating backups", { agent: "claude-code", enabled: true, - env: {}, + env: { + ANTHROPIC_API_KEY: "profile-api-key", + ANTHROPIC_AUTH_TOKEN: "profile-auth-token", + CCR_CLAUDE_CODE_AUTH_MODE: "wif", + CLAUDE_CODE_USE_GATEWAY: "1" + }, fableModel: "Provider/fable", haikuModel: "Provider/haiku", id: profileId, - model: "Provider/model", + model: "Provider/long", name: "Generated Bin Test", opusModel: "Provider/opus", scope: "ccr", @@ -535,13 +567,35 @@ test("profile service overwrites generated bin files without creating backups", surface: "auto" } ]; + const claudeProfileDir = path.join(CONFIGDIR, "profiles", profileId, "claude"); + mkdirSync(claudeProfileDir, { recursive: true }); + writeFileSync(path.join(claudeProfileDir, ".claude.json"), `${JSON.stringify({ firstStartTime: "2026-01-01T00:00:00.000Z" }, null, 2)}\n`); const result = await applyProfileConfig(config); assert.equal(result.clients.length, 1); assert.equal(result.clients[0].ok, true); + assert.equal(existsSync(legacyApiKeyHelperFile), false); for (const file of generatedFiles) { assert.notEqual(readFileSync(file, "utf8"), "old generated content\n"); } + for (const file of staleClaudeCodeGeneratedFiles) { + assert.equal(existsSync(file), false); + } + const wifTokenFile = path.join(binDir, `ccr-claude-code-wif-token-${profileId}${wifTokenExtension}`); + assert.equal(readFileSync(wifTokenFile, "utf8"), "ccr-profile-test\n"); + const wrapperContent = readFileSync(path.join(binDir, `ccr-claude-code-wrapper-${profileId}${commandExtension}`), "utf8"); + assert.equal(wrapperContent.includes("profile-api-key"), false); + assert.equal(wrapperContent.includes("profile-auth-token"), false); + assert.equal(wrapperContent.includes("CCR_REMOTE_SYNC_API_KEY_HELPER"), false); + assert.equal(wrapperContent.includes("CCR_REMOTE_SYNC_API_KEY_FILE"), true); + assert.equal(wrapperContent.includes("ANTHROPIC_FEDERATION_RULE_ID"), true); + assert.equal(wrapperContent.includes("ANTHROPIC_IDENTITY_TOKEN_FILE"), true); + assert.equal(wrapperContent.includes("CLAUDE_CODE_USE_GATEWAY=1"), false); + if (process.platform === "win32") { + assert.match(wrapperContent, /CLAUDE_CODE_USE_GATEWAY="?/); + } else { + assert.match(wrapperContent, /unset CLAUDE_CODE_USE_GATEWAY/); + } const toolHubMcpConfigFile = path.join(CONFIGDIR, "profiles", profileId, "claude", "toolhub-mcp.json"); const toolHubMcpConfig = JSON.parse(readFileSync(toolHubMcpConfigFile, "utf8")); const toolHubMcpServerEnv = toolHubMcpConfig.mcpServers["ccr-toolhub"].env; @@ -550,17 +604,35 @@ test("profile service overwrites generated bin files without creating backups", assert.equal(toolHubMcpServerEnv.TOOLHUB_OPENAI_BASE_URL, `http://127.0.0.1:${config.gateway.port}/v1`); assert.equal(toolHubMcpServerEnv.TOOLHUB_OPENAI_MODEL, "Provider/model"); assert.equal(contextArchiveMcpServer, undefined); - const settingsFile = path.join(CONFIGDIR, "profiles", profileId, "claude", "settings.json"); + const settingsFile = path.join(claudeProfileDir, "settings.json"); const settings = JSON.parse(readFileSync(settingsFile, "utf8")); - assert.equal(settings.env.ANTHROPIC_MODEL, "Provider/model"); + assert.equal(settings.apiKeyHelper, undefined); + assert.equal(settings.env.ANTHROPIC_FEDERATION_RULE_ID, "ccr-local"); + assert.equal(settings.env.ANTHROPIC_ORGANIZATION_ID, "ccr-local"); + assert.equal(settings.env.ANTHROPIC_IDENTITY_TOKEN_FILE, wifTokenFile); + assert.equal(settings.env.ANTHROPIC_MODEL, "Provider/long[1m]"); + assert.equal(settings.env.CCR_CLAUDE_CODE_MODEL, "Provider/long[1m]"); + assert.equal(settings.env.CODEXL_CLAUDE_CODE_MODEL, "Provider/long[1m]"); assert.equal(settings.env.ANTHROPIC_DEFAULT_FABLE_MODEL, "Provider/fable"); assert.equal(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL, "Provider/opus"); assert.equal(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL, "Provider/sonnet"); assert.equal(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL, "Provider/haiku"); assert.equal(settings.env.ANTHROPIC_SMALL_FAST_MODEL, undefined); + assert.equal(settings.env.CLAUDE_CODE_USE_GATEWAY, undefined); + assert.equal(settings.env.ANTHROPIC_AUTH_TOKEN, undefined); + assert.equal(settings.env.ANTHROPIC_API_KEY, undefined); + assert.equal(settings.modelOverrides, undefined); + const claudeGlobalConfig = JSON.parse(readFileSync(path.join(claudeProfileDir, ".claude.json"), "utf8")); + assert.equal(claudeGlobalConfig.firstStartTime, "2026-01-01T00:00:00.000Z"); + assert.equal(claudeGlobalConfig.autoCompactWindowsCache["Provider/model"], 258400); + assert.equal(claudeGlobalConfig.autoCompactWindowsCache["provider/model"], 258400); + assert.equal(claudeGlobalConfig.autoCompactWindowsCache["Provider/long"], 1_000_000); + assert.equal(claudeGlobalConfig.autoCompactWindowsCache["Provider/long[1m]"], 1_000_000); + assert.equal(claudeGlobalConfig.modelOverrides, undefined); const backupEntries = readdirSync(binDir).filter((entry) => ( entry.startsWith(`ccr-claude-code-api-key-${profileId}`) || + entry.startsWith(`ccr-claude-code-wif-token-${profileId}`) || entry.startsWith(`ccr-claude-code-wrapper-${profileId}`) || entry.startsWith("ccr-codex-cli-middleware.js") || entry.startsWith("toolhub-mcp.js") @@ -569,6 +641,72 @@ test("profile service overwrites generated bin files without creating backups", assert.deepEqual(backupEntries, []); }); +test("profile service falls back to apiKeyHelper when Claude Code WIF support is not detected", { skip: !process.env.CCR_INTERNAL_HOME_DIR }, async () => { + const root = mkdtempSync(path.join(os.tmpdir(), "ccr-claude-auth-mode-")); + const profileId = "old-claude-code"; + const commandExtension = process.platform === "win32" ? ".cmd" : ""; + const wifTokenExtension = process.platform === "win32" ? ".txt" : ""; + try { + const binDir = path.join(CONFIGDIR, "bin"); + const fakeClaude = path.join(root, process.platform === "win32" ? "claude.cmd" : "claude"); + mkdirSync(binDir, { recursive: true }); + writeFileSync(fakeClaude, process.platform === "win32" + ? "@echo off\r\necho 2.1.100\r\n" + : "#!/bin/sh\nprintf '%s\\n' '2.1.100'\n"); + chmodSync(fakeClaude, 0o700); + + const config = createDefaultAppConfig(); + config.APIKEY = "ccr-old-auto-token"; + config.APIKEYS = [{ + createdAt: "2026-01-01T00:00:00.000Z", + id: `profile:${profileId}`, + key: "ccr-old-auto-token", + name: "Profile: Old Claude Code" + }]; + config.Providers = [{ + api_base_url: "https://example.test/v1", + api_key: "provider-key", + models: ["model"], + name: "Provider" + }]; + config.profile.profiles = [{ + agent: "claude-code", + enabled: true, + env: { + CCR_CLAUDE_CODE_BIN: fakeClaude + }, + id: profileId, + model: "Provider/model", + name: "Old Claude Code", + scope: "ccr", + settingsFile: "~/.claude/settings.json", + smallFastModel: "", + surface: "auto" + }]; + + const result = await applyProfileConfig(config); + assert.equal(result.clients.length, 1); + assert.equal(result.clients[0].ok, true); + + const helperFile = path.join(binDir, `ccr-claude-code-api-key-${profileId}${commandExtension}`); + const tokenFile = path.join(binDir, `ccr-claude-code-wif-token-${profileId}${wifTokenExtension}`); + const settingsFile = path.join(CONFIGDIR, "profiles", profileId, "claude", "settings.json"); + const settings = JSON.parse(readFileSync(settingsFile, "utf8")); + assert.equal(settings.apiKeyHelper, process.platform === "win32" ? `"${helperFile}"` : helperFile); + assert.equal(settings.env.ANTHROPIC_IDENTITY_TOKEN_FILE, undefined); + assert.equal(settings.env.ANTHROPIC_FEDERATION_RULE_ID, undefined); + assert.equal(settings.env.CCR_CLAUDE_CODE_AUTH_MODE, undefined); + assert.equal(readFileSync(tokenFile, "utf8"), "ccr-old-auto-token\n"); + assert.match(readFileSync(helperFile, "utf8"), /ccr-old-auto-token/); + + const wrapperContent = readFileSync(path.join(binDir, `ccr-claude-code-wrapper-${profileId}${commandExtension}`), "utf8"); + assert.equal(wrapperContent.includes("ANTHROPIC_IDENTITY_TOKEN_FILE"), false); + assert.equal(wrapperContent.includes("CCR_REMOTE_SYNC_API_KEY_FILE"), true); + } finally { + rmSync(root, { force: true, recursive: true }); + } +}); + test("Codex profile launcher bypasses middleware for Browser and Computer Use helpers", { skip: process.platform === "win32" || !process.env.CCR_INTERNAL_HOME_DIR }, async () => { const root = mkdtempSync(path.join(os.tmpdir(), "ccr-browser-helper-bypass-")); const profileId = "browser-helper-bypass-test"; diff --git a/packages/core/test/unit/agents/claude-app-gateway-models.test.mjs b/packages/core/test/unit/agents/claude-app-gateway-models.test.mjs index 10d5308f..cd0da467 100644 --- a/packages/core/test/unit/agents/claude-app-gateway-models.test.mjs +++ b/packages/core/test/unit/agents/claude-app-gateway-models.test.mjs @@ -7,8 +7,10 @@ import { resolveClaudeAppGatewayRouteModel } from "@ccr/core/agents/claude-app/gateway-routes.ts"; import { + createClaudeCliBootstrapResponse, createGatewayModelsResponse, prepareClaudeAppDiscoveredModelRequest, + shouldServeClaudeCliBootstrapResponse, shouldServeGatewayModelsResponse } from "@ccr/core/gateway/features/model-discovery.ts"; import { ModelRegistry } from "@ccr/core/routing/model-registry.ts"; @@ -63,6 +65,12 @@ test("gateway model discovery supports the /models alias", () => { assert.equal(shouldServeGatewayModelsResponse("POST", "/models"), false); }); +test("Claude CLI bootstrap is exposed at the first-party bootstrap path", () => { + assert.equal(shouldServeClaudeCliBootstrapResponse("GET", "/api/claude_cli/bootstrap"), true); + assert.equal(shouldServeClaudeCliBootstrapResponse("GET", "/api/claude_cli/bootstrap/"), true); + assert.equal(shouldServeClaudeCliBootstrapResponse("POST", "/api/claude_cli/bootstrap"), false); +}); + test("issue 1535 Claude App discovery defaults to the first configured provider model", () => { const config = createConfig({ providers: [ @@ -267,6 +275,19 @@ test("Claude Code exposes a configured 1M context window as a visible model vari assert.equal(discoveredModel.max_input_tokens, 1_000_000); assert.equal(discoveredModel.capabilities.context_window.one_million_context_variant, true); + const bootstrap = createClaudeCliBootstrapResponse(config); + assert.equal(bootstrap.auto_compact_windows[discoveredModel.id], 1_000_000); + assert.equal(bootstrap.client_data.rowan_thicket[discoveredModel.id], 1_000_000); + assert.equal(bootstrap.additional_model_options[0].description, "1M context window"); + assert.equal(bootstrap.additional_model_options[0].display_name, "Zhipu AI (China) - Coding Plan/aaa (1M context)"); + assert.equal(bootstrap.additional_model_options[0].id, discoveredModel.id); + assert.equal(bootstrap.additional_model_options[0].max_input_tokens, 1_000_000); + assert.equal(bootstrap.additional_model_options[0].model, discoveredModel.id); + assert.equal(bootstrap.additional_model_options[0].name, "Zhipu AI (China) - Coding Plan/aaa (1M context)"); + assert.equal(bootstrap.additional_model_options[0].type, "model"); + assert.equal(bootstrap.additional_model_options[0].capabilities.context_window.max_input_tokens, 1_000_000); + assert.equal(bootstrap.additional_model_options[0].capabilities.context_window.one_million_context_variant, true); + const rewrite = prepareClaudeAppDiscoveredModelRequest( config, "POST", @@ -276,6 +297,38 @@ test("Claude Code exposes a configured 1M context window as a visible model vari assert.equal(rewrite?.routedModel, "Zhipu AI (China) - Coding Plan/aaa"); }); +test("Claude CLI bootstrap returns catalog-derived model configuration from models.json", () => { + const config = createConfig({ + providers: [ + { + models: ["glm-5.2"], + name: "Zhipu AI (China) - Coding Plan", + type: "openai_chat_completions" + } + ] + }); + const route = buildClaudeAppGatewayModelRoutes(config)[0]; + const bootstrap = createClaudeCliBootstrapResponse(config); + const option = bootstrap.additional_model_options[0]; + + assert.ok(route); + assert.equal(option.id, `${route.id}[1m]`); + assert.equal(option.model, `${route.id}[1m]`); + assert.equal(option.display_name, "Zhipu AI (China) - Coding Plan/GLM-5.2 (1M context)"); + assert.equal(option.max_input_tokens, 1_049_000); + assert.equal(option.max_tokens, 1_048_560); + assert.equal(option.capabilities.context_window.max_input_tokens, 1_049_000); + assert.equal(option.capabilities.context_management.max_input_tokens, 1_049_000); + assert.equal(option.capabilities.context_window.supports_1m_context, true); + assert.equal(option.capabilities.context_window.one_million_context_variant, true); + assert.equal(option.capabilities.image_input.supported, false); + assert.equal(option.capabilities.structured_outputs.supported, true); + assert.equal(option.capabilities.tool_use.supported, true); + assert.equal(option.capabilities.thinking.supported, true); + assert.equal(bootstrap.auto_compact_windows[option.model], 1_000_000); + assert.equal(bootstrap.client_data.rowan_thicket[option.model], 1_000_000); +}); + test("issue 1632 Claude discovery inherits Fusion fixed model provider context", () => { const config = createConfig({ providers: [ @@ -356,6 +409,72 @@ test("Claude App discovery publishes the effective provider context for uncatalo assert.equal(model.max_input_tokens, 258400); assert.equal(model.capabilities.context_management.max_input_tokens, 258400); assert.equal(model.capabilities.context_window.max_input_tokens, 258400); + + const bootstrap = createClaudeCliBootstrapResponse(config); + assert.equal(bootstrap.auto_compact_windows[model.id], 258400); + assert.equal(bootstrap.client_data.rowan_thicket[model.id], 258400); +}); + +test("Claude CLI bootstrap includes suffixed compact window keys for uncatalogued 1M models", () => { + const config = createConfig({ + providers: [ + { + modelMetadata: { + long: { + contextWindow: 1_000_000 + } + }, + models: ["long"], + name: "Provider", + type: "openai_responses" + } + ] + }); + const bootstrap = createClaudeCliBootstrapResponse(config); + + assert.equal(bootstrap.auto_compact_windows["Provider/long"], 1_000_000); + assert.equal(bootstrap.auto_compact_windows["provider/long"], 1_000_000); + assert.equal(bootstrap.auto_compact_windows["Provider/long[1m]"], 1_000_000); + assert.equal(bootstrap.auto_compact_windows["provider/long[1m]"], 1_000_000); + assert.equal(bootstrap.client_data.rowan_thicket["Provider/long[1m]"], 1_000_000); +}); + +test("Claude CLI bootstrap clamps compact windows to Claude Code's accepted range", () => { + const lowConfig = createConfig({ + providers: [ + { + modelMetadata: { + "custom-model": { + contextWindow: 64000 + } + }, + models: ["custom-model"], + name: "Custom", + type: "openai_responses" + } + ] + }); + const lowModel = createClaudeCodeModelsResponse(lowConfig).data[0]; + const lowBootstrap = createClaudeCliBootstrapResponse(lowConfig); + assert.equal(lowBootstrap.auto_compact_windows[lowModel.id], 100000); + + const highConfig = createConfig({ + providers: [ + { + modelMetadata: { + huge: { + contextWindow: 1_500_000 + } + }, + models: ["huge"], + name: "Huge", + type: "openai_responses" + } + ] + }); + const highModel = createClaudeCodeModelsResponse(highConfig).data[0]; + const highBootstrap = createClaudeCliBootstrapResponse(highConfig); + assert.equal(highBootstrap.auto_compact_windows[highModel.id], 1_000_000); }); test("Claude App discovery honors configured reasoning levels for uncatalogued models", () => { @@ -395,6 +514,45 @@ test("Claude App discovery honors configured reasoning levels for uncatalogued m assert.equal(model.capabilities.effort.ultra.supported, true); }); +test("Claude CLI bootstrap honors configured provider metadata over defaults", () => { + const config = createConfig({ + providers: [ + { + modelMetadata: { + "custom-model": { + capabilities: { imageInput: false }, + contextWindow: 500000, + maxOutputTokens: 12345, + supportedReasoningLevels: [ + { description: "Low", effort: "low" }, + { description: "High", effort: "high" } + ] + } + }, + models: ["custom-model"], + name: "Custom", + type: "openai_responses" + } + ] + }); + const response = createClaudeModelsResponse(config); + const bootstrap = createClaudeCliBootstrapResponse(config); + const option = bootstrap.additional_model_options[0]; + + assert.equal(response.data[0].max_input_tokens, 500000); + assert.equal(response.data[0].max_tokens, 12345); + assert.equal(option.max_input_tokens, 500000); + assert.equal(option.max_tokens, 12345); + assert.equal(option.capabilities.context_window.max_input_tokens, 500000); + assert.equal(option.capabilities.context_management.max_input_tokens, 500000); + assert.equal(option.capabilities.image_input.supported, false); + assert.equal(option.capabilities.thinking.supported, true); + assert.equal(option.capabilities.effort.low.supported, true); + assert.equal(option.capabilities.effort.medium.supported, false); + assert.equal(option.capabilities.effort.high.supported, true); + assert.equal(option.capabilities.effort.ultra.supported, false); +}); + test("Claude App discovery prefers provider context metadata over the static catalog", () => { const config = createConfig({ providers: [ diff --git a/packages/core/test/unit/gateway/api-key-authorizer.test.mjs b/packages/core/test/unit/gateway/api-key-authorizer.test.mjs index 8addd447..9b323efd 100644 --- a/packages/core/test/unit/gateway/api-key-authorizer.test.mjs +++ b/packages/core/test/unit/gateway/api-key-authorizer.test.mjs @@ -3,7 +3,7 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import test from "node:test"; import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts"; -import { authorize } from "@ccr/core/gateway/auth/api-key-authorizer.ts"; +import { authorize, exchangeClaudeCodeWifToken } from "@ccr/core/gateway/auth/api-key-authorizer.ts"; const authorizerSourceFile = path.join( process.cwd(), @@ -105,6 +105,40 @@ test("gateway authorization separates a missing token from an expired key", asyn assert.equal(expired.response.payload.error.message, "API key is expired."); }); +test("Claude Code WIF token exchange returns a bearer token for a configured profile key", async () => { + const config = configWithApiKeys([ + { createdAt: new Date(0).toISOString(), id: "profile:claude", key: gatewayApiKey } + ]); + + const result = await exchangeClaudeCodeWifToken(config, Buffer.from(JSON.stringify({ + assertion: gatewayApiKey, + federation_rule_id: "ccr-local", + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", + organization_id: "ccr-local" + }))); + + assert.equal(result.statusCode, 200); + assert.deepEqual(result.payload, { + access_token: gatewayApiKey, + expires_in: 3600, + token_type: "Bearer" + }); +}); + +test("Claude Code WIF token exchange rejects invalid assertions", async () => { + const config = configWithApiKeys([ + { createdAt: new Date(0).toISOString(), id: "profile:claude", key: gatewayApiKey } + ]); + + const result = await exchangeClaudeCodeWifToken(config, Buffer.from(new URLSearchParams({ + assertion: `${gatewayApiKey}-wrong`, + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer" + }).toString())); + + assert.equal(result.statusCode, 401); + assert.equal(result.payload.error, "invalid_grant"); +}); + // A constant-time comparison is behaviour-preserving by construction, so the // tests above pass on both sides of the change and only prove there is no // regression. The invariant itself is asserted on the module source, the same From 0d178f3732edbf23be92581958f3b40d5a9180d8 Mon Sep 17 00:00:00 2001 From: musi Date: Mon, 10 Aug 2026 08:34:21 +0800 Subject: [PATCH 02/18] Improve compact handoff fidelity and token limit sanitization --- .../src/gateway/context-archive/protocol.ts | 88 ++++++++++++++++--- .../features/context-archive-continuation.ts | 5 +- .../test/unit/agents/context-archive.test.mjs | 29 +++++- 3 files changed, 103 insertions(+), 19 deletions(-) diff --git a/packages/core/src/gateway/context-archive/protocol.ts b/packages/core/src/gateway/context-archive/protocol.ts index 0a39675c..fd1f679f 100644 --- a/packages/core/src/gateway/context-archive/protocol.ts +++ b/packages/core/src/gateway/context-archive/protocol.ts @@ -13,6 +13,15 @@ export const replayableArchiveProtocols: GatewayProviderProtocol[] = [ "openai_responses" ]; +const compactOutputTokenLimitKeys = [ + "max_completion_tokens", + "maxCompletionTokens", + "max_output_tokens", + "maxOutputTokens", + "max_tokens", + "maxTokens" +]; + export function parseArchiveBody(body: Buffer | undefined): JsonObject | undefined { if (!body?.length) { return undefined; @@ -59,6 +68,10 @@ function appendTask( removeCodexCompactionTriggers(next); } + if (options.compactHandoff && replaceClaudeCodeAutoCompactPrompt(next, protocol, task)) { + return Buffer.from(`${JSON.stringify(next)}\n`, "utf8"); + } + if (protocol === "openai_responses") { if (Array.isArray(next.input)) { next.input = [ @@ -98,10 +111,22 @@ export function compactHandoffTask(input: { const footer = archiveHandoffFooter(input); return [ "CCR compact handoff task:", - "You are the previous-context agent. Produce a concise handoff for a successor agent that will start with a fresh context.", - "Preserve the current goal, user constraints, decisions, changed files, completed verification, unresolved problems, and the exact next action.", - "Do not continue the task and do not call tools. Do not invent details.", - "End the handoff with the following archive access block exactly as written:", + "You are the previous-context agent. Produce a concise, action-oriented handoff for a successor agent that will start with a fresh context.", + "Do not solve new work. Do not call tools. Do not invent details. Use only the conversation and tool results already in context.", + "Do not expand scope: do not propose broad refactors, exhaustive new test suites, documentation work, or extra validation unless the user explicitly requested it or a failing check already requires it.", + "Keep the handoff under 1200 words unless an exact failing error needs more space.", + "", + "Write the handoff with these sections, in this order:", + "1. Current objective and status: restate the user's original goal, current branch/deliverable, and whether work is blocked, passing local checks, or unfinished.", + "2. Exact remaining blocker: include the latest failing command/test/error if any. If none is known, say none known.", + "3. Original acceptance points that still matter: at most 8 bullets copied from explicit user requirements or failing checks. Preserve public API/runtime-shape requirements literally, especially signatures, exports, named fields like TypeName(a, b), callback/event behavior, ordering, and error semantics. Do not invent extra goals.", + "4. Work completed (descriptive, not authoritative): changed files and important APIs/classes/functions added or modified. Mark uncertain implementation choices as current implementation details, not requirements.", + "5. Verification performed: exact commands/tests run and pass/fail results.", + "6. Exact next action: one to three smallest steps. If a blocker exists, fix that first. If local checks pass, do one focused review against the original acceptance points, including public runtime API shape (constructability, isinstance/class checks, attribute vs mapping access, exports), then finish the requested deliverable.", + "", + "Important: the successor must treat original user requirements and failing checks as higher authority than implementation details in this handoff. If they conflict, revise the implementation instead of preserving the summarized choice.", + "If a required detail is important but too large to include, name it explicitly and say that the successor must query archived history before acting on it.", + "Include the following archive access block exactly as written:", "", footer ].join("\n"); @@ -188,6 +213,50 @@ function hasClaudeCodeAutoCompactPrompt(body: JsonObject): boolean { ); } +function replaceClaudeCodeAutoCompactPrompt( + body: JsonObject, + protocol: GatewayProviderProtocol, + task: string +): boolean { + if (protocol !== "anthropic_messages" && protocol !== "openai_chat_completions") { + return false; + } + const messages = Array.isArray(body.messages) ? [...body.messages] : undefined; + if (!messages) { + return false; + } + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if ( + !isRecord(message) || + String(message.role ?? "") !== "user" || + !collectText(message.content).some(isClaudeCodeAutoCompactPromptText) + ) { + continue; + } + const nextMessage = cloneJsonObject(message); + nextMessage.content = protocol === "anthropic_messages" + ? [{ text: claudeCodeCompactHandoffTask(task), type: "text" }] + : claudeCodeCompactHandoffTask(task); + messages[index] = nextMessage; + body.messages = messages; + return true; + } + return false; +} + +function claudeCodeCompactHandoffTask(task: string): string { + return [ + "CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.", + "", + "Your response must be plain text: an block followed by a block.", + "Keep brief: identify what the continuation summary must preserve, but do not solve the task.", + "Put the CCR handoff in and follow this task exactly:", + "", + task + ].join("\n"); +} + function isClaudeCodeAutoCompactPromptText(text: string): boolean { const normalized = normalizeWhitespace(text); return normalized.includes("CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.") && @@ -369,6 +438,7 @@ function assertAppendableTurn(body: JsonObject, protocol: GatewayProviderProtoco function sanitizeCompactHandoffRequest(body: JsonObject, protocol: GatewayProviderProtocol): void { removeCompactSignals(body); removeKeys(body, [ + ...compactOutputTokenLimitKeys, "response_format", "responseFormat", "stop", @@ -385,7 +455,6 @@ function sanitizeCompactHandoffRequest(body: JsonObject, protocol: GatewayProvid "tools" ]); normalizeOpenAiResponsesTextFormat(body); - raiseMinimumNumericField(body, ["max_output_tokens", "maxOutputTokens", "max_tokens", "maxTokens"], 2048); return; } @@ -399,7 +468,6 @@ function sanitizeCompactHandoffRequest(body: JsonObject, protocol: GatewayProvid "toolChoice", "tools" ]); - raiseMinimumNumericField(body, ["max_tokens", "maxTokens"], 2048); } function removeCompactSignals(body: JsonObject): void { @@ -468,14 +536,6 @@ function removeKeys(body: JsonObject, keys: string[]): void { } } -function raiseMinimumNumericField(body: JsonObject, keys: string[], minimum: number): void { - for (const key of keys) { - if (typeof body[key] === "number" && Number.isFinite(body[key]) && body[key] < minimum) { - body[key] = minimum; - } - } -} - function appendFooterToJson( value: unknown, protocol: GatewayProviderProtocol, diff --git a/packages/core/src/gateway/features/context-archive-continuation.ts b/packages/core/src/gateway/features/context-archive-continuation.ts index 8d426fae..973dc234 100644 --- a/packages/core/src/gateway/features/context-archive-continuation.ts +++ b/packages/core/src/gateway/features/context-archive-continuation.ts @@ -624,7 +624,10 @@ function contextArchiveToolContinuationGuidance(toolName: string): string { return [ "CCR context archive is available for this compacted continuation.", `If the compact handoff indicates missing historical details are stored in archived history, use the ${toolName} tool when that history is needed.`, - "Use ordinary task judgment: answer directly when the compact handoff and retained tail are sufficient; call the history tool when exact pre-compaction details are needed." + "Use ordinary task judgment: answer directly when the compact handoff and retained tail are sufficient; call the history tool when exact pre-compaction details are needed.", + "Before implementing or finalizing behavior that depends on pre-compaction requirements, tests, file edits, failure output, or edge cases that are not explicit in the handoff, ask the archive for the missing details instead of relying on memory.", + "Treat the handoff's implementation details as evidence, not authority: original user requirements and failing checks take precedence, especially for public API runtime shape such as constructors, isinstance/class checks, attribute access, exports, ordering, callbacks, and error semantics.", + "After compact, prefer the shortest path to the user's explicit deliverable: fix known failures, run focused verification, and avoid starting broad new test plans, refactors, or extra validation unless they are already required by the handoff or a failing check." ].join(" "); } diff --git a/packages/core/test/unit/agents/context-archive.test.mjs b/packages/core/test/unit/agents/context-archive.test.mjs index 77c156c7..9af71d59 100644 --- a/packages/core/test/unit/agents/context-archive.test.mjs +++ b/packages/core/test/unit/agents/context-archive.test.mjs @@ -231,6 +231,7 @@ test("compact stores an immutable full request and appends one handoff task", as const config = testConfig(); const body = { context_management: { edits: [{ reason: "compact", type: "compact_20260112" }] }, + max_completion_tokens: 512, max_tokens: 777, messages: [ { content: "Historical decision: use SQLite.", role: "user" }, @@ -267,7 +268,8 @@ test("compact stores an immutable full request and appends one handoff task", as assert.deepEqual(forwarded.messages.slice(0, body.messages.length), body.messages); assert.equal(forwarded.messages.length, body.messages.length + 1); assert.equal(forwarded.model, body.model); - assert.equal(forwarded.max_tokens, 2048); + assert.equal(forwarded.max_completion_tokens, undefined); + assert.equal(forwarded.max_tokens, undefined); assert.equal(forwarded.context_management, undefined); assert.equal(forwarded.response_format, undefined); assert.equal(forwarded.tools, undefined); @@ -659,7 +661,9 @@ test("compact handoff strips protocol-specific tool and schema constraints", asy const result = await prepareContextArchiveRequest({ body: Buffer.from(JSON.stringify({ input: [{ content: [{ text: "Responses fact", type: "input_text" }], role: "user", type: "message" }], + maxCompletionTokens: 256, max_output_tokens: 128, + maxTokens: 1024, metadata: { ccr_context_compact: true, keep: "yes" }, model: "gpt-test", parallel_tool_calls: true, @@ -683,7 +687,9 @@ test("compact handoff strips protocol-specific tool and schema constraints", asy assert.equal(forwarded.metadata.ccr_context_compact, undefined); assert.equal(forwarded.metadata.keep, "yes"); assert.deepEqual(forwarded.text.format, { type: "text" }); - assert.equal(forwarded.max_output_tokens, 2048); + assert.equal(forwarded.maxCompletionTokens, undefined); + assert.equal(forwarded.max_output_tokens, undefined); + assert.equal(forwarded.maxTokens, undefined); assert.equal(forwarded.input.length, 2); assert.match(forwarded.input.at(-1).content[0].text, /CCR compact handoff task/); }); @@ -970,7 +976,9 @@ test("only explicit or structural compact signals create archives", async () => const auto = await prepareContextArchiveRequest({ body: Buffer.from(JSON.stringify({ context_management: { edits: [{ keep: "all", type: "clear_thinking_20251015" }] }, + maxCompletionTokens: 12000, max_tokens: 32000, + maxTokens: 16000, messages: [ { content: "Earlier user request.", role: "user" }, { content: "Earlier assistant answer.", role: "assistant" }, @@ -990,9 +998,22 @@ test("only explicit or structural compact signals create archives", async () => assert.match(auto.diagnostic, /^compact-handoff:claude-auto:/); const forwarded = JSON.parse(auto.body.toString("utf8")); assert.equal(forwarded.tools, undefined); + assert.equal(forwarded.maxCompletionTokens, undefined); + assert.equal(forwarded.max_tokens, undefined); + assert.equal(forwarded.maxTokens, undefined); assert.deepEqual(forwarded.context_management, { edits: [{ keep: "all", type: "clear_thinking_20251015" }] }); - assert.match(forwarded.messages.at(-1).content, /CCR compact handoff task/); - assert.match(forwarded.messages.at(-1).content, /mcp__ccr-context-archive__ccr_history_ask/); + assert.equal(forwarded.messages.length, 3); + assert.deepEqual(forwarded.messages.slice(0, -1), [ + { content: "Earlier user request.", role: "user" }, + { content: "Earlier assistant answer.", role: "assistant" } + ]); + const handoffText = forwarded.messages.at(-1).content[0].text; + assert.match(handoffText, /CCR compact handoff task/); + assert.match(handoffText, / block followed by a block/); + assert.match(handoffText, /descriptive, not authoritative/); + assert.match(handoffText, /public runtime API shape/); + assert.match(handoffText, /mcp__ccr-context-archive__ccr_history_ask/); + assert.doesNotMatch(handoffText, /Your task is to create a detailed summary of the conversation so far/); }); test("compact refuses unresolved tool-call boundaries instead of trimming them", async () => { From 6e34da7fca2403086764400a5994ac9325dfb574 Mon Sep 17 00:00:00 2001 From: musistudio Date: Mon, 10 Aug 2026 12:55:54 +0800 Subject: [PATCH 03/18] Enforce profile model allowlists across agent catalogs --- packages/core/src/agents/codex/app-launch.ts | 15 +- .../core/src/agents/codex/model-catalog.ts | 44 ++- .../core/src/agents/kilo/profile-config.ts | 3 +- .../src/agents/opencode/profile-config.ts | 7 +- packages/core/src/agents/pi/profile-config.ts | 7 +- .../core/src/agents/zcode/model-catalog.ts | 13 +- .../core/src/agents/zcode/profile-config.ts | 3 +- packages/core/src/config/config.ts | 12 +- .../src/gateway/claude-code-router-plugin.ts | 17 +- .../src/gateway/features/model-discovery.ts | 72 +++-- .../core/src/gateway/http/request-handler.ts | 15 +- packages/core/src/gateway/request/pipeline.ts | 59 +++- packages/core/src/profiles/model-allowlist.ts | 190 ++++++++++++ packages/core/src/profiles/service.ts | 9 +- .../profile-model-allowlist-http.test.mjs | 289 ++++++++++++++++++ .../unit/agents/codex-model-catalog.test.mjs | 13 + .../profiles/profile-model-allowlist.test.mjs | 195 ++++++++++++ .../pages/home/components/model-selector.tsx | 39 ++- .../ui/src/pages/home/components/profiles.tsx | 105 +++---- packages/ui/src/pages/home/shared/i18n.tsx | 8 +- packages/ui/src/pages/home/shared/profiles.ts | 63 ++-- .../ui/test/component/onboarding.test.tsx | 2 +- packages/ui/test/component/profiles.test.tsx | 152 ++++++++- 23 files changed, 1168 insertions(+), 164 deletions(-) create mode 100644 packages/core/src/profiles/model-allowlist.ts create mode 100644 packages/core/test/integration/gateway/profile-model-allowlist-http.test.mjs create mode 100644 packages/core/test/unit/profiles/profile-model-allowlist.test.mjs diff --git a/packages/core/src/agents/codex/app-launch.ts b/packages/core/src/agents/codex/app-launch.ts index 08b7d32b..bd9e2f29 100644 --- a/packages/core/src/agents/codex/app-launch.ts +++ b/packages/core/src/agents/codex/app-launch.ts @@ -10,6 +10,7 @@ import { buildProfileLaunchPlan, resolveCodexConfigFile } from "@ccr/core/profil import { normalizeWindowsDesktopAppCandidate, windowsDesktopAppCandidates } from "@ccr/core/platform/windows-app-discovery"; import { buildZcodeModelCatalog } from "@ccr/core/agents/zcode/model-catalog"; import { writeZcodeGatewayConfig, zcodeHomeFromConfigFile } from "@ccr/core/agents/zcode/profile-config"; +import { profileAllowedModels } from "@ccr/core/profiles/model-allowlist"; export type CodexAppLookupResult = { checked: string[]; @@ -204,7 +205,7 @@ export function writeCodexCompatibleAppModelCatalog( const userDataDir = codexElectronUserDataDir(codexHome, profile, spec); mkdirSync(userDataDir, { recursive: true }); const file = codexAppModelCatalogFile(userDataDir, spec); - const content = codexCompatibleAppModelCatalogJson(config, profile.model, spec.kind); + const content = codexCompatibleAppModelCatalogJson(config, profile.model, spec.kind, profileAllowedModels(profile)); const previous = existsSync(file) ? readFileSync(file, "utf8") : undefined; if (previous !== content) { writeFileSync(file, content, "utf8"); @@ -215,19 +216,21 @@ export function writeCodexCompatibleAppModelCatalog( function codexCompatibleAppModelCatalogJson( config?: CodexCompatibleAppModelCatalogConfig, selectedModel?: string, - kind: CodexCompatibleAppKind = "codex" + kind: CodexCompatibleAppKind = "codex", + allowedModels?: string[] ): string { - return `${JSON.stringify(codexCompatibleAppModelCatalog(config, selectedModel, kind), null, 2)}\n`; + return `${JSON.stringify(codexCompatibleAppModelCatalog(config, selectedModel, kind, allowedModels), null, 2)}\n`; } function codexCompatibleAppModelCatalog( config?: CodexCompatibleAppModelCatalogConfig, selectedModel?: string, - kind: CodexCompatibleAppKind = "codex" + kind: CodexCompatibleAppKind = "codex", + allowedModels?: string[] ): CodexModelCatalog { const catalog = kind === "zcode" - ? buildZcodeModelCatalog(config, selectedModel) - : buildCodexModelCatalog(config, selectedModel); + ? buildZcodeModelCatalog(config, selectedModel, { allowedModels }) + : buildCodexModelCatalog(config, selectedModel, { allowedModels }); return { models: catalog.models.map((model) => codexCompatibleAppModelCatalogItem(model, config)) }; diff --git a/packages/core/src/agents/codex/model-catalog.ts b/packages/core/src/agents/codex/model-catalog.ts index c25cece6..6ed51a61 100644 --- a/packages/core/src/agents/codex/model-catalog.ts +++ b/packages/core/src/agents/codex/model-catalog.ts @@ -10,6 +10,7 @@ import { codexDefaultBaseUrl, codexImportedModelContextWindow, readCodexLocalMod import { localAgentProviderApiKey } from "@ccr/core/agents/local-providers/shared"; import { normalizeProviderBaseUrl } from "@ccr/core/providers/url"; import { resolveUsageModelAttribution } from "@ccr/core/usage/model-attribution"; +import { filterModelIdsByAllowedModels } from "@ccr/core/profiles/model-allowlist"; const fusionModelProviderName = "Fusion"; const codexDefaultContextWindow = 128_000; @@ -19,6 +20,10 @@ export type CodexModelCatalog = { models: CodexModelCatalogItem[]; }; +export type CodexModelCatalogOptions = { + allowedModels?: string[]; +}; + export type CodexModelCatalogItem = { additional_speed_tiers: unknown[]; apply_patch_tool_type: string | null; @@ -57,13 +62,21 @@ export type CodexModelCatalogItem = { web_search_tool_type: string; }; -export function buildCodexModelCatalog(config?: Partial>, selectedModel?: string): CodexModelCatalog { +export function buildCodexModelCatalog( + config?: Partial>, + selectedModel?: string, + options: CodexModelCatalogOptions = {} +): CodexModelCatalog { return { - models: buildCodexModelCatalogIds(config, selectedModel).map((model, index) => codexModelCatalogItem(model, index, config)) + models: buildCodexModelCatalogIds(config, selectedModel, options).map((model, index) => codexModelCatalogItem(model, index, config)) }; } -export function buildCodexModelCatalogIds(config?: Partial>, selectedModel?: string): string[] { +export function buildCodexModelCatalogIds( + config?: Partial>, + selectedModel?: string, + options: CodexModelCatalogOptions = {} +): string[] { const ids: string[] = []; pushUniqueModel(ids, normalizeModelSelector(selectedModel)); @@ -106,15 +119,30 @@ export function buildCodexModelCatalogIds(config?: Partial>, selectedModel?: string): string { - return `${JSON.stringify(buildCodexModelCatalog(config, selectedModel), null, 2)}\n`; +export function codexModelCatalogJson( + config?: Partial>, + selectedModel?: string, + options: CodexModelCatalogOptions = {} +): string { + return `${JSON.stringify(buildCodexModelCatalog(config, selectedModel, options), null, 2)}\n`; } -export function codexModelCatalogBase64(config?: Partial>, selectedModel?: string): string { - const catalog = buildCodexModelCatalog(config, selectedModel); +export function codexModelCatalogBase64( + config?: Partial>, + selectedModel?: string, + options: CodexModelCatalogOptions = {} +): string { + const catalog = buildCodexModelCatalog(config, selectedModel, options); return Buffer.from(JSON.stringify(catalog), "utf8").toString("base64"); } diff --git a/packages/core/src/agents/kilo/profile-config.ts b/packages/core/src/agents/kilo/profile-config.ts index bfd36722..17126813 100644 --- a/packages/core/src/agents/kilo/profile-config.ts +++ b/packages/core/src/agents/kilo/profile-config.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { buildCodexModelCatalogIds } from "@ccr/core/agents/codex/model-catalog"; import { parseJsoncRecord } from "@ccr/core/agents/local-providers/shared"; import { isGatewayProviderEnabled, type AppConfig, type ProfileConfig } from "@ccr/core/contracts/app"; +import { profileAllowedModels } from "@ccr/core/profiles/model-allowlist"; export type KiloProfileConfigWriteResult = { backupFile?: string; @@ -93,7 +94,7 @@ function kiloGatewayOverrides(config: AppConfig, profile: ProfileConfig, token: const providerName = profile.providerName?.trim() || "Claude Code Router"; const model = normalizeClientModel(profile.model) || defaultClientModel(config); const modelRef = `${providerId}/${model}`; - const models = buildCodexModelCatalogIds(config, model); + const models = buildCodexModelCatalogIds(config, model, { allowedModels: profileAllowedModels({ ...profile, model }) }); return { $schema: "https://app.kilo.ai/config.json", model: modelRef, diff --git a/packages/core/src/agents/opencode/profile-config.ts b/packages/core/src/agents/opencode/profile-config.ts index e09dfec9..2c3f4e4b 100644 --- a/packages/core/src/agents/opencode/profile-config.ts +++ b/packages/core/src/agents/opencode/profile-config.ts @@ -7,6 +7,7 @@ import { isGatewayProviderEnabled, type AppConfig, type GatewayProviderConfig, t import { findModelCatalogEntry, type ModelCatalogEntry, modelCatalogMaxInputTokens, modelCatalogMaxOutputTokens } from "@ccr/core/gateway/model-catalog"; import { modelRegistryForConfig } from "@ccr/core/routing/model-registry"; import { resolveUsageModelAttribution } from "@ccr/core/usage/model-attribution"; +import { profileAllowedModels } from "@ccr/core/profiles/model-allowlist"; export type OpenCodeProfileConfigWriteResult = { backupFile?: string; @@ -97,7 +98,7 @@ function openCodeGatewayOverrides(config: AppConfig, profile: ProfileConfig, tok const providerName = profile.providerName?.trim() || "Claude Code Router"; const model = normalizeClientModel(profile.model) || defaultClientModel(config); const modelRef = `${providerId}/${model}`; - const models = openCodeModelConfigs(config, model); + const models = openCodeModelConfigs(config, { ...profile, model }, model); return { $schema: "https://opencode.ai/config.json", model: modelRef, @@ -120,8 +121,8 @@ function openCodeGatewayOverrides(config: AppConfig, profile: ProfileConfig, tok }; } -function openCodeModelConfigs(config: AppConfig, selectedModel: string): Record { - const catalog = buildCodexModelCatalog(config, selectedModel); +function openCodeModelConfigs(config: AppConfig, profile: ProfileConfig, selectedModel: string): Record { + const catalog = buildCodexModelCatalog(config, selectedModel, { allowedModels: profileAllowedModels(profile) }); const resolutionConfig: OpenCodeModelResolutionConfig = { Providers: config.Providers ?? [], virtualModelProfiles: config.virtualModelProfiles ?? [] diff --git a/packages/core/src/agents/pi/profile-config.ts b/packages/core/src/agents/pi/profile-config.ts index d3b68e01..09662c9a 100644 --- a/packages/core/src/agents/pi/profile-config.ts +++ b/packages/core/src/agents/pi/profile-config.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { buildCodexModelCatalogIds } from "@ccr/core/agents/codex/model-catalog"; import type { AppConfig, ProfileConfig } from "@ccr/core/contracts/app"; +import { profileAllowedModels } from "@ccr/core/profiles/model-allowlist"; export type PiProfileConfigWriteResult = { changed: boolean; @@ -49,7 +50,7 @@ export function writePiGatewayConfig( const sessionDir = resolvePiSessionDir(configDir, profile); const file = path.join(profileHome, "models.json"); const providerId = sanitizeProviderId(profile.providerId || "") || "claude-code-router"; - const models = piProfileModels(config, defaultModel); + const models = piProfileModels(config, profile, defaultModel); const model = models.includes(defaultModel) ? defaultModel : models[0] || defaultModel || "default"; const content = `${JSON.stringify(piModelsJson(config, profile, providerId, token, models), null, 2)}\n`; const changed = writeJsonFileIfChanged(file, content); @@ -97,10 +98,10 @@ function piModelConfig(model: string): Record { }; } -function piProfileModels(config: AppConfig, defaultModel: string): string[] { +function piProfileModels(config: AppConfig, profile: ProfileConfig, defaultModel: string): string[] { return uniqueStrings([ defaultModel, - ...buildCodexModelCatalogIds(config, defaultModel) + ...buildCodexModelCatalogIds(config, defaultModel, { allowedModels: profileAllowedModels({ ...profile, model: defaultModel }) }) ].filter(Boolean)); } diff --git a/packages/core/src/agents/zcode/model-catalog.ts b/packages/core/src/agents/zcode/model-catalog.ts index 6d036649..c8a87664 100644 --- a/packages/core/src/agents/zcode/model-catalog.ts +++ b/packages/core/src/agents/zcode/model-catalog.ts @@ -1,7 +1,8 @@ import type { AppConfig } from "@ccr/core/contracts/app"; import { buildCodexModelCatalog, - type CodexModelCatalog + type CodexModelCatalog, + type CodexModelCatalogOptions } from "@ccr/core/agents/codex/model-catalog"; import { findModelCatalogEntry, @@ -23,9 +24,10 @@ type ZcodeModelResolutionConfig = Pick< export function buildZcodeModelCatalog( config?: ZcodeModelCatalogConfig, - selectedModel?: string + selectedModel?: string, + options: CodexModelCatalogOptions = {} ): CodexModelCatalog { - const catalog = buildCodexModelCatalog(config, selectedModel); + const catalog = buildCodexModelCatalog(config, selectedModel, options); const resolutionConfig = config ? { Providers: config.Providers ?? [], @@ -74,7 +76,8 @@ function zcodeModelCatalogEntry( export function zcodeModelCatalogJson( config?: ZcodeModelCatalogConfig, - selectedModel?: string + selectedModel?: string, + options: CodexModelCatalogOptions = {} ): string { - return `${JSON.stringify(buildZcodeModelCatalog(config, selectedModel), null, 2)}\n`; + return `${JSON.stringify(buildZcodeModelCatalog(config, selectedModel, options), null, 2)}\n`; } diff --git a/packages/core/src/agents/zcode/profile-config.ts b/packages/core/src/agents/zcode/profile-config.ts index 6e2b21f0..abe33728 100644 --- a/packages/core/src/agents/zcode/profile-config.ts +++ b/packages/core/src/agents/zcode/profile-config.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { isGatewayProviderEnabled, type AppConfig, type ProfileConfig } from "@ccr/core/contracts/app"; import { normalizeRouteSelector } from "@ccr/core/gateway/claude-code-router-plugin"; import { buildZcodeModelCatalog } from "@ccr/core/agents/zcode/model-catalog"; +import { profileAllowedModels } from "@ccr/core/profiles/model-allowlist"; export type ZcodeProfileConfigWriteResult = { backupFile?: string; @@ -53,7 +54,7 @@ export function writeZcodeGatewayConfig( const file = resolveZcodeConfigFile(profile); const model = normalizeClientModel(profile.model) || defaultClientModel(config); const providerId = sanitizeZcodeProviderId(profile.providerId || "") || "claude-code-router"; - const modelCatalog = buildZcodeModelCatalog(config, model); + const modelCatalog = buildZcodeModelCatalog(config, model, { allowedModels: profileAllowedModels({ ...profile, model }) }); const values: ZcodeGatewayConfigValues = { baseUrl: gatewayEndpoint(config), model, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 613521c4..33fcae8b 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -3562,10 +3562,10 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined { const id = readString(item.id) || `profile-${index + 1}`; const name = readString(item.name) || defaultProfileAgentName(agent); const model = readString(item.model) ?? ""; - const availableModels = uniqueStrings([ - model, - ...parseStringList(item.availableModels ?? item.available_models ?? item.models) - ]); + const parsedAvailableModels = parseStringList(item.availableModels ?? item.available_models ?? item.models); + const availableModels = parsedAvailableModels.length > 0 + ? uniqueStrings([model, ...parsedAvailableModels].filter(Boolean)) + : undefined; const env = parseStringRecord(item.env) ?? {}; const parsedSurface = parseProfileSurface(readString(item.surface) || readString(item.entry) || readString(item.frontend)) || "auto"; const surface = agent === "zcode" || agent === CLAUDE_DESIGN_PLUGIN_ID @@ -3594,6 +3594,7 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined { haikuModel: readString(item.haikuModel) || readString(item.defaultHaikuModel) || readString(item.smallFastModel) || readString(item.smallModel) || "", id, ...(managedCompact !== undefined ? { managedCompact } : {}), + ...(availableModels ? { availableModels } : {}), model, name, opusModel: readString(item.opusModel) || readString(item.defaultOpusModel) || "", @@ -3609,7 +3610,7 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined { if (agent === "grok" || agent === "kimi" || agent === "pi") { return { agent, - ...(agent === "kimi" ? { availableModels } : {}), + ...(availableModels ? { availableModels } : {}), enabled, env: codexCompatibleProfileEnv(env), id, @@ -3657,6 +3658,7 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined { env: codexCompatibleProfileEnv(env), id, ...(managedCompact !== undefined ? { managedCompact } : {}), + ...(availableModels ? { availableModels } : {}), model, name, providerId: readString(item.providerId) || readString(item.provider) || "claude-code-router", diff --git a/packages/core/src/gateway/claude-code-router-plugin.ts b/packages/core/src/gateway/claude-code-router-plugin.ts index c4db5bec..bd884263 100644 --- a/packages/core/src/gateway/claude-code-router-plugin.ts +++ b/packages/core/src/gateway/claude-code-router-plugin.ts @@ -17,6 +17,7 @@ import { buildRouteScriptInput } from "@ccr/core/routing/route-script-context"; import { normalizeRouteScriptResult } from "@ccr/core/routing/route-script-result"; import type { RouteScriptRuntime } from "@ccr/core/routing/route-script-runtime"; import { profileApiKeyId } from "@ccr/core/profiles/api-key"; +import { isModelAllowedForProfile } from "@ccr/core/profiles/model-allowlist"; export { normalizeRouteSelector } from "@ccr/core/routing/model-registry"; @@ -76,7 +77,8 @@ export class ClaudeCodeRouterPlugin { }; applyAgentRequestEnrichers(request, [{ enrich: (matchedRequest) => { - injectClaudeCodeAgentToolDescription(matchedRequest.body, this.config); + const profile = resolveBuiltInAgentProfile(matchedRequest, this.config, "claude-code"); + injectClaudeCodeAgentToolDescription(matchedRequest.body, this.config, profile); injectClaudeCodeToolHubInstructions(matchedRequest.body, this.config); matchedRequest.builtInClaudeCodeSubagent = removeClaudeCodeBillingSystemHeader(matchedRequest.body); matchedRequest.builtInSubagentModel = extractAndRemoveClaudeCodeSubagentModelTag(matchedRequest.body); @@ -842,12 +844,12 @@ function systemContainsInstruction(system: unknown, marker: string): boolean { : isRecord(block) && typeof block.text === "string" && block.text.includes(marker)); } -function injectClaudeCodeAgentToolDescription(body: Record, config: AppConfig): void { +function injectClaudeCodeAgentToolDescription(body: Record, config: AppConfig, profile?: ProfileConfig): void { if (!Array.isArray(body.tools)) { return; } - const instructions = claudeCodeAgentToolInstructions(config); + const instructions = claudeCodeAgentToolInstructions(config, profile); if (!instructions) { return; } @@ -914,8 +916,8 @@ function appendDescriptionInstruction(description: string | undefined, instructi return existing ? `${existing}\n\n${instruction}` : instruction; } -function claudeCodeAgentToolInstructions(config: AppConfig): { prompt: string; tool: string; workflow: string } | undefined { - const modelRows = configuredSubagentModelDescriptionRows(config); +function claudeCodeAgentToolInstructions(config: AppConfig, profile?: ProfileConfig): { prompt: string; tool: string; workflow: string } | undefined { + const modelRows = configuredSubagentModelDescriptionRows(config, profile); if (modelRows.length === 0) { return undefined; } @@ -942,9 +944,12 @@ function claudeCodeAgentToolInstructions(config: AppConfig): { prompt: string; t }; } -function configuredSubagentModelDescriptionRows(config: AppConfig): string[] { +function configuredSubagentModelDescriptionRows(config: AppConfig, profile?: ProfileConfig): string[] { const routeByTarget = new Map(); for (const route of buildClaudeAppGatewayModelRoutes(config, claudeAppGatewayModelRouteOptions)) { + if (!isModelAllowedForProfile(config, profile, route.targetModel)) { + continue; + } const key = route.targetModel.toLowerCase(); const current = routeByTarget.get(key); if (!current || (current.oneMillionContext && !route.oneMillionContext)) { diff --git a/packages/core/src/gateway/features/model-discovery.ts b/packages/core/src/gateway/features/model-discovery.ts index c8082c7a..c493fb18 100644 --- a/packages/core/src/gateway/features/model-discovery.ts +++ b/packages/core/src/gateway/features/model-discovery.ts @@ -3,7 +3,7 @@ */ import type { IncomingHttpHeaders } from "node:http"; import { isGatewayProviderEnabled } from "@ccr/core/contracts/app"; -import type { ApiKeyConfig, AppConfig, ProviderModelMetadata } from "@ccr/core/contracts/app"; +import type { ApiKeyConfig, AppConfig, ProfileConfig, ProviderModelMetadata } from "@ccr/core/contracts/app"; import { buildClaudeAppGatewayModelRoutes, resolveClaudeAppGatewayRouteModel } from "@ccr/core/agents/claude-app/gateway-routes"; import { modelRegistryForConfig, normalizeRouteSelector } from "@ccr/core/routing/model-registry"; import { findModelCatalogEntry, modelCatalogMaxInputTokens, modelCatalogMaxOutputTokens, readCatalogCapability, type ModelCatalogEntry } from "@ccr/core/gateway/model-catalog"; @@ -16,6 +16,7 @@ import { parseJsonObjectSafe, serializeJsonBodyWithModel } from "@ccr/core/gatew import { uniqueStrings } from "@ccr/core/gateway/internal/collections"; import { contextArchiveConfigForApiKey, contextArchiveMcpEnabled } from "@ccr/core/gateway/context-archive"; import { resolveUsageModelAttribution } from "@ccr/core/usage/model-attribution"; +import { filterModelIdsForProfile, isModelAllowedForProfile, profileForApiKey } from "@ccr/core/profiles/model-allowlist"; export function shouldServeGatewayModelsResponse(method: string, path: string): boolean { @@ -98,20 +99,22 @@ export function prepareClaudeAppDiscoveredModelRequest( export function createGatewayModelsResponse(config: AppConfig, headers: IncomingHttpHeaders, apiKey?: ApiKeyConfig): Record { const contextArchiveConfig = contextArchiveConfigForApiKey(config, apiKey); const contextArchiveCompact = Boolean(contextArchiveConfig && contextArchiveMcpEnabled(contextArchiveConfig)); + const profile = profileForApiKey(config, apiKey); if (isClaudeAppApiKey(apiKey)) { - return createClaudeAppGatewayModelsResponse(config, { contextArchiveCompact }); + return createClaudeAppGatewayModelsResponse(config, { contextArchiveCompact, profile }); } if (isClaudeCodeUserAgent(headers)) { - return createClaudeAppGatewayModelsResponse(config, { claudeCode: true, contextArchiveCompact }); + return createClaudeAppGatewayModelsResponse(config, { claudeCode: true, contextArchiveCompact, profile }); } - return createOpenAICompatibleGatewayModelsResponse(config); + return createOpenAICompatibleGatewayModelsResponse(config, profile); } -export function createClaudeCliBootstrapResponse(config: AppConfig): Record { - const windows = createClaudeCliAutoCompactWindows(config); +export function createClaudeCliBootstrapResponse(config: AppConfig, apiKey?: ApiKeyConfig): Record { + const profile = profileForApiKey(config, apiKey); + const windows = createClaudeCliAutoCompactWindows(config, profile); return { - additional_model_options: createClaudeCliAdditionalModelOptions(config), + additional_model_options: createClaudeCliAdditionalModelOptions(config, profile), auto_compact_windows: windows, client_data: { rowan_thicket: { ...windows } @@ -134,11 +137,14 @@ type ClaudeCliAdditionalModelOption = { }; -function createClaudeCliAdditionalModelOptions(config: AppConfig): ClaudeCliAdditionalModelOption[] { +function createClaudeCliAdditionalModelOptions(config: AppConfig, profile?: ProfileConfig): ClaudeCliAdditionalModelOption[] { const options: ClaudeCliAdditionalModelOption[] = []; const seen = new Set(); const routes = buildClaudeAppGatewayModelRoutes(config, claudeAppGatewayModelRouteOptions); for (const route of routes) { + if (!isModelAllowedForProfile(config, profile, route.targetModel)) { + continue; + } const id = route.oneMillionContext ? claudeCodeOneMillionContextModelId(route.id) : route.id; const normalized = id.trim(); const key = normalized.toLowerCase(); @@ -197,8 +203,8 @@ function formatCompactTokenCount(tokens: number): string { } -function createOpenAICompatibleGatewayModelsResponse(config: AppConfig): Record { - const data = buildGatewayDiscoverableModelIds(config).map((id) => { +function createOpenAICompatibleGatewayModelsResponse(config: AppConfig, profile?: ProfileConfig): Record { + const data = buildGatewayDiscoverableModelIds(config, profile).map((id) => { const catalogEntry = findModelCatalogEntry(id); return { id, @@ -217,10 +223,13 @@ function createOpenAICompatibleGatewayModelsResponse(config: AppConfig): Record< } -export function createClaudeCliAutoCompactWindows(config: AppConfig): Record { +export function createClaudeCliAutoCompactWindows(config: AppConfig, profile?: ProfileConfig): Record { const windows: Record = {}; const routes = buildClaudeAppGatewayModelRoutes(config, claudeAppGatewayModelRouteOptions); for (const route of routes) { + if (!isModelAllowedForProfile(config, profile, route.targetModel)) { + continue; + } const catalogId = stripClaudeCodeOneMillionContextSuffix(route.targetModel); const modelDiscovery = providerModelDiscoveryForSelector(config, catalogId); const maxInputTokens = claudeGatewayModelContextWindow( @@ -271,9 +280,10 @@ function claudeCliAutoCompactWindow(maxInputTokens: number): number | undefined function createClaudeAppGatewayModelsResponse( config: AppConfig, - options: { claudeCode?: boolean; contextArchiveCompact?: boolean } = {} + options: { claudeCode?: boolean; contextArchiveCompact?: boolean; profile?: ProfileConfig } = {} ): Record { - const routes = buildClaudeAppGatewayModelRoutes(config, claudeAppGatewayModelRouteOptions); + const routes = buildClaudeAppGatewayModelRoutes(config, claudeAppGatewayModelRouteOptions) + .filter((route) => isModelAllowedForProfile(config, options.profile, route.targetModel)); const data = routes.map((route) => { const catalogId = stripClaudeCodeOneMillionContextSuffix(route.targetModel); const modelDiscovery = providerModelDiscoveryForSelector(config, catalogId); @@ -309,8 +319,12 @@ function createClaudeAppGatewayModelsResponse( } -function createClaudeCodeModelsResponse(config: AppConfig, contextArchiveCompact = false): Record { - const models = buildClaudeCodeDiscoverableModels(config); +function createClaudeCodeModelsResponse( + config: AppConfig, + contextArchiveCompact = false, + profile?: ProfileConfig +): Record { + const models = buildClaudeCodeDiscoverableModels(config, profile); const data = models.map((model) => { const claudeId = claudeCodeDiscoveryModelId(model.id); const catalogId = stripClaudeCodeOneMillionContextSuffix(model.id); @@ -345,7 +359,11 @@ function createClaudeCodeModelsResponse(config: AppConfig, contextArchiveCompact export function createClaudeCodeModelsResponseForTest(config: AppConfig, apiKey?: ApiKeyConfig): Record { const contextArchiveConfig = contextArchiveConfigForApiKey(config, apiKey); - return createClaudeCodeModelsResponse(config, Boolean(contextArchiveConfig && contextArchiveMcpEnabled(contextArchiveConfig))); + return createClaudeCodeModelsResponse( + config, + Boolean(contextArchiveConfig && contextArchiveMcpEnabled(contextArchiveConfig)), + profileForApiKey(config, apiKey) + ); } @@ -457,12 +475,12 @@ function positiveInteger(value: number | undefined): number | undefined { } -function buildClaudeCodeDiscoverableModelIds(config: AppConfig): string[] { - return buildGatewayDiscoverableModelIds(config); +function buildClaudeCodeDiscoverableModelIds(config: AppConfig, profile?: ProfileConfig): string[] { + return buildGatewayDiscoverableModelIds(config, profile); } -function buildGatewayDiscoverableModelIds(config: AppConfig): string[] { +function buildGatewayDiscoverableModelIds(config: AppConfig, profile?: ProfileConfig): string[] { const baseEntries: Array<{ modelName: string; providerName: string }> = []; for (const provider of config.Providers) { const providerName = provider.name?.trim(); @@ -479,19 +497,19 @@ function buildGatewayDiscoverableModelIds(config: AppConfig): string[] { } const ids = baseEntries.map((entry) => `${entry.providerName}/${entry.modelName}`); - for (const profile of config.virtualModelProfiles ?? []) { - if (!isVisibleVirtualModelProfile(profile)) { + for (const virtualProfile of config.virtualModelProfiles ?? []) { + if (!isVisibleVirtualModelProfile(virtualProfile)) { continue; } for (const entry of baseEntries) { - for (const prefix of profile.match?.prefixes ?? []) { + for (const prefix of virtualProfile.match?.prefixes ?? []) { const normalizedPrefix = prefix.trim(); if (normalizedPrefix) { ids.push(`${entry.providerName}/${normalizedPrefix}${entry.modelName}`); } } - for (const suffix of profile.match?.suffixes ?? []) { + for (const suffix of virtualProfile.match?.suffixes ?? []) { const normalizedSuffix = suffix.trim(); if (normalizedSuffix) { ids.push(`${entry.providerName}/${entry.modelName}${normalizedSuffix}`); @@ -499,7 +517,7 @@ function buildGatewayDiscoverableModelIds(config: AppConfig): string[] { } } - for (const alias of profile.match?.exactAliases ?? []) { + for (const alias of virtualProfile.match?.exactAliases ?? []) { const normalizedAlias = alias.trim(); if (!normalizedAlias) { continue; @@ -508,7 +526,7 @@ function buildGatewayDiscoverableModelIds(config: AppConfig): string[] { } } - return uniqueStrings(ids); + return filterModelIdsForProfile(config, uniqueStrings(ids), profile); } @@ -518,7 +536,7 @@ function gatewayModelOwner(id: string): string { } -function buildClaudeCodeDiscoverableModels(config: AppConfig): ClaudeCodeDiscoverableModel[] { +function buildClaudeCodeDiscoverableModels(config: AppConfig, profile?: ProfileConfig): ClaudeCodeDiscoverableModel[] { const seen = new Set(); const models: ClaudeCodeDiscoverableModel[] = []; @@ -535,7 +553,7 @@ function buildClaudeCodeDiscoverableModels(config: AppConfig): ClaudeCodeDiscove models.push({ id: normalized, oneMillionContext }); }; - for (const id of buildClaudeCodeDiscoverableModelIds(config)) { + for (const id of buildClaudeCodeDiscoverableModelIds(config, profile)) { pushModel(id, hasClaudeCodeOneMillionContextSuffix(id)); const baseId = stripClaudeCodeOneMillionContextSuffix(id); if (!hasClaudeCodeOneMillionContextSuffix(id) && gatewayModelSupportsOneMillionContext(config, baseId)) { diff --git a/packages/core/src/gateway/http/request-handler.ts b/packages/core/src/gateway/http/request-handler.ts index 0cb400f6..37010875 100644 --- a/packages/core/src/gateway/http/request-handler.ts +++ b/packages/core/src/gateway/http/request-handler.ts @@ -17,6 +17,8 @@ import { ccrRemoteControlPathPrefix, ccrRemoteControlService } from "@ccr/core/g import { authorize, claudeCodeWifTokenPath, handleClaudeCodeWifTokenRequest, reserveApiKeyLimits } from "@ccr/core/gateway/auth/api-key-authorizer"; import { parseJsonObject, readRequestBody, sendJson } from "@ccr/core/gateway/http/io"; import { shouldRecordRequestLogs } from "@ccr/core/observability/raw-trace-sync"; +import { requestLogRequestedModel } from "@ccr/core/observability/request-log-model"; +import { isModelAllowedForProfile, profileForApiKey } from "@ccr/core/profiles/model-allowlist"; import { applyCors, shouldServeGatewayRequest } from "@ccr/core/gateway/core-runtime/supervisor"; import { billingUsageSyncPath, rawTraceSyncPath } from "@ccr/core/gateway/internal/shared"; import type { BrowserAutomationMcpIntegration } from "@ccr/core/gateway/internal/shared"; @@ -212,13 +214,24 @@ export class GatewayHttpRequestHandler { } if (claudeCliBootstrapRequest) { - sendJson(response, 200, createClaudeCliBootstrapResponse(this.config)); + sendJson(response, 200, createClaudeCliBootstrapResponse(this.config, authorization.apiKey)); return; } if (request.method === "POST" && path === "/v1/messages/count_tokens") { const requestBody = await readRequestBody(request); const body = parseJsonObject(requestBody); + const requestedModel = requestLogRequestedModel(requestBody, path); + const profile = profileForApiKey(this.config, authorization.apiKey); + if (requestedModel && !isModelAllowedForProfile(this.config, profile, requestedModel)) { + sendJson(response, 403, { + error: { + code: "profile_model_not_allowed", + message: `Model "${requestedModel}" is not allowed for this profile.` + } + }); + return; + } if (!reserveApiKeyLimits(authorization.apiKey, request, response, requestBody)) { return; } diff --git a/packages/core/src/gateway/request/pipeline.ts b/packages/core/src/gateway/request/pipeline.ts index 9f3828b4..50b053a8 100644 --- a/packages/core/src/gateway/request/pipeline.ts +++ b/packages/core/src/gateway/request/pipeline.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import type { IncomingMessage, ServerResponse } from "node:http"; import { Readable } from "node:stream"; -import type { ApiKeyConfig, AppConfig, RequestRouteTraceChange } from "@ccr/core/contracts/app"; +import type { ApiKeyConfig, AppConfig, ProfileConfig, RequestRouteTraceChange, RouterFallbackConfig } from "@ccr/core/contracts/app"; import { createSseErrorDetector, markGatewayRequestLogDropped, @@ -46,6 +46,7 @@ import type { BrowserWebSearchMcpIntegration, BrowserWebSearchProtocolRecord, Up import { cancelResponseBody, destroyResponseStreams, fetchUpstreamWithFallback, mergeFallbackResponseHeaders, rewriteCapabilityResponseHeaders, uniqueStreams, upstreamResponseHeaders } from "@ccr/core/gateway/upstream/executor"; import { requestProtocolForPath, shouldApplyGatewayRouting } from "@ccr/core/routing/protocol-endpoints"; import { createClaudeCodeWebSearchContinuationContext, createHostedWebSearchProtocolContext, hostedWebSearchProtocolResponseStream, hostedWebSearchUnavailableMessage, prepareClaudeCodeWebSearchContinuationRequestBody, prepareHostedWebSearchProtocolRequestBody, selectClaudeCodeWebSearchContinuationRecords, selectHostedWebSearchProtocolRecords } from "@ccr/core/gateway/features/hosted-web-search/index"; +import { isModelAllowedForProfile, profileForApiKey } from "@ccr/core/profiles/model-allowlist"; export type GatewayRequestPipelineDependencies = { getBrowserWebSearchMcpIntegration: () => BrowserWebSearchMcpIntegration | undefined; @@ -91,6 +92,7 @@ export class GatewayRequestPipeline { sendJson(response, 503, { error: { message: "Gateway service is not configured." } }); return; } + const activeConfig = this.config; const method = request.method ?? "GET"; const requestBody = await readRequestBody(request); @@ -158,6 +160,7 @@ export class GatewayRequestPipeline { let routedModel: string | undefined; let codexApplyPatchBridgeActive = false; let codexMultiAgentBridgeActive = false; + const authenticatedProfile = profileForApiKey(activeConfig, apiKey); const claudeModelRewriteStartedAt = Date.now(); const claudeModelRewrite = prepareClaudeCodeDiscoveredModelRequest(this.config, request.headers, method, path, bodyToForward); if (claudeModelRewrite) { @@ -194,9 +197,7 @@ export class GatewayRequestPipeline { target: routedModel ? { model: routedModel } : undefined }); } - if (!reserveApiKeyLimits(apiKey, request, response, bodyToForward)) { - return; - } + const modelBeforeRouting = requestLogRequestedModel(bodyToForward ?? requestBody, path); const usageAttributionConfig = coreGatewayUsageAttributionConfig(this.config); const recordUsage = (input: Omit) => { void recordGatewayUsageCapture({ ...input, config: usageAttributionConfig }); @@ -288,6 +289,9 @@ export class GatewayRequestPipeline { const shouldCaptureUsage = shouldCaptureGatewayUsage(method, path); if (shouldServeGatewayModelsResponse(method, path)) { + if (!reserveApiKeyLimits(apiKey, request, response, bodyToForward)) { + return; + } const responseText = `${JSON.stringify(createGatewayModelsResponse(this.config, request.headers, apiKey))}\n`; const modelHeaders = new Headers({ "cache-control": "no-store, max-age=0", @@ -355,6 +359,22 @@ export class GatewayRequestPipeline { }); } + routeFallback = profileAllowedRouteFallback(activeConfig, authenticatedProfile, routeFallback); + const effectiveModel = routedModel ?? requestLogRequestedModel(bodyToForward ?? requestBody, path); + const deniedModel = profileDeniedModel(activeConfig, authenticatedProfile, modelBeforeRouting, effectiveModel); + if (deniedModel) { + sendJson(response, 403, { + error: { + code: "profile_model_not_allowed", + message: `Model "${deniedModel}" is not allowed for this profile.` + } + }); + return; + } + if (!reserveApiKeyLimits(apiKey, request, response, bodyToForward)) { + return; + } + const codexBridgeStartedAt = Date.now(); const codexApplyPatchBridgeRequest = prepareCodexApplyPatchBridgeRequest({ body: bodyToForward, @@ -937,3 +957,34 @@ export class GatewayRequestPipeline { }; } } + +function profileDeniedModel( + config: AppConfig, + profile: ProfileConfig | undefined, + modelBeforeRouting: string | undefined, + effectiveModel: string | undefined +): string | undefined { + const modelToAuthorize = effectiveModel ?? modelBeforeRouting; + return modelToAuthorize && !isModelAllowedForProfile(config, profile, modelToAuthorize) + ? modelToAuthorize + : undefined; +} + +function profileAllowedRouteFallback( + config: AppConfig, + profile: ProfileConfig | undefined, + fallback: RouterFallbackConfig +): RouterFallbackConfig { + if (fallback.mode !== "model-chain") { + return fallback; + } + const models = fallback.models.filter((model) => + isModelAllowedForProfile(config, profile, model) + ); + return models.length === fallback.models.length + ? fallback + : { ...fallback, models }; +} + +export const profileDeniedModelForTest = profileDeniedModel; +export const profileAllowedRouteFallbackForTest = profileAllowedRouteFallback; diff --git a/packages/core/src/profiles/model-allowlist.ts b/packages/core/src/profiles/model-allowlist.ts new file mode 100644 index 00000000..ef0fa537 --- /dev/null +++ b/packages/core/src/profiles/model-allowlist.ts @@ -0,0 +1,190 @@ +import type { ApiKeyConfig, AppConfig, ProfileConfig } from "@ccr/core/contracts/app"; +import { resolveClaudeAppGatewayRouteModel } from "@ccr/core/agents/claude-app/gateway-routes"; +import { profileApiKeyId } from "@ccr/core/profiles/api-key"; +import { modelRegistryForConfig, normalizeRouteSelector } from "@ccr/core/routing/model-registry"; + +export type ProfileModelAllowlistConfig = Pick; +export type ModelAllowlistResolutionConfig = Pick & Partial>; + +export function profileForApiKey( + config: Partial>, + apiKey: ApiKeyConfig | undefined +): ProfileConfig | undefined { + if (!config.profile || config.profile.enabled === false) { + return undefined; + } + const apiKeyId = apiKey?.id?.trim(); + if (!apiKeyId) { + return undefined; + } + return config.profile.profiles.find((profile) => + profile.enabled && profileApiKeyId(profile) === apiKeyId + ); +} + +export function profileAllowedModels(profile: ProfileConfig | undefined): string[] | undefined { + const explicit = uniqueModels((profile?.availableModels ?? []).map(normalizeAllowlistModel)); + if (!profile || explicit.length === 0) { + return undefined; + } + return uniqueModels([ + normalizeAllowlistModel(profile.model), + ...explicit + ]); +} + +export function filterModelIdsForProfile( + config: ModelAllowlistResolutionConfig, + ids: string[], + profile: ProfileConfig | undefined +): string[] { + return filterModelIdsByAllowedModels(config, ids, profileAllowedModels(profile)); +} + +export function filterModelIdsByAllowedModels( + config: ModelAllowlistResolutionConfig | undefined, + ids: string[], + allowedModels: string[] | undefined +): string[] { + const allowed = modelAllowlistKeys(config, allowedModels); + if (!allowed) { + return uniqueModels(ids); + } + return uniqueModels(ids).filter((id) => + modelKeys(config, id).some((key) => allowed.has(key)) + ); +} + +export function isModelAllowedForProfile( + config: ModelAllowlistResolutionConfig, + profile: ProfileConfig | undefined, + model: string | undefined +): boolean { + const allowed = modelAllowlistKeys(config, profileAllowedModels(profile)); + if (!allowed) { + return true; + } + const modelKeysToCheck = modelKeys(config, model); + return modelKeysToCheck.length === 0 || modelKeysToCheck.some((key) => allowed.has(key)); +} + +function modelAllowlistKeys( + config: ModelAllowlistResolutionConfig | undefined, + allowedModels: string[] | undefined +): Set | undefined { + const normalized = uniqueModels((allowedModels ?? []).map(normalizeAllowlistModel)); + if (normalized.length === 0) { + return undefined; + } + const keys = new Set(); + for (const model of normalized) { + for (const key of modelKeys(config, model)) { + keys.add(key); + } + } + return keys; +} + +function modelKeys( + config: ModelAllowlistResolutionConfig | undefined, + model: string | undefined +): string[] { + const normalized = normalizeAllowlistModel(model); + if (!normalized) { + return []; + } + const registry = config ? modelRegistryForConfig(config) : undefined; + const rawModelIsConfigured = Boolean(registry?.resolve(normalized)); + const claudeAppTarget = resolveClaudeAppRouteTarget(config, normalized); + const seeds = uniqueModels([ + ...modelKeySeeds(normalized, { includeDiscoveryAliases: !rawModelIsConfigured }), + ...(claudeAppTarget ? [claudeAppTarget] : []) + ]); + const keys = new Set(); + for (const seed of seeds) { + addKey(keys, seed); + } + if (!config || !registry) { + return [...keys]; + } + + for (const seed of seeds) { + const resolved = registry.resolve(seed); + if (resolved) { + addKey(keys, resolved.selector); + addKey(keys, resolved.canonicalSelector); + if (resolved.kind === "provider") { + addKey(keys, `${resolved.provider.name}/${resolved.model}`); + } else { + addKey(keys, resolved.model); + } + } + } + return [...keys]; +} + +function resolveClaudeAppRouteTarget( + config: ModelAllowlistResolutionConfig | undefined, + model: string +): string | undefined { + if (!config?.profile) { + return undefined; + } + return resolveClaudeAppGatewayRouteModel(model, { + Providers: config.Providers, + profile: config.profile, + virtualModelProfiles: config.virtualModelProfiles + }); +} + +function modelKeySeeds( + normalized: string, + options: { includeDiscoveryAliases: boolean } +): string[] { + if (!options.includeDiscoveryAliases) { + return [normalized]; + } + const withoutOneMillionContext = stripOneMillionContextSuffix(normalized); + const withoutClaudePrefix = stripClaudeDiscoveryPrefix(normalized); + const withoutClaudePrefixAndContext = stripOneMillionContextSuffix(withoutClaudePrefix); + return uniqueModels([ + normalized, + withoutOneMillionContext, + withoutClaudePrefix, + withoutClaudePrefixAndContext + ]); +} + +function normalizeAllowlistModel(model: string | undefined): string { + return normalizeRouteSelector(model) ?? ""; +} + +function stripClaudeDiscoveryPrefix(model: string): string { + return model.toLowerCase().startsWith("claude-") ? model.slice("claude-".length).trim() : model; +} + +function stripOneMillionContextSuffix(model: string): string { + return model.replace(/\[1m\]$/i, "").trim(); +} + +function addKey(keys: Set, value: string | undefined): void { + const normalized = normalizeAllowlistModel(value); + if (normalized) { + keys.add(normalized.toLowerCase()); + } +} + +function uniqueModels(models: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const model of models) { + const normalized = normalizeAllowlistModel(model); + const key = normalized.toLowerCase(); + if (!normalized || seen.has(key)) { + continue; + } + seen.add(key); + result.push(normalized); + } + return result; +} diff --git a/packages/core/src/profiles/service.ts b/packages/core/src/profiles/service.ts index 86d34a30..5b00a3a9 100644 --- a/packages/core/src/profiles/service.ts +++ b/packages/core/src/profiles/service.ts @@ -38,6 +38,7 @@ import { } from "@ccr/core/agents/pi/profile-config"; import { CONFIGDIR } from "@ccr/core/config/constants"; import { pruneInactiveProfileApiKeysFromList, syncProfileApiKeys } from "@ccr/core/profiles/api-key"; +import { profileAllowedModels } from "@ccr/core/profiles/model-allowlist"; import { resolveZcodeConfigFile, writeZcodeGatewayConfig, zcodeHomeFromConfigFile } from "@ccr/core/agents/zcode/profile-config"; import { CONTEXT_ARCHIVE_MCP_SERVER_NAME, contextArchiveConfigForProfile, contextArchiveMcpServer } from "@ccr/core/gateway/context-archive"; import { createClaudeCliAutoCompactWindows } from "@ccr/core/gateway/features/model-discovery"; @@ -477,8 +478,12 @@ function applyCodexProfile(config: AppConfig, profile: ProfileConfig, token: str const source = existsSync(configFile) ? readFileSync(configFile, "utf8") : ""; const configFormat = normalizeCodexConfigFormat(profile.configFormat); const modelCatalogFile = codexModelCatalogFile(configFile); - const modelCatalogResult = writeFileWithBackup(modelCatalogFile, codexModelCatalogJson(config, model)); - const appModelCatalogResult = writeCodexCompatibleAppModelCatalog(CONFIGDIR, { ...profile, model }, config); + const profileWithResolvedModel = { ...profile, model }; + const modelCatalogResult = writeFileWithBackup( + modelCatalogFile, + codexModelCatalogJson(config, model, { allowedModels: profileAllowedModels(profileWithResolvedModel) }) + ); + const appModelCatalogResult = writeCodexCompatibleAppModelCatalog(CONFIGDIR, profileWithResolvedModel, config); const showAllSessions = profile.agent === "zcode" ? false : Boolean(profile.showAllSessions); const toolHubMcpResult = writeCodexToolHubMcpRuntimeConfig(config, token); const contextArchiveMcp = profile.agent === "codex" diff --git a/packages/core/test/integration/gateway/profile-model-allowlist-http.test.mjs b/packages/core/test/integration/gateway/profile-model-allowlist-http.test.mjs new file mode 100644 index 00000000..41c72194 --- /dev/null +++ b/packages/core/test/integration/gateway/profile-model-allowlist-http.test.mjs @@ -0,0 +1,289 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import test from "node:test"; +import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts"; +import { GatewayHttpRequestHandler } from "@ccr/core/gateway/http/request-handler.ts"; +import { GatewayRequestPipeline } from "@ccr/core/gateway/request/pipeline.ts"; +import { ClaudeCodeRouterPlugin } from "@ccr/core/gateway/claude-code-router-plugin.ts"; +import { profileApiKeyId } from "@ccr/core/profiles/api-key.ts"; +import { waitForTcpListener } from "../../support/loopback-listener.mjs"; + +test("gateway HTTP model discovery and request access honor profile model allowlists", async (t) => { + const config = createProfileAllowlistHttpTestConfig(); + const plugin = new ClaudeCodeRouterPlugin(config); + const status = { + coreEndpoint: "http://127.0.0.1:1", + endpoint: "http://127.0.0.1:0", + state: "running" + }; + const pipeline = new GatewayRequestPipeline({ + getBrowserWebSearchMcpIntegration: () => undefined, + getConfig: () => config, + getCoreAuthToken: () => "test-core-token", + getPlugin: () => plugin, + getStatus: () => status + }); + const handler = new GatewayHttpRequestHandler({ + getBrowserAutomationMcpIntegration: () => undefined, + getConfig: () => config, + getPlugin: () => plugin, + getStatus: () => status, + handleBillingUsageSync: unsupportedHandler, + handleRawTraceSync: unsupportedHandler, + proxyRequest: (request, response, path, apiKey) => pipeline.proxyRequest(request, response, path, apiKey), + replayContextArchive: async () => ({ ok: false, statusCode: 404, message: "not configured" }) + }); + const server = createServer((request, response) => { + void handler.handleRequest(request, response).catch((error) => { + if (!response.headersSent) { + response.writeHead(502, { "content-type": "application/json" }); + } + response.end(JSON.stringify({ error: { message: String(error?.message ?? error) } })); + }); + }); + + try { + try { + await listen(server); + } catch (error) { + if (isLocalListenUnavailable(error)) { + t.skip(`Local HTTP listen is unavailable: ${formatError(error)}`); + return; + } + throw error; + } + await waitForTcpListener(server); + const endpoint = `http://127.0.0.1:${serverPort(server)}`; + status.endpoint = endpoint; + + const alphaModels = await fetchJson(`${endpoint}/v1/models`, { + headers: authHeaders("profile-alpha-key") + }); + assert.deepEqual(alphaModels.data.map((model) => model.id), ["Provider/alpha"]); + + const betaModels = await fetchJson(`${endpoint}/v1/models`, { + headers: authHeaders("profile-beta-key") + }); + assert.deepEqual(betaModels.data.map((model) => model.id), ["Provider/beta"]); + + const gatewayModels = await fetchJson(`${endpoint}/v1/models`, { + headers: authHeaders("gateway-key") + }); + assert.deepEqual(gatewayModels.data.map((model) => model.id), ["Provider/alpha", "Provider/beta", "Provider/gamma"]); + + const claudeModels = await fetchJson(`${endpoint}/v1/models`, { + headers: { + ...authHeaders("profile-alpha-key"), + "user-agent": "claude-cli/1.0" + } + }); + assert.deepEqual(claudeModels.data.map((model) => model.display_name), ["Provider/alpha"]); + + const claudeAppModels = await fetchJson(`${endpoint}/v1/models`, { + headers: authHeaders("profile-alpha-claude-app-key") + }); + const alphaClaudeAppRouteId = claudeAppModels.data[0]?.id; + assert.equal(claudeAppModels.data.length, 1); + assert.equal(typeof alphaClaudeAppRouteId, "string"); + assert.notEqual(alphaClaudeAppRouteId, "Provider/alpha"); + + const bootstrap = await fetchJson(`${endpoint}/api/claude_cli/bootstrap`, { + headers: authHeaders("profile-alpha-key") + }); + assert.deepEqual(bootstrap.additional_model_options.map((model) => model.display_name), ["Provider/alpha"]); + assert.deepEqual(Object.values(bootstrap.auto_compact_windows), []); + + const denied = await fetch(`${endpoint}/v1/messages`, { + body: JSON.stringify({ + max_tokens: 8, + messages: [{ role: "user", content: "hello" }], + model: "Provider/beta" + }), + headers: { + ...authHeaders("profile-alpha-key"), + "content-type": "application/json" + }, + method: "POST" + }); + const deniedBody = await denied.json(); + assert.equal(denied.status, 403); + assert.equal(deniedBody.error.code, "profile_model_not_allowed"); + + const limitedDenied = await fetch(`${endpoint}/v1/messages`, { + body: JSON.stringify({ + max_tokens: 8, + messages: [{ role: "user", content: "hello" }], + model: "Provider/beta" + }), + headers: { + ...authHeaders("profile-alpha-limited-key"), + "content-type": "application/json" + }, + method: "POST" + }); + const limitedDeniedBody = await limitedDenied.json(); + assert.equal(limitedDenied.status, 403); + assert.equal(limitedDeniedBody.error.code, "profile_model_not_allowed"); + + const deniedCountTokens = await fetch(`${endpoint}/v1/messages/count_tokens`, { + body: JSON.stringify({ + messages: [{ role: "user", content: "hello" }], + model: "Provider/beta" + }), + headers: { + ...authHeaders("profile-alpha-key"), + "content-type": "application/json" + }, + method: "POST" + }); + const deniedCountTokensBody = await deniedCountTokens.json(); + assert.equal(deniedCountTokens.status, 403); + assert.equal(deniedCountTokensBody.error.code, "profile_model_not_allowed"); + + const allowedClaudeAppCountTokens = await fetchJson(`${endpoint}/v1/messages/count_tokens`, { + body: JSON.stringify({ + messages: [{ role: "user", content: "hello" }], + model: alphaClaudeAppRouteId + }), + headers: { + ...authHeaders("profile-alpha-claude-app-limited-key"), + "content-type": "application/json" + }, + method: "POST" + }); + assert.equal(typeof allowedClaudeAppCountTokens.input_tokens, "number"); + } finally { + await closeServer(server); + } +}); + +function createProfileAllowlistHttpTestConfig() { + const alphaProfile = { + agent: "claude-code", + enabled: true, + id: "alpha-profile", + model: "Provider/alpha", + name: "Alpha Profile", + scope: "global", + availableModels: ["Provider/alpha"] + }; + const betaProfile = { + agent: "codex", + enabled: true, + id: "beta-profile", + model: "Provider/beta", + name: "Beta Profile", + scope: "ccr", + availableModels: ["Provider/beta"] + }; + const config = createDefaultAppConfig(); + config.APIKEY = "gateway-key"; + config.APIKEYS = [ + { + createdAt: "2026-01-01T00:00:00.000Z", + id: "gateway", + key: "gateway-key", + name: "Gateway" + }, + { + createdAt: "2026-01-01T00:00:00.000Z", + id: profileApiKeyId(alphaProfile), + key: "profile-alpha-key", + name: "Profile: Alpha Profile" + }, + { + createdAt: "2026-01-01T00:00:00.000Z", + id: profileApiKeyId(alphaProfile), + key: "profile-alpha-limited-key", + limits: { maxRequests: 1, windowMs: 60_000 }, + name: "Profile: Alpha Profile" + }, + { + createdAt: "2026-01-01T00:00:00.000Z", + id: profileApiKeyId(alphaProfile), + key: "profile-alpha-claude-app-key", + name: "Claude App" + }, + { + createdAt: "2026-01-01T00:00:00.000Z", + id: profileApiKeyId(alphaProfile), + key: "profile-alpha-claude-app-limited-key", + limits: { maxRequests: 1, windowMs: 60_000 }, + name: "Claude App" + }, + { + createdAt: "2026-01-01T00:00:00.000Z", + id: profileApiKeyId(betaProfile), + key: "profile-beta-key", + name: "Profile: Beta Profile" + } + ]; + config.Providers = [{ + api_key: "test-provider-key", + base_url: "http://127.0.0.1:1/v1", + models: ["alpha", "beta", "gamma"], + name: "Provider", + type: "openai_responses" + }]; + config.profile = { + ...config.profile, + enabled: true, + profiles: [alphaProfile, betaProfile] + }; + config.gateway.enabled = true; + return config; +} + +function authHeaders(token) { + return { + authorization: `Bearer ${token}` + }; +} + +async function fetchJson(url, init) { + const response = await fetch(url, init); + const text = await response.text(); + assert.equal(response.status, 200, text); + return JSON.parse(text); +} + +function listen(server) { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); +} + +function serverPort(server) { + const address = server.address(); + assert.equal(typeof address, "object"); + assert.ok(address); + return address.port; +} + +function closeServer(server) { + return new Promise((resolve, reject) => { + if (!server.listening) { + resolve(); + return; + } + server.close((error) => { + error ? reject(error) : resolve(); + }); + }); +} + +async function unsupportedHandler() { + throw new Error("not supported in this test"); +} + +function isLocalListenUnavailable(error) { + return error && typeof error === "object" && (error.code === "EPERM" || error.code === "EACCES"); +} + +function formatError(error) { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/core/test/unit/agents/codex-model-catalog.test.mjs b/packages/core/test/unit/agents/codex-model-catalog.test.mjs index 94786ca7..63872af7 100644 --- a/packages/core/test/unit/agents/codex-model-catalog.test.mjs +++ b/packages/core/test/unit/agents/codex-model-catalog.test.mjs @@ -22,6 +22,19 @@ test("codex catalog removes duplicate model IDs case-insensitively without reord assert.deepEqual(ids, ["provider/model-a", "Provider/MODEL-B"]); }); +test("codex catalog filters model IDs with a profile allowlist", () => { + const ids = buildCodexModelCatalogIds({ + Providers: [ + { name: "Provider", type: "openai_responses", models: ["alpha", "beta"] }, + { name: "Other", type: "openai_responses", models: ["gamma"] } + ] + }, "Provider/alpha", { + allowedModels: ["Provider/alpha"] + }); + + assert.deepEqual(ids, ["Provider/alpha"]); +}); + test("codex catalog treats unknown models as text-only while enabling apply_patch", () => { const model = catalogModelFor({ Providers: [ diff --git a/packages/core/test/unit/profiles/profile-model-allowlist.test.mjs b/packages/core/test/unit/profiles/profile-model-allowlist.test.mjs new file mode 100644 index 00000000..da606e84 --- /dev/null +++ b/packages/core/test/unit/profiles/profile-model-allowlist.test.mjs @@ -0,0 +1,195 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { buildClaudeAppGatewayModelRoutes } from "@ccr/core/agents/claude-app/gateway-routes.ts"; +import { createClaudeCliBootstrapResponse, createGatewayModelsResponse } from "@ccr/core/gateway/features/model-discovery.ts"; +import { + filterModelIdsForProfile, + isModelAllowedForProfile, + profileAllowedModels, + profileForApiKey +} from "@ccr/core/profiles/model-allowlist.ts"; +import { + profileAllowedRouteFallbackForTest, + profileDeniedModelForTest +} from "@ccr/core/gateway/request/pipeline.ts"; +import { profileApiKeyId } from "@ccr/core/profiles/api-key.ts"; + +function testConfig(profileOverrides = {}) { + const profile = { + agent: "codex", + enabled: true, + id: "work", + model: "Provider/alpha", + name: "Work", + scope: "ccr", + ...profileOverrides + }; + return { + Providers: [ + { models: ["alpha", "beta"], name: "Provider", type: "openai_responses" }, + { models: ["gamma"], name: "Other", type: "openai_responses" } + ], + profile: { + enabled: true, + profiles: [profile] + }, + Router: { + fallback: { mode: "off", models: [], retryCount: 0 }, + rules: [] + }, + virtualModelProfiles: [] + }; +} + +test("profile model allowlist resolves profile API keys and filters model IDs", () => { + const config = testConfig({ availableModels: ["Provider/alpha"] }); + const profile = config.profile.profiles[0]; + const apiKey = { + createdAt: "2026-01-01T00:00:00.000Z", + id: profileApiKeyId(profile), + key: "profile-key", + name: "Profile: Work" + }; + + assert.equal(profileForApiKey(config, apiKey), profile); + assert.deepEqual(profileAllowedModels(profile), ["Provider/alpha"]); + assert.deepEqual( + filterModelIdsForProfile(config, ["Provider/alpha", "Provider/beta", "Other/gamma"], profile), + ["Provider/alpha"] + ); + assert.equal(isModelAllowedForProfile(config, profile, "Provider/alpha"), true); + assert.equal(isModelAllowedForProfile(config, profile, "Provider/beta"), false); +}); + +test("profile model allowlist keeps existing all-model behavior when unset", () => { + const config = testConfig(); + const profile = config.profile.profiles[0]; + + assert.equal(profileAllowedModels(profile), undefined); + assert.deepEqual( + filterModelIdsForProfile(config, ["Provider/alpha", "Provider/beta", "Other/gamma"], profile), + ["Provider/alpha", "Provider/beta", "Other/gamma"] + ); + assert.equal(isModelAllowedForProfile(config, profile, "Provider/beta"), true); +}); + +test("profile model allowlist does not add implicit defaults to explicit lists", () => { + const config = testConfig({ availableModels: ["Provider/beta"], model: "" }); + const profile = config.profile.profiles[0]; + + assert.deepEqual(profileAllowedModels(profile), ["Provider/beta"]); + assert.equal(isModelAllowedForProfile(config, profile, "Provider/alpha"), false); + assert.equal(isModelAllowedForProfile(config, profile, "Provider/beta"), true); +}); + +test("profile model allowlist does not treat configured claude-prefixed models as discovery aliases", () => { + const config = testConfig({ + availableModels: ["Provider/sonnet"], + model: "Provider/sonnet" + }); + config.Providers = [{ + models: ["sonnet", "claude-sonnet"], + name: "Provider", + type: "openai_responses" + }]; + const profile = config.profile.profiles[0]; + + assert.equal(isModelAllowedForProfile(config, profile, "claude-sonnet"), false); + assert.equal(isModelAllowedForProfile(config, profile, "Provider/claude-sonnet"), false); + assert.equal(isModelAllowedForProfile(config, profile, "claude-Provider/sonnet"), true); + assert.equal(isModelAllowedForProfile(config, profile, "Provider/sonnet[1m]"), true); +}); + +test("profile model allowlist resolves Claude App encoded route IDs", () => { + const config = testConfig({ availableModels: ["Provider/alpha"] }); + const profile = config.profile.profiles[0]; + const apiKey = { + createdAt: "2026-01-01T00:00:00.000Z", + id: profileApiKeyId(profile), + key: "profile-key", + name: "Claude App" + }; + const response = createGatewayModelsResponse(config, {}, apiKey); + const routeId = response.data[0]?.id; + const betaRouteId = buildClaudeAppGatewayModelRoutes(config).find((route) => + route.targetModel === "Provider/beta" + )?.id; + + assert.ok(routeId); + assert.notEqual(routeId, "Provider/alpha"); + assert.ok(betaRouteId); + assert.equal(isModelAllowedForProfile(config, profile, routeId), true); + assert.equal(isModelAllowedForProfile(config, profile, betaRouteId), false); +}); + +test("profile model access is decided by the final routed model", () => { + const config = testConfig({ availableModels: ["Provider/alpha"] }); + const profile = config.profile.profiles[0]; + + assert.equal( + profileDeniedModelForTest(config, profile, "Provider/alpha", "Provider/beta"), + "Provider/beta" + ); + assert.equal( + profileDeniedModelForTest(config, profile, "Provider/beta", "Provider/alpha"), + undefined + ); +}); + +test("profile model allowlist filters model-chain fallback attempts", () => { + const config = testConfig({ availableModels: ["Provider/alpha"] }); + const profile = config.profile.profiles[0]; + + assert.deepEqual( + profileAllowedRouteFallbackForTest(config, profile, { + mode: "model-chain", + models: ["Provider/beta", "Provider/alpha", "Other/gamma"], + retryCount: 0 + }), + { + mode: "model-chain", + models: ["Provider/alpha"], + retryCount: 0 + } + ); +}); + +test("gateway model discovery uses the authenticated profile allowlist", () => { + const config = testConfig({ availableModels: ["Provider/alpha"] }); + const profile = config.profile.profiles[0]; + const apiKey = { + createdAt: "2026-01-01T00:00:00.000Z", + id: profileApiKeyId(profile), + key: "profile-key", + name: "Profile: Work" + }; + + const response = createGatewayModelsResponse(config, {}, apiKey); + assert.deepEqual(response.data.map((model) => model.id), ["Provider/alpha"]); + + const unrestricted = createGatewayModelsResponse(config, {}, { + createdAt: "2026-01-01T00:00:00.000Z", + id: "general", + key: "general-key", + name: "General" + }); + assert.deepEqual(unrestricted.data.map((model) => model.id), ["Provider/alpha", "Provider/beta", "Other/gamma"]); +}); + +test("Claude CLI bootstrap options are filtered by profile allowlist", () => { + const config = testConfig({ + agent: "claude-code", + availableModels: ["Provider/alpha"], + scope: "global" + }); + const profile = config.profile.profiles[0]; + const apiKey = { + createdAt: "2026-01-01T00:00:00.000Z", + id: profileApiKeyId(profile), + key: "profile-key", + name: "Profile: Work" + }; + + const response = createClaudeCliBootstrapResponse(config, apiKey); + assert.equal(response.additional_model_options.length, 1); +}); diff --git a/packages/ui/src/pages/home/components/model-selector.tsx b/packages/ui/src/pages/home/components/model-selector.tsx index 8573defc..f77743de 100644 --- a/packages/ui/src/pages/home/components/model-selector.tsx +++ b/packages/ui/src/pages/home/components/model-selector.tsx @@ -315,11 +315,13 @@ export function ModelSelector({ export function ModelMultiSelector({ onChange, providers, + requiredValues = [], value, virtualModelProfiles = [] }: { onChange: (value: string[]) => void; providers: GatewayProviderConfig[]; + requiredValues?: string[]; value: string[]; virtualModelProfiles?: VirtualModelProfileConfig[]; }) { @@ -340,16 +342,30 @@ export function ModelMultiSelector({ model.provider.toLowerCase().includes(normalizedQuery) || model.value.toLowerCase().includes(normalizedQuery) || model.displayName.toLowerCase().includes(normalizedQuery)); - const selected = new Set(value.map(normalizeProviderModelSelector).filter(Boolean)); + const required = new Set(requiredValues.map(modelSelectionKey).filter(Boolean)); + const selected = new Set([...value, ...requiredValues].map(modelSelectionKey).filter(Boolean)); + const hasOptionalSelection = Array.from(selected).some((key) => !required.has(key)); function toggleModel(model: string) { - onChange(selected.has(model) - ? value.filter((candidate) => normalizeProviderModelSelector(candidate) !== model) + const key = modelSelectionKey(model); + if (required.has(key) && selected.has(key)) { + return; + } + onChange(selected.has(key) + ? value.filter((candidate) => modelSelectionKey(candidate) !== key) : [...value, model]); } function selectVisibleModels() { - onChange(Array.from(new Set([...value.map(normalizeProviderModelSelector).filter(Boolean), ...models.map((model) => model.value)]))); + onChange(Array.from(new Set([ + ...requiredValues.map(normalizeProviderModelSelector).filter(Boolean), + ...value.map(normalizeProviderModelSelector).filter(Boolean), + ...models.map((model) => model.value) + ]))); + } + + function clearOptionalModels() { + onChange(requiredValues.map(normalizeProviderModelSelector).filter(Boolean)); } return ( @@ -368,7 +384,7 @@ export function ModelMultiSelector({ - @@ -380,17 +396,20 @@ export function ModelMultiSelector({ ) : null}
{models.map((model) => { - const checked = selected.has(model.value); + const key = modelSelectionKey(model.value); + const checked = selected.has(key); + const locked = checked && required.has(key); return ( ); @@ -400,3 +419,7 @@ export function ModelMultiSelector({
); } + +function modelSelectionKey(value: string): string { + return normalizeProviderModelSelector(value).toLowerCase(); +} diff --git a/packages/ui/src/pages/home/components/profiles.tsx b/packages/ui/src/pages/home/components/profiles.tsx index 36eb03f5..707198f2 100644 --- a/packages/ui/src/pages/home/components/profiles.tsx +++ b/packages/ui/src/pages/home/components/profiles.tsx @@ -7,7 +7,7 @@ import { normalizeProfileScope, normalizeProfileSurface, Pencil, Plus, PopoverContent, profileAgentLabel, profileAgentOptions, ProfileConfig, type ProfileAgentOption, profileModelProviderOptions, profileOpenSurfaces, profileScopeLabel, profileScopeOptions, profileSummaryItems, profileSurfaceLabel, profileSurfaceOptions, Play, Power, RefreshCw, Select, SelectControl, Terminal, Toggle, translateOptions, Trash2, useAppErrorText, useAppText, useLayoutEffect, type ProfileOpenSurface, type ProfileRuntimeStatus, type ReactDragEvent, type ReactNode, type VirtualModelProfileConfig, - copyTextToClipboard, formatRouterRuleCondition, formatRouterRuleTarget, isRoutingRuleDraftSubmittable, routerRuleTypeLabel, routingRuleFromDraft, type RouterRule, validateProfileEnvRows, + copyTextToClipboard, formatRouterRuleCondition, formatRouterRuleTarget, isRoutingRuleDraftSubmittable, normalizeProviderModelSelector, routerRuleTypeLabel, routingRuleFromDraft, type RouterRule, uniqueStrings, validateProfileEnvRows, useCallback, useEffect, useMemo, useRef, useState, X } from "../shared/index"; import { PopoverPortal } from "@/components/ui/popover"; @@ -710,6 +710,15 @@ export function AddProfileForm({ [providers, virtualModelProfiles] ); const availableModelCount = modelProviderOptions.reduce((count, provider) => count + provider.models.length, 0); + const allAllowedModelValues = useMemo( + () => profileAllowedModelValues(modelProviderOptions), + [modelProviderOptions] + ); + const allowedModelSelectorValue = draft.availableModels.length > 0 + ? draft.availableModels + : draft.model.trim() + ? allAllowedModelValues + : []; const modelPlaceholder = firstProfileModelPlaceholder(modelProviderOptions); const validation = profileDraftValidation(draft, botConfigs, availableModelCount); const isClaudeDesignProfile = draft.agent === "claude-design"; @@ -717,15 +726,14 @@ export function AddProfileForm({ const optionalFieldLabel = t("Optional"); const requiredFieldLabel = t("Required"); const advancedIssueCount = [ - validation.providerId, - validation.providerName, + validation.allowedModels, validation.bot, validation.handoff, validation.env ].filter(Boolean).length; const advancedSummary = advancedIssueCount > 0 ? t("Advanced settings need attention") - : t("Paths, routing, bot, compact, and env"); + : t("Models, paths, routing, bot, compact, and env"); const handleAppPathDrop = useCallback((event: ReactDragEvent) => { if (!showAppPathField) { return; @@ -916,27 +924,10 @@ export function AddProfileForm({ providers={providers} value={draft.model} virtualModelProfiles={virtualModelProfiles} - onChange={(model) => onChange({ - availableModels: model && !draft.availableModels.includes(model) - ? [model, ...draft.availableModels] - : draft.availableModels, - model - })} + onChange={(model) => onChange({ model })} /> {validation.kimiModel ? {t(validation.kimiModel)} : null} - - onChange({ - availableModels, - model: availableModels.includes(draft.model) ? draft.model : availableModels[0] ?? "" - })} - /> - {validation.kimiAvailableModels ? {t(validation.kimiAvailableModels)} : null} - ) : draft.agent === "claude-design" ? null : ( <> @@ -990,23 +981,27 @@ export function AddProfileForm({ onChange={onChange} providers={providers} /> - {draft.agent !== "claude-code" && draft.agent !== "grok" && draft.agent !== "kimi" && draft.agent !== "pi" ? ( - <> - - onChange({ providerId: event.target.value })} /> - {validation.providerId ? {t(validation.providerId)} : null} - - - onChange({ providerName: event.target.value })} /> - {validation.providerName ? {t(validation.providerName)} : null} - - {draft.agent !== "zcode" && draft.agent !== "opencode" && draft.agent !== "kilo" ? ( -
- {t("Show all sessions")} - onChange({ showAllSessions })} /> -
- ) : null} - + + { + const nextAvailableModels = normalizeAllowedModelSelection(availableModels, allAllowedModelValues); + onChange({ + availableModels: nextAvailableModels, + model: nextAvailableModels.length === 0 || nextAvailableModels.includes(draft.model) ? draft.model : nextAvailableModels[0] ?? "" + }); + }} + /> + {validation.allowedModels ? {t(validation.allowedModels)} : null} + + {draft.agent === "codex" ? ( +
+ {t("Show all sessions")} + onChange({ showAllSessions })} /> +
) : null} {draft.agent === "claude-code" || draft.agent === "codex" ? (
@@ -1267,12 +1262,26 @@ function firstProfileModelPlaceholder(providers: ReturnType): string[] { + return uniqueStrings(providers.flatMap((provider) => + provider.models.map((model) => normalizeProviderModelSelector(`${provider.name}/${model}`)) + ).filter(Boolean)); +} + +function normalizeAllowedModelSelection(value: string[], allValues: string[]): string[] { + const selected = uniqueStrings(value.map(normalizeProviderModelSelector).filter(Boolean)); + const allSelected = allValues.length > 0 && + selected.length === allValues.length && + selected.every((model) => allValues.includes(model)); + return allSelected ? [] : selected; +} + function profileDraftValidation( draft: AddProfileDraft, botConfigs: BotGatewaySavedConfig[], availableModelCount: number -): Partial> { - const issues: Partial> = {}; +): Partial> { + const issues: Partial> = {}; if (!draft.name.trim()) { issues.name = "Profile name is required."; } @@ -1282,21 +1291,13 @@ function profileDraftValidation( if (draft.agent === "claude-code" && !draft.model.trim()) { issues.defaultModel = "Default model is required."; } + if (draft.availableModels.length > 0 && !draft.model.trim()) { + issues.allowedModels = "Default model is required when allowed model list is set."; + } if (draft.agent === "kimi") { if (!draft.model.trim()) { issues.kimiModel = "Kimi model is required."; } - if (draft.availableModels.length === 0) { - issues.kimiAvailableModels = "Select at least one allowed model."; - } - } - if (draft.agent !== "claude-code" && draft.agent !== "grok" && draft.agent !== "kimi" && draft.agent !== "pi" && draft.agent !== "claude-design") { - if (!draft.providerId.trim()) { - issues.providerId = "Provider ID is required."; - } - if (!draft.providerName.trim()) { - issues.providerName = "Provider name is required."; - } } if (draft.surface !== "cli" && draft.botEnabled && !botConfigs.some((config) => config.id === draft.botConfigId.trim())) { issues.bot = "Select an existing bot or turn Bot off."; diff --git a/packages/ui/src/pages/home/shared/i18n.tsx b/packages/ui/src/pages/home/shared/i18n.tsx index 9992c377..20bf56d4 100644 --- a/packages/ui/src/pages/home/shared/i18n.tsx +++ b/packages/ui/src/pages/home/shared/i18n.tsx @@ -302,6 +302,7 @@ export const appCopy: Record = { "Generated output is limited to 1 token for connectivity checks.": "Generated output is limited to 1 token for connectivity checks.", "Gateway service": "Gateway service", "Grok CLI profiles use CCR scope and CLI entry mode.": "Grok CLI profiles use CCR scope and CLI entry mode.", + "Allowed model list": "Allowed model list", "Allowed models": "Allowed models", "Import local agent login": "Import local agent login", "Import local agent provider": "Import local agent provider", @@ -387,7 +388,6 @@ export const appCopy: Record = { "Optional. Check Connection sends a real model request and may consume provider credits.": "Optional. Check Connection sends a real model request and may consume provider credits.", "Select at least one protocol.": "Select at least one protocol.", "Select at least one available model.": "Select at least one available model.", - "Select at least one allowed model.": "Select at least one allowed model.", "Select default model": "Select default model", "Select models": "Select models", "Search added models": "Search added models", @@ -429,12 +429,14 @@ export const appCopy: Record = { "Clear filters": "Clear filters", "Clear filters or broaden the search to find more request logs.": "Clear filters or broaden the search to find more request logs.", "Default model is required.": "Default model is required.", + "Default model is required when allowed model list is set.": "Default model is required when allowed model list is set.", "Enable at least one provider model before saving this profile.": "Enable at least one provider model before saving this profile.", "Enable request logs": "Enable request logs", "Environment variable rows need valid keys.": "Environment variable rows need valid keys.", "Idle seconds must be between 30 and 86400.": "Idle seconds must be between 30 and 86400.", "No request logs match the current filters.": "No request logs match the current filters.", "No request logs yet.": "No request logs yet.", + "Models, paths, routing, bot, compact, and env": "Models, paths, routing, bot, compact, and env", "Paths, routing, bot, compact, and env": "Paths, routing, bot, compact, and env", "Paths, bot, compact, and env": "Paths, bot, compact, and env", "Enhanced route": "Enhanced route", @@ -932,6 +934,7 @@ export const appCopy: Record = { "Kimi profiles need a default model and an allowed model list.": "Kimi 配置档案需要一个默认模型和可用模型列表。", "Kimi model": "Kimi 模型", "Kimi model is required.": "Kimi 模型不能为空。", + "Allowed model list": "允许模型列表", "Allowed models": "允许模型", "Pi": "Pi", "Pi model": "Pi 模型", @@ -1192,6 +1195,7 @@ export const appCopy: Record = { "Model": "模型", "Default model": "默认模型", "Default model is required.": "默认模型不能为空。", + "Default model is required when allowed model list is set.": "设置允许模型列表时默认模型不能为空。", "Model descriptions": "模型描述", "Model override": "默认模型", "Model routing": "模型路由", @@ -1367,6 +1371,7 @@ export const appCopy: Record = { "Retry": "继续重试", "Retry attempts": "重试尝试", "Weighted order": "按权重排序", + "Models, paths, routing, bot, compact, and env": "模型、路径、路由、Bot、压缩和环境变量", "Paths, routing, bot, compact, and env": "路径、路由、Bot、压缩和环境变量", "Paths, bot, compact, and env": "路径、Bot、压缩和环境变量", "Ready to route": "可以开始路由", @@ -1448,7 +1453,6 @@ export const appCopy: Record = { "Select account": "选择账户", "Select an existing bot or turn Bot off.": "请选择已有 Bot,或关闭 Bot。", "Select at least one available model.": "请至少选择一个可用模型。", - "Select at least one allowed model.": "请至少选择一个允许模型。", "Select default model": "选择默认模型", "Select bot": "选择 Bot", "Select data": "选择数据", diff --git a/packages/ui/src/pages/home/shared/profiles.ts b/packages/ui/src/pages/home/shared/profiles.ts index 6a67894b..664aefd4 100644 --- a/packages/ui/src/pages/home/shared/profiles.ts +++ b/packages/ui/src/pages/home/shared/profiles.ts @@ -515,6 +515,7 @@ export function createProfileDraftFromProfile(profile: ProfileConfig, botConfigs botConfigId, botEnabled: surface !== "cli" && Boolean(selectedBot || profile.botGateway?.enabled), envRows: keyValueRowsFromRecord(claudeCodeProfileEnv(profile.env ?? {})), + availableModels: profileDraftAvailableModels(profile), fableModel: profile.fableModel ?? "", haikuModel: profile.haikuModel ?? profile.smallFastModel ?? "", managedCompact: Boolean(profile.managedCompact), @@ -531,9 +532,7 @@ export function createProfileDraftFromProfile(profile: ProfileConfig, botConfigs return { ...createProfileDraft(profile.agent, profile.name), ...createProfileRoutingDraft(profile.routing), - availableModels: profile.agent === "kimi" - ? uniqueStrings([profile.model, ...(profile.availableModels ?? [])].map(normalizeProfileClientModel).filter(Boolean)) - : [], + availableModels: profileDraftAvailableModels(profile), envRows: keyValueRowsFromRecord(codexCompatibleProfileEnv(profile.env ?? {})), model: profile.model, scope: "ccr", @@ -556,6 +555,7 @@ export function createProfileDraftFromProfile(profile: ProfileConfig, botConfigs ...createProfileRoutingDraft(profile.routing), ...botDraft, appPath: profile.appPath ?? "", + availableModels: profileDraftAvailableModels(profile), botConfigId, botEnabled: surface !== "cli" && Boolean(selectedBot || profile.botGateway?.enabled), configFile: profile.configFile ?? defaultCodexConfigFile(profile.agent), @@ -584,6 +584,9 @@ export function isProfileDraftSubmittable(draft: AddProfileDraft): boolean { if (botAllowed && draft.botEnabled && draft.botHandoffEnabled && !isNumberDraftValid(draft.botHandoffIdleSeconds, 30, 86_400)) { return false; } + if (draft.availableModels.length > 0 && !draft.model.trim()) { + return false; + } if (draft.agent === "claude-code") { return Boolean(draft.model.trim()); } @@ -597,12 +600,9 @@ export function isProfileDraftSubmittable(draft: AddProfileDraft): boolean { return true; } if (draft.agent === "kimi") { - return Boolean(draft.model.trim()) && draft.availableModels.length > 0; + return Boolean(draft.model.trim()); } - return ( - Boolean(draft.providerId.trim()) && - Boolean(draft.providerName.trim()) - ); + return true; } function matchingBotConfigId(botGateway: BotGatewayRuntimeConfig | undefined, botConfigs: BotGatewaySavedConfig[]): string { @@ -642,7 +642,7 @@ export function profileConfigFromDraft( return normalizeProfileItem({ agent: draft.agent, appPath: draft.appPath, - availableModels: draft.agent === "kimi" ? draft.availableModels : undefined, + availableModels: profileConfigAvailableModelsFromDraft(draft), ...botGateway, configFile: draft.configFile, enabled: existingProfile?.enabled ?? true, @@ -658,8 +658,8 @@ export function profileConfigFromDraft( model: draft.model, name: draft.name, opusModel: draft.opusModel, - providerId: draft.providerId, - providerName: draft.providerName, + providerId: draft.providerId.trim() || "claude-code-router", + providerName: draft.providerName.trim() || "Claude Code Router", ...(routing ? { routing } : {}), scope: draft.scope, settingsFile: draft.settingsFile, @@ -670,6 +670,19 @@ export function profileConfigFromDraft( }, existingProfiles.length); } +function profileDraftAvailableModels(profile: ProfileConfig): string[] { + return profile.availableModels?.length + ? uniqueStrings([profile.model, ...profile.availableModels].map(normalizeProfileClientModel).filter(Boolean)) + : []; +} + +function profileConfigAvailableModelsFromDraft(draft: AddProfileDraft): string[] | undefined { + const availableModels = uniqueStrings(draft.availableModels.map(normalizeProfileClientModel).filter(Boolean)); + return availableModels.length > 0 + ? uniqueStrings([draft.model, ...availableModels].map(normalizeProfileClientModel).filter(Boolean)) + : undefined; +} + function botGatewayHandoffFromProfileDraft( draft: AddProfileDraft, fallback: BotGatewayRuntimeConfig["handoff"] = fallbackConfig.botGateway.handoff @@ -1221,6 +1234,12 @@ export function profileSummaryItems( : profile.agent === "claude-code" ? t("Keep Claude Code default") : defaultProfileClientModel(config); + const allowedModelSummaryItems = profile.availableModels?.length + ? [{ + label: t("Allowed model list"), + value: String(uniqueStrings([profile.model, ...profile.availableModels].filter(Boolean)).length) + }] + : []; if (profile.agent === "claude-code") { const aliasItems = [ @@ -1236,6 +1255,7 @@ export function profileSummaryItems( })); return [ { label: t("Model"), value: modelValue }, + ...allowedModelSummaryItems, ...aliasItems, ...managedCompactItems, ...routingSummaryItems, @@ -1248,12 +1268,7 @@ export function profileSummaryItems( if (profile.agent === "grok" || profile.agent === "kimi" || profile.agent === "pi") { return [ { label: t(profile.agent === "kimi" ? "Kimi model" : profile.agent === "pi" ? "Pi model" : "Model"), value: modelValue }, - ...(profile.agent === "kimi" - ? [{ - label: t("Allowed models"), - value: String(uniqueStrings([profile.model, ...(profile.availableModels ?? [])].filter(Boolean)).length) - }] - : []), + ...allowedModelSummaryItems, ...routingSummaryItems, ...envSummaryItems ]; @@ -1268,7 +1283,7 @@ export function profileSummaryItems( return [ { label: t(profile.agent === "kilo" ? "Kilo model" : "Model"), value: modelValue }, - { label: t("Provider ID"), value: profile.providerId ?? "claude-code-router" }, + ...allowedModelSummaryItems, ...(profile.agent === "zcode" || profile.agent === "opencode" || profile.agent === "kilo" || !profile.showAllSessions ? [] : [{ label: t("Show all sessions"), value: t("Enabled") }]), ...managedCompactItems, ...routingSummaryItems, @@ -1282,10 +1297,10 @@ export function normalizeProfileItem(profile: ProfileConfig, index: number): Pro const agent = normalizeProfileAgent(profile.agent); const name = profile.name.trim() || profileAgentLabel(profile.agent); const model = profile.model.trim(); - const availableModels = uniqueStrings([ - model, - ...(profile.availableModels ?? []).map(normalizeProfileClientModel) - ].filter(Boolean)); + const explicitAvailableModels = uniqueStrings((profile.availableModels ?? []).map(normalizeProfileClientModel).filter(Boolean)); + const availableModels = explicitAvailableModels.length > 0 + ? uniqueStrings([model, ...explicitAvailableModels].filter(Boolean)) + : undefined; const scope = normalizeProfileScope(profile.scope); const surface = normalizeProfileSurfaceForAgent(agent, profile.surface); const env = isPlainRecord(profile.env) ? stringRecordValue(profile.env) : {}; @@ -1299,6 +1314,7 @@ export function normalizeProfileItem(profile: ProfileConfig, index: number): Pro ...(surface !== "cli" && appPath ? { appPath } : {}), ...(botConfigId ? { botConfigId } : {}), ...(botGateway ? { botGateway } : {}), + ...(availableModels ? { availableModels } : {}), enabled: profile.enabled, env: claudeCodeProfileEnv(env), fableModel: stringValue(profile.fableModel) || "", @@ -1319,7 +1335,7 @@ export function normalizeProfileItem(profile: ProfileConfig, index: number): Pro if (agent === "grok" || agent === "kimi" || agent === "pi") { return { agent, - ...(agent === "kimi" ? { availableModels } : {}), + ...(availableModels ? { availableModels } : {}), enabled: profile.enabled, env: codexCompatibleProfileEnv(env), id: profile.id || `profile-${index + 1}`, @@ -1348,6 +1364,7 @@ export function normalizeProfileItem(profile: ProfileConfig, index: number): Pro ...(surface !== "cli" && agent !== "zcode" && profile.appPath?.trim() ? { appPath: profile.appPath.trim() } : {}), ...(botConfigId ? { botConfigId } : {}), ...(botGateway ? { botGateway } : {}), + ...(availableModels ? { availableModels } : {}), cliMiddleware: true, codexCliPath: "", codexHome: "", diff --git a/packages/ui/test/component/onboarding.test.tsx b/packages/ui/test/component/onboarding.test.tsx index dd5b4654..2dc8803c 100644 --- a/packages/ui/test/component/onboarding.test.tsx +++ b/packages/ui/test/component/onboarding.test.tsx @@ -104,5 +104,5 @@ test("Onboarding profile step does not prompt to save before continuing", () => assert.doesNotMatch(html, /Save this agent profile to continue/); assert.doesNotMatch(html, /Choose an agent, model, and required profile settings/); assert.doesNotMatch(html, /provider identity/); - assert.match(html, /Paths, routing, bot, compact, and env/); + assert.match(html, /Models, paths, routing, bot, compact, and env/); }); diff --git a/packages/ui/test/component/profiles.test.tsx b/packages/ui/test/component/profiles.test.tsx index 0c9c7f35..8167d297 100644 --- a/packages/ui/test/component/profiles.test.tsx +++ b/packages/ui/test/component/profiles.test.tsx @@ -98,13 +98,17 @@ test("AddProfileForm shows enhanced route as a sibling of profile routing", () = const advancedSettingsIndex = html.indexOf("Advanced settings"); const enhancedRouteIndex = html.indexOf("Enhanced route"); const profileRoutingIndex = html.indexOf("Profile routing"); + const allowedModelListIndex = html.indexOf("Allowed model list"); assert.ok(advancedSettingsIndex >= 0); assert.ok(enhancedRouteIndex > advancedSettingsIndex); assert.ok(profileRoutingIndex > advancedSettingsIndex); + assert.ok(allowedModelListIndex > advancedSettingsIndex); + assert.ok(allowedModelListIndex > profileRoutingIndex); assert.ok(enhancedRouteIndex < profileRoutingIndex); assert.doesNotMatch(html, /Routing disabled/); assert.match(html, /Enhanced route/); + assert.match(html, /Allowed model list/); assert.match(html, /rounded-b-none/); assert.match(html, /rounded-b-md border border-t-0/); assert.doesNotMatch(html, /Profile routes/); @@ -136,10 +140,11 @@ test("AddProfileForm shows private profile routes when profile routing is enable test("AddProfileForm uses Codex-specific enhanced route info for Codex profiles", () => { const config = appConfigFixture(); + const draft = { ...createProfileDraft("codex"), providerId: "", providerName: "", routingEnabled: true }; const html = renderToStaticMarkup( undefined} @@ -151,6 +156,107 @@ test("AddProfileForm uses Codex-specific enhanced route info for Codex profiles" assert.match(html, /Enhanced route/); assert.match(html, /CCR built-in Codex routing optimizes requests to third-party models for this profile\./); + assert.doesNotMatch(html, /Provider ID/); + assert.doesNotMatch(html, /Provider name/); + assert.equal(isProfileDraftSubmittable(draft), true); + assert.equal(profileConfigFromDraft(draft, []).providerId, "claude-code-router"); + assert.equal(profileConfigFromDraft(draft, []).providerName, "Claude Code Router"); +}); + +test("explicit allowed model lists require a default model", () => { + const config = appConfigFixture(); + config.Providers = [{ + models: ["alpha", "beta"], + name: "Provider" + }]; + const draft = { + ...createProfileDraft("codex"), + availableModels: ["Provider/alpha"] + }; + const html = renderToStaticMarkup( + undefined} + onCreateBot={() => undefined} + providers={config.Providers} + virtualModelProfiles={config.virtualModelProfiles} + /> + ); + + assert.equal(isProfileDraftSubmittable(draft), false); + assert.match(html, /Advanced settings need attention/); + assert.match(html, /Default model is required when allowed model list is set\./); +}); + +test("AddProfileForm does not render unrestricted allowed models as selected without a default model", () => { + const config = appConfigFixture(); + config.Providers = [{ + models: ["alpha", "beta"], + name: "Provider" + }]; + const html = renderToStaticMarkup( + undefined} + onCreateBot={() => undefined} + providers={config.Providers} + virtualModelProfiles={config.virtualModelProfiles} + /> + ); + const alphaIndex = html.indexOf('title="Provider/alpha"'); + const betaIndex = html.indexOf('title="Provider/beta"'); + const alphaLabel = html.slice(alphaIndex, betaIndex); + const betaLabel = html.slice(betaIndex, html.indexOf("", betaIndex)); + const clearIndex = html.indexOf(">Clear"); + const clearButton = html.slice(html.lastIndexOf("= 0); + assert.ok(betaIndex > alphaIndex); + assert.match(alphaLabel, /aria-checked="false"/); + assert.match(betaLabel, /aria-checked="false"/); + assert.match(clearButton, /disabled=""/); +}); + +test("AddProfileForm keeps the default model locked in allowed model lists", () => { + const config = appConfigFixture(); + config.Providers = [{ + models: ["alpha", "beta"], + name: "Provider" + }]; + const draft = { + ...createProfileDraft("codex"), + availableModels: ["Provider/alpha"], + model: "Provider/alpha" + }; + const html = renderToStaticMarkup( + undefined} + onCreateBot={() => undefined} + providers={config.Providers} + virtualModelProfiles={config.virtualModelProfiles} + /> + ); + const defaultIndex = html.indexOf('title="Provider/alpha"'); + const otherIndex = html.indexOf('title="Provider/beta"'); + const defaultLabel = html.slice(defaultIndex, otherIndex); + const otherLabel = html.slice(otherIndex, html.indexOf("", otherIndex)); + + assert.ok(defaultIndex >= 0); + assert.ok(otherIndex > defaultIndex); + assert.match(defaultLabel, /aria-checked="true"/); + assert.match(defaultLabel, /disabled=""/); + assert.match(otherLabel, /aria-checked="false"/); + assert.doesNotMatch(otherLabel, /disabled=""/); }); test("AddProfileForm places CLAUDE_APP_PATH below Bot settings", () => { @@ -199,6 +305,7 @@ test("AddProfileForm marks required and optional fields", () => { assert.match(html, /Opus model/); assert.match(html, /Sonnet model/); assert.match(html, /Haiku model/); + assert.doesNotMatch(html, /Allowed model list/); }); test("Claude Code profiles require a default model before submission", () => { @@ -210,10 +317,11 @@ test("Claude Code profiles require a default model before submission", () => { test("AddProfileForm labels Kimi CLI model fields with Kimi-specific copy", () => { const config = appConfigFixture(); + const draft = createProfileDraft("kimi"); const html = renderToStaticMarkup( undefined} onCreateBot={() => undefined} @@ -223,9 +331,40 @@ test("AddProfileForm labels Kimi CLI model fields with Kimi-specific copy", () = ); assert.match(html, /Kimi model/); - assert.match(html, /Allowed models/); + assert.doesNotMatch(html, /Advanced settings need attention/); + assert.doesNotMatch(html, /Allowed model list/); assert.doesNotMatch(html, /Default model/); assert.doesNotMatch(html, /Available models/); + assert.equal(isProfileDraftSubmittable(draft), false); + const defaultOnlyDraft = { ...draft, model: "kimi/k2", availableModels: [] }; + assert.equal(isProfileDraftSubmittable(defaultOnlyDraft), true); + assert.equal(profileConfigFromDraft(defaultOnlyDraft, []).availableModels, undefined); +}); + +test("AddProfileForm shows Kimi allowed models as selected by default", () => { + const config = appConfigFixture(); + config.Providers = [{ + models: ["k2", "k3"], + name: "Kimi" + }]; + const draft = { ...createProfileDraft("kimi"), model: "Kimi/k2" }; + const html = renderToStaticMarkup( + undefined} + onCreateBot={() => undefined} + providers={config.Providers} + virtualModelProfiles={config.virtualModelProfiles} + /> + ); + + assert.match(html, /Allowed model list/); + assert.match(html, /title="Kimi\/k2"[\s\S]*?aria-checked="true"/); + assert.match(html, /title="Kimi\/k3"[\s\S]*?aria-checked="true"/); + assert.equal(profileConfigFromDraft(draft, []).availableModels, undefined); }); test("AddProfileForm treats Pi as a CCR-only CLI profile", () => { @@ -247,7 +386,7 @@ test("AddProfileForm treats Pi as a CCR-only CLI profile", () => { assert.match(html, /Pi model/); assert.doesNotMatch(html, /Provider ID/); assert.doesNotMatch(html, /Provider name/); - assert.doesNotMatch(html, /Allowed models/); + assert.doesNotMatch(html, /Allowed model list/); }); test("AddProfileForm treats Claude Design as a CCR-only App profile", () => { @@ -346,7 +485,7 @@ test("profileSummaryItems uses Kimi-specific model labels", () => { }, config, (value) => value); assert.equal(items[0]?.label, "Kimi model"); - assert.equal(items[1]?.label, "Allowed models"); + assert.equal(items[1]?.label, "Allowed model list"); assert.equal(items[1]?.value, "2"); }); @@ -381,7 +520,7 @@ test("profileSummaryItems omits disabled profile properties from cards", () => { surface: "auto" }, config, (value) => value); - assert.deepEqual(disabledItems.map((item) => item.label), ["Model", "Provider ID"]); + assert.deepEqual(disabledItems.map((item) => item.label), ["Model"]); assert.doesNotMatch(disabledItems.map((item) => item.value).join(" "), /Disabled/); const enabledItems = profileSummaryItems({ @@ -399,6 +538,7 @@ test("profileSummaryItems omits disabled profile properties from cards", () => { assert.match(enabledItems.map((item) => item.label).join(" "), /Show all sessions/); assert.match(enabledItems.map((item) => item.label).join(" "), /CCR managed compact/); + assert.doesNotMatch(enabledItems.map((item) => item.label).join(" "), /Provider ID/); }); test("profileSummaryItems shows disabled profile enhanced route without private routing status", () => { From d48fd17105c4bd88f75e3bda3b720d0d68d7a63a Mon Sep 17 00:00:00 2001 From: musistudio Date: Mon, 10 Aug 2026 13:04:07 +0800 Subject: [PATCH 04/18] Attribute Claude App encoded models to routed upstream usage --- .../src/agents/claude-app/gateway-routes.ts | 2 +- packages/core/src/usage/store.ts | 89 +++++++++++-------- .../integration/usage/usage-store.test.mjs | 36 ++++++++ 3 files changed, 88 insertions(+), 39 deletions(-) diff --git a/packages/core/src/agents/claude-app/gateway-routes.ts b/packages/core/src/agents/claude-app/gateway-routes.ts index 290fd864..b7035c16 100644 --- a/packages/core/src/agents/claude-app/gateway-routes.ts +++ b/packages/core/src/agents/claude-app/gateway-routes.ts @@ -299,7 +299,7 @@ function encodeClaudeAppGatewayRouteModel(model: string): string { return Buffer.from(stripClaudeAppGatewayOneMillionContextSuffix(model), "utf8").toString("hex"); } -function decodeClaudeAppGatewayRouteId(routeId: string): string | undefined { +export function decodeClaudeAppGatewayRouteId(routeId: string): string | undefined { const normalized = stripClaudeAppGatewayOneMillionContextSuffix(routeId).toLowerCase(); const match = /^anthropic\/claude-ccr(?:\d+)?-h([0-9a-f]+)$/.exec(normalized); const encoded = match?.[1]; diff --git a/packages/core/src/usage/store.ts b/packages/core/src/usage/store.ts index 7420ac01..68d9c4e3 100644 --- a/packages/core/src/usage/store.ts +++ b/packages/core/src/usage/store.ts @@ -3,6 +3,7 @@ import { EventEmitter } from "node:events"; import { randomBytes } from "node:crypto"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; +import { decodeClaudeAppGatewayRouteId } from "@ccr/core/agents/claude-app/gateway-routes"; import { REQUEST_LOGS_DB_FILE, USAGE_DB_FILE } from "@ccr/core/config/constants"; import { estimateUsageCostUsd, providerModelPricingForUsage } from "@ccr/core/models/pricing-service"; import { createBetterSqliteDatabase, type BetterSqliteDatabase } from "@ccr/core/storage/sqlite-native"; @@ -216,6 +217,43 @@ export class UsageStore { usageEvents.emit("recorded"); } + async recordCapture(input: UsageCaptureInput): Promise { + const headersUsage = extractUsageFromBillingHeaders(input.responseHeaders); + const bodyUsage = extractUsageFromBody(input.bodyText); + const usage = normalizeUsageInputTokens(mergeUsageSnapshots(headersUsage, bodyUsage), { + path: input.path, + providerProtocol: input.providerProtocol, + usageHint: bodyUsage + }); + const fallbackAttribution = resolveUsageModelAttribution(input.config, input.fallbackModel); + const responseAttribution = resolveUsageResponseModelAttribution(input.config, bodyUsage?.model); + const route = splitRouteSelector(input.fallbackModel); + const provider = + input.providerName ?? + readHeader(input.responseHeaders, "x-gateway-target-provider-name") ?? + readHeader(input.responseHeaders, "x-gateway-target-provider") ?? + responseAttribution.provider ?? + fallbackAttribution.provider ?? + route.provider; + const model = responseAttribution.model ?? fallbackAttribution.model ?? route.model ?? input.fallbackModel; + + await this.record({ + durationMs: input.durationMs, + method: input.method, + logicalModel: fallbackAttribution.logicalModel ?? input.fallbackModel, + model, + modelIsRouteSelector: false, + path: input.path, + client: input.client, + provider, + pricing: providerModelPricingForUsage(input.config, provider, model), + credentialId: readCredentialId(input.responseHeaders), + requestId: input.requestId, + statusCode: input.statusCode, + usage + }); + } + async hasRequestId(requestId: string): Promise { const normalizedRequestId = requestId.trim(); if (!normalizedRequestId) { @@ -469,49 +507,24 @@ export async function getUsageTotalsSince(since: Date, filter?: UsageStatsFilter export async function recordGatewayUsageCapture(input: UsageCaptureInput): Promise { try { - const headersUsage = extractUsageFromBillingHeaders(input.responseHeaders); - const bodyUsage = extractUsageFromBody(input.bodyText); - const usage = normalizeUsageInputTokens(mergeUsageSnapshots(headersUsage, bodyUsage), { - path: input.path, - providerProtocol: input.providerProtocol, - usageHint: bodyUsage - }); - const fallbackAttribution = resolveUsageModelAttribution(input.config, input.fallbackModel); - const responseAttribution = resolveUsageModelAttribution( - input.config, - bodyUsage?.model, - { physicalModel: true } - ); - const route = splitRouteSelector(input.fallbackModel); - const provider = - input.providerName ?? - readHeader(input.responseHeaders, "x-gateway-target-provider-name") ?? - readHeader(input.responseHeaders, "x-gateway-target-provider") ?? - responseAttribution.provider ?? - fallbackAttribution.provider ?? - route.provider; - const model = responseAttribution.model ?? fallbackAttribution.model ?? route.model ?? input.fallbackModel; - - await usageStore.record({ - durationMs: input.durationMs, - method: input.method, - logicalModel: fallbackAttribution.logicalModel ?? input.fallbackModel, - model, - modelIsRouteSelector: false, - path: input.path, - client: input.client, - provider, - pricing: providerModelPricingForUsage(input.config, provider, model), - credentialId: readCredentialId(input.responseHeaders), - requestId: input.requestId, - statusCode: input.statusCode, - usage - }); + await usageStore.recordCapture(input); } catch (error) { console.warn(`[usage] Failed to record usage: ${formatError(error)}`); } } +function resolveUsageResponseModelAttribution( + config: Pick | undefined, + model: string | undefined +) { + const decodedClaudeRouteModel = model ? decodeClaudeAppGatewayRouteId(model) : undefined; + if (decodedClaudeRouteModel) { + const attribution = resolveUsageModelAttribution(config, decodedClaudeRouteModel); + return !config || attribution.provider ? attribution : {}; + } + return resolveUsageModelAttribution(config, model, { physicalModel: true }); +} + function buildUsageWhereClause( since: Date, filter: UsageStatsFilter | null | undefined, diff --git a/packages/core/test/integration/usage/usage-store.test.mjs b/packages/core/test/integration/usage/usage-store.test.mjs index 8150b4dd..69df3407 100644 --- a/packages/core/test/integration/usage/usage-store.test.mjs +++ b/packages/core/test/integration/usage/usage-store.test.mjs @@ -289,6 +289,42 @@ test("UsageStore keeps the Fusion logical model while grouping by the upstream m } }); +test("UsageStore attributes Claude App encoded response model IDs to the routed upstream model", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "ccr-usage-claude-app-encoded-model-test-")); + try { + const store = new UsageStore(path.join(dir, "usage.sqlite")); + const encodedModel = `anthropic/claude-ccr-h${Buffer.from("Fusion/kimisearch", "utf8").toString("hex")}`; + + await store.recordCapture({ + bodyText: [ + "event: message_start", + `data: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"${encodedModel}","usage":{"input_tokens":12,"output_tokens":8,"total_tokens":20}}}`, + "", + "data: [DONE]", + "" + ].join("\n"), + client: "Claude Code", + config: fusionUsageConfig, + durationMs: 100, + fallbackModel: "Fusion/kimisearch", + method: "POST", + path: "/v1/messages", + providerProtocol: "anthropic_messages", + requestId: "encoded-claude-app-model", + responseHeaders: new Headers({ "content-type": "text/event-stream; charset=utf-8" }), + statusCode: 200 + }); + + const stats = await store.getStats("today", { includeProxy: true }); + assert.equal(stats.models[0]?.model, "kimi-for-coding"); + assert.equal(stats.models[0]?.provider, "Kimi Code - Coding Plan"); + assert.equal(stats.recentRequests[0]?.logicalModel, "Fusion/kimisearch"); + assert.notEqual(stats.models[0]?.model, encodedModel); + } finally { + rmSync(dir, { force: true, recursive: true }); + } +}); + test("lightweight usage synchronization records and deduplicates Fusion internal upstream calls", async () => { const dir = mkdtempSync(path.join(tmpdir(), "ccr-usage-fusion-internal-test-")); try { From e8db9406a74bb7882f90b0f3d4c7e88cf468e396 Mon Sep 17 00:00:00 2001 From: musistudio Date: Mon, 10 Aug 2026 15:40:48 +0800 Subject: [PATCH 05/18] Add automatic provider model refresh and dialog Escape handling --- packages/core/src/config/config.ts | 2 + packages/core/src/contracts/app.ts | 2 + .../core/src/providers/model-auto-refresh.ts | 712 ++++++++++++++++++ packages/core/src/providers/probe.ts | 66 +- packages/core/src/web/management-server.ts | 15 + .../provider-protocol-capability.test.mjs | 25 + .../provider-model-auto-refresh.test.mjs | 475 ++++++++++++ packages/electron/src/main/ipc.ts | 14 + packages/electron/src/main/main-app.ts | 22 + packages/ui/src/components/ui/dialog.tsx | 49 +- packages/ui/src/pages/home/App.tsx | 11 +- .../src/pages/home/components/providers.tsx | 258 ++++--- packages/ui/src/pages/home/shared/i18n.tsx | 4 + .../ui/src/pages/home/shared/providers.ts | 43 ++ packages/ui/src/pages/home/shared/types.ts | 2 + .../ui/test/component/components.test.tsx | 12 + .../ui/test/integration/providers.test.ts | 67 ++ 17 files changed, 1639 insertions(+), 140 deletions(-) create mode 100644 packages/core/src/providers/model-auto-refresh.ts create mode 100644 packages/core/test/unit/providers/provider-model-auto-refresh.test.mjs diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 33fcae8b..7be556b3 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1433,6 +1433,8 @@ function parseProviders(value: unknown): GatewayProviderConfig[] | undefined { icon: readString(item.icon), id: readString(item.id), enabled: item.enabled === false ? false : undefined, + autoFetchModels: readBoolean(item.autoFetchModels ?? item.auto_fetch_models ?? item.autoRefreshModels ?? item.auto_refresh_models), + autoFetchKnownModels: parseStringArray(item.autoFetchKnownModels ?? item.auto_fetch_known_models ?? item.autoRefreshKnownModels ?? item.auto_refresh_known_models), modelDescriptions, modelDisplayNames, modelMetadata, diff --git a/packages/core/src/contracts/app.ts b/packages/core/src/contracts/app.ts index 2539d605..df8c4fe5 100644 --- a/packages/core/src/contracts/app.ts +++ b/packages/core/src/contracts/app.ts @@ -172,6 +172,8 @@ export type GatewayProviderConfig = { icon?: string; id?: string; enabled?: boolean; + autoFetchModels?: boolean; + autoFetchKnownModels?: string[]; modelDescriptions?: Record; modelDisplayNames?: Record; modelMetadata?: Record; diff --git a/packages/core/src/providers/model-auto-refresh.ts b/packages/core/src/providers/model-auto-refresh.ts new file mode 100644 index 00000000..1295c4ac --- /dev/null +++ b/packages/core/src/providers/model-auto-refresh.ts @@ -0,0 +1,712 @@ +import { loadAppConfig, saveAppConfig } from "@ccr/core/config/config"; +import { + isGatewayProviderEnabled, + type AppConfig, + type GatewayProviderCapabilityProtocol, + type GatewayProviderConfig, + type GatewayProviderProtocol, + type ProfileConfig, + type ProviderCredentialConfig, + type ProviderModelMetadata +} from "@ccr/core/contracts/app"; +import { codexDefaultBaseUrl, readCodexLocalModelCatalog } from "@ccr/core/agents/local-providers/codex"; +import { localAgentProviderApiKey } from "@ccr/core/agents/local-providers/shared"; +import { modelRegistryForConfig, parseProviderModelSelector, providerRuntimeId } from "@ccr/core/routing/model-registry"; +import { probeGatewayProvider } from "@ccr/core/providers/probe"; +import { normalizeProviderBaseUrl } from "@ccr/core/providers/url"; + +type ProviderModelProbe = typeof probeGatewayProvider; +type ProviderModelCatalogSnapshot = { + modelDisplayNames?: Record; + modelMetadata?: Record; + models: string[]; +}; + +export const providerModelAutoRefreshIntervalMs = 10 * 60 * 1000; + +export type ProviderModelAutoRefreshProviderResult = { + addedModels: string[]; + error?: string; + fetchedModels: string[]; + provider: string; + providerIndex: number; + skipped?: boolean; +}; + +export type ProviderModelAutoRefreshResult = { + changed: boolean; + config: AppConfig; + profileAllowlistsUpdated: number; + providers: ProviderModelAutoRefreshProviderResult[]; +}; + +export type ProviderModelAutoRefreshOptions = { + logger?: Pick; + probeProvider?: ProviderModelProbe; +}; + +export type ProviderModelAutoRefreshRuntimeOptions = ProviderModelAutoRefreshOptions & { + loadConfig?: () => Promise; + onConfigChanged?: (config: AppConfig, result: ProviderModelAutoRefreshResult) => Promise | void; + saveConfig?: (config: AppConfig) => Promise; +}; + +let autoRefreshTimer: ReturnType | undefined; +let autoRefreshEnabled = false; +let autoRefreshInFlight: Promise | undefined; +let autoRefreshRuntimeOptions: ProviderModelAutoRefreshRuntimeOptions = {}; + +export function hasAutoFetchModelProviders(config: Pick): boolean { + return config.Providers.some((provider) => provider.autoFetchModels && isGatewayProviderEnabled(provider)); +} + +export function syncProviderModelAutoRefreshService( + config: Pick, + options: ProviderModelAutoRefreshRuntimeOptions = {} +): void { + autoRefreshRuntimeOptions = { + ...autoRefreshRuntimeOptions, + ...options + }; + + if (!hasAutoFetchModelProviders(config)) { + stopProviderModelAutoRefreshService(); + return; + } + + autoRefreshEnabled = true; + scheduleProviderModelAutoRefresh(0); +} + +export function stopProviderModelAutoRefreshService(): void { + autoRefreshEnabled = false; + if (autoRefreshTimer) { + clearTimeout(autoRefreshTimer); + autoRefreshTimer = undefined; + } +} + +export async function runProviderModelAutoRefreshNow( + options: ProviderModelAutoRefreshRuntimeOptions = {} +): Promise { + autoRefreshRuntimeOptions = { + ...autoRefreshRuntimeOptions, + ...options + }; + if (autoRefreshInFlight) { + return autoRefreshInFlight; + } + + autoRefreshInFlight = runProviderModelAutoRefresh(autoRefreshRuntimeOptions).finally(() => { + autoRefreshInFlight = undefined; + }); + return autoRefreshInFlight; +} + +export async function refreshAutoFetchProviderModels( + config: AppConfig, + options: ProviderModelAutoRefreshOptions = {} +): Promise { + const probeProvider = options.probeProvider ?? probeGatewayProvider; + let providers = config.Providers; + const providerResults: ProviderModelAutoRefreshProviderResult[] = []; + const addedByProvider: Array<{ addedModels: string[]; provider: GatewayProviderConfig }> = []; + + for (const [providerIndex, provider] of config.Providers.entries()) { + if (!provider.autoFetchModels || !isGatewayProviderEnabled(provider)) { + continue; + } + + const providerName = provider.name.trim(); + const baseUrl = providerBaseUrl(provider); + if (!providerName || !baseUrl) { + providerResults.push({ + addedModels: [], + fetchedModels: [], + provider: providerName, + providerIndex, + skipped: true + }); + continue; + } + + try { + const probe = await probeProvider({ + apiKey: providerApiKeyForModelRefresh(provider), + baseUrl, + forceRefresh: true, + mode: "models", + models: provider.models, + providerPlugins: providerPluginsForModelRefresh(config, provider), + protocols: providerModelRefreshProtocols(provider) + }); + const discovered = mergeProviderModelCatalogSnapshots(probe, localProviderModelCatalogSnapshot(provider)); + const fetchedModels = uniqueModelList(discovered.models); + const knownModels = mergeModelLists(provider.autoFetchKnownModels ?? [], provider.models); + const hasKnownModelBaseline = Boolean(provider.autoFetchKnownModels?.length); + const addedModels = hasKnownModelBaseline ? modelsNotIn(knownModels, fetchedModels) : []; + const mergedModels = mergeModelLists(provider.models, addedModels); + const nextKnownModels = mergeModelLists(knownModels, fetchedModels); + providerResults.push({ + addedModels, + fetchedModels, + provider: providerName, + providerIndex + }); + if (addedModels.length === 0 && sameModelList(provider.autoFetchKnownModels, nextKnownModels)) { + continue; + } + + const nextProvider = { + ...provider, + autoFetchKnownModels: nextKnownModels.length > 0 ? nextKnownModels : undefined, + modelDisplayNames: mergeProviderModelDisplayNames(provider.modelDisplayNames, discovered.modelDisplayNames, mergedModels), + modelMetadata: mergeProviderModelMetadata(provider.modelMetadata, discovered.modelMetadata, mergedModels), + models: mergedModels + }; + providers = replaceAt(providers, providerIndex, nextProvider); + if (addedModels.length > 0) { + addedByProvider.push({ addedModels, provider }); + } + } catch (error) { + const message = formatError(error); + options.logger?.warn?.(`[providers] Failed to refresh models for ${providerName}: ${message}`); + providerResults.push({ + addedModels: [], + error: message, + fetchedModels: [], + provider: providerName, + providerIndex + }); + } + } + + if (providers === config.Providers) { + return { + changed: false, + config, + profileAllowlistsUpdated: 0, + providers: providerResults + }; + } + + const profileUpdate = profileAllowlistsWithAutoFetchedModels(config, addedByProvider); + return { + changed: true, + config: { + ...config, + Providers: providers, + profile: { + ...config.profile, + profiles: profileUpdate.profiles + } + }, + profileAllowlistsUpdated: profileUpdate.changedCount, + providers: providerResults + }; +} + +async function runProviderModelAutoRefresh( + options: ProviderModelAutoRefreshRuntimeOptions +): Promise { + const loadConfig = options.loadConfig ?? loadAppConfig; + const saveConfig = options.saveConfig ?? saveAppConfig; + const config = await loadConfig(); + const result = await refreshAutoFetchProviderModels(config, options); + if (!result.changed) { + return result; + } + + const latestConfig = await loadConfig(); + const mergedResult = mergeProviderModelAutoRefreshResult(config, latestConfig, result); + if (!mergedResult.changed) { + return mergedResult; + } + + const savedConfig = await saveConfig(mergedResult.config); + const savedResult = { + ...mergedResult, + config: savedConfig + }; + await options.onConfigChanged?.(savedConfig, savedResult); + return savedResult; +} + +function mergeProviderModelAutoRefreshResult( + baseConfig: AppConfig, + currentConfig: AppConfig, + result: ProviderModelAutoRefreshResult +): ProviderModelAutoRefreshResult { + let providers = currentConfig.Providers; + const providerResults = result.providers.map((item) => ({ ...item })); + const addedByProvider: Array<{ addedModels: string[]; provider: GatewayProviderConfig }> = []; + + for (const [resultIndex, providerResult] of result.providers.entries()) { + if (providerResult.error || providerResult.skipped) { + continue; + } + + const baseProvider = baseConfig.Providers[providerResult.providerIndex]; + const refreshedProvider = result.config.Providers[providerResult.providerIndex]; + if (!baseProvider || !refreshedProvider) { + continue; + } + + const currentProviderIndex = findCurrentRefreshProviderIndex(currentConfig.Providers, baseProvider, providerResult.providerIndex); + if (currentProviderIndex < 0) { + continue; + } + + const currentProvider = currentConfig.Providers[currentProviderIndex]; + if (!canApplyProviderModelRefresh(baseConfig, currentConfig, baseProvider, currentProvider)) { + continue; + } + + const fetchedModels = uniqueModelList(providerResult.fetchedModels); + const knownModels = mergeModelLists(currentProvider.autoFetchKnownModels ?? [], currentProvider.models); + const hasKnownModelBaseline = Boolean(currentProvider.autoFetchKnownModels?.length); + const addedModels = hasKnownModelBaseline ? modelsNotIn(knownModels, fetchedModels) : []; + const mergedModels = mergeModelLists(currentProvider.models, addedModels); + const nextKnownModels = mergeModelLists(knownModels, fetchedModels); + providerResults[resultIndex] = { + ...providerResult, + addedModels, + provider: currentProvider.name, + providerIndex: currentProviderIndex + }; + + const nextProvider = { + ...currentProvider, + autoFetchKnownModels: nextKnownModels.length > 0 ? nextKnownModels : undefined, + modelDisplayNames: mergeProviderModelDisplayNames(currentProvider.modelDisplayNames, refreshedProvider.modelDisplayNames, mergedModels), + modelMetadata: mergeProviderModelMetadata(currentProvider.modelMetadata, refreshedProvider.modelMetadata, mergedModels), + models: mergedModels + }; + if (sameAutoRefreshProviderState(currentProvider, nextProvider)) { + continue; + } + + providers = replaceAt(providers, currentProviderIndex, nextProvider); + if (addedModels.length > 0) { + addedByProvider.push({ addedModels, provider: nextProvider }); + } + } + + if (providers === currentConfig.Providers) { + return { + ...result, + changed: false, + config: currentConfig, + profileAllowlistsUpdated: 0, + providers: providerResults + }; + } + + const configWithProviders = { + ...currentConfig, + Providers: providers + }; + const profileUpdate = profileAllowlistsWithAutoFetchedModels(configWithProviders, addedByProvider); + return { + ...result, + changed: true, + config: { + ...configWithProviders, + profile: { + ...configWithProviders.profile, + profiles: profileUpdate.profiles + } + }, + profileAllowlistsUpdated: profileUpdate.changedCount, + providers: providerResults + }; +} + +function findCurrentRefreshProviderIndex( + providers: GatewayProviderConfig[], + baseProvider: GatewayProviderConfig, + preferredIndex: number +): number { + const preferredProvider = providers[preferredIndex]; + if (providerRefreshTargetMatches(baseProvider, preferredProvider)) { + return preferredIndex; + } + return providers.findIndex((provider) => providerRefreshTargetMatches(baseProvider, provider)); +} + +function canApplyProviderModelRefresh( + baseConfig: AppConfig, + currentConfig: AppConfig, + baseProvider: GatewayProviderConfig, + currentProvider: GatewayProviderConfig +): boolean { + if (!currentProvider.autoFetchModels || !isGatewayProviderEnabled(currentProvider)) { + return false; + } + if (!providerRefreshTargetMatches(baseProvider, currentProvider)) { + return false; + } + if (providerApiKeyForModelRefresh(baseProvider) !== providerApiKeyForModelRefresh(currentProvider)) { + return false; + } + if (!sameModelList(providerModelRefreshProtocols(baseProvider), providerModelRefreshProtocols(currentProvider))) { + return false; + } + return JSON.stringify(providerPluginsForModelRefresh(baseConfig, baseProvider)) === + JSON.stringify(providerPluginsForModelRefresh(currentConfig, currentProvider)); +} + +function providerRefreshTargetMatches( + baseProvider: GatewayProviderConfig, + currentProvider: GatewayProviderConfig | undefined +): currentProvider is GatewayProviderConfig { + if (!currentProvider) { + return false; + } + + const baseId = baseProvider.id?.trim().toLowerCase(); + const currentId = currentProvider.id?.trim().toLowerCase(); + const identityMatches = baseId && currentId + ? baseId === currentId + : baseProvider.name.trim().toLowerCase() === currentProvider.name.trim().toLowerCase(); + return Boolean(identityMatches && sameProviderBaseUrl(baseProvider, currentProvider)); +} + +function sameProviderBaseUrl(left: GatewayProviderConfig, right: GatewayProviderConfig): boolean { + const leftBaseUrl = normalizeProviderBaseUrl(providerBaseUrl(left)).toLowerCase(); + const rightBaseUrl = normalizeProviderBaseUrl(providerBaseUrl(right)).toLowerCase(); + return Boolean(leftBaseUrl && rightBaseUrl && leftBaseUrl === rightBaseUrl); +} + +function sameAutoRefreshProviderState(left: GatewayProviderConfig, right: GatewayProviderConfig): boolean { + return sameModelList(left.models, right.models) && + sameModelList(left.autoFetchKnownModels, right.autoFetchKnownModels) && + JSON.stringify(left.modelDisplayNames ?? {}) === JSON.stringify(right.modelDisplayNames ?? {}) && + JSON.stringify(left.modelMetadata ?? {}) === JSON.stringify(right.modelMetadata ?? {}); +} + +function scheduleProviderModelAutoRefresh(delayMs: number): void { + if (!autoRefreshEnabled) { + return; + } + if (autoRefreshTimer) { + clearTimeout(autoRefreshTimer); + } + autoRefreshTimer = setTimeout(() => { + autoRefreshTimer = undefined; + void runProviderModelAutoRefreshNow().catch((error) => { + autoRefreshRuntimeOptions.logger?.warn?.(`[providers] Auto model refresh failed: ${formatError(error)}`); + }).finally(() => { + if (autoRefreshEnabled) { + scheduleProviderModelAutoRefresh(providerModelAutoRefreshIntervalMs); + } + }); + }, delayMs); + autoRefreshTimer.unref?.(); +} + +function providerBaseUrl(provider: GatewayProviderConfig): string { + return provider.api_base_url || provider.baseUrl || provider.baseurl || ""; +} + +function providerApiKeyForModelRefresh(provider: GatewayProviderConfig): string { + const direct = provider.api_key || provider.apiKey || provider.apikey || ""; + if (direct.trim()) { + return direct.trim(); + } + return provider.credentials + ?.filter((credential) => credential.enabled !== false) + .map(providerCredentialApiKey) + .find(Boolean) ?? ""; +} + +function providerCredentialApiKey(credential: ProviderCredentialConfig): string { + return (credential.api_key || credential.apiKey || credential.apikey || "").trim(); +} + +function providerModelRefreshProtocols(provider: GatewayProviderConfig): GatewayProviderCapabilityProtocol[] { + return uniqueProtocols([ + ...(provider.capabilities ?? []).map((capability) => capability.type), + toGatewayProviderProtocol(provider.type), + toGatewayProviderProtocol(provider.provider) + ]); +} + +function toGatewayProviderProtocol(value: unknown): GatewayProviderProtocol | undefined { + return typeof value === "string" && gatewayProviderProtocols.has(value as GatewayProviderProtocol) + ? value as GatewayProviderProtocol + : undefined; +} + +const gatewayProviderProtocols = new Set([ + "anthropic_messages", + "gemini_generate_content", + "gemini_interactions", + "openai_chat_completions", + "openai_responses" +]); + +function providerPluginsForModelRefresh(config: AppConfig, provider: GatewayProviderConfig): unknown[] { + return (config.providerPlugins ?? []).filter((plugin) => + providerPluginEnabled(plugin) && + localAgentProviderPluginMatches(plugin, provider) + ); +} + +function localAgentProviderPluginMatches(plugin: unknown, provider: GatewayProviderConfig): boolean { + if (!isRecord(plugin)) { + return false; + } + const key = readString(plugin.key)?.toLowerCase() ?? ""; + if (!key.startsWith("ccr-local-agent-")) { + return false; + } + + const pluginProviderName = readString(plugin.providerName) || readString(plugin.provider); + return Boolean(pluginProviderName && providerPluginAliases(provider).has(pluginProviderName.trim().toLowerCase())); +} + +function providerPluginEnabled(plugin: unknown): boolean { + return !isRecord(plugin) || plugin.enabled !== false; +} + +function providerPluginAliases(provider: GatewayProviderConfig): Set { + const aliases = providerAliases(provider); + const protocols = providerModelRefreshProtocols(provider); + for (const alias of [...aliases]) { + for (const protocol of protocols) { + aliases.add(`${alias}::${protocol}`); + } + } + return aliases; +} + +function localProviderModelCatalogSnapshot(provider: GatewayProviderConfig): ProviderModelCatalogSnapshot | undefined { + if (isLocalCodexProvider(provider)) { + return readCodexLocalModelCatalog(); + } + return undefined; +} + +function isLocalCodexProvider(provider: GatewayProviderConfig): boolean { + return providerApiKeyForModelRefresh(provider) === localAgentProviderApiKey && + normalizeProviderBaseUrl(providerBaseUrl(provider)) === normalizeProviderBaseUrl(codexDefaultBaseUrl); +} + +function mergeProviderModelCatalogSnapshots( + ...snapshots: Array +): ProviderModelCatalogSnapshot { + const models = mergeModelLists(...snapshots.map((snapshot) => snapshot?.models ?? [])); + const modelDisplayNames = snapshots.reduce>((merged, snapshot) => ({ + ...merged, + ...(snapshot?.modelDisplayNames ?? {}) + }), {}); + const modelMetadata = snapshots.reduce>((merged, snapshot) => ({ + ...merged, + ...(snapshot?.modelMetadata ?? {}) + }), {}); + + return { + modelDisplayNames: mergeProviderModelDisplayNames(undefined, modelDisplayNames, models), + modelMetadata: mergeProviderModelMetadata(undefined, modelMetadata, models), + models + }; +} + +function profileAllowlistsWithAutoFetchedModels( + config: AppConfig, + addedByProvider: Array<{ addedModels: string[]; provider: GatewayProviderConfig }> +): { changedCount: number; profiles: ProfileConfig[] } { + const registry = modelRegistryForConfig(config); + let changedCount = 0; + const profiles = config.profile.profiles.map((profile) => { + if (!profile.availableModels?.length) { + return profile; + } + + const additions = addedByProvider.flatMap(({ addedModels, provider }) => + profileUsesProvider(config, registry, profile, provider) + ? addedModels.map((model) => `${provider.name}/${model}`) + : [] + ); + if (additions.length === 0) { + return profile; + } + + const availableModels = mergeModelLists(profile.availableModels, additions); + if (availableModels.length === profile.availableModels.length) { + return profile; + } + + changedCount += 1; + return { + ...profile, + availableModels + }; + }); + + return { + changedCount, + profiles + }; +} + +function profileUsesProvider( + config: AppConfig, + registry: ReturnType, + profile: ProfileConfig, + provider: GatewayProviderConfig +): boolean { + const aliases = providerAliases(provider); + if ([profile.providerId, profile.providerName].some((value) => value && aliases.has(value.trim().toLowerCase()))) { + return true; + } + + return profileModelSelectors(profile).some((selector) => { + const parsed = parseProviderModelSelector(selector); + if (parsed && aliases.has(parsed.provider.trim().toLowerCase())) { + return true; + } + + const resolved = registry.resolve(selector); + return resolved?.kind === "provider" && providersReferToSameProvider(resolved.provider, provider, config); + }); +} + +function profileModelSelectors(profile: ProfileConfig): string[] { + return uniqueModelList([ + profile.model, + profile.fableModel, + profile.opusModel, + profile.sonnetModel, + profile.haikuModel, + profile.smallFastModel, + ...(profile.availableModels ?? []) + ].filter((value): value is string => Boolean(value))); +} + +function providersReferToSameProvider( + left: GatewayProviderConfig, + right: GatewayProviderConfig, + config: Pick +): boolean { + const leftIndex = config.Providers.indexOf(left); + const rightIndex = config.Providers.indexOf(right); + if (leftIndex >= 0 && rightIndex >= 0) { + return leftIndex === rightIndex; + } + const leftId = left.id?.trim(); + const rightId = right.id?.trim(); + if (leftId && rightId) { + return leftId.toLowerCase() === rightId.toLowerCase(); + } + return left.name.trim().toLowerCase() === right.name.trim().toLowerCase() && + providerBaseUrl(left).trim().toLowerCase() === providerBaseUrl(right).trim().toLowerCase(); +} + +function providerAliases(provider: GatewayProviderConfig): Set { + return new Set( + [provider.name, provider.id, provider.provider, providerRuntimeId(provider)] + .map((value) => value?.trim().toLowerCase()) + .filter((value): value is string => Boolean(value)) + ); +} + +function mergeProviderModelDisplayNames( + current: Record | undefined, + discovered: Record | undefined, + models: string[] +): Record | undefined { + const displayNames: Record = {}; + const merged = { + ...(current ?? {}), + ...(discovered ?? {}) + }; + for (const model of models) { + const displayName = merged[model]?.trim(); + if (displayName && displayName !== model) { + displayNames[model] = displayName; + } + } + return Object.keys(displayNames).length > 0 ? displayNames : undefined; +} + +function mergeProviderModelMetadata( + current: Record | undefined, + discovered: Record | undefined, + models: string[] +): Record | undefined { + const metadataByModel: Record = {}; + for (const model of models) { + const metadata = { + ...(current?.[model] ?? {}), + ...(discovered?.[model] ?? {}) + }; + if (Object.keys(metadata).length > 0) { + metadataByModel[model] = metadata; + } + } + return Object.keys(metadataByModel).length > 0 ? metadataByModel : undefined; +} + +function modelsNotIn(previous: string[], next: string[]): string[] { + const previousKeys = new Set(uniqueModelList(previous).map((model) => model.toLowerCase())); + return uniqueModelList(next).filter((model) => !previousKeys.has(model.toLowerCase())); +} + +function sameModelList(left: string[] | undefined, right: string[] | undefined): boolean { + const normalizedLeft = uniqueModelList(left ?? []); + const normalizedRight = uniqueModelList(right ?? []); + if (normalizedLeft.length !== normalizedRight.length) { + return false; + } + return normalizedLeft.every((model, index) => model.toLowerCase() === normalizedRight[index].toLowerCase()); +} + +function mergeModelLists(...groups: string[][]): string[] { + return uniqueModelList(groups.flat()); +} + +function uniqueModelList(values: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const value of values) { + const normalized = value.trim(); + const key = normalized.toLowerCase(); + if (!normalized || seen.has(key)) { + continue; + } + seen.add(key); + result.push(normalized); + } + return result; +} + +function uniqueProtocols(values: Array): GatewayProviderCapabilityProtocol[] { + const seen = new Set(); + const result: GatewayProviderCapabilityProtocol[] = []; + for (const value of values) { + if (!value || seen.has(value)) { + continue; + } + seen.add(value); + result.push(value); + } + return result; +} + +function replaceAt(values: T[], index: number, value: T): T[] { + return values.map((item, itemIndex) => itemIndex === index ? value : item); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" ? value.trim() || undefined : undefined; +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/core/src/providers/probe.ts b/packages/core/src/providers/probe.ts index c9872d8c..f41cc1ba 100644 --- a/packages/core/src/providers/probe.ts +++ b/packages/core/src/providers/probe.ts @@ -349,7 +349,7 @@ async function resolveGatewayProviderProbe(request: GatewayProviderProbeRequest) const typedModels = uniqueStrings(request.models ?? []); const modelProbe = mode !== "models" || request.skipModelDiscovery ? { models: [] } - : await probeModels(parsed, request.apiKey, protocols); + : await probeModels(parsed, request.apiKey, protocols, request.providerPlugins ?? []); const models = (mode === "connectivity" || mode === "models") && modelProbe.models.length > 0 ? modelProbe.models : typedModels; @@ -548,13 +548,15 @@ function capabilitiesFromProtocolResults(results: GatewayProviderProbeProtocolRe async function probeModels( parsed: ParsedProviderUrl, apiKey: string | undefined, - allowedProtocols: GatewayProviderCapabilityProtocol[] = [] + allowedProtocols: GatewayProviderCapabilityProtocol[] = [], + providerPlugins: unknown[] = [] ): Promise { for (const source of orderedModelSources(parsed, allowedProtocols)) { - const result = await fetchModelsForSource(parsed, source, apiKey); + const result = await fetchModelsForSource(parsed, source, apiKey, providerPlugins); if (result.models.length > 0) { return { baseUrl: result.baseUrl, + modelDisplayNames: result.modelDisplayNames, models: result.models, source }; @@ -566,15 +568,27 @@ async function probeModels( }; } -async function fetchModelsForSource(parsed: ParsedProviderUrl, source: ModelSource, apiKey: string | undefined): Promise { +async function fetchModelsForSource( + parsed: ParsedProviderUrl, + source: ModelSource, + apiKey: string | undefined, + providerPlugins: unknown[] = [] +): Promise { if (source === "openai") { for (const baseUrl of parsed.openaiBaseUrlCandidates) { - const result = await requestJson(`${baseUrl}/models`, { - headers: { - ...openAiHeaders(apiKey) + const request = await providerProbeAuthRequest( + `${baseUrl}/models`, + { + headers: { + ...openAiHeaders(apiKey) + }, + method: "GET" }, - method: "GET" - }); + providerPlugins, + apiKey, + { model: "" } + ); + const result = await requestJson(request.url, request.init); const modelList = parseModelList(result.payload, "openai"); if (modelList.models.length > 0) { return { @@ -591,12 +605,19 @@ async function fetchModelsForSource(parsed: ParsedProviderUrl, source: ModelSour if (source === "anthropic") { for (const baseUrl of parsed.anthropicBaseUrlCandidates) { - const result = await requestJson(`${baseUrl}/v1/models`, { - headers: { - ...anthropicHeaders(apiKey) + const request = await providerProbeAuthRequest( + `${baseUrl}/v1/models`, + { + headers: { + ...anthropicHeaders(apiKey) + }, + method: "GET" }, - method: "GET" - }); + providerPlugins, + apiKey, + { model: "" } + ); + const result = await requestJson(request.url, request.init); const modelList = parseModelList(result.payload, "anthropic"); if (modelList.models.length > 0) { return { @@ -611,12 +632,19 @@ async function fetchModelsForSource(parsed: ParsedProviderUrl, source: ModelSour }; } - const result = await requestJson(withGeminiKey(geminiApiEndpoint(parsed.geminiBaseUrl, "models"), apiKey), { - headers: { - ...geminiHeaders(apiKey) + const request = await providerProbeAuthRequest( + withGeminiKey(geminiApiEndpoint(parsed.geminiBaseUrl, "models"), apiKey), + { + headers: { + ...geminiHeaders(apiKey) + }, + method: "GET" }, - method: "GET" - }); + providerPlugins, + apiKey, + { model: "" } + ); + const result = await requestJson(request.url, request.init); return { baseUrl: parsed.geminiBaseUrl, ...parseModelList(result.payload, "gemini") diff --git a/packages/core/src/web/management-server.ts b/packages/core/src/web/management-server.ts index 5a590d0f..119e3843 100644 --- a/packages/core/src/web/management-server.ts +++ b/packages/core/src/web/management-server.ts @@ -33,6 +33,7 @@ import { getLocalAgentProviderCandidates, importLocalAgentProvider, probeLocalAg import { getProviderCatalogModels } from "@ccr/core/providers/model-catalog"; import { getProviderPresets } from "@ccr/core/providers/presets/index"; import { checkGatewayProviderConnectivity, probeGatewayProvider, probeGatewayProviderCandidates } from "@ccr/core/providers/probe"; +import { stopProviderModelAutoRefreshService, syncProviderModelAutoRefreshService } from "@ccr/core/providers/model-auto-refresh"; import { applyProfileConfig } from "@ccr/core/profiles/service"; import { getProfileOpenCommand, getProfileRuntimeStatus, openProfileFromCcr, stopProfileFromCcr } from "@ccr/core/profiles/launch-service"; import { getPluginMarketplace } from "@ccr/core/plugins/marketplace"; @@ -428,6 +429,7 @@ const rpcHandlers: Record = { await applyProfileIfServiceRunning(savedConfig, runtimeStatus); } invalidateProviderAccountSnapshotCache(); + syncProviderModelAutoRefresh(savedConfig); return savedConfig; }, scanBotHandoffBluetoothTargets: () => scanBotHandoffBluetoothTargets(), @@ -482,12 +484,14 @@ async function startConfiguredServices(reason: string): Promise { console.error(`Proxy mode is enabled, but system proxy is ${proxyStatus.systemProxy.state} during ${reason}${details}`); } } + syncProviderModelAutoRefresh(config); } catch (error) { console.error(`Failed to start configured services during ${reason}: ${formatError(error)}`); } } async function stopConfiguredServices(): Promise { + stopProviderModelAutoRefreshService(); await gatewayService.stop({ proxyRestoreTimeoutMs: 30_000 }).catch((error) => { console.error(`Failed to stop gateway: ${formatError(error)}`); }); @@ -505,6 +509,17 @@ async function applyProfileIfServiceRunning(config: AppConfig, status: GatewaySt logProfileApplyResult(await applyProfileConfig(config)); } +function syncProviderModelAutoRefresh(config: AppConfig): void { + syncProviderModelAutoRefreshService(config, { + logger: console, + onConfigChanged: async (nextConfig) => { + await gatewayService.updateConfig(nextConfig); + await applyProfileIfServiceRunning(nextConfig, gatewayService.getStatus()); + invalidateProviderAccountSnapshotCache(); + } + }); +} + function logProfileApplyResult(result: ProfileApplyResult): void { for (const client of result.clients) { if (!client.ok) { diff --git a/packages/core/test/unit/config/provider-protocol-capability.test.mjs b/packages/core/test/unit/config/provider-protocol-capability.test.mjs index 753dcfbe..61023969 100644 --- a/packages/core/test/unit/config/provider-protocol-capability.test.mjs +++ b/packages/core/test/unit/config/provider-protocol-capability.test.mjs @@ -18,6 +18,31 @@ test("top-level provider protocol becomes a capability when none are configured" ]); }); +test("provider auto model refresh flag is parsed from camel and snake case config", async () => { + const { parseProvidersForTest } = await import("@ccr/core/config/config.ts"); + const providers = parseProvidersForTest([ + { + autoFetchKnownModels: ["model-a", "model-hidden"], + autoFetchModels: true, + baseUrl: "https://api.example.test/v1", + models: ["model-a"], + name: "Camel" + }, + { + auto_fetch_known_models: "model-b,model-hidden", + auto_fetch_models: true, + baseUrl: "https://api.example.test/v1", + models: ["model-b"], + name: "Snake" + } + ]); + + assert.equal(providers[0].autoFetchModels, true); + assert.deepEqual(providers[0].autoFetchKnownModels, ["model-a", "model-hidden"]); + assert.equal(providers[1].autoFetchModels, true); + assert.deepEqual(providers[1].autoFetchKnownModels, ["model-b", "model-hidden"]); +}); + test("explicit capabilities win over the top-level protocol", async () => { const { parseProvidersForTest } = await import("@ccr/core/config/config.ts"); const providers = parseProvidersForTest([ diff --git a/packages/core/test/unit/providers/provider-model-auto-refresh.test.mjs b/packages/core/test/unit/providers/provider-model-auto-refresh.test.mjs new file mode 100644 index 00000000..40aaf3f7 --- /dev/null +++ b/packages/core/test/unit/providers/provider-model-auto-refresh.test.mjs @@ -0,0 +1,475 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { codexDefaultBaseUrl } from "@ccr/core/agents/local-providers/codex.ts"; +import { + hasAutoFetchModelProviders, + refreshAutoFetchProviderModels, + runProviderModelAutoRefreshNow +} from "@ccr/core/providers/model-auto-refresh.ts"; + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function testConfig(overrides = {}) { + return { + APIKEY: "", + APIKEYS: [], + API_TIMEOUT_MS: 600000, + CUSTOM_ROUTER_PATH: "", + HOST: "127.0.0.1", + PORT: 3456, + Providers: [ + { + api_base_url: "https://api.provider.test/v1", + api_key: "sk-provider", + autoFetchModels: true, + autoFetchKnownModels: ["alpha"], + id: "provider", + models: ["alpha"], + name: "Provider", + type: "openai_chat_completions" + }, + { + api_base_url: "https://api.other.test/v1", + autoFetchModels: false, + id: "other", + models: ["omega"], + name: "Other", + type: "openai_chat_completions" + } + ], + Router: { + fallback: { mode: "off", models: [], retryCount: 0 }, + rules: [] + }, + profile: { + enabled: true, + profiles: [ + { + agent: "codex", + availableModels: ["Provider/alpha"], + enabled: true, + id: "work", + model: "Provider/alpha", + name: "Work", + scope: "ccr", + surface: "cli" + }, + { + agent: "codex", + availableModels: ["Other/omega"], + enabled: true, + id: "other", + model: "Other/omega", + name: "Other Work", + scope: "ccr", + surface: "cli" + }, + { + agent: "claude-code", + enabled: true, + id: "all", + model: "Provider/alpha", + name: "All Models", + scope: "ccr", + surface: "cli" + } + ] + }, + providerPlugins: [], + virtualModelProfiles: [], + ...overrides + }; +} + +test("auto model refresh adds new provider models and extends matching profile allowlists", async () => { + const config = testConfig(); + const calls = []; + + const result = await refreshAutoFetchProviderModels(config, { + probeProvider: async (request) => { + calls.push(request); + return { + capabilities: [], + modelDisplayNames: { + beta: "Beta" + }, + models: ["alpha", "beta"], + normalizedBaseUrl: request.baseUrl, + protocols: [] + }; + } + }); + + assert.equal(result.changed, true); + assert.equal(calls.length, 1); + assert.equal(calls[0].apiKey, "sk-provider"); + assert.equal(calls[0].mode, "models"); + assert.equal(calls[0].forceRefresh, true); + assert.deepEqual(result.config.Providers[0].models, ["alpha", "beta"]); + assert.deepEqual(result.config.Providers[0].autoFetchKnownModels, ["alpha", "beta"]); + assert.deepEqual(result.config.Providers[0].modelDisplayNames, { beta: "Beta" }); + assert.deepEqual(result.config.Providers[1].models, ["omega"]); + assert.deepEqual(result.config.profile.profiles[0].availableModels, ["Provider/alpha", "Provider/beta"]); + assert.deepEqual(result.config.profile.profiles[1].availableModels, ["Other/omega"]); + assert.equal(result.config.profile.profiles[2].availableModels, undefined); + assert.equal(result.profileAllowlistsUpdated, 1); + assert.deepEqual(result.providers[0].addedModels, ["beta"]); +}); + +test("auto model refresh initializes a known model baseline before adding remote-only models", async () => { + const config = testConfig({ + Providers: [ + { + api_base_url: "https://api.provider.test/v1", + api_key: "sk-provider", + autoFetchModels: true, + id: "provider", + models: ["alpha"], + name: "Provider", + type: "openai_chat_completions" + } + ] + }); + + const result = await refreshAutoFetchProviderModels(config, { + probeProvider: async (request) => ({ + capabilities: [], + models: ["alpha", "beta"], + normalizedBaseUrl: request.baseUrl, + protocols: [] + }) + }); + + assert.equal(result.changed, true); + assert.deepEqual(result.config.Providers[0].models, ["alpha"]); + assert.deepEqual(result.config.Providers[0].autoFetchKnownModels, ["alpha", "beta"]); + assert.equal(result.profileAllowlistsUpdated, 0); + assert.deepEqual(result.providers[0].addedModels, []); +}); + +test("auto model refresh does not re-add known models removed by the user", async () => { + const config = testConfig({ + Providers: [ + { + api_base_url: "https://api.provider.test/v1", + api_key: "sk-provider", + autoFetchModels: true, + autoFetchKnownModels: ["alpha", "beta"], + id: "provider", + models: ["alpha"], + name: "Provider", + type: "openai_chat_completions" + } + ] + }); + + const result = await refreshAutoFetchProviderModels(config, { + probeProvider: async (request) => ({ + capabilities: [], + models: ["alpha", "beta", "gamma"], + normalizedBaseUrl: request.baseUrl, + protocols: [] + }) + }); + + assert.equal(result.changed, true); + assert.deepEqual(result.config.Providers[0].models, ["alpha", "gamma"]); + assert.deepEqual(result.config.Providers[0].autoFetchKnownModels, ["alpha", "beta", "gamma"]); + assert.deepEqual(result.config.profile.profiles[0].availableModels, ["Provider/alpha", "Provider/gamma"]); + assert.deepEqual(result.providers[0].addedModels, ["gamma"]); +}); + +test("auto model refresh hot-applies saved agent profiles after the switch is enabled", async () => { + const config = testConfig(); + const savedConfigs = []; + const hotAppliedConfigs = []; + + const result = await runProviderModelAutoRefreshNow({ + loadConfig: async () => config, + onConfigChanged: async (nextConfig, refreshResult) => { + hotAppliedConfigs.push({ config: nextConfig, result: refreshResult }); + }, + probeProvider: async (request) => ({ + capabilities: [], + models: ["alpha", "beta"], + normalizedBaseUrl: request.baseUrl, + protocols: [] + }), + saveConfig: async (nextConfig) => { + savedConfigs.push(nextConfig); + return nextConfig; + } + }); + + assert.equal(result.changed, true); + assert.equal(savedConfigs.length, 1); + assert.equal(hotAppliedConfigs.length, 1); + assert.deepEqual(savedConfigs[0].Providers[0].models, ["alpha", "beta"]); + assert.deepEqual(savedConfigs[0].profile.profiles[0].availableModels, ["Provider/alpha", "Provider/beta"]); + assert.equal(hotAppliedConfigs[0].config, savedConfigs[0]); + assert.equal(hotAppliedConfigs[0].result.config, savedConfigs[0]); + assert.equal(hotAppliedConfigs[0].result.profileAllowlistsUpdated, 1); +}); + +test("auto model refresh merges fetched models into the latest config before saving", async () => { + const baseConfig = testConfig(); + const latestConfig = clone(baseConfig); + latestConfig.Router.rules = [{ + id: "user-added-rule", + target: "Other/omega", + type: "model" + }]; + latestConfig.Providers[0].models = ["alpha", "manual"]; + latestConfig.Providers[0].autoFetchKnownModels = ["alpha"]; + const savedConfigs = []; + const loadedConfigs = [baseConfig, latestConfig]; + + const result = await runProviderModelAutoRefreshNow({ + loadConfig: async () => loadedConfigs.shift() ?? latestConfig, + probeProvider: async (request) => ({ + capabilities: [], + models: ["alpha", "beta"], + normalizedBaseUrl: request.baseUrl, + protocols: [] + }), + saveConfig: async (nextConfig) => { + savedConfigs.push(nextConfig); + return nextConfig; + } + }); + + assert.equal(result.changed, true); + assert.equal(savedConfigs.length, 1); + assert.deepEqual(savedConfigs[0].Router.rules, latestConfig.Router.rules); + assert.deepEqual(savedConfigs[0].Providers[0].models, ["alpha", "manual", "beta"]); + assert.deepEqual(savedConfigs[0].Providers[0].autoFetchKnownModels, ["alpha", "manual", "beta"]); + assert.deepEqual(savedConfigs[0].profile.profiles[0].availableModels, ["Provider/alpha", "Provider/beta"]); + assert.equal(result.profileAllowlistsUpdated, 1); +}); + +test("auto model refresh does not save stale results after the switch is disabled", async () => { + const baseConfig = testConfig(); + const latestConfig = clone(baseConfig); + latestConfig.Providers[0].autoFetchModels = false; + const savedConfigs = []; + const loadedConfigs = [baseConfig, latestConfig]; + + const result = await runProviderModelAutoRefreshNow({ + loadConfig: async () => loadedConfigs.shift() ?? latestConfig, + probeProvider: async (request) => ({ + capabilities: [], + models: ["alpha", "beta"], + normalizedBaseUrl: request.baseUrl, + protocols: [] + }), + saveConfig: async (nextConfig) => { + savedConfigs.push(nextConfig); + return nextConfig; + } + }); + + assert.equal(result.changed, false); + assert.equal(savedConfigs.length, 0); + assert.equal(result.config, latestConfig); + assert.deepEqual(result.config.Providers[0].models, ["alpha"]); + assert.equal(result.config.Providers[0].autoFetchModels, false); +}); + +test("auto model refresh uses Codex local model catalog during hot apply", async (t) => { + const previousCcrHome = process.env.CCR_INTERNAL_HOME_DIR; + const previousHome = process.env.HOME; + const home = mkdtempSync(path.join(os.tmpdir(), "ccr-codex-local-catalog-")); + mkdirSync(path.join(home, ".codex"), { recursive: true }); + writeFileSync(path.join(home, ".codex", "models_cache.json"), JSON.stringify({ + models: [ + { + context_window: 272000, + display_name: "GPT-5.1 Codex", + max_context_window: 300000, + slug: "gpt-5.1-codex", + supported_reasoning_levels: [{ description: "High", effort: "high" }], + supports_reasoning_summaries: true + } + ] + }), "utf8"); + process.env.CCR_INTERNAL_HOME_DIR = home; + process.env.HOME = home; + t.after(() => { + if (previousCcrHome === undefined) { + delete process.env.CCR_INTERNAL_HOME_DIR; + } else { + process.env.CCR_INTERNAL_HOME_DIR = previousCcrHome; + } + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + rmSync(home, { force: true, recursive: true }); + }); + + const config = testConfig({ + Providers: [ + { + api_base_url: codexDefaultBaseUrl, + api_key: "ccr-local-agent-login", + autoFetchModels: true, + autoFetchKnownModels: ["gpt-5-codex"], + id: "codex-api", + models: ["gpt-5-codex"], + name: "Codex API", + type: "openai_responses" + } + ], + profile: { + enabled: true, + profiles: [ + { + agent: "codex", + availableModels: ["Codex API/gpt-5-codex"], + enabled: true, + id: "codex-work", + model: "Codex API/gpt-5-codex", + name: "Codex Work", + scope: "ccr", + surface: "cli" + } + ] + } + }); + const hotAppliedConfigs = []; + + const result = await runProviderModelAutoRefreshNow({ + loadConfig: async () => config, + onConfigChanged: async (nextConfig) => { + hotAppliedConfigs.push(nextConfig); + }, + probeProvider: async (request) => ({ + capabilities: [], + models: ["gpt-5-codex"], + normalizedBaseUrl: request.baseUrl, + protocols: [] + }), + saveConfig: async (nextConfig) => nextConfig + }); + + assert.equal(result.changed, true); + assert.deepEqual(result.config.Providers[0].models, ["gpt-5-codex", "gpt-5.1-codex"]); + assert.deepEqual(result.config.Providers[0].autoFetchKnownModels, ["gpt-5-codex", "gpt-5.1-codex"]); + assert.equal(result.config.Providers[0].modelDisplayNames["gpt-5.1-codex"], "GPT-5.1 Codex"); + assert.equal(result.config.Providers[0].modelMetadata["gpt-5.1-codex"].supportsReasoningSummaries, true); + assert.deepEqual(result.config.profile.profiles[0].availableModels, ["Codex API/gpt-5-codex", "Codex API/gpt-5.1-codex"]); + assert.equal(hotAppliedConfigs.length, 1); + assert.deepEqual(hotAppliedConfigs[0].profile.profiles[0].availableModels, ["Codex API/gpt-5-codex", "Codex API/gpt-5.1-codex"]); + assert.deepEqual(result.providers[0].addedModels, ["gpt-5.1-codex"]); +}); + +test("auto model refresh is unchanged when fetched models are already configured", async () => { + const config = testConfig(); + + const result = await refreshAutoFetchProviderModels(config, { + probeProvider: async (request) => ({ + capabilities: [], + models: ["alpha"], + normalizedBaseUrl: request.baseUrl, + protocols: [] + }) + }); + + assert.equal(result.changed, false); + assert.equal(result.config, config); + assert.equal(result.profileAllowlistsUpdated, 0); +}); + +test("auto model refresh uses an enabled credential when the provider has no primary key", async () => { + const config = testConfig({ + Providers: [ + { + api_base_url: "https://api.provider.test/v1", + autoFetchModels: true, + credentials: [ + { api_key: "sk-disabled", enabled: false }, + { api_key: "sk-enabled", enabled: true } + ], + id: "provider", + models: ["alpha"], + name: "Provider", + type: "openai_chat_completions" + } + ] + }); + + let apiKey = ""; + await refreshAutoFetchProviderModels(config, { + probeProvider: async (request) => { + apiKey = request.apiKey; + return { + capabilities: [], + models: ["alpha", "beta"], + normalizedBaseUrl: request.baseUrl, + protocols: [] + }; + } + }); + + assert.equal(apiKey, "sk-enabled"); +}); + +test("auto model refresh uses enabled local provider plugins including protocol-scoped aliases", async () => { + const config = testConfig({ + Providers: [ + { + api_base_url: "https://api.provider.test/v1", + api_key: "ccr-local-agent-login", + autoFetchModels: true, + autoFetchKnownModels: ["alpha"], + id: "provider", + models: ["alpha"], + name: "Provider", + type: "openai_chat_completions" + } + ], + providerPlugins: [ + { + enabled: false, + key: "ccr-local-agent-provider-disabled", + providerName: "Provider" + }, + { + key: "ccr-local-agent-provider-internal", + providerName: "provider::openai_chat_completions" + }, + { + key: "external-provider-plugin", + providerName: "Provider" + } + ] + }); + + let pluginKeys = []; + await refreshAutoFetchProviderModels(config, { + probeProvider: async (request) => { + pluginKeys = request.providerPlugins.map((plugin) => plugin.key); + return { + capabilities: [], + models: ["alpha", "beta"], + normalizedBaseUrl: request.baseUrl, + protocols: [] + }; + } + }); + + assert.deepEqual(pluginKeys, ["ccr-local-agent-provider-internal"]); +}); + +test("auto model refresh enabled check ignores disabled providers", () => { + assert.equal(hasAutoFetchModelProviders(testConfig()), true); + assert.equal(hasAutoFetchModelProviders({ + Providers: [{ autoFetchModels: true, enabled: false, models: [], name: "Disabled" }] + }), false); +}); diff --git a/packages/electron/src/main/ipc.ts b/packages/electron/src/main/ipc.ts index 1fb184ad..09eedad9 100644 --- a/packages/electron/src/main/ipc.ts +++ b/packages/electron/src/main/ipc.ts @@ -39,6 +39,7 @@ import { isLaunchAtLoginSupported, syncLaunchAtLogin } from "./launch-at-login"; import { getProviderCatalogModels } from "@ccr/core/providers/model-catalog"; import { getProviderPresets } from "@ccr/core/providers/presets/index"; import { checkGatewayProviderConnectivity, probeGatewayProvider, probeGatewayProviderCandidates } from "@ccr/core/providers/probe"; +import { syncProviderModelAutoRefreshService } from "@ccr/core/providers/model-auto-refresh"; import { applyProfileConfig } from "@ccr/core/profiles/service"; import { desktopCliCommandName, getProfileOpenCommand, getProfileRuntimeStatus, openProfileFromCcr, stopProfileFromCcr } from "@ccr/core/profiles/launch-service"; import { findProfileForOpen, resolveProfileOpenSurface } from "@ccr/core/profiles/launch-core"; @@ -331,6 +332,7 @@ ipcMain.handle(IPC_CHANNELS.appSaveConfig, async (_event, config: AppConfig, opt await builtInBrowserService.syncProxy(savedConfig); await trayController.refreshIconFromConfig(savedConfig); invalidateProviderAccountSnapshotCache(); + syncProviderModelAutoRefresh(savedConfig); return savedConfig; }); ipcMain.handle(IPC_CHANNELS.appSetThemePreference, async (_event, theme: unknown) => { @@ -400,6 +402,18 @@ async function applyProfileIfServiceRunning(config: AppConfig, status: GatewaySt logProfileApplyResult(await applyProfileConfig(config)); } +function syncProviderModelAutoRefresh(config: AppConfig): void { + syncProviderModelAutoRefreshService(config, { + logger: console, + onConfigChanged: async (nextConfig) => { + await gatewayService.updateConfig(nextConfig); + await applyProfileIfServiceRunning(nextConfig, gatewayService.getStatus()); + invalidateProviderAccountSnapshotCache(); + trayController.refreshUsageTitle(); + } + }); +} + function logProfileApplyResult(result: ProfileApplyResult): void { for (const client of result.clients) { if (client.ok) { diff --git a/packages/electron/src/main/main-app.ts b/packages/electron/src/main/main-app.ts index 0641a10c..1c0b5439 100644 --- a/packages/electron/src/main/main-app.ts +++ b/packages/electron/src/main/main-app.ts @@ -17,6 +17,8 @@ import { browserWebSearchMcpService } from "./electron-web-search-mcp"; import { applyNativeThemePreference } from "./native-theme"; import windowsManager from "./windows"; import { closeRequestLogRuntime } from "@ccr/core/observability/request-log-store"; +import { stopProviderModelAutoRefreshService, syncProviderModelAutoRefreshService } from "@ccr/core/providers/model-auto-refresh"; +import type { AppConfig } from "@ccr/core/contracts/app"; const gotTheLock = app.requestSingleInstanceLock(); const quitProxyRestoreTimeoutMs = 30_000; @@ -176,6 +178,7 @@ function handleTerminationSignal(signal: NodeJS.Signals): void { function stopServicesForQuit(): Promise { if (!stopForQuitPromise) { + stopProviderModelAutoRefreshService(); stopForQuitPromise = gatewayService .stop({ proxyRestoreTimeoutMs: quitProxyRestoreTimeoutMs }) .then(() => undefined) @@ -242,6 +245,7 @@ function startConfiguredServices(reason: string): Promise { const proxyStatus = await proxyService.ensureSystemProxyActive(); logProxySystemProxyIssue(reason, proxyStatus); } + syncProviderModelAutoRefresh(config); }) .catch((error) => { console.error(`Failed to start configured services during ${reason}: ${formatError(error)}`); @@ -254,6 +258,24 @@ function startConfiguredServices(reason: string): Promise { return startServicesPromise; } +function syncProviderModelAutoRefresh(config: AppConfig): void { + syncProviderModelAutoRefreshService(config, { + logger: console, + onConfigChanged: async (nextConfig) => { + await gatewayService.updateConfig(nextConfig); + if (gatewayService.getStatus().state === "running") { + const profileResult = await applyProfileConfig(nextConfig); + for (const client of profileResult.clients) { + if (!client.ok) { + console.error(`Failed to apply ${client.client} profile during provider model refresh: ${client.message}`); + } + } + } + trayController.refreshUsageTitle(); + } + }); +} + function queueEnsureConfiguredProxyModeActive(reason: string): void { void (startServicesPromise ?? Promise.resolve()) .then(() => ensureConfiguredProxyModeActive(reason)) diff --git a/packages/ui/src/components/ui/dialog.tsx b/packages/ui/src/components/ui/dialog.tsx index 1d971dd5..b357929d 100644 --- a/packages/ui/src/components/ui/dialog.tsx +++ b/packages/ui/src/components/ui/dialog.tsx @@ -13,6 +13,16 @@ type MotionSafeSectionAttributes = Omit< >; const DialogStackContext = React.createContext(0); +const dialogRootSelector = "[data-ui-dialog-root]"; + +function isTopMostDialogRoot(dialogElement: HTMLElement | null | undefined): boolean { + if (!dialogElement) { + return false; + } + + const dialogRoots = Array.from(dialogElement.ownerDocument.querySelectorAll(dialogRootSelector)); + return dialogRoots[dialogRoots.length - 1] === dialogElement; +} function DialogStackLayer({ children, @@ -38,6 +48,32 @@ function Dialog({ ...props }: DialogProps) { const shouldReduceMotion = useReducedMotion(); + const stackDepth = React.useContext(DialogStackContext); + const rootRef = React.useRef(null); + + React.useEffect(() => { + if (!open || !onOpenChange || stackDepth > 0) { + return; + } + + const dialogElement = rootRef.current; + const ownerDocument = dialogElement?.ownerDocument ?? (typeof document === "undefined" ? undefined : document); + if (!ownerDocument) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape" || event.defaultPrevented || !isTopMostDialogRoot(dialogElement)) { + return; + } + + event.preventDefault(); + onOpenChange(false); + }; + + ownerDocument.addEventListener("keydown", handleKeyDown); + return () => ownerDocument.removeEventListener("keydown", handleKeyDown); + }, [onOpenChange, open, stackDepth]); if (!open) { return null; @@ -56,6 +92,8 @@ function Dialog({ } }} transition={shouldReduceMotion ? { duration: 0.12, ease: "easeOut" } : { duration: 0.16, ease: [0.22, 1, 0.36, 1] }} + data-ui-dialog-root="" + ref={rootRef} {...props} > {children} @@ -138,4 +176,13 @@ const DialogDescription = React.forwardRef 0 ? capabilities : undefined, account: accountConfig, credentials: credentials.length > 0 ? credentials : undefined, diff --git a/packages/ui/src/pages/home/components/providers.tsx b/packages/ui/src/pages/home/components/providers.tsx index cef358b0..49908637 100644 --- a/packages/ui/src/pages/home/components/providers.tsx +++ b/packages/ui/src/pages/home/components/providers.tsx @@ -19,7 +19,7 @@ import { useRef, useState, X, isGatewayProviderEnabled, isPlainRecord } from "../shared/index"; import { PopoverPortal } from "@/components/ui/popover"; -import { TooltipPortal } from "@/components/ui/tooltip"; +import { Tooltip, TooltipPortal } from "@/components/ui/tooltip"; import { providerUrlWithDefaultScheme } from "@ccr/core/providers/url"; import type { ChromeLoginImportJob, LocalAgentProviderCandidate, ProviderAccountHttpJsonConnectorConfig, ProviderAccountWebContentJsonConnectorConfig } from "@ccr/core/contracts/app"; import type { ReactNode } from "react"; @@ -1983,7 +1983,7 @@ export function AddProviderForm({ providers: GatewayProviderConfig[]; }) { const t = useAppText(); - const [advancedOpen, setAdvancedOpen] = useState(false); + const [advancedOpen, setAdvancedOpen] = useState(() => Boolean(draft.autoFetchModels)); const [iconDetecting, setIconDetecting] = useState(false); const [autoDetectInfoPosition, setAutoDetectInfoPosition] = useState<{ left: number; top: number }>(); const [protocolProbeDetails, setProtocolProbeDetails] = useState(); @@ -2391,124 +2391,144 @@ export function AddProviderForm({ {advancedOpen ? (
-
- - {t("Auto detect protocols")} - - - -
- - -
- {manualProtocolDetection ? ( -
- {providerProtocolOptions.map((option) => { - const protocol = option.value; - const checked = draft.selectedProtocols.includes(protocol); - return ( -
- { - onChange({ - selectedProtocols: checked - ? draft.selectedProtocols.filter((selected) => selected !== protocol) - : uniqueProviderProtocols([...draft.selectedProtocols, protocol]) - }); - }} - /> - {translatedProviderProtocolLabel(protocol, t)} - - {checked ? t("Selected") : ""} - -
- ); - })} +
+ + {t("Auto fetch latest models")} + + + + onChange({ autoFetchModels })} + /> +
+
+ + {t("Auto detect protocols")} + + + +
+ + +
+ {manualProtocolDetection ? ( +
+ {providerProtocolOptions.map((option) => { + const protocol = option.value; + const checked = draft.selectedProtocols.includes(protocol); + return ( +
+ { + onChange({ + selectedProtocols: checked + ? draft.selectedProtocols.filter((selected) => selected !== protocol) + : uniqueProviderProtocols([...draft.selectedProtocols, protocol]) + }); + }} + /> + {translatedProviderProtocolLabel(protocol, t)} + + {checked ? t("Selected") : ""} + +
+ ); + })} +
+ ) : protocolProbeRows.length ? ( +
+ {protocolProbeRows.map((item) => { + const available = item.supported; + const selectableProtocol = selectableProtocols.find((protocol) => protocol === item.protocol); + const selectable = item.supported && Boolean(selectableProtocol); + const checked = Boolean(selectableProtocol && draft.selectedProtocols.includes(selectableProtocol)); + const itemKey = `${item.protocol}-${item.endpoint}`; + return ( +
+ { + if (!selectableProtocol) { + return; + } + onChange({ + selectedProtocols: checked + ? draft.selectedProtocols.filter((protocol) => protocol !== selectableProtocol) + : uniqueProviderProtocols([...draft.selectedProtocols, selectableProtocol]) + }); + }} + /> + {translatedProviderProtocolLabel(item.protocol, t)} + + {available ? t("Available") : t("Unavailable")} + + +
+ ); + })} +
+ ) : ( +
+ {t("No protocol detection yet")} +
+ )}
- ) : protocolProbeRows.length ? ( -
- {protocolProbeRows.map((item) => { - const available = item.supported; - const selectableProtocol = selectableProtocols.find((protocol) => protocol === item.protocol); - const selectable = item.supported && Boolean(selectableProtocol); - const checked = Boolean(selectableProtocol && draft.selectedProtocols.includes(selectableProtocol)); - const itemKey = `${item.protocol}-${item.endpoint}`; - return ( -
- { - if (!selectableProtocol) { - return; - } - onChange({ - selectedProtocols: checked - ? draft.selectedProtocols.filter((protocol) => protocol !== selectableProtocol) - : uniqueProviderProtocols([...draft.selectedProtocols, selectableProtocol]) - }); - }} - /> - {translatedProviderProtocolLabel(item.protocol, t)} - - {available ? t("Available") : t("Unavailable")} - - -
- ); - })} -
- ) : ( -
- {t("No protocol detection yet")} -
- )} -
- +
) : null} diff --git a/packages/ui/src/pages/home/shared/i18n.tsx b/packages/ui/src/pages/home/shared/i18n.tsx index 20bf56d4..5450c10b 100644 --- a/packages/ui/src/pages/home/shared/i18n.tsx +++ b/packages/ui/src/pages/home/shared/i18n.tsx @@ -260,6 +260,8 @@ export const appCopy: Record = { "Advanced settings need attention": "Advanced settings need attention", "Agent profile": "Agent profile", "Back": "Back", + "Auto fetch latest models": "Auto fetch latest models", + "Poll the provider models endpoint every 10 minutes and add newly discovered models automatically.": "Poll the provider models endpoint every 10 minutes and add newly discovered models automatically.", "Auto detect protocols": "Auto detect protocols", "Auto detect protocols description": "When enabled, CCR probes the endpoint while editing and uses the detected protocols and models to update this provider. Turn it off to keep manually selected protocols and custom model IDs unchanged.", "Auto detect protocols info": "Auto detect protocols info", @@ -878,6 +880,8 @@ export const appCopy: Record = { "Optional": "可选", "Required": "必填", "Auto": "自动", + "Auto fetch latest models": "自动拉取最新模型", + "Poll the provider models endpoint every 10 minutes and add newly discovered models automatically.": "每 10 分钟请求一次供应商 models 接口,并自动加入新发现的模型。", "Auto detect protocols": "自动探测协议", "Auto detect protocols description": "开启后,CCR 会在编辑时探测接口,并用探测到的协议和模型更新此供应商。关闭后,手动选择的协议和自定义模型 ID 会保持不变。", "Auto detect protocols info": "自动探测协议说明", diff --git a/packages/ui/src/pages/home/shared/providers.ts b/packages/ui/src/pages/home/shared/providers.ts index 3fbee0e2..58b21366 100644 --- a/packages/ui/src/pages/home/shared/providers.ts +++ b/packages/ui/src/pages/home/shared/providers.ts @@ -637,6 +637,8 @@ export function createProviderDraftFromDeepLinkPayload( return { ...accountDraft, apiKey: payload.apiKey?.trim() || "", + autoFetchModels: false, + autoFetchKnownModels: [], baseUrl, capabilities: payload.capabilities ?? [], catalogModelMetadata: undefined, @@ -733,6 +735,8 @@ export function createProviderDraft(providers: GatewayProviderConfig[]): AddProv return { ...accountDraft, apiKey: "", + autoFetchModels: false, + autoFetchKnownModels: [], baseUrl: "", capabilities: [], catalogModelMetadata: undefined, @@ -763,6 +767,8 @@ export function createProviderDraftFromProvider(provider: GatewayProviderConfig) return { ...accountDraft, apiKey: providerApiKey(provider), + autoFetchModels: Boolean(provider.autoFetchModels), + autoFetchKnownModels: mergeProviderModelLists(provider.autoFetchKnownModels ?? []), baseUrl, capabilities: provider.capabilities ?? [], catalogModelMetadata: undefined, @@ -2166,6 +2172,43 @@ export function providerModelDisplayTitle(provider: GatewayProviderConfig, model return providerModelDisplayName(provider, model); } +export function providerAutoFetchKnownModelsForSave({ + currentModels, + detectedModels = [], + draftKnownModels = [], + existingProvider, + nextBaseUrl +}: { + currentModels: string[]; + detectedModels?: string[]; + draftKnownModels?: string[]; + existingProvider?: GatewayProviderConfig; + nextBaseUrl: string; +}): string[] | undefined { + const current = mergeProviderModelLists(currentModels); + const matchedExistingProvider = existingProvider && providerModelSourceMatches(existingProvider, nextBaseUrl) + ? existingProvider + : undefined; + const existingKnown = matchedExistingProvider + ? mergeProviderModelLists(matchedExistingProvider.autoFetchKnownModels ?? [], matchedExistingProvider.models) + : []; + const draftKnown = matchedExistingProvider ? mergeProviderModelLists(draftKnownModels) : []; + const detected = mergeProviderModelLists(detectedModels); + const known = mergeProviderModelLists(existingKnown, draftKnown, detected, current); + const currentKeys = new Set(current.map((model) => model.toLowerCase())); + const hasKnownModelsOutsideCurrentSelection = known.some((model) => !currentKeys.has(model.toLowerCase())); + const hasObservedCatalogBaseline = detected.length > 0; + const hasExistingBaseline = existingKnown.length > 0 || draftKnown.length > 0; + + return hasKnownModelsOutsideCurrentSelection || hasObservedCatalogBaseline || hasExistingBaseline ? known : undefined; +} + +function providerModelSourceMatches(provider: GatewayProviderConfig, nextBaseUrl: string): boolean { + const currentBaseUrl = normalizeProviderBaseUrl(providerBaseUrl(provider)).toLowerCase(); + const nextNormalizedBaseUrl = normalizeProviderBaseUrl(nextBaseUrl).toLowerCase(); + return Boolean(currentBaseUrl && nextNormalizedBaseUrl && currentBaseUrl === nextNormalizedBaseUrl); +} + export function pickRecommendedProviderModels(models: string[], protocol?: GatewayProviderProtocol): string[] { const candidates = mergeProviderModelLists(models); if (candidates.length === 0) { diff --git a/packages/ui/src/pages/home/shared/types.ts b/packages/ui/src/pages/home/shared/types.ts index eb23bb71..d7ee0152 100644 --- a/packages/ui/src/pages/home/shared/types.ts +++ b/packages/ui/src/pages/home/shared/types.ts @@ -93,6 +93,8 @@ export type AddProviderDraft = { accountMode: ProviderAccountDraftMode; accountRefreshIntervalMs: string; apiKey: string; + autoFetchModels: boolean; + autoFetchKnownModels: string[]; baseUrl: string; capabilities: GatewayProviderCapability[]; catalogModelMetadata?: Record; diff --git a/packages/ui/test/component/components.test.tsx b/packages/ui/test/component/components.test.tsx index e19a673e..3e2f23c5 100644 --- a/packages/ui/test/component/components.test.tsx +++ b/packages/ui/test/component/components.test.tsx @@ -6,6 +6,7 @@ import { Badge } from "@ccr/ui/components/ui/badge.tsx"; import { Button } from "@ccr/ui/components/ui/button.tsx"; import { Card, CardContent, CardHeader, CardTitle } from "@ccr/ui/components/ui/card.tsx"; import { Checkbox } from "@ccr/ui/components/ui/checkbox.tsx"; +import { Dialog, DialogContent } from "@ccr/ui/components/ui/dialog.tsx"; import { Input } from "@ccr/ui/components/ui/input.tsx"; import { Label } from "@ccr/ui/components/ui/label.tsx"; import { PopoverPortal } from "@ccr/ui/components/ui/popover.tsx"; @@ -145,6 +146,17 @@ test("PopoverPortal renders outside the server tree", () => { assert.equal(html, ""); }); +test("Dialog marks its root for top-level keyboard handling", () => { + const html = renderToStaticMarkup( + + Confirm action + + ); + + assert.match(html, /data-ui-dialog-root=""/); + assert.match(html, /role="dialog"/); +}); + test("form primitives preserve native semantics, state, and caller styling", () => { const checkboxHtml = renderToStaticMarkup( diff --git a/packages/ui/test/integration/providers.test.ts b/packages/ui/test/integration/providers.test.ts index fb6b2c3a..4d2dacd6 100644 --- a/packages/ui/test/integration/providers.test.ts +++ b/packages/ui/test/integration/providers.test.ts @@ -26,6 +26,7 @@ import { providerConnectivityProviderPlugins, providerDisplayIcon, providerAccountConnectorsTextWithNewApiUserBalanceTemplate, + providerAutoFetchKnownModelsForSave, parseProviderAccountDraft, providerBrowserConnectorFromDraft, providerGlobalBaseUrlForProbe, @@ -196,6 +197,8 @@ test("provider probe result keeps only supported selected protocols", () => { test("provider draft restores manual protocol detection mode", () => { const draft = createProviderDraftFromProvider({ api_base_url: "https://local.example/v1", + autoFetchModels: true, + autoFetchKnownModels: ["custom-model", "excluded-model"], capabilities: [{ baseUrl: "https://local.example/v1", source: "preset", @@ -208,9 +211,73 @@ test("provider draft restores manual protocol detection mode", () => { }); assert.equal(draft.protocolDetectionMode, "manual"); + assert.equal(draft.autoFetchModels, true); + assert.deepEqual(draft.autoFetchKnownModels, ["custom-model", "excluded-model"]); assert.deepEqual(draft.selectedProtocols, ["openai_chat_completions"]); }); +test("provider auto fetch known model baseline preserves excluded and removed models", () => { + assert.deepEqual(providerAutoFetchKnownModelsForSave({ + currentModels: ["alpha"], + detectedModels: ["alpha", "beta"], + existingProvider: undefined, + nextBaseUrl: "https://api.provider.test/v1" + }), ["alpha", "beta"]); + + assert.deepEqual(providerAutoFetchKnownModelsForSave({ + currentModels: ["alpha", "gamma"], + detectedModels: ["alpha", "beta", "gamma"], + existingProvider: { + api_base_url: "https://api.provider.test/v1", + autoFetchKnownModels: ["alpha", "beta"], + models: ["alpha", "beta"], + name: "Provider" + }, + nextBaseUrl: "https://api.provider.test/v1" + }), ["alpha", "beta", "gamma"]); + + assert.deepEqual(providerAutoFetchKnownModelsForSave({ + currentModels: ["alpha", "beta"], + detectedModels: ["alpha", "beta"], + existingProvider: undefined, + nextBaseUrl: "https://api.provider.test/v1" + }), ["alpha", "beta"]); + + assert.equal(providerAutoFetchKnownModelsForSave({ + currentModels: ["alpha", "beta"], + existingProvider: undefined, + nextBaseUrl: "https://api.provider.test/v1" + }), undefined); +}); + +test("AddProviderForm renders auto model refresh in advanced settings", () => { + const draft = { + ...createProviderDraft([]), + autoFetchModels: true, + baseUrl: "https://local.example/v1", + modelsText: "custom-model", + name: "Local OpenAI", + selectedProtocols: ["openai_chat_completions" as const] + }; + const html = renderToStaticMarkup( + React.createElement(AddProviderForm, { + activeStep: "verify", + draft, + error: "", + mode: "edit", + onChange: () => undefined, + probeLoading: false, + providers: [] + }) + ); + + assert.match(html, /Auto fetch latest models/); + assert.match(html, /aria-label="Auto fetch latest models"/); + assert.match(html, /data-ui-tooltip-trigger/); + assert.match(html, /aria-label="Poll the provider models endpoint every 10 minutes and add newly discovered models automatically\."/); + assert.match(html, /aria-checked="true"/); +}); + test("edit provider dialog keeps advanced settings collapsed by default", () => { const draft = { ...createProviderDraft([]), From c8e5e2b53d9dbbecf09874ee76abb8223f2dc4ee Mon Sep 17 00:00:00 2001 From: musi Date: Tue, 11 Aug 2026 09:25:53 +0800 Subject: [PATCH 06/18] Add WorkBuddy app integration --- packages/core/src/agents/codex/app-launch.ts | 394 +++++++++++++++++- packages/core/src/config/config.ts | 18 +- packages/core/src/contracts/app.ts | 5 +- .../src/observability/request-log-store.ts | 27 +- packages/core/src/profiles/launch-core.ts | 10 +- packages/core/src/profiles/launch-service.ts | 14 +- packages/core/src/profiles/service.ts | 64 ++- packages/core/src/web/management-server.ts | 6 +- .../agents/codex-app-model-catalog.test.mjs | 109 +++++ .../profiles/profile-launch-core.test.mjs | 28 ++ packages/electron/src/main/ipc.ts | 6 +- .../ui/src/assets/agent-logos/workbuddy.png | Bin 0 -> 493711 bytes packages/ui/src/pages/home/App.tsx | 22 +- .../ui/src/pages/home/components/profiles.tsx | 13 +- packages/ui/src/pages/home/shared/i18n.tsx | 6 + packages/ui/src/pages/home/shared/options.ts | 2 + packages/ui/src/pages/home/shared/profiles.ts | 47 ++- packages/ui/src/pages/home/shared/usage.ts | 5 +- packages/ui/test/component/profiles.test.tsx | 52 ++- tests/e2e/electron-cdp.spec.ts | 120 +++++- 20 files changed, 866 insertions(+), 82 deletions(-) create mode 100644 packages/ui/src/assets/agent-logos/workbuddy.png diff --git a/packages/core/src/agents/codex/app-launch.ts b/packages/core/src/agents/codex/app-launch.ts index 08b7d32b..c581481c 100644 --- a/packages/core/src/agents/codex/app-launch.ts +++ b/packages/core/src/agents/codex/app-launch.ts @@ -16,7 +16,7 @@ export type CodexAppLookupResult = { executable?: string; }; -type CodexCompatibleAppKind = "codex" | "zcode"; +type CodexCompatibleAppKind = "codex" | "workbuddy" | "zcode"; type CodexCompatibleAppSpec = { bundledCliNames: string[]; @@ -49,9 +49,31 @@ export type CodexCompatibleAppModelCatalogWriteResult = { changed: boolean; file: string; userDataDir: string; + workbuddyModelsConfig?: WorkbuddyModelsConfigWriteResult; + workbuddyVirtualAuth?: WorkbuddyVirtualAuthResult; +}; + +export type WorkbuddyModelsConfigWriteResult = { + changed: boolean; + file: string; + model: string; +}; + +export type WorkbuddyVirtualAuthResult = { + authFile: string; + changed: boolean; + configDir: string; + homeDir: string; + userDataDir: string; }; export const codexDesktopAppName = "ChatGPT"; +export const workbuddyDesktopAppName = "WorkBuddy AI"; + +const workbuddyVirtualAuthId = "workbuddy-desktop-ai"; +const workbuddyVirtualAuthUserId = "ccr-local-profile"; +const workbuddyVirtualAuthToken = "ccr-local-profile"; +const workbuddyVirtualAuthExpiresAt = 1_999_999_999_999; const codexAppSpec: CodexCompatibleAppSpec = { bundledCliNames: ["codex", "Codex", "OpenAI Codex"], @@ -157,6 +179,56 @@ const zcodeAppSpec: CodexCompatibleAppSpec = { ] }; +const workbuddyAppSpec: CodexCompatibleAppSpec = { + bundledCliNames: [ + "app.asar.unpacked/cli/bin/codebuddy", + "app.asar.unpacked/cli/bin/cbc", + "app.asar.unpacked/cli/bin/cbc-prewarm", + "codebuddy", + "CodeBuddy", + "workbuddy", + "WorkBuddy" + ], + defaultCliCommand: "codebuddy", + displayName: workbuddyDesktopAppName, + envPathKeys: ["CCR_WORKBUDDY_APP_PATH", "WORKBUDDY_APP_PATH", "CODEXL_WORKBUDDY_PATH"], + kind: "workbuddy", + linuxCandidates: [ + "/opt/WorkBuddy AI/workbuddy-ai", + "/opt/WorkBuddy AI/WorkBuddyAI", + "/opt/WorkBuddy/workbuddy", + "/opt/WorkBuddy/WorkBuddy", + "/usr/local/bin/workbuddy-ai", + "/usr/bin/workbuddy-ai", + "/usr/local/bin/workbuddy", + "/usr/bin/workbuddy" + ], + macAppNames: ["WorkBuddy AI.app", "WorkBuddy.app", "WorkBuddyAI.app", "Workbuddy.app"], + modelCatalogFilename: "ccr-workbuddy-model-catalog.json", + userDataDirName: "workbuddy-app-user-data", + windowsAppDirs: ["WorkBuddy AI", "WorkBuddyAI", "WorkBuddy", "Workbuddy", "CodeBuddy"], + windowsExeNames: [ + "WorkBuddy AI.exe", + "WorkBuddyAI.exe", + "WorkBuddy.exe", + "workbuddy-ai.exe", + "workbuddy.exe", + "CodeBuddy.exe", + "codebuddy.exe" + ], + windowsPackageKeywords: ["workbuddy", "workbuddyai", "workbuddy-ai", "codebuddy"], + windowsVendorDirs: ["WorkBuddy", "WorkBuddy AI", "CodeBuddy"], + windowsWhereNames: [ + "WorkBuddy AI", + "WorkBuddyAI", + "WorkBuddy", + "workbuddy-ai", + "workbuddy", + "CodeBuddy", + "codebuddy" + ] +}; + export function launchCodexAppProfile(configDir: string, profile: ProfileConfig, config?: AppConfig): CodexAppLaunchResult { return launchCodexCompatibleAppProfile(configDir, profile, codexAppSpec, config); } @@ -173,12 +245,26 @@ export function launchZcodeAppProfile(configDir: string, profile: ProfileConfig, return launchCodexCompatibleAppProfile(configDir, profile, zcodeAppSpec, config); } +export function findInstalledWorkbuddyAppExecutable(profileAppPath?: string): CodexAppLookupResult { + return findInstalledCodexCompatibleAppExecutable(workbuddyAppSpec, profileAppPath); +} + +export function launchWorkbuddyAppProfile(configDir: string, profile: ProfileConfig, config?: AppConfig): CodexAppLaunchResult { + return launchCodexCompatibleAppProfile(configDir, profile, workbuddyAppSpec, config); +} + export function refreshCodexCompatibleAppProfileFiles( configDir: string, profile: ProfileConfig, config?: AppConfig -): { modelCatalogChanged: boolean; modelCatalogFile: string; userDataDir: string } { - const spec = profile.agent === "zcode" ? zcodeAppSpec : codexAppSpec; +): { + modelCatalogChanged: boolean; + modelCatalogFile: string; + userDataDir: string; + workbuddyModelsConfig?: WorkbuddyModelsConfigWriteResult; + workbuddyVirtualAuth?: WorkbuddyVirtualAuthResult; +} { + const spec = codexCompatibleAppSpecForProfile(profile); if (spec.kind === "zcode" && config?.APIKEY) { writeZcodeGatewayConfig(config, profile, config.APIKEY, { backup: false }); } @@ -186,7 +272,9 @@ export function refreshCodexCompatibleAppProfileFiles( return { modelCatalogChanged: modelCatalog.changed, modelCatalogFile: modelCatalog.file, - userDataDir: modelCatalog.userDataDir + userDataDir: modelCatalog.userDataDir, + workbuddyModelsConfig: modelCatalog.workbuddyModelsConfig, + workbuddyVirtualAuth: modelCatalog.workbuddyVirtualAuth }; } @@ -195,7 +283,7 @@ export function writeCodexCompatibleAppModelCatalog( profile: ProfileConfig, config?: AppConfig ): CodexCompatibleAppModelCatalogWriteResult { - const spec = profile.agent === "zcode" ? zcodeAppSpec : codexAppSpec; + const spec = codexCompatibleAppSpecForProfile(profile); const configFile = resolveCodexConfigFile(configDir, profile); const codexHome = codexCompatibleHomeFromConfigFile(spec, configFile); if (spec.kind === "codex") { @@ -209,7 +297,119 @@ export function writeCodexCompatibleAppModelCatalog( if (previous !== content) { writeFileSync(file, content, "utf8"); } - return { changed: previous !== content, file, userDataDir }; + const workbuddyModelsConfig = spec.kind === "workbuddy" + ? writeWorkbuddyModelsConfig(codexHome, profile, config) + : undefined; + const workbuddyVirtualAuth = spec.kind === "workbuddy" + ? prepareWorkbuddyAppVirtualAuth(codexHome, userDataDir, profile) + : undefined; + return { + changed: previous !== content || Boolean(workbuddyModelsConfig?.changed), + file, + userDataDir, + workbuddyModelsConfig, + workbuddyVirtualAuth + }; +} + +export function writeWorkbuddyModelsConfig( + workbuddyConfigDir: string, + profile: Pick, + config?: AppConfig +): WorkbuddyModelsConfigWriteResult { + const file = path.join(workbuddyConfigDir, "models.json"); + const model = workbuddySelectedModel(profile, config); + const content = `${JSON.stringify(workbuddyModelsConfig(model, profile, config), null, 2)}\n`; + mkdirSync(path.dirname(file), { recursive: true }); + const previous = existsSync(file) ? readFileSync(file, "utf8") : undefined; + if (previous !== content) { + writeFileSync(file, content, "utf8"); + } + return { changed: previous !== content, file, model }; +} + +function workbuddySelectedModel( + profile: Pick, + config?: CodexCompatibleAppModelCatalogConfig +): string { + const configured = profile.model?.trim(); + if (configured) { + return configured; + } + return codexCompatibleAppModelCatalog(config, undefined, "workbuddy").models[0]?.slug || "gpt-5-codex"; +} + +function workbuddyModelsConfig( + model: string, + profile: Pick, + config?: AppConfig +): Record { + const catalogItem = codexCompatibleAppModelCatalog(config, model, "workbuddy") + .models.find((item) => item.slug === model || item.id === model || item.model === model); + const vendor = workbuddyModelVendor(model, profile.providerName); + const displayName = profile.name?.trim() + ? `${profile.name.trim()} / ${model}` + : `${vendor} / ${model}`; + const workbuddyModel: Record = { + apiKey: "${CCR_PROFILE_API_KEY}", + disabled: false, + id: model, + isDefault: true, + name: displayName, + supportsImages: Boolean(catalogItem?.supports_image_detail_original), + supportsReasoning: Boolean(catalogItem?.supports_reasoning_summaries), + supportsToolCall: true, + tags: ["chat", "custom"], + url: workbuddyGatewayBaseUrl(config), + vendor + }; + const maxInputTokens = catalogItem?.context_window; + if (typeof maxInputTokens === "number" && Number.isFinite(maxInputTokens) && maxInputTokens > 0) { + workbuddyModel.maxInputTokens = Math.trunc(maxInputTokens); + } + const reasoningEfforts = catalogItem?.supported_reasoning_efforts; + if (Array.isArray(reasoningEfforts) && reasoningEfforts.length > 0) { + workbuddyModel.reasoning = { + ...(catalogItem?.default_reasoning_effort ? { defaultEffort: catalogItem.default_reasoning_effort } : {}), + supportedEfforts: reasoningEfforts + }; + } + return { + availableModels: [model], + models: [workbuddyModel] + }; +} + +function workbuddyModelVendor(model: string, providerName?: string): string { + const slashIndex = model.indexOf("/"); + if (slashIndex > 0) { + const provider = model.slice(0, slashIndex).trim(); + if (provider) { + return provider; + } + } + return providerName?.trim() || "Claude Code Router"; +} + +function workbuddyGatewayBaseUrl(config?: Pick): string { + if (!config?.gateway) { + return "${CCR_WORKBUDDY_GATEWAY_BASE_URL}"; + } + const host = config.gateway.host === "0.0.0.0" || config.gateway.host === "::" + ? "127.0.0.1" + : config.gateway.host || "127.0.0.1"; + const formattedHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; + return `http://${formattedHost}:${config.gateway.port}/v1`; +} + +function codexCompatibleAppSpecForProfile(profile: Pick): CodexCompatibleAppSpec { + if (profile.agent === "zcode") { + return zcodeAppSpec; + } + if (profile.agent === "workbuddy") { + return workbuddyAppSpec; + } + return codexAppSpec; } function codexCompatibleAppModelCatalogJson( @@ -366,7 +566,7 @@ function launchCodexCompatibleAppProfile( const configFile = resolveCodexConfigFile(configDir, profile); const codexHome = codexCompatibleHomeFromConfigFile(spec, configFile); - const { modelCatalogFile, userDataDir } = refreshCodexCompatibleAppProfileFiles(configDir, profile, config); + const { modelCatalogFile, userDataDir, workbuddyVirtualAuth } = refreshCodexCompatibleAppProfileFiles(configDir, profile, config); if (spec.kind === "codex") prepareCodexAppCdpUserDataDir(userDataDir); const appEnv: Record = { @@ -375,7 +575,8 @@ function launchCodexCompatibleAppProfile( ...codexProfileEnv(profile, lookup.executable, spec), CODEXL_PROFILE_SURFACE: "app", CCR_PROFILE_SURFACE: "app", - ...codexAppAgentEnv(spec, plan.command, codexHome, userDataDir, modelCatalogFile), + ...codexAppAgentEnv(spec, plan.command, codexHome, userDataDir, modelCatalogFile, workbuddyVirtualAuth), + ...codexCompatibleAppGatewayEnv(spec, config), ELECTRON_ENABLE_LOGGING: "1" }; const env: NodeJS.ProcessEnv = { @@ -406,6 +607,19 @@ function launchCodexCompatibleAppProfile( }; } +function codexCompatibleAppGatewayEnv(spec: CodexCompatibleAppSpec, config?: AppConfig): Record { + if (spec.kind !== "workbuddy" || !config) { + return {}; + } + const baseUrl = workbuddyGatewayBaseUrl(config); + const apiKey = config.APIKEY?.trim() || ""; + return { + ...(apiKey ? { CCR_PROFILE_API_KEY: apiKey, CODEXL_PROFILE_API_KEY: apiKey } : {}), + CCR_WORKBUDDY_GATEWAY_BASE_URL: baseUrl, + CODEXL_WORKBUDDY_GATEWAY_BASE_URL: baseUrl + }; +} + function codexProfileEnv(profile: ProfileConfig, appExecutable: string, spec: CodexCompatibleAppSpec): Record { const providerId = sanitizeCodexProviderId(profile.providerId || "") || "claude-code-router"; const realCliPath = profile.codexCliPath?.trim() || bundledCodexCliPath(appExecutable, spec) || spec.defaultCliCommand; @@ -424,7 +638,7 @@ function codexProfileEnv(profile: ProfileConfig, appExecutable: string, spec: Co CODEXL_ZCODE_WORKSPACE_NAME: profile.name || providerId }; } - return { + const codexEnv = { ...(profile.model.trim() ? { CCR_CODEX_MODEL: profile.model.trim() } : {}), ...(process.env.CCR_CODEX_CLI_MIDDLEWARE_LOG?.trim() ? { CCR_CODEX_CLI_MIDDLEWARE_LOG: process.env.CCR_CODEX_CLI_MIDDLEWARE_LOG.trim() } @@ -442,6 +656,22 @@ function codexProfileEnv(profile: ProfileConfig, appExecutable: string, spec: Co CODEXL_CODEX_WORKSPACE_NAME: profile.name || providerId, CODEXL_REAL_CODEX_CLI_PATH: realCliPath }; + if (spec.kind !== "workbuddy") { + return codexEnv; + } + return { + ...codexEnv, + ...(profile.model.trim() ? { CCR_WORKBUDDY_MODEL: profile.model.trim() } : {}), + CCR_REAL_WORKBUDDY_CLI_PATH: realCliPath, + CCR_WORKBUDDY_MODEL_PROVIDER: providerId, + CCR_WORKBUDDY_PROFILE: providerId, + CCR_WORKBUDDY_REMOTE_FRONTEND_MODE: remoteFrontendMode, + CODEXL_REAL_WORKBUDDY_CLI_PATH: realCliPath, + CODEXL_WORKBUDDY_CORE_MODE: remoteFrontendMode, + CODEXL_WORKBUDDY_MODEL_PROVIDER: providerId, + CODEXL_WORKBUDDY_PROFILE: providerId, + CODEXL_WORKBUDDY_WORKSPACE_NAME: profile.name || providerId + }; } function codexSharedChatGptAuthEnv(): Record { @@ -468,28 +698,136 @@ function codexAppAgentEnv( launcher: string, home: string, userDataDir: string, - modelCatalogFile: string + modelCatalogFile: string, + workbuddyVirtualAuth?: WorkbuddyVirtualAuthResult ): Record { - return spec.kind === "zcode" - ? { + if (spec.kind === "zcode") { + return { CCR_ZCODE_MODEL_CATALOG_FILE: modelCatalogFile, CODEXL_ZCODE_MODEL_CATALOG_FILE: modelCatalogFile, ZCODE_CLI_PATH: launcher, ZCODE_ELECTRON_USER_DATA_PATH: userDataDir, ZCODE_HOME: home, ZCODE_STORAGE_DIR: home - } - : { - CCR_CODEX_MODEL_CATALOG_FILE: modelCatalogFile, - CODEX_CLI_PATH: launcher, - CODEX_ELECTRON_USER_DATA_PATH: userDataDir, - CODEX_HOME: home, - CODEXL_CODEX_MODEL_CATALOG_FILE: modelCatalogFile }; + } + const codexEnv = { + CCR_CODEX_MODEL_CATALOG_FILE: modelCatalogFile, + CODEX_CLI_PATH: launcher, + CODEX_ELECTRON_USER_DATA_PATH: userDataDir, + CODEX_HOME: home, + CODEXL_CODEX_MODEL_CATALOG_FILE: modelCatalogFile + }; + if (spec.kind !== "workbuddy") { + return codexEnv; + } + return { + ...codexEnv, + CCR_WORKBUDDY_MODEL_CATALOG_FILE: modelCatalogFile, + ...(workbuddyVirtualAuth ? workbuddyVirtualAuthEnv(workbuddyVirtualAuth) : {}), + CODEBUDDY_CLI_PATH: launcher, + CODEBUDDY_CONFIG_DIR: home, + CODEBUDDY_ELECTRON_USER_DATA_PATH: userDataDir, + CODEBUDDY_HOME: home, + CODEXL_WORKBUDDY_MODEL_CATALOG_FILE: modelCatalogFile, + WORKBUDDY_CLI_PATH: launcher, + WORKBUDDY_CONFIG_DIR: home, + WORKBUDDY_ELECTRON_USER_DATA_PATH: userDataDir, + WORKBUDDY_HOME: home + }; +} + +export function prepareWorkbuddyAppVirtualAuth( + configDir: string, + userDataDir: string, + profile: Pick +): WorkbuddyVirtualAuthResult { + const homeDir = workbuddyAppVirtualHomeDir(configDir); + const authFile = workbuddyAppVirtualAuthFile(homeDir); + const account = workbuddyVirtualAuthAccount(profile); + const session = { + account, + accounts: [account], + allAccounts: [account], + auth: { + accessToken: workbuddyVirtualAuthToken, + domain: "www.workbuddy.ai", + expiresAt: workbuddyVirtualAuthExpiresAt, + expiresIn: workbuddyVirtualAuthExpiresAt, + lastRefreshTime: Date.now(), + refreshExpiresAt: workbuddyVirtualAuthExpiresAt, + refreshExpiresIn: workbuddyVirtualAuthExpiresAt, + refreshToken: "", + scope: "", + tokenType: "Bearer" + } + }; + const content = `${JSON.stringify(session, null, 2)}\n`; + mkdirSync(path.dirname(authFile), { recursive: true }); + const logoutMarker = `${authFile}.logged-out`; + if (existsSync(logoutMarker)) { + unlinkSync(logoutMarker); + } + const previous = existsSync(authFile) ? readFileSync(authFile, "utf8") : undefined; + if (previous !== content) { + writeFileSync(authFile, content, "utf8"); + } + return { + authFile, + changed: previous !== content, + configDir, + homeDir, + userDataDir + }; +} + +function workbuddyVirtualAuthAccount(profile: Pick): Record { + return { + avatarUrl: "", + lastLogin: true, + nickname: profile.name?.trim() || "Claude Code Router", + pluginEnabled: true, + type: "personal", + uid: workbuddyVirtualAuthUserId + }; +} + +function workbuddyVirtualAuthEnv(result: WorkbuddyVirtualAuthResult): Record { + return { + APPDATA: path.join(result.homeDir, "AppData", "Roaming"), + CCR_WORKBUDDY_VIRTUAL_AUTH_FILE: result.authFile, + CODEXL_WORKBUDDY_VIRTUAL_AUTH_FILE: result.authFile, + HOME: result.homeDir, + LOCALAPPDATA: path.join(result.homeDir, "AppData", "Local"), + USERPROFILE: result.homeDir, + WORKBUDDY_USER_DATA_DIR: result.userDataDir + }; +} + +function workbuddyAppVirtualHomeDir(configDir: string): string { + return path.join(configDir, ".claude-code-router", "workbuddy-app-home"); +} + +function workbuddyAppVirtualAuthFile(homeDir: string): string { + return path.join(workbuddySharedAuthDir(homeDir), `${workbuddyVirtualAuthId}.info`); +} + +function workbuddySharedAuthDir(homeDir: string): string { + if (process.platform === "darwin") { + return path.join(homeDir, "Library", "Application Support", "CodeBuddyExtension", "Data", "Public", "auth"); + } + if (process.platform === "win32") { + return path.join(homeDir, "AppData", "Local", "CodeBuddyExtension", "Data", "Public", "auth"); + } + return path.join(homeDir, ".local", "share", "CodeBuddyExtension", "Data", "Public", "auth"); } function sanitizeCodexCompatibleAppEnv(env: NodeJS.ProcessEnv, kind: CodexCompatibleAppKind): void { - const blockedPrefixes = kind === "zcode" ? ["CCR_CODEX_", "CODEXL_CODEX_"] : ["CCR_ZCODE_", "CODEXL_ZCODE_"]; + const blockedPrefixes = kind === "zcode" + ? ["CCR_CODEX_", "CODEXL_CODEX_", "CCR_WORKBUDDY_", "CODEXL_WORKBUDDY_"] + : kind === "workbuddy" + ? ["CCR_ZCODE_", "CODEXL_ZCODE_"] + : ["CCR_ZCODE_", "CODEXL_ZCODE_", "CCR_WORKBUDDY_", "CODEXL_WORKBUDDY_"]; for (const key of Object.keys(env)) { if (blockedPrefixes.some((prefix) => key.startsWith(prefix))) { delete env[key]; @@ -499,12 +837,28 @@ function sanitizeCodexCompatibleAppEnv(env: NodeJS.ProcessEnv, kind: CodexCompat delete env.CODEX_CLI_PATH; delete env.CODEX_ELECTRON_USER_DATA_PATH; delete env.CODEX_HOME; + delete env.WORKBUDDY_CLI_PATH; + delete env.WORKBUDDY_ELECTRON_USER_DATA_PATH; + delete env.WORKBUDDY_HOME; + delete env.CODEBUDDY_CLI_PATH; + delete env.CODEBUDDY_CONFIG_DIR; + delete env.CODEBUDDY_ELECTRON_USER_DATA_PATH; + delete env.CODEBUDDY_HOME; return; } delete env.ZCODE_CLI_PATH; delete env.ZCODE_ELECTRON_USER_DATA_PATH; delete env.ZCODE_HOME; delete env.ZCODE_STORAGE_DIR; + if (kind === "codex") { + delete env.WORKBUDDY_CLI_PATH; + delete env.WORKBUDDY_ELECTRON_USER_DATA_PATH; + delete env.WORKBUDDY_HOME; + delete env.CODEBUDDY_CLI_PATH; + delete env.CODEBUDDY_CONFIG_DIR; + delete env.CODEBUDDY_ELECTRON_USER_DATA_PATH; + delete env.CODEBUDDY_HOME; + } } function bundledCodexCliPath(appExecutable: string, spec: CodexCompatibleAppSpec): string | undefined { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 613521c4..b9acb290 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -740,7 +740,7 @@ function sanitizeProfileConfigForDisk(profile: AppConfig["profile"]): AppConfig[ ...synchronizedProfile, codex, profiles: synchronizedProfile.profiles.map((profileItem) => { - if (profileItem.agent !== "codex" && profileItem.agent !== "opencode" && profileItem.agent !== "kilo" && profileItem.agent !== "zcode") { + if (profileItem.agent !== "codex" && profileItem.agent !== "opencode" && profileItem.agent !== "kilo" && profileItem.agent !== "workbuddy" && profileItem.agent !== "zcode") { return profileItem; } const { @@ -3568,7 +3568,7 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined { ]); const env = parseStringRecord(item.env) ?? {}; const parsedSurface = parseProfileSurface(readString(item.surface) || readString(item.entry) || readString(item.frontend)) || "auto"; - const surface = agent === "zcode" || agent === CLAUDE_DESIGN_PLUGIN_ID + const surface = agent === "workbuddy" || agent === "zcode" || agent === CLAUDE_DESIGN_PLUGIN_ID ? "app" : agent === "pi" || agent === "kilo" ? "cli" @@ -3636,7 +3636,7 @@ function parseProfiles(value: unknown): ProfileConfig[] | undefined { } const appPath = readProfileAppPath(item, agent); - const showAllSessions = agent === "zcode" || agent === "opencode" || agent === "kilo" + const showAllSessions = agent === "zcode" || agent === "opencode" || agent === "kilo" || agent === "workbuddy" ? false : typeof item.showAllSessions === "boolean" ? item.showAllSessions @@ -3720,7 +3720,9 @@ function readProfileAppPath(item: Record, agent: ProfileConfig[ ? readString(item.chatgptAppPath) || readString(item.chatgpt_app_path) || readString(item.codexAppPath) || readString(item.codex_app_path) : agent === "opencode" ? readString(item.openCodeAppPath) || readString(item.opencodeAppPath) || readString(item.opencode_app_path) - : readString(item.zcodeAppPath) || readString(item.zcode_app_path)); + : agent === "workbuddy" + ? readString(item.workbuddyAppPath) || readString(item.workbuddy_app_path) || readString(item.workBuddyAppPath) || readString(item.work_buddy_app_path) + : readString(item.zcodeAppPath) || readString(item.zcode_app_path)); } function parseProfileAgent(value: unknown): ProfileConfig["agent"] | undefined { @@ -3749,6 +3751,9 @@ function parseProfileAgent(value: unknown): ProfileConfig["agent"] | undefined { if (normalized === "pi" || normalized === "pi-agent" || normalized === "pi agent" || normalized === "pi-coding-agent" || normalized === "pi coding agent") { return "pi"; } + if (normalized === "workbuddy" || normalized === "work-buddy" || normalized === "work buddy" || normalized === "workbuddy-agent" || normalized === "workbuddy agent") { + return "workbuddy"; + } if (normalized === "zcode" || normalized === "z-code" || normalized === "z code") { return "zcode"; } @@ -3790,6 +3795,9 @@ function defaultProfileAgentName(agent: ProfileConfig["agent"]): string { if (agent === "pi") { return "Pi"; } + if (agent === "workbuddy") { + return "Workbuddy"; + } if (agent === CLAUDE_DESIGN_PLUGIN_ID) { return "Claude Design"; } @@ -3803,6 +3811,8 @@ function defaultCodexConfigFile(agent: ProfileConfig["agent"]): string { ? "~/.config/opencode/opencode.jsonc" : agent === "kilo" ? "~/.config/kilo/kilo.jsonc" + : agent === "workbuddy" + ? "~/.workbuddy/config.toml" : agent === "pi" ? "~/.pi/agent" : agent === CLAUDE_DESIGN_PLUGIN_ID diff --git a/packages/core/src/contracts/app.ts b/packages/core/src/contracts/app.ts index 2539d605..d01ff4ff 100644 --- a/packages/core/src/contracts/app.ts +++ b/packages/core/src/contracts/app.ts @@ -11,6 +11,7 @@ export type AppInfo = { platform: string; usageDbFile: string; version: string; + workbuddyAppPath?: string; }; export type AppDataExportResult = { @@ -1401,7 +1402,7 @@ export const DEFAULT_TRAY_WIDGETS: TrayWidgetConfig[] = [ { id: "model-share", type: "model-share", variant: DEFAULT_TRAY_COMPONENT_VARIANTS.modelShare } ]; -export type ProfileClientKind = "claude-code" | "codex" | "grok" | "kimi" | "kilo" | "opencode" | "pi" | "zcode" | "claude-design"; +export type ProfileClientKind = "claude-code" | "codex" | "grok" | "kimi" | "kilo" | "opencode" | "pi" | "workbuddy" | "zcode" | "claude-design"; export type CodexProfileConfigFormat = "legacy" | "separate_profile_files"; export type CodexRemoteFrontendMode = "app" | "cli" | "claude-code"; export type ProfileScope = "ccr" | "global" | "custom"; @@ -2190,7 +2191,7 @@ export type UsageStatsSnapshot = { totals: UsageTotals; }; -export type AgentKind = "claude-code" | "codex" | "grok" | "kimi" | "kilo" | "opencode" | "pi" | "zcode" | "claude-design" | "unknown"; +export type AgentKind = "claude-code" | "codex" | "grok" | "kimi" | "kilo" | "opencode" | "pi" | "workbuddy" | "zcode" | "claude-design" | "unknown"; export type AgentAnalysisFilter = { agent?: AgentKind | "all"; diff --git a/packages/core/src/observability/request-log-store.ts b/packages/core/src/observability/request-log-store.ts index 052c21d9..8acbab38 100644 --- a/packages/core/src/observability/request-log-store.ts +++ b/packages/core/src/observability/request-log-store.ts @@ -1651,6 +1651,14 @@ function inferAgentFromText(value: string, options: AgentTextSignalOptions = {}) ) { return "kilo"; } + if ( + normalized.includes("workbuddy") || + normalized.includes("work-buddy") || + normalized.includes("work buddy") || + /(^|[^a-z0-9])workbuddy([/_\s-]|$)/.test(normalized) + ) { + return "workbuddy"; + } if ( normalized.includes("openai-codex") || normalized.includes("codex_cli") || @@ -1727,6 +1735,16 @@ function readAgentSessionHeader(headers: Record, agen "x-z-code-session-id", "z-code-session-id" ]; + const workbuddyHeaders = [ + "x-workbuddy-session-id", + "workbuddy-session-id", + "x-workbuddy-conversation-id", + "workbuddy-conversation-id", + "x-workbuddy-thread-id", + "workbuddy-thread-id", + "x-work-buddy-session-id", + "work-buddy-session-id" + ]; const piHeaders = [ "x-pi-session-id", "pi-session-id", @@ -1737,6 +1755,8 @@ function readAgentSessionHeader(headers: Record, agen ]; const orderedHeaders = agent === "zcode" ? [...zcodeHeaders, ...codexHeaders, ...commonHeaders, ...claudeCodeHeaders] + : agent === "workbuddy" + ? [...workbuddyHeaders, ...codexHeaders, ...commonHeaders, ...claudeCodeHeaders] : agent === "pi" ? [...piHeaders, ...commonHeaders, ...codexHeaders, ...claudeCodeHeaders] : agent === "codex" @@ -3150,11 +3170,11 @@ function normalizeAgentAnalysisRange(value: UsageStatsRange | undefined): UsageS } function normalizeAgentFilter(value: AgentAnalysisFilter["agent"] | undefined): AgentKind | "all" { - return value === "claude-code" || value === "codex" || value === "grok" || value === "kimi" || value === "kilo" || value === "opencode" || value === "pi" || value === "zcode" || value === "claude-design" || value === "unknown" ? value : "all"; + return value === "claude-code" || value === "codex" || value === "grok" || value === "kimi" || value === "kilo" || value === "opencode" || value === "pi" || value === "workbuddy" || value === "zcode" || value === "claude-design" || value === "unknown" ? value : "all"; } function normalizeSessionAgentFilter(value: AgentAnalysisFilter["sessionAgent"] | undefined): AgentKind | undefined { - return value === "claude-code" || value === "codex" || value === "grok" || value === "kimi" || value === "kilo" || value === "opencode" || value === "pi" || value === "zcode" || value === "claude-design" || value === "unknown" ? value : undefined; + return value === "claude-code" || value === "codex" || value === "grok" || value === "kimi" || value === "kilo" || value === "opencode" || value === "pi" || value === "workbuddy" || value === "zcode" || value === "claude-design" || value === "unknown" ? value : undefined; } function agentDisplayName(agent: AgentKind): string { @@ -3182,6 +3202,9 @@ function agentDisplayName(agent: AgentKind): string { if (agent === "pi") { return "Pi"; } + if (agent === "workbuddy") { + return "Workbuddy"; + } if (agent === "zcode") { return "ZCode"; } diff --git a/packages/core/src/profiles/launch-core.ts b/packages/core/src/profiles/launch-core.ts index 0035564b..1ac60d2a 100644 --- a/packages/core/src/profiles/launch-core.ts +++ b/packages/core/src/profiles/launch-core.ts @@ -49,7 +49,7 @@ export function findProfileForOpen(config: Pick, profileRe } export function profileOpenSurfaces(profile: ProfileConfig): ProfileOpenSurface[] { - if (profile.agent === "zcode" || profile.agent === "claude-design") { + if (profile.agent === "workbuddy" || profile.agent === "zcode" || profile.agent === "claude-design") { return ["app"]; } if (profile.agent === "grok" || profile.agent === "kimi" || profile.agent === "pi" || profile.agent === "kilo") { @@ -77,7 +77,7 @@ export function resolveProfileOpenSurface(profile: ProfileConfig, surface?: Prof } export function defaultProfileOpenSurface(profile: Pick): ProfileOpenSurface { - return profile.agent === "zcode" || profile.agent === "claude-design" ? "app" : "cli"; + return profile.agent === "workbuddy" || profile.agent === "zcode" || profile.agent === "claude-design" ? "app" : "cli"; } export function shouldAutoStartProfileGateway( @@ -334,12 +334,14 @@ function buildClaudeCodeLaunchPlan( } function isCodexCompatibleAgent(agent: ProfileConfig["agent"]): boolean { - return agent === "codex" || agent === "zcode"; + return agent === "codex" || agent === "workbuddy" || agent === "zcode"; } function defaultCodexConfigFile(agent: ProfileConfig["agent"]): string { return agent === "zcode" ? "~/.zcode/cli/config.json" + : agent === "workbuddy" + ? "~/.workbuddy/config.toml" : agent === "pi" ? "~/.pi/agent" : agent === "claude-design" @@ -348,7 +350,7 @@ function defaultCodexConfigFile(agent: ProfileConfig["agent"]): string { } function codexConfigSubdir(agent: ProfileConfig["agent"]): string { - return agent === "zcode" ? "zcode" : "codex"; + return agent === "zcode" ? "zcode" : agent === "workbuddy" ? "workbuddy" : "codex"; } function claudeCodeWrapperFilename(profile: ProfileConfig): string { diff --git a/packages/core/src/profiles/launch-service.ts b/packages/core/src/profiles/launch-service.ts index e1c1db2b..98956fec 100644 --- a/packages/core/src/profiles/launch-service.ts +++ b/packages/core/src/profiles/launch-service.ts @@ -7,7 +7,7 @@ import { botGatewayProfileEnv } from "@ccr/core/agents/bot-gateway/env"; import { applyClaudeAppGatewayConfig, readClaudeAppGatewayApiKeyCandidates } from "@ccr/core/agents/claude-app/gateway-service"; import { launchClaudeAppProfile, resolveClaudeAppProfileUserDataDir } from "@ccr/core/agents/claude-app/launch"; import { claudeCodeUtcTimezoneEnvOverride } from "@ccr/core/agents/claude-code/environment"; -import { codexDesktopAppName, launchCodexAppProfile, launchZcodeAppProfile, refreshCodexCompatibleAppProfileFiles } from "@ccr/core/agents/codex/app-launch"; +import { codexDesktopAppName, launchCodexAppProfile, launchWorkbuddyAppProfile, launchZcodeAppProfile, refreshCodexCompatibleAppProfileFiles, workbuddyDesktopAppName } from "@ccr/core/agents/codex/app-launch"; import { CodexAppMediaPreviewBridge, shouldEnableCodexMediaPreviewBridge } from "@ccr/core/agents/codex/media-preview-bridge"; import { findRunningOpenCodeAppPid, launchOpenCodeAppProfile, openCodeAppLaunchSignature } from "@ccr/core/agents/opencode/app-launch"; import { writeOpenCodeGatewayConfig } from "@ccr/core/agents/opencode/profile-config"; @@ -127,7 +127,7 @@ export async function openProfileFromCcr(config: AppConfig, request: ProfileOpen if (profile.agent === "claude-code" && surface === "app") { return openClaudeAppProfile(config, profile); } - if ((profile.agent === "codex" || profile.agent === "zcode") && surface === "app") { + if ((profile.agent === "codex" || profile.agent === "workbuddy" || profile.agent === "zcode") && surface === "app") { return await openCodexAppProfile(config, profile); } if (profile.agent === "opencode" && surface === "app") { @@ -260,7 +260,11 @@ async function openOpenCodeAppProfile(config: AppConfig, profile: ReturnType): Promise { - const appName = profile.agent === "zcode" ? "ZCode App" : codexDesktopAppName; + const appName = profile.agent === "zcode" + ? "ZCode App" + : profile.agent === "workbuddy" + ? workbuddyDesktopAppName + : codexDesktopAppName; const profileGatewayConfig = await ensureProfileGateway(config, profile, appName); const existing = runningProfileApp(profile.id, "app"); if (existing) { @@ -297,7 +301,9 @@ async function openCodexAppProfile(config: AppConfig, profile: ReturnType `export ${key}=${shellQuote(value)}`); const botEnvExports = shellBotGatewayEnvExports(config, profile); const agentEnvExports = profile.agent === "zcode" @@ -2082,6 +2083,14 @@ function codexMiddlewareShellScript( ] : [ `export CODEX_HOME=${shellQuote(resolvedCodexHome)}`, + ...(profile.agent === "workbuddy" + ? [ + `export WORKBUDDY_HOME=${shellQuote(resolvedCodexHome)}`, + `export WORKBUDDY_CONFIG_DIR=${shellQuote(resolvedCodexHome)}`, + `export CODEBUDDY_CONFIG_DIR=${shellQuote(resolvedCodexHome)}`, + `export CODEBUDDY_HOME=${shellQuote(resolvedCodexHome)}` + ] + : []), "if [ -z \"${CCR_REAL_CODEX_CLI_PATH:-}\" ]; then", ` CCR_REAL_CODEX_CLI_PATH=${shellQuote(codexCli)}`, "fi", @@ -2190,7 +2199,7 @@ function codexMiddlewareCmdScript( const codexHome = profile.codexHome?.trim() || defaultCodexCompatibleHome(profile.agent, values.configFile); const resolvedCodexHome = resolveUserPath(codexHome); const remoteFrontendMode = normalizeCodexRemoteFrontendMode(profile.remoteFrontendMode); - const surface = profile.agent === "zcode" ? "app" : normalizeProfileSurface(profile.surface); + const surface = profile.agent === "workbuddy" || profile.agent === "zcode" ? "app" : normalizeProfileSurface(profile.surface); const workspaceName = profile.name || values.providerId; const envExports = Object.entries(profileEnv(profile)).map(([key, value]) => cmdSetLine(key, value)); const botEnvExports = cmdBotGatewayEnvExports(config, profile); @@ -2216,6 +2225,14 @@ function codexMiddlewareCmdScript( ] : [ cmdSetLine("CODEX_HOME", resolvedCodexHome), + ...(profile.agent === "workbuddy" + ? [ + cmdSetLine("WORKBUDDY_HOME", resolvedCodexHome), + cmdSetLine("WORKBUDDY_CONFIG_DIR", resolvedCodexHome), + cmdSetLine("CODEBUDDY_CONFIG_DIR", resolvedCodexHome), + cmdSetLine("CODEBUDDY_HOME", resolvedCodexHome) + ] + : []), `if not defined CCR_REAL_CODEX_CLI_PATH ${cmdSetLine("CCR_REAL_CODEX_CLI_PATH", codexCli)}`, "if not defined CCR_BUNDLED_CODEX_CLI_PATH set \"CCR_BUNDLED_CODEX_CLI_PATH=%CCR_REAL_CODEX_CLI_PATH%\"", cmdSetLine("CCR_CODEX_PROFILE", values.providerId), @@ -2716,10 +2733,11 @@ function disabledProfileStatus(profile: ProfileConfig): ProfileClientApplyStatus ); } const providerId = sanitizeCodexProviderId(profile.providerId || "") || "claude-code-router"; + const clientName = codexCompatibleClientName(profile.agent); return restoreDisabledGlobalProfile( profile, resolveCodexConfigFile(profile), - "Codex profile is disabled.", + `${clientName} profile is disabled.`, (content) => isManagedCodexConfigContent(content, providerId) ); } @@ -2745,11 +2763,12 @@ export function restoreInactiveGlobalProfileConfigs(profiles: ProfileConfig[]): } const codexProfiles = profiles.filter((profile) => profile.agent === "codex"); if (codexProfiles.length > 0 && !codexProfiles.some((profile) => profile.enabled && isGlobalProfile(profile))) { + const providerIds = codexCompatibleProviderIds(codexProfiles); for (const file of uniqueResolvedPaths([ ...codexProfiles.map(globalCodexConfigCandidate) ])) { const restoreResult = restoreGlobalConfigFile(file, { - isManagedContent: (content) => isManagedCodexConfigContent(content, "claude-code-router"), + isManagedContent: (content) => providerIds.some((providerId) => isManagedCodexConfigContent(content, providerId)), mode: privateFileMode }); if (restoreResult.changed || restoreResult.missingBackup) { @@ -2757,6 +2776,21 @@ export function restoreInactiveGlobalProfileConfigs(profiles: ProfileConfig[]): } } } + const workbuddyProfiles = profiles.filter((profile) => profile.agent === "workbuddy"); + if (workbuddyProfiles.length > 0 && !workbuddyProfiles.some((profile) => profile.enabled && isGlobalProfile(profile))) { + const providerIds = codexCompatibleProviderIds(workbuddyProfiles); + for (const file of uniqueResolvedPaths([ + ...workbuddyProfiles.map(globalCodexConfigCandidate) + ])) { + const restoreResult = restoreGlobalConfigFile(file, { + isManagedContent: (content) => providerIds.some((providerId) => isManagedCodexConfigContent(content, providerId)), + mode: privateFileMode + }); + if (restoreResult.changed || restoreResult.missingBackup) { + statuses.push(inactiveGlobalCleanupStatus("workbuddy", file, restoreResult)); + } + } + } const openCodeProfiles = profiles.filter((profile) => profile.agent === "opencode"); if (openCodeProfiles.length > 0 && !openCodeProfiles.some((profile) => profile.enabled && isGlobalProfile(profile))) { const providerIds = [...new Set([ @@ -2823,7 +2857,14 @@ function globalCodexConfigCandidate(profile: ProfileConfig): string { if (codexHome) { return path.join(resolveUserPath(codexHome), "config.toml"); } - return profile.configFile || "~/.codex/config.toml"; + return profile.configFile || defaultCodexConfigFile(profile.agent); +} + +function codexCompatibleProviderIds(profiles: ProfileConfig[]): string[] { + return [...new Set([ + "claude-code-router", + ...profiles.map((profile) => sanitizeCodexProviderId(profile.providerId || "")).filter(Boolean) + ])]; } function globalOpenCodeConfigCandidate(profile: ProfileConfig): string { @@ -2931,7 +2972,7 @@ function readGlobalProfileTakeoverMarker(): GlobalProfileTakeoverRecord[] { } return parsed.profiles.filter((value): value is GlobalProfileTakeoverRecord => isRecord(value) && - (value.agent === "claude-code" || value.agent === "codex" || value.agent === "opencode" || value.agent === "kilo" || value.agent === "zcode") && + (value.agent === "claude-code" || value.agent === "codex" || value.agent === "opencode" || value.agent === "kilo" || value.agent === "workbuddy" || value.agent === "zcode") && typeof value.id === "string" && typeof value.name === "string" ); @@ -3381,6 +3422,9 @@ function codexCompatibleClientName(agent: ProfileConfig["agent"]): string { if (agent === "pi") { return "Pi"; } + if (agent === "workbuddy") { + return "Workbuddy"; + } if (agent === "claude-design") { return "Claude Design"; } @@ -3392,6 +3436,8 @@ function defaultCodexConfigFile(agent: ProfileConfig["agent"]): string { ? "~/.zcode/cli/config.json" : agent === "kilo" ? "~/.config/kilo/kilo.jsonc" + : agent === "workbuddy" + ? "~/.workbuddy/config.toml" : agent === "pi" ? "~/.pi/agent" : agent === "claude-design" @@ -3400,11 +3446,11 @@ function defaultCodexConfigFile(agent: ProfileConfig["agent"]): string { } function codexConfigSubdir(agent: ProfileConfig["agent"]): string { - return agent === "zcode" ? "zcode" : "codex"; + return agent === "zcode" ? "zcode" : agent === "workbuddy" ? "workbuddy" : "codex"; } function defaultCodexCliCommand(agent: ProfileConfig["agent"]): string { - return agent === "zcode" ? "zcode" : "codex"; + return agent === "zcode" ? "zcode" : agent === "workbuddy" ? "codebuddy" : "codex"; } function defaultCodexCompatibleHome(agent: ProfileConfig["agent"], configFile: string): string { diff --git a/packages/core/src/web/management-server.ts b/packages/core/src/web/management-server.ts index 5a590d0f..1d51496f 100644 --- a/packages/core/src/web/management-server.ts +++ b/packages/core/src/web/management-server.ts @@ -10,7 +10,7 @@ import { loadOnboardingFinished, markOnboardingFinished } from "@ccr/core/config import { scanBotHandoffBluetoothTargets, scanBotHandoffWifiTargets } from "@ccr/core/agents/bot-gateway/handoff-scan-service"; import { cancelBotGatewayQrLogin, startBotGatewayQrLogin, waitBotGatewayQrLogin } from "@ccr/core/agents/bot-gateway/qr-login-service"; import { syncClaudeAppGatewayConfig, restoreClaudeAppGatewayConfig } from "@ccr/core/agents/claude-app/gateway-service"; -import { findInstalledCodexAppExecutable } from "@ccr/core/agents/codex/app-launch"; +import { findInstalledCodexAppExecutable, findInstalledWorkbuddyAppExecutable } from "@ccr/core/agents/codex/app-launch"; import { findInstalledOpenCodeAppExecutable } from "@ccr/core/agents/opencode/app-launch"; import { loadAppConfig, saveApiKeysConfig, saveAppConfig } from "@ccr/core/config/config"; import { @@ -516,6 +516,7 @@ function logProfileApplyResult(result: ProfileApplyResult): void { function getCliAppInfo(): AppInfo { const chatgptAppPath = findInstalledCodexAppExecutable().executable; const opencodeAppPath = findInstalledOpenCodeAppExecutable().executable; + const workbuddyAppPath = findInstalledWorkbuddyAppExecutable().executable; return { ...(chatgptAppPath ? { chatgptAppPath } : {}), configDbFile: APP_CONFIG_DB_FILE, @@ -528,7 +529,8 @@ function getCliAppInfo(): AppInfo { platform: process.platform, requestLogsDbFile: REQUEST_LOGS_DB_FILE, usageDbFile: USAGE_DB_FILE, - version: packageJson.version + version: packageJson.version, + ...(workbuddyAppPath ? { workbuddyAppPath } : {}) }; } diff --git a/packages/core/test/unit/agents/codex-app-model-catalog.test.mjs b/packages/core/test/unit/agents/codex-app-model-catalog.test.mjs index 0561a192..85b256f2 100644 --- a/packages/core/test/unit/agents/codex-app-model-catalog.test.mjs +++ b/packages/core/test/unit/agents/codex-app-model-catalog.test.mjs @@ -7,6 +7,7 @@ import { codexDesktopAppName, codexSharedChatGptAuthEnvForTest, findInstalledCodexAppExecutable, + findInstalledWorkbuddyAppExecutable, removeLegacyCodexVirtualAuthMarker, writeCodexCompatibleAppModelCatalog } from "@ccr/core/agents/codex/app-launch.ts"; @@ -288,6 +289,114 @@ test("ChatGPT profile appPath overrides process env discovery", () => { } }); +test("WorkBuddy AI app path override discovers the Electron executable", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "ccr-workbuddy-app-")); + try { + let configuredPath; + let expectedExecutable; + if (process.platform === "darwin") { + configuredPath = path.join(root, "WorkBuddy AI.app"); + const macosDir = path.join(configuredPath, "Contents", "MacOS"); + mkdirSync(macosDir, { recursive: true }); + expectedExecutable = path.join(macosDir, "Electron"); + writeFileSync(expectedExecutable, ""); + writeFileSync( + path.join(configuredPath, "Contents", "Info.plist"), + "CFBundleExecutableElectron" + ); + } else { + expectedExecutable = path.join(root, process.platform === "win32" ? "WorkBuddyAI.exe" : "workbuddy-ai"); + configuredPath = expectedExecutable; + writeFileSync(expectedExecutable, ""); + } + + const result = findInstalledWorkbuddyAppExecutable(configuredPath); + assert.equal(result.executable, expectedExecutable); + assert.equal(result.checked[0], configuredPath); + } finally { + rmSync(root, { force: true, recursive: true }); + } +}); + +test("WorkBuddy AI app profile writes the virtual desktop auth session", () => { + const configDir = mkdtempSync(path.join(os.tmpdir(), "ccr-workbuddy-app-auth-")); + try { + const profile = { + agent: "workbuddy", + enabled: true, + id: "workbuddy-main", + model: "Codex API/gpt-5-codex", + name: "WorkBuddy Main", + providerId: "claude-code-router", + scope: "ccr", + surface: "app" + }; + + const workbuddyConfig = { + gateway: { + enabled: true, + host: "0.0.0.0", + mode: "process", + port: 48765 + }, + Providers: [{ + modelMetadata: { + "gpt-5-codex": { + contextWindow: 272_000, + defaultReasoningLevel: "medium", + supportedReasoningLevels: [ + { description: "Medium", effort: "medium" }, + { description: "High", effort: "high" } + ], + supportsReasoningSummaries: true + } + }, + models: ["gpt-5-codex"], + name: "Codex API", + type: "openai_responses" + }] + }; + const result = writeCodexCompatibleAppModelCatalog(configDir, profile, workbuddyConfig); + + assert.equal(path.basename(result.file), "ccr-workbuddy-model-catalog.json"); + assert.ok(result.workbuddyModelsConfig); + assert.equal(path.basename(result.workbuddyModelsConfig.file), "models.json"); + assert.equal(result.workbuddyModelsConfig.model, "Codex API/gpt-5-codex"); + const modelsConfig = JSON.parse(readFileSync(result.workbuddyModelsConfig.file, "utf8")); + assert.deepEqual(modelsConfig.availableModels, ["Codex API/gpt-5-codex"]); + assert.equal(modelsConfig.models.length, 1); + assert.equal(modelsConfig.models[0].id, "Codex API/gpt-5-codex"); + assert.equal(modelsConfig.models[0].vendor, "Codex API"); + assert.equal(modelsConfig.models[0].url, "http://127.0.0.1:48765/v1"); + assert.equal(modelsConfig.models[0].apiKey, "${CCR_PROFILE_API_KEY}"); + assert.equal(modelsConfig.models[0].supportsToolCall, true); + assert.equal(modelsConfig.models[0].supportsReasoning, true); + assert.equal(modelsConfig.models[0].maxInputTokens, 272_000); + assert.deepEqual(modelsConfig.models[0].reasoning.supportedEfforts, ["medium", "high"]); + + assert.ok(result.workbuddyVirtualAuth); + assert.equal(path.basename(result.workbuddyVirtualAuth.authFile), "workbuddy-desktop-ai.info"); + assert.ok(result.workbuddyVirtualAuth.authFile.includes(path.join("CodeBuddyExtension", "Data", "Public", "auth"))); + assert.equal(existsSync(result.workbuddyVirtualAuth.authFile), true); + + const session = JSON.parse(readFileSync(result.workbuddyVirtualAuth.authFile, "utf8")); + assert.equal(session.auth.accessToken, "ccr-local-profile"); + assert.equal(session.auth.domain, "www.workbuddy.ai"); + assert.equal(session.auth.refreshToken, ""); + assert.ok(Date.now() - session.auth.lastRefreshTime < 5_000); + assert.equal(session.account.uid, "ccr-local-profile"); + assert.equal(session.account.nickname, "WorkBuddy Main"); + assert.equal(session.account.type, "personal"); + assert.deepEqual(session.accounts, [session.account]); + assert.deepEqual(session.allAccounts, [session.account]); + + const second = writeCodexCompatibleAppModelCatalog(configDir, profile, workbuddyConfig); + assert.equal(second.workbuddyModelsConfig.changed, false); + } finally { + rmSync(configDir, { force: true, recursive: true }); + } +}); + function withPlatform(platform, callback) { const descriptor = Object.getOwnPropertyDescriptor(process, "platform"); Object.defineProperty(process, "platform", { diff --git a/packages/core/test/unit/profiles/profile-launch-core.test.mjs b/packages/core/test/unit/profiles/profile-launch-core.test.mjs index 4eef4995..a98d69dd 100644 --- a/packages/core/test/unit/profiles/profile-launch-core.test.mjs +++ b/packages/core/test/unit/profiles/profile-launch-core.test.mjs @@ -94,6 +94,17 @@ const kiloProfile = { surface: "cli" }; +const workbuddyProfile = { + agent: "workbuddy", + enabled: true, + id: "workbuddy-main", + model: "provider,model", + name: "Workbuddy Main", + providerId: "claude-code-router", + scope: "ccr", + surface: "app" +}; + const claudeDesignProfile = { agent: "claude-design", enabled: true, @@ -130,6 +141,7 @@ test("profile open surfaces enforce agent capabilities", () => { assert.deepEqual(profileOpenSurfaces(kimiProfile), ["cli"]); assert.deepEqual(profileOpenSurfaces(piProfile), ["cli"]); assert.deepEqual(profileOpenSurfaces(kiloProfile), ["cli"]); + assert.deepEqual(profileOpenSurfaces(workbuddyProfile), ["app"]); assert.deepEqual(profileOpenSurfaces(openCodeProfile), ["cli", "app"]); assert.deepEqual(profileOpenSurfaces(claudeDesignProfile), ["app"]); assert.equal(resolveProfileOpenSurface(codexProfile, "app"), "app"); @@ -138,6 +150,7 @@ test("profile open surfaces enforce agent capabilities", () => { assert.throws(() => resolveProfileOpenSurface(kimiProfile, "app"), /does not support APP/); assert.throws(() => resolveProfileOpenSurface(piProfile, "app"), /does not support APP/); assert.throws(() => resolveProfileOpenSurface(kiloProfile, "app"), /does not support APP/); + assert.throws(() => resolveProfileOpenSurface(workbuddyProfile, "cli"), /does not support CLI/); assert.throws(() => resolveProfileOpenSurface(claudeDesignProfile, "cli"), /does not support CLI/); }); @@ -145,6 +158,7 @@ test("default profile command surface is CLI unless the agent is app-only", () = assert.equal(defaultProfileOpenSurface(claudeProfile), "cli"); assert.equal(defaultProfileOpenSurface(codexProfile), "cli"); assert.equal(defaultProfileOpenSurface({ ...codexProfile, surface: "app" }), "cli"); + assert.equal(defaultProfileOpenSurface(workbuddyProfile), "app"); assert.equal(defaultProfileOpenSurface({ ...codexProfile, agent: "zcode" }), "app"); assert.equal(defaultProfileOpenSurface(claudeDesignProfile), "app"); }); @@ -153,6 +167,7 @@ test("Grok and Kimi CLI start a temporary CCR gateway when none is already runni assert.equal(shouldAutoStartProfileGateway(grokProfile, "cli"), true); assert.equal(shouldAutoStartProfileGateway(kimiProfile, "cli"), true); assert.equal(shouldAutoStartProfileGateway(piProfile, "cli"), true); + assert.equal(shouldAutoStartProfileGateway(workbuddyProfile, "app"), false); assert.equal(shouldAutoStartProfileGateway(kiloProfile, "cli"), false); assert.equal(shouldAutoStartProfileGateway(codexProfile, "cli"), false); assert.equal(shouldAutoStartProfileGateway(claudeProfile, "app"), false); @@ -168,6 +183,7 @@ test("buildProfileLaunchPlan creates CCR-managed launcher paths", () => { const piPlan = buildProfileLaunchPlan(configDir, piProfile, "cli", ["--debug"]); const openCodePlan = buildProfileLaunchPlan(configDir, openCodeProfile, "cli", ["--debug"]); const kiloPlan = buildProfileLaunchPlan(configDir, kiloProfile, "cli", ["--debug"]); + const workbuddyPlan = buildProfileLaunchPlan(configDir, workbuddyProfile, "app"); assert.equal(codexPlan.surface, "app"); assert.deepEqual(codexPlan.args, ["app"]); @@ -218,6 +234,12 @@ test("buildProfileLaunchPlan creates CCR-managed launcher paths", () => { assert.match(kiloPlan.env.KILO_CONFIG, /kilo[\\/]kilo\.jsonc$/); assert.throws(() => buildProfileLaunchPlan(configDir, kiloProfile, "app"), /does not support APP/); + assert.equal(workbuddyPlan.surface, "app"); + assert.deepEqual(workbuddyPlan.args, ["app"]); + assert.equal(path.basename(workbuddyPlan.command), process.platform === "win32" ? "ccr-codex-cli-stdio-workbuddy-main.cmd" : "ccr-codex-cli-stdio-workbuddy-main"); + assert.equal(workbuddyPlan.env.CCR_PROFILE_SURFACE, "app"); + assert.throws(() => buildProfileLaunchPlan(configDir, workbuddyProfile, "cli"), /does not support CLI/); + assert.throws(() => buildProfileLaunchPlan(configDir, claudeProfile, "app"), /Claude App opening/); assert.throws(() => buildProfileLaunchPlan(configDir, claudeDesignProfile, "app"), /Claude Design profiles can only be opened from CCR Desktop/); }); @@ -239,6 +261,10 @@ test("profile config paths honor CCR, custom, and global scopes", () => { resolveCodexConfigFile(configDir, customProfile), path.join(configDir, "profiles", "custom-profile", "custom", "codex", "config.toml") ); + assert.equal( + resolveCodexConfigFile(configDir, workbuddyProfile), + path.join(configDir, "profiles", "workbuddy-main", "workbuddy", "config.toml") + ); assert.equal(resolveCodexConfigFile(configDir, globalCodex), path.join(process.env.HOME, "codex-home", "config.toml")); assert.equal( resolveOpenCodeConfigFile(configDir, openCodeProfile), @@ -253,9 +279,11 @@ test("profile config paths honor CCR, custom, and global scopes", () => { test("profileOpenCommand quotes profile references for shell usage", () => { const cliCommand = profileOpenCommand(claudeProfile, "cli", "ccr", "Claude Main"); const appCommand = profileOpenCommand(codexProfile, "app", "ccr", "Codex Main"); + const workbuddyCommand = profileOpenCommand(workbuddyProfile, undefined, "ccr", "Workbuddy Main"); assert.match(cliCommand, /Claude/); assert.match(cliCommand, /Main/); assert.equal(cliCommand.endsWith(" cli"), false); assert.match(appCommand, / app$/); + assert.match(workbuddyCommand, / app$/); }); diff --git a/packages/electron/src/main/ipc.ts b/packages/electron/src/main/ipc.ts index 1fb184ad..74e5f3ea 100644 --- a/packages/electron/src/main/ipc.ts +++ b/packages/electron/src/main/ipc.ts @@ -9,7 +9,7 @@ import { scanBotHandoffBluetoothTargets, scanBotHandoffWifiTargets } from "@ccr/ import { cancelBotGatewayQrLogin, startBotGatewayQrLogin, waitBotGatewayQrLogin } from "@ccr/core/agents/bot-gateway/qr-login-service"; import { closeBotGatewayQrWindow, openBotGatewayQrWindow } from "./bot-gateway-qr-window-service"; import { syncClaudeAppGatewayConfig } from "@ccr/core/agents/claude-app/gateway-service"; -import { findInstalledCodexAppExecutable } from "@ccr/core/agents/codex/app-launch"; +import { findInstalledCodexAppExecutable, findInstalledWorkbuddyAppExecutable } from "@ccr/core/agents/codex/app-launch"; import { findInstalledOpenCodeAppExecutable } from "@ccr/core/agents/opencode/app-launch"; import { loadAppConfig, saveApiKeysConfig, saveAppConfig, saveAppThemePreference, withClaudeDesignRuntimePluginConfig } from "@ccr/core/config/config"; import { @@ -68,6 +68,7 @@ function applyAppThemePreference(theme: AppConfig["theme"]): void { ipcMain.handle(IPC_CHANNELS.appGetInfo, () => { const chatgptAppPath = findInstalledCodexAppExecutable().executable; const opencodeAppPath = findInstalledOpenCodeAppExecutable().executable; + const workbuddyAppPath = findInstalledWorkbuddyAppExecutable().executable; return { ...(chatgptAppPath ? { chatgptAppPath } : {}), configDbFile: APP_CONFIG_DB_FILE, @@ -80,7 +81,8 @@ ipcMain.handle(IPC_CHANNELS.appGetInfo, () => { platform: process.platform, requestLogsDbFile: REQUEST_LOGS_DB_FILE, usageDbFile: USAGE_DB_FILE, - version: app.getVersion() + version: app.getVersion(), + ...(workbuddyAppPath ? { workbuddyAppPath } : {}) } satisfies AppInfo; }); diff --git a/packages/ui/src/assets/agent-logos/workbuddy.png b/packages/ui/src/assets/agent-logos/workbuddy.png new file mode 100644 index 0000000000000000000000000000000000000000..b108129035cfdc7e01019f1e2af9d5c55c61a072 GIT binary patch literal 493711 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 |KKc&Kmoler0O*p$!CC^cfnYwpb zCv05`+&4BV(|cTzo%SWNZy(xgQ5HX4M!*V`fP1AP3~HHN-4epl)Rz1x&WbTi`u*eb z(-QS--5*N36~f8^Yo9KI$%ER4IGRu>zqG{k^%&XUb1jIYN3Y1mSVW)bGH=8dK8I3m zE7G$T?7xa$E$dDqy^>(lnqqARx4(JVvyiw{@iRb7rD6I*?1`f6X7OrorsuWJt`6xpq` zkqMcrXORupMW?v{MNWy-7-`|pEDij2N@}(9KXfiA3#CxX3b*McR&d?%UfiI_`1m0 zlfE>?z#pu-e`->(xzy+xqYpT_@vmMT#VXP4xw0AVqce3_fpJiBZ zN@8u%jT*r)`ux~tgpmx(_M$%J{5dQ=59kVSP8koPTLQa+s`G6qiiY@DouEl_7WGE; zbFv{-3aoMDjlP_O4E|g*lwN}0v#MrwPCQ6GqrKF@lj=-R>MwhDOf+RONbUk=-yL^h z`ipp9&kKFIB4OM@=B97;u^K_*G_|_9o#msyZlV!TA_;X@PyZ=pwx?L*jkkN&e_mno zsfO1gzoF$&cO!O86xtwUOX@=Ye`VWlygi&FawIJ+gvpGH44Gn{ga@DtgWR~6|KjdT zllA*3P{=s-^x46OvYPS5(a{jUK&O>0aZ&Ll^y8HgPygQ=eYKe?`Da!>SqE6kL!z2Wx4ZXYp>Ayl+t$!Ke1^={qS?)<{NDVRuWauvzutE=J1pzIAx|oM~-YD z){s1uwMtN09nFE%vN8Li2B8VKwseano{~7!_oZA+9RD1*U*i6I>-lx3DquoYqn80- z#=WO$p^dC8ecU~z$(_x?kW~<3?NZ-x7NFtum}MBc?+39s$a9;y8ZILyhN9Hb zi1V3Ko^1Vap4-1>_y(i0OT#);2W5YdPUt4XFdv4&CPcEN=p__$y7EO8+g*h+eKj7u>1#7bRkgO($M0c1|=p0Chd6o^B z-um9cfc#VQSBFHFcbL+w1#j`X+Wo6P3<;jMn5h%nDaRWhqN{tn%&-Zw0vFca!jpCo zBMG&VVeEBI&*yh=hK`l#XJTG_P;dQdjRjhwl0x_8=M^wO;b1yyE=~~f+%CGqoe0s* z-Jq|HV8*tlCZ<7E{(F|YJDit34_(ZtUA}1aEwY5`L8t<{W-oMS)9}Em!fSWh+p?TB zgDI=74jpfL)wgN8L@cdI#RXE8`44{bsA!ceQnlZR2ztNW{Y53V+&oQ(j2%5RgnPaJ zR+&6U*~BJB3Dj5|nl<9K$xP3`Ix|i-H}dPNyPuKR z9jiR0#W%L7gJ-4=c)-8&{7MY^Su>gfIQ{~}l@O#nnDX|_!!;h|$&Z|W2&dkI|f)$o4xp^syRpx8e4B{7wvi%$>`m7 z^}utN3s?qiz>bL)qg_9HPbGG9jJGAfN|Nh|<&=sd&C~~c-ob6S>&fqk2$zOT2`&$c!08Gaffh4}a*qll-9 z-Bg)gG*?5eVPCJ<>7_Ep7f{h0EnaGBLceO0)_wM3h1Y0c@95?NnDUC>(FP$EK3VMR z&k@TU#DQP|I?jwRzFBD)=^PCb`uyz!;^6PV%*`x8BG`|hVsSS{ey2m-J@=^?E@crt zGY~nT{S!H*lJ{D)Hp8Xk`>GPygH%&OpCxmhFUHZjqbv^X1UkfOa^8>bo0~#I*)G*# z8$e>cjntDUZ|RY|ggZyn3nNoVUx$T_@8|F><*G~nx`t*LNpE!JyWGWm@R;&ydN|jt zv<2fl63uXGvhAi=ReWH3ITd*4O(pG$AE%-g;`C#1rY~H<%{iG-n^&30uCKloDk^KL zRNxo!rOvrsr$S0l*Qc?Qo+hgM3+&L7gB~h{xP4=-vW%-NlM7(u(L87s%WwW^Z24fQ zdui!Fc6xT#=H1W*VR4zYQstV7!_RlX(LF!r^bO!{fw9(&#Su9YUOp!q@Nzi6^d4B2 zmo#!NLRUZ9DSrEsDBejr%3DIlL@3SZ3k7l|s3u0qDQ?c^prxY{=1~X@0p-2!Aj!rE zI;OBe6r|>%@NS`-6#?A@_=mt-Qvs=PfXRBp%;=m~eBZ`YdiFF+hoJh54Dm1hzU-CW zng#5TrvsoB-txiPE9*vVbEUmzI;V(14GfTls{TA$4^T z16ix9#Bye(%3i~xImc5{o>Dh!gN6$dQxHG6ETz65ESHV=q5M{YcFoLLkC^(Q{a&oaDJk(^^sB3LkZuQA?SCAm;;8yXf|1=!XFE}8D!NWZ?6 z`AWUJp(FR|BqhB9oTQ)Pkgp{}CG>R2%ND#mF$F&CKBsj5*_SobtU+UiUdUVCJ;VdF z-tFnfF@r5rmz>y0aX#5CC%j-Y6JMYX3)Q)3S`jOK#$EbZ3)9`hJUYv2o*yP;ymjr& zlsV%~<7|{zZorG_hjICS8s|G1qPE|NvgV;SIG1Y-q4xuS#Z=gn=w9)_cg=4_OR{WY z=5%lC~8mzxva;Po{ljtd4f{hXp-d)?sg@X8R4bOdb8@+PuPw`iO^DGb(Rd z<2r^?8s=)=SIoQ}m{Zlg_Mk4pxB5C!^7uf_`p=#TaaI43`}zSEH>koL)qX~dLKfm`=_T!W;U1y` zC+}g)F>DRPRliow+mmFJ>1cy#Oy3!X6|F5_8eOt*-ugG5puIFWW8k!!md&+e}cuo8MMXIp5pVo z)Le}QJ(k@v8~1(k(y4vvNhGYip1(h2Y9eT51hk$|C@K#8n>Q%v+_3=v$o$6NDba=3 z!w2n#iAhoAFMH+|vc0r@$EklugBzbk>(fh$DD2%4qP4WJ?)oDQ`0peIYL+1ZY^8&- z;D%BZ(+Bw*=X&g}+TGBuFbjUFWG!M#DwMlzR$ZuX`xDKWrFLAiT73|yYH}6-i;1l^ zw|2*(CNixw&u79wlU!^x5Yr?y5Q=WY@8^iX+#<502~FA3fu1v{)pE<`Z?rw(mSi=B z<)iIB8%d_)KKj&LYojPJO|vysqbTnJ)>qd`<)1m$cF*$tty6yfEl%s!9Q>QN#`#Qm z-d6$PkT^js)}1Vi{DcjE{$feTsuuQw(W@$?CjM zNFc3wlkO>)8dS`ZxUQLMQKP(VUu+5+Z#hPx1M#kticZs}8q^=+(|{l6C^V&OhBg6egsRbfztnG|?g4gXS?zehFGN*)!Z=$Iq)p_!Qj#$Gc(m_hNlCLDU+=wwNm ze;Q10=5Lf0uNiyixeU?{Q6Y=lmELflzrU|F?fkbBAex_|SaYPx8y!;|&np*zoEy9@ z*jER&`{=|{jr_yD{5gnOP?H0wEx=x~kOGJDu2pGvCmCy05d-PW)lNguY8Ga}#UX{? zU*^V3SS0)2b@TgHOa0E)Vj8Q^yK1{Iv#}?A^t0Ew1$=qM`n5)!+mySqPJ!Wf23_b8 z;vpxYPMQZs(rH7H;GmVRLEiBPLkVqX)SKX9IT~P?=%wm&N82p7G4g!(hl=HZnwr{`^srpK) z8$ZT*EyIQpNrpn%vjyLAC%9Gc76TSdRO(K8YP+q=sS!dq4o^6sWwLDnZ|hJe_NdIf zlUmT7hD^)lFVFe|$M2Dye0|4!OUwcNRduV{_4~rBgLlN6K4qXvCXPS5Q2arVpv=PJ z3b&ftzWH~aE|<#=ym`Nm3u(`G`o7U8nzMi<1vEBngr20N?m@<57btw!gmtKt>J0IL zA$+cyYR?sesSGbg6ec{5lHTEPQIIO-*ZEb7O26LKQq!i`rixaW<`N6|!FIneZqgN? zaSAg^aE}EUZ%(T>U#}Lz>oqn*N>S40hK%5@x&3dxy%Hl%jp#+J+a95=X2a|y#m;oW^R%94d^-tluAJ^lYWOmLW&zBYdB%*2MUNgK#}qwRoM{2RhEr78Q6Ov| zipc2XHHaC{ybzu1%bXXUaVrpCatHk^L#d(nmX}y>u?f7$o)_-tkTbEFXMmb!I%mWK zNbbqJInE0z>;Sv7!Es-EZ@`@@V859Q zVHFioyV{$|!!2-oI2|$bL>R3+t`#;zD3!^YY|RBwBEf~Z^h(v*JiZDxfHP0mA;#89 zQAqvTOSPtjJ3ZEYJLA}7hMY`L{{@>J2EL)&;wP?|dsKuxLweN@bV7Ey zp-!+;lhn5uNc1`ocf-6&$o<8B8C^{W#OmHkitU%fC}dJ2eMcn%y6*Von591&vr5+& zuS0)JZ!k!#KR$ed+oLOP-_d09LK&WG3$Pi3yN)s2bKcH7$ewr})fD-Cbhju=a6bLI z5^$Gns73-Ri8Z=aj5lY&BE$#Y*@2o3gWpcvpl9+hYvU+yEZ1qmK5ZM54y4zxaZ#UYZdruMOTX|~ek}e21w}&bB+RY%W<6QN! zGq>;9-f9;iD4R%KWzHGK@zO>`<@&6@w#re^`^E(CT+Aqd1oVqKcOET2uG5=B`t8Yh zK(z_!@HcnRi&luh0gb6SS75fI5g7$bWT)kFvZ0q`Zm+tGYekpgIxX7toHgjt=na`@ z&~$c1GWrn@2P`h_l8mcpx6EA^D z_5rM`DfDTBXIz04!wMa{>8F_3QN(ISoZW<{pVL;zt&pqZL{Q*J3PaLVsk+&0+yZ>5 z@O^inu)&`w;qM16^;@4n^sV~vNGdK;7secaHCo!!h-6H5x4)>)y=JiOtZQg}QuG#W zcRZHwNMfT%>;-p0&`mTrnh0^yG7@icz$M-Qu`V~*(ZaCwCUv0=dW;m zXMhn&5xK}p6*fZZJH%|kxn~0K@wK&x%4pZi3tuO3Gx0*+(Q`lA( zXes0B3}kiQn6K6LM54DG8j8~Z&8ntMl2h5SmhPt}W)WHB##z0+_A9317=>LUS<}WX zA&kO$myoE^wBX_WmFfF0%S72m8YUlog^e;4WV{5X3=0dX@k%{8uevig1P%5r+S)qh z7Xzx||C`5s?{*qom^AuaZe@aOD4xZ*oC0j%G1j7O_k@tbbP(1m@Br=og=~Cn3}E1V zh=S`oW)yTQInwoMP~)KsrpBH8As_L#fu_BxnE-NjBO(U6uJU*LtlssE=BxO-xaOqvgAMv0 zybF$2a4jMI&{J)00Rcjjq@>3CMnoR!(J%mS607MpW%A6vpu9=iu7`}p|gE@+y;O^ zGL;0yk#mQv{tSJ7?p<9DcwCyN80CEVPLCgQJ}Hf$L!SY8GPZS(4zdMfgD$3Hl zmDsF*XXjhd&KEDw*6xR%e5Hr4L^2;=+G&xhajgZwy6*7E0~DRi0}-Ep2z;iLsTcPB z#vVehyIz)67)O2zf_2Yj+*|Fy-I)CFZK>6m^6CPe$8iC5)$5wAeQsR3-yJXx6Y&~eb<5R2`JrO z&JlQ?DKI5gG-+i4=KU2K^;mJX^hj4|E2NKdM((Gq>u<=bW5Avk9zq}GEVAJ#c5asR zvFHA4_3){s=+h~T2K9mdEm$agS$9gfryir^g2O-SP(waX4SE%@n765GxAlPf^6E_8 zy}o4GHLa8RVVd7*t{Hq+w3hQTmd~s=k?sjyF=jWvyofkLbia6V$9R8tWpbS9Pw(jI zSsOFf0SmAXbld{^_&d<;x3@?=bziFKH-W2BO~HKe*ru#VLO73+yP@JHaEn_pAnHL( zR5)IBVFCEd>x3aN0gsP1qc;3L zMY4w4_zcqO>x2^MEcx!R(y}9*mZG!g6I4Ex*G;@R%sxY6X7}5eGBd@Ms$GdfPQ?VW zBe2;2B=N8R0$?jKW`0;jl=tV0s6h|Wn!->emqp(W(d@wICVqoip2LPLk2l?rvtxGS z3#oR>$Ru#Pa^CAR-(yNOffr^(osxO&WWo{zty5y#Td4^6A9AFaiYV8AkwaBMO#Ei@ zH4$*MTt6A!P<#={j(d|mR2YNE;PIgVD&C+>c!LYY#ZMq&6gs)-cyH?zIN}u@7Z=yUDu?^c8Ytpudm?r)^Yx<9QQ}+{b$?#}HAth5NVy!R)DF*Bsx z>aPR6M~-0lrD_+wtB)|Hn4fdPW1gi1mYYH!@+BPpR@0y{=alAHdO<G+u zq)GvIxf&x@Ja|&V8}QPjD>GG8(r?wPivcynl&)-Vx(!FNZywKK>l4U`FrYi=bq}6B zFUU?QMKgzXQ|+|n5Han_vNNNb1!KyH|3UFbw~(o1b8hdF&wl1lwdM|GMs}cT*V@Us zVZI~hK}swhKT>6M=VK$UK^k=bD?!H=uK0-x=t1554J= zV&^Cl)kH9APMUe`4>+Ret6_FxR}H=wxIP(FUe>?2$&NyNL;Ph~)_Hu`joWjVElwwJ zsNWugAegGYv zwr;IRhLoAUFG1&K4;H|ygBQk^l^DHDS00a3(Cs+gr6qp@>Do);rDw`!hFNYAPPaCt z#g_(Q(zaV__k*>A6n?Px zA&%R&wMGHdm?p|E3AAsNZ@l=azmG864w_HNNbmQY6Nm0nC4nW(0bX$%F(plDp(^fA%&f2;U11Vt$F<{Uin0!9{?h z1}*=xEAwr1`$?|9QRbaXxVYtiK9W&7Us-rVaB1}ct)Q;ZN&?fzw+^LEsJsJB{vB+P zzHnb=id33zL#tP#N6eFtg)lCs6P}X{mo~^^Y&8j*KHj~L{fFc6iz+_wIJltF_DbnVr2>u;1nvwiaB11O3N;1;W1Ls1ExJWyb{RZyVVn zi4L6PFMfnvyXkmJ&};R9G17d}w1PAm!;iawFT2p>u@rF`ynTBC4)XGx)}NL=4CrMA zd2oBgb7k6XIyU;H{@bM+@nc7bq^%VM6Wvn|eGu%OqbAfhIL%f5J|5LBH}uJdSt2G; z0-b8DdBny#ZN7!-3@PTxm!QsewHFL@{YInGU*OGn?Vp5X{&pzl7OqBKHJwjIr%S-9x5>YSo&`le5IwoU#XR`Y|G z9v9kXax2Mc*#fyG1%%VNXKlWhKS5QDc?D!cQN@93YYKkIC%4NkU#mfIyj^=8{vhC7 zDRVv>Sjx;m@3v2)dB*R*oL=EEPq(Z)ZqtcZ;p<+d`lqDFF;k7=b{?0C^-CyQ_Y$bA zYr&my&DP$vneM`-LO=PiZ0j+EH8oL35vyKK0g}IHmT{@v4;}pwq%f)8bg{5DNoOo@ z%(`j^1V!yHb3V*SXqnfYWu@eMGwhEXm>5^j$g|hg8-Tw48BZxeuf?)D95!uuaflf~ z!K+ogG^RP7*cck0eXiJbefEQO(UQ4brLu}_V|Sx->KW+L2G4;B&6X5$Zue2&E7A8N zL!!&MkkUUSDvID#Dd-=)YfylZ`vnifM6&U^R2|>OQoF{v!QtxjAi+#8ZGU{P_^xq5 zxY2?Bs-y+4_Z<&6M0vLBz!74h?@UuVo$i#o>b@h`2H!aNU1d|wJcncMu0`&5#o_wJ zKjB8Hv@O-;ur&DYwk=0RKU(qSMdCgmXa}vblij|;c^%#$u(HeK5r7}e;cfwu(@Bln zYj&KGSfa=?gT1KMbVkS0I~t=cgN9gtx@#5HBs4)bIOrGZGTRZ;CMefo?4?^cD~dWN z((@cni4|@7`R)g(l@ix}hmCD`HL=5Y+2c5{V=O;01`#ghx!F^O8XQR5!`vdSuB*=;40(GEws5(&9Tmt30|-)!^1u1*e8_ zHrN2)RT|8t7`LTe+~o?vYFBtM)gRl3>7^Ig0pH}_Im2y zDJ|7v6m0VE_M>aV_-*D7@^+v#OgqdzTpIeM=JA0Z`v|W;4Ncx%AkKJe+97o~P(tGs zguTWf89)s)lznJ>(i+2RS*s5vOryCQY)1-AriQ<%v_gxK><(%VqR(d&vt(20k7PDQ@&PyQG8p~G zhn=}S0c^FH@((D;_?GRrdC<+qQ)iu@5Q=Euz`>IrvGLWZGe@B;cSz;g{?WTO&7^?~ zHqEWoGi0sc&0(C!nOk}-YvO7E26Xs}~Qd;YnOOFBxpA5Cq6 zyXM6#PVcysq9Cc3+oX2#zL##L36F&Yxp~^Q)<%k%6-IQAG>esEW4x71`x~21=3mw6?|19J|^!9d^hJ{0{i-EAU z=Y$%=qkx^bTxm$jz=OKYwuE z;5n_vis0+4U_9d~bKa3g!2%6`>-1XW7%N(YPOl{Q>t;jg?;((sFRDR@L-1j5Y?Y?*xt!Q%&HnM%J{MR)? z8QIdB$Jjr%oGiEH425ZHKbZIX=KbjoveS~U}CheZZum$QlP-16SM z%agx$BxWCX*-Di*jl2yOQwg>1fBvWMZsmjY&gHMaHQat0aDBd^xGNzHDNVzhO4kN- zln|qdW-iJ%S&MHG6Zpx2n1n!?7t+p;vb+qF2K%3j%=QoX{P%qedIA2J;5@%UYn(UZj#o+Rs> zz!r~fqgk^K{sB?6$DOxY0_p802AmcWObh;9^91DZ82~j*PzJ{*yP)N8lAX$)h+bPC^u%=OMVt8#6-$c#lbJm)7?bLoJNjN?qSeJTs=ZyvE>9<*w#1$jgqwW6_`GIu!H7 zMOUBG>3@9;PRdOU=c5FMzV*>uQ5}Rfnb((UEfQ(*#(Q(__&DT)S zqJ5-$rCL&)`tX!{Dt>V*VsEN1i^(Q#5domhxSEZaX7%&U1$j*-YVS3s+X(l8!ljFm z`S5?!CnnTuZp8fY9STn*i_4WQydQk-Zp*(3Kcv^LCh>mOl4eq*a2o@;AI-{5 zJiH-5l*E(nAyH;=Bi7)D)gDyM$d#|`K+ig`GOT@DmtSUUJXhga(T?%yH_*n<`d?6G z^NM?Wiza9|vv;p38MKLeLNGg_Mo9dTTjS@*Io!IX$m*cj98U7weWrNF>t1Ppy!G2H zx&4bj_M@^9;w?Lz)g2`uDljM%B>r}cnCeHmOuOqMyR#-o*GjogEb@6%9s7BrtbXZV z?B5`IIn~ZW4LHfAeKA!9s2A1;pN(cHg7FxhkIC+qt1Mzz;KW?~Kegqg9L;HBz#pvm zaCc;e8=y?z>zNxIawRx)MWnf08=s%=KJ#oln0hCx<+vcg=*mT%Rg;y`4}G!N@6a=m zyDKNP#(t)uM-l=D+)fL{jxwf3KdKcF+;Dp1ayifa`Z+r16N?RB-M!|*WhlS5zg~6h zS({?xWTtFct@3DzqnmR3fhNNy3HK)ma*lKR=)~`5%o}4ZZ!&smV{R%X;psfdx%0-W zl}}@KCM(H}E11jG`2o>&0XlbAD%0SGs1bg~Eyp}>bzo4osp?s~h_bnAoF#^&WSo=2 zYxl23Ji@{VjdzqECpyM*|1$^ut+a`;rX3NS&;DDwlNz;H#6^?kn_p0r7fHL6zTICo zRG$i>QKAJc2|5%x=QbR~cG>%E`Ys(mUiJ}SE0b(`bTGO31eq!hEN5&$x#1llsz5A9 zbpM+I?{ZfsIRUmGNof@yyh4bS%((<>k3KCn{WuJ)zUnPwQzB0ZHZ-|*eEDQxXXpFu z-{u4jm%o)_F(aMlZXkaqv6g8{l+U9IQqo0~+9G+!G0mb3zcN$qhkq5_#^l)8CeHk- zEbF-8pU0sI7O%kUJ3gQLjw(H9W|!bVwLf;^dfmB#T|V2YtwBF*0s_ax`x}0_wU0^! zC3N3@e_OU=uqRwRz&XeLBCpC!j{2?8`B2b@_Sx<{;(N~Mq5Q9~E5%UkpH-e~pKQ{OJ7d_csI5zcW ztYy1P(sUY+F7;94o>|vAyIANJs}0)3mV448M6s`OkuY&TAo2duK+tJzHn|`qI#lg-J9X;@lDi zouXD0Nb{`9#ql?TL`A9gVIaGy z;pX*a@$W24WGeWH$=XrQJhW_XPRM=3yHm&HL&MeDntXKU>0eL3OOT(%0_&=Uqss>q zmq=rezL*#UySM%foM_803BxMbcR?-*!)7r%hZwci`0og3Mj#nb^ZJwkdLeE9SS>+RmwgYIKJskz&ogx4` zjtlqK%NOIbxkk%r$X~_%7@w{ObJ9Oc7t>xSg(X;P^iR-x@lIJPSTrjY9<_;H^t9!~ zK58?MwC?peyCiO?0Smcx3l_raaN`y>k=q)8do-K2;IeX8&38h=&6wn>GYGWQ<5r$ z>voJ^lk}bzGrCj(j}GqHKXmG35t7-i~8{pY_dSh?=TdSD#+V2IB5JXEQJxP&d_$`~) zK2o%NueF_noRdQGYDA?N`t`CaKY79>NmH(D^SID^*91>ymvrIcbwV92qz`mASi=KVMsy5(oPVqIxiO^8vvF zMkGBCWLak(OnPVRDGdKCbb!>qWYd8y9Tp3Z<*#~LEcSX&3G`fyYT?xFLgt;`K%Y3vXlT&rUZyB zKCVdyw~kinWw_k5k|lY?De1T5UfEjxtMTq^vdiBne)RttjH4=I@VfYhWVLzYjoPih zRT#bI+8w4idIwF`76)zDhU`1YlU%r5nFZ`mkx$4VUiO;VO$7ybH85nn6MlwAiEGpA zJSZ-=9%I1Q#)kq+9T$1NUv+xi_)U*_&Y_~$Z)58G%*J4_|i=(0M7Ez4!9>=K39pC)joXq-1 zygf7pM~;An34~)hJ|Fqv9Nvuae%>YPp^=^KR^UQF^mopj2 zQ)cTcKCAKWU-Z-8Q?DklbZ%;S3x`JS9kxWxs(~?k&*hTfwEP%DMtSU^Gg85^$r10w z9ainKPi%cC!oxgx*uIgEWVMc^wE_Iy+uJ%B_0wm-!nb@(#|2(PCSea+<7q9bcr^vw z81!*wd2@4esJqqEGY2{QTeJ!#E3;dWJkj=4-@xXS*Vh}4fJ%B|^4;;E|6U^Vh)=sf z4pa8!{clo+q}Jw&-c~8Z60}rE5GWpY;i=js0d^8UkQ1r7Xp^z3&_%X$kLD>IGLeE4 zLt$Zt4L$GFq)6{Wk$(*oJq<$#jdyEIEw%-I-jR29-{kEiyLSM^v*D+fQEVC-a)mxZv4$*%=)rm2=7|vllZnC&oB=f z`JS!@EO*b!Og*BOqdA`Qo>^Vxt&5l7NgNI+5f`4$%+Ixjav~+ zeS5lY6iz>2W`3CvwrGtjZ@TjsWeMHa7j&dE)05SaYmrJ@PD72WZE9M+73n5_%aA~n zg+|N>l4Ew7-6)r!Or|yZ+S#VcP!u7PG6avloJv(lLb;ooE*{MqOMstWu_u@=n&tR( z@+xJ>V3&dR=&`49#i=1vit3#_Ab3(4v6y}TE4=gnJG^6<(au0d%kom3!CrX)=c^@v z$7v%Ty$1+Lqe^-UD0po;ZZal)ZYs|tTcECPOd5T>F?*bF@ROGPFW(p-Bxf^bDQFzl zf8Pgd&$%;tjmOONB*gOgUSjBvR6n)ySS>wZsW@Qm615QfAwFpJvn+c1CI(pL`T7#^ zLde3KJY87kKaK${n4&pFuQVH!3pw**LA#=sk&upwdgpgdR$r|TzuIwWm=+bbG(Z^cWE)Gz`6Im7@gJ8)BQt}5@m%kgj<6DF5>uh zEtu3-Gt44TEn~bjw<9&>lRh9w2dBHfOqxKIuCkFNY?=_dc+`uP4MD+#=!gZg=m^+T% zf?wp#@4ajnMSvi$=E*c_4H&JP^9WE4lc8Oc7fU+$rwB{Tp=21G@Vj~aU8GD&GPw9& zvqGlYssGM!ecgvsl#}QEg!~6Jr$l_<@rN`77cn*+>d#A z{W#*6N?~B98(z|SUBXi;fg_cWUf&s*M*inSNvMDVDiJO8p{=NE zfCk3w&ApG8cm9{h%!23Za?EL)HN!2ro$1Pum7%r_0&dzqcIcoNw?jm2w8-UmYOs6O za_~1^JuZkcT7)~GC}rU3yXtz}4By3*<#*OgYF4XV8}={BtaNiZ^W$qJ6QW7Y(}*b; z|2O>K*Iz^@$inU|W{N`_S@+7aTdBsis6(QDNX~%Uv*I)}^D{R!-0>^AMJ_evXY|r= zmKvUV!zu4Gc*ET;W)viM>MWOcT5SBkKVA!a+H&R@g2u)*teA*F=?&t_dmI4T1@%7B zlfK**glQ(4PtVeuRxkiQ^3Blp_P3UWVkO4yU-AK65IGSd!{`-m2Gqz%&F5XvJ$X7) zK%MVbPHIbI(ae$Sx0)}B5We8PyXCrOkys^CCKDDi)p$o*VXAMQz2>fytCV>vslyrr zJX!pl+%Qx!_=Pk24$2ytOB9H;-y>e~^mU@nppKFQ(*K|mvDs(xS~lcB6>!|{@}BZE zhdQsl67r(OTGgq}kTBl^G&Xb^=L5xd3wnnvUwqx4j$41{h^dR#p(6f}z&(a$2$Oyc7$iY-W9a#cba3TE1A!!J37`8~(t>lW&2s-t&xkMFd;(#UBZR zWr=7cJ3g4!@Uyn-m%5MaTUH_?`ZN}-NnXd_NNUlzG1ZkRU^3F8vuz<8{Q`>r$3Kt^ z(=5v>l&7eDhNr8|v>R)|iQCV1C*eZK<|JAOA8@P%I6Clr7ALX%Rri4P)90HLzRC@3 zgs6tU^WW(YCy~+cTY?UuC*2E^$cH~|{RDnKnPaHYZBq_dbvp;1?Ggo7AOC6~G2B$j zV;!(O%}sbKAn7Y?tk>7l>btdiB!L6l4116LwRk+$9C2CU0w97QO(q;t6buI(fzlf5thL(e-8)T`Q6|pgnVMcJebyudGjc*72#R=hWJ| zBKKEYvSH_>4z%Q)l~2ZH8-_?uW|px)EZpZ!xNdou0wLEmb{3r1e^#yCi6gMNB;RvnbC7Co4ybI!99TfVF1mzTI#k4&5hIu$p}-(t zXUeAGp7WwMp}C&J*V6XW_*h`4kmnjD4#4LX%kzkRy(q24iB!@lWs?^bznA`2uAEQT z8eERctcl0@G+qdToS4CWUfbQ+$VNE(Q^HA`iU2-@8Pn|T4{@9Yh;RQX-)ErQcw?($ z6)^7P7dhfhWmbKu+XzA|ugN`m4A=TE8Nv^M)YSxwPb46t)wKtQwQWv{8Z0L_MB6k?5kU@2YUXS)BOL>oMSPet=7BH zAQop{pclt&=8RxFI=1q}(R^No&#>IPAkYDcIYwUdlSpKO)>fWOeOaq<#`DK`0{Gm< zm1YUDcoqsl>FSN+=k#{uCxL`t@y_j1E#tld)YQVSt;GiC;d{RoGxdUPznez){8AsT z?BbhAfH@7{?k!D?LHJ~ihV?rQ|GVF_-0`>`4P@_kPDu;_gW3w^X-xeo(e)=!>r6P( zA6Sru3z?5mw>-8_-S=;>2GiF+j9o)^wF|s?XlQ#DSRv-={wu#L< zd4Q1|?Ok(E2F0p=r!&hluBA`lNy7`roRSQlkEOBaK{%~Vh`Rz}BvFgn11r(E>E#Yp z2%~*oA4tZD11&E@{ig}0 z+3nl_5Q4rvI31UmHukQONs96Jm>E<1S&dqkt`nPLz0~t$2E!+V|3F~uOjY+1RT5;T z=91}Dn26RcLAtRecI>~{G1p<;puFHxS{-(bZU~*UgAQVw+)XoU1OP8NUAn>U%CT_Ti!Po3e9Utg zyR|G~kQ1bO2=RC?6Yz2+zb=07qW_|YBaNuMb9jbT4KE3kF2Uu&x9-s(tKRUE^^m5j z>ZWwK40p?Pmq(Os+C7MYG*FnTPj;Cg&bix;fpCedr;@?4_s^9aF2CC+clY6!g5y`*m8d?vTT-EdrKM%T zqq6cB#}CFE-&Z&`eo%nS4`l-f8%aZ(puN@FE=FfMyrdd8(eUxXBm5jyK+XY=C<_!+>h`WX)_}x=lgeQ)r^#*5)zW`zIo&e zAfka%Di8rcF-an-*e}h|zu4#RMRI!Hr3VKc)K^^;Cv|OXGSK+6uvA{1T_PDd!(jR`F-_iObtqR(f<{yL%Oyo&yOE+((|M9v`Y?vzZ zY2f^V9n;)UgmHj+Sot6)S)Lotzo`$prnPH{H*ZQwUkH!1-~buA_G-UZ_vPPZzK9X1 zU!WcHY1@BA$;Y#8fUDHM3-%RlP&0T-}jyJW+>U z#ULooe+o+d+-@B`szbCr5msEI%k2)J^0pohjqJXJxdVEMVx4<6EkWLFoEMA`uZ}Z% z37GTWCcZ5U$_fQY$B)zt%60XLH+CK|b%egyB*^qA_sCJt?wnYGaMD$ErY_WA8A$x> z*((O$=670gl=t!W0y>txI9*I5WiYHQtJu4!$^U67|EkD7VtqxapG@5jKWNW=ow&FD z*TIZ&q;XH$R9uy7n<-H7=1nGscT>y(66c8d4}=6HtxX_%P||b@kKC%_%rj> zIe-@#bmWLj;^`lgLq*WBj!;!*+Z`ag-L;s|A1(w`@2a$NrWe3ROZN+W8*3TjTo9=qVABrVj(rqbGCv|HL4Dmiy^`6jfnqSW`w@%bd zlA=?FyQcsC-NkV+&5Y`{HqxL-@)TO`Fc9VxzCi~JJP1jt%>Pdc=<=Ewkpza7X?Iw=>GgJDKYj@}iR+~b0#Rqlc&QTx2MEg{B<;%f>dgl(Vr^^$OpxLk3k z`O0V;@K;yBL+7^GepDhq4@S6G-pAtIi(fhimJK7W=?#Tc+B?&;{qGPbfZCYAr7cT& zgJ1H=hsr-@l9t}~qU?-BL~yhF#hqw31;FY!=lmke6Z)58)H-^bbmI(rJ~VC%y>2%8 zUa_-M9j$D#M3*~R5w}tOwpu%QQ~tgf`m1GD@BT)Xa|Xivg{oz>h)B?mgjrGi{5abP z61dH-5E1F{XbTsBe9(2xbXCIr*KI!qcQN&zHCg?kbDv6r7)S7{lZxOvPJe;`q=F$$%?=Y+l`d@l_^rMLNBpD!{Ie{c>aa_k0K-*+Ejt%eq7)yQ-B=VHu-PiK!5#un2*}?4 zAWu!<`6~XbBpPjEFKf+*tjVj~n16f{(SK|8vt>bHOYBiY__sI{)I!mT1MpXo^33bT z^)*Xa7u2notrF>9F*jALTg&e|ZEYx0_8!J(ZkH}j zrwpOEvXZSf$nf}+$4X|ikUiG&hsn=94TA9D8m`lDIuZm|>B3N}`LX{Yp)_ggAqbBY5!K=G(OdN0~TG(4sWt zOHsdszUW6*7``^A*dvvK={X0uiH4N{Sgrpuj6Q-}uZ zxmVx(?=%q!`PF#6ZqEnErf6M!q#Fj<*w_^z2uzJ6`dPg6eVEIJ?MxpxIZ$7gVx7AB z)jv2w1>lCMhzmHtq)L9GN@XRkB@WHPuGzk|`G~CYo|_L0u@GJw4kX$4x+?nV>Fp>H z+}l1toj!w~R^N3?UQi@CqtZFOX4F@5*f-%KkP1~TjP2vz_@L+ZU3899ZE`gb+SzAw z%K@iV^3`$Yvu5z2OC^s&5@XK}ipJ|7?{+;yI_xdUb1Yi0v8_Z^|DBtG)4dol)!=on znK|s75BqP<+R8QUUB`Qh@~d{kUc$IJ7ojwrmNs>V@9mBm5zCJ=Um22-RSteow?7nig)+2WLLe}L~qC3Wc%jQbv^K%fXr86r-bFM z2KT?j0LL%17M}pD^M{1C6B$5N?3!GR>f&$lq=5@Zif?`C3lB=X($Hol>(diCS+qt7 zi=rO5F$jBU#WFZ;Y9F}qQP&DiI)W%Hkp7iVaS9;PllMW#cQr{Fqn@L9;a{ONfqlLX ziBAMJI4!uw>L>4aYoJ=rzx$-(2K_ARF59v$4Go*566)^tSW|)i_>RZZyCnl{>3InF#GB=GH5@Ud0PEb|i|O1s-*#27OF5{(!?EWXF>nf9kt_9{g{pGKD!(1fA;y zsJISZesoys+>@IPP2D$S8#+JuemR7^`= z$=xI>CUs$$mI6~(C#{wHCxC-i?ZlYa=wZ8pR? zP`hXhxZ{%7WO-A(8i@we?%f79lmNCQdH3EDLoI=O$as=jHZ7A`Qo zW3C$5Iu{Q8;j=!eNStgr7$F$XGgyl0-0YR-K+XimI5zgK)PBS3Y|HPh+6tIIlGBsX zC~e^P_|H!9&Kko*f#FbIj_T#JRr{(at{u>m9+m_V+F?x=t`MGZrY_trEdc5T&~ zGURzpHr-vF^Z{yM?vO`Y3ovB+&eek}Pb4!{f^sN7$mJy71Qi(K4-Ogg zvFV8?qN9k_1fkQ47)#nxB7<6~@NU)2D{8~_ob7UdQxD+SMi-RXu`aYvB(iT^803+( zs0(@{=2}U;jh9sxa;S)OWF6*I^%`_-PmgVdK4lh}Fac|^% z=Cq%e4rd#{A(Gpg*=O3AW6-S1&hC)PRY(%1Qw1bv8mBD?}(_w1E!So-QNbC@Uj=u=lZC`W+c?R}rBX7`(9oP5C=r~pMR`dJLhPFlR)itUO zD1IYFiTFQhm*^*P780Ngj|&ktCR^-|2e8jZDtk^TlOJL&b7k%FJxXS~>;gUbv|g{# zvv$kQg@Eq|B?i?T3QK8yv3n*OLZW%~Ozg~M7tRTXeUMa^Xz$My-JF2n6qCDKnM>Tk zRb94s-9u<+_G+t@y)5JL270%}{Bl2a*L@Zok1azPs`2L<~#B>R8$Sm%8(~5Oz|FZbuz> z5bJa0qT6}BzL$&l;K)Y#!Pn9m@IoIFigryRdLY0@%awahWIIEnyz4GkN8zKvxvp?}CuyRQXbqW5PMQFSP- zaf0eW4~&hKf5J)8>1^sc36s0$z+ z{25FG@-|KneOw87y zKcI5w7lS^^DRlQI!9rVE=|d_Dz>I~`U(P)ZstXV8g|eW5U07T%iXz&@yqlR2Zy2US zVC}~L=*>GoFyH8Ebf<@wkp$(qi#2^yTgUMp?fo z1;5qiCpBy@7YTyizc@KV#)0SZpAv2TznnR!ueB_-mc*0P=J27bxcg?CIo!Q~x)9E} z>vSt|pADV->t*0((l`%~gKC0K^bz(!9K`ij$IC0gdH;3vS!vfX>$5SfLG|~44t~rL zt?BqHtsY&N7j^)sc`ABq`pI4xbKSuK*!$&Vr0BFp0l{ir~u1$$$yZ%%}=xhZE4V*hQsyT1Whxk#l2Be=bfH)QKY5GW1I^`-+i^J{h5D z$d;Im0rq;A(eqT(r|~2mb^L@J&2EN@07gLLs)4QzA}crwcq2DEr>`-I8w~K#LpP(@ zYxjX&tn{-0unJz%(iKql1epcmQ3q3(0ESZ+{c`s1v4v=k;+rzN6i29mfo~}?78yd? z<9z;rek?o&5z9CiOTA}(kbcu!@?^U15v`hWxK-Xq5F4jndYAUc5>YaOE4boQ{7_)+ zX9Y4(7V6Q+dt%`pi#&ta4u}ZBWx_m5QnEs)2<3EAP#Ju&RDpdQ{MQ7vHnhR*K{_S# zWgyty5ZAa77}lgwzDgFB1xrBl;epSRN$BrUAs^F_R zruN7UxC(kgdv`7!`Q!&zudM0F?0PLJDtgmEhaETB6={$yCv0UHrE^?JzFIv&uK%{~jZ~}7k?yxfQv7!t7S&&Pkw|iszfo8D5mlhM(Dw@4t zM;Z1!5C%&Qg%Iknc2SVx2l8n8 zrZx+P)k1FpxW|8Yzx?E~d9(m|#k91DrP6g=Gdkx3}m1#eP2r)AhJl<@Hvf;F*F(>C-m@n*O*HIiEMAPPjFaL zc{28)^+>Tz%wIJ+Rc*dN^D$NWhuq7rtBuN4@+!B$2Ha`WN7f_iStpz2?--C^i9hJ+ zyh@0pX;NS%aM83_sf%<%G_*1%s^Ys?=a#NUGvGaZ6Y(b5dBlrackAG18j}Qzl{Ff8 zKWanF!j6j*Qx%gG6Mx!W}(JG9`P+>kXHH(suznzLE%n0_YKjDvW2 z>&;)a&aO;qg%+)?=$ZRU+#YR4Njjs^n1XGQ1n5#SiQN-V;LUsvr|s_XvTy=X@T_%Z)&c5D7Lp>FCeO;2o>udHGxvB#BUN!%Kc9=KFta zfk#74qK`}3$??PNQumBoWq%O(?`V)^@L=5L{VdRNK5cl9boc2@Ns}UGhth|4H;p;I zV#mle>7;pSX|_fYKDg?83Fa(SS5jpInuUB9Rrnh#Ix1S*AI_T4@GUzp{wLU%lTWcx zD!6soEaqiwdS?R0hxd%ddReRRn}^0~yyN3Il2nR~a%%ttAj9od>mq!FowfertW=WO zALN3lupNYdg^~wHI|qrQvYII$;LE{QgkOp`#wcdVKAI*F9qoPv>x`TbMdrfaxCZS$ z_Kp5)vQf@g-=-?_er$)Cv(h4O{8qNGl1ne+nzFES&B1GZ5nUT~`Hib+Hn{cLDWe02 z)qkiw9?(9R`y%#E9~iGtvM9YYk&N|NVdetSL=Y?P&UMMtH%|Dc!yNmc>-wx-Jj?sX zLkk~A3uOgOocBN)uAfZlG(E(a%{3L8VYWhLQLCw@LgG^)d=Pi&&=G|t$DTQq0D#o( z+UtXWK51|7*MtLUyTp{CcWIa>R-H5-+t@CLJMz;9-98PZ1}yFXckG`h3ShJOSeDKs z5tyqy0NVhd5Jy}H?(?^;Ono?6KdTP0Oly1G#2lWe8T4!xF5ctwVy`pbc(c2k0YoBH z{O}Y14?cs*nyWRe--I(qOYnVP2+^*eu4~anS-Qqgjy{t=gQgKnFLSa21)O?p)X?=_*Lz7SxNlIud^ z4h8=Wk>iD~ag-Ooe$4^2yB0hTQGvIj-=s-*%j(brV&V`fT~lLb1xX>@)|OD27cDq_)VBt}0dj}~wBnhs66X>F1 zmxUf&idoLA_wi$NMgjmpc5Fzb>+k^R@Ov7WS%@v%QFz%Osd*=0dHCakNf*!#_8&H)1Gq$Z3wS@7q?0A=uFoC&1AjVZkvWYHeImQG3qO{|}x1zm&~$t@oI|Dw5egp|M; zKjUbApRJuhr%dn2WbUC#Fa-L>b>N=I=7T?ZYq-7NIFuP1UI}$W+xw4+UxMDPd(~fU zq$Q+s)qFWB)wJ}wkdlh=Gr6qAnm~sq{{_1J4OCl&MRPJ~y2x`gg9i_2r1h0V8+rta zuIuH}5(k$jZi+e$YRGjQMEAC8(kRb$Z>|YcJE`2d9ej`bE-l$UW@+g(?4xG>So@uu zTE>GLsDAIBX>Hjo$?XXu_CrQ}GTkNBxW41#)nq2@6`0ZOa&*zFKty-3G!$cy8;Dy{(31 zX?PoUBM1JDHEc?;22g{SSD&%b+osSUkHFz4BF8}VUTu+`nm8^DAZjrE_L00g{Mr4F zG%Qs<41-1V(%u`?QF&3I-cZr^RS%hZ$=cYu~d1>AWsGu9NkTG!^zfbpNA1}M!F8^I| zKV{4@FL9BC=J`*tX#E|hb>fJ~i`!>nc=dhPhsJ8#Z&SB{Bm;3K&1@st8lGo^tLusZ z=}SX`R(cmzgXqeh?9&~k1p3-_^x)r2!#bpG^Uhz=hJL`D^;A|`V^gz4*TG9JLl>ED z3xW>;fToE{R=Vqajd5|b6!$3MG#3+_z$4~z>_t6ly&>UpvxK+Wpz1Ls0{1k)_X|u+ z(g(Me5DydbsS7-o z60U8j5Vz*H7G@NU(Dcbb8e}$_7C*XgG1=7>++Sl9dB0L#sbMyYOP|XQWOjTHaWEv* zPcdxeGDmsU?1=jsG2K}q32@m+L2q62Qpq3Rx*qLHpMIY9Mef@**WcQ1Dk|eAx*|R4?<@l_9%~Ep80aG zQ;W)z%Hi@AowQ_or%i*n&n%6PM=Mp~9%Mpi^CC<>(9msvnvMy^!MwzTU+}HyNl~wu z#N}e*K5%9~?P7R9wJASi6SzCorjI7=NhweAZ{FWC;E$WON>%KxvBC92_8UYiciP8I z)GU>~sCg4^y$uh}TnL`Klm2Z@ZOLA>c5&ukga<2Jq(Z;g*5D_LfD!pOYh1r^?k!<2 z2VOzP9<8D`$u`s57fn26n>g#q<|}@|XZ&W#TJ!aflx6DJWKO`7zkfKZC+4CGRdbp+ zogJ@D($B7)o8lPh<@W%~2g4Oq;<*QLw!X*W+=oWtnSzJCydO||{2sv0{r-*o-O${h zV@jBV0ly4@`##$Wh}-fN1ovzPLeyQ|Fh0FTmVw)rW5L0tU*Cn=rZlUMjL&A@8#xZg z+;LTc#|ucd^ioQl-K}?Nfcu^@>q=yV9>Vx3X)yCxp21!c$_1DVvLTfSw}B zXx(VWd1tOys@~{E8E=S^usXGbILsGgVXtSWfqP}VH*Zv(Pz~4(Hf%u~BrUl0M%n~x@DDnj~I|8=uxK|pjy$ki5 zF^RaOrMj%Q0$#d)6D{mI2h09k)>=K#CY zdw<=2O{wddez|OHh6DyjyZc@S7V8#WONn<3&J6FWS&}tjY*ZKZd=I=^&$AchZ&8`$JO z`jcn&b8g)#t(+?0XQnr21kTVQxZU5TATN9?7CoxMC(?QFt9{+Kt`62z)er@m-vMbYkgp)!^MBJkc}*ZV~>Vp4g?*~QeDz@L90w?sCN|4h%A1H8QQ!b z`_oY&c^-5UVj-lTA5_W+#@-Sj7(AZ8f!ey81qgKu@r@Z@S9d$YX?a_nLJQPHAqQ{e zZ%yCAJJbMOv#p11XYzG$W2F8nYwzkucmvk%C~SP2We~$xkSr!+VJ?qX*0jECPjpeDx#<|J`!HfROehb>P1m)E~OQsH5Gi}kkY1U`` zeN`g44yu%AaSFTXvKZGd_Zx`}l|?djc<&=+2eqRe5wO?l8S8nX8~Xf!UcZApuBA4b z)QlX%r!JlTG2GXLrf{qw8;CVD0KgcYc*OZ#tt)3bv~`!>e{9&U8R!uJnrPs?Mg0%iy1q-G`x4(To) z_q!q>!i0I8VjdoC`o9=^&!{H8sQ*_*#h(a>Ez%WK6fj7Wgc4MQfG7yk35ik_kQR_m z1XM&I0Td7jpcLu7h8B<#Y9hV&kbraop(MbK&vXCl-nH(V`+nBUnX~82+2wmaThr@W z=uIb`YBxc7^3NGfmtVaj9%T{seomh+)_(SI675VhgVof&LlzsOl7r^HM5yXt_&xmp`jA}``x)}Cp(4Yzhy#;8JkAddJ;+hibf_7e-_wauSJ=H1NtAh$vbT+@jb! z87QVQ#E1Of+n4WPFJ%gl%8s^=K7}CUl>-P9O&Oc0BP8ji@hs8^$@ykAoQ4jYKkw(* zR&C#|hT?5l=pjx-9|Wx@-03yU_ex{kZ2+B=%w5u9i-Xwp|F>)dwcI!9}r@=0bSqfEoQPz`MO)-_`?CzAjxpzwqzEcNaDUj#J z(3sMZ@BzmeDYF;6Qdi5`0n&2Xh;+Y=xeIL{xu1&6rg!Q+Di@M9k_?6R*>HX+YX1vp z5#P9uA_hOZ@RYHgAeBL1y=%%r{;nQwr>GH*t6D9xIIaZ0V4Ne*p{%77ZUf;1sC>5%W^(DVV6jvhOIE#Dl zVUm3*v@ze~Od70RQT0I3jyvkMfYwyX> zLR9}T@ex!-ob{0VlyR^TbxzgFywJO+ojYhyKBvqgHaUzb6D!UQslin#TD=b*57FM$ zzy8@jDF;@6aL+fN=CaZ{iV5?#aKJ0@I)rjSkb55`YtkK{PShaP;aD0&WSr#)m2v72 z?@W0$C=QAXRV!ZtB=;cmJ4j9NP1>kifT}JNwlTgyhRurj?pDbl-6EQ2Ufg8Ph}3Ar zRCM+tqlCZz9$i!JEXP54#nwv@E=|c+GTfqcj~utXohH3$YnUm0?h$qTjaAnQO&LIS zeJhXW&7GZdZ4_R93$xcLb)nb*OUA^Qbw(S1R^9^X=ZI!8DIt;aKE(Jiq%X>=;F>Rf z!JCo__TZIZmY{WG$O_zl&v9+;L}s3b#Fhl#!5k1vVQH?iI@30)HWg?Ug0+yw@{+;) z<{Eo))A|}@b9cQB7uJL{-X5WygMR*cy4TQT2i_9#f=+`$J>z_$I-JOnoAbB6^S@eD zF4C4Uz%W+1^e*ZLBrAzf)1CWI+U|O3aBaT7+y0VxmYGfkziaWJ{c14PKiWfOA4s&a z^h2NUM_;4_;2s;v8*?Wt*sxeb0+vu)`=Vfiit+OrCM*pgd|K4Rh7Jqm#`TEWxoqT7rPl!CMML8S=bP(w41q@h-&H`o#<2%gf57t|#7N?zWI-pqvBjIR zH>Q1=gzS+zv8L*|LGaPZeW&F9^5A&GN>OL16=N8$>gioYAAsGXq)W-wUz-o(1%MEK z`^D}v?vLe7N4qUz)6R{yQ(SM4=1Y{I_7p^AJUFbV)M#DmOk#M{8xVG zZuEL@(Y<=kS0iriA4DGYHr&FkJap3HOqUln{d(z2G;V9Z!Il@coS7D~%xCB`Kyd8y zTfA;C@&L-0q1q995UOTb2qD8$WLGoye?CM|tXF?hwAsw*0QjIh2{S1~jd#l<#jh}W zcXR}i&MJvQi@p(R1Ymo@4a_!pE;&b-Z_7r9B4l4LS7d{l`MESPuQ~vsuj!8Oq|QjJ z`uFU{9otv+<(%+qMK9s{j*Kp;Bty*>p&AXT_W*uPg0`?xHiw|3QltDph0 zYYZlcu6rVRCjV#Bq8!d&@vwsprrx)z*kSB`U74KLwVUz?*jFk#JjPo8uJ`<=bd&J3 z>1EYF{Dl5u_oyi03O=?k)&Xq@pMT<^2ypzITp>oxI{kF-=+h^<;EdzFwRnLN`Adi-*s#j>EAW4 z`^?lbuK$4yA$bm|vN>iHw#>In9r9eTIyXL}Dr1(x)B4ZHTni zpJSuK$bi3J4bEK{ceDe_b<3CJZS)_z(i_z$N*5r&CWn#ML|uPP%bVq&nt|EUVjHBZ zU6<4L3Ib6vU&gMaELj0j_8k{$(oaW&=>jQBQBxIW)GWB-!V60J zuM-@s|MzuY{y(ovdy$fE898mMg#7uYAj&2p^K?-p8Y{Gc?++L|`h(+W8mc@xa9%qP zSVRW#;k8=7p4~+o-8ax4kjE__KUpcnfr&VD25#;-ceiZ{Tdm{?FL6|P@ zX5n%1jW_|{BG)zrM!cAR)X}cm`+rgnzn!M0ow{YtG$P?dhtAYZn%bSLJDtmExK?b} zhE;dBROdgF)boLFk>0LgaA;WOZ2HB*EJr({PmH{f0Bfm~FY@@Jt?DloCdg&o)n-i% ztE$m#6pCtn2}E>PawaLUz6D{<6`@y&Q}#+0{}hBS)pVppq7oMuKJB1z*rDp4OHX9~ z@XeLWP@wqq_4%l`3&T+iavZ5w4aCc4u~$|QbrV#v1#Pi^&L3Ug1F|AW4PBqY0+N_W zebxm@CRz~!VGzFFhsAvTu^D$Ez`c1z@|~n8npF44MCm`W==DFjH;iNB*^ndhLv0so ztP6sbtr}W?;RZcb8oIoioxYaL7aL^ym2f82w)t;5&xv5e0}K4jSl3FL@26SHXK}WV zzQz{M#VtiT6T@^4!FN~}=;Wnqpw)#r7k`*=+IRL|5sqWJ32uHPw(Z4Hes^D8;DvfE z7)DEUb_L>=S>_J4kQsWIEdA1|54Kbz^wL(@vQleg(t#M!zm+2nZ*+RJDNxnLjhU6A+5Y4@nvUrMNcD>)=Yw-SHyPsA^c z`fQuYDfbuc3jyvfTTzOXTd3~tZeTP&JpFl5pypjo`)F%Ut*+=Uk7rBwg{l~oKv8|L zTQZD;)qfIq1DS7kxKBA)q-(rK9X7^sPwk}+agi$UXP*lR{kBD0=RaB2wEx!s{i~U| z&lzZi_*k!z6m9OAHX|Ez#osTvPFGtC{r>Z#Lh|i~ zYdgzcx{K%BoWK5?7&C{R)0j$@D4xwZCKJzwwaZLdd`n#a%|KBKXm-RyTZbl&ZR?@w zcDzL^M@rgf7&-;t+?8bSdZ#x`PO2a(6KW}#{uy3y6NyxonpnU0gx*;-Ch>r153FuKj`*d-Q0F&7Gm~YMCtU@JX>0yH}y{X{nvPrA8|4 z(f-X|JKpz09#4$KMlrU#J}Q!XJe3J+1obmK<=g=E*C1c?zo)rw?PFbq7jF2T&&8E$ zGen$4c4C?%@2qu0NT}IR^Md#1`aa);XbU`uwg?^{o8QNV>foF=^3>zP3#3*!;E&&~ z`C9j|Pz5WfPSpMYE7tQnMAMtzf4PA`sM_=>bIZ{M&50zwXnr#%H(K>zQ7JIq;k$XE z2e>t9=^($S#}WC|rN!0}?)pkHmx$oB43d{Mhi0a+G0~lfV>v;iWBfQy)Q%cnk@*{n z@~xsykN=ojugY}dEpFC@#@1Lwkg>B0{%vMU!h6qCD+_5u6o>6 zE*7&Qssqg?9k{Xr(8yKGM?g1ir!YjpF@yF6fnAMm$cTg;0$m<=0>+NucE6;_E=ze&HtXmfk8N1s34)_wBiH_=% zZ&~ejZQnp1GTeA{SVZRum`IInF>1CbEX?Yk_N!rXAfT&2;plAf=5$kt?o;PSn(0e^P0d?5?5^oct?RQUajvV-3*|;i`pS07Q3)gh-sT zzTRA_D3W%n$+_MtiYbcR8pr?2lsf3v4-?Gm9+0=J zP|FYFOH%-cfp$%!ZwmD(F9*|xot-#Oc7h9#4x6@8GR6Z1!s82{j@D|oIGfhsKK5Q~ znTJ38WgakgBBCD7uXI$Z9wj}4s@Lec{B-blUQWbp(%Pt|6Kw9jg9Scz{|w;HZkbdZI>L@*3Z$9JvqsV+o@-)){uk(6m-3ABlRN5kF15^mY*Le>JP3jy!Gnp zG%>t%JG!7zAE3LqCt{Bpdh_kDN*M55z#RNzr)^#Iii&-4OV1b!3?eoD;~J94D(!ew zKx%yNoC95xEud;|j8r$b_|6{0*B@J%omF`^@A6QC-tn-!>$*hHHrzJibtlVp4qvXG zttwN%+55gY#TWCz?uY#+R7~?2+a;|XmUuWzO`9?6^tVlIGLNY3y%lW2fY#PI4xrog zcWnHLk}DB48A5b_597BU#!VL!Z6egTC47TedA**8^m#|~>VBs~#01N6V}bNGH$;zi zTkq{m6(amG581R8g`!tI^Jb*Lo_&Rhhyw^SP4K+Ut#|L2n2R=5GW#wM!j0Lq=?V@yWXr7xI=Ud3s+4=q);&m`NTqS@ftM5qlH(0%CBI-P(mGLCDX>&ztvQsW&UwgTA9Z zuun3J^LbF4mh=(lo49lHudB|${KFx`<9^r_A&tD!+vukiycdrSs@}(VgPecVun$^f z%JNu!YE+X&8sGhbd}gK2`4q2y_={eQ;;vLU=2uXMg9#aQn+=pZJyrJM6kKQolPLSBdb?}u;E)PMb<8GEW zPkOJt%6BGjZ@f<#fbdSM`j`33G?=c;m9r$~t5NnITiE`D`r%y3#gz2AhO=7v^cki; zbyoRB-suacW8K5gk=Cf;;O7#{X$}?a?Czxo#^dZa-uVOqLowC?TMSF(J*E5m%LUrT zwe~sD-_sUCMpc?T_3WgnJ#!Mg#s@ETT)?+??@`j-J&`6w zu?Qdl%q6JWj@xB<;$6#1nR8+8^>L19{KGMekYzj*WHFFoRVbcphtmse#zJW%l!9zH1>yX|+{Au-B-dFaoPwitsTR(KDR+~2S<1VMaVh+hK5L>) zs($kTPESDrH0pT6J(GQkFUgs^y3PVeXtapt4&`iSHA?C zJ$p$)p5!zx&_kM;D*SZi@?BL_lalSM_fF%;3{`dCyh%xw=`aUX_#47@_~Csrhe{|H zyH2uR=l%Etc~#Z`sx(yh>l09@)9QuJ5o_JV6!2~uMupKY5mFhjB;4p3lUc~NggAz+ zS6R6_;q7(k>rN}=C;>jIA6^Q|i0g~2f3TMsJd+z{2+aWSLx>TF-f&c4J~OAW5*hAT7b7PoH#J2zM- zfm#P(XO!h+MXFxYV9k#W89jt%h?ii}xz!-cy0L&N1C* za+)qL8*3An;EFZAuhJpLq>o;PKUg~JUN59!CmS$Xi}q#d6Pv#L^NY#Nn)>X>&IuSf z^XzA1n|RjaGAy+y$v;icw(mleg2J{T8p;07zW$*c;LMGFxXu5LCe24QwHvvS-1aNP zKWbaxfmm?OG-yteAo0n+2d1X@y#U>%>D^fLWF-8@c)|-d85&0Uv0m=kq z9>96z%~-)tvQNLf?lwlV{W4zY(v)bk_KFkIJ6!-~$`=M#n}jIT*{Eo58r(_2Dp<~X z!>uR>qHfKO>Kk!fZIexW!&Xm~8jgc2bCJx&xPY3S2|P`r>1eB%J7SI7#p5ZP8R?B& zL=s(6IJ>jg@MH(f5UfbYR6ja>g~#K_J~?kkQ04@K2YiXgDl|;FO>nZ$v^8TVN|xC) zXpAdcZObYzl%V6)nft(jR+)W7)&9?we0|U~W4=l# z*QgIM{-DE67)VzTwM4@6=M(%McEvf4g|^2EgiJUzp7L{(YPSJCt}3Zk088QABGB&~ zhy9C50ot%jR?ea}s)bK6t=k;m^Lh>zbg&4lbKk)y^8)0e@{&L7;)bar{{&aroiLNe znvOdm(-(RgJD2lwC)-xJ_*2^cxQ*Kdg-Lw2_?k>h-BDSS-@Ewru9+HPwR{__#y&pv z#`71|e9J_d?{SY+`+^rLc^i^8r>6|^aL$7YL7!o^aY2yzB9_Qkb@h%v$@i--dcpxK z4R5(W5n=Ksl1ReST5lly@WggWvY z_oT&2@s$3r4zHe-q+e}ne=+}B`B9ne$&|PP=+Y07jie_53)eYnGvV-`LRBYGBUS@> ztcH<>iuHPHe*(TE@A)BEt{%6AkK69Hn5y*n$f0~govWSSmPfTI$@=-Yi;1|CH>vmM9~s%K**t^rRaeb8a}YALjVgvZ6)N6V?YfLkUIo|} zMoY%VhZ(sMi(2w9@YN`$TR-!rlCm7qxm63yI(^mhHjS?nCc@?e&)%s0F|h4lb-Oj* zANH5C+!%)oj+`en$&wc=m zoc&62M@r@g^)iFtdgllN6G6J@O{~<39!~!)cSzaZimT5*5O^t=na{T5dkkA4II;dl zYGG>p5W99AC>9;C@Mc8{XG!f{1UwJee4SfMJ-uYu+LDq2G}AD$>6sU2<;LeTM=lK+ zeh;z7NSUr9W-Xnq`bCQkZGl}8Y2sNu9!+mTIflubfZ`ttpG(BO)^S?a6b*8)c5GXB&4+VyEoj4JxbLY&C-P;&$UGJ$f;uIE z8Q@^~_U~4PWv$F%3n55jU+XE1c1qfmsHcXI3c!x8s2piaO=D83 zZtJt;;!7s87xp5)VtOz(z;zikGbrWnM9W73vd69658KIf(~T+S`dskgg@&4kA2Mb? zuiPz8c78oCnk4V!W6CZnzJ6BuW;d&I_9x!^LqGNnARZ#63JkLHreU(cT$Z1^puuTA z&DKBSu#NZl^xYTSOIBUB9_ZX9k+c16fXgQd_c0!#0@IxvR zaou?D^Hy5U(f3<;Bdp2uWkd1Uy7m}7zA%8=}WLWbSJuGgK%Y^0Hw(9BFk7GE7 zfB$hAhz~1%TPlxS^xnck8@dCP4L@OU!$Jao;^^v2n==waIBvk6QmO12=>Ck$dRs=W z@nO#L63Ach5453R?wiMSBjRw69-QQ$+E=i{y;w3G*x#3M8GgnlER6GAt>2qSZti;m zRBfx)1h}Nf!{g4YA~ufRCx6kLek&fo;sFu{G}-{)->#?pmlP%}FDg2^XIkin0=8zC zpOLEiKISKtN*Na2zA+eKbM7y8E-?Rz)tyJ*r-LFh9Qk%lKHakRTOJW^Dy0U+Sw--e zytIv{e$i04Am8stc742NPLjRH+Qoe{gcx0&j7usu+_C8-exDT5OvA!X`jjfwPSp}+ zUq-WL3K60jKl(I2jO#*<^dvP&L~(zRoXA~wy{o53Zqt+I)}=RI;GU}~Xh{QK3iptzX_U$ILgw-#@&a~k<4!P1(N(_#iNSg}n$3Hvmodw=56 zi{}Nu7mNOQ3Dvku{qednYf_U(jr*Xc?Sk4;|w=boIJA z##7LAdsP#!Q1UvGJ^QS@0=sc~`M79HJey3_ZN8c`8`)oj?YVmpQL2YUfkE+0n>P*u zX{W19#34mi2x-t8(X?+cdLnckPGiw=AGRj`pqRb13!Y%}d^q65-ybtiKeP7ZUTBY>Z@BV7q;S1vi>!=l_Y9sD3Rb~F zJipa_fwgk$?!776=_>74=1%RNdFhq2vR7HRKG1|en3fo2ZtfZ%o32J5Y64Rp(x|)) z8(}#cieFfQP4}K#ehuYI|Gl6hi`>D6eps5_tBCOeWOA70gv7rYGdatnys5RS6kFA` zao%=bZMEYR>~Ew+QwjB$g2(0B1k^bv_B|K~w7dLHcO%o(e137UVV~`NUuhil>8hgd zVU1L!v8eyX$+#_Jn8BrKZ3(~^@Ea3^fDPuldxUrlv?)3-aQZT$Ro*kBf4wqI$ObVWP+>Q_DRs|4{4=}p11Y&jZyeDX%{*aZ^oMbb`r_(Gbt zmNdQH?kReq$1R+);8B27M{%xu!s)ms>cn2duG%%!EL(Ccv5X~SVpl=s80^gLNj+29 ziIW$mL0Ccr?I9V)`%AvZ6=jCLr5wQsey6M6b(bF92li{8>K7e>s5`zE{%?08p>WRK z(|XvN4^4$iJbJIXX`=*I8Ik`5IGw)~t)EeAMfS>14IylIH)OlJNDtKyQ+gUrcB2dN=xVK<0Jt+hl+`ML;XDhEr#@f0GP2Y7sdTPOY?FnZ zsAKm&A+f9=lO|>8)YMhaQbh->ZoLM>uWU^&?ec*Pozyz&J7Ej^m+lZczd*K?C(nYS zJ4bwVdmwQ&8S~T7j(;io<*A%vsw_yqUfGyZw8@8@=@A>eQC?8zy!IC|?n!5^ zS1TdzG1pR*WPTJ$V{w1k??wq<0yrkfzR1^TxJzU`yWuMzhk)C*%3Da3L5<* zIctoV=cB=|zPgVa%%eIP1Q$H(2zddSDN73mw0dqcnY++7n!Vr-0IH!{>PQDC(>sJ)M8=gq@D= z+4y- z-t;|f1>Jp(W&O|AeLsIq z>c=m0giy!_(9Y1?!h5p{=UygcON`sPU&&Gtk+ToA(ErBTm2jYi*(Eu&WvE z`3CJTK4&)T4^Wh~bE&VoABhrYD7KoJ{bs76MGG#?UYe6$-#W`%QeF z0Xz}(#bnPxlNyD5ReGah3XsHZRp4OvNnwiiXI&~Kz;ZW^~^$-^CQg!Aai5D?O3 zJ;Zo&TeaR<_-KH-H}%g1$w$6r#vgb5r&U%i1W;CxfV0WV#}-eSBKRPd#m)GR$=som z!lDc)8WL9ebxMZ7;jJ@dR=;KFw*}+nL92HP^?~8hzaJaQI z413&CP(MPyReMK2wM!4o61}#E-vy^+|Hs1)=zwC$AxX*^S zfIIEmZerKJ6WzksGiof?^w0}5mcD!Y7yYct&NCWIsV5sjdH@8`+mp|M!?P*A9q==iJ?{ zFRFgmTsCA$=i+t@7bYhxN?Prhj(v*z7i`hphs|0!IdmztwW`Vq2ZJL+y!`*D`v%7= zwFXwY{SV3UvUWmoGaAWcLYOCSHQ#M98LMHd!#2YYAZs)UCP)2PC_>?gaa?!|VgaQ) zr-Jz+%`DKPC*_NsnZ4?e$kcbBYxAXu+Men6;~^m;aaH|ZTjEJ7_V<37ymvH@P)osh zTI?FcTWw;)qkV33-NAo(;ju?P9wP1_;Da7e9uiH#nb|)b+zAz{OvlSS>XKmJI+%+% z#hz}|_}Q^YhcctT=?b}IxgB|>>9OhOz9jp308yN?k!GS0?QY$^rEn)Rsq&+p zqx}%Lyt3Qs?Cu!KCsn8`!jIO8O++w9NaJ@0$zQ2FO;77~+RDk`WHV91>Q!%vQfHsW zw%)viDGE3fHUt_v$)Z@@hkM+XYqKugTQm4ES-x#wDS zM@pT~_Sj$k{kFi&@*CVIy!DMkEuuMsrFaBz?>~N98|P?{gC`5^YIktok#MCiW8&GF z;_O?HUOvh2xu--yfvmUYwT}IvtkTg)rlp>5%)gxLWE5r2tZepQUCcK|8@8V7p;;*K zhfUk@&#yBtwZ*T*NRjzf* zLH=AW{0D3N+*iw0^;+;BV=%(w+*2&{D#PWtL~0swX@(U+v4C7;Xs_rH(hmrRe`jU= zioU9Lb{tnJYCe4z>cdr$ZW;9@5~w9ta#t`ypitf2FLbDyG8_eckk*~B+?+1-R^XFyL|l z>Ba@pBwQo3qqQ4Fw*s!(US=q?3M6^ZFFAAWnwJu>Dvt`Kd%fGBhw>UrW&>QMQvG(? z!cpF$`jZa-hXr7d2L89U&lV`8E1#W{^~|h2+7HGnjYcx%o*2!Y<(3+hv?{kWIxO*I zei*ruT<_ztyUBoqnE3#_Q`PvP__4>VZ2rnNcF|Yz?!GP}x6D{z1XH|bRck!lIX?CI ziz#|Y+y~vq#|TzAZ`qn`^luGZaqVh|?cXNja*gXb$HbjixLkC`M5|IMksA#Jtdsh)nNTY8<`NNBeK(2C1~Gv79BVe(Nh@zT9k}l)ZX~P`U!7`y%%D zXI3z^KD#U=69E(OLW9%kEG+`)K>sd8gTKfetcd7}SyQcmZXLukCPm8yw)qs9gkd55=(J7!y<*QZ7%svI^gg%F7HN=x=2;e7D2+Kv2MB% zL#5fRZu15%tC|kyA;FcA^bBFfY~j(u_F!47imGDK;i(3im9SIRmwyF%g~}sp^*#3A z>UzBhBs`!vrX?;Vk~Z0A2!C+1T{JsTAjW;V<&nO3l-o}yZhU#)OLU(=|AB@3CUtymRJzRq9LI$1#CJy^pZRx&(1#})im z4lroce=hHkp1MaKvTd9{pYkK$zN)0+NHAlu<8K26qDKq!WD8yMNG-z(DaXx15l*{) zMfLeW_5Tns=wagC11%f#o98O2D6; zg{t@Py;m-8_)GaVt^cCOiRe}!KjCkA)V0d|6`($_<}TCMkI=cyrgFZ5)bPz6cq8_W zvOkCm;A3v1k@*i#TJNCKLYWQNK4=n+jIDC)WV!n6Id<#EIN}csdiG@PNT}tJO#Jo2 zLYjH$KVYp>xBpTO#Mfojyr3d%cgu|j@N1vEdMx2lUbA6hBP6wAE4 z_wl33T(oX9v_e+w3YX?cV}rt+0Y8M%mQklCx!pCh+@UKe5DC_)V*T1-L<{HyW1$x20pSNbKx2-1{V7NBqe^JMxWWIjD#9iyJTR2)MhTkh%o}_oK;ia5l z{it)d{Fd5J?UkKs?#i8z-9zZqF!|#~gop*E$0PlIdKx3m@_rFi#`nC$8^2Bgv7;}6 z$*kFY0Tzh-l9~KPyuwsWNVC_~!gBXvAN~&G6h)svW|jozdSipF zQic9r5O0(ByQ2{xf6FCEx8Q~Jw7Q($daNFsSf2K6ftMvE@qaA++IJS;u$* zMIvgCNB%#i(pah+-|o?G)D0#J560+#;w+o`nkZ?XG?{~eTE#HZYNcP_{(OzOt2 zNK-CGX0|u#b=|c$*V0bIk9kLJh8YiM%SU241Z&dm-IE5FW$W%uN&*c|0}PJ~k7UJ+ zvQj|<=E!pbk@dTDAr;S&Sg7^=`kjyapI@x-{j=WGgD(lJb0-9u>ecUe0EF*QuV9%+ zM1lRYdgD_09#!TJNMRKlY9zQ;KB^JzXdnY98AX7x+=(Ik=U%BgJS#MBdWJ1PY8 za)*DgPC^U#rtWi_f8!7@pZXSNMW#x*x6eN`GX4QuLbgtHB=wy2nT%VIPx?xAcP86u zOA%UH*m%7QuFZ|RWDzA%|M?Gj|GFBf;_N&)5O8e0f`u$ubzEDAD7eeEMZAn%@9;81 za>kZu?VM>3_gjeWqB`bjb&v$RS)ePvU&9M+|3go`c#(4|(Bd7VH_^KU~^cue8A;D=! zKR5t)MPVusa8!x_dChJjh3$_B+xm#7lmjPgL$S|Av{1KFw+YQ8Lg+`1S|>w@x3dwD z0A_P4aGnInOyp3nZnMHr#SLcEp0d(25=ya_MJsOJNOu|mmSenp50a#=9)=nFFS_V5 zjyJ@dmQI$+&z|dsh&>IW7-7s)@?>>a=gQ!K9Df~r{nd%vd*LQI8I%j84F02KC3?nP za?vnqp5e($ks8G26!HZdeGEAt*0za9Y)s9w=z*Ig4?)(XYQ06n}UBR zsR&dIn7&;QdwQ*rg~#q^RW=*dW&f&;DsDIa|G!<;LS+T1V(RcHh8+P`c3C(&5EF(! zPSe&erfeG-9lErYzis*tb-neT(LzFCaI@J`mSTV@T6J2J!;tV;_uC5^mY=^~bF+!_ z4#O=xjfF&=Hp*81P27Wt-Trb+t7OmVuuGLDXyvB)q`Aa3;+MjyejH8HSM@!fQ9=@g z`>mpX`ZsThRG#!a@j{v&*C5yTgD5U>^oLO7eotq(=WFMGy4iId9mgjY4FTNwT?6fA zF&b68gpP;RB`GXQ(*iWv`)`=D2JtdYw(lR%Hzgi2^-Yq6+Lu}qtKd6S6G?}I%7EFg zvcXHPuGxSKgpmSVYNSR3MH9#!IDEqCwWRBHDML+e$dkn{KH25WNZ~VP3aZda7*H>F zNzWF!Fke**01R=lle=eAtD6Hu9Hv@Y-XVK=>N;3j{wDqp4K;`HJ5AWws;r!VDYsKO zXOqs}lm&=3jPWS!D0jlzv@h31jOKR1V8vh?mPA|eYowDq*?-Y@3PI{pAIWmnF#@Wv z(>tQXNa_QjY?yJBVKn$hA~$ULqC-6C0)R37Ul2J0U8|rCGcd$EcbrX;%ziF|lsOc-e?VkAg1?T!yj#3w-gQ8 zbQ~wBQ=&ui-=sL9j;~W{#u&%TFc<+}>YqV&U$gz{E8Cix#v{L`vBxbO_I`StFIyEC z)PkPSnpBXH0qj1<$T+|=Xt>m@8_1WdqciVoO<@Lcpmmp(0v5C|Bp1A>xnf8}_8>kA zB&+ytNwo!*5}jhWc41Y9ITlOGe;YUss7Jz1sw=4Wo>}3g$AAbNM~kq7-cUTV%uP?p zX*;u){wa#2Fz9sC+l*A&S0<&H!a`lpsHQAHX2r_KLkuIHPCc3$a~iMT{#eKhIUFx- z0@rOH*}BNyjq#2(D^y6*CfUCGEqzdGZTu8%s4Rak&br^nWT;z3p>BM^YU~{?cbu>1 z^3;BRdC!}zzPB+>1#Zn{y1T(I!1n%5cM0HkcxAD6{NSyE72VpgOih7vM;jriw_ZcS z%-oseVA`Bn{*LVb#nyX+{oLkS=?bdZt&351eDDkt-v^Ig~Xr&L)iZ z?s>!a-_qS{$ar*%B;uPALS592aSHqj-6{%limW*T5yFv(uG*yf@ihfw$Y=ZJp8axB z>c51Hu=7Gfsn~?}=5^K+S;sO>BMze5Xvt`ZDmrfk4}%2i`n?y__}zWX<+-N__VErJ$wN{S&M^mkI`R z$s5`vsYU4lDM)|yn6WQfHy<$`f1l^Ktp*TCL%{hNQ_p6UW_Q&}5NWj?VE|`%gMK45 zB8S$oMhPNmltM`IBQ3HWy;Z4^n)raAy<~f^A!7Y~KuI%u?{)ORX4en?$0vG-U+be^ zzub8Cw_)fT2Sve>l)Y24W<|u$4XD<+5|KUi{+qk!s9F2@znjUc6;Qw3_K#K*i^a;} z3b3ajpIKtjg`>nBJuCSO>ru(7;Q{`8>Xd-8p|7?j82jm{c*(>wM-YZ4% z2K!XAY&Q?Tq?T<^Bo$rXG38qsu0wU0{S?xTOup z{`5U|%NLzr=g1uQEX-9lwf@~toj`S@s;qV-bQB!YXMH;UFdIr|=ZibC2k)SmPx1X; z05}|yc*~boF=oz8vL%{w=U0H6bKT!RrmU8Ni+j)h${Yv@oykxuam+2cK2X)WaEplr zyRC;fa8)|`D)E=h8DMrC$3MphE2;Gcop)wwIvR%87cp(7pO5l`z0h@JF%OF$e7uf5 zPC2b*&d(7MljXl&HKnxF=MS{sewJ<12cYaoG&<}eLN;Nxrk#^0mQkYy3b z)$4Z1A47S@Q(Mue2F}gFwgn9QzFfcG8^<`D!#}YTZ+k`Ah0Qy{9#CG(u<1v0v?vVO z5;FCA55apev}it2==nXkvmKGQ_U=mdgN>XK|}A#Pc4 zYg&cz;YJv_39PjUbFBoHq8*6Hw0T*CYj$*)RZS(@XQVEww7cxX)Z~SUWqfa~AZia} z0^{Tb@P2Z%rkkY8OQ z^oW(|o3_+HDW$DYV}JS2I+Q}?tR_Q{Vm094UZYVXD0khF5H~MtgT3pomFQOO6m<6q z-ChFNBdh{d=Z-w*M=y))Un@>d-hEp-s_|Yk;;Iu0ay&`W+39vDHM!86&{(J2b{38R zL8IXoWd}_;vn$!ghq-n%?6?)XvMtzhTPivjCus^{1wR#A%o(U8zP6f~EI#BimrR`|NQZUA zXDS9F90nAJvfHDZzlQFdPdq=X!R-h+V&{zZwUKAIsqMsrz(dzjS&(@hnnVCAOsoRb zpg}rJS;5uk8qdfEt-Bj2MI>A^n2SeOzzuZXZ1ww0P+AN8{l;FF(r$(NlM)&iItVFs zCdK5Fk1ZEkT<{J zXdN^-3a58o^JpbmJ7rLXOz96BDvX?g48^);^dLFMx=|;{Ae*8LHzL_A`1FH3Jbxlf zDHiv0cSllv##`T)?G`C_+)VN7Zn$c`vFw5(K&(iI+^bTGt*xon;9^3fyypZxA5!_% zPN4Zy7mbVJ*r55JhXL#*@oXGcV7u>^_ceobTe&MtoyVc|N#&cpequvxu#pA_by4cS z?YV7~(LW3+4@(#~o~X~%+ITCeWlm}7KL=P8ZqOFIdV}#7bJ=mJZ#i>GX)Ku77W3qP z2jP2-a))4{agI0HEAHx`wvG&BiH?ZE(gucAWD^!H1O#y{pRzk;@&fM!jv!X=c4inK zp;aX8?c`;;tMcd>(~ zL;1xUo|d`739#7IK4_^c+h+qxGyC{7Sv4{Tqk!12_ImZ^y9RA z&`PcjnL8R678iG0MBIg=J8+ZDpZuCW>89FPl^y8GZ}bFM<>M?z5Mt9T9OzhZrTIPd_-8no#?LA!2>%_r)J>O3BN3$dS1_`Svb;;7l zZWMR+S3)1Gr?W0J8|45126^V?3kfzay*8{T;De$uO_NLYKP0!>wNea+&h}L(b#x7T zZH0^t7KzB$f9M!xDRilN7h@}*!jBw`6${Z%O*O3p`#L~Y;Sj2dp*^YI<7HK|hc+?y zL%ahx!T5+8PoZqfcfNdaKBWTR^Z9R+p-CTo(nb3WWsK4G1!?%nV!g!=?dq)WRP4&@ z>qx(C=Q3$~wIN1mAEzNiMwlp$rHrcL+SfYzBeTe{%E?6E&eOrzs?rckP*ff78#4gt z`k8+U%~=rYEtO@@nwqLA_3Si-y3f4bL3;W)2V}4>?lC_DZYM1j&dkJ@YJFPUUw}wj z73UEmNTc8We!~-^RLXm?N`5ZCbCksVQ<*e`G^6WrV6>f;GjJjk2bW{YM)?UbLG)=WSo*{$7}b zm61Zd0=#6GFq$tF(V&9sH+abSsgPb-2NMojDcz1%igL3pixD3^>O8k^_nsedm9Evi zscXVmc@1h_Ugd~52JQ5#7S86Fo7WrFt_QY|4!VAB+JCO%D(J6=k(|_VueQ!#1ytly z#vSoh5qbE-$Nu4Y5rmQg))4zzrhTF7VX7Ga&QlM=PJI*bLNdc(<7WRMlSKLfU-h1s z-KY*;fAX$$7g(l7zsy;VxJYjVBv&iJ+1>Bm*Q-WceMff|t1Y*+8mRhP60H z*SNb+@p^b-LpCD>@ccABuucp{R}msgM9Zn zvTMHelFxMejTw5BPT2)6IE|jO)AI4d>mu2iF)j^#FAGwN!M_A>9J3rX(TQ}lw!=8b zsfF<5))lLQ27t(=YQMCOIT8g>jP(KEw!2~ zGHx=#ohC~UO4}*!8`rQWNGt5Ep4VvWl!-hLP(T>qwYo+jLR`cFA5V`r!%Y8Pj-s9B z)HZKfA@?vSaO4kO(AeWg|1jfgDFkkB{MiXt<8RQo+iTOoZhyFH&dN5TBHmq?hY;?M zILGKwcSO+Qkb<)-UJwgL-ekMRuO6NY93MpI zOK$sZZ9nz;#bu*l8r~au#97F2biIxHli-r-7V2f!rZrBRQmtL)I}l@@0@Q!U4{1Mt zNx#H5ZOdO{-mP?S2VyI!O&sL!a{fQVF8>*xIQ6&k0}qOV%INLi#eQiw)S26mgJo8Q z$Ml=e3?IWSmgfb)zn`^d^}YjPC5S zQXXGY)uKG@OvRbKCglOjjt(P`Hv7zMx#ja^)q0W`3+96rc1VAd6#TII0|H*8H!Q=* zm_I?|{$9?8Q|QX5Ij8djw1Tw+M81`6>_ZyVC^(UO8I<| zT`>udH!4=Fj-#Z6Z^^~T$pr-U2ZhwMQSH-xl-qFVz3-9`n0P;&U=6&o+Z`m#O$PHM z1 zl^CCj!N@)?8F^e12MAnM&Mi`r8mQL5*;`9E?2H!lfMjE-aiDi$4A0Ks7h~KP=VKSa zKh;y3zu@cKf~eSwN8v#+BE~Ks2x0?%km9PM+XM%jCbO=Gqa9GJccpHzC`EUp*M*#p zS-T1f3U01a;_7Lx&CR86_ui|#Q0`Ke`7kq(bhbgM=ToIXu5H@o$TvHK+$il*X@TsN zp%nm)++0SXaWy>Z-l-|)kN(5%8WrS^^O3y<;fa@zJv(-4arE>R@3c}Q=g9BMK1K}# z)yQfY{(St5?(Y$A7Z7&#`BZzu$JJ-Bkv;dH3wlX|!(aS(DiI3LaS^|0uIshUj;*f} z(ebBG~fy$Tz2N z*fo24$1je#^OGU@c4GUb7w&3zGUPoPxlP%Pr5h~x=PL6z+BbtNMzP}IX3nc1Z0sDS zrH6V=`YE%+c$J8EG4@KeSy#YGuC5AGROZ;8$&@fZnI_Lwpu6{1pLv7>tx|OuvuDD#V-bW$aM!kjG8`peebJ_>$^p3 zSLO|Xt>&F1vg6JCsZUx~vUBdO!2P_>>B8`**L8Po+xxjV*ZtTI0_#04Vz!PSR|+kA zUE+M5IF7pzOf@(Z7)fQn$Bd!BNASr89p`-1Ajzlgxe|>AVjb?JwPi=x0w~2w93aS| zXx)h|!;7~ZrNg^pK26isPwsJud%pv~NOliv%L&QNA1K;IQ&cDilv*znlVuJ-n%vvZ zJS9U)tE>PuUE@PxLGW%Z_U32K73MG2b!@f_?o_lJor|pECX1p(o-~)Eo@N~wKeKqN&Z+YN{D^$ zlV+wgw(Hk0j2D^f({D0#7NCV#AH3vwKh?0preMOnD} zJV2E!8x^uJ$0d}RV);wh(AmsUhrFc+3xJA|)XB{i%2xXgBKl8lo*1NBnPt3uHyG4$ zVGgXiOOH=dD;t`u)fAg=zZ&lpUuopou@=)XO6_&9iAONNO!#-|>47i+f;wm(pf+&X z^c&+UZSN?7mB4k~Vm*7^OrM=(Fbeg00My>H>WtO~?*W8?pVU>v2c;68yC=>yt zxadTzUyGRT&ERieV^yRToEV{@rtjYk%U@6?radL#=0vUkFDtj-RHXOZJ|xYZ zs92j=m~T-=Yu-wl+TAwpeHsi6{xtPG2V8WS*FyMSR z!=wnEydDAn9FI3oL-X5xtXu^v6v}`Xa8ABX?Rrqz@ZDo$pT&A$iGz2<7tK*FU3u*T z)8boQZ{tED-ZV+~r|Kr{d?kJ!c$UvpgUj`&%Hf^mp^qs{d96iyhhi%u`V?0h%kL&r7I5 z!)En;gOgw{?XI`xwKpz%MVabh>$1-^(>JD?4aJCyG4B6EK%_#u&jO4%6V9>Duf*nv z9h0u%H(&0UwHSRo<7K1|djU$hZUT+fG0^=pqpzs_%wM1_(&}~!*gqaUv8-bl;$TjT z*1x{72>z#wWZ^jYOpY-GO_a#Hr0l%7&pwBa-A`l>;=!Ajz(^v5VDhAvap3;g5++ao z&H^Q`GDfMv_;TXD_sGo*+c;nd`O#RZ7ji^=)A`dy4};SiwbWb6|ZVcZ~F~<;A_t&5?{$ zz`i$sCfAgXHBchj())T!k4T9b6&>}K-p=k-pR*GU~%TB;wK)rfrLKUKa3Ju1FENB z=Pk$H%1*qMy8%cwHX2;N_V|ws5#EWPK?^1v#}fO9J^P6w7Z)!DVNAb$U!Qq>m zK6OW+?1&>9=6h3FSTzr|@d?cA1Vj|D)A}>tPWKD@@UVt9jg2i#uXT8@U;r{6mrhm9 zRkiPU5ZTw`R5b;=J?7?qKI$Tf-ql}YVAqIyi!WfLfLpyHFU(LpiYCV6r9D^c%wZJ| zx08f2z>hBx74Fl(f@CYfznv*aw=yvgPtZbQLI`0l`35LV&IkCXS!F*s#PMAYD#Cr< zT1F^qX&hqF2S*aCn>s$q4(hM9^sC3q_9XrNZNaw&C5jQ!BbJsfAG}2(*`5J{-$*#! zZCAuzd{}d%0hC(h&AdncC}u07m9epL)05%h55Kp;`XZStrrKe1M&pjrD9CFCNt$QE zGwVUvCx7P6KKgAmJBK61i2-(!Z~INlRF2!+q(5pR*(cr7Yn_E_0Kjh?m+qYv?ab(L zt=b-=)_~_BUFsXhqG0RmN;EW*=o^rW99AwwIAms#ebh5O~9o(0%-m&2@hNjvDnONIx&f3j=Ww*Q8EcIOqL$@}3yXZ)#N&hHog2#6xt zhSq3!U@~l3hj{r970!s{MU z4w?H8jguY$H+N6^SbjC?4bD+eUJQq{CYH1oHu^Tx)h4JO42ZZOI(7-{o(V2aVT8?2M`P|>EGY4vURgZLJa@bqJ16P z^(6A9>sY*WN!z5)6}1S$+i9lzv2^-E(VNGf=}OfuWzu6UA^&>2RC<@%o*3`k5i@uH zCFTA+K0?@hrxG9Wx=ccojy+G?8z2(4<8}-xv+wFU zubn&Tnu?F9{xYM>dOY@c|5O{atF2>`xxKy@MXE9l=J3(H*6qA~DEo&Fj)z!nryqPv zqsu-mdVa>`NalEvFx!-M(@NNEu>XkoK(-UZb*DE}rq?q5bt{LgGe7;6e#86bH-Ns` zw9t2eG~B=4_XkJD;Bl5;G0CxXw_{@_ys{I~J%>oD%+F4WbM3x+;6IyFzzq zl@a0;?kV23QU)!YTE=7t$)bH;kM~Yu%Zu#aV0pHn<-0c);9{X6j>2xW}n=Dsb1?~hf(G0WUc0tk-W~EOsR|! zuaeyj<>UH^HfO~vf6eqnQY1eA9hII=zku6OuQl;1J72S2Z{}R~p{!1@CM*vKLmgHQ zatA-BhsC4aoL;tc^(yVK|3^WOle#;duq+tIXY!yPGef_ShxPcSgo*6bodzDxOo>dP0 z+~8+z!jNJsOfTgZ>XVc^1W77`u~;+@$+N6r{G~uPt*_D)A*Z)}I|2*lDl*OV=hq zc2lGIBJ1v^v?-o|q$`Lq73RR%4v`2?-J+OASZ2hC&jd6W4dt&@ji9I|_46qb5r8>Z8{2qJ`? z1zOF?=+V}^N^KEo+X{^X^+<-}TC*~4QEm06$#$PUik`bj)Wnfjv2J0mNl%*klB}Wz zTAaCl+Yt+7qZPU^x~A4HchXiur1HcI{n9*e!{HI%b6S*qBOCWX@+Jm+0kQcRM_8q7 z;GL`~Ae-#`q8$v3+6RtKk2YGn^@Pd{+*owEK;QcIJYu0x0sTeRSiL`#cz4!AdMBhY z;MTi2eIx_hFfFp3p`{^<7eER_G`{ubWiWad$cHxjl2kV9+aB*P?#*^N8JN^iI5C-= zE!vf2O{}RSCf9}Ugpi8uzrZgk+bo)gnVy#oz<={mrMI@vlI0^mPq&FNY++xnCPa$9e(cd`7R>XFRpq#^Qvr{`~ zPZ!M9T~kx)*_OLa)ZO>yW_@#yumHqu)V=OSQwi7?e(E|i>%dQQy1eeYxi7)sMXdD+ zQs1Y=a*P%_X3H%j9&6rn(7$u7@y6Kz;l==W$azITfdL-OP!24Y_Y0^K;vl&h@Mx>2!Dk7FPr1uO;^@E`(`uw77PA#`0mrg;m{+^v?e#)*N^|2KG88vt05{je3m{Wv1m%Y&QrAU$CO$&yo zaK%6ZqHylgmWw9g1yx(URwtY=vo+2=F_#}WPa2=1%L^qObZC*CER$l)BFqIo-Fd2w zcR94C_cgyuiKO$mNf<=wLoox{3x#i*n+41EU{n4q(WF@*zYnMIGY0I1LhFwv;|y}^ zjZE4Vum93U=cUyhT8Ivaa+4+5NEMfgs|CN&+<7$i`fE*Tm@|rIL~8lwa+_e}9Hyb0 z@DaWUcj4XXqfbPrV;9E2Bd4fBtOTm2_ z&@dM%Z6^Pj3+kOZDXc*PoRNPoTa`qe-9#WSHO0&d2gih1j@RC=aqMqjSa&$$pQPv(*Z`l}l3R>K)#6{gFN5j{HRxSZGR)1$8oERF>V@^f=4rtoiT^*k5ic)k=0EbUU^7m2tq)Z`cW@k_3-sqkJGBewn+|*k9cG@z= zbIXX<#upo)e@GrrYMHzntezv2U9IWU1${Nu`#8ZQd}-aN#n!9a-dYQlI~zt`nI=WQ zx30h&hgpPU_WI9<4G<~cZNWBWG5s_3o#3k{jd-|NjR5kRcCgBB5@Z9zU83I49;Kg! zKPBV+-@I*0Sph5E+6g(nvoKsX-4-H~eSC3COFUq$OC#h3O}53o?|ZC?vZb$$opX3! ziEDlO@HA)sV>>6=vEzZR}d65Yk=KXF7H& zTVvE_?ILJEw=)SFOiiHhgXai|;PIlX8e?yDN*_C3y=1fk$#LtG>CDwK;rAFj*D6LO zwQsIP__~}*E$Wot>^fqj9+v=26OK_jo2j*=-K>H8KcXuVnH52)F{-xRNM^e+l^ZsE|Fw!(2=bSgsn4&^i;epQ3t6uE{3ZOX9sIWxwpZbHXs(+LB&oeuFQ_FCBvuIx_TR~D_83-fkn+O6s(rE| zY?4?$f+H$8UA;He<1iGDUr+=IoqMj5_^aYIyqxDSG+f3b0C|a4?u|)Y>;d7q+h7Tb zN3qc&+?)hu=>Q;+9!y}m1hHTYl4*Fq_l4k-W?ehL=1eVP-sgny(+|8EWC3~S*3)({RaN2LtRibm$hq zDank;+Q!CX6>bh&|#;-H2j(E&kvD_KjCalAzaC}%{6MG0`6&1lAeqLq@Tcrzz z3otWBKNP+XDHM-vf}xih3r~AVFL+DS!vARdQ%5;fK?mDH1$4yPy&29}YlT;8>cQ`w z!BnTdvT5ecyg^;Pv+)`J6&h#^%fir(NvtHd2t{Z6#k}iD02LvVx%P2$54Z6Ed{2CO z?nyI9nLDaP=C2@gt!6zT#|kh* zYg&Y5Gcs-D5_TMWq%E52XCj70zu=Prh|)Z$Ms3X0vns2c@SxrM&36|lkc~X%)ywdE z9_wtwbf%-cd;6-A$l`tJU^xs^{e!$kaW#-+f5wLK-D*OEP{IA2WVE&)ARJ-iC`Z0N zD*vHDYO%Xdw))qloEM|N0P*}Z{Q{R z#~oiP#Q+&o7jY)k{oR+PKNw%GcnCTXGGfyE?*kR7K#t9|60S=_U{8^8a=wU!?|M8t zb^Ba(UnQjdMP?wVS_T!>xpLiZ{aRtXMs5><%v*RjgLlS4yq$^g9Fleox4e*oQniC^ zUT+S+7;3Lcxq*fDif|PCFLKt+=y1ee3`_1-=O{mWknYmXjXn*swZMA$Lx+)jB z!5Bab=-cW0XI*FO?j&Bg0FN}N*!UL`A@C)6r~99TWBX42!;yvh&b=S!l*i0UA{$|2 z;Mf_=DZ@N&HeQR=KIS_TDkoB}{kv_$8YUCsxs> zNE#mOFl)CMN_yQ{NtZbu8q9~E%wk43R>$9anH7+?Ws75bpO?@4SX0f6ToMkJV8ZsN zpZ>nK6bA(PeYqpNDYDrxx$a8^73a3E8CCTkJeySsc?k^}I#Q>PJZ1uDKt&+_IOl|S zv?;r8xruuTMG4^&u2W9r8begAir~RMaU+&oXaZ-(eI+Rtc zXOa?NQ?u%@qK>}7C8V?sK-dQ3yD2<<`pEx#Ayc_NZp#$e?S~+I99l8MBq7lvckO&v z=E0n4?3s7QYSn&l4YYRGDV{*U;6a;=y3t2pPS9>P85sjjf;+8&W4@8)*JP&jTS5p$ zDmtb$xPMoo&+m?S{eSC_0LUAMuWHW{(@fbcP+FN_$LyzHTWs-3eI|%zs6kcD^F%HE z{uK}vwRuka6B+Z{JhubA`*y7XdLa|jc-ZW7BNJ}&tdp7g^O9~QH#YDQ%^`BKgMF@< z6UoZ!K8?bOCE@Ja`ddtA$cxV|h(2mUtPIY2*?mF>viAn0zH$x6&!j-RXPOMd*5B8b za>pw)8;W<{%-!885q8G%lmd`!x|1uPzU{IfMKrGZR%Dd6^e4ojEuOg30{Z~*{<680 zhYvA@J}<3~$I}0crb&#?u0pK)oXbJaHJh=se@NnHT+_VR%(|n!q6sXGvTAucfB0KxVj?L#9P>F+k&aqVLEtcieaEzGU!+ zD!OJzzsU8~sPA2ZR`lMxlc!w1jSNRDi0h<2zg8yW`~`1dD1tmZ6zMKDAmwV@p2azZ zj-V+exKntf>pjtbp_cN~9T!sTC72F%KQDJE!GFW$rgpU>y$VfQKXUF{lhpy0JBQFF zLz0X`?tVE1_-}9N2faJxRtwLOAFjQGaPRm{feBv7XUMNi`g{J*_h(bP6*WWtIPLu8 zZB$vzQf<7xiESVMC#ugNulGSjiGtvhujzs!grPZhJ5=AzhhY3lo4IZ4oNB`h{y$m( z9c8~8;WilE+^YYxM54)^Gp^(n>{pWdD?{q2IbB0OF_)`c{QFN=@}DNYo>t^|%Rzf>`GgC*Y?QCEY_=na2{yjTVQ1CTrNozM{1{!#W;UFwa8(Dd#A zNht3uu9D6_V&Bm$Fy8Y9MWOv0s4^o!S># zn{tGA_w-YOys9Z{kGU;W3gks0*<));<+?iU8>z{lee&Zsj+j(Z|!7SJ+C?EByMR)*eM^g;`S%Q=?{fxc` zqA;j6WoPCCY)1UF*u?Jfcdp=EM-Qw#ZRcpX@htglbAd0nDrzqP8wBIfiCk!)idpk! zhE~%%XvNsgw^m}`7bS9?nQ=WDq0lo#KmF)ZLfkgV<5*DN_V@JihXpF|`vjHs%N9?% z^0G@q$MUacWRm4a3Eo$9f9Z}@{+8usGi8bO!gmQTBT3Aa zf(`gMBJLS1f_>`winsH08}C+ujj#F_`XKqbvH0tVf5d_ivTJd-L*+BDHU@L=7gA53 z^3WU=FdK>+w!4yVRl~a4zX16n(S7MsfHZC`n*uSmGuk#1w@G@&jr-Ab=LEA$w$T9k zrn(O|{$9qpuhp$-$iJ3byQF^QM(?>x@WvwD=DE*h2rtJ$GVfvf+>yYIOQ>$@l-}k} z!G8Af=car007t>U`D za~lqT6IsgpoPu@2bPR z1jo|O7cSo6(+F?@H_wP14WfqJr6}a!M%U%Z6{B zg7V1e%~P;!B$PgFJcn!=%%cY8h<}JkSgEG=wFu=G(>sH+^DKEr{(9q}6Q>a`FRNeG!PUAq!0K388@)<#TCh99oRtqeD z(Zq|~0M>H6Huh?e_=~Yd(Sw}Kp(F?7BjN0bwK?`=Rsn!NPALdWb1f&eT@9pO?NX~? zxvNoD!hW=$y*u*qA^OaO6S@1m4_2dmW;MBxXI#Jte(cxfuUoKni_D<{Hgqk zIHjM=B8%vlksXiuOi?M}k=y6k&+iKN8Inm>%EMTSHibhB;x^#LTA zU;EcZDKE9P2J3&05{-Tkf;vO^-_w%1r`JH420MU*dTiJ+LPWlJz_Zad7p#={o?qK^ zRW^j68+w0FW23_<8u6i6Z@UZIm#KX&>2pGjG4pJ8fORUZ222Wv+KcA~VAIt%$O+Xu z)ZY!N+RtS7uCGZE{WlIFl5A+&ZdFPo&y;whZIpgqNbbg5Ru(+Xsdxf(TQ*G}NBr5u zIvL4$ZX&MQSu{{rI-vbMObohno{P5z#@!Ylk;oR3IXdoI0HA;{24wvfIcGqKnh~~0 zIBa@xLPP^~YkbtuJGbDC5Ous_CAJrMBC*~im+`e7SW(#0;__qesosG5 zd-YaL5wOI$???f2uQ=cx0st$9hI8wC{QDZlQ=FaL!2nrFk(AG#xsA^}nE>B@bIRNN zB<5q6wYVwN1r#vz#?|Vu2%p}-w_rKz!EJ{=Tsy|T~oTtghRpG^v97-#E1 z6>=z)N^*(i_zPz|e5-VM9s8<;95lOFQAnA4zxq3R8DyMR?UL(=T-~ZIYZh?}_`H$4 z{u=(ExB5fQ>D?Hb5GLQvKWoLdiW9{gQ*(V%RIP6EbFu15pkWN-X+rQ?9T*E$}vQ9>&x0o)Xm07!LN=#@UG$0W$EvWz{IaPfO2D~iXS@JK^2FkPpnAs#ev0aS zr*wi`kXcx5Q0Ui}4;J)a6lYM=otXf~+pW-td(O*Ky}~xEReMu72il7|z^jfD-b*Nx z@ZXWhx0NX)9;H)X*{Rv4k<&U!yQB>BXVsQW=X$7GV=fmxK$nFI+EElE2x-@kR$I25 zCT=ctios@aG4vBzr>tgpn|lrX5cyygfQ2Itx`7wD-&}>^a)6=c=uSKh#~-yH%komg zZEWaDQ^B!X-pu9Jjqm5yUXpcA=m6K`A-Nmos%EFd@E<}9vlja*ZrY*o)1|f!q#|lQ znd&@)OP|{{2Gj^c*r$V&hR9wQ?5oD`q$eho_MYByy4(@C-z0O%3wxqF>JA$fmj@sA z&4d;yePn!x?vu&G<13@Y>^q#oK)yB$(NTs^0+L`FW~E>yoshs=eq4P~eSB8l3GLx9 z0(LH5Y-|V`TI#R+&3v)3WPlt_S(hUHzZNfkbNK(U7$Kygd~6Uw+^s$zAd@4K4KJ6> zqh&PqS0VYmUaH1EtNX83!RO4RQ?k2|5;8V=;wGM~D2OR)05qb;u8s zToPRV8gStGqWidPk9^s&?p_TRMs5P(jhd(W`JqMY z&Zf}{(ahAULCDWcq}7<97csR-Ur6-HEvJnJL7e4{|M>zn;TSa_Vjp7m)hy5{z)_8I z$VhHnSNr0SY?Z|;UpM1k)E*E3aKs&)r!{jEDZm4s$s1e$Ri#k6F+;QPy_~X0o&vxW z+bB>Str$mMBej+|L02Ib#31OYi2%LnKbF=cGFeV3&?oNnk4;+_dlqrkc*jAz9_yi| z)qL1Jfd17aN1+t!YC6LssXlKJObECXvV)(EsQ4~2x|dZLE?ujchO<0vs|B5&72VA> z9UT>BGXrGUeD%k3tbwZf7>_=-;(&D@h}k}>ubC@@$I^Mfb+%(MAWwg*!fRO=Yoi^G zNPR$vOVt=cHGt+75Rg=V{{@IU-qQ=ypUi69$r`+Kfd#JzYavpd73?-2@ln)GS=wDG zY!STQM4ZL=xFx2R>FDY@*3(kE7qw>&Fy#NX68z-k>@&Kdkq`!!z`z+~|9f2@RkuV@ z;)zus5P~(`t*{w)SkePc;k9YS&&^sPsiQI+X%Pt$7+OsR<1_N{v^-+o)%RKAz?SGFI=h_0N<8pXEU;%pN%T*^SHgvp00jfD3ZEonEH|TZqgy(j2)}9qoqJ%4-`ukS zbpkFWEvloun3mv&@?v{yPNqoC>i{Hzy)!mvg016)VAyx}jVDap@x}7@EVvY+O}KKY zLFG_xX2h|9;eK<)wQHfd@>y&@?DGs^=qg(^hp4WhLmm`ig!+4M-JOQYYyFKjYUW;3MdJPA7s5X)GhX{79CQ&ZRf#VjsFxm?GpDu4AVyP)FlT(xUZY3nq^y!T zzSw{A%R+{ZLxAmeO-5DJCdc3O1O4(amvLe0Qac^KodO&ZvR7W$+;7&*B+ILYmQ z9gSjp*wiv_Rod-wry-H9rh0>{!`13V--=4et5Eog{-zUGB{Baf_ug8gp^5Q$OpvM| zGF$*IB_?n`Q@IPXo~)z+ts3_L*K#uNerq^4!j60*f#`clxdtmTVvxv9;d7t4Y6Ww{ zBZl|p1S2eQxMqIMIV|{Fwf6h+uoRsaPqK2+GPxw(krl4 zumO2WUB%T6N5f`DaErAAm%LVtW`70-hnGDrCGqsCx|Lxy zwoWDSCgSM(f7flgY44;IGfnr5$mcUpYpY|mOYID^LjGPVPE4Js72#+>*XB=N58#NU zWWH~yE%tA;5QLD5mc(s_l0yhcQVU#&YF?h2xf+;O`O)DJ4HW z>-FdbJ5Ol0Cy%uUDIlwnrD*CV5^UE?X%+=T;}+I{dA~ZJJS2tg1JC0cL+5?cs{tLH zjixQIscwzg{_EY*Ik>|+@|6P&FGG86&OAP_vE8Y0#`X=Q-^QJgJmwn|L32XSU7%;*8RkoE*;*cd;zp$OZL_@nZR>JMvys&ac*#m8en{ z&pHNqM|ZhI=L1`kLE7c4Q1osj5$&Gd6*5G6Qx$Poh5la36KS_d&+P7eJsN+>piiu; zM$Hg^#58F>-LtMx5Ms4IluQX^+z;_xCn3emhTXiOaru5N!#IcI;5qWCCR(y7bh%q1 zaF{GB?SS=(wl*ckYR-y_*BJS6EvWbDN!!YO0c~^rO38CR7z7?Tk>@`vVG;C zc3Ow02>n(U)6iU&<{EMqFGybXr)wz7h>1nV7uMSUBSg7ew^?|#j0<1q;3Cu#rBc8; zZ91}CW7;F@7|8xmLu+=${JUY{Zo&^BE?jrzW65fL(1OhuuCBw0T<~KnP&l* z%egRj_RG!d;JH z#|jtM9^qalneU9~){U76_+&p;QZ4$F^1UBL>19~Sv-~a@gyh7v#XoFbJ>MLDocCXH z%l?2iHv$7yGlv%h4~1?gTBPsqk{aq)(tp=}v%-wxl|#S=$uv1$_i10bVr)%0myS0g z%{COv2PoU=C#X^FzT9>55_ihQ?}|i#4{@CtcUz-G>Te9rp>=j{8l}5GLp~R`>rc{qsJ3|+bc*0aWFeXo_ z%4UZBjF^d{o*RdqP`2GO@jWMivigl=cE+G-3!#(!4RQO*9st)Us;v`(tyEtzU*;Pho*Efww`6i?Hl=2 z(Ado5LgX4RVxLPhfyhcE3B~^?64Gu-I3DZ!3?XXj7rS*8rie$-65EB4DOK!4B_xZ7 z`QiT3+>+)gajx%@h)uAs-L~xiHTsfR(alSA*}+ z&j$#}vIc;bp7eh0$Mi}pdcZTJmn&ZSdR&$l8G_}K*I;{Zem(q`;&irz(@ORyFY@JIky62Nh zR&N#vs=9&^0|+xkLUZ3L8R`63!#s^Lr0+Lp3$|6>msSI9f)WG1 zznVGzYicri$6?G8vh_67|7L|S`&%LDcsRNpo;up`c|nVN@9y(pc76=@;pvOZ4qa(t zd`slNUpk_)1f`bJAmUG|L#5oE-^{^fHxre{A;j~kHLm5SXs6r(xf_!2xwM#+<3-W! zU(8pRmX||f(Q#vu*TdfA-vET?!(R7cQY`feH@_7N-Zm(gB)&eMS}>TZ{f*L}-v|9M zKfWM{bWi@y%{12>8WlTOgQ)W@S>`}VS3umeTjl`nr|*fRBN;Jv(|>&>I`qS&Q$8Ro z)GSK<9X{uXF1jSYc>Vej&iLEtRGgK=wKOYT`uqBy$eSY`1W`(B(RU9QbH4m)t?r-O z$49*l=%Sy~$cJ^YiNY$@PMj&d8r%E^wfwC-LFYvsN6!#dI^$@V^0DA=gbDZY9THB{ z1~w=+4XUQ0(a@4vH*5U&cPh<9iOTmAu`Tc3DcT|`rs$R-ZQ|^jB0=#2G1xMm!CDl4 z;9X`-hW5qhg~t9k3NcpvE>r@#jR<7K+iqrRt?8%X^K$u$sKEh4ym9H-B>9o=19MaS zo2_rY`GXL+W93!Rz-x?CfN zqPw8)a#>}_m1`)3?^%4SeKAbgG^3nr`aHFG9f99i?cWXlIa8r^0l(SY)#Ysx7PvPU z)_N5zUN!7s`eyH4zmex7%tHIwb)cCp);(6jX-!+l%LEX;ta0g+t>_xnz>q?HLR)dT zrM(s{{DE(M{=y{HlN=vRxSOZzevpJAm9`fg4H45CW5CIf5SU&l9KRp_HQUWo>7)=1 z7!9Di=m#i_Y3bqJW)bjB#nXEW)uFRA>dfxKprUT@I&CRUH*aW^>K_23I8m@G)1$ie zFl~Yk{h5W8DfX&$oxa-x(d|P9k`Tx%_V~Kg|IN|9{l4yR#DNf(9bP^TNwaZF%7d#Q z!W|;6uetlr%vr(7n=nvF&8|E~_e$$Rk3P2<748O&w^o7nx8@r2WP}oKymW~8>X*{j zdCL?*kioUhchP3TNl&l5B*sq|+EpUdh%MRZp$B}Ct4j0Jm1V7`snWq((Nl2HX~J9w^mL2oG!`_U}E5V;i*#X9fKXX2UW%KCvI+OU4oB~ZyO zz5OMWd#DrxGrD`74BgL85v)5Y>d07mmttu%BCRx58h|TsFH+i=7f>X$TzgVi{)T{W zNieYlGYQmFjrhn__!uI25}IEF#7s|3N3{vg+M=>Tc@> zBO#d&y6a|j9@Ns~_R_AE`0cr=nxCRS1u6TFg%8xDHHYKy*E@V{6raAlnOQXPufp60 zOp&M*$bpvT4!v6}WIRvjnu%mUC-%0s62piGn28JTmOlQ$&YLGL9~-qCWw{t7eOx$o zm%d|Co}t1uGz&RYdxJYo@u4-LvvozEJ2M;Ej+uVfoMqZ_F19~BWzpfz?i|0hRK#al z55lW;eF4ZhKVsK|D<$BM^QrLw_l;uzVr3QP^@_yWoEODKtmx&+lD7+Y8=O@g?7Gho z0m_gJe$Y9UnLC6u72)*f+4v5@ViIV-dwXk60c5u?`wPU{FQhUE%;;fPV6*&NZ{1LiX*xrr5%fQ+Md&KwX-~MKuaD1<;K-y?Ls7S? zk0~z2x5471WeG|-Gq=wVEA9?Gl3y}Z6C5ip6A8b442-G^uxpA~4n$gJ&B*5)N zE~>W3*5>B0|EaQ#8=D>8Ay#G0uS*3gW_XKcj1}D~Vpq{MBg0|qu2`-xI`gsiF^k#w z9#M*(ooeKaNd-+OS>{e@Y!_M4wD3&(YrgL)BRrD}C*rVg7-|P; z5@1DB{aih5bf9N+Tg7ciNcy$6YifQ5(>Zh0;kiKY1hQq6$l|~4+3zU@IVU{(|Go+S!=0>AH;=~TzdsS9bWz^ZPJP_pwD%2 z_V2l);gFrgG4}89O`zW2Qk0$uAUb;|5UG2b$%Xj&=VIYoytyE|hPj1S*K>avQY86* zD6+~iE=FM-Ty*+I^=5uUWyZUzOs_mf{ik0XxX*ve zR6V-Tk>ZfS^`V=MI5{99&Y8vVfQokf17@thgMKrLtJasH`-|A!xzQxKWq$=v1BG%0 zZV@&0kg}kgi+J(MB75KluJt^ zPZOS^D^2yQ8MDNv+pS+;<-1T?D-U{RPfl>{>rqqH?OqpMdn1Q_X_4$==gV`84o!iN z;stTdB5A>qe$l5|^Ee7Oa<#wt-CT`WDU8?y7D?N|bT@Kx=YT~5AIwbP$adN33&B#ijV0BV~d+E;Crq zwR>=}6JRY*BKXZkZ_$>Liz+@1nbyweL4Z?mEQ|U3VyQf&kAMB$)x@e1m#x&t!@{1l7mfn{v=csfrK_U5FnqOOD)s-0tX0nP^EP6-`>*hX zm6$J4wLr&^xsR!-4_@s@Oe^8iJNoB3)>_kEwY-zZ$*!LD-jwk^;M*G2Q=~fRoHlSp zSh;FbH=qW#+IFMEZJ&4;ato$cCu~ycYb6ZCiF5ny;;Xnirq8SD)E?hn%-BkcY(dq~ zTOG(MfZD z$>Np2vw)R@$6cfzZpQHIGs+DBeQwYq_TMzxo!cZdZDzt9m!vFGWE^WpFKJraE-#MI zsUiv8Dcdw0&5?}q@8(By|cg`y%&cfVmK2wI=EN5;fy^8e~u^0&n(AAgLWY&oY%aQ7O^4f)Jf zerk+E_gfNDg?7Yk7DE2K=du)o58ZTedk$XnjIHM5_Jm24p%?3rs_Aw)RhwhvCJCE) zmgz%X6$8ggcKIQy_FBWxwl;{MhHhXiJ2nG+m3pwDN)^wRsDBpqJ-UP9tY2;5-X()x zhT{G#kS)Vr`KxrE_LwAe>+!XSp5B28+xmSfiyMwQ{zm)aRNQL?sa=0=1)ix5+{h0f zSN|yi3w^W{9qaSD>RFz2O`YWCo)zcb2MKtG&WMY!>gs7OcS++{PVa*b{*cv&wP!m4#lf?xf{P0fLzi`Zn${Tu)STXxTyi)3ad61;eeQl>7KD!n zTnJp?@wZVF=Vol3CkK17&A4=h0B;SxAR7J$es1;NxKi(+;g+&)0lbQT2J|39qABV2 z?;7dEu*sN)4J%~_!pGl$fqEwoLuEJknk&s+|NV1w5j5e=f<*VEMDb>Z>g3Ze(qfsWiz5PJ z_NU90Yh4@@)#%l}j zY6^8nMdvfj>gdZl-%b&P6?J_@x30m2)%^?z>S|AJ0mR&5wM87lTpOS98XBt{@%G{_ zJKSiK{#~+)#Dl7tw~zvxga0noOY}uHdX#f9*aW!a|I)5LHc2V}<>=Mw-!0xRx{v?G z(kC6&FjeeTaE%un$7xitp~Tqr7a+5tpS|aQHV7d~ zypu|J>Ekw@!Bf1O(Re0~XB*FIR;CY<*Oc(R=;}F5=`y>%U=a74ja+usXlvS!52Y}H6KK&1Y}uZ??SJ07oisL#sji=rdBliGu2J3Cy(c(tzI;$ohOeyn99vtRQi z2?)QR6FBDv&@Yj_6gJLHEY#_k%xxF)kAh~YRfpx~!yAR7qhC2xU;Yz3KJi(HkQS5P zUk?jkjFadypp}Or zhMQySE3KD%!X#UJ@GjXgCSoHMHteY<>)>uBdgzcTv-}LC-m0g3juN3Wz^i@MXNp%8 zX1d>Et+?jDB5MXcsQ{=7j;|g)zEw5)Sv8Q!fW~!c{8`O44GI_XOqp9gC@euZr#r$( zXOv58kTke=0fS4b`_-*Z;g;ic+r*50;wlx#`7v9HCBdiGg_E1L14`$fRiKw8j-5~06{KV)ibO{>jWIK0|{$f0AqCa<_&c#cklm{ z`Gn=G4Q&O@v8*;0RLJ-UPLbhu5I+R{7aLcw^@krpvH`u@`EkZ<%-mA&d)Lz* zJ%%(pPYZ&lH+?B#PMm~a(vV*Yx$m%3s1Y*34pEw^H~uAzIb2UZ%ujWWt{&;klXCOf z=5e%tcFMQRj!$QCF+itF=}_^?GW%z7;;G0a{^^k3?Q0^0y{OMWK|kwQViJW4l%d|# z=V7dh#^*cf2Qa`;`M>yWoD#z`q(Ls48h_E1#bC`&UrFoc5>peuqLoY2lx585I&fBG|nBBxB{(l|6eSQ z_WHcA8?`X$Rt|7Plnu2 zTHeO>duN9YLppvr-f>;IRkBia*`)&AEJ^3qS!JawQ%}#V;@?uj|NEtX@?}xnM5r<; zU`kv^0@^nH%`I{_xZ9QcF4exGwG_y3*2N2YCb}jqH%nNJ@Ql$~mw14TN@tG71* zP_#{UTFUCGuy!Yv2 z@G(AC{dK#1zR8I%Cj@z(ctVI$W$)bgGM|8GL-sK6s8poUnXO*}4W0Pmp5%R4Y|8IW z+grCyv`uYUfucBiy)%Q4-gkJe8YvbLOwG12=PYv_jd16k*kndT0Oot3vpfyGepv)*j7 z;a5j!^i-@{WFK#cf#2GjcOp3FNL94Jn>LhYV)jJ2RCQV;88B!FH3;EaAx9i74i=vGn2s{mK5Tey0^Zs=eUh)$dxZ+_FM5$#oYh5B=5XdQS{vI%8nn8=w zUHe!PyA{zjeDg%1mF1HWsrXB|6<(oZ!Wb@>c8ZhY6zrs#fk(J!s6K-Yll*E1wl1NBvB##Sjfvp@S zYnH=9H)Qn*C#?+@`<;J?*<`xxoa{SwB=f6Z9AZ8YzF5(|^2DyTRJuYSu%^Hx4)6P? zOs>ai_;EhP2-I<#p(i$WK2uUw*g!W-@%eT1&|}Kdi;%7D-mOw#V@SUv>ZzBR;fR*gv6oKig+{Vt zn63U>rnc3usR|DbxN@csbSTjisTt)C@1|Cgfb{NCY+s8zPpvJrha3*C67bIOrK2Hf zHtv1LoO{EGUcIIoUEAkk{U*rGSIEAOdg@FQOt3`64u?Rj)0s>HlAH?HUsn#5+ar@) z#=mr0hal2r&+7?K{)iX9``GwYvMJ8!(8t1>tV4+xHgq==y?<4Qe9%24EAZved$4iU z@N_OBxpAjx=JFhzl;(+r*Jf9^j?{Vf_=L3u5dkC(_Y!=c&c}RvbPB{LCF6|gnf^E%ogWWAr& zd#B-`9+Q&6=n*AB&FCFS->kUU){`fDbunrHQGVCiiPmTB%NG^yy|ehSQ6Zd0UDv7O zoWDzF+$b5v%syhE%yV^FH9D$=SCllv(^ZM}Q+aLlY+zwWUn=0-pRd6V#`4JqPt-D6 z2$Lq|-<;2J70t`x3LYEt&LzshBld!I(#D*Kn$Ph-YdJYto(1*rowZT`}w`D63*jwmo@wrt3RS1=Mw&pmT^$I?7@A{PWj*Y97z~5}W3z{o9 zU!=DC*vj~hhV{dO?m6K%p;d)zmzC8k7Dcwpcx<(0L>TS~a}yYuMe36b-J_+fE9jc0 zX#E+QsC(83iZ1D6U1}nsCf99>J+5Q0eQhSfVRguH=>>4H|DWTuwGEW>Y!q;OG?Oax z8dJ0G#F!+g5MdWWSG@wZHNqHrud*S}HT71*c7RSgNB$lz3!`mwUEc}o`s3i&}#I4f&m&mA^z~8nb*p7|BeG?BFwoK2L@XVP9vyX~G-w^JX}F# zGKSY2lPhGykwBC-zhxPrm1|kMG9$nGwwn?s`uiH}nzb7rhBo7CH+eIn)JSkA%jQ-> z*@E*-@9%~VxBJ!|ejTC!PtrTlsd#6u?3y?$bup*)+T+;XLELvj?48*sae(4<+*vgl z?e0DbpHKb-e!u%ztnjy1DYTTAlS}zGRFi0Duf9%8w49120j#i$nHcJC{NJFkvi%{h zbhDtAqap5DXLw;r0Y>J~4-aGil7`uaGo-IN!gkN&6t;ZOuWDx040P6zZ z&)Mczm4+&U)Pk^w$bEAR;ODnBd(ycN6LmJoeK7^ywLXM8XH787ZO^+82=l*}jK0eL z-53Nw1atu1OO}?idwU}yNfM$tu4otc0FN}EA~h^s$pDXV8F{nl)vFEOM;~^|>BuC&a=dwzenDnBbls=VpExtajV=Vt@>R9KTvi=so!+lS@ z@)SfN@1g%jYZ2V&Smk)-xk6S(WZX&%%P~^7TZ(@h>W*p}LnGK02ej~za&uZb0)tNo{4@0(cLJgZjq-W2C|z{D6?d>$ z?q$}fVXn?4hZD2)bk@5M7%=fn2qd!l7UkQQd1ABF@$YU{ga*Y2J;@8W4RKktHd*t* ztNHsv!Hd$rJLGH-kYsP|cq{fGBZ|@>qdI5r7nG;P)*n%LY+olRDiL88vRY50H@S~^ zI!<9zu;8q@r5hMk=dT36xN%*^^tj;-(?WX2y}6nNXA)2>eLT%O=~<5)0EN$-MTVE> zTxdN*OA2&deI8-DLN3Zh#{xV%Cvob=|6n@FuNLoz&2E{0O3>|Yg-XgzC4%>qkVCH# ze2}tS?u>2V5~C{HDZl9&UBOM5Q&dZGnt0EhP`C-~oZM4v`UK&optf~z6(w%GWyV)& zfSjq4O6`dUkP_3%#Eh8asXoUCWqPtxlS3MW&ms!>JgS8n@zXxt|2GXpwze)jgw5P+o(HFa637^LVimfalkB(WHD7G(Hf`0(j#iE6I$f ze#c}!K%oi3vK868rU28TuOMoka-84ifL>zSH5Ai)e0NuNRbXe|qeQ9a%s@kvr z+ZICmXetTpO9+xa%CmF2VMYaKFpiibN4KxTHyw6*7q_O(M;5LHSFW7Bz|h{O1pCbx zmzr2`#+J{^${cf-4c7DFYuFiwBIy+=JqA$XqX%(ocD%+`EQ*pb|K~`mT3?NFx zIvTG29%T@jo_zTQs=6Cpha1Z}W8K2CH^`*#7)Q zduLae(_;8rm68KdkzqRrlejx5SH@olw!0&v?5)5N{@ci6DPkIMySINakbFm&`tM2~ zhPwFGE+lsFDg~^k?KSh?{ux2e{(QvAZm-F>NV5M~E|G0Y4~mMwN%7veOjtenrJi!` zTgr9${9gH1j9KI7zQnSGd*31@THjlc%9pwfv#-HNz((5ZFAP|02ouFm?4?+Xj0u$4l zRVl1wmB!5r$oXwnL4AyAvVghtjWc{cKhUmDe)1um!&(k|^?B=~1k=@X$T$FMIhoii z$>W+XSdrg7@T7w9gq|!YAN0XAJ+-Vvn(SELa2I<vM&BkE^$lnaKh1Ed z^>lo;^r3rn=k01pi*n%Y*kwci9Had`bbh)oVd~#%a0f0QZ}!Q5{CS2UNeVR^#sBh@ zF(#e?YlD}F5YbnhiJFTN>x}48y8!UI2CPbJgVUY5o)$`;W{iq?FLJtog*mzHSBrkQ zf+}i8s11;SqJWZ_&Py|?^|}T3Pd%0hPo0=c`EEPzF604iwlAl&e4L5LEqQk0j(5dR zy*u%Dd;d03surHef}*DeUBxPh-rcc&uSA){iJ`}DS-%mBjv7zv0uWBkLada}kTk%( z>SLo&mssLyi*=DnYW5L9qAwv}+~NXv^Ry`{$~-==7v}YnM(EPxfI>)7xw=6d5)y;@ z@qS(2-Aifnb9o}ZlX#CZ*yn%M4fRpd6m_zq-AZQG%)h6fA3N|$wt|)gMGPhj7IAU2 zleH;DkY%mKSSuySiCS$HpCF~wQ%PZ+o7Qf4G0eo7Db9&sPc$V+!AYRETNk~3gTpu6 zL?afCb;U$AIzVBnK9F`XK~&2VpAV@i^BJ0>@+yJK8W+(8v8`p&;O2f=1FT!MdGUfodxM7HZqw~djhKpV;h)7(MJo=t zN24-%5D2ee!EYWft*+_IINO8bn^U3r*QLFzpttL?{TbaN z*r$k;b!|42R@ddN&Y8W!ZO?c77Ih3TU{^L2$Ht9I?0;En$d;D3oKv`75jMgU#22zm#+{rD@l4WXlQ2JKRUdM=%FSe7(*?ET2j2vNq9M0O{o90B-Q_EO=13@+KrX%4)WK zz$set@~6eb?vOGVN9UoTgo^5TV?iLF%Hclc;e`_IY>|!dpSiaO;YqGMIwAYvYV4l2 zD+j9WV#mcf@83&SyS(lWy|P$^kg>(J;{6G!!XdYKWtaeIwQanDHY*FoYcc%fxw&8M zbh8vyQd0mW`m?;FVBF=Em&@i|@{aGLW0R4C779WA2@qbSALJtRJ7YXt$}OY!K98qF z>+eqa(HfWL*;z`3i#Q;Ll#L;*q~8F0<32CU-y@`I00e-}{~p{4pg*Izwr!vQFP_{G z{A!@nF`K+B-qHDL(-NtG`d#>)TT(dxij+jK7WFZPuY*Ef|Fb-mffJ0|(XGK-U zsTv35Zb4pmt~pXjI?po6Hlh$?HTPwbbft5mBLS4_o}(?A;d0T}?TqVSc0(FGiPo+4 z^u1uD`D^$;lAKWIQ0KiO=X9apN44Yr&X3{V-jq>R;Zuf-=j)|PjdvV&Urcxv3;$GV zaz{hXN!d{PO_EW?R|&MW^aatzO>D_yAeS+-1H&qWv-s@ zFQa4Sz~#HH5B=;#nHVhJe&gN_Rza7zJF$$ju<=9)Eam7eb~rZW$|rmi{sE1tKnVSH z?XjE(gjFhb$nu`CAUx@>i9m^fdNAh$&2?{mtk=c(NZD;Y)YrDLY`XV)Cc z1B#2~+$*UjWBtXY?I!6jqhw9>@pL8(u4JrpXWMDO2V+W#E9w1vp?yfU)pzT=J8>!U z0?@hlb;I`fQHT}cV}Z&v9FW1~rrB^A|~8Rq6HJgYdJ>-S98XTPUU zcsdRYkGpp`8k&mwK8|lBNIAy1Kz;M)Y|o#Dee;~~eVYKYd|_ZHSubwWW^L;2t5O%= zjU$Zd_5(p8hG)v!y9?Ei=%u0++^a`)+89{?qpyiDt-NY%`DmcRe*@UewYE$C%di?= zeh>E&(%*U?uDCKkPpL>FhoApRQ#doh2QjvB$4Eg$ZU(pO8}ExE->AkfcVx$cjc-#P zm%QvcXkMuXH4TugpGYhl9?-0~b*(Bvw)iqzW`gVMY`p>{I=^I<7N2_m= zpE2zP+UB9XPWRcDdx3Lr9HPX-N_SJAS!49lOLhu^e-1iH+n068dU@f#M?O3vZ^Ix2 zme?M_5N=g%SwiTZ7>mg-&>xLQ3Qey$WXk7M3c?#uYW}pdlc5tW{jEzl)_c45kzQRQ z-$l>evscF#omYGAP9GHn+fy{V?A+>dqx4$IU2fDT2f|jTvFQD9#XSlObfhz?rovWn zO};hy!ojXc@yfWSM+2T~NhKl&5lfM;5%7zC!JmQ3Dey#ywg^0%5%tDc(H*mpq#s-W ztGR8?z#Pc)f?7`OVGd~J5B5jwH2Dq&@#-giOj65WhJ+iz@jlT3MH;>ks+)3G8Bwi) zM)?d$zXNcN7N(@}#xZAuln9hNkYs0;$uhqhf_wWXe#n6zWz ze$byQJR34;@Hb)il&@p}xMs7T8ZLdizvXP!lTo%-U-{)muaDz^g%iYHF7c~y-}xA} z@4(s%OixrY;{mLQOllo)N!B$jZ@C1}Y5)pnAFoJr|5Ig-s-=K(y3eHOnr;#7wG@H6 z;Fy4-;A5++@_SQ{*tZ04c}UG@Wbe7z`S){=G;Mp4a(4zyn)J%sd)ly1FRKo{ z57NAguJkXx;~5W|LSJTQt`}Fj1z&d%+d_UohxLyJ*hcw=rc{Bjf9pQ(?tq9M0uyUGY&+$R)!2fJ_Uz%8!k}2*5 z??w%(D)g@ExV(Rwg-PR9dg5KZB&pG)oXL^iE?oPRi>2jv7zDDOi{|U*~paVlhWEDXxrj4%vNQF!y8zETdbvspt&Q{onoD{6qNJ zkbFTY;F}PkK)%(&pT2VG#-IF4iqaQ8|5%V8Z*st8O{C0z)P*PB@(=v%_WGwhCB@0w z#M8Z7$X!dV*MBnjc`R^d6^EsE?{zh_>AO6tzu%1cdj=!&$YnzpdfJ~emI;&7svD!M46>wEeI7FL3W%QJ-fPmrN8|xvIg)m z-GIC^csYKs=B9P*C0tHXocE(CPTG3670J@P6R0^F$WHDOMLd-*CvfL7U2-?F#t^Q{ ze_BSiG|pO1aMbnYHuo&5p|lNJ{ud1vQ+-fwI49)H!LJTB!6$$hyw$R*XH|vjYh5cz zmoFcgB(_7XhKh)~-m1b+GJozX?|+|aZWUhice}gc**BoB+y%({5<64<>s%q^akY*W zEfoogwME5E`RSl>kgFgE#}Jg0(1V!*2JY=($5k0!_?N~q zPseyCRcDs~z;s(S>*)HA@yJOmmB+p0CZz$h6fA(CTqiWY?GY6u-O^uj-$&_ znAznT0YPEZ)$JK2>a_P+M<)!t?Q6e^JELzjdI`NFVYXYo1`u)B+eI{a@{TxX@E+oe zA&1Wm_{4=YX~)Np;dwOngVWEA8evX1j%T8E9Hg&asW3e|;v_Tn2(uD z9!j5fUmuY^OI(gmDKpuJwx%y+cYG1*n(#xpr<_|VeJcN9>)$b!rQSqs+Fi{Mw+op_ zWf6Bf6(x(31NWJGPr$M`iWL8tqH0+W=kE@c+w1RNYHk>nR~N$Stw1EfI_s{FUYxcy zA&aMvoiPaplVL{hmwwKVl&dL!j(a8pX>)U5iPC7$F#l0Ft|Fq$OI07#J1W?%0_S8|xRG&EUJYGNw0YU?iy6BsxrD7WyzY0N{F{n@+ThI?K06q`fS zez=H(v7U}lSC7>UdFwY#CuL;iWG)is`-Fd{PXY5cUfd2S0xP)02u$>n%LeL{rN> z&&y;f>=Dpyt2H3wN>7TNrs{0`%>da#Jw)=83IwxFQ&cE-(6H;th#-g)xy_|71nw9g z2o*apUY?b)E`O&h&B$Y5vUW(_i`9p-N|0qsYQkFv1*2O&fwgZc9LB^c=oIF+7roew z#ZRk=&OjB<)%FD^J}Zw;&{?}1py{LR9Qc!pR(uwVA5cgBrT(a4pXl2wb8;fcG%EC{ z5?4zm4ZH2)c{7V1Ct%B!<<{#Ddj@l=_r(KV3Jd@~2jWSYB&aE=`>{}kQ``&sb#cZS^%Jp72#+(QT@)sa>+}c$@tAYs0ws-bwVBbl53e9u17Y?D_Bog0*v%VnJ%7{D<51w;9dXA33H}d>K_-1KdVgAPX+{aoDO+3h zh2G3ksY4-Qw>lka9-X_O#Kv7q32w_jIj)YHRJBHtV~6+0>%>*8{ks8?ryH~_51yCv z#h5BTL9N`kc-0~d=i3fRHr?hEl)jC&keEz%EqBAGSgi|Lls;&4jnk2G^_! z8=pmhi2|bc4uCOHJ629hk$`HH5!}j^#+>@fEiI*+{Zin5*Ry5SF-^*7n$?Bb8(4Oq zVNJ2-y5N?OGy{i4yr;9)Sh<#(dE(2_Kfa|6F5=^R1xjKk)>vE1YB3|hLKWARZ;r|ofi#Qt{L$$l5?Q#Gy01zm>S4mrk; zZ_)l0$Z!|dL+%RPZLA9+aX?P{LOo&3Z10<5MVnV79mSvLc2duF#J!UHHextZ)D*RP zVOCo0^Q=nL+sf@pc%~^nd>~KY>IFgUj8E+N`SG2W%h^_$ zjMrRuYfadJ|Jy`0Qn=u6SvnyqsJPBu{iZ9L-dAzM_a;Ftb`?Vo-deK@53~0VYIj$#*gZ2%;lkKc_lCHd#*Zc(!x-h-8#`BHB4wv4?5G&v= z?i{PTd97m199v(b?uRK@emZf_kCAl3`6>Q$?IDj$@$@s5ye{H7Z=fvd32zwWiT!6w79a)M}zbWr4eKFWt zQq7AF+@8Log*AGrz9?n91Kdb{y{(%cCQb-4?dGXNoeL zM~LBpDma6!>XgQlTwg9ysbb19g4@FQZ?@Eszhv(1p7`j0CBT9z9F$8QOmwtUT8c`z z%AD0yTN?Vhe!?A@3b1MOoDDYd96 za+1c|RhY@9?a52U8tQ$hkuvo_w$zH~Pa6l*Uokega(9#*@9e5r(f;#o6&faL9yf%!j@jG2SCkE?3jXJ<&)hVMYVx;l0=!^K=gSmx`^gf>v_hqQ%;-tFX#ob)^{^6D(iYa9JLvi6WA z5b^d|>XOHiz4%T2Irf-K@rvc`HiX)ob--fokRQ?reJd?{Lv_T@|8p`pFDT$O$i}SX z8>xM_(@BcMIv87?`P>RMj^m<|14x>xE*+O@gHF72tdSp|dHK!yB^o%}_DW=s3ZGpW zf^%))k9T)3dh(6WN4A0&E>7E>J~WNERa5l*qSwb1U8iL{ zefFJA^Nd;sz!|n$hU;gbNQZ!Z6ONpF8VPYMjxy|jKN+57)Rz01A1};IWkpQ@&7~gT z2;2~Ue~`f?Ceg52@ylsUi!bSx`D`zs^4X39h&jEClvuRyq}}L4rCz9|mASh@IF1}- zg+6zL>K&S*2Xe+cZ~tVaSnYG5(|S1m#HD4`eU|VQsn0v-nq>FpY*p7>9uDD5WIBh- zDu&^yzrQVqq_d?1*InnllJ(xbOZIf5Rk&1>o-cXycYjB^Tk5%We5+(v;|<2pXo#u^ z9~j!>R_!s+%+Y3@3S9L}g2=5F%(isQ9WvpRu5G3gYRGjt!CK8HH(YmVddjjt7gP9z zE(P9%FHoGVX5({&;qVw>0wqSxO&*fU#8>gZS|zRfrxSz};Y~X#Nv+%I1|dA%J{%iI zQ}}HQe2KEU0w_8`T@TuDl(Z*8-@7(Hm*~TKkLz;Y{TdO$AnrbG5u_ER2WBtOIpasy zD|erS{+bLa-e~yK=3irPojZqs_D*mo-91MVb+gqPYEBSd@Wbeva zhP{E*!UWMp{OfK0HP+*Ll99gPrp3h0|NjqD_&`>^K{~c8x=Md%f72Na-0cFlw(Qye zvrDK18tt(iv0?ZYZ(#9+87xoEK?3uAAy-n~!g#qQfs9=-w_|Z%;5y7Eu@t4RA+kxCWtQ*HuT3+XLE?gzfMe$NO4fWjB>KQW8@!opJUo*?mkj$<+ z8$aWZ8>B<}*?nitpDUhfz4Nn{d=Fs}kTj4LWW9sR@sdUmW&?O@xb=S_WW)?Rn z!HeWezi)0m+wJ^=Z*rqg)Cg*E4mrnu*EBns9m5IiEIyUmu52JyUrXLd|DbORgnWzs zv>G{Su=VpnR3#;9<5R87hyg(Hk4|agX9I~%zpHS&#&CV_h$Mcy8o6Crs&YqGOt4m3 zy>x^-zBXUq_IxK|udDlD_h+fIdVG7zG_XB@w?9Oe%8LeXzw(*}?Nw}W5ln~5={VpS z?s2TgJ+9YIU&zN*)7?U7`BIjSZfs@6XR_Qs?T=KKIuReQ65Evv%DCA3Xz4Wc@<^6k zew>Vo!@?Av(T*L9jixyn)q1VJW%$+E2eg7sctD{o9DXVmm17}%5jC7VMh{dC+>blV z7W`C;f{95)joPP2rdQ>^r1N7wXbA?q-u9OKZ!gP9D1ZmnjS75{BepGgFf$2m>+S?70*o~+Bn{+r$sJ=bs0`8wzwt$1w4^mk3c^i4gn zcI}qHagkU2$Nq!&d*#p9qlg=YPnQ&)>Igplw@S5eay)fO)$6TQ^`gS;SGQX9brBF6 zx?3?6faC9NZi4E99S*Ztt<=%<1NuT%0f?CNUWl3AFWgilMx{LR3?igh1tsj4%= zX|;&T*xG>W9 zfmemL;5=cF61r%!GkEDZ2=`iN^rEwwtK^dX%RbqJxlECRU5!$D{je38)rlXIGI*ER zrC5##5Z(*Z{j2t!m|XwSrN11S8LBXConK#Wps8EfLR?%SmAjD z648J+KH?<-q!oZ_X&8gLY`o^*nG@l_y#j0Sb!eUq-L2N1CyRI7Odi&vbC9AVx}i+=mGB04nro-6|eujkRTR0mw6Sx z_w_sYtkq6OjOX#`CK3xn#wS@wMyIEZ0b1jzL(j|*M5Cpm>6vASX5)R8cQbLAg5-CB zh0z;C`^ebqDh>qLU8iCF z1Eiib$sNQy|Q1M962X5I!|uW9uzM^TpNTL=>p4a+H= zYVi5XMS%r)Km)zy6SW$O5p8Qe=Qsa&Dq)kMIYX?WPnL!q4_?87_Lb>^Ycx{4s z2fK^6EhAyiUE1l&wW&(;>Sp-9R$Fw4)L9#QQ*TR5+LlAF13<@I=bu(*m$R*l^leD3)>PTDPSa;kN*$<j7 zTiB%#G$g^Ks4C7Bt-XGFe}jyQWEVFE&`zAhAT)sGWV zB-TfTP*Z_|+NQB@DOIrL1_e?}l~z8%xo%~u1=R}CYLTRBeaN@UczuFoJ>K!@WjIbdX>F#LcW?DV06ib!>hbxB?0$} zuHT`+-NJVjEa7|{+HpTmUC@M{=2mP!8}&`}Yz0bf3G#c$6oqY0LqW)4*ACR4_IxMw zBP%%HcNY}NtQFnA8Aahq{e<2JdgDcxHBhbkeCnB5F74BLG%>+Fq~ zOr*Z;l@xN|-%FR46;5-KyW8;SA<8o>~x5$)iNWqV`5_C{Hfq@ygqUF)c5KEnu85!X;7dZ#__p{u>q?Qn`7H5Qb36Ni8wFL{P}gk-JlZNUEnSjU?RIvC7ZCDM9Z8vbTpd%6_L5Sz z<*Uc^AG2tW32$F6i?Y1q->}_Hf?I_tbk>K~+n2{-CLg484c0x4Eay+mNLB&`-H@8$ zRtSpZ{?tzA_Rk>nZeO3(ft@{sfOL-oZ!2hpfV*!m4x70_yG`{2-!9FCPUbu9j257P z?1Zb4c8EE5?gV6FNnw~u;P9O1Kgpnh}{##4E7`*&Ql9mp~!+#_C z5Vy(ngqkc1@!P)p`$_!bSlCtICA{@p8L8V8-^9K)7rAx9bphkGU%Ec1i%EZtBs@Q8 zKIuq;N!AO#2_7PR#!ujVm9?uX#1lep6aUeDP@>yP(EF66@21UkJxiuud`e^OWc%wF zy^J}>W_u5$xEn8d1H%_oOEqIzSHXJT;2y&`=BL;1`jCznTU`Ykw4ZR~7+8es6ZFC~ z@Ug!DgdPWksB8gM=M{h5R|duwZ0n@_txf$$zRaKPhLXLpf=4xsqJhvd(;}ApdkB2p z&P#R4z2$2C^s|zaJAKizg!r-$ETL;lC+T&~&;w@B_1-orDbvSSepbTiD*ejn>{9QX zPzfhL(lbqb*_1Cz?9_%Eg)2D^j1^a!?cmK2$0A)lXyiMF8um|RYnz+gV5;6H0P`VJ z7v+$7{e>|Ylg#NP-D81$qO3;s-f|A)CrE3lK2v`yTn^m zH%(@GeZQ~)zOh->r+VNlz`C*_u&v@oEdM@>6OiH1_=!4nfOFG?-+oBgk-AtN9r?A~C)gUGE^WrX%QjYJ-E9bZHI`idRz?&&s z$lkYhp*z?aJ;4P9jTTL|Z_IjTwd^@VUu(g$3NUV1BxF40*_jWiv{o(<(@dh$Gnf=q~ z>5gy3BkSdT)eGd~j<}D@aXrnldw4|2wNXq<4P#iOOM)f+w^*Xi%K`44yu^s2jdSoQ zfB|&;&aqk0N)fk&Evd1Gz&;Prry%aO8S9rlJOmu2;h8#Ay9Q702 zBd2>VQS-UJZ41=A!7Ac>qLK?uAII92&i_eh*lU+YHtmV+M_Ucr;EctP>*f& z!L99oW-k9;W7qJp`FpC!^nTmYf~jkBtNhp~*I^{X?p)?Le|Gor^J?d+<$J@Jo0Twx zmDhhji7lzaJDC^LqvaMb)4g3ZXhI4&F{TpihWj1HG|r1yD4+9Ep)31{k3+tB2o*l> zoKDY6fH-5OAnMNO$7RlLyF4Up6?;n}!F2sv`FrL#51DHbEZA8d%V2O>YZs+xD=vdZ z!o5HX9Zz-yvYKo;ike#H1TU@C-uwL;#d=-6__<@2S8n7ju; z<{sY;zAJfaW5VxG%(}IA(e+|6KVp8qhBjS!W!8f9WJ=SgKlLm$!|*sFhl2cC>Y&KM zz7^C3({$tjLDqy@LQ+^$)mbz9AOc`nJVcNiNH)*Dk0DH?lNO0)_FQW}|M_Le+S@O! zM$!BIWaM5ZIofMiwRh+Bv;M2G6Kd|3Bf1)f%rONgHe_IfXfu5(tVL|$DgUoSLA(hp zS@XQ^At1r*X;dvdF90a}0QYU}q1#|rY>U@D?TS5P$$t|5!o9f{1@)V6MQHFjJR+cW z%I^*ErYK-m@(UEJGWN#jg&fNI8O=86)?>6P*>I_(H@?5_OME%doD{{-xG+?lG|Ae2 z3-5Tiha2@--#(2NQE3!v-JAdZiyurzQ=Ouve-IK}r;L{#NO%r#Wh1syDIoLkq?AD* znwF^9wYZ>60+{o)(>0vJ?mIzbJ0-nNvQXBn92*Bs-D-a1z5jB8Yk5hJvv>t+0r*LO zeA>b#Y@ip_qMI$U*>o4@L)TIW7&J@9*_ZmZbTCB!yrKo{+esBPX@6Q>p;^grK>~(e zq#7VMl{A_Ia`McXP(6iH^hK9%ta_8P6WK)P85)AQ46oO_%gVTRxrdvU@RN}K(AD*GLMu6%cDqsZBRwLmPL5_}u(!5J(ciBV7+vnJ^7hkP zBURFcYPCJ2Bi-yob-jbi_igrkUQiAbG*)p~@%7SB zS5$~pEO$J!fXh;boP3nl+1CCDetA>+h9;PlP@RR$GsKK($7MS2-SI3-#83`X#xo%x z=HgSJeFuty?QtCUh>8CFH)xlrvk%z}vlOTkVKd9Nwl4s-*wwg={-fl?Ty*J`>{I5O zhOe%R#wu1bzQa1Uq_p-rYelSDxzq;B)?F+HwCDU6IGMq1HM5PSqY6@{rWC?%HXlfK za0YOW+?D^~c*}w$;X|Gc|cM4SfGIb$mxY28aC~&!)4@r)rrdWCW zcMeQqRcXA^>PMAp#p5pNKPp+)l1GJCC};#ILS0`%mesV|4xNCSoI3_52TFRSSj$*$ zwoJ(>q*9w}Djqz5eP0S+JY&0=TttP;y=SNe`+fUpI+`2pO+4bp&>vdB5-V#s$9X=$HQ3ABlV* z(0Bwil#95q`Ng8MlXwGbx1cSaF(!EJS31hA;kOJ;A0r{1U2{%%fK%v zz#nt6^OyO4X!BA?>%Wv|+~e?UUUKxkg`XJ&H&2yCT9s)-)XBdR>uk$K zUOJ^p=5?xUmMLN5Vd-cdHPbqew2H2LHFsPSETfUQGnIf!*;{(K z&D-D+4A@G==WgX8tN0?Ce{ckZ>@GE!{v>6TK;{O+HT|FFq6meP07PlCY zyKa};y>o4Ppg4^EJ0;N6^U8cuqv3tRFn2ck#8_vceDQ;VnSa6<5fvzglZ?{`ip$25 zp1SjgJ7qZ?8a5*#l8d^b3TRD%duyGCF)tG7G0~MB&>_EZQq)f;CFj~7A;CfkeHH9S zPZ2FpPdK_~jDA~oGOy6n0Z=!XSGL-z0GuT3@;37^Wim5ERQhRKNXOr+lz(w&lx$=k z2#5h)k-E$eD}kwSdMfNvDzS`}3ae<+H!UI$RPtR0`zEU0k&lvnax<6Z!%-~8XEC|u zmhNKKr|eH=Qq9Ka5zfnHVU)klIKulwFNrRFopQYQ`XSp)Ro9L?qrsWPSQ*fbLp%JL zkUvW`C4^wDuO+klOUgjkH_ofL6LHn1pKB1NuV{~FQ_%1z-ZrfvWq-7piNe)cYi2YC74>#!;*y9`X1yGcwAPd`_m7afU7%vAx2072f3- zqcQjr7P>ujW>x3mcgEHxA2Hz~Lf6-QINQ>*44V;4;VgjsIPP=((UL*1Zx2IT>bg#7 z3o%}dvmxR3!@F;Da`Hc)eQ6eoU-Jr>Z}H@MD9#$f8{HR!=dDzD*QKJm9izO^#yG-O z@qyO$aaHu*Dt{WpBv263dSA4m|8g9dj-O9x^9h&@mQtw_G zH$Z;>My}jB)^E~RcTfCVKDkN#VYZgBP+fr`ZjR+%f7fP*`SZ>_Sn}qhe)R#Ji4{8) z3v+*mf19J;$!N>?hAdxXtsiv?tf~*v6=KI{d3(+6MM&p{hbPj1-=D>X>kHH%@W`#g zB!=JI%xWVz(@{5?C>K?7?S9AY+fX%?5{v6C$#x;r23n>rlVzoXm{-(Q>3@}qewBm^ z>O_f88@gk$b53O}O9|iu_%Tt1x8^6~jouP(hYBwNEB(C+G_Y>>FvuG{3{ZgTS7R3e zI{PjY740L#zld$r-K=t}@gvaQE2}&@g1HIAfgc+rAG@%&7^GQb^#8^D{6}+j9{*ok z)qjC|0$k)&gW^yK`k130aShhh+M@S7YPO$Txqs%1UmxZ{zAz)gx5(n@YB<9UeeBXV z8o;_2d`)x?#C%4WsQc4bl|8eJMHc#(B_KZm5?o%p)jxF*)dyo9S4D+L`HUzScb!Q$ zfDtgB6l-yQ0e&sXzVkvYhTV&5bw}Oh2)TuLVNRL)9M3IG%*@B8if0S&ooi61;_g_sQB|sVmj0IXo9g83lCqRtwr)G*EtK;TcCX8dfnUV?HrLBGukkb&&`>|78e=!(!Ve;vEgp%U=NAhVm zoOL4mj-o#Zv7r3-Py^L>WE9L8He zgiQ6m(o=9aIPvqA3Q7L-t;qgW$*V(On>>qk(ru?bNz`bNoQUH;HPt%Es)n9)raN+m5)csCsiW4qT*1J0 zI7%c);uB9XUDj*MWu%m9Dz}F(xTdQJaS70S%3dc`7t4M-9axABN|Wgk^oqc%K`PFb znJ@9x?WM4l`Xc+s|3PLeru) zQQaIso`APX-^v=#e!mgl+U!LL9NY|jndUuia1~%GAerRrz%PIm^h}EI-XoSFJ3MtE z{Va9s?9AgYO#z8@KTm>woJ`t$0{cmVSN~u<#oF%P${Jsk6Y`QbQ&p(^b2#yilWx$d z{P{m>ADzA3N_uCiLb79Tept;p3}lV90lM(j+BsUi0=myVrbT*F^=}2FLRO=`hPJb1 zo~e%4%asmT@4}p}gtnm9VycIV`;iYMlN)mxJY053N^8(&NcNNajzej*!Ase!$fRSR zt_bCynmuzT^<$sY+tce*~vS zv2ADLL>ABbNu4?GvtReFB_XENX&mu&s4}r)bfVmF1?8hF8we8A@pVlyyj9m}FO)@g z`>4p+l&R*MD1;>Eo@6)Wd{lN>A6_UMdgml03uwN_n|FC8<=>M|FPyg+6LQ<_Hir!f zx{JqVwpe)FzXG_JGWQPh=oSCU=|FC=r>Vt!%ubkP(GDN=k20K2{&%sY(uH-#DP@!x z&A`5%6hSa|_D%bQ4z%(}(CuOX9Zh_3Zo8$X=^<6pSnKrNSp8G>pyxc?sl-yEK6pAm zA>B`_Hg&&hDx$zBM3Ub%ajhK zK^cua5f!!mL@cA`d}_P*tY^6Unsu_TfZnL>JYtXj@g%{Zckb-H8eP%N@Jw%di)Ziq zQGB=I_5~FQ_*@muT_u9^Kww0GeSbq*RWDV5he6^{@rBPF+)Z_$4}VBOyqh_YxtY(% z)H~{GD^RI#XYQhZJe+=#{f31s)XOb1gUy#ks+YNuKRo%IkZ{#@{>ZVnFAEuCCDLV0 zVb^hU?~g;hvk`SAxqv5R@yM`~F=-6a5!n0{yLaBGVesn-sb}gX@hv2|Y;i%Yn4>`G zVLxO#H+t&-J@--w_gru)aH(8BrCX!@?Y_T&;4sHluIxPdSCcy$r~-DzBJWpxHzpB`6`)Qju}4t3PS6<6M9`O&Jx zk?S*}-&(F2p3lyFB;STY6yBrDT1~syq)sZ0b~kK5TTfjVdD$nqqNi*JTe0CLnE_9` zQu8p-!CHRsR$HeQKaTAM-;gYIjHrw#>5Y}@!%IZ@%1G8M<64_)zxF%68Oe;z#LJYq zG?(0MS+>Ql)ppo}a_{^1X0p@?RP5?dKhHV^MR*FtEO)qIuJ5nzTm713UpIbEGU1DY zE*wRyUYev_tWIEEjrs1LX&CcLMZYlC+3*<39@OJWLs|!%i*zHM@o9DBVDntunAzE% zUdNoqAQ|>9r;l8(t`JDlK1|LI!oqfa7{7MP;y}q;HqSkRSp8b#;W0k_H9uOaQ&|bh zvxPqo23W=a6&0_m$s_WOU0S0Wgm5*yrF-I@fSZloP2jCHXZGofcn5<#;p_{MSz_PE z!Io|P5sOVO_f{bv+O_LOmGf5Kne*&hZeIDZayN?CKoL!y`Ekz9g>jGi&JjE*C;QOem%&yRhK3eL>k{^;f7^}ra9OgtX#}^0I$(Vtz9)Q)3~cx z=?4B04tgmj{5=kw5?ZuuTD%aHaty9Hzb{7Lm)KY86A-6N`JKHxmgfNc(QD@w1}UXK zbR`29#bxE7-$K9(gVe8z>t)=iV8527{_=}Tsf8)zt&a?ddMN`o;24ykIRU)2kR8Zl&|nvItfbHvSJAd0Ew9{p8_fi9XjuWy@og!wxAo&vZ@juAI4B&XEff zh|w%!!;L#nkMA5KcLo-a^E!U_vl4CYb7n=J(1}{y=WRd_?5&Lp74L5;O@rV^f3(vS zf`hJ;6JI_u@Pr@H0EFB;BoeOvz4wRzbf4f21^45>OL+`$V@g|)gvyYxiE;%^g|f!IQE~X7g52S zca@oS#+NnNe3#Q?7h7Eqbl$=fdyvj~^uTKRnYLurh+yy$Ak?tKF%OpU>bC31OIiQF z-BW7M+|3#RYT*oX0A8!Zzvs`NR0|PLXwNdA8J;Vn%g=XI^P)&DVzbyqth4aXJC@SA zG3pRGl!}Zc?cBYSaRLF1811&{cCZiV+j5k)$X>_bxWsj_et^u7W?g5Y%-gUe^O?E1 zBUd8P667Ze{z>BXHCF1DaSxRiO02k`yP$>v4;%Zso>>R+m*-Q&Pv4i*hp5}+cW>AlQ{Ac08hfoUTJe?*?}1!o+n|% z=&;|e_X(jQ7nIdQE2Q~3{gX)&8nl< z4CBe|*7we-kh-R>7P2$1NV{uMxyFc&Q0fVJabk2bwh{P^UsJ>%PVxOZlSylx7d*Qg zdZZ0eTkf~L{@ zWnE0*%Wy*Wpe`Y}qWnEsAnvw%b`vUNXscsgvq~ubeCU^0cX0fI_j-MbMxZ~#?*DlK zGe>n$9Lwxu?92oi>~U8!9R^{-`C4q?q^ z@r=dsl+pHsyP+y0C-ii?4I>1FAYqZ`?>_XM%6^kK`nm4|MW8v~-rP818~NQ(a6~YM zIpb(Qo+2LG3x{6AhqU<>dXAJz_#tghH^L;B(V9JmguO3)b`fX5DZP!)7 zK+|OC8ojvkRo!c=>)mcg!$X24ccM;Z+eeNm4JoR2yxfeGyj<^jP^mzcS)7f9n0+h$8)+9y-xEr|CxAF;@+w(=Nt)fe~=kU#zBKM*3cajWfm$MyM&e}`wXxUU*oB|1oI4Nv^- zcd7Ng(dp^=E4k+eRl#>9O|#56afw=2O-iXMKMg4}CyPo%n%GXjQH86vVM;*~ki44~ zYahiP3hV&try%FwNr#MRzHwIBL>PIK@o${fBj$9^EQEd`FOAIM{t&f(5MZF&BV7~f z`>ZvnM;x#UQB?69D27xIokGmnEZ#%BANA)@^UOy~V`5ZK(uS?4(w7G67KU~B%DSS# zcwb?z_7vcJLJrKfl>vm}Oh15ujtd((f-MB?j%I^G5RHFY!e_P z0DU9Ay6MtOIf+-*3iIl5$U?IH_`qEJrlFMU_izkEU3QGqWBVQONz*`_Q1$q2ET?Zs|Lj%FRlDZ0RlOQK(SQqph@(9G!2 zmzPl8x81I}az4#I1&kbCHKMy0%cv}(qQ6&aAW&ffLCk+N8%3}?IC*ky_e6Waq)W&$ z{T3m5U?gDUJ)!;h7?m**`d~C;c6jP^zW3*x2Gw9M>Zl?!aN-mq_^Qg-#)p_8@jz)) z;XKj3(yW-YoZFVGE2c_6GcdtRwS9);#bQ|P5T0NE#+yEcpVA)lS$7w+gHmjQ%1uZ^ zT~sco>2h0}{kOH&v6n(+By&CD?Zb;oQ=){L(%HS?DL10h&(xc{Pt5nLH?DI6zwyJO zdcKhkO{=%@F1MJdsO?><$2Zlvs~Ga%ahG%o&uhejJ)eHeW7W$ya%|$`f&4dY$N6yj zObpWr5`EGn%<7vXP97z`qQQ0WBgJ(|rj^{yf(~+(bzdpCfwvM)O2yI-OTbe?{9UXi6<9*N7WcE{lv(<;yZXg1{=-8K`UAMp=UIoZhpiI3TOiAW0&; zT4~>hxCf3t>VG3k-A)2`Fi9z*-J^f--bZSNT$y}enNnC~u@rW(&snJNTK##+d_P~^ z1$3d!W!6JuO=y&tsYdHucj1PHXef^f`EV@o7dJ;B;-34KuVF}myV^axF(g8r75vFm>=inh2nO=hw+VRR$cqM&-m-?7Ov#0XQDa$*WwGkR#r+$i^mE010>J})=IQ7NV?6^$K7s+*ev(-%7RP9)=gD8pl9Y2) zfz`@drmUH9XAn!_gz*RV58Jd!#j1eCX>!7uLOZ{#IDZIH15`WR^{RUM(RV%ww1cOEq&Pjj-}6FY1%-4)$3D78N~L#B6urmAhXPoDwZlv7aA2 ztWCMqdVfc7X4--;uY_5(Vs{RIiH+jI^M?Je!w+JAC<~e}Q$XhuX(MO^x9ZNV*R~3w zZ8xj$deH3ndylWFP ztrbZC?giQBOs~hsD5uqZ2mkrA!6ZAr8Xr~FZv^LlAoKrWjuf^L%wq*i0D*xhegjgB zS^F@x@#28gTD}hZjwkj7l$I#sg+=yLvNS z?u^sj@3Xmz!8vBv!OeZewg2TyBEIUsjyVEq8QKm?)dRZQ z)obHat)JRO>evK!JDcwSV=wv%U`%9tC7=DMvGmGC)3OzFL2qrY+`JypbgH3yc*HG8 zQTXedyTr!h?z+!XFtp{ioabX3pTd2sQX1bHPEw*^KDn{MQg3IRL{9VUiU=2TgiA8- zE5=xBLhE;5K#EWTUXVrW#oOZ8s}17yP}i#iI{eK68<+AxVZO9uuZ+rCbrF5jX||Wt zd6!|8r@f$}U@C79n0HS2ER|Ns04)8qW**^Xj)q#ecjdFMQL?T}=H_eutf5)2e_epB z>xeW#F4LFIzv~s$nFV$pxV{T^gv1Qk`+A-E_mOQs>KVKWSwa`cj)ySvCrwoZ@gOj1 z!Ei&Q;uE%K#zcA^oCZtl3I+Q`3S&nv+H-o49ecxZF;t3aU+cBKV`xF=xn*Y5`X-KM zI8(QG7;fnjsErgDM(mYE#I`IjEW!($bqiZ$Q6H+Eb6@z8^VrYs1ARG@d+&=^V-Jk+ zUgvn5QKHA7N?=uR0ZV~07Ckk)ts7G-4P#4gFiyMXP6GF~Ae?U>$vb&%df>apoP&8` zw!#(#yf|Nk@{C_;J4OAGbUIu>0k9J10Jxo#KXT}@3ocV(}01d$IB?;VhkwfP4!Z7Af)4jK}hy*dB%xe$%7~}w{vAWfp0r9zw?&b=6Y~R31`Np zCC#$ZwEJ4D9QLQ}MU2l@+rvA&Dk*q*jVYbE z$oJD!zdq?n;<*HYD=_=e|HSLD@ainJ6qgO8)#D>?L#&Ooj^(7qmD%W5VH$#UKHKN!6oSUm^XQopDeeOReoz)?{75L(lVIrGmLY%b#?L z*sV6&0)QC;qpr4|&C8V3EnNg;tOVkSx3tEpp;D_OB3bZCmPjklycmO9&mJ;OAYCD} z0DXi9x8JgkWpscYLw?joS_LLAL6YOL)-yKvMGld;fI#s*6 zac$+lGE?xaBFDxT4)^@Ev$Q}6>eJer@arzA-D|Hzcf-Z%Zs#5ik*&QN2k;ZHhF1Z3 zri4>gK9Y5gSK>==;#DiI_!=W%zXw&hj&h>h=e6_G^n#Z+WgL5V@1{Y5k>Ih~m1on* zbqK4;?DEA2l4d)-Ag!S4mx;5+=a+LPmucmuAzivcA?w~f;-g+y)T2%r8-PqIz7^e; zxGN&32IYtcub^av3bku*T0YBq^HDL0X*NUFY3r_MmFRYL^bn0vHAp4F8-I1nk)792G{02Z|u1U={c z5hAMIPzGizszb}ke-TkYaHW>exZB)g@*q+!7 z>sJ?XcQaK*>@U=9Ao$u5Rg0l}O(Me%!NXm54W*Qj_^zc3mL5qWjfhyB*FvS+#-(iY zmD6XPefE@f-2@Y4vZhvP5g}CJ2YZ7>p5puV^RD${eeVVvtOf5PtB!J?4U5>?%H0u~cbPCriZqDWuh^$j> z3i6xub3^X=M*Tc<$zqwL`%Qi2&gh={tYDbDtd=Tf0fJz=-zEJ|s24_tZ)(1F$gBc2$4apraE%=NefN9B`SFBj5hh=nSKu!q6fAfm4Z@7_LR)4CfifRxc#9LTgmlLf; zVNAI&qIPmwgHEJlSt)pA#UfIwUy%~a<1{u6ZtFB;w^C9z9@HKmHE3A$D3d9f4GZ6) zyY0Rj(xUDV<3u(cHXe*-Lz>KHySIIzF8%AW#%_K8IT7t{Yx6}rXw6E5Xx89V+@|a-4v(^kR$om3a4p0P#0j0P#(}|JVXAwZlnagy9xc9i9j;P=(u0hr$ ze5SYf3zR6(WSwwUANH+sAr7ivjhcpx&V(+gsX2w)=Lryr9_ZK>@9A*&ktwnH7XRE> zq&-IAwSXR``v9iuhD=VOVuK|gZRQB%M3It_ZaB%-P=QwC`BfDtvcr(13Lw@{f>jnK zfMAqgvJW|8DpVlA6%SdPM?j<WYm6IKMm+Ec^^B1y)is591FqD;9ADGh$drjNxtsRdk7c(w4u18E zeN>)wY+8=(HM%<4^hW>6s7v*f2G|+!Y{))Eq(dVBa1DyZAKN=^%ZymqGF9ohvHu3{ z+jEiIyK`rBt3bOd|E zFH$o4*@l3RH5K3jpoi#+YkObxB4d}W{Ql#L{c<4cx^&KTYBIzl5JD?T95zWSJS)^g z58Y-|f@L)#+($1Ned{Qj+^p1_&PZLXgUQ^tzu*#z=vd_ek>KIQXt5K3?cyTL@F>P!j8$4|QUG&<~@a50=1&`=4$I3v_r) ziH0_A^xP50shH@s1T8uKJY}gl-45)Rv-w@HlgOxy`_Rj=pZfK>?#Z9Gy+?DPMJt@n&-vJ1LERa8_IY*Z;KAR^*nP<=Ioh0 zyF52M)?-ljr!Gi5=byRuFS~0sAu8eL11V1z<^Awi_3y)-yjN>}L;F#q4gzsg^%$x+bGYEP%m+medC%AFF{@g33n1q~$|-|E5~8<1 z?(Ws4S}wl(pI*S4-afrKqFp8|o^+G%RaD%lPUJ|bsO7j)C{_X05t_yV2F?R2qc-(Z z&c*L#b%NC^VKv4Oz1wCawMoclx@^tvW%JXnix_Q@1o6srP%u>8W6Upu@|zqb$WvEi z@yBpe4Pw_^zc2s3c@GE3#@6h6KR(hq;hjiQ=z8fdigD?_;$bwA++=^X^--byL-aw& zIwqjZ&DwVkIoG(0@q-+JF{`Y5sc?y^Yl_^8UcBK+ix%&n?}>&eN1oPr694$r`48v5 zp1$=2a9b14r~m3|RLJF+Q-|R;|Hk-CoLRhIIej4uasuV9T3s3*_52>Mn7oWEWVFFm{i~`EcffW} zE+6EV4N5#wG}`5s6c;@9qQl7nrNZMJK!@LAitmqh&mHA5_`;CF(hFF>$rQDorAc0{TzjUEdy$)WS z7F+5%kS6FXQt;s4^#}p6ZQXmfjgNtU|_NECn5N))I}BgHm19w$e&jHAZ%I+uf+Q8*vCHAvWqZlk z#zkG`ul!@@l+9J!P$Kxe==Lo)>F8gczoRK1Tdluot~$QZygGLV`@BdCo25?z`RkdP z!nAgnDv$Ub13pdMV{mpXnT@m)`_hAT|N>8T5;In%b>nWuc3YL94 zM#v10i%6$nuPmL(4I@UEOS-I(IgfQ>%~+IFd0(H{#u|L`xPF2*m+}lg`43#a9}z%( zg8xW8L)15g$xNGwUxfUk-E_ANV`#jBG`vy2xpz|Y9F$AGZ)tLOfwR5Y6_9>V3r-4q zn&>v|0AJbtDZDd>0@r@rc`O3m$dfUsD%%j5M}ciB>4gIT%C7n^MI=`SWB&D7a4(kk zZJlTEEnCa-{#!yxa3zR5TE~jUFrc$;m(OQX6z^76>VMT%Ncww`J1F$8M`2H#k!_ZE}b!+vDdDGp?#9mL=}@ z)$kB^(2KsA1KT`E-tAh%KZrRxLh+ctcI{9F7{dlVxnB{9Xl8X#t#w`(@P zQVjM!>$u14L+Yo{Bx(41O^;M;WrmWt+u+U80#4LdH252Sgd-4wVo@lD&9v5D9g&b} ziYuJ=cMS3CpiPBE@TTaoO8RF@*1Bv)>3gl`dG_jJV{FE1fwwzkSCU_ThErkM>4Qtm zvfuq{COzl`o0IDJy+xUB!T{Sxkyp`+A0AWo9`q>JLCOUW+6W6TeBQT6MD$<9T!sa! zXJyZE%$f-yHs>}kc++a@)PFWVF#g`$Dx%JMhC!>*x=wkg+{9Xz428Ul*r-tum zIlB5c^OGG@I;*Q|3>HukV%=f^&naX=dc*3;XAU<$j+ zCUM;b6X<`@^xyGnY8Ky*Kfg-O8(Zr4*>5_r(jDZ?C-h-RR99aNK@Mb=AwwvsE%OE302xy*55p830+kQ>mMDh)cAaqx+IyUhMdXNlvnjx+oo0N9VSgz6FA%r; zVn_jIuMg+kJ#}_;U4!uguo#E9Yv$u$#;bL?!R!Q`5O^z3b!8%6U{%d%9M>f;LEYVU z23^mC{^zFKipD*d-Hgs z#xPCCfu7j$03z%?;)>yYa#mSRO*o;-a1ZB6oXPBYe#s}N?{=5AnR(7wA6&L@fX!LN zp`zDpa|k;Y<+=07JD)dBVH?-olVRerkUTgL5pUXB0q)QHSPj)XHZfqTJYfcEF9mj zW1fLv&KY{Ie}zKa%6;8Gc8BI1#*SS7H5!`Zjn@@nrEbcP$VyeO3*3x!iNY+&Qb#fBFp->;b;Yld&LHQL8UciJ3g=@;_g6pYEDQ@J_=FzTw!^Jv5YT7z>@G*JgqjidvZ z^|s7ay`Bb+1}4TFDRIheBYemdvg#6Pdv@FDz;S(>`68wQliK*zg=-Js>h?!C=4$kM z1H&BH%<+#-!E7|12;MVkM(xiI9uU3P4sY~}hHMm~aaV@EYS$R-s3tyeOeibkS;KX1 z2j9&*t6c6d)^1Sh)fv~e0aIN=ne&(Xzf7B>?yHpjW}JDy_KPD?fTH2u6f|r;+SG+u zS!wAtlhMj^7};v?Wh)SIetaAKTi%I5Il25-vv*R1P{^r2y|!Scpqv?U6K8lJSz{pk zxBVNqrZlk)9k~6F^LRF};7W9nxQ0LbOyX@51=|LdGVMl}-QU0uH{EeDr8$5J4k46_ zR;BQ|)Tbs>oz0jv;EevDuOQvAnSK1d&sFQZ%8o~|gwk}x>P{Q>j)nz`vC#pN2@a zkWZO3;~r zSHJY7e8?Pnqmwe$2Mj#*Ru6xuR+HRwp*Vf*9ru$L^+6AQIUEmfZBT@=_x#((*z|8a zAd6!%I10XJej2Q=zYube=V6UqiSU!jb)nwaT~fHi%1f<1uLW_m=SG^C@_T8dR=Bqm znAbIJ>qF*Fllm)&(EBR_>Fk;6K5SvJkgTc@vX}R9sR}7}lXA|^m-y`=3 zujL<@kPq%1?tHIaAxyPlC-Ix=4QS)iB`dVM@kYh_HiMYcvh*K!_tMfW)i-zT#gOL% zgq!Ql{_UUojhqF@G~auXnhLO)_Ajc43ZVZ!5!#y>&e`6>ekVtb&g*4Y^ywTXZZFug zw`}y1NW`q12>4}h8E@Kv!)FoS>Xhiyq6NPMBJ_b=?-MLVW&5;ip4l{)AOPyUwkn3@ zA>#=O^qGwV{b2fS?5h1s-}@=nn6A=5?p{TJbxRnfzY0QK4M)tUb}-T~ zX&am+6|2SfO2A(}l3!%wpI>}5UcM_CryY_LgNxagWhuYQG4k-r?se(Spj{~{S5inc z6x}`BgrI`Iyyu;*YRcS{6wR&VH)~yMbH9y=L?8YWcX6}y5o2KiLPI*_aM|j;`>!jR znBvGJwx1R+Z_jO0@5|b`T>id06lG^@0x0Q1X!J^Ro!uh z3`Yi+S6e^K{wFli{wwM04E%%rvbpv8jb*Fw*}BA^*80pY?5_92J4((bzgn{n1s9r@ zmH*Ttr^eJ0;Q^nxB`6*%i_Rz!^|PouUXUQ#CPinOc7WL!gdcVl9xYJX`q=@8eG+kO z^60bCn(bnA>^tObNmzLS5f}bWd)~zKc1VbWLsO(#bl`fa`ipm$@{#4>$A1drs#Oyj zVP(|q!1Dh+l#n94V|xigfd6FfLX7>zskbrbhVKv(xwUVrk#75D1)lgbE#F06UScyG zPvBBQL}MLwdIArAt9j=;@hY>!QM@k_)E+WgEScZIZC^vAoM={SsV=vIId{vlguB}8 z0o7z^2|IQ9WctuDiXO+j8rEMOc;$28)k=mW=3HtJth(?3{6)>0h8L;lkY!Y_OuW9hkMV>M_ot@V9;^YY8;NaaV8GNL_GK z>*5$PIWYqx(g~L}Q2yUb87}G~D{Zw!E%5PF&(Ir@(mCMjIQH&Y+GVcdb41**&gn5~ ze|HWY{+ipVbZ#O5>o_@RwUH6S@@mAK*xHE+PXW=6Ni?J>#qc$ulgeMhp3N`7ZpP>A z#=}9R#~$iskM{g>k=?@wiow3fG?458bEB1=-<&+kZA(lQ2M*o=gDDR(3r@zpa6py+yK+Z;zJ;Vsqzw5v0!(`|HRi19Elu4U)T592HU@s z>7ypqb-o;mr0`FmxqEpP6VehQA0@sj0u=-ah#Jh&s<+AYaL1%^~W=!^R}eUVn5ovI@4w{>}rh{dNO>~)LgT7Meu4EAkM zr9=WES1g+q5f|-lb>|uzeaSW!ToEYSDyePdnYZ|xcRvSa9N_b*K<;X2-!Kb_D-nmE z)J#gEycGj;zrAVhfOI%S82H#_F>=feW1l@E`5FG78|?~%MoM;>)Y-(UJ{Iz&Fbdb( z6c?%Sj5JSfm&N3yx9%-rq7@`_T_~<8kg*uGz#j=yi3ec0pNshBD)x6?pGIyj{(pRU zp`3k=9N-{!Ha*76mIc?`$jb-+$j{g5#-A6(e1`R2(5b2P$FC5$>O77@&<8&+H!7)l zUuCo0z95?Uqg7*Yz4~o5-@9MBd5FFnGfmrs4!-kFW>G=a*F-F{hMqVi^hMmnjK}j$ zebCed-~MpJXT57Z)#vd?e(`{>c)rqB86Z%px&YFk9TK~jwbZ1aI|RHMK8a5df-EW& z>g>+1x`@qh)!$z`?W>*K?^NRay+R>}v$bnx^uLEcxROd~^B=vamBR{!x!YP7SHC@4 zjc-#NZ$s)ekNdci-f-;9%&PWI)WtFOarMFW0Sf~gbQzIKgNpc}gCTx-dG4f_7Z=1j zLmW3(-^?L%?kMzG7xJ>Zwskw7PS`XW79^#b22#>8HzGUu@(d1>A~jvRsi>U)O{N1k zdjq>YPl`$=M?G`^yH@8#)wC=A6t+RVFmb$3G17VJ&3CNdRAeHpmZfy62|u|cij1pv zGKW-4m~Gdvzr$7Y;M#@QA9a==OUEXv%kr~}$gNSD#_jOmC3GH{m!}iQugQ;BpYjXe zdFzf{`X-@WJE|}k($fpV6+`jgYvw%dow|N%T+DZkEPvgmV#|e)Mk-bm_;0h)=WR`qQ-;(jj4fD7aFp zUU`rRl3n~63o%xX&l#jjfRrPpv%jbBw2AQw${{LR5%!RXD*&HHo0O>7Wz z(1_H0hWqlm?Sl2O2OXxdi!I^lYDD~v0wwF?RC+2bRY+1swi~Gu&ZZolyDLXY3?1B8 z88lY>c~#&1r}>oX32Y@oZ!-H1jqo~p-4J$|JYTb~w?x|w5U<>ceA9G1RK)PUp!*5) zzRk_^Hx%2Kd5!;42t(Z2BFa zkaNwD6DKD#Rv6Nzn<^5mw1tOyg7usR1ooOvS$dZ_^yOuaR*AUpPpC=1Kd5*^D_$Md!KHKd6W{5-0cWnWtg zzY*DB9M*?)Q?S-?(z%V);aEtVk!O75LRQ<|vY;AF$DQJk(fbiH>HGil2?v6dLMT#k z$}T@0CjbL*J#^Wy}7K zYHaW3ix{y9MMWf*zY|spJnwNQLJDm<(Iv0b>1HsN&mZ4!q;{#hjiwSz*y3B`^RauH zFoX{HD#CBt*%co?Ws^#amWmD1-O9UFKeU+&XCF*qAwnFeG_M|DYRx&E1H7oj-7$%LNBCoUP*6a+Zhz)|ycyYhv zI&k%p`%GN_j&{tQ;+Uz5H5#0>f450h$m4dx)ZQt>j7K9Ap0@(?&HviC!ru1nFQZ)c zqJk_tf%?kMFP6IWY~Of3h{#mJJ^jB$xjlRj8Y9i8BK9lTMJz!gS)_+P9PKvM7>n}<{lYDkZJ!47#h`dfbMs~?0bw;NaP^ag|*aCY1I=coi9 zY0spOS=E^zjMz?Rp8Y52cX1-YCpzGh=&1m4{y&4tWncEy?MAtKF-rFoSqTEGQq9oB zH{Rwv6CY&y_7=13hDwP7mQlp+A&>oL&s{ntnG1y3M{)(#V%A8~z4{wMAV6Il+uc|Y zb1Ay4)D9(M*^`QnF770cMh!No=B!&E$<+}D*7KG&Ksn;!3ZaEa(Nx#Ao4(!~^4Ps< zyR`xo$?M=al@>ZN;bACqZ#c~7jpqkuwUj-$so^HKmbYNHVXPl zHzP9TVEMMxKJ;vq=i%!Uojh+%k7buo5tip~um?VN|1C36;N>Al3I=d*B&l3ulV9sm zK1|w+U+HDskK12dU?a;5_h0S1OAvx_gbJxKSCzc^mSQ~Z5+sgqrfz%d#lgRh6Xx+3 zE`F|7*Wy}ZA%%60~AL5E|^ooFU5h@Vs0C$X|>4v!3Y`XEn?%b4~CIG(J= z!h}r5!I%o-X9K$Q)%5IcZ`*pllkXK;9O&i{(oIKlM$5+8$;%naHt|HT0nqJw(O#P& zS+Lj_6VEbgnkAg|H$=^6O5nDejet^m)*hyi{yFe^l}Fz@{f-?T2!KqQT)O9P>tB&6@K_mlzH%Ot#!{?9}F^u zu4GPovKhIA(Ka`D06iQvY!N$D3!xsz2IqT4k8(h^9&g6kgcH@w#WAhTs29BaN2tfr z0pGS9DtoQze@3u?XdmQ-8*T3XoBS_cTcm3}d@^NBGsh*Y81_*mmQbl;W8OYRmV~}_ z|1A2TkR5LZ*s6(BnJ1F?`TNCB2_-$tqUbxRfBhCAj`61tv&q4|Y+!=T^q&*;RH=%i zH4BEhgI`ewCCgXbS_v2BbY1=CF1&ls8uJRI9x~o#gIvk|@g}<3Ikz&ZZ0AE?@E13| z{$N+4pj01$TB%j3@DkBu_>8fl_vn(|k@Z-1KkPWVX{Y8($GvO2*)}{r9&H!S)u_$n z&t1BC5iMoXZ}Xfm))&A5dvo^avV@2C#9pJS1o(7XyToRsNBF3d|I&p5{xuVSOAH`E zLGh2j2(15;ShXl$rKoRz7&Kau!LWN=p)=K~)@IB}K{=}>U%M##HLNUMW%Ti)(O}+l zpk0g{qU|xUzj+s}MT}+qxbc0ZOxR;Gwam#Zb+SYcx-K%k>KZ;{i2I`6@f?Avnm_Ym z1!1VFbB))n+{;a5;sVBkCGRZYQ2wI*^YbGnDh_*nE7t{bVGjy9tRl=aeor9%PGVq; zKT-eypcced;&<0xuz!VH@0p%!f@K_0`RW8U+E|z>ef+P52qMv}B*{gYD_w1V=1`%C z*D@Cw_9nGSZ5A=4R;1MjD#wWlXVsFl?NhsKQ8G5RuMWvk6(KLYPD9>5VaU~qo#ndz z=Og|$63@=Vb@9Rdup!V}`4u(FT`bJ}^zJJNZAmE{{(WjaRB*F>R!P39gqK>HV7UP` z93XtjNR(iCUn<8%cb$OK_3d)*t7J z1~#vH#uU&*iFJX#K>;FWhhZA-lFx46A%7ujso|RW zzqHL3?j6h*K=hS8SCiA7aKvvHNAyYV{lOkT9V{`N@E#qZaMK<=n^7!W1y>60^+&$9P*KR|b*>xn!OClN8 zu4_Yy04Vor7`6{R)72;c@`1K_ijiN-CTq6(4$1@LH&)_CrhNTAK`ZTgQhaHZdoL5W zyFOjA>A4abo6oh9u_l6e`cuT9r%J|o<-vPjZSvdRk>5E{V@H1AD{EHQ)m;*EL`UAl zZr+V33!oP&H;ADxU_eVmpvR%kb)Q#;h|;<;O|^G+`B@c=9@(eLJCJ6=Q4pN{Z#m2T ze#o{h1i8P=8^WTni5&hUxQM`6lrU95J^6Hi&%wD5Ftl9dhgcwQte#i5&L{M{=Owlm z^Kr+*9}U!i-;94ML{cc#0Jz%v z`n0h9vzu>7TN@6SO|@ytdpKnjbh7d6kGqu}U7CuV&uFh2Y0w~V!L>6Wv`#wmD@6S* zo*AR|?rRE=r-RE`6av+$mI+!>9w#>=8%FPNnYvvN_&T{vF)hie_vQH-csRC%sAZWcVc0rpY)zsH~lu(PC*CG0wHxP!D_h# zqmO?AWU>x>IXfS<7b842iGa*^G3%LuCKvLAf0YYfE#EJBOI!JSmUc7L+lc)jb79Fs zl-6C!7g^Y1BS)glzTrV^k!m(LPUgN9VPjG~j;aZbxy;`bXzo!PMdXM$C=+tT7%T%^ zR~wzXmCt+WVCcl6XV~bJ!*goJF3V>gIkyBZGB~S#t4iOlR9GR=R=BBd*+L0gc0=cq zwt4Pw1Yx-@D{jK&%mDcW3e1vy*;)`AS-CBLW4^ew;ZPIx_b-6 zvODjW#II9zPfl1288=*}@{wFXaTe^&EjzxmYh5*{`XZr@ex7adcyjIFyNIh~OWbf; zpW$;#^qZAY_u@RSgq;UH#D*`XHTD<2YjEY&PUvhu{AWF46cBG2_y1>o-z3oJdRof5 z4xsbkF>j%Wg|^;C-a3wIIyc8#eff;-FuWRI`eA?d7E8BcDTe1A90S@3l@{MK5=dke z>*ZXL#*o7>#lTPBdF?w^^HhH93-3I5x}RXl4Vxs!^D0;W?XHkG03cP9xn=|H7(|IV z9Fa*s5?IRk%B?!a)2ezZ_E?1T_%ituqS&duO(KCTFnRMYV9MsH~~^{|GQ z*zPBdXnzvJvr|&f{Fa*lPt>29`LYN9c0X4uP z(ml-GkZr@20+oDTyf0wWoHi5(w>5#RKZocawW$T*-f_h)2s0zYgr&!AA!B)f!;x!;sqW3SLAX{!X$W;v z{y2#yhxX>=tpa4u**lJ@!CKg(Y?L)ClBYMn?L9}mi`ncL`WfYuTq1~{L92d8j{S|4 zXEywGPL5*J3|iV+kn}A-TrTaS|AqE&7+AB>vJguqf`sX)`q!ZnoIX9Du_B#!X+v6waq-_E!BK^m=!1Jc24YY+XAh@~u|a#AM)1IuW%1r_O`VG8meeu-^NWO#nEC->$|28wf0B=HJSg|L_VJ`L_SrJwpO?Vh!&S_QC zOs6`@wCamgF`vBT=k=1Psc4suM-L~KU5dfSQ(&u9=xo6r>Mj$n{}*=%w@n-UO|aqp zt`X3(I{eW6zvrh6p4p@&V82SANp?p^nkH%;g&9^{@p(96VL7BxWW{{kz^kJeoAXL2 z#d6RL@ZSp_Ckj)X_hQ1-!mHH$CxMT#Sj^pw-7xjNiXc0)*$&qpn@Ef^y8j0#S5czyTLnvXN z7l?+I;O#GZmZSi`R{I|ucaU`DBHjca=WAM7WiOGQZ%aRU+cRs^XB+%4EG~LAT(`e1%S_B3-4X>ZA3Sy0rZI6Ue zbHlw@#jd$D#&8Go$qw^Ja$n|D)2-_tGZ&t~>yp_U#bBsnm~<&T*E;L`n29r~97b7i- z^l>+!>mXmS(QdkM+sGv_JU7Km79gdvap3PtW3)yY{8gM7d%( z+RAh%TQ0O!B0Pv+L|wU)XvHzH2wT9>pZxBp(~m;vLJ;hJIe;-h0I{|d>&ce_CMYx6 z(;M6P&5S|NhOG4z3f3p-D;dc?GNgEsqgw~b@iaM2VJq$j;rOlz@ryvjr1tsG>|e?@lPtCZ{%MF1 z(s+fkcD!uFn?;^cHAd6x?BWymJ3arHz`f--Wq;|}B?_{8*&EK726j)$ji<@eHug;o z7rEMJdz#0h7Tw#YBa@NQRB3}LodHwoF+5O-A7>I&;?@A1(4E(y>*j*mi**AvZND`g z4>gsx5~dkzK?eKZQPzz5K%<#*f9tC16fbPM9;*xOe5kmZVN~9*FmWl_odR(WnYYd* zV{pNxyRTSBV;kj7AwLjHM-xEiI03_@UZ;iwQcFG9cnyyfMib9AAXR#D(riNG^He%A z5w30VK+d4aA(KVTt6_omgDNe0v7{vB*jJn&)u?2&di zU@^NBw_(6F(19|UWu9{oe65mUb`&vU=AGE&?Hf4Nx$%={aTK}GFs{^!)0J+2O;kt( zb*5=zWerzLLxwqI$Cf6;p(-F$8Pw&dW2!pVgdWy}&+7KVI0~*N+8i{VouJk!1Qqg& zG#nig#t#%z=dlNi6wuM8M9R^!Vz48_|M*advF`VSz;^-)4sJlp-_L%q(oRqF0$O+W z#Rq$hC`Kh8mb3!={SU5H2^r3ifXo9ZQOlZ096Hzy?Yi`*BW>@5P>ywrq1lm{1SNa8`jX>)!f3{z_TpR-!f391 zzb3=+(OCCni9B5IK6T=@Jl*WjWC?PI2T>CoI9vjnF4%s;ikZ#!T{_x2;Hn*@%vk}4 zfybjR44dE#y2L!DsS$z8#rEk#=!Jf4d&K4(ips~3M=l12lO~%JugDq(y_t$_0rK}B zxjfG4`v4F}C7Ca!A$?l43@G-TxWC(#b+(n0sG-7;bxAHlI>CZk%SJlcfd7v83Sw8x zt~uOfSEf)h5%R*}1jKvoo%W`?((EJIu-u zQfkW4etobz14jZiVkh^OW7wyn;%K+uJ74yABqxFNc#!hENxUa4 zGF-%AgB7N)o9Ckz&b+q?$DN6C<4^>DiRe}K;?&!!q<#3PWR2d^zjKyVwcnji12lK^ zISmrxI=!#P&kP%O=Xx#JmqnlCIWiUZ(pK)caj+~ZHnG#B5$)kVvhBub21MBQp;H7}}_JeYS0&&ilhP#dpG3LKkzH_QLIt%U`yJaw>Fg z|H22L4qTeh_%hb^m!_aQ>S-jN6LoSn41F05d;KipAWEbt({Z9(B6UaAMqm_9jGMMb7%9xKgH z)PmnOpwxigd)=ziHY=b z;p=`;u)D{!()6XviY^8QQ|*N!cHc!7-y_uXDS|gFVdLV$G@{@QIc5Gei@9E={2-5C zU+!_yiFG$ZDo5MfH2@K7X|CN?(ZpQGV!!HBS~u$V=Xcsvr>t4?bwOjI9+p^2eBqS! zp~w9}q#v8wH1?PN;0k&{a)nvL&b%$baAYoD(^hP1u;gQ}lMdW6z&y>^KN!ax`$PaT&niqswsRQW8g5A-Y&3na{ z5J@V79UG}Ka87~)z5LXO6u;m(9D=9bIh3#To?Rz$@=8RNPtZ%w?Dq~ z$gvUFxxo*>HS{?X*-!0gmVe-P803fbNOkOg&|SuUjK-=geXZg!uK^lg-u+n!v-!?; zz0?spP!?bNBu>n5@}aCe9D=BHNC6)4$ne*!Lz7yO(QmZuU;fOX znZ{(K9~+S>f1@A|tcknwH!;y@JN21ImGh%gpp>q?)`q3Jwt#32^oGNrFa8Qt{eO(G ziZp%9Vxy&8w8|_+iczeZ5hbVr#gQ(13>amjn-A_hAFTDLXKa{f%;96`^yzx_tru{Y zBX|n=O*3I)dVd}_H+S?7{^#HcoYj1|=KCaM)6j4W_JaCGACV;y9w35rvNs5Ze_4+& zxMG;?@--YFTRWx`Q$wxuvGFocYd^BZR zdCf$n>cXIba@7azyVhkhh6~QVciGW&Lh=5HCyoQ%w!`Kda3I!e*mcjlzt5*KC)Zmo zITuy(HszMoU9vpxWqFyGRLxA(2g1NZxj!i(OBR} z)mfI^g-Wr5JFY@R;%UAYE=>^`{ z(VZ`b8>E(G=6;IpplQrJF^d}wy`Ex)I#mD%wo)29c~!z?@&U57V0OO`M&vGm~qo{8AQ0n zC|r7-S@M)Wtim#6AavBSU?H!(%vQVBiRJ$#B>3bj`0QqZ#O^wH+-pF+?6)NO#ANWV zo%W`X?N}Z+P--C0`|_M5BAXo7vt$Rgn8@ZBBkUhrEbrbZqAB%B130({l{>;X&=fa{ z+m$oBI8wdWInftwQ!^6CFQqgGBQCG?&#JaBDUjpGV8j*p2d}hdjAOE9yc}1&IDB+j z@4(rG2z%xgORbk(G%rXcpTXvLNFv|wFn8fFm3g#aX>2F_idhlk?x8Qh&g|3wEWE;) z4k$_KlO4A$GEoClg)Ohh8P0P=xBd^rl;5Xm-(4bB?mz6iua-`jY;fFrb`-UtxK}@I z?c#x)FG4Ux4FQntt&uhB{bs_p-~Ho_zcS%;NK>{6`8jXcu+o8XikU7Q$_sQWtr0ez zI(+WIsB^5bRr}-sse6!zsH2N&t6+YTE!+aGHFZqR(i*hyZHNaLI}YH~s{YGd;KbE( zhM!gb^g1|g$n(;-OuS&DxUPxf;K9F!%@g14g?3^tS}2MGwRhG-%oP2*q3Z7ou*sob znvOy?EX!-M-pUcV-lF?C0r+=b)}c)=ki^-QhG0X)t@odx!nkC8_0_6p5S}95J4aFT zhhYSdeLBbjc(`7e!afb1V%xZ(PkZthbUZoArcxNTUX>pCN>8Lyjf*78f{wq&uBtbm zcu+y6{T-Y?>8VbCZ5#&hkn!zq5W7UGYLUbI`Qh{iQe;zsO*?p;6rjdix!5P;-@dJX zI<|$)xWoH&;CCML$6*P~%Gc>RB`sf6u8#SPMscTu;Iby!U(4-shdF|()cPnYre;kk zDj%nZ%(9i^1(Oy2<9RCk@GBTb^5xtW{~6R}lbS=`%r17$=Ok;*N9|b%Mr952am&h! z8S0wq0Xp1vjdcmIkTy?pdiR@YL|IAkA32O^=QS_hVVUWA*$MTIQ`WzbJA=+#ATL~?JxxKA+6S!}ekUKXehKDcE5{$?Cs z1w9Ge_AO8!=E5};W{=XoSHAx${Qx^;R)JZ}}t}h|iWFg_r zr`BgNm3SMC`cpxtzsYkxwY8(NHx7A>UdBI>e%H@L@&1j9knzc5v0pFa{hgOsp7mBj zSKVgpVy;7#v)LErwHUG%cagBRLsG%S@1%#YOh{LBGV69*(=MJ)^*Tt%#bTM@HPIi% z)n)J2vR0ttrMs7Rf6eru9Y9TX?Io7;Bf$!$KAX=icloVTju#Iv-4j+g20zO(xa|f? zO6H5eFF&lleyMF4cPtC|D zLoD3Kn*MKfTrhx1bY;*AtHI9A-4pxTte8n_k2x!B4I<~LF(-&jY3Bk}HIZ6$rmf#j z^D<^JjZKrlv)|^6B6>Zq*Mc2`11vIy$6N}%DR=B*W;csaUXy`WZbEa7qZYw2OA^~w znN2IJs@X0*e(4&6@|?(Cof#-Xf_=`*fR?VPT9|XAO=lob?}lREE3ev18IOiN^qc*p97#&G;1a#i1KsQZ`aM^=>gt|7=;4=i z%?EveO7g4W zTyax3X4EOkZ&~bp}>zi0hz@ z_KExId1hVQsDNduyA~8Db=9Ls_mqK|I}X8r);O5%^)~_$!t`=3wWj^=^zws{)s5%y||6HxO9ZicJD_SxB?)O2hfwc;r?GmwsB>9;aeeN=j zYOYX&b;)a?LYa8ioe8P67ob*>U@mAyG!Fdcd;JcnNn^2oF3`&nl(Xg2`@)qT7DOmc z4^mdjTUJ0!)(yn9IUBgzZ8FB9Qn&vU1S{A(xgXPEe>r51{rsw#F^jfQa8&EBr$nQm z%rbT*YoCSO?lO+UG7|n{VhkF;rv5IppV6lVxtvDG%h_>o!&N@m=|RVQs=N`j?SbS~ zAlG?Z+9v~9rM(aaE!V2~!7oHqH^mdK)-)?jR#TGitkDGDn@(A|k{6dyb~tt#jHGh(^s+ynS!azr>a^!dx&f{Ext=gEWEe!j52^smBM z=6P*gC1wq~`Mzjy+d2zJbV~68+s3JXi;E5(qhqS=rEfZqIX}`FDDS@Q2q0njU|2_s z(wR&v*#LO|-l1TkTc$_HzC~kafI?4Bs%4;|bHr*X8M(M~;7X0E0+mi47f4uHeYNCd zhtEZ(r8q(KeZ}cSvg$@RnS%)nlAdEtE7Ffz=;%WaXOHcpScy4g(7*4U6M(MaUHY0{ zHSof>BIdZO-$Sz($~AyN(<-TL*CXABo&!~J`W%Xvq@4WZy_U~DQGQsIlr_1r5w};J zlePQC1$tB+t0*iQXJs!!ipZulc?%S~e~fsgr=B37)=;HbS5Xnw`ldGr7OXM(yz7<% zd_Y;yd?J^(EoGE%?e8UT+S`i_`JW#scN`M(gzXm~6lU{4hnkI@OATKm*W8w#2s$jh3!sV?2+QmXKz}?ORO9z* zQ~t?MyPR5N?=<@7h5jv?+e;s2% zeNl5ub+>~$29r{^@?zO)E{K1{2#vAH>i|~0N;T-s-+^Tq9yo8 zop+%F6VJ+Dm-nI`Sx-PJs+7=6+=_mEh7p0iT`-{ov{w(!LwFrrikMr~@~5?S2^g+u zHDN<;OK!epu+A;5s(?kJsA^K|ij@uZ#(9kNDzb>pJjXarL0@<^Ik@l)0*y$YU)s2_ z-@LNzCo?~Mc(l+t*APU@8?3EwovT-y22w*BXDOt9V33M^%#f|-M#;TCAbw&M+9|ZO^&-M_YO{gI-b(5-AQ7APMf~0*R8H zfy0Ueov>6`GL`4N$GS8;U-5NIfP2iGqp{NLziXkwUo?d=esZ|dhqE|{HW@l*m!S^4 z0s)#(0#c!W<>{onjaG)3t9wAL|a_(MrUq4;UzDaE- z*j77xRbs-}F;;ao0#@JnYlGF%Cls#G5CD*J=#BEG_>HQdt~NJPc|F8)9!!d#o`H!iWoghgt^}Q^c2hM^{?mL8~kwIb$0Y;9Y%rs|kJ? z-)p=PI^O63#nc!2XH~aqMXxR|r+#yc*N6>)HoVADw$=9jT=h-V-2%|{&E}zza+v;d z%ct|HLP0CPWS;M5$s6ZguFXq<^r-x)u=08#Qa55$WjC$zE4guGqpL}Zhte_-t^02M zz&qB0Qkm;*O(@U}s2`gJH}cG`{nq}SS?R=S0xopHWae%OeNRNhuXmDV1Z>3O58EYy z&v22XmoNsKye0aK{d0#kL-J+KnBE35jv%w3bIqeH913Rex-9#`=eXLwL8GYgThob( zXF?OplzUhNAe|rrf0svrwVoiLPP{VBU_bR2bAIf9^F~jVhJZ7`JU~@PR*?#6d~F!y z3hD0`xO!N&o zc3Frx4bNy|=r4ODC*@^C|5n~WeHIx)AszMv2ZBa^c1W6Xn)^D(Ym5%Vy=kJc_AP3h zuE{Eem@1<{_;Obca9Z1)MC*402^8MQvQT=nQTY9(x?S#!%`v}Bm3l+N-7hIX=%C7ks zG?YKs1=&&>A9y^&zg&U-M4pV+3p{B-Q!*!z*h&6Ne4ymmPWDgPvX`tW%XC)(6zYbSS z)ZM!HBk@x{7{C@$u| zzq8@)_tB zI9vebX8VyNepH~R4ZgMxWIeOKK>T%7b`UfGu?OB;%1p#k=|LmlpsfL3@UatZ$_9C>C5P@;8ocOBAEDe=-565> zbiAn*xDD^RjVj+-r0H{FQ++lf=kkLo_$_GfJj^8JvZuwdn6hi_{eA zPNp+uf;K_rFwm?r&RE*W0@kcBJbzY}KJ#5uv7B(xaRoaD98d-{rY#TGF(%;~bB{Rk zYsP!(1Bt-lffh|G6ht3p@^bQ?{L)#)-n-?%GTn)RPYZet5f2jrUjQW&CmJVE&SN_T zhkaqWb;>RHm*wc1O6VUj!Nc#=cihdVwPe$4jedCKUJ zlsUk2WD2QTJn;_fUUdS)5j{Fuh=~p~(hNYATVR3!F$U7NW^uxt0J}w@<6Z0Hx9;=K ze%%7^IoVpSY(}boyLIlr=^4(QcW=bOoaYU0m07PKjhr=eGzDRAO7idi`{nKboqklv zBa;4s@qcIEf4wR6(S}u#TDA85nb!?Z1yAa0$K+ew#s`2PT;=Vkgps+OTV@}V#gbOb zji2j-)>1ehEvaZNKy)&D*>9*{_U&dau$vkIWX2lU7X-vsH!*=&T{63(bGLdv`YUig zDWn+g#s_m)F|!1J9MB1kUop$Ib@HUQSH3sP7AmnB{w>W21z!w)0tI8@*{4lXRVUBS zIjH1i_Vb`KMFP7BCRDZGE&-C?!SF5JPOZg%Jlhm|6LFqcW(>7 zLA-qY-DIQ}wWzpdnz|~I>%s}-)U1I5{0$RoRx$U(yh?Wte6P%EJ$Bw7!=6-G9Ga=s zrqGPpsVS{Vq}2t<$?$KEkJxe$tyQ8EfVWshuuqVD7&@sd*v3$QKHDzf*ztSeA*E^+1>N@^ z;z1Az8K}ul@jn@$qcB+1M8s`xnA$x%C}(**TdTDsyH(&w91PvtGAiuXmu!YVUx4J@ zK7gO5Vt&Cuzgylmo?vmm_PfdLkiL9c=Qm&Ckbj)cPe`Y>#Ros3k<3Kf56N(^!c!7$ zX9BIum;3d&lEcDE9vv!Yd46QY$-yRbJ8$dOyqi#uM93=miFm`cpBs@b2g?#NBLy=S zWuii|F3-v;_pKShm&Ozl!s?Qn^}_t3IU~zM@P=$l`#U7hr%?@>j_*;CdDC(~S(}_+ z>tHrSV+?8YZsHM}A2Fn$>vtD-)vn_&L6N?p5mLku!Li| z4AWa!6pVEPtxYV%g;^JlqpZNv0ilP{tMfd%W2_2X`B2?fFKAi+XPjx0TrE<`KNf61 z*}e4;dc{2KLeeDh(NS$~*P?>2!Ju%=^l-$B@%v)L`)`v+)FV&wXKiGt*`WL2P5!cC z4W%V4H$%r8(^i?5-?trvJC7?VZ1CpX=U)GZ6Z?BFTAI21WYRK!X%j>{bW>`|&RMR1 zqR}?*Hk{1y_(O*&oYT%aCGD{LQxN5c2rj+d%iAWy`Ll12TJOTpK0?-!PMmv=*?#Y- z>Zc*_xZ!2)42mvt;5@FZP*X?9tsYS09+y1m9|b)e%_t-m9!Kpi5zHTz*|GV{TY#e2_kwRM23b*ySO!G63h@Qu$j;KR>T z+Dh}wr^Ag+tHkB`0$nD4uFFHj3%?aIlXPRXEERwAna{1jboFLW@{izDV9k4ck7IW2Sax?)mFvm!hv9LmpXK(@a+XGB6$%nhX0Sr^L)Q&%lo- zk>iHj+i1S>8~={^NLgNcHhL=&{4Il80~qTs&}eWfKd)oLxk!G!uf4t>Z+|>h5C2Fl z$ZJkk!WF`p2%Z6`SN`ph65sK?TBOm*ZwJ<5Wl!r>Cz?qF#D|+N|2-$#n{94&VNE_?q2hBhKp{STaOesWyQ8%O?V$OBM~Xeka5HhWZG$n zl#fK}iOe5uTwntl0Z*8D5XUqvHF6B+?(U;HYfE6*; ze@v2hAtdXO6&F^$GOp}2(EnVMtEm_{OS>Th3{`6Ub~R%jg;pdP;Idz+w~ux_)hq5% zee9$^YmBj84)bL4W+=MimL#{QG|!eNH{XWO3}=fgPl}jLqCu0{t|NMWO|R^Nl#;~5 z)1v&Psn+?I?GO;JSyB-&t}*bW=F5Apt5cN| z$&SgSIPwFS{*y-m_5z$NVOwm$jvlmDz^{*jt2nkAQVJSSU45A*`vUj)C?wicM~t7Tfr6?is>j80{q zD@@c}2b>IBDk7wFTnL0OE!mXq%#clIz3qypTFXl(TRwd6K4aQJzi*^D@_Mhx46%h3eBi;zI?9e=E~c~xar+$AllCErUK2RT)x5q;|@F5*II z2wJL1e>?Kd+N;#4+pYv(?s-9cf6gP^U1>W&`*Yvto6LI~2#KmGos!ytssi53>h~7a z0=g~|XPEvc&%mW)%~dvfWJ8Osn(YW>4Dy3j`*w3;K|-vy_mA2kHQ_q#sL*18FV0FO z)rvfUMOWPBc#cSBR*wUy2lFAK<)GoF(Da=$J*>3aQM+K*@SdeH{lVYC6^)yWU+^Sl za7jGPI3c%2I`oNxs$s`kS>mae6Kn>Ca|6zpJ$D>P<9L7uGayg_3tE!E3s#*i)MXpHahD^V{?Xjhw=!~k4IOpb>Dn42_gE=0?F8^I4a8l02WcPlN_x$H ze00zYxl`_>^PDHaVEh5v4J>b6DAH1h7%v5QO6$|R_4=y0MR{z*+*b55M8MuoDJOT5 zP9ZymS?Fb@oWAf!vAh6s(uB}2V;yW$> zZ$@tHsmsWkObmTnT`O`UuJc2pF}JGWG$e2u0%&-x;P4fm6xG~cDWb!;TiK>h4xMo8 zU3)d0ukzIDvDv6jwdb8cdWMT^<;8&F?9x^=W7D^3@L8F2w0@78+S zOErdMEJ7}adSQ@R?9sdvLWf2fsLvm%plg&sqB;fgojqr*yUHNTPOz z2fT2;cf*vljyYJ@Us>mPsnwyG7%B<|Eemf1;Z~}HPt5t4%2M719K|CZlH=lTHptAa zp~cRT&jv5PtH^mla+%Yfb;Vt!NnVZfiaG=R&ww{;j)TQ5A2#UL<@be5H_@V}kmVD5 zXZ%sj&9RLtTmO{{W;0okQ`72~_b@AhX;yi(vHE{sQo?_i;uNh5@^W zb+q3F%-J%)uzNa>oRQD$sEEfq-MSAGu=4bRjr7o@6^-Y@;TXvyGhSq~z0_JXSL_A! zXt=wuZs*T80;jHc#+7sG&Bn#bDThbFGn`0=6`aC$gwv-@0X#T%mw#c0C_cf__|pdy zUiUQ7mrwQOTRTtfVhlABJsVH_ca^NWP>>7eIn}#&aanu--uj8_%@|B%G}-=BVMTKk ztvgvh!L#H&y&!dsN#jSVA0A{i#_4?espqrzo4e5;eCcsA56>?~Siqj#qnfBRVjgMq2_|<@mpIg^pww zhh3aRqbXq>u&mYet#QZ3LwDrCiMR6xxQtoO*s)mwH@qotff^cpNM4jw_QfnIA*M)g z97gOQGe5Dn?9InKn(QLl#Ytth)ol{D(3FN*a0d~wD;aBb%*2jLCjWZG5LV@8RsJ1f zF?*F-WFG2&)WzKDIq}2vS)HR!H{aUYsF9CS`?J(fms4*f8Exkp^;Ng%FQ~6v_Q*Gu zh^HoJ_N$m^T@;3Xkf=Ii{iRaZ|F_>)c7;=%j3|(1X=mIAL z<{^IxF)7h;Rh&!FRh%%TZ$j+WFHYpxoOV5osSzz9PS!V z<6&8pqT6*KTvF$o*c8McSd57hq9(ps-7A6xq zo>@(HYf1r!W;ao9x`$cOMq(Z6u(l52Kb=(9N(71HnIl$4n-mbMSVSl-2YJM`b$VfT z0M1j)cXq|tS{?hN zO?M|>rb%>?_6f5e`5|#%MCVS9 zI08%hn5aF``47>Af%7?>O?~8(R{!_1EYeCJ9u&dn*BBc50}WQ$0p^uCPAr>rA%)vsn3TuvU?n!Ctm3$=a00VErBmON)7kHcPPnH&h>`4r=<_ zE+FJdZzo_DIvS?=_!jPc0Bj^$>+v$aW8_<{=u$=JVs}S~7ZI?a!mUvY9GS?N ziEYGazw)H<01pkYiLaPNAjZJzGKOiwc-zG#Y&GwF;d6rjIS{iADBd&*!F2pDB7NYo znuuR@zWi!qtXp^RyordygKyCqcexTPJbL^2q;!@F1e_82M`qg+8;hqm&j<9ooLFpb_N_K{Y?SfTiDh z?32+kFls?=_l8hI@L_WFP;)KjPP91TPcolVoIQH~LzLqOrqG^2OQJSNxx%pWqg57p z81}^i$)oKyk-!VEnMzt8(tv3Xc8g?RMv9rVx$S3S7dYmG$-Y#$E8@4fr|((jHdXlZ zPWVt%Ms+BM5VXZ{$l;~99tdML@3dONO^dv;l9TJtrE<=+;{P0dxvP^;c2Uup`onu! zrzlLL(T7@7ZCczXGj;nIO-h;d6{0Y_=&q1|M#NLMOeRQ9s>69hgU=J48rtXn@RyiQ zc`akcK>M4FV}OVG1@yXWckzg(Q;y$<3_()AG+X^cY#NX;52;rl?q^;KFOC-h>(rKb z=gy)~JT}7QidgkWBuJ-y)Cl5J#z&OVKaPFcj$r(xJ&=hTf@4i2It5n)15j#nE5;k(~VU-#GQp2!F#*6{f!;2$DVi1L)Pt#+z z#2iuNxO}?ful^jBKW$}_(mK}why*h&f6L@BwV6c|BSvH!pc)}TkKp8Hj=)>JY@`Qh z^MFM?jo@jJdDArO8>8FC5PYq>QGIG@E!(FTU zUi2`$Ix1G3>^0{LjH@oh<1p$&QPcZ-TgA)vnsp`5~K^=0y&wq1-)V*Q8r3 z8J@{%?K4`G?a|r>_j=Z@rY>B47otZc{@X=bUlw z&W5-n6-lX>i&Yk?cby!E(cyc^63_})=S-uqle~71hgJLm*2wy^g>xFUR`^FAdTz#S zzm~WanjlBwU>H+o*u{2&UfJZ&^F4txyD>(IkG$5FQa}M06emhpMV+^{x~~4ItFT{7 zsk?vS(H*epl+-$3bRvAzoL8Ka_{s0yoj;~F)7Q${ob3#uy{G!TM(kO!Fsq28Jdll8 zJ1YEwmmRpn{Es$k)bW4+^biE}&Vx>yB4R-@S9%&4PQ2>6G!~U7F2Cmc1?||c3`;_5 z(yl9(o2&O;7s6>9$Zu)4r}v&$R#6gTha*&mWDN^eanHVJ*n;f;v=cmwTlb!9UHheJe}efjyQ02q{nj~GHiHw2nET3rcq zk}yA%x>bh^mFcS6Dh8Pz);|f&S=00;&xe!C#9YY+0aL5;(} zscPwMma#9HiY%}kR_WImDKbyR*WH&Vq3^g$SPkxb!Yh1f6d)J#`d)!if`sQMj2}Rs zTa%ehn5sA}XhO=z0!- ze@~%aO*J~6x~As8T7R;p$n$B2iumH(OUGf#(vE?TPRmh-IIVG&ON`O73TOhH4Xa`r zHwWV4=ZWJpR6nAh9kp^JO2uRw3fHlGp$)1suprz@|IIr8XD@QBr!VqsDD|X*-yev& z^PF&LRC(B|Ja!koV>FijwPV_r-2L^S^@XQ2qeIMwz=>g-{qM!YuGa~WFjbM=4p_%# zTWd{vtb0lO&3C5(ClIN z@A;C)o0#NC$NzvbXH>t4g{zl*GZYJb{yAv5dHq#~kOX1nx`M{&<)K`eNo{C8XZ=;+ zG>7^a1*I#o)y4OgM2T6R35Wd^-l|BvyZ*em`%B!CV`+|X4dRQ5;e`p+d;wCN&bgeR zfO`XW5@1N@N(2XcR`9{Suy3M8>xJH61+5@CFphD*GdWR5N^q$_qNe1F9F{{-J#CKI zE^$uDIh=Y1^xh5&w^W8`G?H8>%Z{Su5gUQse$0d*k82P*B>WF#Yg806GDV!vje8Yh zk_+Vzs6&L}PhUKAxoa7g& zPoK=}T1uRrn*FEV=0f<|0Qq758C%i zFfam7P>Y4-PG(KQS2tTZ-owfllW=?q1d$&uKKR}C_m1p=gcXY9ia`%tP{YDk>s7k+ zy{B{Tj6aO4IL9u9`VR--Ce`(Bgx*U5uHKv0g7mi9`_`=41mQ_^bkanjp!MI%WIhjA zI=1qu9aiR2e`^6rku%z^8K*4nMzNNP`b(@e%8vxZ7z0cqo~*&(lTh;* z4MoX$p3(A?9%AHv?pw=o&HGvAYLg{YB=l>Nc0anXh3kutDQoN_91GjKMLn_CHhZoYxStGc3>z-Q$g}A(aOd#FLra*`pY6X(1x6Z| zZk6~PQ341w>f3#&J%&#mb?lYSfmGQTpPJ*?;=&WkpOFadm!QKAOJLQN3E-8ZV>VC^ zRp;C|ad{mCs<_29{M1?O*R~%q4m|r0{UO^z>^;XnZ8$|!(tWYimS;c2wjcgwRh6Z` z^#p~mF+xfxk6-13$1Q7@h%mJ49Ir%nVB5=f8amGyI1elPxXlqCCac8wGItdv&SjNb zqM>=DgFIKPjZEGmz;cUg+PWz}@gP%VLp;#xX-#=;Oa-xP5u@oj?2*2?!ePbBuhdKn zGG75=@aXPJ3+V&d^^uf9T7y1A=VFv`-|C*@6+J&8iw^mss^JeBX@S5k-}~6Skx?WH*b0vF6(V4H$oVF(~;Rs7|#YN16#)(+9UF|H$C|JMfx~ z)wj~$2wdUC?K<_MR4(z997OZl^5wOdFCR&cpl)l*1KIR5iYmCl#A@|igQXSBAKj9W z^CU0BMQbbdN7VHs1rRltJR0R=1N{iZzP640J+*^q11S?9$-8iTY+_C_;dbNg^|}gN zj<**Da2}SlamU}r?!egvht+MPeyf}wd4lL?YBz}g<6@X3VbIFJW|an*E8deBeqipk zT!y$lmH;d&6ao523{hgb6VQ)Vz6U*KHvpW=;dScbN!@9Zi9!>~f=6rp_QKQeB;NlU zI<75Voe9ox`L!&s+KIj{MqQCSqowf-56*y_to1r9gF+6(t#wJemxn-J;%5)p)37Gc zZ0%ON=ku+ZhVr0!K#7kowdHUQpxSso<+hi`KQ$5;6YgA}+q|st=Y#dBl=iO6?7w#3 zb!%0(Uz5LNeDT`iU*`?`Bd63m^INf1Gi@=fcwd$!BNQ2q*1*nKlvr8}cQzPobTm-I zD;pO2);!}{@VOh}O(@=7YsO)wyl$ao27Vq>ym0eX3DcPViHWEP-$9$|vW`f=SxQ*D zn{pDNPsM1fE^maw`s=k?)SObl$-3M>eVlwp z9nip}DMHJ+v1<9CxYC`_LHit2#BJM%jemzL5NXv<7Kx*1 z9lb%D!PWC!doo8P?WINV#(jzKb?mLeETgKvda;}~^A@d<^md;z=C>|drWm=?Id7&a z4a*1LcDQMBa-DUgvc!QPxIryt zo;?2WYdbf@Qv-iIDXejXZsS;?-SS52%vi(j?^4N{>RaUd0^I-Jgh1^No4BDyC9K2Q zkaY``?F3O^)zVc9?JQ_^BxJyededDIJ)6|8n~=&etXQ#G&vkK6Q_MMpA?d!eFotKp zcRxTXPgkKB_4h6cMUxoQIMhLIyHyleXHE)0Bb1g?h=$=^+{7zt86itb(%-!A_JDT`Xz*kWHwH0zTYExQp6& zLmVaMHA&;yLBGl6+Zhk~3)#rL+ArgIod`d=t#DClbv<2i2;OY9rbjm2?zj?wY|+nW z#?4^g8YHG6cC2brHl8BRp^9XrbHl03zmO{_Rb^lE_t%3b0u4pN(D}U{%HoVA_iB;K zuK9w4Jd8$7w0m#o!ioqZYS&$>l7H%8+1Kxltj$LkbglR{AN|)6*51w0d0QE~Ru?$@ zl6zlujM|qKAuNuD%(qUNsc z_^nas|FQsHcnwS_a%=oV@5}z|*zkuxGUTo(32X@Myx|!heI2z)h*wq{UiO{%`*?X_ z3qNiuh3lEvU6093mhqA=5LvfY>$o|UWS8r5n=w_@_@2>XrsfYYF~758r=(~{i9xumI@L9vRX5OQn*Bkpg)2{e({scx7TR{CY{XRsiMjwh z_1#W)KY6V5LQ-C@-@r?0tKIE8{*QHtg1&}#v<6d8QG7KnpYy@2r#Df?J&Y^a=T8I2 zGHna=GV9-DpjEdm+zWvt$L(t&>>o&z{{0+Ek@-+dO>f`$?~Lbd%?q|5GNMV8qH`?N zi3ViIu!l$><|xjtlf9s&8zsE5NUf=Zh_yC^k*UfWXe3OMW?~ej=hA^I}^k z))!O1Z_NBkkZP=7HP|lPtXY1k6){@ZD}=&Ysmmk7~~JO0V} zs-CxaD7~S0S^tc?#9f}liS?xl)!tUc{DwgEVNns6Imi_FqsfvdO+Eohi35h518#h8 z;8RwdZTM0IAsP(eLw}{=COoD`UjM~NnSl-YNv~=)Y7n{X&IMSv4A<2%3#fnCCJgf@ zJB^rUa2nNo2-AeCIvON{Y&sXjj&_J-=2jRsgS{^1X{l(Y;Io;18F1!>UP9Jz@8ON; zY^<#6O20%naa=ryxOg7{48DlY9qk#=1Nv!;fnVBZGFfOuaYRImVECY;l{rx#5#N_> zFn{HD*+2la=bcYNY3?QnyxKg`52BhMDBanmmSYXBZeXpwH|81|fVE<1L*Kl_eltr_ zE_8%>?`y5s?No~Lq-IIc0c8d@-d-p^EtnDR*|EAfEoHiLq(->2J;Z$cV&Onk1$tp9 zXiX#OCw> zrluvD=$r3iwV}ov=EYat9x7gNuZ^1#C-{wIm4jWC_S6#Oz z^$RbVhg%RVhPpv!ZWx@n8tH?6IajZkE)uwy@1s{~p|u&#tO9$&ua8jBgX=%zQIcw- zkAQc#lx@smpsl$f|m+h6^{3Vicy z=BErwuTGfQ*wI|KY{>F>>$)&riOw3cj4#<@BTl zX;G~6URO7D#r{Mst&^ER#){Dprhz^E&5wVuI5Tt;GCanfLy0N>*V!zz;-@2j=gX-d zxBJAm?Z+9ls)DL#2Ry{KmcuUB6mbFC>{bct3+b1ujW{lw-&j59^$RqEHw2@x*U0FU|}nJ;^~BhD@+OaA9?ZSQqoT~!6d zRo%C7xe!k>5~i^;4}B}OtiUAB}C+v3cs8*>|NVk)VBo_7Vr&$ zVaoIPZyg_JQ1?IPX+V6YL83M-8USpqe@qk8%HG+itiaSg*6Y&eu1QP#e2|dp;kL@a1*=p6(ymX@-47so1ILYtwG!Z@wAU<67h0}WN@b9&Oc6gtN7A~VByVBj!bCCnMl7 z%S&o)(6<9MmsbTPH@5A__pL=sK`vQJR-rR`r>nE~8--W0!-| zde_#N_hgAr;IJ!*gg*6GA9*@BJ#!&-tjczK*VUd(5$hJ4`8ux;ClhLD)(_)jMtNGE z{C+;DLAG?O&y`sh%;3sbeh#fuz#nrU=;XJ5_%3MrISzd5D&??b<-2a6eVg`QV6d^+ z*yUwwbR`Xvm)!kUc)<*quv((;v*PN#8V$Epqd2^vU;eZ_5^`wtBT<1o{o{{km_rjf zYsCxR<(f_0IC5y|bvbr-9gyJ~W!=Hk``20vZ1{ga7!|uDqT z5kYySjCqjKA(R(^uW|OqrX)KUEeg&?WqWWq*%Q}KvFKX^vs+t7bZvI97@2UB6Z#io z6J{dVu&sp6`FS-K`No^WF2~)wJvY^SN5&2O?kEdruU`!J_&TE;@hVPXuKV~K4uMet zaO%-w)Xl>1hzo`DI_D}%C;cB*Zg?azCC;a;+(+(x_#A|3>sSkL&>9jml=s>N^-uc6 zHw3l3pXwfr4;=w&Bq5J}l~(I>i$S&VddyYum6|{QWUEzI$DC{mM}IK!pX5Yh2cvWY zjWPmLg#CN!`G!TJ<%HvHvoaKAz+Z8JS-dKPSeGYK6w})qeE=!i zWu93@rkjUEAUA8jFtK)o&48cUwS_a&@I zW`ndyk0NBgbJ`!G*8zZ4Z#Y~+>1F#Bp<@YwgALu%1IqvO$^;E^*KH^0iub#&dPFH2 zyjoGkZ)#BEE?3{t=j`B38RVN40i&4|gU?2#a8>-to5D!5^n^wW)aGhrldDsE)5mIE z%$%Fkp(R4G2CjWnl{vq({;+MS!g@`KhhB9&@yB!1mXS@m~o}WZVME%$uiwz?L&N;L6ztAvc9Vl@zaT^figg2uut6H!zGkdDZ(u(pgwsv zJ)ibHe_64*UA4vSyQ0A(j@r?VSC5ji(Oo%R{H9wkF0QQ)buzw%AKMZv>h`By7wg0) ztue={WcznWPv4_Ql0KX>+g$({%xDUlo^bfOt0Cz}4N5~t|1eSt|24#sp1q{gBg^OR zYp9+Nuxal}YhJ1K#b>Q0BV3MWKz*<$X5nG-vr38~uEj1(bF7Tn1{>V4*s8oU1{pYkiMC?um;-{r#N)x`MdcRYyBbdx@&nI`h3dR z;`)xny@V~+q@qy2j-SiZMxv`F<4EFM^FQ9$6S)5~+3<#BiP_r+2|>Mf4SyN%6F2TZ zL=H^&QYhQ`|`8^zaFb7w^*m4Tj2`fcpv^ebXOk!j}7qK;wL z@_o%;`i#p4ip<*fRoKk12>kM^z>W9~zpEa-Us{h|BY925j#Sday~g5N;=oQ{H5g-} z9bd`@cGz>eFHz;rdkFehP(sx|Sr`vl0M=63 zpc>QST)d#zZJIlHllj=_0w0cM@mxJV1KDz4(u&qT;Y@9KoEHT<81Qvz@MT zO{D_m9uHsyxZpvB9x+c+;pj-enNZ`}O9OG^=YYH>y^k_eK#kKg6tY=^fR)3noPm{! zV~2<*+Hc230=uT+ibx484@$CYa29u+wA;`Qt?7+fzyeH3Sa!$-0qM$q- zz8Ly@SJ2+D;9m`4NSvC;@|;?*XiY(>U(Y4*9yH*pr);}Wn!!Xsd%8v^Q57`t6#8~k zDya6p5B5fT#fZuVM{`>e@0Ck5_gwew>tI?^`{-2craGe@gACRwi>YY+xKXv91&vv9j~5* z^XxM)DQ}{7=|sO8>$07n!#OfH4A6JA4z4YME>jBL<2BbOo{qgzebwFub(@)-ZH4Ae z^Sr4AeT`Ox#(7HI^0#SG>s@i*7JDe-90%sKX9`K20Q@_tB@9!waI&~NDJKFgGnMF+ z4EKtXY~a`Ls8Oj7IV60~WA*Pl8ESAH6ct2+Vtw^u?hHjxJBLm^g?Y5yI9$@1UDFA2BqCim1VF%Q@Enz0k6S;@l(CN^)it#$Y zraLF7`EoACOV?}eh=KbPo`g@TGnOwPy>=_0hNHHm7fnS8+NU75Yw2eu$V1S-k%+3K z9DTO~&CIs2csFAg2|>zOCljOY`~~9ghOB@nYNGJX7C=7_+zVZ1!1K`AGS-hjZGgRL z%&<+{$ddH?SIZ(#&)I9RsWP?$=ZrT(;)fcYueCj`<4!=*?3;6cO&890O(nKuY7FR6 z(+7x?q#PZU;ZFq-QqO-^5d+Huv6`Pm?`!WS9@#CM0ZF1)$uEqV{d$7aX>Cho*LAYR zZ30GsowN5BmbJU$-yxw{54!}Sc{0QLLEvpwIpV{&cH#cN=u0E|j%#E2;hQOp|A(gU z4rg=y|8GxE>zt#dYLAedR#78jZ_*aEDXAUQ3NfpQ zy<^4}CqXcD?xi$(r23d@}#p@a9R1 zwz7G09_X-a`+xubS9lI<1nXbQUn9Zs0?TLb;z0idnRKr_>+xSp0si!gJ5)H|IjH^| zgm>N7aJs;bb)dVByl)O-uPU|r@vm`nbUDw4<~0U~Qd^s3nvG?Y3o#K{*=Mz^G;s@y z{0|dL5s+lu#MsIPt0fZ*tA>xAwkj7!dZMw((Q?bJsRc2E<$Z#uW|=k_#iYkmrCh6p zyeQ{?>b0FGum(FjdZMFdl1Hj+Il)B0z(N;q5Z&=qZD3>3>f^&UXo$d8iB`44I#g7X zHXjGDUKo;Qw(L8^1Z0F5Q$GJ@WL6(p&b&D)SlpzV&lYMXuzM%Js98)X7uj&@B%#4C znRl~>ae}3esztQ`16~2J%7op&#_XiN-ShiZQWcYGtJ!otG*4LBI6-|C~;?U z$PK7dblg$a5-sz86eurSU(4fPWSxW5`@C6-uaMruZN*ZLCm*z|bfhsVSFXhgPy@rL z!jI4HBqUAYN8&>zajE_m{cyqiQ^wTCWU_LF5I!M6v-t?Z%Nu`i?4WOZ_O zC6wv6<@_zGNlp#PWLay{k7F}%TFzcPq0jOt$e=kg8~I890FHafdiJE)zjA+EDXP>* z4}~C2{K&53RNULL*`R224#5lmU;=B$sk8F8Sl4)z+?T6z%jrUUyH+2%(|)F!xiCTf z@j@uIv}&h*P^S9TV6{P_%Z7#%*TNJrTn(>R+df*6DOqXV?&5slwI1$5WYA-zT! z`uqw%_13iw>4{@$jFECaYEW0x>FdmVo?;NZM5G$X7TEDjvwu9Z;k5D!)Bar!h{2GT zgOVu7OO2l4OcsBR+`#$A8$aK0HUVQ~vpOso>F;PSrVF7iu6#+iCB1k@bAp_=@+P}4 zXYBbfNUlQnZ|ym|;A8iaDH;6X>oj`hp+vIRV^Jo0_HSa${n%fQZKEa+i;}rOJ63IN z?i%}pI*}sdAB4@c#)SjG_|&y${2Z%A#<=b3LDZ2%p48|(eh##PwqXtXsrF9&O!*jD?h-~?Nb84r}y?K zZ_|UM1HSEY5$PZW`|IIVcfIZ1?hSUDd1o=x$6YH|Iz~e7_=w`)k{TIC9Je51rjt{m{6cz^aKt|0PGwl1D^5((1p;)yr;dhYT$E|m`vR-^ zyQ+H2t$;TkSi0AAr7;7i(zmA8rR;+Xa@pSV6lKP)*0qLZ(+3T1vP7UOa(Su($23v`}sOjZ~0=AWLhe})>(jC(X68Lyz6@O zuI67_M&ml+8{_bHT6$&49)I1dtd2RA6n2X0P>Y~)zlchZlgBr`c+Q)A8pB=wRh@L) z)*ExsN?^x^ce`Nueh#Lcv#E9e1iRskQ%(SnCh?5g_+`mIC+Hr_ZU^+~m1_fXt6q97 zqtR1ws{_;o;Q#Fum9>fGFOTEzLxaXFVvqE`XihBO$oN;<-(BPJ56sghGJuAwQ2N`I z5GobibxV2i0dM1klC){m&-Uz&@n3N@8~dg)cn4+b=bDm1QoRG7d(;u$0`eN*dLz~V zl!9)YRJ~>>*O1XGXHz1a6@z>UN0_RAHOp6zy3NMtEbC{M2bpIF=7GXtVA$mB0tIqQ_p37uCDa%XBb0NKI zI81X|Pi0<;4nd-QtTp+(?l5EJXr4aW+ie>F27rg!$bKud25P~ zhyu;vOPjQaM{Wo^A9OO1-@j8S+WaZ6gRSL?e^WO^5{mJ0UrGSJgvWFu`!83Aq$a=C z*x&uuyv(CT4*BFi56F8PK2d`7?58J$sC<;*Wh6!@R~a{ohdLU~)wwXpSNYRu1}XA~ z3>5(#DZ(&#O6-*{rI9dGy1ikCGArNnc}U6{dt<;Ccr4H+qevK5edGy_&lr*`f3z{i zB{a`du0CEdALY;_cfJAbJbwU6``^?py^NRFR{!}(sFH$If12yQW9C%E>@P7RM*FJ- zJmi$aoxpWs4K!sBQ`Vm}>)pQrMy9m5@5c8$b?Ut#%`52|E^}lsBb_~wu=fnK7Y|t& z@*S~?neUQZ{2BxJ$^2Nc_vaIut(M_vjCIfvpv`^vJ(gISTFBCQn&&FvME^s&xo57% zpXewmxBM{$om{QSbt*DsjNNM<;@{dvzI8tiI&nIO1kW@55d#l&QdZoUjD@R5NW@VJ z=4|&_ip@Gk(Y@Xy!Hsf-4eyyBcWcq)CNB{^vmtu@!KrS?v!T?pqEtYEIDD0!aLna( zOERDyvT{wZb84qeBjb>v#GOatWYcs0>gGUq08#SYa!+HHMqmz39l2n*Gd_vkBi0gu z%&9+`%kB`I3F*^ymAG=9?UtTPS|xS`e6U%AWe>;$w8gP$R2pxm*0Q)Ir+nFGUq}2{aPK?w5SfAHG0e*v0eC>i{1To&{`!x|wOq-!}a8oly5^ANg-bUHDy zNAw(gl#c_HBM5T)=;@?#(Awc?8%C;mzn61V`sgI?;O>ux&B?RbIzRWufA=q=9v*by zEptFK1;D0QIMT_pj4>m`S~X^{$l&Db5)5875LH|`(r~}a^Hq6u`Boh@`V)Vp-&uKT z<6nOzdat`Lnf)<3kV-LJ^kCbmD6Nfnr@^Q+o)#Zs9#E@l8FuAX=N5++Sc3AvqD5Ua zJNGXWMPD=yco?AJbpDKC31@zM%t0b|=W0L~-_mQ?17c>hml&^y@QGPL>xlC!!>dRk zvBmv7V;f<5q8eGh=4Fk`@%2m~QGSoP$qA0ZHKdu3$2YdfA9y3K%_bh*F`xB)?$r2e zDK#*TD9wK~IFRf8P^PsITh|p1+!M~?f$A}O2ukHOw}+<2P~7=8C+H}O3-jEo->y%( zOf$u7tQ@Rm{I6p7Hmtjl1U@+Nu&Qw(P-jX%&0qG zQq+yToE_)nnXlUXo~`C*WNil>Q!^Kzj`*|n;ugmb6b}HG-1@xL-1-teIl2@ntLjOT zDc4f6!aB;nFqFUL(6!O>{3ZtMBuSMF5@^2bD7pl-`OKH%ar|AWlLWdM*j5eLH1(wg)hoTczj5 zptA+4FO$MmGof>#oijx%<4>4osK7&j)KqPr4f@NsNfn2f`=h&&oRBT#y-W~!-IZz5X80J0Fhq$jX5>ASW-JREiBfdQ7zqrjeUB=;D`Eq8t zb@C=bt+=!pr*K0k99N*m<(K-^X0%M)QggfzG#i8+Kqe~bj*#z+`00c-x6~0D8GDV=GXymi5!0aLFc(EJ@}R(1{<_$@IFCfl74;c zZ?&u4+OsQefeizvcg&xv-XkEv4v$1Wc`7*fJ1&W6#**3E3XD}1mOg-k)17lln_PRN zep5Sr4nl^muDl4ecYxGZs1WM5#>JAEy((}@cWwCL*LUN+Fmpc=>fLl!Ev|mPL(!{C z!;2!5gVEixw|S+N`&f<9J1WP;ErLWY<%TmC3f%JDN?U&5mH!gZ z7ndAw32NDPyU6?sxFya99S<5Wu9^MTBCnrQ>X_4Q2o3Y&h?Jjs!8w_#N7CHN`8&)% zeD}6k0HTz;K!oARR_!d<6x2!bLVL~aek6KewsE40Cp|-bUL9zMY?e?aRB+#WyE(^3 z3u`bUy8wfFlQVB-c?m#^!J}Rs8aM%HBWU(INqh$g!J3&RV*C3 zpUS%$e_QMIDMH+R;r6daeu!5x9SqO3FU*7VcbG|PrzGuam_sgA<(-esc8kymx{Jy1 z1SC{)xLEpVXyp{OzB?eFKhbs@00SzspUnp1StYjkP`&ZIk> zSLs`E8DF*6WVV6QU)BL8>>9%vyTdF|5Pdfl>C-2b@Zpwg`NQo`U|YY{t@t$0jr(*# z6OZ(adz99__9RrRJxKRiE%e>S3rw=%;Q3UA)ce))I6NU7b+2Zf1x8iC1h7UD? zT|Us|?@^UK!BQrqK!Z|xwt&(_se={T0tD^vQ@%9WejlCudK_B%D3;e_DdXd?7$&IN zMH$vA0}C%x9esYods^IwvBG0hceemu=n}EWEnT+pr_ol<(`K*pA=UqD119JT8`3@a zS4j#PrHyo6fo}b6UrNT6 z@}T2S(~np@^d;YSmD{Vdeoki}dG00b)R(h#U$jaW0=JiTc5)DqGc_&M-j-B79=Er7 zxfIx=1KtZzvoUZ?d95>${_8JMcwAX!@k}brvIwSOhS5*E^hGbGDz`7Vf@Gr-2K!3t z)Ho;?g1;BGTGlf4`N;Q_;E3*nvX2*5$!L4{wnY36$}qNfm|~sznroKpvJazyUs)&P(&faC z@0WSrVb67OCWn6SpDG*0nLLGlNOF=k0zwD=P_;ERDVAq=m^I_lAh^BWUS*P@uw&kN zhNRhoZn4tv65Ob%D@E1 z$JCH;LuZ|M4J-Ml(Lw7GfLIqVu$YOV<|#1MAeS_yyX3gZ zXR>39xc}2N7^VzyzG{9qe3@b(C+#dnH6i6?zFw^BawXmXO;uN45E=`*j|m2!_zyvg z#*gqFN>a@CL!!6<){QeYWAy(vki1w*Z68VQGt5l3IZ{d#AuJ{j1&*pA-z<$_qKD!h z{7i%ps;YvPv~bu|`kqTM$9#b%qunvPu)O5MGm4xHWXW9P*Zk!9g`5NOOwr{lGSr+U zx?{g3Ytx*p)3)EL+N~(LwME_Sp!iXZY)C-C2wqN_DyyK}nf{M;IxYw>okrtU0{7iM zDp6jXXo9c?rr!4zaAz)4_{%1==O{1993*R`Q~8f6av`4f?qKJfKCRlrs^~6_npY0) zKhL0~FdB!z*ZmQU%x1`)w|t>6(cnuH{g>0)$8|e--+mFgZN4SpkpDz1pdcXY15N%t z0Jw7_WLFTmQg+%`ZEppkmlD!rIiUkv04*sCZj9YrySoDaVl*vg5g1lmczBnMrns_u z1%Ne)pEP59$u;`c>3Ivb`=)Q?IZQj~Z^6a=qb6!^C_OHdC7hJyl6fPk#QaBk9ve3l z5Ykm5>vz23zuZj!p1UU)8xGQ=Q+tx}X^Uo8p%ugmXgHweQ)K8x9c)T?g z@?3IZ&EGXka3n+_!%VZBhYe6K2^YbIgeGqs*O8r<@M371JSnptH?!LU+HKcQ^QDtz zmfJ0m3tfb%-1h8~zeh(yOK&WB4h~Q-@Jydq4`RsuK>6oUu2olZzz1F@-UI)&tMK|J zWadv1ion7Q7(e}XFu$MoU+FVs8!#dGWl2fX@c6^OCf%>h$gE$<`Kr&6e!@MZR9SrQ zPFF{O1G21~YR9qj?z6rAP-n7q1>()a5%}-Yw7=b*u3@jQ!?mXX5&;DfX}wk-Dr_G( z-xkPp=-7e-zr?I%*ie63lpa26NUzr=7&K#VRgLLy3LR?nS3F9h;6;ht{Cd(}3 z+Tltpd#pY_t9`sQvA_4CN-4P{#@d8lB~GeH?ycU2_3}eZ{`uzFFSKV0riuwnzDiv@ z0kHPCBt6Vtm+v0qr_{cTa;+?WEWeVbHvD9`yOV3u@-ULU48wG(7`j18ZAuG43LP3D ziqAu~rF)v`8=NW4r%b>LwY!_b5qxmocR0Cno)OH@-mfyR`)H|F_N`-Py7V~ATfWTK zjY<_YA_0w48;j-;X{j@JH7pN#@s)tq5Mc-*o){NM59mh+2Wh9(MKcD?q2`^;`IF_) zAm}*N*!dMc{LLN7`N*Ux^e*qHaiBAwNb0WZ8R2d9VpUWf@BOoqm!)ODH%HFzLB^`W zq9ksi_Z%IPttLwp;d$;xpNk!*e>rDfyIQd^G&(EPrA~FeGfzY9--cA?o=L>|Yf1I!*Q&-snx$l^Av(bW?y`Wg%h9>%@G_p5k-JxKKW~8l zT=kc1h4r?raC^;_1U*EOchzJBD$NXe3U5yaxn^=6yN<*^WlR9d91ZQ%9WqPaFeh_y z4zmM$`Sa<6`A6I9)~A#1N6ovdLp|>Jqs=8wEK`Bgj?Q3~LZxSPmpuNm*ZTwzCK=v; zZSo%Dx5s@2%UJn;IW2d;_a9Kxzx~P)`-4<~$uYTG**uYMkKcVFt*o&0McZw>Gt7o> z@?C0?Hp7&{sWBjr?(MkY6mT$QL0~GR_FUO0m(GoxK+FcNl1H<$}kQK;%=I=dHI zGuS)Nr3tS56V~Jbsc71v&U#eGtlKF21~0#(FQo_F(a~#Y=4bqm>!qPQCaY@u#-o{oj|Zb(gu%$=ah}Ya0RoxK)ut zOOG{-@Av_A!c}%S^6%ucLK+NzdzP&P(LHFqSc2twJ^>TZD_!fEV!C;SP87(yLP5~3 zumUd$f*x`r6HgGvID9$Ii>1CPNMDs;*_OvgX5t5yQBsy7J2c#CEJ%)U%k) zY@xdB$a}n!P>W7{ZSwboOBTcuE$C=@Ht%?^|4vm&t<&u$&#zY;v}P_(L`0ja`NWuB z@+1=jcRY~txZtZS1f8HQQ+Cf{4l3*`S5zUDdC$2Y$1tb+KmOf@Yk*n(%cHbkiYX9F ztsM4(>dt12&1y8O=7jzPWA{dxfZ!0a;e4adx~F-1X8qh8d^ACA)vQTT6_~OHHyqB; zMNow0Db@JqaZrDC6z^^{>CnzjBz6_ye)rIf3gcVm!d|HbkO(gPq=+v!WE>P5zNQ6`YCUOPW zx~*Bh{f##8#pqriqs-uvcNrG#N54^CExx|l|G9jDl|D%?I-66Fz&Xg}UPy9DXxja)^gUC!V7Q4{`=-?oCB(6KL*j6vS6nZ{ zW;t^y(|zR2QTx}!XD8}Y)=Y1UwW!mxrF@jbF+1d}p9-zPx(ZyZ9c5bABH-PD?|xK} z<_UuRA;~{TXx?3A^C#K@lMgP?h}yHbn(Zt(Q8$J$-$(p$E+&yZSdXVdzW;0^^S6X} zwcH#xn$VL@3F~T)z6ufdg9-y#_SFL8#SLf*Kp+g~L>D)I8YRppx%U~AaO6&mZd03s zH`%{5E`8WZQM6ZLOVDOp7LBa+l1H$4%6xUXL<<#U+zM&>0{%yepZ=5FHapYy)~kDL zoHJYB$>U+p@Os^=m~ot%C`MARS5kSwsqPu4ERadp==87bt3^b^ga_8X3#? zV$r9$db1nFVEJ`zeVu9DwaOMu;x)Hi@0d?u+E8{df+%f5;?I!dKNyF_zU7bKxTt%4 z6LTsnk) zcBqfG7(UUnl+YQ~>2vhpxQ*aX7z-_b(EiL-{<9ERk3y_MQ{PLiXz+hgxkIzntW)zgH<6qx|pfZ@q=i&ZvvgatV~zV zBHysAYnLG#=!K))ugm6eqHqE%F-FzdR?B%QKWr-Rl_MbEtJ+|A)JmyFN`?P7kqE(o zXiY3vB{NmG_n)=Rj`|EuaA>IF@rM=3^UB49=Ni!w`4#5?K=O3`_shAYWzUN9pNH6x zp@M;8W?Ecod}cU7DWnKD3upcofA~Sb?I9O`i!TX$0@OB9;{)>?=Mwr5A-z1+_g}a| za!l%au?cIiUXZqU#>8i3T)5J@%9HTn!XOVxr$MC;!W_>XH-y>(PA!?J zqq2vg`RU*+U0P%3@~rza{S>XeD~o;Wo{p)HIw$6wbGGY#u}0PKDCC+fh@>g(ySyOw zI*A7aN`$SC5E}YK+^dQ-_SBN2f)L)NaeHIwJ-4ep3jQkcD@mS7cwkhTcX&y?o4uT4 zV4OsE8)>~y><_Z2x$hfZYPACl~9%s>J3w<~wNjFc85c72k9JK6`Ys@#%T_ynhbSFvH27 zA{sLS2{tpM&6RS0_eBB*E|OTbqk-!VrqDdKi2?JfXCdU0IGu@~di_?*3J0*WRqdGy zr&Hm1t(a=SP`()3V>>21C}^58YkV<` zHeDpY_aGXvSeJ630WTLhNb7}PM`-MSHVC*^vp>1jcE{57YoFD~O6gf>5x@VN)x#Uz zDPWTExhxIK6Yd`$AeE^1!U0V|xd^c}pAU^rI zk+UpO;_K$>ufZ;p#f_C({G(vC%F2ci1J-`Cr5e4qIB{jMvP5rcCjqiZnR`t zO$B|XM+o*T;B`#(LJHEM2!B#FzVMszS3-k^OR_(pIFHMyP;1I5toe)E=)%N{A++!8 zZnDO}ES)JW>*<*dTpJsE?8#6_GZL=7=OgOG95}jK!y2bX!|&VWty6Zn zZ2$444b!>Z+55|f9kELNMjWy}D4u+ApSCo`to_hPs^MMRlYAR09vtj9j>wjpl6HO$ zCW{s(D_brK0orvl=wqhroWO6No4K}~NftCTEoa*ys=&rDvnegw+sc=J77Iq~6;My*%Z=4g$0YvU%?jN9yi?VGJKDC9LiF?4>}x>lj;Aju=UU`8}y&L zFh&}?Q~kUnW{TR=#{?k-Htsn>g(fLojE1(4~Sdh1_Jw3d1FQJdCBpMM=z~Oa0Pdm+Ep`%2O z192CJ;Y+LD5UUB|?qOjrlYpJjaC*7TmYvcwcgqkJ#%usK5>D=IhNQ&3jcJNb;QSyW zv^Njx-sd~%?JgKieW25HU@_~mE$u!P?0)=9$XF7!)KM;d-tPsmbgO1LQ{Ch}58ZQT zbj;&k06NA@|Y@S9Nn#1=#t79ki|>Z z9S?uhkeAuIvA|P4&hSzKGTB|bX-iD?fDE?)8%5nd&x!W+JaSwlhnBlFDApMuReI#( zGz4unZX~z)&zka7<{RlL?(p}0?~U%i!JOsr4H`Y$Tb~42ZjXoPU`lAoGo#u8FD{dS z{Wg`5t|moNELT|#|7V_wI_}HfjOE@Ekr<`IHo4IZuU`s|3Eao?U>CZ_d6A4cnYBxLC&1V!`qV2m|GF#xNV!$ z(=Ny<)A8IZMTM4jYxyr_I&k^>un(b1v@mSHASUwj6-D5n9VE+_MJTi8#qigrR=d%o zseZX{|Kki*J2At2aesaj*QMVr=asWvZ~8g@E7pZ2XCrF8j`DS=;-K?W%bTazpw7Pg zb=pCg&r;6+I9-eR!&sl%XZytDmkjoTyw+)2!g zazvqTN;E^OUKs;aIstIO`tcJUi(;zX?Rmc?4gHh~Cn)fpPkT|_mtg>r6Qu`xa?MGo ze%ImkfE7D|JI@p7w^S*A#n7n#2N36b`VFed&fl0@`Dnu3EQ)(x@6Q#pt+9}j&949N z1rX|yiOkOH%pWjFc0SV8gj9CAjQoZ%H@sS+H`%96}00{ zzsmzYNq5~msM>^O)Cf#btUIbgIKWft88-O7G=bn>l^PN))WCyZ=T!V-F)KoCs`8B5 zKB08!Q@T9qyaIVWjnk(mxo$q{g-z?v0ienWf}GQMUo^XH#w_(kvs}C=@NuCDR)~SSqf^K74PpIuTi_tCv`mVix z)qw?mjN(J-EfsxLsdkRz1JMRH)h>nquQB>Oe$3@M@wca3m+M`{tIq+!FlbO*OiS2; zq$3BX)LR~mq{o{}8C+BWkx47&YDBG)eS;ZtWqC=N&#W2$*qpK6X=&;=-PVLu%PivT zUaNM$(+H43P|~Jptj0#@Nzq1P$r5}%udp6n+6LgYS7xX%!}Dknl>DTeBBiDl;64i} zkbJt{2kwl7WJ3FuuhiMH)*SjD1bn`oKXEV5q$jidCMX~QE&|Ef>Z~YL`;nKeL4&z* z-{R2ozbRLI%k3Mzl({c1p7*o(rC|Lxn2E(rr_)O-*+Fj@Tt5ycInUQJ80sMt%`Y$_ z9^N_28MXDh-!VdO<VZiqRz2zd>cq;iFk^&#BzzlkgEmZyl!OX5L-{Z`;CNj&<}9|`4Lr8w5!k9_y{b< zbfOcq*x^u8`4%S)K1woFCz)3*bE$j1cTJMKX;R<{D0kS5^@0S97gd~Lc zcWW5T>u4mk8hJ=em*-i6Vbo>^(Ke4oApMu=yC-ZvStU^N-d$B3MpomCPt?uMyTs3W z4WK-`F?KsW$LY4WR@+fP*fl_&H~UGUnAkTtsc}!+z^8rr%H8!ZQ308V74Fo53ru=g zAPMOr_euKVi?^;1tHh6<5A-b}Z$5Riw@EsYwQlqvv~Ott&=(=pNcp zMN{nEO5N$fwUh+`n+4F{DEaKSGRC$nz%-4%^dzxJuVJ%Q!hLwRk6H zpJz8}52M3?6tspTDFg0RfSiYoUho>+Lp-GC4~(tYJ!5}wld!4{A+$U3#g47Wn)rXB ztOSyC`0*mUEuz{R>+WlXCK}Ge^|U*9`uDbI41lc3JE}z6*9p*97jlAUddcjK7opZC z6Niczy9G=+4xmv zr0v^24u_}VeJ#AP3O@p?>b=z?4V@AjQ|fX<_ELYBZm#&uyR@1Q)PN$RZ-p5By5=2t zJHI4?2>hegN#XdqUE*Ba8xY5>=;EE-#3O3EF`)g$d1C63P-uqyMtuHGUUtY|-F5+G^rdKRsQKz9xn_m%o zGt;ydNDcq;^~a6Lm&KMlYWp0$$OOc}j{n6QePwBA6GBByV2yJErxz=y2Nx%T=3r{- zN{;;A5lBBOtJHb{CcoHapN+mR?OFeE)|)Kh#xsReI(h}Y>PNl7f;EYu^+?(3W7c)u zx<>p;OGsbv#frtVV^kvl>2Ok2t#Mv5X=Eam*HyN`IEx1jm$sddH#0SI4gfnx^NaKK z{ZWpiM-NF&yC8qgt0vg^sR`}uNJG5g&3vgq^hlS;;wM}qD8Es(HQ)+YxkE!6lZP+d z?AO(XAVlZ-PD04O-ej@nn&f4LKv&yNhWg zlVq^0hce)B-k^;i3I*Xf*18-MnbH$~a=BBJ2Vae+4tr1a25ws?qC1el!^K>SKS*0o z>m+{F(%_&XB@?!NEFry2-JwaNbc$0;-Z}d(&SWjI)@0k9tw%8hDy1tZ?>gEXdI^0v zYfBl1T(}_ZjsE%7C1AsiVD1_^^wxE_gDZv6eXtTxE!qv@4f^F+(w)WgS4ZDUMaJOc z-W5JoWx+uqqGRHiNNC>724dP~uM10)mFQ(zlp+|V`FNQ&E&tr$&cF*3g+zP@Z;q<) zm9`~6S0Jnx2TWl{UvE}mGxkrs%y8{(zf-cRT9x&Gp5PC1k#D;*2mlAy{UMCTSx%zNhMstZsd2rV{$bJy6do~ z{X8&%1~Qk>!12ap&Hcrxe>LN|{DDT|psK-eU^TbXU}|$ zjY_>5d1)JV8|mm!fpW#NwkZ@j#g&;Z0u~M=KX4z{RzHIUWWOu!|F^%;t07G8ht35f zLv){0V)#yu^di%`=)V%aP9H^v27P43n((*gy0!z2u*LiHaW59bdj!kccQhCN24o&a zg~LP(HLFcM7sEMfdxk%A$*HM~Z|B=OW|zGu0=7A^Hsj5IZNHRZ9{#)5j9vqcfc~TV zR>^*#ox`{1R5=KfLTWH;)#P!KMjK#E!~GnB8c;K`{cb9G*2lbIXGO1B7RcA~Trv@3 zyEU$zic>`~wEfiH$_Ffq4}}yz-SN${IW_xBJs!Yipr23h!L#|YSTo%ju}%9rP2AE= zx<|FjI`&v1o;%-|A%|M4MAFF(=rxMisODLv$)(CePF9X)*0xlJpTmAFj|uMKKI%HH zeD^-Ok$*2|{q-tWelI+RNM!PU$U)Gq=sV=s818deE-~IrCL_41h>6L zZY*&e%N&LKv&7q?y{PX^k;O>)au!YMYhIrLhtI`2SKqAWKNwiIYyR8G{5;Z5>fuEe z9*-TKmAMPvrCJA2u4TQZ-UfR3L`S`RzL1E`PaAO~O@W^CyFbJ}4VC#MQ*Xs?!o)iM zOVT>F!Ugz;P?KX7+jB8)iR$1P{};yT`#r!@xjz#aeJQ*(m09wm?@^5EKW52EF~9?U zX~eNo%2|!{Ff$z{!cMI&EhAkUfM=3N$cBw{p%@Q)Ktr<$Ab_w7a4*+{FHD@&O)h8~ zKkyU~+3hygJ%7;Uzk~LSQ4DdxRS^Lej~2v0^(F`Yf;$D-57$x_qk#u@mCeTm7nSWF znmwp0RU$^s{x!xIi1*jXwlYe?RdB5P7xc9tHd`R<=MXp4#I}1Qb($##ptbe1ZO$xI z9f+Xl=w=jUz;aR9T4GwAy!UU*wVGPkp(s%%8}==pGajD?UxTTfx|ZL-b2pTX4B_6p zr#cG4hNgoEo{ z!eywulq)^V9xU7j*zkCl$W;lG%C3ks-HmQdNmnnEdmluqRdw@zC8wc6+*pJLpXB6} z<}}45Q(gspy0*_JTx2L<8E-OcO`Qv&)TDg(Xi#9}>ycgm;sq?Fu0~IIO zHq3|lM86BKcb!d3Ztz~s7+?rnlytOSd6|B)P4jTxw0`EM`*3-2oTag zzLZQu$y8nUxz>F4wnxB|1l~LfDG1$2E6!|T-&5rqpYoz z2#6P*<<3%=@tl}xWZHz1EOr^L7w6G8_} z6i1*Dtc{?LRp-pma$xWL<#)~G5gFQDxF;V4#?T3_5naCU7J8rkjzT`_R$Eo|+Rv-X z`I%zjr>?5kLR0t~z1UUdW6A`x5YB-+W(|X~BnVgCIREO%3agI!;?@t=lz2MIT-u>& z`>cEXbpIb?qXmvprlnyele*?N)OT2Of^fVB*7h5DzrMNg(C4@SIb$MN@`tk=&N$IJFw=MW{FMN`v26 zE>$9fky|F5bmK}F3erU!L|h8C_X0Ivn&XkqQoLGy@%(L9-+RMfo&eyCbgYC4KhZq< zb-?9Ymds#o^X5PE&E2Z=%A@D+OjdSf+#uOMW8b(})96qrP{}`zAir{fqe{>=0pqwp z2eh!C!$8MJ|D5(2g8&n~(rY)g*$C?4vID$>MV|tPpUEPUGzXsIO%A|3Nh~nMV!2|i zkG+P_3Gu2fPH>M$I~OUw`-LIDL*soZw%3;I0I~`t>mO?eEADy?FVj(kc6sz5mt4o!k!P2Kz z-=Zc=*y5Gm-t`dDVS>yAtDTnqBjKk84^eIGzQZ02$E&3I--cAiM2bHrEqf>885i%; z4tSq_6QX@-Y1?c$5maC8eEeFP9^y9(K3i?{O9nNa^iwBJwoA8l1FM2T#A3f$xU@}E z1KxML@t`C3aDp}2Z*pejtekNmW-!Vr!?1r3x$s(obo1w>hM8UO9Ch-nOU3@ra?VGz zAE=X-@Cnw>iYZLjn`ttXS+Vf4OB+|856sk#oYD0X6x}5U)b_jRDbKRuvMYwPwVdaH zH{J)t8l5lcG`{kTHuh2-8-sb+XVoD0FcAF(43MbPRM|J7@pa3`+pvHcwyP9oZqPR) zWcxr}3K36{S;|hRoj^Hvou!^%MUb*!bIAWuF&O=GIv-&VL2WL z6??0)`Ww36T+HP3uIYZ$Rf<`XQoYuj%$#|_|6z}*5LO|U+U#t@FIob^cc6Snt+M*| zchNN|F~i}uvdVK}QVJ(6Asqibd~+)v_#o#+uM1hDd!JjmAjCqK^tmt_wHB|dVw>+k{#Oa?R$!8 z+ut)Nlk77#XN%^@pyQqalxx5e2}CU?3thZv4W*3~wB)qu zOp^->wduOC?Ye0Sw20u(?vbUu#%_<}UOkUde#J{83nP|e8SMvf@uvY!%`(}>N1b(& z5C1S2`ARok0Fp{;q2QRBjx8(L%Bh32U$MwTu3>6MoZCP5va=ofbP;e&~BmP>j|)bTs_TVGsPy zaE%Ul&GRoBIxdOJ%-j58Gp&(F-QIp_Oq z{q1l2fuNP9*7H4xFlmWheW&tN32eI)J~BZawk*G%HZSOR_wV}CLBW=OhwZ(=ELd=6 zw=gA+9eS!`*~I{FpA3#v3XJo33OHg%IqF9bQ%f#`w8q^cPz7~m?wjt4pFp&umE?1B zIMV)@a{_xXV2~9+4ArI2+2*z=cUQ6&WEE72FnoLSs$vr&rT;0p6B5bm!r16I5@#*{r-5 zY?0d+WHS7KkC8ae629e`dN%*hUIKcGc#D@N%V|y7umN0hBVG6trtd7sOS+KOwBI5_ z;EXtn?WMe>T)x;r6h+EY8= z1XDBH4yv7U7IJ*H@`U#&`VLUssz>m)mt+Z)&uTKlR+!>#BQU|sq&{_g1ijf-@rSVQ zWWF#&R z9WrY%_j&@cT`+TCtn$YFdte6yXyqn6wJ~mvbZ#x|uP_@QF6a{yXo*%Kcs;chuz~5$ zR}TCwPTOb$C8V=h9Srt42-<)GgkY(-EwxJmOUzQK5##B*9b!Fu@l(g(*LV28_{MBj zr#{AbAg7L)$^y@ky5rhiCb0sMWToMz=A(<8s+)2@Ew%cIE$1F+f7x#;Sf$|{zC#^! zKbU>ptc94L*Gbt9-h%g(Hy@#CH7^3iE+}Fp2Dg5=W1VCfmCZh7u~2$U@U+r>Okxg( z(EbU-PLnfAQ~V_1mJ;ixzu2TAXv?2A8VSLzPK{oy>mxV$l@seDMiKtM*JwA$nzf13 z+((?3&r*-~lX)Dae#!RZyAjQPksysv#@+Cge>A)SR&b9fbIBErlVT4g2e*dUYqRA& z7wS@l)?i12C>or#v+WfalOr9J))VO-zKM^f;=3oaN&MPt6+Xc`?){kRaEST^ICjvQ zirSu5oII~dM$xx;d(H>>56;~XtLF{wXS-C)ghj1O;Et^Tl9xR=*bj?08}OBB)v+s1 zdAg-~h~#5C#$1)d2Px^0nlgP2y*|~P!qFtGz0-5>mw&G76&VAb}c&?LI2)lQpXF*3|#v9QQ62Z%#J?PA=g))Wz$JT%U zHT6ATz^DyT5fM=V5qK*iSRf+OA+b?}8GtVt6_ePI5fGqT$)=!ZlLnM>dTjR zZLV`jeImsKyjuRnpA1RUdS%hVn?>0URI&@OkdpYuQAN*=PwfSG5j9X%`nf4)AVLw) zWR%$Tt(URBuhg2^yJk>Tm@%wD-H`)PM{o6D!)T2?Mkp*DJ@0j|ezWQ0Zm+a}gt<{O z;WhcwOjIi^;P0HeX{&{L&SKV+Alemhub)J9UPMAzUM?WGD3;tq9aSytFjvc{ zf*u%iobVFhKQ2n~aQD>UExcj-vZew^X63v;L64v$`sm^k$(9wtG@(c^q{1q=A74d<+mL4obBa<3QV?D%N%=a6SpmX%#9BGK&|$9wi*(8J<3X}a)8|nu2yVa zKq;{1j!v}y!~>|J6!G?fmX0Z;4@=sXRdz`iZPZ$qC5*`T0V(JQ*e1C*m{y^W{Agd& z`!HNEuxg&4=k(=YmB*M^aN;d*Qn@_J;riOo1B>8_m*7&JCSL>Hrl*{Dy^ga{2>**# z31FzPGsjlxQxa6jP*Mq4$;olkI4;X32$Cc3`Km*t)Pdr5!*A7TEW$ z?NiEBIxPqH1t6{bMAb#zqpj7YY0o-1T}UPSG1DwGIxoZG5-l%QR0`=?6Sa;BOE?O> z=DkomuZWRDU7a*~MNi)Cb?k4A@~Fmb+qajiFY5!V#F(XtqPfm4EeL)QUyX5Z1N3za z@v7T+mU|W?%q3yki6OfwvpPh#x>Cqo<#LKS#!FQI3(;#5>7i(r4f;D5g_S0t7DEF_ z{XaS6Sdb-jpNzF)_lv>mn2D4le+adJEe4K~0c&19xF2fOgnou@zW)Oj>+yXMA=~Aa zd23}HRbk&jd`hq{watQ4j%h5>BT~d~WTPG0*T@=lcOE=dbk@qN7OvDZ47|Qe`wB)0 zAIo-=>(L){$HuZAJTzLQ?Rh63kBKD_Kaba^)|SEQ%J zuf!hI?T1hWpi9H#xZm!LU% z4t8_(EY?-I(mp#&2XwOl7u@B*!md6g6by#58n<=6H+o`Ujo4iY%4s%dFodmi{#J1jp_PnBfk{lkTM3h0D^f zx~ogdP3t#i6GBs!#WZ)h6FIG^>S^U=vvq$j#HklciqV%1Z8ts z#2IZ}5F+DRHSf$PRMGqD5x45W4jsY~ZkfJ`^~MC*iOVNOkE`*jVc%UT_Preals4e_ z#=|A%SfT^{Mm>8_+6&JHLO%6W(D%Pgw3yC?^wdLV$^j)KKq6zihsH+yuM9z{aNg3u zcC5l4a0{IySKb11p=P*%>NeZ8X>Qr=KRZGOd^%@+Y+YI;q|DGPh=KS zMauL%;B7xS)}3_ci(GQ?(O6So#&?SG2iQiJSu%(*Do`dVeBcx6eSg2aenO-9)7s}l zKIihMySKd@HZdCh`^9)eb!78i`X3nCwJpkMgj{r89O7OJ|GE_8|0jX`gK@jN7L-}I zbV;5WY>ZsYk(F*f`u-V@MRBD^Meg@K`*6ox?PR`#(ccg{#-TkgCDmeXKC$$E~F3lHZ4~CugE}0FaF!kUDV?!-?RWxLi(-e zHE|adJ$G_%AhxsJntS_Dscu?^@M}i>liMC4JumOr*Y&1nT7r~X2jWItX5pRK)R9f} zxq=H7E&l~BMV5%TrC15%svZT`nxkfBC}bC|_aPpQW*2Z^AuNFu&R#6{4(Q5KN#UK* zI7}*IhkW<}-uhsw-aQ}c4iYnt;ggHKBpTWlPrfWond%?nB$f64(le;&T}p5Q_-ZpB z7QVFX2zrV`tX39XnZO?4rH*bN;cRZNB_klj{1un%AaB|fx1t7|(X2=I%_SGkLmmiS z)U@n4$a7@pnQ%q}HvLn=xE8l@ce|$4wW?wcHt-0pmTR_q2oO}h*5J`}^gmRx!rp9% zu9!pZtB%6gyIR*3Q6|AciOiJ0;TjjhIaktxv$``Z1NM@6 zqb{au%*?CWWOc(_ZgTOmQ%B-aCrJf|%b=SUe0EO;oj;|FVc;Q{ z3Q~0$T@g`X`}=X_kjSP&N*cj{S0dwDy`yB-a%pT$`Q)nlxQ2aVN<%!O1z2ukq>R~2 z!3NoS*jp+z%^}RI@aHm?Q+a1QNo7D;cT*~Z|6Ojeq#Ib;4ur+}(6%Z3EKVPSox6gD zZBxg&x`5%7!E$Vp=I}iKyDC<)g~*!=c(&zTS(RAEs0Ni3!EH5XZBuDPDWe*IU|0Yx-v#}fIjm@Bp6wM1GA0QgZvO8_ zplk1?iRp3jq3h*ZMTX5gZQeeatNJ@_5}5UY%D!8mKLd&0WzMgmH8v&ZK^a{K8i!09 zlOxKcSKf}KCPO1rxart*?VVy?mnrOT(*j6~*6}SFDx8k}sM_92jMuXS4Qp34X?gYC+gUA_HFrJ_;P+_dCk(rxI~ExJV>Ix9i*4 zoVIIklfI=cNL4#%=PVp;`=+4H`*`rg$J3GF@ezWt>h??D-fCa^nW2ZeH%Rz}{I&Bd z6g+44avV0W&sc?(RgD7~jGd~Ys$V& z4b7}_sy1%<(&%`7M_W9WcyG1SH;vx5&3snJmzs*dBvdrt#9+QkJtAuJ=?;1_{1k!J zJ%5`!4Yu7c?LKZlOF^=SgEa%@j(e+>?`YiIEYs_*ah|n>S|QP=hzI$;`zRI+w`Z3M z{WW{&;2?j0f7&~T>G&A1YvP>J(^g`uFhX2l2mvi`Hov`L3>e&c%@vz|R3Emz= zFiS2Fqzu`Hlq2R%*_D56oRBqOn^BE7QIxQr!qltqDf6j!O?P&T$Hg2kr^Nj9%4jhm zWxj}%fcM(e67+bwXLovhg?3Iep46X|(YaCmza`{D2^A z%JS*Nk`@9V&nH;x{~4Q`%}11&CnI0V6JhyER^e3ZWFOsHZ7VBVu@p5}S9Z_%LOyO= z7J3;(R9n&rC`Ml-(5xBOepdr2G_L#n{KP!<%Z6Bw3I~;<7eo&5s!?5xGI8w}tQ!ne z$9wf(S>Ody-wu4a-R>?X6TCFS%Lgd^=monhxc4;WR|z3@By0Y&9bBf=-q;<$T>5u? zrE1vO1e29uY(3nW`GCqQsOb#sa?ZEvjh7l-$z{uOvZ3O%Ac@QP)Uo;`HwDS(s42Yr zPM6s9h_6I_vyL4zVv%}8l@Hz@d7yT1vY+)ZG>yQC-o5Aavp0#Rg z(4Mg9ZytekB2}3Ge9^mS9$DEncDQ@Ox2l5%WPGwRici zRfniu7w|qfYl|@XF`P~fXun#2iD4V#)5wQNCxv;9uT^%y@e8qaHmeNDkQ^jeq63$1 z+i1O;n>woL3?HF++e^Ylw4rz1DsS8Qm1l2hr3 zD`v#}h!imN`mXTuX<6r!;9*VJUej@2^{TRC!Sk2l>kns6h-&BvN0o}V3fqjFM_2-s zJ>FpUWdM$Y$pa#wb+tL7$){gG;tES0{~P>#T!k_vYVvXUA?LpGf_SICOROZVcU<@V zHHx&wXgf=M`QQ0zsPSWn-i8_5Ft;IvQ@!riwF>sL^TbyvD73q`C9&WhX!ez5#zU z%&tJ}#Ru`Po&1rmglg0O*ryKZ8^C`Vw7hTZN1p4b#(Xsn`@yh3;ljw3XxviX8F6^< zzkfjZN4@hptH6l!+6JM&gjdZXut&{2?oI5c>V>`2N>5c6zM5#=$KsW#jpVI(W@E=v zQfRcfQTYa~`r6UE!f;dPt)7%=?K`17ga|B>p%_CUaB)|y7 z+QO~q{;A*aqs7+AGyMf0LvHRX_arcwIxQ7tCF>U>%JW5Cww}bfy5y*M?ICghi;5cY z3ej3@VL1k+9HJHqMPj4fgo zSKIo1tKls{W~CvCwrfZ{{^uO!xBiRUxsYfnt7aa=T!+)wztpQDi=hp>y0qILrc+b|!uTk}fm(n^(Mj{?$c?H}qJeVp^MkdVpdy4Y}Tb*rd{ zkoK*E1xJxs`Yl8G`c%~3+>0Iw?QvkQ^-O!a2fxW2kE+{EVHX)+ zaIPzy&|OWnt@*uj7ljX6L$L%~#vEeoIt=yx|3pTy@`BNab=~(~68M!5boVm?h$aYK zpl9GHd!HBRAM%Xm`!T^CJv5-Ris0>Kcuf(WG@h}SpebM)uKyiYSU0ZyzEw9_6=3=r zcJ#I02d{5Ii@)n12yV~yUr^QJi2ex>%~3V5lCRvp+AgAvsHKQDDr<&4{bHB0sE|CF zL^1>lgb%>)!rdAX5<(vkuQIIfuCj|wviM#R!*RPonhqsK6RUTJ#rAwxmnT3X`1G=N znO}&nzD_2du7)Ax)26*fvE|&Jv2T|pbK1J2=C4UJ;IA|RI4^}mP?)b1>(>bJa`<#b{_SfY{*8{8G4WESs`Tf3Zzlt9ic)^A zM~6rNC034%dz1eD7cMntN%e=Tgeq%t{bHXrXY)$V*SlqSuw$T07oSL!CBHa03z=B=jk-0jsp+);(=^sF| zof|4KEtD3cw3gNH-qv#Q~eMGn>QYv=g; zOqTh+dc!5wJ&2x|Hb>H3Vp*IVGMg9hcA2tk$zh8AEpOzQ6aeb+ z@kBKtl_QgZWQ$Q`@wV#KK5}8)9jTP;^kuhZhnv?J+sIQ%w4X7Jrc@%{`~xe`8e=cn z@sfG$ezc){+5#i-y1f2qMEAcPolBqxb6)SvpSwTN`sF#A3!L|#O)ubMo1cr4c;tVAyK9=wmGDrxf$=+dbzM5y?2 zc$)mJ#!r+mz2O_S^%(0Q@kTcwygYUE@?6}*D)ctaWLUsY zq}c~2?jIe|kbv}%Xo0{xSvTO|j%cHe@Jw%1bupzx>+YFQ|J$x~?<;A2e0ec`3$ zhG!}y9o#D*oNxZdDD}YnjVWsoW3ARXJ9stBmQ z)oF(zV_eXq{-BMp%Z_hTz?<^8hmdym^nmGNg-PHhP_%^s#7?#eJKX4CftC-M9>vSg`(`M#hvQ85i-(BZ_%+);$Ht|c2)>$*$I!35BLbddx-N|y1 zCD;4h=jei;Cg#`zDSRzRJ;O2qc=h)&hsx{Isr-^&TwZ zFLG!%)`#8kb^hL5D3cVnSFRUE4lakW>p1DYhjRWTb~H|l9JQo5_6YSfS^VDDu1H<7 z7f8RzXdWMsm#lm!Z^#%=#5eR>KgDP*Ek1NEEvA+3ryvo-HTC_(1peBeJGrqMcW7y& zJv_Y$;u}Fy%4@~wTQHw9bI+{BaJi5_kfFd$g>TU&U7^7mhAbtKIFPKe2sKV`D~Inh zpkeM?GT2Rjccr{J16juL?rPenra|<6HdRiI}>G~K7c-ibb0yG+-tNr zKk^^aJ&!a^+T9qRV5AgS+K6)3e<3-p!5`t}@c&ysrmo!K2|`PFje5*F6LI1oHV}A^ z6;EtQ12o_lgY@oCp-A<AF;}t@_dopoG$m#giCWN>33t&0% z0o!e?FHt0A7rC~lY6qFxQNoaz`pD$Ut+63gZkrB#TLk{agsCwgr;7CzvBIr56~ zQE|nyulxpp&X5v-jgvI&_R=?)`}D@X0V7lrnCX|_tMlbKNuV^KpC{ss5pM&JQIhtd zl~3~W^6g~4km`?LwDcZpG<_MM&n!$?3i|$(ndb74!A^G8Do?4RKx)eDZdh8mV}Wt6+SCNX2>BIO z0&B1iYNj$}xMqS&SVI_13Zwz~-Ly^!N*hXCv4S}vG4=RUZJoZC8M-B7lk;mXD*GJ@ zNR_3Dq54<7uLg8OOyHM(+2PuM#!A=lkaWax-an-3ExRkta%YT}axeigdaXCuS!)+F z3H1>Gt@NIAMn;(L_vDiyJ1=O89~*A$&e*Y^jHqZaS2wm-xc3anK;>i`)!}T3zU{Z| zjj@vZ7NMqeNMqN{--x6PkB2ApR1Vo!>5-t&Y z(c)d?SsA(T0iJpp9X?CF4aswH$QmDQ1HmVgN!6Y;Wl;HlkKxMQqO{a=4?_BE$N9>B z3*{;cyxZZkeaJU zIRHHrBLz;I_nWy@V3d?`I7$FK{Bob~EZy)?$^5y`z7_h-*1D3H8~(ymnW=)38io}| z$2Pi~4uM6Q6&jvz(aa&6C+$XYPS}rtZfFj$k2}&ElNl;PJ)b&wB;2zw`xa!#rVp2h z?5N)D>&%U^j=y%*-&!|L?pc=V#lYB-$tV+q`|m+x{tz0y%TQwh(RjbJT;M zXB8t1s&Pg9`v7}?L%jWAtDVM0wAW8j232k!>&dC%8!!M74?+vklmkGrFiT4z>n4CT z!v9FAOnmgV+epcu(rJ%~qJp@9Cm&k@7`wsDucP`B{vz!bj9!1(%ttF~i{9q=Lm5_~ z4pwM<`KrEG@H2UBk^N!I`_nH5cnsD@*^1f5^5$`(4x?2sCp4+KuIg66lpSDz8-0^l z=z`{E$<*Yo_jysm^@@4>JJBx%rq?)DeYV-M zoc+3M-7=}8^4D|+>g|K7@koDyw$>w+pouL8RfOl%fNOX*zAQtyl*h+L{-4L%VU?=w zwU60FmYbN=vb7`SsDZ0|>EoKhewALuDT=Sws>jjz`j=tq?X3;3kp{yw*H~tcD?~cz`3IUf!0~P*iD|qRebjD-72EsgVC{Z|KkAHiM~?_rGRc3-##3|BuY0zZ_O!6CtY&5g z5uvghB|YuKteL-Qa559u4?f~CE1ov${bHgnb;9tc1ILu+%rT%TB0#3{bTlUy{oIyB zJz&OiHi&CXlKi1<|9HV5S?kX!>Vmf|FcD>S#gSRd&(-uA&uip(@j-W_ybc$?wRC8i z68QEdV3VvxuGiv76k73v$jL=hX06#)?^vGh%P;i9?p0n02&4s8Tak-K%gG)4TdLd9 zA*WVm^P@*{S2n7#5Q2Llh|=luOdzw)zg9(3Qb!N!I{y__Xofz*5IbasE--6df^yK8 zUN#+9PUgMz1QN*}ZPY?WZdggMGB@-I zP{$V0AHsLVC!{#}-g|cB=%&D$+o4sqQzZJ8GF02molB<+gM%OP{i?<1`gp0L9!w%b z1_MDPfluSEI2W*al$*-&*u0@03}a%syh+Q2V0CIvUad!fm3>w4={O~nj+BAa_$%#4 z^Sslr$1ek}F&>$Bl@UGmJu%rhj}i}6%J0#*zxRhmQfoiocu*_M9wq~3nUk4LPLJf2 z3gl=H^h|{+B{*1iCU&EC)e9Vm{V<%QXF(LpAIV{ahLnW!|5PL^lnHOSfM z+5m1`*kA8x3AHpA$whw6%M$8ngxr!*I{URp4^zNSTCOJs=L?UvG%#;WgI>)G>>_44 z9kl$!lpc2Rg$T5vV&gOz8O%dbIfM+3gfG9(-G02FwLblF!H;(GzJ}U1SJ0DP9-v)j zG&NAC`Ej(@cKxoGLpXN$6~PU@V$_#gt|w|tIdW89Z^*ZK+N&g(+~I(b_~1S!14XVF ztJ&w_=w`X-nZm2aiO$t?soNtF_;@%*r&Js546=$E@&s%wQlA#h=Y`d4Y6t&hws5$c z!%~iN* zjA=1J6xt|ovnq+&v6IbpvSeu6CFO?Hk|gv*9=`BSmz58BENP}Fcz(T(Kj^vrU@CM{ zGV~47#lYCg7k6}~22lYYEZG+Zds@|IzVh3m#lBDC=A;r6FE3T3loVo$$qDGjZUS3L6JoW(9$Mmi)6oz1 z0&LDwJW$wUJ#i`nJX~Gp)4=mUH4~LsD+!Tx=K_)b^P&A)D|THyhtRvBG%2BN`i(H6 zzYR$T#k&3GU$jHSEjJ-6>Pr2;uQJo_i_+4g{`as*Bgqkc+C*?3`bxpE-dN?gIdmoA@Nh;Vm{9J(sj5u3X(*sx+4SbK7`J%M6<1V?|@q zn+2M@=^eaS&E~!F&~B)p?Vv?vRf>(p|DCB)=)|dJEVcZlQ~BV|8}K3PpN+S^*p=}x zqK?*|Iu31K%^7Z*6bS0$e^!6TY}eKxY0@f0%J(%#mHID#qaradS&t10q?b`O)j`B^ zy_@jw(T7@GNvbLG^!%@L#>if2<)Mk7m|`^fI9}LkJ|)cW=bA8M9-$yjACEiGvR&8f zCF!^>{}b7yh3+K46>!YYxT`NA)9K8i&#OHC;#0D)cR5P-8p6ac$kx&Qq3B#phNrMz zx*dIpFWZheu{X*-bH~ZKspGZwM6J}DtsGdHH3oN zsSQ0u7=grI11ijgFh|1_zk!)uI;pMnbF0p788(h*BAaYW)heLXBh}Juk_ejpGTAP4=28 zGU|T;Qrx5Ght(IRzKy!!a2ht_?I{0myZe!P4S$%2lzTIq*)?C}v?c1WJUXBgpi6^8 z^5ixgFHQ+@ELR?q9qszo!jd?ps*~E8=GzmqzJFuvWdDd6!}-L2t<;tq%EyP_%oX}? z3S?$Wp?(h3Qib3hz!|nM{M?4U2`Gb@VRv)0nLu|rrTV@wN#BCdY_3S?NtvXI?=~NHP`f?iM$dE<{TqUki zP+EAiF*&5ETN`G%txfA7`PJ`o%j+nnN2vJOv4a+CgcwAskPqUh4OqeH~k@A zXRvhC$Obr*<=ss2Hc}j-lr{HHq3*dA`4?N@iXj&iupzAIL96}o0 zK&HFz5bN}P|F>u|=HoiqEjKf63F~`=O1G)|8NRj!BOqGyim9fNm)aHM zy3B}!f9sNaa&Zd9A6;f&7#3MZNmRT>)l4jj02U{f&jmHpo2b>Sn1?a0yQ$A2zV61sNE~lH=wy}{R8LUs+e^P!6z;4vE zxmieKo9D^julsM&zRh>HrLrgD;R~0o=^OBy?cPVAR*GuNH_xzw60Z2`Y9SGRvH2O7 zE%H6J+azWNM}&l+_eMnqu+qDsnvRRoN((YBjT1XYWnX+&EE9kew{iBM(+02- z^MI*CH=1k3c_d%j+YJoKo(ZEcrG&UKIpZG)>WqsTSZ_U|ShIbUFx&}gV})XUKNzv) z#io3+YJz+o*r_ziC&?UXIaq-k!YCCeBXHyX2iCJgQN9;8xVgm3+p90{)ih7r=-@(4 z^vdrOPQDNLyZ?E}NeZefG_oUe)IqZc5*;U?;&IT)l5uO~ww*bD&hR}P+Leb({5=OG zIsWWeo|Locv)&cWUY5og5OCJ#B(;FO$wh1DWSyF4HId4k20HBuA=Ox(sNSLRD=s}a zst7_E;mGEy%hMjm^yo6H#r&m`f2hHffWC(%*VO@%Y7z!;WMuHnD;s}i2Ukk?l|*FI zo#vG|MgeEB0%ZtpWU)8WhBV42~Z|pys7VSY|eI;*~t^MaJ zhDUl^nx6NJ0qhKjZeFTJ@X<`jmlDalqWkF|3C0L9%%6!CPH#TnNb}0^14+n1RRQ=W zftPFC>gRhO^Sbx(05Ka?`@iDL{gyw3Okq&SWmGrt?4j53=kjf$rMp-MS0jxI+bZ=3 z6z0({nr1f16RVZ4%GVw>-D-z#sbe7#2wo3LTVQi;Z_oFo3$5wbLDj~Y64Cf!tWNoP zbPv#ns@UD`KZec!3e_*3G2ScFvYEvn)59`hW4CM-xed$6JYJ*uS5N}X8>*0C7glY) z9h}~toMT2vdlz0SBH4I7I7-jXG`3;j=uA@5nU<9=a`P{IXKZm!23csol-b7}wnnfg zpskg5G)8u+NL≨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%0AQOX2l*{z#el^u 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 uz#+mFP9 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
    TREO*V7lcdCDoT^rp??5MN_#>`S_8J9eKA z;GKOCb4z0`-TAFU!J`z5Huch7W<{OqrZKk-g-0n(>$*+ts&gA{Y{(n$ZMx+*Z~A2K z7d~40-+tt$E+2T|Sh`Tt}Wk`hVXy^Pb$5!xO#I?o!Q%%1efy= zC*TpR*PVnk8~@S`*K$n5!TndKG^^(=FR>ksf7}?Dn zs1uG3_TsndU;p0y{D&|9`!hc`pl@|@-Ss{94fnvm_ErDr%zFEz z<06EP$*RFwbBIxaW2)1dKM{DJiisHLW;Xb`V(4Sc&1dIo^sH{|)MP6??+2%Sr*f!N zd~oCA^#*g4`c61HR{^6dWvVz9G^eG+-s9X1^yVwIA>$@1+mvu}m8Wz`n zln2eh#Y!INQR{-dMt7{U96ZYI8JW_8Ft?U59%4&}6Z4xRy&jW6Q_>-l}*0 zf8sMAzWk8Bo&5>D3sC&%6){|%P;$Xkl%s#RjfXl`Z=@)UY=knn?LueA@)MgpHI~w| zuiXY&V}pN|)fPTkx1oZYnzZA9k{XqREM+Zg1Q zQ)7rtto9gpv*x?!SROASy+to3{fI6u|BQZZ;>pK(VM*g91&-d#*$+wRXw0R^njiJO zls`UZ`Y=86L!WanLh)xigrfu}y;Mv@)G1ZH&409x=h_ypbjRpLKxI3fdQ=zy z{~dEd-(^(V-f<>xj8A*Puri*yL$Uh!JKy|I-P(!a3rn@Z%Y2Q8T^AYc`t<)pjyZ$& zzBbUKloG@FTC+0r=$g(y<}C44UTXivapT%pBRIW#bi#5RQft3BcQLW_1>?vmew&pj zjM4uyRv&m5d#e-Ks_>*-Dm@fz3fl|51Ub3t1_k z9UVNaV=yhWy!meQpabSO@a0nH1mX^q0zsTGrkaevIG6Uxq*7?Vyf$&M71uEgX@L!x z@!oSxJ>EK2#Kzv%_SAQ02GCz>CcphYgN_Aj!WzRb@iPDW04tHNSbXy${nP3U#^ztNzWZZ8sqbg`jQK+)iudO)xd-TLzGRU%SN?;q`{yq2)TaSn)z9J1 zap_=eG}A@%NJ^bUUDI##g)sxd~6V|x;ud<_541hz2ddK_!h z-{pat%>5iAgBhdixDS5*^-Jwjet72KMbK&6J^_2kt*3^^L%ft&PdkAJCz!?^dEnQ5 z4D-I+$sRagf8WUt3p-qX!&0Nm@UuIL(lhYr6`e*dpz@Hy40m{5sHaa8x&35plT;I@KCWxxxW7rn+s2f{kJu0t$ z_&qniMjjtCIH9Zun9)mI^mz(x3aFHzD;5c^``>hY0TZq2mKsM5C_E?^H7%Bld!V{{ zX+Kqu751854x&)o^^4oJl#3>GkZf^|dblGVHR=n@iKV)4%J2jey_39cU*yU8@g1N< z86D0kuGwdJj*_bSkm9Q3?RR*-5S7F-1wvkgMBM0M%Q!~9Fpx(vecq(y@D)s3b_2G`V4~LA4mvb(jy@kT5gTRZrT~5OHv;od=BnP8_}%Y{{RweCs+<3B|H%Ji zd%mphzC79<@Fy)bzTfipZ`2n8{*#yIUwu(Ge9S7XCy&!MZsP89taFliO??dKn&#Uq zvmFy*9M!iidREBl?c4;A{Vu?<^}5pBNF1R+(zdE1eT6L#ODQ+_)b75qY#ayht3GXO!Nj$?Zv=s#*SVxvOk-vMju&kiLKS9 z;lVa;d|+$2g{dF+lC1Rfi`JcjbsR#jEmqD!xcB95_5fpgU+!oRWV2z3u?ksOETo=U zs4POx$kTS^aO&cVE%sg0aDseBua^VR)(BSPY6PVyCZZKnWYvg0=XyetcwR4;*w*fW zo{T>5g-_|-+F!gp(En|}Jo=~q3=j`1p7^w5`#ZNI$=Q3ds_b7JZSOkgdh*CvWu#Ug zoOSm$aWoca`)}829Uqi>sn7DOx6{VH=kj{{a`Dc7cHDEF$ePQZ&sp!B<8D*Cn>I&D z<3g`}eZyv$G}ltw9N{i6>&cmSmA*&+R`NIX{Sprzd)&x=VpOfUmB#<_eOnBP31@39 z)3@N1qC=a~evhmgu}{eHgoO64Z-6Mdn$w6_&&Kf)8rzOnFq=c^1~RGz&kbL5x-FOb zZ!Xn`1}V4Hx8~ALnSm_-%_X18NG~7-c~RAJ6*3XrbZf@~E#tH2QupcUb6>CVG?IX! zy|Cogvtu)zk4j^|MvZ}3kY!EHJij{O27wVo&K_(AaWgwQ^~H;hST< zSONl8qNy4%xQ=vK#Ih=-;jXa(gPX(7;uWD`jYVHNKI{K?GxL zV5sg39|y+iRJcfY=GX@#J(1?p9x_7&cgePzW5-td!+$6`vcuE_IY$b)Bp>xr^FcIY zYR>^snDpm>G{2bcOT_?1&ILRDSBrXgl-7$YD`Vi&_=2H-#B*OFp2w_&nH-QKz%GfF*cM$&GY{ced>LeAN};N_`3l3E};AJ=z1W19lU?< zxBs)3Z+i3B>IL#wG)9b-q=PaO*BDX9hB4xt;e>h@6SFT0MT(p?X(MS-S{H`>fW^Ac ze$+i_#Yc^p98&NMk#!%>;EpvDb>n#sT7tXOjH6kKdeo17kq4r#~a&BK? zkw~F=2J!QZ9Nf&HoE_=DeiwS+{sn-$FbY><=#uGj3gukMShTVo%lj6ZZJ?G|dlR;H z>bNiNnIgMcKX1C5l@(X_9#A8aWP%OzV{GKu}zqO|G!sgJ$q@~e6`;DMh; z(5#2VQQVuc#3#OyWrL(8zKm%12sdZ%-8*B{iD*T*M}ePYc`XL}7DnkAdSw%w-3Pk6 zytc2G%PyZbjv%dbE^yC&_IRo9{ahJzFXi8+ds9Ez9x+xAz`qffK=%APhG$+e`KaUD zAAj=l{x3Xt`Oc62tp3I1;tNVLVutsDzAs)jpY>f@%p*sZF1;Q`!hambb__)Y%f7&#Ya2X zZ!8yIJQ@Y0b15&Fc(Wg|>o2~T1BnHMWtEyUP_>z)PY2e}uf6fq2yS$eQ}&IHhc}<$ z40#`6#jooD8eWEhBdkskQCl~BdkzR1yQmjPydmI%4_itiw7NaiH6KgGB5l>=ngdH) zOHdT_D4emlb^&64I`v&(w+DV?Lc@nJXa{joE^ZS87p9dWN81QD>?e*lc5HNvzQE>i z13obH@w4H5^Fd!2V~L!Hn#SDeaASO7CI%wT!xm!l5ooJADNy2XWFob7^HfM=J#w+n z7>TEo$Hox!$iSA2>wk2uKYR^W(_`?1B@?WsAFzFx8%d<2ZfyB8V=*t`7#?)?`6(vOd}u!?wL<(uC8HJ3l|wf~gH zhj+2_LJPyjxV)xu4WTZU{sRb3mt0%2-vA)?-)^?bl5BA zegW>32Zy+C>>uqj+{vD35|P?6<7k`O$|Wzc(C5gb)xMTpACfyR>zq!=%mRa2u&5U^ zecE0++21$sR1ahZ->H!9gP{jlJ}jvHV2RPr(#vw&mK|o+U|FIWihglo8O~bBNB!E} zq=_kfY!=$>u_XSRxipVb!5I%4?LCLxXLDH=Prd1h%ZFcj{_-6k`(UB`}_0m*?f)yX|9z+Cv&GSqsCCAlS1 zBt&6*a9ytsxU$k8H19Hj0&7ufzCc&Sxx~sEu^TvY;*aib_Fgme`M9Z(DattTu`a;X zrZ{*MMsZ&#UNBn7aqz>cY*3F!az!34k?P@Cupc4FRdQNL?h`5Gk{-Pvm=3>(8|ZT#`x6xd%luwvA`-u)?)&cX0e=w1Xy<}uV} z%$9j{-gKU2-eH9@CgWkhV;-^|n8VfpOEC+`*)XSv9l6z)W$d}a$)v|P2&M52*iC-p z!YYnjOS6!QDFp_##97Bjb`6O%@_?AuS(_ z^dFM@ayNTmzxTPD5q{Z6)#a1Llx4~}%dIra86FFqg&Iot)iPa)MUP0sU+3!YIKj^u zejdSY45c7tD_EGvTKscvblF&97cVz+Sze1495C3NzyH|}Tz>5NkN9T+`8fBrf}eA2 zi9`G$D8;_@(qh)eI_~X-O8n(lABs)2ptsf0-ODw+^wB!+`qUe5X?e7F-r4TlzUI3+ zd;H}=Z{y!4Lw`T^zKu-~8w)(j-mXn^JJVRK+_9|<^ey`6|9d|F$;)?r^#8p)`NRXi zo0KO&q}$<8YFX6I;?Q7c4N`CWH+{`0tokC%t`}b@%}}>o%zJn)jk$T`@}bD}k$&xs z1F#vf>2#~a-@LeRl+j0moqefNpNY7Uj6U{73B29YmdnPjJ(jC~j{Br_#sv$eXy)Q` z3;jk_Rw&CZXo)BGLxi{wO-lD4H%59;N~ux&KcdvRBD+$oj|dgv)#FYP=lxO16%1>^ zVGca%wjXT^VYnz4Uu5Q=sM2HLsAe2;01tik*u(&{^Ni3VM`z)p!$l$Xk@OrdT*ULz zi9L+m$SYEO1&cEnlu5;O?-HcBSgpf25+SX<<8-cY06-!2&QJ6s6QN;zc&G}W!-&}P zH+%vU15p|S+ZYy#6K&^<1`r&oQy9B6s=xBVl6O9lL`^7;=V;#>ZY+`PIul+M`;rD_ z=Hu}lFYqWZ&$o@@JL4mpG!&D3sE=}pZ)~-}b$r4T^}EQ9KJyIuoU3X~3}Tj^5k`K@ z6HLLuts6Rj*V8|7c~;*A#4os1V)6ayk@Nt4{boH+{>Sh7mdi`8z3j2&lsSMjbH#Jm zW5u|^%j~o6oEskTF>hqcSc)*t9`Svi;bTm&?#ZQ~$iRo^Pn*L(;&=x`$Q+kt9ObE4<|V4aBTPIQF^TJ;&-qdajXrsx|4 zdt+cr6%=$uiPf@<0}-a6D1ncdJIpDiA86!Xd?sFT?E$;saYo%Hqvm!30_eWn!5+AO z0pJb}NHIALuKlGSa+RHMUt3O*?G;?UgGA7iDZ`?{L@%ki=qC%H$B*oixsHfd5 zVzsb>d0Q?>m)BusNBF;<3l|=!^k@u?qZcaNq7TGlF3Q<1HhjMu?RKNF={#{^c*G*3 zJetF@9Fe$h#g((<;>*hEyYZU>uL_p5XD(YzzOX|+#;o>e_rT7NEnebo0iBEE_^3Zx zxF1|Ciuz~Ktjwh!RcXT=`B-7}DGN$R5JoOrEVZv3^x@SJfSbN@7WOl1;3OFFS0Nwx z!3y#r7cQo4i$+Q*t=z3fAI3+t1q8k@X7cve1|b|Tc?ii|D zKa&kEyg!Qe8EWz^mkzFpe#^sc@%XbHeRQRO#n^RBLgN>8@84JK?peR}Jyd@DQywWT{&RMXfHa(n>_drnnC`GcP6%$Wg;5TXFU|Trm5bNa7 zSYMCc^qv?K;W~2z_mnOI{^BR!fB8}Ugxg#6g@Amd`&wZyr66wI`|HL>UYYBo=c%_O zLwbTfh_CX{0qz{Z&O1mz`Hxv8&TK+lH1`dYjG8 zuWsp*1sVVC%hJX)>DgEypMCoBziRIEXaDBMfoo_m zPQ=-x?Qq%(GQ9OjZ{1ugD{HKiAy%ZgtRKE+a$ypkSS6BPjDvH%lz*gqi+bZYyyNR} z0`+E28J^^ldJ$HP<=>!YEWPDD7_@W5k_b{p)V^w}P9(jMTDb%@vBN#)&=!1-oYNu- z+Ig2qw%T_ZN$84w*>UlB_D;KTP)R#rqds$ut?{X<5)RP>BT@Dp`FQ494mk!y#Upbi z+R35z(j2)&8@r&}uUgF^?J*weT=$7p;|hU(>bIev4yx2~?VQX{ewV#CH1O}EdxfBi_ zTwiQkOb*y)TwFC2!n||mJ|4WtiDew{#az}4AseGIYG)dQ6WBz<(K+m3jODo_m}rGh z3=!gY+>H2tzrjNpzG^!Bg2RZYu}>-m=L^MBcGkx}NIK&yNBj=p7|0$B^mDv+c}4F| zKYsb1kN)K4LoYsO9IWrlqw4{>o@=VN>*oOfz;FA#c>w^Xc=PB0mfC$sBDCLx@A=a3 zq~HC@^TZ8}t!U;=DD%uHnn3%SVP!q0dCZ)NK6uO#u}Lzki z2N=z@ZfFmTk-#vwD6@$mc1IX)h4}ZpDyp9|-fevB>ZWnSJJ;b|D`&l8optLU{%x`C zHsp3K$MUX?ANISS1D?G8Hy{3~%ZvKizq|{vUl5apqS7-T4=|Ef&^VLVet><>^|rNA zelVMTeC(+4UjG~ALdg-~b&qVft}H0&b%%p$G>%9a4sZls*fE<46Id zrX2cZU6EihE(=&lV^Q_yAFG*-F>&AXlvNaaFMROB7;jtnz>L=!omQplVu6Rg`Cm{ZNp~=tIUE+kxTuz5b?Q zWc4xFcEirH;n1Jtl^aY5wJcQu*iBGy5>B|wX`w_IDSBIM> zV=EHlx?j)(n|R{G^9?8+egR2(j(g-lE{vD`VkbpnN+e*67>QMn%(v2d!yg`NHJ!W5 zGZzvU78EoowonXD$o`Z17l3d7@Q;{(|FeMZhDUqyM7dnP^&Q`M`KRCZ4VTZo@{<4M zV$Ku|M&}}PFgM|hf5ta+O6UA}p86)2`NKe3f%!$)o;jW`VA3)j^UmsGc&0$ zc^2zIZOHr^FHQ-=4xv;jWBxB6FoiU9z(j{!I_DiO@fdGHBu@18LK$%+dd}!bpTmyp zJQ(;MN~%yNXJo3|hY$>+ETP-Ja)7grRBW^Ci5;-?PhuFVIFmb$Krg>G!CB%a9wFp_ zn|fZ}OdYq_6E-p4m%G^m_ak#R$3;=GkWxysn2_|*EhI}g7O9UV%#s!e=k=(p%kyCr zE9A?1E&VL@o9#r1-{x?PY@}se>d7X4s#)4tciLE*7ZeOGK^{Ez#N~(e6K+57+y^gD z@hJfDTf8X|)~T@JQA*QVXZ%r0_j-AS#T)guqsuwv$WLqU-=()S>jPQ2*K*n8Kh%d+ z{RF2->HnRFWJ!_SUaGg-l!sOO;~D~a@n~GT-JbJq+r0Mtce`D;^D?JXMTtG^$ zCIn1f8vp!1WPaj`_?#D1P3I7QHpJLE$cU2&kn=Ze2elLVMg~}7 z7;T%_fU!^ZePAvAR$#~2xM!~x7^-(1WVe?G_|nq&Rax5TJ<=fv@k0+g8+ zf9Yq?8U=jX*keFU5Hn4iOFaUderrC(235s=p~TZeO^=Uo%iC0Iqg|K)Ff`h)jB3rJr-;=&x@dGlQl-f{Vt-uVw-UeXUQ^98zV95bHGtBjR| zUPp`)^MNAj>EqLwhm2$9R_B-Z#c3fq>P%ohbpoF`sK2ZMSJXg9f1N|7*{{Ke@A*FB zL)j)ZXHg)t+_%@3+vD(sP1jCyid$a9$h+>?6I=G?;YCKoJr5F^&hgv6k%mQ~(XrQ= z>sm?)*;=G+{czw?!P^DaHu$V>BF{`6vzULg96-og0ZPyGDl3!)EX{FVSa*^WD zj$M4pcK9$E8r4jEjf{S%*YM&$mey5uYmM0IS+)$iP>8s-=3uJzzUSf#AnPK;(!=?M>-8Bg-#U%hN9~iV5WcwDQK58+u-bZG_bcUN0PzQ+zcB zW0C9ju%F?JzC6BQuzgBr*fke>clJk2rjFt;{}cIdH(s!Y2mvEK>^UUe zwxL}!o@bKef3o8j7{5T|Ca2E3McprQI&Z+H@8~xdM}TkL;3vjy?8Hti9yNL4cB6f0 zA>#DE`-F!0`$1D}5O@RNGp~N(^1b?5z!zTqqF)3^4-Bk(@+J2GT~GPv-}yT(-|*m_ z`d5HgJRBaVF$X-aJ>HDDEIi-0xnn($G!NP5n4q+zmo#z_n)W&Pn3wX7ZRSwoFk2v%quu|tOTBe+6&Wb0=^E*sZ%NgTV<%&p7`C5Fo{c9a8UPKX4o`$^^_rpt}W0r zGiN2d_AzWntmel~%=>Z&dm!uT4hHt+9umtYi>!-nEMyj!mmLcrt(@cCyi zfAZt+xx7Q)=>963+E8GR^gbz@y>1YA6A{ez=i=}3Ws@4QG^<97q%o$R))}X5XdmeB zrE%ZLMUZ9Ar~d+xNMZ*_$KL>8P#K{U1M>`Du#9bdS}BQBGAXT_v(caKYc%3- z)g%{-PaE(KvEMY3xsDENAoBxUVS%SpzDzLona4 zgSH8RUj#@FAmKBJ#GTl~mcads0&BD5fqpFF3vJgN{>llb_vJ43K-R!rjO@!iCYDte z*P>lU(DQ*Vyx4Fq!lJ2lfkvA%RxH-iEPgn_DNQT7S=_Ck^%BSO+;;TrQm6QSAOCs%mUKSGm@kctwtG#_@aS?7GY?vv zL)%V9(TM$T>Y7nNzK&58S*y(V#MOvP6MbTe4i?GhoWp7(=Q0A!97RZ`@E(>kNIXP! zs%W$Xmv{5~`j^lHjCekkbCz4wcz26dEU zUZZ0(&srp?fI;0t6ntcVwjbnE7xkr@w*CQEVA)Usi4_BLUPS(fYs7!?*MB@3Qf?crr< zW>Ke|$_;v?^r>I=uSdM%k1^IWPC($~ieq2%EV9*3 zjdKvlF*?PF?5Nkc6I)`v*|Uiu8LYmmFRbiKYW|p_{a!l-rxAt*j~MrM5W@j`&m|XN z(T}W_vD$O-Mai&kKSZvx1`$iz(Bu*7=r!O$q?Iu+zjf?=H8gO1aYaP63P0G%A=h7# zr#HCRs~$Q$;0zu4V6Npx1Mjg48d_2IRPgZnH9meI-stgi`9JSoBc}l^nq%IvrDC z6gytt7bNB4Mu6Q7{?!c+Fp#5AZ; zhOYF`-4BV~20mdSO&L+Um!A^y`#<;b%b)$&PY&rld6YfCC{z9geGkA}AA6v;Th#ku z4ape&jyZ#*d6=>4m|?KbAxS$=t%=?k-H9{Sh4oM$PK7krbJwFjslcZKSQok86Bcli z8&#(Jn(dFCe^@nwjgsU_?@@5TnXv5kTna%4Ps5dh!u-#)agohPk{4U(HW zInNOTSk4hXI#!Y$O=3(0)0S0fsMtwMnlQ`+%~e?XbG*r!xRV$Bx#!KC>MwTS@RvP+ zT(RMVzx#3*d*J>BfV(&oBM=5*8OLmdGF6G7m+ zeQb(H#vFTTd+F5AqR}Uf9y>zo?VB@QKIlQEypxiT!3y z)pQ=i-Ktc$x1|)T>Tixn;98Ip6Pl`%2j8HT~{;&)Ivg^{i*D zwTJUP-|<~@al5Iz!prK>JoBuW3ZS}TF*6pF>Bkz*$X3R+ZyVi|)g4i8Sl1ZCX-rdL zCSG&-MvOC_%9sBOg=cQI^y67=B}Jcz&sR2EbH={!(5mvgUe36)(%oBmsyo4PLn-eV z7em?mi7VW`jYv*(yN7(eB4-W^E;ssdYwpM)sO zq04&76LJT~izXD9+7TB>2i|gP{PFpUfv-7~o@mG!-jm$PrFnQ~Pw(#Grb~Nbh(xX* zcvK9;;lsH(2e%!T=2Sjyo3JxZsaZ3or!Iy#m05N4A0c|z)p1|^B^L){RI8&Puc@me-|T=H&DLe)nBr`wq5}6XQNE0Ij!+et?8;h^TN8+ni$s% zb_g2glxvFjOkP-Ov59r=)zxcCbBd|WtAXB!*14{2T2Y1Z6$`M}C4|jKvkLpb*Ppp} zttp)Rxb{A>t;A)Qd<~mA^JYAHomTuKc4ABsu?Co8Swl6rE- z*SR`e$C_MtK;q8;KJ@I9+xOi2UVTsPnjQemgiUfzn?7y+ohy+t7PQyPT67oL!iR44 zaS^s28+5%s*5W8ft6lg~uYPE!B)+x3#*h5ik4@zCX{UeQ##Y3WlDv&aGj+j8*Wxwr zRUT~eH?eRIKi6FMF`z$5S=ELM3H)rrhx$p@B4b2SV zq@^1rJwsUMP5SRkF9x_`&=$!8?Ir@+|2RK4YEBCuubuY zs1Eq!Lp`lsZf55U%$dN(x0%TUoF(>QuI5x;AlrW@?I-0OfCvbrF}>crhZZ{^t5fVK zfplk`8{-M0a%tl=7eC2fSA|HHCqQ;E$Hy_~=A1Z*N&nQ;6C*Pu(3%(JwVxk_qPup2 zcLKiQw_W^=8V+DJH`tQHnumOrf+u8X=cA_M^O^n8@BJ4HsNfxf!fZD!lFQ@@XWpT8 z&Y(_9@<@$Wt&h%OURONsy!i3(3r}y~bNA1S%wO?3<-L-8_9JlDwcEC@e$|_bz%NcQ zHLo#MHof3W@^F!5eMePS$d<}4mlXfqj8+}jUuGfU@9(+ zf$=3+3cBFMT)J7Cs_kM9UN4%!Y+KElSi9dIoJ*a1%>80S_;H1<#xEhX-BopYM!>7< z^2Bo$$ru4HIlkznUbdD{V}lA8{JsVlT&U^uj}6S;)J9(F;HYz5i0|~#h1az?k=n|& zb&kmh=M z;PV(8x{N{UynBG|m>aSbbjg3sCqBTfKIx;2pG3CiQcB&be(!z1sy}DM1&|zKLmeJ9 z`efFuMf*jy#ufbPmv;K-_xxr>YLshZ=AN;>5Dsnl99*TyI2`MEz&B;sRBOd16TF@) zYaYpGMUE1MDJ-B$6a&A&(I4B@JC#h|F<6O++Jl65Hzvjb*$inX&?UD7F7=o*MY0h=ok)(Zj`)qSo=}+APp}yq zO1-pOe%vdgVO%oZseZ+4j{dX*Tei}L$82~e7h&Ze-{5nApjQ|;)HW4e z`)RJS^T&n@BscJ40C4j8$0^MxHNN-{rv7Ja84ulL3shILp{o~L)|+|c^5L5G$e8#A zv})7WQ5B`Ie&Pl3)PPbCu?{$@!4V3BghZT%$A+A$%#(8|k-k6N>k_qjYrPeTV`uE} z0~5q%WM1T+uRGM?xcIG?`m?|f|MvEOKlmH^ivauyyQ}h#cLcuf)n8OUpxcvN^Wv%R zHC=N0nyH2i?J+z}!9UYx2=eQ%IHb&JrCEn=B$w=30A53yijf`??W9D_dGY2!4Seb(-{&{>z z{_7ZkqK_^`Hd+B*!|@S)>T^DmTXNx-|6f}#2>r~XpV;2@_`P;ivgp++-^VdsS76Z8 zD{k$tn8bIkzR2F#L{+_NCcimD3UtsOeWo8k?9assCqvFE_Ic(e`%nk3w&%={rw^ z3Z*#Xz3}8$|H6PiYlC>)YxbTu;m4TEOYv~C4qOhzr)XpYY5a7KinwHk%TrFyiJt9D zT-aU*!u2><*H|ouKiWd}KrbEnq|ZS|k-8oG(sA=`6tSW8C&E1-6_1Qj){|%H zW0`reYkL43eS9{nDA3Wby8e5vBocUx(HN`nIUhhsQs&HCDt1m)l8ZETiXB6wCni&3 z0~@&=BgM&hg|us7!)TNBg%XYF;E!|g+?@-Km4g*-u~tKvvsf4Uwf%B zkAcmu&Y#5yFw%_kSKRppb?*5bQ`uJ!O$0jDN1fD)W!JTM#yt0$uo-r;#83qeta4vq zsrQUr*BbY>!|vdx`fLI1KZ6&HBO41T`f|wi)w`>6l+yx)UNO>L^rce-%Q~YkBXL&? z?)vBbkEZfibR;61mWk0MVw%bp);M~PSPhxCyrbuFOMSsZgw1fEm{wlxQS=&z$t&x` zDsf5tU*>)h#bXz%tNQYdz||)JF5jH&BH{u;p{A9-gv-enO7@^PcIx15pWb5F&1jDG zDe}DJXqWyuG`i?hueij1tb=(u`)~$#TfOuDOZ7VdKY0HKx7XG80-t+{zgxQ>C%BO? z#@6iXb8>7_!k0cZ{v#iC$V;EchUNOZnkxYNoksBRZ_V+<2<}Bln*%ONYT8F!WU--7 zIkmB5N{dI`?_4*WANT9nG;`F*j--?b1JQgVvFpi-+-`9P4n|I`&5f`evU#8nX^$$j;T7z!Br( zW8paRr@vCJIiwxE@S-;VIs3?{-3GhF6SB4L7`Yj;jV;pNK^q(0v7S%xY()`e_{3ZORAeV&ccteO4*i6>Y~|AEJ{MjloVZp~ zd{z_cHQrLSw~ki;Km)#nSTeXKpJOiU5QU8x!zHQ(gQj#+ua74MQI6&arRzHLzf{iI zn8X={pRnUkp+NJPFvkx=I7)gt#o>)TgmV zTsWIH*2hEFKx7yBpjSitzgGYVp@zT4UR9TJ1g>5HxRlf4pw^qMmkV=VUcE`ve>uBz z`lH|UkJ!kM^@>|{w9{WP$97^r)<@jU^znDCju-1j@cTdYzU_COd9;4Dw*D;OxByO; zT;8a;ejWQ0xo;6C|L~Ps7hAC$uuY144qYb;qH8ey2o^IqDfZ$s{fy?hF}1}$2ir`a z^w)CECgG$8zxq4IL_hk}vRMgAkl`QWaL})Q;G`UD2@j{x9CLZT9P#&|-}A(M+fP06 z(MsalSObGskCq3*o9rgnV|_F~>eF9wkx}R0)OUtSv=t+V!&ptb^Q{AoK*N76M*q9W z*4F+A@qS$#Emo%R4@?$Ad84eksC@|X&>b|RaRAW0p?~QMQ*k&pqX*y8$UE`LA@u?| zX_7$4%Alu5Z_Ub(YQDmi--_o!CiJxLKA?tb)5^nsmE9@+~yaTmz4WgLaWXw%2 zUdp9)spRKir+ZM`54q#RMxr_;p72P=oQ$w-?02*ml*IE^#&~0ef_reRb_hARc^@x9 z2r%#qjT9$HOTp_vkIlg@W0D*V;oZNb#I9|728)+(EEjUjCBF6vc}-O|6@=cg+^g-R zczL4k8}`Mof{k;yeGpr6>5ycEZw|!{XD6F_#|%z@0!15eNUXgT9ad~h`}uNvXQv7ZsCdlfpKHnvM_tk6h39dDf>F@_MJ=u zbq8D(;3pMBYTbJQy){*LvEp}p#ff&MX;6+=7yBiGR`{UdJeA9F~5#o&)0e!H{& z3cv@RxW8@!e%(L(wt}i1+tjGn_5rSLVobeamP_j@9*-sCF=cUbkxdBVCpGeOHVb7; zM=sz$8=FD2G!jQ@RJ-<>K$15#k3ZCr9qrGp6=bWAC7jV*-O?ssE_BpC_0UJRPdxeH z_8MLUx(Ti_QGqACsavHgu^#IcLwnVe$C^*_S}_b#`bVzdkM$9^_7nT8Pgg_y*>id; zi`&sqUzAI%!bLxJ(GklrhxEswH+bC4$i~8%%OY%isvG&2kG>f?<}%`d2uBMJ(Mc|) zj}3L&U{zT2r$a7k^sETgNlLxt6#q>U|Lctz5m595N^|2I&BUU*Ah8|ad2Qb^7KNF3 z`e{ZP>7CgTu)y=AI1CnqCZS5y^6m})L_$eCwBeaKg#Z~O#)I?4W6ErU-Zp6V<6RQ| zqbQ8WL>&ae4t$HF)wN1Cg8GD;gi}C&+F~?H*LgI_%+bE-XFf7NB324bEIK~TV6KeI zz&o(gmt>76VV3>ziGPP<-}1HOjuM|$bp*av;~&Y;TdN$Zso#_+8@{ivSR1P+?-ira z6=SJIoLH&#B%yefO7j=$hQ%<)1iNw+X_lVCzGkJ3!??bR|p9wmMewvM`08JD3cAbXs# zR@@C#Ol4ykTvz3?jX+k-WsB=7o|_nfY^-Dba;9ut4)$|uY_j!ff2QRaL;5$%6a2Bh zk^Q&;73k%1;Tm^K-30uNdI8{9pZrw)iJ*%=1rRQL)6Tfe3oe>{K}YPtqz0u2t36yj zF%qS4mLLXyD5*#3b%c+S!4Ukd13wzF z60<;n<7vqmp4PAH#k@5}onP`ozAMG)n{Hz9yX=mvPzt4dl~JDf+cx=D&66`WO!1P# zVK~KUbjRGWt})Em;>)h}eeN+g_T#*!pG7UBIR+yOxjM!$avFq;gZ*mxRX#(tv|oJj zrFzl#j_v<^;8(WyKk-R%XK$}LU9~^+5#X;az2^EIHT(=XYqvOhO=V7)B)@o9b(ESn z){e})=4vhPO1O$>tcAh^r8=0w!y0HIzT}a(vQEn$aTS=xda1s|F{MvDb*Khg!RlI8 zwLjSc2Bt2tt=i2+**eA1yr7D;s^W{yV$vi%Mv23|+ENT2k#-=XB2i)qtZh~?@GjZQ z_UCwrEn^d$^-%9)k-p6MxkBQijF_09=*J4Fvz)$s*Qm&L+znd4$Nk-K(_ zaly5odTkHxX+F4)-1Nh}w&B1$v8SIp)TxoD+{k8nGdjML?;1mN%1_bET;q#6`KPVA z@hk5tkDt`mao*>AQ7%Suc9idY^po3f)~7)3=tDw?lG7Q^Tqb;c(&g0EZ`z={IH!V zKo>X&@UdC*K}S4-FLr8jp-fr$cCik_0QWbd=80~p2jB4CO6;pC!v!zVyqS$g(1ckw zAUYdC#*J}_qhUO0j}pZY6t+0^q{SD~A||dlT7lKZUic{&vgCQfzw$tHsH7pWmYF6ysw(xl>o#y1JoX}f#TlB-qvs~ zF@#fQld8n+K!Y9Bz7~IAty<$Tg#jnA$l;77%)Zt;U&i>VT<#ILdI8{a&(H5+blI%? zdhO9p|MxJk&%#xr+}HdGApR`i58eM8+b=$Luf7TZIjCF*W3f8T1KVO2G!m`HZ}uF$`fX24bByKa!W5+_Zv5ESJ_z;pq8-;UhXFlp6hD7Ti%1XA zkM&ThVYj5Lfj}{pyBteAHM{Uy5q}dUO2kt%439aX72vlMpOcE+|1wpBEaH7^4==0{PFnbM0A)zeMB0yMu2 z6Ic9*81g>o?~1t@1mf1AnwYm`lO)G+POf_ zI#}0_IZD`Nx9%d0j`c^Y;7|yu@htCcs$nD?$6jB?I+^Mkp4v2-g3u=?i7b~^)v0Jtz{r5HcZFb zsORfu=fAuAm+H3x{8TU~wE4EHk@LJhN#|)p`qb-#!+K3dPJgY#7<>A88j@O^EuXkZ ziFQ7eBz8+-+mB=P*7%H!QH#wv3`j7J&iSr+OVy`c{)^&V-cirIX8w^~=qJWiJGMno z&5QEjBZ}{P@`3H0kKMi9cI!o#yf9`xy7m~M%Yn~e z?i7bK@x|xN1>L*`tk^IeYcOju_k6&QuZdY1(w^GF%Y-=4CZ{ErqmechM!}xelYik6 zKV&%?Rb9t_jQ!9TZd$`aNv(J~Hp>#%f+@<0$2va1(vD5l0$OHn@RU+W8K%_MS|3NT zk8WRMxvsA;%_|D{j;kttr?)`C4L02I3jD6t=I-ED1tQ`3KC*4Bc3z4FL2S6Y?|d=s33PMBO&3tt(wa}}pK<81 zASWHd^a;C8Zt8kWN_XonKRL0XVrxF!gKM?v;ww-5V@q9!>^(R3n;f8zp3vZt#HTh_ zT1H|phD>9jv8{J_6_bsF7Z%ZS*hVzPQx5pw2B{G`mL=!n5Ll*Oeb?&KUr)aD?Dj*S z`laom=bzkoY&iFW!1i`^zL^nVnDv;gd10N^p%?nRkIbRTs3r@9%JY~-$$V?e^tzik zus)bymBu!c)z$IG&kdRHIuH-`#H7mCH2j2EEpY4w`#?)TbmbRu>vro=F%??*_};=c z3!KD6NDMwi&naPuzWqsA>$y00{*%8rY^zU6IAmGDCs+7X(T;H|fwiUwJg}-0O!iU( zMQGMB2jPeb)+4@-p>!wt#_p=V6eDoa-SDLd?v)%wwr_8x8j9S;_<_uNF1xgXPVLQV z8%A;3aQQLk#DMrr3YU~zR#LAq%-GDDsHP9cV7X(%K6ZPnHO^@_Z7s7+u+!7Ag&hz2 zKmYhAwjX`qx3YbE`La2KXb+}!lEV(G$ShTh(`*JROo3fSIF~Afy zQJ*^jZ5RIGB-mr`n!7GqzM&bbC1$G8k&A)xn%XwPh^ToLkJ}(eE{#cF8G8_$Uxwo} zZ?t46%*dfg^xag@t)_OorIeHSt}Z$y!mYqG;N$3AyBr313}viZHtWT$R2dI!=I>#{ z2{QmSR41O1OY7hePh0uMI)>;Ncj7P~y!Idip9BX(F#?pis};QrjTG?JhXN?o;2<_i!}TOp-NaC1U0lK|AHPeDLo8)2zJj%XsN?~;4l>GTnXmxiT62fQ zz@Ed>;|mONt1gi2s1cVu$H=W6vL434lP&l;ILR1_D#VcW<#mHE{a%4MZ6{-ek};`{ z`Z_gd`|dn&1BfwEE){EPefmQBIw_ug9T9&G==Im`+}{28$F?88|JP!1RsSwVfK~YP zi_g_{r$$)^V_&mgNA}35nLj3aEj>A^>cn*W`}JOPOep_QU>mN?y_Q4qnbrJo2+I6-jKN82d`GAaNOchV>}Nav-It;75hrwfr}`BJ8;2 zxA!3VnXlrCodQZ+$t9n1akTPPj_`_qMg+&M;q0U3>4ItzEtTYEIf#m7rD$C2Jf7@( zisO?mxU2e7jlk6l0GDbyKGR^lvAt`t)fT=13EOf#fO@O$jUqeNMulN-9pA@#&wO$3 zXqW!Q7N2=8j{I6(#9nyu#qE3V`?dOExF_@r!Mc~|vGBtU7fk0*9U0LiGA_#MSDovA zuGx_rF>PkwjP%XV*_UWzW;?ThI}RhGPi?D-nSEN}IJS*~cjimmIJl=g*wo~le2@7f zPa$54^E02b_jxZ*j>CIe*EtpkA6Z{=){!CQy-R# z3ZB*j9Q|X7>{uHY26{m_BP1SgY93r&)@&m?*3)j<95J)c$n&JZC61~N&44btk^3|Q zz9OX20-zPL)Y2L<7R9Mf8_1}}u46GdMhl8DSmZ@SiE5y_QS#ii6vkMe0mLe`p76<1 zPMTv@y2&}OGN|q_wa&E0PIHZa4b@P`d&Q?sMa+Ajz8TB>l}<6VUD^2N%lYVLcbW&> zk}~9fjfq|O9Jq$HM2V;!c?dOrLAnmUeqFN!lPm86Dqn$0yTs}EZGt-Sixrx~_zivO zG{Oq7{Rxi^LXHTkNl#cBnRghRei8vNCAD z%<(AgLpr+~=BTPO4&@d@kGG$g7f$Q0ba6FebBvNN_A{U86Nf-+tPjQY?mb;k;Hci@cTniqxdy5+X* zZ`Pj${KPX4*C!FM493W-^UaR{y zo%lP}D{j@%PJhMFj{I03ace($+$hdfXFN{~@2q$J-~0Hz+Yi?70dO;r_fmY>=XQV# z^Zd7ZJ=f+st$UPq$94bZN^Uv~#TO-psWTpCb6lW;4$k`H8EXzu>v6=b{TbKI`M@@h zQ^`B^%6n{Iwb+v~>i?>_k9?W0dWtQQ09b7i7^qXO@GfIam! zWznTRa>w7XE{2FSV|40}a3~IBkss>=th(doV#J>KQm;0VQRi6W3-;(Y{pe@y8yWQQ zox??TtS855D{)41?630LF-1McIJ(sBcK{Mc^sdK^(8%EoZWqR?=b92fdtQ$V@nM{! zG1*+QjV?~eYxOaCeG|q_DY6Aqrj8>r*TMr0HEorS`^?x#B>ZZ`)d@~yb+mpL=L)wI zpJk#`ONrk%)FdL!*?jSrd@xN;<$eie$hrAdS#4T0+|!`+80%)f`^7)zvN6fi31_`A z7VwaVb8!sqm*7gR>JW;a^y`HM6j3HP$D{}&62{nlYznYXZ3k@FjmC(>ymcNIa!D2< z@~?T(I8?2<>3(tV^Xb?ngtL8DW3JJ4J{pg=U@=>GX8_wFse$%)ad-U@X31e z|NWnQCxWZ;yBGm}Q2F6{BjtJxbsp38VxY!d177Uj1)gtDM^A>Qr(oV**;kt2vg zcAs12Jd?`~an-p5BXIQsz$KW3i$h=+v3XL{UN;l+94oHG0~dU3(+9Jb$XDDdJ7Uo# zu44^0w&*B{GuY@;ueg;P+hEeBA6aU2DUk(ttjiiv{aL`jULOsA|5Fc)KMOb>z(Q4Z zB3ndio_l-==0cl_+Yg07&iW!75I53I2Dg&^jckrFByZ$LU3kXzz8$XlA9cg#5IKIr zpZcQsS2=t+Ryl2W5fugQrt>UAukzi3fje`{lp>3FVkyHhr*MT{@t9{V? zKm=?4UPmP*5Gl1jL{lfNFLMGJI^^P&k>#wO_Af52;hBeYa$~;aX*miX$la!{LpD_8 z-DTw3duzm52tGAaeO(u|kDDi46{CG1uR>qM-M6sLi=AY%FaDj)%2_xOvagSOUm~&X zhDwsF(`6X}X6~w7$`QyWWcShLvOuBEQyeswB6pei*v&eaaAOhsHdhs#eXTyHzZFk= z+Dw1(bF9JcxnY>xH{t?m`x$$3NDU@s<#HQ8`~cvc^#Z`%Pd~K%!2R#9-vQu9iTD_z zF{xoW$BJCn=FoQ^IVCnL4{;{~5cKlFNBT%CKhFTC85XBNt00MVZCe)&Jr{d}%{sRx z&JPs9c3lCi@{{~W1T+8mX}81Q?58#B)A*;3R$`7cJ|d^)aq?C5@&8|Z{FB?e9{c2W zOMT*lPXtiFyCh#~kM3z54EormZW>j=?bkKFroRJ;pO+9kriv#SQy95jO z^gr>o9Uo5?2Qo_4GD+W3pC^~rWI_ifoHnQavtP!`&;r-p%89M$AXi<*PDICA5M4Tp8q@Gy`18|c!Sx0+Rw zQR9y}l$~$XO0OKvZ=bgJyERaOv5YyP=v*@0r%DIS-amHpraD&B&pNKQ>@iZ#v{Q(c zFo*-GpVSc)DBR=6F{1a_$y!Ra6rWQC!?+=DpLi?3BL`gBOc*tXf9%=U)0gZVWEo;v zI`S{vE)$J=sc;^=$Q`p>U@qs=esByOMm;Vs)p%gY_}B4L=RtHTZKKLbm$-WTuNZvn z<3y>oP`vssef70xG{=R-4)SqQ!#0e6b9X~P@o8ABmn~*Cg{?{i#=NwA6j~bWa}0QC zdf*r*`$^550^~#vRGdn$vukroDO~V&#ym7m^9hs3Yv-$dpdIgY)>^e^d%IS;XI^+= z`(N(+R0BT<7_S|dOk<}U<$C$SoW#M|SRNGHt%f9yw z6Qwm?oO53=@0lSK;h`=aNBb)Q!PI}RGu&zKls?yd>l+Ca3;fFm0yxPFUgy%N!nVA0 zGAt{KaX5nq-_$V9*pveSarcM04*H`bp}u;s#6m8P1-0*GkXc@p0)eNwsEgeE#9_H& z?|2etDTOsy9f`2vaR!@nxj1HQ?brFYX=qSgl}kASS1$lu%4s1eZzzZ79pnv;#{Z1U z?qY*e=0-OMvLgoBu>`ZPk3QW8!?F5;FEOkf!LBt}=W(u{JS+dHy-3cUR{!=eVHrJs zAnv8@wrh86-+$k)Z@==yecK)N0)Rcn-d25$RY%vkYNZbENx$bv7ycf9UHHucgIdXu z&gwNYyqC#Dr^r)BbH49+i7s!X22$SeA(r@j>oiM*4n6q8Rh7Y{m9Sd9~PoI zjm&4nKANITLX45#H1* z2kqlw*DE)Y^v#Q8<&QSp=r1b4>}yAnEN0K~t-j#BnOwlxhn_^L=e~YK3iaTDZ{;*@ z*bkkImQ&-jDT#e@%nk9qM$>AY`H?`=;5T$h}2Y9Fbg=%Qyndj9)83i z`gHwSp#Sln_jJa6a}Y?^TsyfQraq|0=ubIMhvDb)?b>;Oa6Ha(ecEhmYdK$BqD>8Q zZReuBGDJV)!6s7Y2R;u9$roAjK79;v7V(!_HpQL%=^u+NXWWS-^59oa+Bf3FhMf3x z$NkSewf*EnAFfBPYxSp|@+8a%bH@`k-cz5$*kXT9#MJAt+Q%aL_*i{kgpY0X;Yb}h z^~#4nepW2FE`g`7_~``i`t*hyIfXhtLY8`B#yb_ViCt>@fNS1JpbHOmw*^>uN4(&V zrEoHX_uRZs9xQz`i2?HVH|pj%_{Yxpve4#8f8}QVQ#jWyY^P@c06+jqL_t)cjTIk< zoZ}X_Hko<&j1d2>*AHynR`$2mi>BCN<7TYl#HC|dZ356_|Cl6lxo0)3!n3pvAKn5d-2g;j`*xv zAqr0zVB!l0NnoubO&%Cu2!%wR*+b90^!)ZC^=AR^f9`SZsjEK=sNuQk(+8fscU~42 zt7SUpT;3)~NX`qUJpo77P;;a@%85_w zFE;Hm6?XVecv@sAiKXJu59>wg+A&~99|G4SrjFIQu6_$Af6cSIj#)RaH2~|H+~a4T z@l~oW_L-h*P%e(k@zKt>qU{(oPt_d*K)mV3e)VfR7_Q3Y8-eSWFQlu8_9Kw(Id4O< zrI2{bMH0(wz+=7Y$7QHF(g!o`%s(IgqYYE)JgIaI7Z=2fiZ$kr-^sAInz2}pRq~y> zHO?u;V&6k@w7!1J_3iIJ^3m<>5B;O<&%W_%>hA+Tts8D(3#`WoI#tF!BklS})U;;w z&YN>ajR@k^(L@q#(5?k9>l>A5?R{@C;HG$dV784qby72$UI}gyN2%= ziF2F!AUw0H1&q;}#TeUKoA#`0zU!vOV;AevI<*N?%ffwxw%mIH>{#|uCYSSZPU*B? z139&BH^8gRNV0~>Emp?(>^xX-HIb&?k*(+Iua>;zo$_Zm$=H4cSrm~WaPv=2lo_^$>Sk99`3Busz`CyUi!TXEW==6R{mw6DRd^S3^@g2sVE`nk8^l93&XFePXWHSeh1*k>r(*#;hXEX7N<%s;QCn+*Gw*12ls&u^oNZ2lTAV@p1I> z2|{bVbg;R!oz`UO#gB$Kqcb|T)|O6c4DeLfr3#v_zijpO9`6DA<{bXSsmSC5hcEJV zpeoRT)9{EJX~`-X$zXTtZjRx+V! zDMp{^b&*ZAi^tZcQC!o=VCJVyeP6R+!*^9*su8$)0pLO{Qfo7hZMoXg z4r;C0!W7r5=9J6YY|S=y=LI+nye z$JmI-S3L8^M>&7`;oqs}MfK;2uJdHd51XfRvsxyCwNBKoSIsGxwVC^@i$v-=8CN`d zw9KAaGntq*I@&k(zoLU&t!NjwCN$#_PS0j~)Oqa3X0kP|IgC#ZUE@Nb#EP1ny0my` zr8@c#xat6R&2YL7Bw_ic2x&Ct%iNG$#y>fvCX(EMjZI+lIumUBOmi6@dxL?}zPplW zC%1y9{H$x?njeMqVs`>HC>y^w`-u3x)ym5Q$?XTP3eEX}nFS!2ddctzMp5S}L zUfQ0o7YFz=T%WE_yxd#wVt(S;N4F2x*IqtYAI*K@h3D$Uuxt8`?zMVRfbZ&Za0BhV zL=2h$q$A|S${Ns^DwmnaZfzSd#|+~M71bC|uBuvC%NP?$;pJ=W(}hHM`o!rc7M?p~ zRpbkuN#u*}QU*oGngE5By-vjtL-%*wrE}zc_0(8TBe@;95U^`5=~YLet1S(!DzxoeYrkTmX0N^a z`W@TanJTx-8<1GoPTvIvTXL;4x~6R@8)H%w9s?UG zoF*`L`z>FJrF7ws8zR~l1!t+i*n&yCr5D6w%L^HR#ZoxGu(M~p>RpZzxOxHLa?Ha? z6w9USL^lPs>r_B{!`-NEaBa-v21he3XA4eUmJT)`D82|B>zB8;_5M7qW^2zJ{Pcb< zHc*pcwqos^+_Uz@x(U1E);qRecp!K08yUe|t(FsK@ry zYcLtlQ|zgu*q~Y4r^qSEx}i>cYAiUwGSVw?9@dLVnBZ-?II|yS`|9!)H9?5HpuJUnq1mz^=jaf=O4AW!EKL{MTYzVxdWU!2wG z6sH5QVyrEh23&I|3Z9@;X))eM33L^d>=ymNpY08qv5>>?S7K>ue$DGA`Ihp81`gMH za!`HQoVJ%S2vBxh#h@QU98vWcjK17-&rWq-a-q+QDj6qxJFXdLXEyT?@31X}bK%o6 zkY`?A)K_Od{rqFw58V6n+kf-6f87*U=L<)G7c2Rc-9LErBiqyUA|qd=pfJ{+8^5T@ zC~H)~eQD=<#t^}3M# zuq_BWL9r-|VyczWdq<|kD$>fKxzrUIEq1kczV}SUE*v>Sj>C$y>+xv@3?(zau*;_c zvbTYC0I-xz^R`@`JE!DRAdN4E_E&bqTP0gI{Eoa7#bFt9+vCoQAo6Jk$)SYiEpe{x zj5GFpjqR#j&JnnJ0pN1ZOZFz0plY)vdq-Eiy>GjP+=%nF6Pz~g`#d?#dX8)Lx7r@t z@oc{k{J3UTAF-q6F-Z?_ORdML)&;wA zr@hwUPc7~`5igzFg)K>+b>v7x7i^DHhj)&Zm-@dMqYV0s6tK>UlEqjITo5=A= zfX-*ws#ta_fC?6=fjv*2r_ zSzw-?F`(;u)7Fca3A$=sq7k@y0pJo%#y%8pHeYDj_}P4^ zL9@9i*<|NxBhH`0$y~@VD;&!1iB#={NVg1hGjjj^9?fJVuwylM9}b#&-v+8 zlyx}q&pWEu>Iu!;ANpES6uox)wY$%RhtFQYN%vOFck zeF)1q!%;^K0AsKpb&M<@L~;o!2QNGz0OMNSl2}f)+80JQ$x&m&Q3d(ZMdX!&9~4#h zyh?|-Z<=|5fZtKb%386ynO3Yw%ZH3P&7vn8;)~BhF&HXU*MFY;XKXBnYZ!@H+{!QU zHbrphR*kLaQ1sJXFde9w>Rfxr84^r~Ck$=Jm<-`1x)C2eFjbIKwC2?E$ZzOCm3 z&sSv~0p9)R6BO@z;_mI^Pk*{zKeG(6l+Gv2`I<1aY0tvJf`8p8?U5Q*bDLt%=Ynv+IbeT$H#dzc~u#2 zbF`eu;3XdIBlFG(=}#<}d0m_?^>S)CgvX)fMS0@7Dwk;lu3iAROtZ0zr8hO3QDv>O z30H$`PHo?DSpgYM&|%8PrshHf@>s_}Y01WQWB z7CD+(5>F)2Ccntn+S!`R@z`o@uXx&(o{Anvs=d}Ibj_!(f8kFZ*;>n{b8)WNdiJby zJ2FQYjK>51qmS?+&se2idDr&vp$Daxw!dE=d48$p?$-L=Gq3mCD;8^s+z}+}!L>kp z=*Gx^bxf!AWSs=beM&se^gZtIP~s`Igp2%KbheAV{by85R|@Nn^D*-tx%ACS`ont@ zP$fTf;mr-j=*#e!1H#I)#NHK0HW`&N9&Dd^LAzQER0;F3L-dK=HCD}2)v#H(K%h`e za7i%h)xN${Vz4mg<^99>lvN0tM}N+$96VD$MalQ||KuCKeEW~T@DFW&{B>{DJN4X9 z^CwByZycH8K-7FMS6yuD?>pa7AN~K1dhvndmmj}p`;q(KSI7Ier|WA2ch-xbx742D z#yTV5aY-ze`$o|SmJC`nekYs=%c@PVAL~Ply>dl4eAGHDx%`L)`~K8HhPb)VPzE-7 zWbh4K8eVUfTQ+P(ge)Yf3sGYDSc*uoNe!3tu`QAFjz7K0MavQ`XL{b~!Z-#Qbj(3l zZAYcFJ*Y9RJ$|dsEbwE<;Y!0>O+{I!jIdbAO)%p!-;93)}Z z#4bB0;%3h6Njx48;$Cb5>^$(tKgC+{sV>Z@s{v<9VnXlY*6hcY(U=H_aEqSI{>l>{ z+kWhU_iX?6oBl<8MJ69_Z}=;|%KPW96e6fM!g!OnP3aJnxw) z=COsl(ezv+5>MbjtG&w9hSiO+Cg(M!MDlN6bHCc5uT5T0GA^pI(uxyWtvktMQ&E?~ z>#=OnX-#$zYL^gODzJ5+rK>BNY>fByzCV4WfXrFYE_k9uUcCwc8RLh4igT%k{3Af> z5}or8wu35&_E(4$mS(1a_A>0lr);`ITuwzk)hshTLY|2wR#)}q838}aUY>ZaBIyzE z9?tGco#8?2%T>-?5`4Mn4XjH9_}QoJJ~DGoW_#35mX7{J+0qQKy2N@u>^d^Dm~raj zKh{Rka~88cpG#s0kE2_2Nj&FsN%WH^5@N6|-&cC<`DeEO>7IA%hMdUIgweJu1GbbP zjJFN4^pRX#&M{tOvCfL;eDX#&C2r+Htp~7#TB^uF#WtMjQ%APq+~&My@4oZTsLtA2 zpTi0N>Vr0A*2b^rjMtJ%J!ZXB-<7_;{leq-*7uzstK9rOMB8~T@R@&ja17Jasb_lv zF;R&m-l@U4UO8+Xe4+A)TI z6ZOu2K6+0~d~L;95Y<1K0zZCu_l_SqI|O$!^vXNr}7eFNM^9Y|T4; zm?f@n)u#l2E4l(R&OYLg{X34*in3|JaMcr*)8NV4Dm3qS;)fV9E*$UMz$*+n>fSKY2k%yjtV*3a60s!Ns zsnpDX={c!M%j(cRwE6yA(5x+U^g0#F^C7kxey^LkBbgD+%4k$yBxVAc{b#z8vo>W_ zM(n9Fa37sx$@)YfrV5aKuYYnlG`1<3_NRF9gcUa4PvU4PS`yZ^MZS$yCj4Mvb#Y-g zoofb25!jcnvkKqnaq(!rVS__T$x+ej9HyXR9WoFdfjFE4(%_8+10gfqrMhkD_`9>t zNuXEtT)q<2v+@KDU<8wy40m!k*5>70zBJh8vu|fZ&Wn zh!3xZ_Q|HI(mnmK*i-ecI)Ae7KYjBb+Wwz^_TSyU>y2O4KfdQE_=?K;cR)q;Pwd1^ zJkjfu2UYVz!2j_z|JL@GzwBSzzO?@00AEu8p&Q~#h@5oP8diQft>JkrB{8r@uw*=; zD~sF+GR%1bJ?3FNfu4NhMst}L7OCChD*;p%>O#EQ(=S2K$xaDOSPUt}D-QSOKDCZG zwbsbJ5^>ycPuAoDjab?1_*ndSk1`%cG5)l9LZGLv;zLSK*)b|k`LhO84!r;ZPF%4~-9}XjN6uxae3Umh z4$85%G-v#Q>Cub0rM4vt$I1&qwAb46!pmZ>uE+fMKY91|cOLj<*_(Su`HJoMlR?|| zp2t46z31_dZm+$5`}Sge)z0gSVey=4eN|g^uU{>JW#q<;)&TqZz?@Ll5jUTC`4Bzo z%ri*-*@AuK{h~&7Tx%~?Q%lN=t8KhzvNy^=ME*1n>w%IS;3~hW%qw*}w>)U{)h14^ z1Bycuw-reCD=vL4Uaz(CRcPCn6FJDZ`1$}IYlTqF>C67H@p_+q@#+OOUzdrB4W}0k z8z$Kj^W+5!Ck1c}+|FYp5hCjXrBE!@2rfZTA-i^NU&YZ8*=8 zF@cv7{?y38tcBhx@jO`;d1`e2W#?`CrTPTGOErJ>ysHWx5+UowtkmW^U1!MgVtw+; zSeqdkq=wb2z_yNS`a(MWC60BJY4`&g!6aPh*^|G(`xiALyY=+e; z+=(wW5obIY8|+io9#0_XE0^+r)HQ)URLnhZobJFgmpPKj~z zlRpF=wR7ui4nZ3hfu4}gg;(|pqU7=1oYl3AP>|IjS>4>y9nsS58(#5FECF+Wsy|%? z11@Z=T*!y(3UaTc%1aPGdBg|VNmsw@uP5`uP9C?sI=o)WN`7(N?(k9n=qD$x4Ow3v z!8|D`-%25QP@76NC=GDj8aL04)?BIeltXJ9+lo#24qW=oAIu#a@i|Jh2%YiaNHGFb zXNq2Ez#Nr>a;zMdTK+2rK?I#`*nBYZ#NyjljaN%RTgn(QH>;H+&=aQe;h`xDfn1L~ zO3Cj5y|#Wp^M8Nfm$u(}@?LrL>=o%NxG*=j)(eNcsQF{}zo*8rzFvor0cBiSPkm@E zN~b5~H8+~8nsCnxxai8X+C3F&h)&~dG(z0ix>{Pd-V5H>#GqwWCmMB#l(~f*dl=Bs zr-=?ZM|C-ISZN9iw626vU}aIY_NW(-Igk}^Ud)34Hwfhgzx*#bTqBp#X20WG*bGSH zhOd)c;AijF%I0vl&J=kc{@1=$_|5eMIM!9jNqNz!HZ;3@EpK)@RlG0xT?g|t7g;LV zJ=!)`=gTkxzGhyANUmZy83A1kY6qqUK#$9ZFCnwmKJCkjb)bEjNo@>0|1*L!yxz3O z+S>g&JdVq@SkpHPK3rBlcleZt^V#L% zJB8BDTyPtI&cnLo#Nx*P%;b;*oTb00D0Vo?$?6vijV3n+i}%m38&U%us! zZ!gqu7Q9gJ@bT9)G){~a2mKy@hFPCtKuGB^_q`-auvU%n<6Zxra{%HHPvOF2lj?e& zyW4&nm!RlsF6|GjVpNUu1VP(;x{>c>vOdvqXm3JQ2iF$3BrAd5*{gm$$PZX0VX=K< z=fhae_~w=w0G>c$zn;pw4iH+$OZ*NC4L12C2KbU;2R4Uf_+RDK*9|^_@gKZ08#Ng? z7j%9>Bb+jlXS{c>{UGpIz~EdU#Yg^G<8#J##7-beaMsvT@WwyAKrwoW1|PKTi)?IX zTYQUFAh>g2M0*iMbyAn0#+9$`Jo4O=+xLC)X9=rJA$_Hn8k;KbsbBto*P|b<$J^U$ z#4VG;lcRC=T-3SO1!KzEV$O`Mx^;fXJ$kFNzA~SzZPo-c&OcZfxiT|N=}UM7OrbiWqn|a%Bl^fU`)=Vi|MpDuvh`hK0srIx zvlcYYW7ZRY8eO7YIW9&W{iYx5HIGDbWaGhx2d(eB?|s{EJ^i4*TfV$r+8Kw5%l3_9 z#P^CIrnR5gjI#!IcoSp#D>m(Z+#zI@)R70b)`>ZF{G`8P(ZX%j!F*@SN6`;8mUz zq$rz-8-ngrW04whIBB~eaUQcq6QtQjP8Ur40j0<4l_hMd18&bp?|lzZTJ4sqofEdn zg?5f=cF(nZjGWd?tTD?HgzW27BHi$G?m?uMHJ#AJPNEj-2~R;{q=*4J2icWV$g;iMC%)6m*8*;@uR;A6 zU;NG6U;Fa!tdB(BQ9od)->@FRGxsiI0Cm&pd6^ot6Wb%V3Os;?nq#?+_WtIvWg2M+Tw&) zq*-TP%ko}w>Ks^-hjLNkrK$hi+=C$yzjLpK_+3NUk#op5pYvXFE~K8)ID+~ zcBZMtms1h(c=D=R_doNeM=9^0VFvBV!zcOaP5?V)J&)1FDtLnReA9(z}?S~)u;P|L)IH(hOYGmPwPsTTG^psVW@kbsW zIMPN=IVVG&n;sr}(oQ`dj{q(u+=&&{Iay5B@rukgYj59%FPy1^Sp7yuopD;n2l+Zi z^w*MH^Az+~p7>Nf3wur5=ZLr+^C)mj#{>QU^Gj7U&JwU zHIs44hYZ8J=L_%A-YD-G9<<%xXN*n8HTL%Ip4zpz;^SDyHa7T4oU@fxQFSYg51ZHg z>=9H4rl55zQUX5(A+j}>_?&)0G)2WOpQVgGeyBArQdrA*9zEs~SdTV89>$AKfBDP4 zZTkyv`4;_<9#5wKY5Ns%z7{|s_V0S**KR-jRexrC^KGxuUqsQ5eywxg1H+iPl%VW& zGmot08s7rx$(n#|SZ&$br74gx4>9gJQGb}(KsT@8+J0w89y*$~+WJRbIr#Spyv{)Q z-O7#!0XX?1i9{no+1ERg?G_At^BqX=SoBd{-DnkXK4Eu$df^g#z`>_xywoOMd?X%f z%k+W^x%3$Lh0wNh7-qvqwZN$^8)A+JuAltN6D&dPTzIlie%W!^Gyc&&oGtG%;;#?5 zgy8n=&|ufr-{Y~TsibaLIVI1_WU=aUcBk_%e2SIPu_-qxzx$yRy4^owL%k5 zoisM);L|VPf6vctPrUGq;vG3-@Cqs~)OC&WV-LK0dw>1;KmU1M&yjY9=EU>DD)Ktd zO3~_+J#(V@6Mn8K?PJE>YE5(2o7ZfOeCYvjZGp#g=T8P?{mMx=dUYoXaTmp$^X4m@ z=C$YDYfNbEIj^hAvf$IdW5u74hF81f1@sGRwMt8lNWZ%~QA_QhgY#BpW9o7|>b@+9f%Ziu8tU}`A6;ytXRFgb z*3ri<^|W_3#wEk)=b~J~J0pbg$UY^(AL|*1Tj-BD(0{Hjrq}96Zod28U)_GAK32}V zVnpjPb`EKWGqESCBQH2)DQkO<1UXDUaE3Q_sdcSylC)F%!6L|6hkHtZ^x7FO-|J+mh3jkYv6~Mo7j>fqq2aIS_k}I|S zxwZv})r$VUgeR!fplIu{uR+$?qjbS^ZT*a`%}VQ%@icB~)7s@y1D2eOhhuHkyu@(u z$InE@;s7mMkE_?iA*T`^?bi9TB^bAO!V>W?J zQlDn{H?}ulf7SLhzpYXK^cOb3Xn;IQy3b@`)%x(w>&!TFV#(~OHM<)4tOH?-ZogIy z9yhCt?aoee$0d87Jo9`-e8_RtTs`6Z2|Qb%71Br4oNm==zCK6J?zZ9wOl)UuF0zLh|&VoLut&+)oMyn%wfwIf#dt`fy_Q09*Xf z@Oa{@I&~aX&s`HDI5$%kw89rI7rCn106zJIvAQ0s|4 zYlS)W_<0WVqK)@IbG-c10xlyhvSn&&B&Di)>}$1+VNUSDpUsoL+7x7>YD#du07{pr zW-F1l%8?Z;>nwh>rdclta+Hyp5kp_=!<=+nXeCYalvO83mAykPio(O8{fvyGeNMcQ zWFmjMkhOiO#921vk$(_r4&cI*aIS5M+`ipXN6o}yIYGCNGT#-+!#U6kM9!;!G+ZE8 z&ClrwT)hDBIUW9sowIe=Hfv2gTk2>zFLS<7`C?(z(Iz~(pah#bdg?U>+UbY$T>aVD zulW!MCGn&VZ?LQHV2*Wc)_Tn&`M|mI#Ros;^-%qy{9pX!d-STeBa7B`>>^E{`Wzk{ z7wWa7pL*4$&5ai2SX(;f=?mL_R^E%+tDSA9WFPI68@JbnpPKpb$WqT^Z()SFIM2R& zJ|FX@&jThY@U02`{sZTy#@1a|&;e=~EwbNS|?CamIFDHQ>(A2+kDMb+UA+3k*I7df7LS z$|V_~2jO~+$?vMPuhPeM$GO8H+o@9kyS$^DhLe*5+x zeBmF|(?+fvF#Xe9h@Vd{@C4u+U-Ko~|5`5seBtf&Nr3J7%B{vjYr*3}vKqM>i{!2T zQ7El#f@6%t*PIzMZfu2_MDnQ{RLP8tt$M2*`8{ zuJ<4uP>HT&6e4n7jt?xk`Re0$OPvwi_;f8$*^(2-h)b;P8!q@;kK;8Ldog?7ktH@F z8?g_2{Mw!xF7Z(*`SL=Z9lvT}G9Kh&v(&NEClZLUUc4IA)o`w}&do1%j)Ctu;N*pW z{w&~o>zD6&;>(L-uax4s_8flSCx34H=u;1Duezmv`M<_stm_`ztOv%~FJdvSn#@ua z6YEU_NA0zk7p$~uO37N#y6APHwODH)*Z%0>%Q{5|@4Ak7GR*4WpKIrU9WB?M$E>qb zcuiqa#d`cK-f)|UFb#urcV zPoMUR!Rn$uy0z^6>he|UqxEWWaeL3T=EO&MuWxUE@I%{AKk~8dHMic@PcD5SHlr_& zEBl(;8WC;KeqgXB)~kG#!OQiVYdNK_>F%8A>snv(@Bxg!RTdxU_iZE|1IOz&GDl3y z?eMMk*3BHv5hjqHt8iUdM}ewm!UM}q z?U|md6P|V+b?Ozj#z&hY{@r&3*sNGOyFP$Jk-s|GujgTB6@r7^xvn{ytL)N0Vxi0d zhW4?J9$VU!A*;r`8Hr~4lV8B2F)_4mrYN2|iJ}ZrW1CS@yMybhZWwRbo~*B&{MT>! zSGIru3%_B!@C5evOCd)-;qXoMNr1n~Zvxbhf6*U*16=dN9L&#? z+7yFJ^0w2)A2e+i{)4>4dIDaiSm(e}dTY&}PZkPk0=Uo?sPOo)Fo#@I|I^W-K`hlQD(Y{yV2K zQa?rt1Of%pw%E@DKPfBMzFnUJs27wUf8puvyYK$#`l~=sj~B&a`}?`(JO8YnpLpQC z+mAo^-t9H@mrV5OrcPSJ+haN=q;wj?Qfi$6s^X~tT&;u%XbzO0` z9zOiz1|4hLSs-Y+TByCZ8fv|a8&H*Uq02tNBE*oFXm{BtQJzp^0wjm_QVYd_4*Q0v zfxZM|&s2NtnZ`LDtXklf&xxnh3KIz9LhNwG2_z=6UDLUPk&j`EqUUXwujIzkT+5Mi z)m^nO#|T_>-M<{sy@I32!&^2{@5gLkjZF5h_p&WwH){`K_B-{;l{TpOT6vKjZNjzc z_V#9&F@zD+^q)3$5avFg3pk1~=AoS&QiGvQ+4JsmiS4n*Zrc0fZ%@4N)b^J?`R?tR z`c-VMgG3n2>IYeTp4ZP?myD0?ZRJ}vB>SSro)+c8u4{TZNk10hSov33+QCz=u|~ei z*7nrTTvpk+_BtkM2X|4u=DfC#c=F*l8{YZo-IYjP(%Jzr=ZH;wQ!_rP(PzB&7}JGk zt+Am`S#2VlSjK7c&|WdNH^Yn}jA-|H#5M?OUiDYqk%ULa==+#4P0sLT9qo7*JKpg? z2mV->Xyi0}3r{bM6El3uZX!U&!u7PpM|R!O|C$%L@Y@DM`#2Z7xay=QZ5ULEy;pcg z(sw>);wJ5u?Xmh(fB)GR{R`WF{>9(uC;v5wu{G20k2CLl@}&P;Uia4Rzx@N>x;qHs5!jik?hNHY{m^{2nw!Y1}N}iv1_stR-@_1_RNFdrPIxCtH*N;nPw)7 zOGc9~>kJp{3*Celhu&?henLszNehQI?@F$=u!E=8jaTz{S=qPtamn=rp~hF^Q+Nto z*c)#iUfR=i<`Xu}Pc4B|C@8A3Q$G5*ydEZtq`RUzq28RKxgY~*A zJ>GdSr(ttk3!HKFjQA{;0(<_vim*jK>T00T-ePS^3#Qu@OZGar*g~MM4=7OBrf2!p zXSUpuvH?QM=Zpj2z6O}b=ZXvSI=WK6^w1+yU8n3T+p&kNvu!fLg5uiP!ZkKyt33#& zoNM0*j}7B6?ahL?I~X{BPjh zJI|Lfw)FaszAqf9c|gL#=e4#A>!uBM>tPRIebGuC{uLXpz0Q&Pyu17sr*nawGBZhg z#+4jWgHKMhDd+ve<-@EX_qnWgYwXC^zT*oE-vq7inAf{r?|Ag%+uyDq1pK`Ivw+x8 zuulmO{pe}0826_Hf639~2Vxd3+LY9^>ss!MdDXO2fUbPuMIZUmhi$?DZkDuDM+0y8 z(ng2u$UkkY=NzA@gP~sYT-)f9cTtRje{NQOw*GA6Rv*{QuQex@M1)EFQDc`HGWU#e z-|g67zaGgZ`K0E7@n|DIby{PXaarnd14&8k-aeO!4Bth$*m_~ZpbFJ`=q92y_Ut)I z6x_JPS61un!)3kBbvNQ0F|2cl+^7--@75eIqvyQnNyb>MRSPsS>0 z`8~DO4OJ=eZ4T#RdnAs^b#<%HlmDmc`|#iKhOek6#D5H-ZZdyAKcMolT0Z*w=icE8>UfY;qrb#_YvkJ0P41^kS9{ZkXui3dS zVGvY&Sk|1EfTMeE)cR#jNL(k`sb5^(N0tjOe(bXN0ze&G+I3`Y@B|2Q*;UgK*7a1f zT3P=;dv6wN-Glf2SOrq&!4HiXw$bfglmUf^>lBLTIGeL^LtMWgAmvmv7nCx3{Z%Gk#-yW6int z+5dmtDnop5oq5h)-<)4FzGlw3_F8-IvyVEK$tKO-%%g`s)IMdC^*!;DlRZJxs&|aB z9SkD}RKUt(9vEYXjeQv^>mGq~k)Sz&cfv#;<9Ya;DE&QWyOrE|OBICN08Sh>2Y?nz zOms^n#e_UUJ_dmSw*Nv4~0Q6^G zBRBT@2Vea7e|8h))7;kH_^g%LcLd>I<~nPLHBBs@OJvwen%%LEV#mhX8ca)$v4a&XIye^* zAUv9Lfjvqo3;Ctt!+ER2K1gh1FC+gs#F5L`poa(lPA*f7kmNuO@#mljjGmpjo3VM+ zg*aTHBi5w=0u{pVv@PBP9~V3}HNd!9_V@JQ+b!|U;DOw;3UqDWv#=h*>Gru*iS9jQ zkJGzlTbp8(vUTZ0X{8w*+kSL>Iz9TqxIUS#jb97{Z-?@)>8A47?rulFk4NQFu%(wQ z$p&62kw<6cz{kp8(>DVDH$U;WFaPkRPy62j=5$=t<-Z&-Xu<0!ydJM*_2?&$G4XRi z4Tk1qFKp=?eR=BnIJq!xH4+Er^^tzg;f=0z+_(Fp^rx9 zB=fNAqn`8%_HXoyAgPej^#W5tp{d%?y%f^coG|Z4VK?o? z8@=jUr*l%<1#j1$B<%RxZ)POTvoiB%JCqQr|$PY10><3OT!Amaj%^u0kyvF1eDCww^-=It8n|SMklGZqkDb|<=3%q$? z!2>zE*Lfn%+=b=^2y!43EYaA1QlWYzZ(i&Ree&|0{{Ht z%S_4l)B1CV|MUa@%jJjpx4QZSgi0o^nhw<3UG|3`t=Le_vu`q`=PAc_9^N~YzBpi^ zr+MhZvx3|_yOMq80Zp4++4Xq}ZNr~1WkmvN{xkqG1;$UkTBt)g@gA=CE4IjutB&yM zGwYgwu%E|~qdXWO{=M3avDYr3@hy9)8m)THXn5em=V3@RTa)nx3+{#!?`V!+FkOS_ zjSCd~IGrEyWxS#oeRyNfXacJbv>w|1tw66Q?{9=(!I81ma)JuY>56T5w{=@W^K`aO zUyN6aw7k-yUen5#l};Y>17=P;_)2>mO>8Td-G0nXc8vIKP94=(5ce2^8ToZC%PKgj zZSsj-EO!6!8S^i(p3^%3-}~tgUH-?9{=fb^z|`Sx?%2>)ZI-|C;ncsCuMRv{@iW+*Q*IOzS0%(Zh8U<-0!4CbTps$g=F*rmZtc1z>ZfsFnSTB3v0eK>`?@v% z@)^^1Ph6n=P5t}7mvmF+f@vIo^>*9Sj*%H!wK~@<((+1+dQB?_-+MRQN zzsJ*YFk-X(w1-yOZfl=nhjs);cv!$as02m4rirRZiGxvd=*NI}|M6q^@lll8hsIYu zp7jaHm|E;mJnlZw_SnuMX-U_(pqL?b6?Tp6t0{dE)Z2F8u!^ zz4QNb-~3L0`ee5?*w4@ddGg-qv--Q}-}?>!!sYGySE;@U@|9?3sL3;)Imm3O+@sTt zTvg;-ovp`s9&7dwgQJ7%+=SC%X1D$C6G-OF@H*sT{4gJVSvfS>t5cMOnb_dDkpnos z9RQm?>qyc*jDWt@+3>7qT^l2jy85WyXt72BfR1voZnlsHIMQS2n=@zNg!*DgoHU2v zh!=>&en2c-V|UIRv6Muup~IfD-L0TeG(Oub9UVtb-o`-GHVhL&n@3~5L#2=#es03& zok#VG3+)ANyb(D8bbUn1f3;XYwV4P>(15bR-;;bP=4+SV{|~?C@_&5d@B1V2tpCO) z@PDEkxloHw>pKtrW4-hL*FN=ry}%6#{s!jLHu`W;pM>g5-)K0u9C1NTM zPWJ@7ed40d{D$wf4^u5KAb7`lHfxu4CQ$n&8a4I|B;APz!88dv#pvge_05R&wdU4{ z#s{!#iWrltItC-QA_a>PBJi9$zKD&$Xi~5y&qd-T7qcZy{cJ~WC`_}vxQcxR8i7F^ z)|MUidoDz24->>fJwV)vp1Mq!3Hxod9l#nQcPm&-%6*{stOBfwhjtGu(2dLfVJ~H? zX8Ury{)tXDJ0}RnJyDb<%}601OK|D5onA@?oi7aRff45F9)hJyVHKy1k z`V}?Yv|W;1k`vO~xggmi=@`1V+qv-VzfbDI_rLt{zj^sVeKd~$g^T80#KhPb5@+zQ zd6gYc1mMZMW1Y8;R(`(W7Qe@-**e9l518=s6Br`fAj8~_iG1l+bd7n|sMVs&Pk5CS z@0vC@@@?*=cXX8zjl5c)@B7S$_3g}Gm?t@8$& zo&9<4cFjO@?QHi(_lz$;FjY(a)9*2>iVHq|_KMT@{F++H;YR>ip@NcQH<-Je(I{7RCs#33JvdM8IYu+(<7$zkoA!q7$pb%<%VnYB|_SGgPd$*Wz)f{mcb^u z$T@be2mzz8002M$Nklt^=9Sl8z5L!E`IDEw@`)et+E-~6KC%Ck z+xYVy_VK6m-*kT8KlqcEKl8D_tv`FvH~#Bu|C&3cV&;w-DrNRgUdozUN@JgsycjU` zQSH1B17tJ~Q|e=U%n364i%)z3E>9{(2W!!Lj4 z@?XF3xAbJ0oEuLmNHnZy9Yc$4!FKTT#?`nd?~U#58E;9;U!2uTM8rZ|X*+Lerk6Mu zHx-CI-1s^p6t~j7&aqi|?KMa8=dq8gb4{BkwEgeDQmsr%YbCe8aPEyZn3l8+cv- zM?VC%ez02?(BY$nE;RmAR>cGs`SYMg^El>0 z2E>XXT67uvra+^ohIe2LmhsV(9Lwf3Vsp9SLEapEQz$3a#RjR0i4OSHrM}fA4tzzP zTu!k_7du*Md(?#1ck<~~M$F_3O)iV_!UZ=|e)zn^I^tb$5QsZSrN}_zMSvGyd-?MF ze&kPF{_My8uK!;Cybkd9PfjB%*7gf8fAaEsfA~*a{`|+k|MIq{p4Sqha;?Lf6VJ=c zC-PKS@?(zBrGcxS1pL>K>>X%iRzZ=68tbL&%X4E{K-rU-)rm*4?48U}*8teW;kAu_ z;Sw*n=&$1Yvs!(DSad`UO^Zeqfp zbiK}qQy5R^9F-iN};+&uX*D*l6a5<`nTLd$whM zyl3qL#3C2Jba}h(;f;1p7t%R~<#**#-7`ueBc?r<*e6o#81FfB{EsV_16L=t_rLfF zhDYwHL~7f9b4%Qf{;X5oopL_qyh$s~C`XXm$e-4F)K|FcxGWs@A%MTj5P(s@hb=Dnr314>VR5$qJW;u2*l{hqnl(vx0G3?S^C;VDNZ|CAC zV@nfVVy}f|BPt9usKB=KE95#i7$4_hYp7LFX~e!zfE6)_lw{1UjIMv{>#Z-`zxVOwfMO3 z+n;{(<-6ba9eJlvwf6;eimWn7Yc5#V24~)oP*YEmbl_>6q01M!y9eK};AxG}&&6!! z+lXQk7e0-su`i0D_Qy;{?z4pD(iP23LS94wODyBZUfPNc9ydh2s0R(Tpo*a29ITi= zW1q~xlrUB_X&Unm9a1mcOpq$(sS6fBj{B65vl<{?H45 zC2K=+{zM=aSl{nPoV@c-ZNLAsAG-XWANr%0zx=5mxV-J@=Ps{3k^RuMlG?0VcnXy( zCCvUISIMcne$X`a`k1*BkCqWV>2zP1&mr@fJ%?N@>quLo{jvVG9^Cj#ydIi!cmvj) zcTf;zqc{5 zEPv6uedHWT>v_E6aPJ;h#>u&H-PK&2X4KPf>C(8{tDoYBp7nxT^nET{^=d4gB3$Ro zc+Y+BA2y!jU=a_kaq= zo8|~*Ee3DkuSj(lyS}d}8qG0Q9d*siPo7;5u==iMX%yDz@Fn}RP2-!IDD7l^xR{Rg ztlG#jJaD%<99{<+&Z zM%BDlG`4G-#vaqGBM{)}26^nxE2X2T9l2m3vq4jitWzolCj#DaBQD0K3L<)9xq%&o z8HzN2=(;&TUf;IZ%q-&|+fFnl>h3-{@mpQFMj0m-CzIfb@TkZKaIv5`4c0yyk7^R=lPQt<%#jne(djD z{=f(S_sb_<{oLiPPe1DyH(lc_ENQD+GRjSG=WW%C>{@p8oI5_m%nNd@_Mv4=td8e0 z*H~Vs){zC5cpZvloqOM;!XL&LC z$cM-H(|H4&qsF6WdO^go0;Y!kQE0~4K@6Opbf~?Xsibmz#2CrL@g7y+;RS$u zG#Ps+Y*pHE0&;KHILoRF&1t2lmqoi}&nOu^%4X-^waY0Wk!0iV@ivERyE)CA*t;Eq z>fG{Q*$H49>^9f9sZMH($?AP6x#$Z4^<4n5=au6*>A9DBTR7wPOMC9^xe|WQucg(s z%%|CX9wXiBdtDRqT2ZCHa=k4J-m+R1Nu3y~i@#pZ&ZrvN8vSxYq~PtQPMwj6?~tR4}2wy{BBznY%Yh5 zvfXX=QFV8`-41?r8W+WG1eNXfd%rf&-kiiH@fL>#aP%2?geLv-eA#>0k|q}P>dOXm zSo|=?%!B%4{p12^E~s{Z_N7z&c+3p8I$+foCK(5BMHuf#POhUaeZ~IQ-ud${KkM1I z`vRFi2l`5G)Q5Ku+1S79Yk$?{uYT%BE+2m7bC+j*feHUyoeZh?)y=;ZeJQyN?%2Y} zJRR?L&DigF2^M4;>!dSFk3ejxd0g*+h1(drW^5<>@uc;JB&%e32x@h47dvy7x zi;&17=auxaa%L&T*hY6)haG z@Iu)sFC6?dHonl9Z1{WskG%Zp%OCpiU%C8GKk3b6V;~R36yGRgq2~NDU)IB}{6k;9 z$_Ig8%YxFA39%7*a-h>-vD`#6F$w`K-r>%VYS+eX>#%Yh8~HqO`6_)m4wP1Z^-;|7 zt%P2uVfs8z&2!Zut4?i8_xKvywV%S5rpEE!KC;S@(2uO#%mcF8(65w!tAqc>6@G0b zkwIf`PUK3z)JWoWT>a*^JjLU+yn64~7c##5g_r#O40vrm(EmU7%=zY@99Lb4dH-p> z8hdH$2W_5bJ93F^(MRtYin`nH__zAI&lO|xsJ|VLeqt}p9Noeu>O_w2bq*P0ofjUG zCwhIvT6!#$v3F-bjBJ-TUgJpKimYBWl7n-atCi^BGl;M1M(&+@cl*2E^GobCUwXF( z@Rix84%|@uqUYap`ObI!GkHOPx#*^!vM`6tbZ-7zSBfhh7m1dkbJf7|9Eh*JFE9^= zOk;l7v-jew>qlc<5&I_D`!c?Z!#sPgebo$pVr1UC-V9h!g+Z4U(-hGq#H=6nRTJ9} z55B)(OxRghLjjtc)SH(0?ZdfPX?*G(W$C*d!Xmz7&qxh^AAs$(0M#*%7ZW=P1FXax zO_KAwdBbnqkya-!eCAw(Z($5RzPY%>XJ4R51gR5hXu!+g?aR_Wh2vbB$zYPJ@u@qy zY{mTa=;iIiP6YNsk69&Sdv4=9Z_??FXil3YVmVU6MIX-~HM{%J+#z|_^vDXQH?VDWj zjhYVP(WPZg5o=oT;{%>th2{yq`okj zSay5*pmzo%ODyS6HYD@V?qvlYJ^^qqr)33|&DGOM`D;TRClHBzq8OEGtdndt&l|ZX zh+Qw$aWt=H$_6qsi!2E|D(e-9NH7l1nfa(vGp`P(GT= zKLL;$)vi*`cRojp(S9j9?L*skf9>xSy*qx)3&LKry>{)_RrO-kIx{0HmvB?h zNUB|5K?`g%`TkQ zXV*B_!p4p%y~IZ!JzoHBwaaDi(y0x)wB~e8kyR&pdt}(foPt(rZNL>=lmyxIn4j6Xha^`pjb5 zW%i0lofSiR`Izcp%67M_FYKw=tNIs{zy9q%$6o+w9*2kK3iNvr{@t(p)tCS6<9}b@ z$MLbtGkRaNvz}^aujb3|;B>w^(=$nXO*u)Z`QrC_ooT_aW{!6Z1<4F{W;@%yIeA#hi}WkR-DFk$P-8PkaX>Ojm~+4cUraEik)0J(t^lw zkc|&N@fp%uXjB_CTJ!Kg2g`Mco~YD#&`z?}ynD)0=_qdW8>?w^l1^&RF`p73jDa5? z=)}9}I0q*Y5Rdw(9Gnfo{521!I8Lzzu^1rue5Q_XE(bD^FbHPOd5jW3P@-(zuz@|= z9z!(s?=hd^9rV{P|Lu?erOW^M$-jU3&iDMv%Wr(wFV#&qU$NMl6!!+`f&NqPG$xQz zeSOiRYW4k}`?1R({qWz=7ybP|mzQVrsKG=sXm%3s|~)RVyF8E{u`5<*VPDD# zA&g?ongpjy(HN39-mFm!vGXqTd;^rVb6qRx0_G_2*}92}Pj&n$0q{^0&*;SDUoqit z_Kv}96r^u??3*)2;80!n@e4Kz$n+wmff>?_7c5^xO`Li0PD0|bUT~?0dBY}#@T_#i zl=-QXKs+4pPX!)c0JuMMaf;+P!FJ=%M(&9KuJM8BDj^ zdD(-rPLjJtbRsJsUD__&?RS2ni?)p(aoBAhjdeCo?ybR2Z|AbCU%$NN$!DB1yymmy z>2c2VxqlP%JX*SmjxM>}YW0)2&l-?!j^DaGXCg=DGKrxBR%qzWK-RS})ZtC6DINi+A7SPQ#4T=$&qxcFMRtQZtUFOopl zuP@T1``hPl|5G23A6hFwP59g4?|$E}z5K2p`X1j1@opmX)Ap#Ff3LV*5d$t(dy?=) zf7XN7m*$!m-j3#O->I6_E8<%Z$ujS z_qx+cQoLPHhn@AZMy(IXkqfnn#c_+25XKx&N8}Uh_>~?~!DzA$gSc$jgk;~L21ZTgO&_kaF{%YX5~ z|Ml{xKk|K-f8#yB=JL;d^|$EHW1cZO{&_bZKP+?TKZ*t}H+rtEfcZ&vE|(v9@h2~T z_=UfA`KzD&0sX1Yr!LRyQ;g4F-lUfR=0zLkV}ihjh(d#5tZfcarJhLVSA%mN3=a&i zyY+$>{LTyeCwMkEd}nUDP85>>eIsiPMPxJa;A?=4))(f92acoCq3b;S!JFW7vnp*X z%ZTn_oYay_-)I0^v5};VQg`|o@u1DxM~{)>gL2cK7n%7~53$RRN9!P(dY6q|KDm+e zqQ=reEB|sZKFO~y$XMhnrNo(WA|o$~7=K&l%%!qaADS*Jk&he6M{Z*X!86I2Imut>RwOM)GL-mZx|kzuh>8_gr3D z-ffFSwb!}*)?&Bo?3GrwXHj11p7ptIwJI7ky!7awXQB2MJmS#25w{P$)tW1^H-b%E z)RQM(ZvKD#mCtCPcN`HQ^SWG=-mTW!hN9D2>=a877e;$Li5`7FZGw=!Qo8&$c`S;* zqP^{ruAseQd9K{UbEPa-jW1mq2=N|HXQ4T9{z(&jJYV6t@eUBSi8E9@PpBh4WT?dk{q~4AH{*4HWZ`*m=g>JS ztv?ibNBu3j!RGJjzyEU|(!UG*D|BV@@wAq^Dggs)7$j|cE3mHb$zGE z7mUyO#?>Qy{d}>8@07(o+vLxaV*80#KI<1E{?teQroN-&{g=<_Pvv-b{%ucXZxSb( zxsh`bPcD(MYT6nTw6(NsLLH)**l$^H$Xz1|Etg?%7%^vum4@{-%3dJl$*$d)+BJu~ z;3F^MqtX`wz>DhOF;I(sbnPFp46SXQV9^E3bexk*RAj&+wnw@`8jR|c;!_%W5FL8K zA5)LfIOYL08hDuL0akuTh;QnJ+$!qm*DrE7Rs_x8D#jpR_P=8tJMr9J-PVAP|-o>Bw>4) z9Utcl@fLplU1RxVAzPmi2D>`D>`Sp4)PNX!SP!(v$;hvB6eda!X)XF_x>v7W76k4>>W!D;I&XE73Z(UDc3UdFZ z@n5c1`i={FK6KwxIBz%;7h^Lj4@GL`~SW?^OD#`_pewstykvjO2fBKad zFaL@@{?B&+R2RgDwkklGc}Md(eKP9b(H8>#^-q7mUzA9_bMljh=c21Y^q3oUHS{}7 zQs*3sO@2_rHtVkQ$iK*HI}p`j{@QX=#|ZnO!u1@FS}#r+uC;EMU6wWs`qD5~ zBEmLufWpfS0a@@aAH4j%JDkKI4i30k!dFb)XO9^LLPIUYLo0*lFnuuJy|ID#Mgz$h zyFN!Qu_G>m@4hzB*r3rpHE_L2|Ik@ zkrQzur_+4;XB-oTYr~p9KCvJ{#+dqLWyNW}Rr@?>dZW<(0y_W&h>m z(mbH6I(T?|Yb=lRS)IE`c;in1@;7~V<>B~AL#jT}idA|`|JqmUvnU={Yp#ov{26yH zS8lO=oXyfGLMocoRWCtqOb2%y_6ehT8>zu)aGihSE6(HSt=OyoAzkdesCPM@f95Th zSB?wmwJ(}mYh1tAw0MuAt-n)ZUZq&xQT55UzDmJt-Qd+X{9Pw{i*xjZd&|B!rM>6? z=TWUOLnmC*@XuKp<0_|oOmO8;-{|&2hCVZ}x8_oPQ(sJUhOVeX3C0;|P-W|DpcyuD zhbsf)d(kcHeLWNFDHi5sE)XdQ@f!0ry_2Vl^I!Sa_t_KgXg__unD<1#g8TeCF8KLU6jr*@8AoV@suN|B~*rRN)nF{P${QN{5lZuE{OxpNfbh~G7v*haB7 z@sV77n?(-9V((yRJPDYf>ezxX(Ta&)azLdy>Fsjk;kKCc)5MwBS`p(|So}!vNo(90 z&5;=06`eKF$~Kqe8U2BZjRuV?6lt}0N*t>$=rVr#DcykU+c7?WdFApwAN$_R_k8qw zFW>OycU`{e*{`|$tmodX7X#jV`Pu5j`|79P;=k9Qs2@S{>7*B4`o!gfFMag#!+L>% z#|PB^p%-7cd{Y0B7Q;Zh}O)YGB5O-h6W|Pkucwx=yZN=FVovoJ{RE zEU;Z-|OJiE%a?kjU2d&tlqZ%LiU=%y#;WN*$spC-j_PJ*O@q?USABc@6;@;SW z`Ea~%6?k|7;J(es#KWeizV@K?Mg?@yY>#6OwJu`QFW?c&Wg-Hg6=C$JwvSJT4b|*2(WJPwKw_Fwl9N^C%}u z&V8kL>bbC9&682%X1w<+hwiNal0a?0nI-E;_I`C&+$$G?#A9eROEX`QE}PUHoxK;E z=4k1b@1E=4FUp$pwU!rpu=gYRHSf|rXa4mP?MM0ezZAeKUTa#PDAcyRT_<*{3ZTXN zwG|$V)u>|Ux=0#}YkRD!zxw!X93Xd|=wG+m2l?)v>COM$T!iGH5?TBuJLfVN3@hi= zJI`b6W4Y$=h)c>Vm#nMn92i^Q#p0cWqOHu2Dk-kcXS9l@IFN1r5<@M6o1DXI(q6vnYk$S%FMZ+%FE40+ zKEt2q$i%aJT|UpV%kFtiFG{_c_efWu*mrI+OVRT+Y;nCd>J3^%^=HlGLVaOf?-`b- z*I4Iz9OPy>tBosS*jYt(V@H^xmk2=K)|qiweMpyo40|K4?p2e$c*tq&8ElWeBbj|! z989a&Cc1a?6Q=O;H^g{vym~T=n?jhPK|6ApI>N)!vBRS#qYH@@T5W)j1N?iWFQ#+7 zu$6Rbbl}(&8#uj{3q{n}ZEW2fY%#8kYh7e(*t-4u%i$0pqe-n64e`5Gn_22pQTQAS+ z2L4%piJ<fhjgN<98tiFf*+)Ah-_o_VW3 zMZkSC@$}r~^+aei2Rwz+aF*H3P4da#iU)%lv~J!bM=ijL6j%`R35`^8-R{O^V_?3s zronCX#@DQ~yifxww#$FDeY3k#i1Uduc?ZwyO$iD%QU#G0(t<=xgy0c)?r5{pWx{i5LD@-i;f*sfAOfB+r+Z$Wo7(`Ib!|L?q>xaUI4hC^P;G-sR^&W zY(qHNQv1g8W}QuohIjH!YUUluvEc`!TY#x!X&9=0rMKmv>h? zoYrRC9BSp4vEtt6C|2>f__uAUMQWXc*fh^Q$DaFlT3qy7|Hg%E;TAviu(Qt8S^d5K zWYRI;X8f}LZt{aKeOk|-dFL_cL8{N{$Fj$z(-_OSO~d85#}HdE*L=orXR37h-R&D= zYSP-AeK_IeUcF|7*R*g}J;NQ}RdVDf$&FkVMspe$B9aMm`8vmW&LyL|UR4;heGyF- z)iu?j?>a62?Ga?28e{z-G+=L1ohe+~G!Im0 z%@MrQ&6E`CqqH`$YmA1`0RwVI3oq(z2Faq{C;4j+Q?5DO(I^d`k;XTAjE^skLoKeRBMmLe)H0%C zKo2nN0Mncv?O2Nu9e#)3`6wL#lkU~SmyEIJWAF8F$i!!l*!>FLVI0~lF2^>NL>ZR% z!>kJ|#6NCc?QobDNthr)bo7^kiU*rzMRjw|*zLwlQokUAZ}fCb)QtPW7X4z;(SvrZtCRgy6|R3vBcWfT!2r z?9KTT@y*r>m7>%fO0NwYS@~TB9p9xpI?~hvoVLARULr|NG7HgaT(#kxPV0$3qa2%m zX=Qt*;Wa||3%?#>)jY@Dtf8)@=wQL$G=56&diF3nw^(sD z#_ESI{S{;KXBws-+M5oLB5~Yek z9lW^!ICzJOKYB~F)wOI6`H=R)TR6Iinv1~ed>l7-P-Fl} zFP#}R()~us71zS`4IXsr&@mrh|K{huTI>D^{fWsJe8IkfK6GzXAYVdux%|%e{>sar z`q=t8?d zmLOl)7WG7DIT2dLBf8NiqWqY{e-|JKwOAyx`ih4fn-S})p`bVBrtpm;CO3Fq+eRO~ z(f1k^-?jsm7dWt3?Wk`9vPc@TX&q}3iub->EPN?-&)N+&Vy}2Gxe9YaDEcveGN-md zT#@BQWaWnh3^=#22*2Jq59}>93Ko2OnMtV)6iHuPJjR21##^ICocQ9y^Fm$pgYU+k zUh*g}ktV0|ptS1KXP$UL;F;IAe;FuabQ1Z>AHS2HeLG{#%aq#4ev!tLRBzVOAu%`h zb1f5*=NyzeAwa<-mz5tqTi6eXqi2`Vj(2H|aB){0F^dDZM(R2fo14og8;j#dYyVt9 zBFS3k*)(44K{(VRf5+}LTst&~$(s1km^~GqEJt>@m1gQ90eV(&_5s0=;o9M|rvEx58%~%-HaUBi}**MtBYH0v0l7w-Dfi4*8`11hd6DLM=E>?;G=@`Iv zpkd3iv8=@tl$wRY5d};X&D0GU4|L=AFfsSxaNjEM@B+Ynn~^P^Ikkx6Z8Tf2TP)mc z$7+-}o7Y=Y8*%6c?QU(eW!K0X|F8uUjMY|L@Dkg(cUSjp6Z!JkxI>EwZR|M_#>=Cz z8s|bz?5RhiYA%r_zsTx&&#G3X($UGy{{~P%e7vqF$DT{k+~aP%dn_xT;$~ixbNn}p zrE#}copXlhtz53H8e8~@uJPQmYF>E)IOhwm^4-tE3u1ki?z-(amyRDrThGtxqmLha zNq+*MC#ihAj;xzO{jOSq2e*E9&Bk?2uVcs)JQ$!1k_vF1+r)viZN&wro>x58i!}1n z#n&h__MOHi3dv9L=*c-T7IFC;C|#^1s#7kq+H*md=lSLmS@H|c5+d_OXB}*`x%isF z`NwPG-RmF@Otm57Tl$D5uS6a_j)_k>dok|Sp$^e@>=Kz8zZYx*Hb6Y~#_Cno=N$B5H55`{k$zWjQwaFFBx zwnCmyGxX$AbVSnge#6$-8iOrxG@PmW$DYP1`Pm*thA`CzGlPfYeX0P@XAkY3Rv;%h zHr#B=iPL*ceQ)Zah^pt^2P3D1jhbn_Wg9Ivba1n2Gm8GBj)@At=Lh;rmLC54Q@lAn z*zbyG=|^wZYCjTeXuB;M|G09oGkofV#>K6pNeIU|t}(TK(DD-J8V7G3n;jI`QZ&X5 z-BZ6hrMvvmHjM#|KCnDv_q@${+$Vmi?tSa;PHZ1W=76Zc%mv(D2Mewa(O!A)k)7RS z^w}@y1%Q`7r7vY$U&wgvW5=YQSVD1M{dk45TQE*TZ`nr6IVzNWuB-nj+B~4^ksFvL zQ_sKkTFE&&q=ai~yz1>aw4aEon~O1Qxx7yfrGbqdh(!&;I+kCZ{mG=VYU8&TV3jp8 zD7@TU%!SS7)q1?rb~QK61!`NrTXD_>+xpoc#g)%PuyW)AtNHMe+^>H6O_!hj=C?=R z$A`lkt3YP?pMS^Cx%{i|{1$y$=Oz6qK+XeH*z?87q_ZRSeLk`lGC@JgYs{>AMC|>*9>d5=QsQs(uerWt}wK*(9caH;}-|-=rfLhReu|GPgpO{{+qW~hmJe1V|qd;5{0 zvjW%HXxYHn8RTd`6_FdL_@ysfV&UPrfSQ3G{qE=Jo8N^O9TpyG!HrFMJWSqpvadaXCq7XwCU5FQvmwPW7o&Hh6j zENf#-q7gf(@_VO|1I=?YFXmOxxnQS<|09AuA`No(KhEq@d}LWG>Fr0^c-=-HiT8|# zG{)q@jWEsUlqhL{?FdB5o>C73+kheG90OKr)mbsLWyTOv=2s*A##%@G5d(7la9(lj z;@K|h)_}n$53-*<@Se0^#SErHmrj4ga(=e8Q={S}4p2~IXy4;6&!5p*=iul*9PeKR zSOX939#(+umhHXM)86xDjkG6&(&({o!6}_BeBR^$sKwu1dgc@%HsI2r`(yXH=TNO3 zrGD2_TyrR0In+n(v5BulQjB6=QwQrq5g0@(2#s)2q{wQ4kLUFNd)I|r>_MWf7|wh@ z5jx&8KUO(jOBgq($83&DM&puk88R-*18(O zB@*y?(CVucPAsaGaZyU&NG?UdhR1Fb-)Nj8-p3aDP=qD5WrG=Hc<~dutRC-&3lJ_) z)_k0E#;bLphmYvk#uqYoee5@KU^LU?VvT6^8_Uq5ydo~&p#61kdh1w{%|m-56`)dl z7r?VRSN_}Y{r1bd^o3?GzovgZ#^tVrzCkPy%Dw`Hks9w8CY^7js4@1tTGNlk>=~{| zi!m3;45Nc>X4h+p{X9Cc;yeOqv~p8nXF7~6;%JEjsxv~ooX}MqIcLRl*PGKc1~}vI z!0XL`(GENia$y}GzOZ&0lMD97479A<)%Lja2k6k95s=iTsre1jMf6$2l~ra-NWyrB9=~*K5|M9cM=&* zqRM=yetX7L#SbZUEe)FHVbvQQTIT~ynr(Hc?#g9es2inYy|Mpf%%ja19c&bpu4!V* z^Tqf?kN76Aki?6~=NrkrMl|jkqvsppv2z&2qo1|zq{BHu51NnCx1GkA9`!_+AT6+L z^%$ZD?6NSrk!@0%NsnT*YM^vV{pi$|g;=0A} zxxY^y^;CMz-)nFS58gGuaqTe{AKIMD%WU1X`f+^=G7ZV29$+C$UDTOAw1}18qVTGR zO=B~z4yMCj_HS&@V9SG6ANBVry8G*s{k8o`N5YQlBNrCScjcx!>|?NL`y6!6JG1E1 zI?wg9=RksM>`S2K2cL6c0?xI)FW!tFS>r?3O$IB5uo!oTx~~J2*r{7{=!I(Z(MgZn zy6*LNKmEKxuh)J&JsclZ0lt`wf1CJiZ~gkqcYe*c`Wwpm@=h*fnPXR42PhmU^O37f zH0WwZUe}q^bZp>DIU1rzM}oQd_RRr00bl;cwim)0TBkSFPx@cc1wZQqjcH=|=6KdB z&|E;_o_HAhL0|Vf+%WLtnAnGPmof4WOg$_!CUc;OM|s4KC9KIsx{+lrJvYQA=3ygf{N_k+0<41QkprR;9{|-G z8A#}uhOFc0La24>9dThwnGgCWDu!bhB_`&DI`lztea@uFdQ2E`Fh?+H(Du!^I#hN| zuha%7>kXfoOXeN#ixRhQ09Bmk1oo{*ec1P&M3t*B_S-Oc*ld$*oYAG`ou3IY=2F6h zw(6R3YhK9A8&w}Hb(oKa$C{;;GPL&033f5AR`nSNGjp18kM-9$Y$Z&;XvZKhCY}cw zz_Q&EZ)+$sfxY)q0KG1~@35CM*9?1rpwjEAW1M|BLKw7hiY_hdLW5yAKgS+9fuhRe z*w>P~4P%c332bl=JEGFSFS<&B>^zs^MaNk+gh1<sePex;pUSUudA$9K zCU(tf#fClS&gc0Az;WtB9*^Wt>lj)d{5{^rSg#$IqTdE@w}pR8uli`$cWG=MZ&mx8 z>@(^UO=)#Xn=9kyT3%^&ciKpr*Dky6?>MPfjvw|507bgC$~c;-9~`uA`7H06V#A!@ z@ghcfJLWBWV{mXogO(@JYkf-#9L3YI*1c$`{jS6lcw;KRO;<&PFESS_^^4x52rmLdY z^;O-^>(5KxG5<&VI{w4mL+54%RK;Um{m%FPs>`4I_zzru^rcVw+sdgqjrs6A%*6Dj zklBC*2J6Ew)EwiPiERwW7r#c64TjU))uC%26>ASKLUGKKJPNem2hq2m^ln@;2j1Cw8rZ`2ja!m_-(GR zXjrxH8?y~Nh{%~2wC6h(u&a=M0Z>spjiu+hY&w`;>zo2X4$#&-9*ousarK~xOM9N_ zI;;Jz6Z=?n25OItw&xkn`DomYbv@ohH@nt9@`_4hSXA}bJT{jdXwRqm&Ansv{Wsg= zhcU9px2)S^aokdNahBeW={XJ4!K`ig%-+U1 z@|@UEn$|jO+L91h6v{`>=a~Zw|H}*KwZzHpP~6p%PB`(+cNV!!=!~9KhZ!#tpw}QY zbem#f$Cm=;MSzH2tGxrM`ouS}Ij&-w*Z3JEeq7+!wI3b->4W+t7`ggNkoiEHT#1S1 ze;sLE4iD|VR^ZmT3ws1RCzdlNfS=)}0w|`A*d#zK9%5hJhhKzQN1#ju(B+eHA@=jJx=*Xkd(MEXx2w{D!U$+zuS~o-O~ClB_{f%)qA6~PfpkRVAe-v*ZJRyap%!rH4Vy+x%ZJ4 zKe=!bkki%+FY5~c$x&d%zotQ7$NGitkH&OO(Sg@T$Gh#$yJe5O4LOZh(04m{)vHc@ zc8pFNy47#rRLbbn-|ap2#l(_T$97{=9#7^}xfs+oCI?hid$>C#!V3U6=tsy=jo zSWgDU+3mP7>BSc|=s;;Ztu2&xt;a4Yk0|H*$`{vszXUvAu*Ll0_$5?;^B;c-@D0zt z!52_ z)j1d|9v}(}b#C^bIFMgA>xp?ygNj8S)QUbi^T=?{1sb}}u%l)-do1+d$OFUOuH)Jk z-Z~v@)Hh)ohfb^sR!~cke87OS)B1y2TWgHxqP{!7&#z-Zac?jy5P<8l%jttTdR;e} zEBUPbU_g+O5Ag(C&y9l(J#zN`AmPo9PFfjawlGrrZ%Y$_B5X#PC$)ERCbPY z^zyu8Gas<6pXP$VIoIdJ&hW@dMjgA347L{2#0nc9X`4SWk6KCSa|XWTyw8ob!dcHh zcBKO`9`Sp?SPvQD4}At5H%`VVP)BrnJu_@$SXT_|B6w)`t^%Ildl%J1K*xsb#)FDp*)T-r4acR!-h2mDx^?Q*hNdX-lDF71ZjW3Lxza*4ll4v){J7+BtIjq6PJ zQS5WPj(XPvyW>5V`h3%qxhcp+1NksBu_&*)#VKt~>ZAD8FMl7`W~aM6Hu12>(LQvK z%TG*K06zE2!7e-XJ!{mZnIvyuPS~zRz;ESNUg=dB#@c=C`1M0vMDYTj0MGy|#<%EC zbhSrC2>am-;M1^GXDY-B`_`G&T1{nE+}Ve^WySO~Xzi)!%?>vLawdBxuau+7OaPjqImW(eb@FCbI3sT)v0U$xgIGUM z*7PCTN;xX02pKgmNn#THGr zD|hS>)1V-h20i)Y!bi{`g50O))h2(_!`@&ayW#fY~34~KhI0YC5DtB4+gaRts#x!FX=M(ZxNX14O$W;sDH zj%3lIupxPHLP6S7%c8N#wvHCsee5Ua+4`0e4qC)s3a`1)r`12&IXNWKSXU!7lEzbu zAjGyO%M~p)w{l6`;8p{K=2z@Ut98yLexKFfw(>4PJSPfJ9@i?3$42lti_{5?+R!f! z{YSNA)S9&tOS{hn<@n-61Nhgn>g;ovnk15)UtK8DJ?{3QJg!@DN-MuvR{`*j?2r2s z0DL4cPhPbxethj~tko-Bn$6~#f|;M@BS=TL#{d9807*naR6G@|Rb9081`}>*XPR*% zS-GW$PkiLbGBGjU8>4U<`!%h8;y@dXL(3Ce)X*bJ=aZ=IQ@XK*7B8IUz^LyUVRQIV z|CEcc8X0=+L;H8vXY0|Lc)5yb4DlgLWEv#0;v#u&CcvJS4H9F50gZh~#> ziRq#JR4TwM@xtl9|4;ooPao$`*H?$EDUCHhzG(DxWS*Yn!qk(nWWM0ln)Sz@Wz!W< zYIPlatv#4oE4kdkN17+!{%r$Oo&4LtDpewMcQfuyDp4k8dBFBquMGTN^57?*xEQ}8 zx!FprXeb#ynhAI#6CaUv#Onz18TGM{-Fm{AVcQmEt*Gp|nuj&brH*PVBzPGGA*N>gTP{ z+|cnnmK-y`#L9y>h8{l96A!mO+UwCX*OQqX5IhNxI$f1m2PFvD#JjDHm77b=aag0l zU^SyEA3q=1r{)Hx8hCB~F9RCvxghhr%Kp%ntwR;ZH^f5mx+@<#cDUm_kkz1CYK*LY zLu1fA8w~?U*GL-BI)8wqAMPAsOTE}V4;ex#&fhP{p;6>jW!9bZK}Q9`GY8|yq{HIITu;e{xHp+OAf&RA!7+A zXAoIet)K9x?|H%Sq20?0JbVYhy_}W=#lFc#VrzHnBFl~{C$!tYyDgNh8jJG5Oxr0^ z#*d?8cGxrCF>Z}_yk%djB`qiPb}q4RMvXVRyZu6~&WaNZ|>>akn&YES@~<7rry)r4l8*b_ypeIYPFsZ4yu z?rtv2XM2M-G2X51ihYkMwIiNZ6up@n36UqS#y&WJ*toguafFu`f^7B4KKnKG@8GUO5jbh8EAJLHk&Jmh)y$!$=KfsdjF%wO_lRxmA}aiYz-&AQ1N?Mg+B zB^tf++JcMTy5WGcav^dqw!v_I#Kj;7ItDiW@Fx;kO3St@LR7Xg9eG+qhuDT%*C;qq zz5LoK5fO{sP88-uXO{`U645@lcs{JDEGHLq(t?dq_xq8H45@-OEKg0ai@luKA9|@y z_&HWkfEIZ zB{h|`ej8(GW1biekFl*^>&~KJN@ymUgd!xKb5x!`YhULD0G0tumR6iKJZKU(-bkxs zbIVubXEv|SuR{=6U#N!4$m}ZYNMFzeBh+#lQ=r$(!oJ0Kg^u%X9>0|4t zIp**54J^at5)6s#hm(W4>DwuC{3emZXZ0tSSjFuT6P~4)T%j4`z%glzFXF)!x{b-~ zhr_+9K(E{HRZtJXP=RCn9UCfjVLP$KyxnfnY`|c;t8)gC5Plvzk42$B!}(v36xsxO3`~l|8|Z) z0ccx2I2~a@V_fT@<_rxYG~7`79q&4DgL9Wgzqts&7>sMN5Rt|6NWDPtjSiAL z7=;tSc1_qC_losuzu^ z?Ogc~yJJDyzgW@WDh>@_dHoCS=R(fihr^dq0V>OP0dVg8uCM#{%lqE+HvK8Ut1A1f zBow$Pg=miHF+ZJ=e51BT%q?79>NPA8)|~X{WlD_)c5cq`5n>Vi(gUOm}! zo5l?n8sNuAw9ur77C-dd+HIl2Jv5xNWbuL3e1d=);Y*K|OHey@Sfl5hni~nL5r?Bw z>}F8|#{-o_4}ZjbXvdKOfX+3IUHGZNKsi?W#GDtcLSbFERX<@g2Xc4Be41ur91iw| z9vXRc{7$|2`4mM$MxW4Q720)Y90kfxTIil?>Q(C?d1gm2C0=tQh?Wa9!lxx44J?Gh z5tWkZp^@2g-A00j=ZE?j4P+b{##(UFsP&SUi(O(UM`Igl1|~>!V8zF|j;%wm!H^%% zjpCIs{zZvNgO00L4c2$&oH&DH9s05jD>?M0J@O!8VYi}SdA|^ga<;|@8&hH=m)N_y zBuJbN01iXRXS_T~@X+p61$xfAS3x}l`c-j}MSKeiTKsGWxYCMYhY)p{-m~|Bj*kvEF9P`J3kFuyp(UloS8R zi#007!%O+s6ur9HX>&NstFdw@9(wfyul&Z1{8r;n06zKZOOKcbqM+@R82YsN_;q%~ zF!AIjB{}p0Wkza$>VVs+%X}qCpdbsEn~>T!ckqkrkWB zD!b015IC_$}|d{Oj-j*30MT zy8u%2VEIN+6T}=bFP)ou>bvUu`s#dojrK&hfzr*IE6Q45IKrN`FxTkJ3rg^8p!nL* z6o&jC@i5jP7u6alcbeCz3|B6R0bO|I14N|j?{o16I!A6a(&K0w>%&G6J!=R!obb?f z1x5wbw8Y*~A)+5dayjQDJ~2-v>_-RRl)xYGaTR4NaYN3Knq)qpZA832%+E+NesS(! z9n_N9Sc#SWPaf2*kC9tHeP8%W?)XN}H`s{5w;!QXyBR}AoQ`uQ!Aa5-rBNK(7#~NM zRj_<4^AVIBTze^#M|0_uefLiNa?Qg2vM%-EQS8Ymx!@gvs1#h&QDjb#6|)p`@PeH_ zM;d5tzTqu=#1&b)_FiX_GzaHIIB4rYIIKCZGtu%bJLVJRamKpslY^AZwNWtK4NB(Q z$hcY(-xOzPd(hp6M?Y(h_`>x0OlzEx&jV&pyf(On7tmr3QE=ocEj;pNQNz~dhR%e)Xw@EgU5WJ7s|2Lgegr8>me(}&<}@uT7ic@ z0l23Vvo=LTxbBWHLN#kukkIyA>@B%Xq*L^gBO~;v;X<^Pd|}B-ed-H2Cd%yj8irc3Pd2A2FLtmWjgeLU_@3ZbDO2=}bQH5ad%hhT&o?Rm>%%yv#;`PctepNc6ccynOlS ztDnC-|I{;g_KcMu4qs9QdSU!sU;C>rf8i7Fzx?p$Kc;^R__W@m)Vsjco}y3l1tmPj znxpK=%vbhU6qz@_`{eb5KJy3~bC?nO;h1X~zPLb-$E+eBy$rGzvQoSvjAg@tz%RDG z86U4B(ug=UvVS_<=qdvF#EvNAckoLR8rg73JxmqG*W32yBtk5HZRf{nGLr*q9hCABUYc+bJKr~l)4-i#;lXZ z-}=B`?>K^Mr!{shGQcsuo2d|#Waek+rfnNx#b4z?;&{|)v8IP4@?jjPtygHI)lO6$ z6~F$gn>bc|qVK)ZT^Y;vk zE1qJ^S^_T_=Vo%i24SDV?tK=z-Mr2@$0pLE)$`O+`p!(j(!^^k|9ffA2v-?)tHOf6}gu700kuIIb zAuJh@ViTH(=M*MxB=wIKt=#xpSV(T=5-XppZrE2EYep-lB}?yC1k3*;a~ZRKt931o zi!%FM@!_ko_>JtGcO;FdwECr~h)@4VjVt4w%6a}M%d|Y7?@toNg^hmNI&X~YtFqF0 z3Z6B2H<#9<`q0>{^-w?8v^MZ>wdPXUC%^F0Rh;!6gm{d0CFfDNx7OKQCrl)XXRaga z-n8{wwBmY*`v@PI ziLxs&`YX;{RD%`ZNDPdznk>ca1r(ekmho@w$>~lm1QBSH+Ja(VBMYv0gSpRJ=QTNU z(K9GoTx3`Osn!#hr?q-{_y5DMd`ADq?rl>cJUp~7w*s6G`1H=+e%~+8ze-bm-+~#}Tdrl>T7gNg*1|n>XNH&0rk)Dp@ zpD}Fv7FX1b^QV0fQQp~y&+@gpgHdnnVyLlM$`w9}(+V4Fn@wy>Vr+bhg=y9+0O)`j zYFs%$e)xAkE4w}#0Q#AO<}`?c%YFtI7qWL(l%kGbD09<| z0e;)2KQS=^`3|+uD;p{%e~AZKm1GScIjpj6uE@saS{qW4Vl2=jy`eNzw8j%3TOyKiU-XUE!|^^p5pMDm&vieU9M#ylsd+f9&@R936KNiaIkk+|9y6Sd4?1Z!PCuE5x6>A~ zF957pqZ?i4Efj8INK|XZEd4|hITuNdEw;5cTZhPZo5asC7z<%(*E;^uP*n5G+W1O$ zo%E692Sn?+Naq}Cx5v?_nhSBPYmh{m%cI7Arpcg)ct}6|<)4^x@d7WA)Yc3x1oUnL zlKvdI7*lpPbCN#=+#h3Uly>b4ZuCvZihds9`cl{spqA$hG)i=Qa^h(TKkn^2_D7yyur)zU`~N;qsC`$+JFMtl42Hh_h}0v~E0KnHxU?c<(SN zvj#k7?lVq+>T7IMGa}ENXC+&vb?2ogO~32a?1*E7%f68X`r>o02-Y0MH>z#a)0lS= z#oS`h5D3SKB0?LaKfV*42FNUD@8|{sSM~y>!I)ggmA%3P332Mj{_u&fHsb&tp3i&Y zF*mtj7Y9Q%ACWPTdfxGbi*0D$=~Ik*Ug*0KAJ)=uKBD&HF-piBtz+8MvK}OUYRsQP z9k)DgG;*TueNX_;gL9L?PHkVHueYZrX`nxF96~^Mh6h=bY9pobq&-jwLeSLOUrtn z=L1;A0fRx#SnkOwEaqgCf}t-OuPju%d=}KBS-cP>h9ZE_SvcS@j`_a{vk+ zKFCr1lmFn5gLAZA@ODhhByzK^2Dt_j9lY$*tUG)pt1$x1Ak;Or@^uYj;*=a05AA+d zz-#<|hW3>n6&os>tn|7f-H4&p?@913tv&k725wJsw`k?-6x80r@NOSW`aP?xnCde& zV4hp~XUuaBpoWv)wQk4Nxu%Uf*7#rewGHK%oX)vmIZu2N>$&4-Xy0n(qe{P&er31p z;1OMHT1RZ|TFrAtpF0dkZ1DTI_Oav8$5;F@I{RpviDl(bKG9kn^qsYto_#W zQ8aqN`Go%dpLY@y>ovVyC+wr&9G6|A<0<(ZeI&6+1(Iv(6?e8le!8#ztXk{vHTBrYNgcIb}L8=t42c>413S6{sR&`Td5 z?O1v6aQG=zfO+AQRNwu+@9=l3Fe|yR*R0GE>YQb5bgeQ|QeZuKP6?xP&-zI#ouLJ} z5Qf7=E|)&C^T{)-#QRj|6lU46#L1y!zXHyU30isBV%Az?b|8Z#MQg(I`MBpT4RV2A z4WcHOKF1_iV(E(}Xtqry=7OD=#gVQKel)_Ie`4iyCx_I7pp*eNL|LOmTvx_LghFX< z4DyboT{?1f$RNk$vez1Z6sJkTmHrK{2tiLHoaxHLRdP7 z-wAJsRei38Vc&@neVX+wOP@95x`>x}@}xvq$rxe}rK46C#Y8gZKW_ zAP^IXqalW@FUK~MoLCzq*x0N%D?j3vcv6*aFvr?n@&d=CFp*)9NaEjfh>_Y5cdX;< zhQxqX;z3g*l#g?6j9XL3CsyN41Tvt0aW3gYyKfa>Z9lYoSpl{dJ7>{^>0PKU(tV>w zFB@@AAY*@Q#@X(%EH^XrfuZiofXYH~u1ftF_o2~72Tx~U-6B6^>R^{Xu`#|_#0I`(W;-w&<#KIRxWmE zOnR};Mb4^IF5Ze=TKyoP-A5$EuyXNWqDdc|#@T-3-F#1M+|2K}q{gw&1s)i*Vur>> zc)lXqai#A?Gj^y?LyLCM**xafjpNBK=f42_@QZl?fSbk*Y7igDmsf!l``h34jhF9y z_b<^4EuX(U{iOc8islVTE~e?Mb!H3ZT93@1I3A-UJBhp|=p1V$I=*3w)V9$(R)KlO zmriu?4W~MD6FWW<<>(Nz)=})#ml+qmv6c>vn5~l`LUJV1l_561-SEYB);Bj+#DFG< zJ7p+|So!iq`Iwl|M7Pn~)aJy2BskqWygS(!zHEw|7&nYy5)QV2Bqv554@IweICk`% z0}4X)SUu&RnkYy8TURGSGJ5!?lwAD4gZh?*CItLvj7D(B2K_!FlrBD#f95J0QaqaB z4pSQp41M_^Z?#a4jPq!%QgitkelD_=lQiL##tnLnW1)$)wfIf~Vz+){=n}!M$l)Z< zRZ|J6pS8eKBsLf-erf}iV5_yGl27WJ^)>V3T8^%LpL4-iG8#Jbxjqr#04Anrdrj~s zHTZ(65w)AcC-yQzU5+F(6(@IjcD@+xHW??U#FZC7$OAldvBwFFE%v$j4KDi}oy=x~ zat&pN6+CGAjHeoO#a(0aOs>oeKZkF}LN2hxce2vB&JP9s+8PX09UF#l^XCT>fpo2$ zC{d9{A6W;ngPj~gtw|jmkcIYZAo6p($k7aj_a(!JcJC_i@Erj6Zb}+ZHV@m9QwTI0 zG-D65%iPVzMmC#n=-J-8@?=#UJYX9dPiV(t(dZ|daN}WFJZ^k#oxJe6V?8RDShoth z1(Fr%qjITzH9=~<&S>X&R&GV$)!=jMxR&^6AI*->Rv9(k=AEk4rpQ;8lLRMvpUXEj zc#muKx6{YzBLlKIls?L)@$dLuTeWH29t*HB`*~%Q==09kUYGV?o+sm|HlonPx}rcQ zYb?Fkz(c=ut^e5PgK*ATjA%yNMn+sVdj!=D-77ctPIkB0bryfWAV(wC$?I`@hHHf6 zmP=3UY_G#Y4;n|f=ef#!pqti8G3tD*n0+x450+nh2}UxA)ye_eM7Ps;_JxT3#TK0Q zg0sh|9Bg*Z1&c;Z-`91*@t!#((vh((wXUG#z%GCDlh0h<|Kdk4Kl#dMFJJS_Tk@%s zE>;41XkS(Zs4)K#fKLJZ*7tnd3`5)uoeE(*QG1r`Huf4Z)JgMSm`Q(}cujAejPutA2ZH*vo-()AHc;`HB z&edZrxe*KN#J&d$TSYN1C#Mw*XpA& zOx05#y#d$sj9|Vsr5}u74lg4&*IK&Fi?zj1#6}5Ewaps?z2+cOn}NtC>H;cGd`EEz zM*#S;xe0oarzh{~G#;zy6jP5zOgnP&i!^9R87GSNsuAu&*tMhUSadQSIJI6L5@)Pq zm)u&xP_&FQY97Biqmf*XG_x?OfU#O*$tY*)f~DKUUy;J^$JYl8jDaPNZGzl~xlrj$ z86fuHAc%^UkX+%XH*@h?uaDUAU_BwcpvO8H+_`r=ZagDm8IE4Syg+0xoMJi(DOt}< z`O~0YdDHG)^wF`BPs|{$H4fRIdBJMcxSk2lROTn>7 zAA4StjDm01E0{3-2mLI?BX%UISr~d5!%Muj2VAVgW06ia37)RcbIxswZR(2MyOQg# zjLIG($-LMS+Z@lB41GI2pbGSA{eVKcg|aHZR`bp>%_w8<+NH>L_b4jaz1SHwqNNc(4q>oKwh$iRzTRqmMS!5hPyAEEi=Tg5#!e=e_ID>gDmqrPG^C&%{ zRr4BMK9@8RuXu{O)1{qW^K7j&O_;_lre^_t5&gM?9*3l@4pVy|{bC5W{p?@K^61w|^2*4=aE&>VXn@w@A+`etrcF4JCuqXk|&lg*)Y&!y3!%K=I$-PTL} zds}h{YWi655M(^xJz$&&=e)20aOJizf+MD6u|T1U%E5Re7e8pBwdB!eTz+#XMMIB$ zT9mKqFVz3yS3j>$0sMw}M-8)Xr*E(VJp8|<`T6~?e)959fBc6oZ|YM$-upQTHFM5P zeRen&>u##pZeN!uz=WPs`z~P8X%iN9kvvjJ{{}Z z={U(RdOe84b~7wuC%jN{Nh_U-Qj(^XO9>7+^+cb!3{5P=Gh#yZ&QX~J)~of1#@OK3 zpZEqTB^Uf9jMADBPmne$Vd^%Fs$2?t#NxuLKl2;DorsV#FNgs}Pt@_mhInYO5T~Vs zJHMGGn`T{#ggNG?*85Mc$>(@ryPCsu9E&F;n}C+uEewZ>Dq(Y^D~c`y=12N=5>K#K z8%9z{B%BAnMb#E%-B+gG05xx}N4vl3HC?Yad_mWj(Nm z)r>=(*rV1aB{Cx(^z#}Wg?!?{Tm(KUj}IFM2@{*4&v9!Hu>!X@03PD3jF`A6(Mku~ zoUywmD;FVecgr)zp3PUb>{|+y#CG<&AlDvTsH1v?mF~44i5UXBfUSD>JjIj zSN)cPc{i84{?SWoT%q^n5?y<*`(eKi8|9e})k&J?9@jCBsd}g57cV&|MPXd$@9K{F z*3Y6;zELi-`*K0Ewo5xbaw*nMXMb};oPLd!qsF|`+U!2y#YecW>!aDk*;bn~t(?en zEKchmI)btuI_Tq}UTWX*k)Hi_yzIdQ6`a!6>@JpHU#*QrY-~v1!}3C#OYXYz;vL26 z;e&&5=(>5h>mOhBeDseb%{Oc^tvvLEC3n2Y1S_#r-?5k#zQ@w_JPQV-pmDCw=u;r` zPSkvfWSRV;g?0V<<`tDT2^|2LKxV(67G-oox#4m_7o(;(`raz= zcx?Qi{*KRH{$9Ni@Y*|Xc~*<+d9#jZNE4;GVa!}H^`o`#`H{Tih{tWt@C7exBLF$G z2)dT&gT%PJLD8}5_*8)UFihODc#e_Ma-+O7`E?R(oC}yRVCURJvo265GYa7uQ~IM0qz8i(4_7>taRX@t7|QeIlmK zvPL5|BiMIhpSerEa*z>r=B-Z$!NK$qKNXikqt_!k6TpsV z4z9D~=p6vQQj6{#`?o!bV;^~Z2)TPs5*mE_V-ERZjw71XdV@D}A$RleLmPe$R*~G! z52FH^uZI!KEs8@0*tXfQr4eTXuL~30^Y&aDFg6`8gtJ8?)sBAra%Q|A*Ywb+pM@S? zatRVT$T`|SQ}ByXOlbSamqs;9Bh9%n)py1_Rxo!xEIZ!4^fK7r+{|TuX&n9Tqt*9? zbq$qe=Xv+JDX81m!ku!>rj=%tvoG4Sev5ak!Lgdu?p!~{d!;~HbwNKCrO4ag_29RW zgI{kpXUz*8?9aA4Z=`*BLvH}AR-3ytI(sH{e5OU-XjL57s?WxrAvESt&a9T+929fa z@2C$GC!HTD@{=6al!M-%ABpvs_O<^5UzPKmExc*0rwdWL#}sZ{|jP&sFBOH!hxg2f(ao zZuD`~d}KdkZb>s^Xo8umN!Mr|=m6-fhWe&hT&$)qyB0=O6h!c8vfkDv4>25I-<%UD8nJfNeCJhhzqL(UAjNi8WvFU)b43i+ZMZR$ZEnxp!O z6PE!jjXc;92|+3xtcxI*R{J^)mvaEHq958A&3xkN=k9ws%75mpeIIaGh@(2?!H$=Y1D18k%U^@qOTL+s;&vCHE`8L7NiWWWI z09@BLEE>NIMzfz547ss%JsZW#z7VtH&I1!JUgHd0@EwpCd9!8itiL4(GJhqu#%sz6`c9#l}bV5k6FkIl2wozG^@_B>Ox zp48Z|xd6qomxa`*X)ZEHUw22o@E8ey3#`n%|EcPq2a}M|Mi?Q2ZKN2yc84}$ z)M)`uUuRY3P5P~>*5VRSs+s>--1yXWR;-cWEZAZl@OW5N>hB(2KJ7`q&Ro(#j&0m`T4lpqnJYhnrKm`J2P5bK}8vXrO~M_yrsj# zy4RJlz!+vI;evQri(ZdwjF`r%;(=!UR1ZIVbtR*fe2C-GJCf;++IZ4ZM+cI|mjLW{ z_yz&hsMj6_Ec{?wAL@|{br#PLRf@e0?*64guu*WmCAGbVgyLAmOIVi8n96B_F0RplmSld-_=s^G)ilWPBeB`|IUca%??|gxZ@W%q zPp6ro*fy`-&mQCKle-3cPKQ3S`PIpRDoGa_DLzZ%-#k>tWKffoMRLryV+aBnC55kS zJeMHJ#&e@}4JG1X@95DpKE6k65fH$zO@u@t4L8TAtMq!aT{@oI;z$i=Cde4d$~@^f zzEJBCgX6Hlumy{BpxA1!5YxZ{8->I&2Czol1BB^B&3ZUV4n_axT zn6?xpj(|vx%v#0}!>wMBAWz=mGAFd=k?TwYSbQ5=Z#YX5WlIsq#0eUIGeAS)@t6|^ zxAqV#&^xz>7~3tTdn>@E>h|?@#hTu4cWk=(pzTMg2xq!OaJivD`?citco(6PrF*Sc zt*_U9&n5Al5jwu+b1 zTEUK6-cAdDrn^r2#j2n3&*So;&1L7&*K6p#t$yxR*aUAq324(>T^z4f(J-KSp{k~e zJ%gw4Tk$u9B9+z}8jH*cIUVwv&T3h@_VJE2`!kFcd3rjJo0yEJVoc|dP6myqo+q8L zp~+20eKB5n^aeX}bgEY>{d?Yw$W2B<+wEdW&D3W7&w9IT z_ROOut~d3#^3A2^cdQ^yM{eYi@^%6<29vRx?vgXd!ZI8u@heA1Yc9AOn{^1E{l&Vl z0pnpPvTk;T35@md6Mby>A}YRRtZ(ft?==TAGO=+~bhg;~;ZJk$%cn0tZsH{tY;(Y0 zIiW`+quGHToUr#^tsI?vSyKkyE7Z{SV5~znkMx)O{X6~qUO&psNnJjP!O#aH%hTyx z9VyC@yS(_2aqDSnFa+o&m&R&}A!!)ON*ZiUtgH;JEwRBYH9m88IyUQP>yD%|7YPPz zg0hbUmmIDjuBb;%ZHpopGCRaK#_)9(pfO%moEmiLY$(+CENquw5lI8YX9-t;679g1PS=>S%A z5*KlB-=KzoZ$TSA+}rtqRe*JRYY(;pY)o2dM(ga|r{S3{jb#hQ8Ru-*@v_g28J6oS z{;FHLY>vJ9tpARWT6c4J5^k};C|>o?^z3Klg3sjlbeo0puKsXUe*gTMRW7mdv25^E z?VX!`E~2s-sg#y~*J9xjKiJ9Rs9B$jF8Mhc#y!xgytd)RPkE)tXpGM>GbSJCe#Bz% zhPOS3=4H$v*TnS2v!l5Vx*kdoW*V%_BI$V7t1Yzs%Bz2-l|y4g4*jy<8FOuSo4sG3 z_22QXL%%q^jF%3*iaiWgUW|)Zg(GE_&+U+h@i-%;PyZvAh(0_=0< z@&`ZonaiL0_zzxw>+RPrj~{6*c+ad!xwAHzGS4B`L8g!S)zO78MjPM<`_t+_DD zL7D+;WZck}Wnl(+xEKt>b}fcCN6LYFc|vl0^EyzlxU<}$TTwy~w!8>%1eu)eR6J2` z?zaa9^j59F!8W+?Q=Wk3?FsreGO?e*@ucI#KWr+4ratk#$3M_QHq78j-!;T8S1}ZG9Y8`t7|}{ccVJC>(M>$(}?OkLbdR_tz%gubGgWvht zgc6PXU|LTn2fT7I_6X7IN{p^AR9BqiHO`R3z|L~oeZ>{V(YsRvn;3lLY>NU7v-(gy z+|>y+tG2V*#X5LmV}g5|x)RhErPy{_Z6+47#K{0|EG@sC9#jQ-xAmZcx&=c8ymOvX zif*hk-OY*vZNKufUB{K*N71WpX|e046pYI5!-HR(GreYW4f|}@@iqJMs&l4|w|+{o zdqKZ{o?ijDmKPU%y(HKHwBs{v1W;b9xQNnrT7GFXaxNYt`n0oOGq+Q-#1;MFa?;8- z6L;(DOsm(J88_B5t!`;!wT+_L$MDYM@HnTfcht$^oJqB=(Pnw3yY7zHe&uJojw`>1 z`W3UfrNxHkm^aolry;ruTb$nLbbL*@K9JRK^+nBMT-h}Y#L}@@bv@YZ>Sw3Hnl~?3 zTv+Bmo>aWja~w6^sJp%CJJ}7j+^`I+nvQ)l5|XgCQz7YUfj@rX@#X)0<2Nt=>F579 zgYdTjZteM2KtBL<`)5A>*~|C5?<1G5zVn73&%1SeW8C%0MTc+C1M@YGcFpOU$g(x~ zTnIShDwDl~*=!nsn>EQmdhZ@@m6qQGo$*9yG`P^R;vrG(;M1NH}i-66i6&yopa!l^%TWQdap-*b$K|9lM zb8bw<-(_bzYNSZE{93nCD|kKk^`P|Vr}JSfTwZhUP?*!qYv#7K9@IMPL>$NYi{7jw zYZ4Vs!{>?xwuM8s<{+@KaVAX%s0pMv^K*A|mt?H1X$bDlo9bC(bMKKi@vN8v22 zj@PDCr7+E_Cj0d!qL|jf)&beMdvi!9zFl)nj3xfiNnDr;$7~b^Ho19NWkl@@H((gM z&3H$inL~-{bKs(EQ=kmjyv7<6Z{}oBa=?n~0rb`$Vg-(`Y7a5O-?1@y_dLk#+34Nu zrQ?dBczyNRC@DP5TpIhCu6VEHqKny^{0lmXxUQYAZD8#W%4EMc|8DEfP@n6i@iw}v zoNe&2`$3oS-o}UTCJSjb&a=_+U7IK80(fJuqmKO=Q09p?_Od%Z=dk37y>Y^S#7_rZ zX}Fb?qVa6)_W(_3O`N#sBocm+t*x&l`YiD&)Zpp=8aW z)VUy^A0=!qnUi_ba$lHUFvs~LmKo1(C$*W&;7@$VJ#IRtY$U^&!}efD!BkyBC=Uz! zwxf41k2QWxe+2L^eg1#Fyruomp6nZM=-%3UuK+dW-va*L_kaAtn*f=oF)wq%nLK^_ zr_o(6%%xg&Jdc$PHVRg6lfu5Jy}tGK4y;NE9(uO z@dw6q##l%`ff%{t8ax#8a0UbAp<~QrthV>HWvI&7UpOe90XJU*ryceI}7#b;d zhL9iP+>hzQ_FJ6zumj@vx6vG%A3W9b!;8IdI$*?SbmK4Cjk6Vyp8Rd&`~gTl(j0uW zR~t2yz8B)8XjHbezp-9%>f;+jsTDP6t#NeSCI7y$!5bd(#W8b8?L1%No$Dor>&~g` zZBlqRpE}kuH#mu-b6mBC;4mh0z>M&WELnbPp3mu6pBPzRGgclzM?O;U^3=)el-New zZQ@Di4JV1fLz=kZC0Be3XvErWGslie^0|(NDmH4#VPFh=V;cYsWID*hj)oz8kHhv} znENj_SR0F2N8e*(nqyq&)p(Fu9!6_65F(p8_$DrU^2h&OlMzSvkfm}GHS}}j&5vOm zh}-!ARp9mpzyq3$Z%!!L z(>g9K??6Y)jT3s#r84Yj#e<&5&8adg=tM5fCsux6c_38lz0x9sQ*XQH_lDm&3wrV2 z8)mG*+G8J>CzfBt4|#3A54!inHg(8kuh2dE8~p4}jLG>1I`t=Nyp9V6^USO4NRtWL zclA@RrH{@B^v3{y?km4~`Ahm$02&XgxAuH1(0>c~&wk?XzkJ{OKjr_taJU{E)(``v z8&lnV?#BJ|79`r4@6Bp%p>?w>z4Nj-U?V&#%t(e_{qPZb+FED2c8_N<3kYNcvvr-v zo=bI{^LW^Rmz#MbtrPtDf)f9UZ*x>_v-FT?{gIc*%pdtMBZu%&03a0J)ZcN39jw;F z;teyl8>99yvY+HAC$r}CX{Eupo?|4=J{%tPY-Y>Yc3w&=KT}@Ij)y8L#y9Uc`a8#S zZA=c$qjc9}j(t!g9i7)*XYcR&*4^eaddgpG(GRCEx)7{)oSVqd8ONrs?noA@!*MdZ ziLraA@Tj+S&O0v6gZCbA;_;_`cD4+>_7A*~(_GZ4Td|38s?+fp9W-{~$iaq^qtMq4 z)O>>kKw9USg2bRh@YaE@PvWLEA?R(zR9(*L2PnjJZTZD=Wpmtrzl+q4mG}GyfXEFl_v#gFUf2 z-s$dV=eK{&-HUv*Y-Uu=uaC~x_Jdiyl^%qrL3I}0U#w5cPaIy4MJ9#5VtxM$YmK1o zUjaC$r01db!tl#xBi1C~eAjv77D4c`w7TgO#69 zzjgTwpZgn^zw^eIFE8oW0&c$wuod<>beUUz!RiB#U%vdzr+)PEvVJXuzrOFL>0ym! zon;lwRXUbO*C1lo^CgaS=(O?_FqQk|E|LF3d zSJSAl;zmO-gn8HspLk|WC*AOCNOa>?udGeqIKx37=IrjI>zZPBgs1);tnpCV$EgWM)cbDEK{BZ1iDx)z8g-&^gA>b!thI5i5M!6SJ|iB8%SZ6%7T4ik z5uep><8NIBI@8~}2yULg2^Hw3KGP?;I_q})B%9w3S>u4V|1`hVJJYAxJ)1m#1AvPo zjY}jiich}M@w#-rR-VUa@4*ds$G`TlUfX8O_(>7%c;`OjYd#upbAh&1)^TaETcc9s zl~wl|B?sI<_k3Q`uK?`hYqrnM+mV`7>61{ZbEZ$S`R$PH@o|>Wyx|Y#08C5+Yl=?Tz*x2cZr$lcX7A{)o`2}^ zE0@3e<^S*UpN&re+_&zh-L3O=SD-ulCqD8AEk7Gqz;Ea!cAP)Vv}9tnsnsYT%V1ukK;$bJ~DW-P7{6b0b9+s=x%KCN+3&*9{KFedKMt$8|R}{wKkSZhDz1o z;j4`?32p5l7yr)qnl2Dv)sYJ)UR@z;9OZ#C`uUXu1^|U=tm+PYOlBK#AL4TiLj#*z zdyo~ly#eqbr{#K3eJF$O{p;G#hr9dP{XP%$9UHukp9l8+7|kMAw6D)+Zm8bW3o<|X z(@UH$lZ}0imiPUd-s^pNeyzE3aollw-Cc-Wn(rFzoV}0zJ}wS<7pML6xVoi#yzTF~ zRBxxnhPU_Ec-Ky!cpLK`|6U6|jvbwydlw6zH0a*%>*B$pP7lmyAM{0c_rpgQJH8L; z-dL{0GttIkx|;nJyZd~X-`G1YH9i5#U-Cfn=(WG6SerZao_N4P)cd639@MwN)jG+3 zQ<;Nny4;}DM=5thUH8O|&b3;YCr58iftYzRXnwvq@@AF}ep;V1U*qOF=@v($g6{oI<=MN`=5!ol9I3kKZ=d zB}WoW>s;0GtY;O*n_u7)o?COWufbFhgKa41XAy#+6DO^?H;x%69W~6*dAImbxZpsn z5Zz+f5VP72t5&F8e7(10qQND0N?@8_Kv*Rb(Jgcl&XZo!6 zjr~lYY>&5mp;zBewpmj2U)LW2+uM#;elMb2+sAcu(p{&0=pIY^rPaGTt|S-R^9A=j zzUHTxtz*YUZZ6lxNzw;~qV^wbSIai?b^anohsmq`I*biR5^yb%G z_f7-u*mFME51JQGKMGZHKS)_yV(`tH^B%%zNVYXoOs8b52$xE1j5TpKzoUzm|40@e zE|4F`WxRB4zvVFHsQSU*5s1Cz8CKv%-h(B*^<^G+cN`-J-<DW6o9vwt6R{Pe{>PgZ!*RmHu6aORw;`IcO-J&lwQ4|T44zw!o!%DX^o=t5 zK_6reY=`anh#98eq=Q&#WV8Cy-#MN!b}q80-t35RsQ1i&)Wr3TRUbNA6QV#uJ5%#K zcgNaJMQp(Kn{DKhPdFqmt9miwwN` z^pNe*wI3VK*ITjcr&{%M?Sj4MPpER(C;O*X;0j*Cn}UTsS*!M)xKYogL2ru% z5GLR4g=m{NFGC-IxAv*OcE|zqRLEf&P7kKlO>vUVi_ppUS@lWZnAvO1-y5!n`u{ z&1l2g*Mj@esn~Sr(D%)*)_z$~<3?!t6v7+L*8|x-@%cF{XX}j1p7mH^#-z3fN*!ot8ZPIFd+INZ zr}cv1`50*QXPfafKh1}DsjcXqN5h=PS30$2epUZm`&MmR6N90q{^};!YfgT{WcLOwhfL_`dNl{klkf zL%}WG{T=~%*rSDViKrdi;?dt*Z0?W!EZ=RPwO`-R_QAa$`@UST;DY&8y#bKl7%fI( znHNgA*{;3o0(#=t57yvz?2EF-wDLSSoth1_$4SLQ@UFGp&n_qSb1oh4dV8NZo5Pvz zdV4?h45tyE>8`)`Kd3(vhHmQfB&*I5bnkCy^Od;rP4?7oZT1-8CA#>m&KWHH=1>}~ z;+<{I<6_@O&-%yl{$jo)^{tuYCkxJ{`Jh!_%ObI_`C#m_df^mr=wrSusM;>v;zM1IPK1<9!Eu*N*%2kNg=wA2r^pk)3-6A{krB^Y zQ)tMR;GGm{1Z$xOCOJ#5p$W@yjL8dXUKJKX9*)sYvmFMDCHMK%St1yED!%vOSfhzS zS1ON*n>PGukQ#_XAO48T8s-HTDGbpPo(aHOa+F|SqXf94H5X6>Pijs$_Kpaf5P~7!Z1TPG$CkL>w>sn{sa3l|+DJ zUjFiL{y&$$___bty0?D|=mI|1Q~xdCPkrL=yL|r#zf-?{_2%WVJa}&AoZ!-vGvov)*h527#Ik@hXje4I2VqU3na>?EiAYG(hKGU&-0A!s=w3W%UI;d zcu+Z9Sc#E1d-!2bW9aHHzth+t%dYlkntBVq5Y21m*|>?Z{ZOYzMQtpe;}k6x)Od@= zW}FF+^~M4A)<-huM!4(ifZ)NLw_z`3w?u;Ph0O|JEq861V&I@hF2+f0kIa|%a~|c( zQ|scL2p^sfnYhio%z+y}&ac6WyT_T>8@DV7V6;7ExU5NU6FGHQ){74U_a_R(i$CcP z97t<_j;Y=(7wB=EQS!zqUPTl~Is?}Oc+{IWG)5lkOkGL)cRkPy5{7tATfN{cf6NR1 zlhKYjOmF0YRA66o4W^zMA-Ul>I18<@CaT6(i_liQ++&o9e#s)m!xt4oqS@_1%{ac6m-h0}J>xi@qczbAU~9

    HdoRjED6e)%Mn{ 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 { - if (!appInfo.chatgptAppPath && !appInfo.opencodeAppPath) { + if (!appInfo.chatgptAppPath && !appInfo.opencodeAppPath && !appInfo.workbuddyAppPath) { return; } - setProfileDraft((current) => profileDraftWithDetectedAppPath(current, appInfo.chatgptAppPath, appInfo.opencodeAppPath)); - setProfileEditDraft((current) => profileDraftWithDetectedAppPath(current, appInfo.chatgptAppPath, appInfo.opencodeAppPath)); - }, [appInfo.chatgptAppPath, appInfo.opencodeAppPath]); + setProfileDraft((current) => profileDraftWithDetectedAppPath(current, appInfo.chatgptAppPath, appInfo.opencodeAppPath, appInfo.workbuddyAppPath)); + setProfileEditDraft((current) => profileDraftWithDetectedAppPath(current, appInfo.chatgptAppPath, appInfo.opencodeAppPath, appInfo.workbuddyAppPath)); + }, [appInfo.chatgptAppPath, appInfo.opencodeAppPath, appInfo.workbuddyAppPath]); useEffect(() => { if (!isProfileAgentAvailable(profileAgentTab)) { @@ -807,10 +807,11 @@ function App() { setProfileDraft(profileDraftWithDetectedAppPath( createProfileDraftFromProfile(profile, draftConfig.botConfigs), appInfo.chatgptAppPath, - appInfo.opencodeAppPath + appInfo.opencodeAppPath, + appInfo.workbuddyAppPath )); setProfileActionError(""); - }, [activeView, onboardingStep, onboardingProfileConfirmed, configLoaded, draftConfig.profile.profiles, draftConfig.botConfigs, profileDraft.agent, appInfo.chatgptAppPath, appInfo.opencodeAppPath]); + }, [activeView, onboardingStep, onboardingProfileConfirmed, configLoaded, draftConfig.profile.profiles, draftConfig.botConfigs, profileDraft.agent, appInfo.chatgptAppPath, appInfo.opencodeAppPath, appInfo.workbuddyAppPath]); useEffect(() => { if (activeView !== "onboarding" || !configLoaded || !onboardingStatusLoaded || !providerPresetsLoaded || providerAddOpen) { @@ -2523,7 +2524,7 @@ function App() { function openAddProfileDialog(agent: ProfileConfig["agent"] = profileAgentTab) { const resolvedAgent = isProfileAgentAvailable(agent) ? agent : defaultAvailableProfileAgent; setProfileAgentTab(resolvedAgent); - setProfileDraft(profileDraftWithDetectedAppPath(createProfileDraft(resolvedAgent), appInfo.chatgptAppPath, appInfo.opencodeAppPath)); + setProfileDraft(profileDraftWithDetectedAppPath(createProfileDraft(resolvedAgent), appInfo.chatgptAppPath, appInfo.opencodeAppPath, appInfo.workbuddyAppPath)); setProfileActionError(""); setProfileAddOpen(true); } @@ -2537,7 +2538,8 @@ function App() { setProfileEditDraft(profileDraftWithDetectedAppPath( createProfileDraftFromProfile(profile, draftConfig.botConfigs), appInfo.chatgptAppPath, - appInfo.opencodeAppPath + appInfo.opencodeAppPath, + appInfo.workbuddyAppPath )); setProfileActionError(""); } @@ -2754,7 +2756,7 @@ function App() { return profileDraftWithDetectedAppPath({ ...createProfileDraft(patch.agent, name), envRows: profileEnvRowsForAgent(patch.agent, current.envRows) - }, appInfo.chatgptAppPath, appInfo.opencodeAppPath); + }, appInfo.chatgptAppPath, appInfo.opencodeAppPath, appInfo.workbuddyAppPath); } return next; }); @@ -2769,7 +2771,7 @@ function App() { return profileDraftWithDetectedAppPath({ ...createProfileDraft(patch.agent, name), envRows: profileEnvRowsForAgent(patch.agent, current.envRows) - }, appInfo.chatgptAppPath, appInfo.opencodeAppPath); + }, appInfo.chatgptAppPath, appInfo.opencodeAppPath, appInfo.workbuddyAppPath); } return next; }); diff --git a/packages/ui/src/pages/home/components/profiles.tsx b/packages/ui/src/pages/home/components/profiles.tsx index 36eb03f5..c89c9b41 100644 --- a/packages/ui/src/pages/home/components/profiles.tsx +++ b/packages/ui/src/pages/home/components/profiles.tsx @@ -792,7 +792,7 @@ export function AddProfileForm({ scope: "ccr", surface: "app" } - : agent === "zcode" + : agent === "workbuddy" || agent === "zcode" ? { agent, surface: "app" } : { agent })} value={draft.agent} @@ -829,7 +829,7 @@ export function AddProfileForm({ }); }} options={translateOptions( - draft.agent === "zcode" || draft.agent === "claude-design" + draft.agent === "workbuddy" || draft.agent === "zcode" || draft.agent === "claude-design" ? profileSurfaceOptions.filter((option) => option.value === "app") : draft.agent === "grok" || draft.agent === "kimi" || draft.agent === "pi" || draft.agent === "kilo" ? profileSurfaceOptions.filter((option) => option.value === "cli") @@ -940,7 +940,7 @@ export function AddProfileForm({ ) : draft.agent === "claude-design" ? null : ( <> - + onChange({ providerName: event.target.value })} /> {validation.providerName ? {t(validation.providerName)} : null} - {draft.agent !== "zcode" && draft.agent !== "opencode" && draft.agent !== "kilo" ? ( + {draft.agent !== "zcode" && draft.agent !== "opencode" && draft.agent !== "kilo" && draft.agent !== "workbuddy" ? (

    {t("Show all sessions")} onChange({ showAllSessions })} /> @@ -1315,7 +1315,7 @@ function profileNumberDraftValid(value: string, min: number, max: number): boole return Number.isFinite(numeric) && numeric >= min && numeric <= max; } -function profileAppPathLabel(agent: ProfileConfig["agent"]): "CLAUDE_APP_PATH" | "CHATGPT_APP_PATH" | "OPENCODE_APP_PATH" | undefined { +function profileAppPathLabel(agent: ProfileConfig["agent"]): "CLAUDE_APP_PATH" | "CHATGPT_APP_PATH" | "OPENCODE_APP_PATH" | "WORKBUDDY_APP_PATH" | undefined { if (agent === "claude-code") { return "CLAUDE_APP_PATH"; } @@ -1325,6 +1325,9 @@ function profileAppPathLabel(agent: ProfileConfig["agent"]): "CLAUDE_APP_PATH" | if (agent === "opencode") { return "OPENCODE_APP_PATH"; } + if (agent === "workbuddy") { + return "WORKBUDDY_APP_PATH"; + } return undefined; } diff --git a/packages/ui/src/pages/home/shared/i18n.tsx b/packages/ui/src/pages/home/shared/i18n.tsx index 9992c377..ab6f115f 100644 --- a/packages/ui/src/pages/home/shared/i18n.tsx +++ b/packages/ui/src/pages/home/shared/i18n.tsx @@ -325,6 +325,8 @@ export const appCopy: Record = { "Kimi model is required.": "Kimi model is required.", "Pi": "Pi", "Pi model": "Pi model", + "Workbuddy": "Workbuddy", + "Workbuddy model": "Workbuddy model", "OpenCode CLI credential was found, but no usable API key was detected.": "OpenCode CLI credential was found, but no usable API key was detected.", "OpenCode CLI login detected. Click Import to add it as a gateway provider.": "OpenCode CLI login detected. Click Import to add it as a gateway provider.", "OpenCode CLI public models detected. No login is required.": "OpenCode CLI public models detected. No login is required.", @@ -602,6 +604,7 @@ export const appCopy: Record = { "CLAUDE_APP_PATH": "CLAUDE_APP_PATH", "CHATGPT_APP_PATH": "CHATGPT_APP_PATH", "OPENCODE_APP_PATH": "OPENCODE_APP_PATH", + "WORKBUDDY_APP_PATH": "WORKBUDDY_APP_PATH", "Drop the app here or paste the executable path": "Drop the app here or paste the executable path", "Tool name": "Tool name", "Third-party tool environment": "Third-party tool environment", @@ -935,6 +938,8 @@ export const appCopy: Record = { "Allowed models": "允许模型", "Pi": "Pi", "Pi model": "Pi 模型", + "Workbuddy": "Workbuddy", + "Workbuddy model": "Workbuddy 模型", "OpenCode": "OpenCode", "OpenCode model": "OpenCode 模型", "CLI only": "仅 CLI", @@ -1741,6 +1746,7 @@ export const appCopy: Record = { "CLAUDE_APP_PATH": "CLAUDE_APP_PATH", "CHATGPT_APP_PATH": "CHATGPT_APP_PATH", "OPENCODE_APP_PATH": "OPENCODE_APP_PATH", + "WORKBUDDY_APP_PATH": "WORKBUDDY_APP_PATH", "Drop the app here or paste the executable path": "拖动应用到这里,或粘贴可执行文件路径", "Third-party tool environment": "三方工具环境变量", "Environment variable keys are required when values are set.": "设置变量值时必须填写变量名。", diff --git a/packages/ui/src/pages/home/shared/options.ts b/packages/ui/src/pages/home/shared/options.ts index 8190b445..7fbebf6c 100644 --- a/packages/ui/src/pages/home/shared/options.ts +++ b/packages/ui/src/pages/home/shared/options.ts @@ -121,6 +121,7 @@ export const agentFilterOptions: Array<{ label: string; value: AgentFilterValue { label: "Kilo CLI", value: "kilo" }, { label: "OpenCode", value: "opencode" }, { label: "Pi", value: "pi" }, + { label: "Workbuddy", value: "workbuddy" }, { label: "ZCode", value: "zcode" }, { label: "Claude Design", value: "claude-design" }, { label: "Unknown", value: "unknown" } @@ -136,6 +137,7 @@ export const profileAgentOptions: ProfileAgentOption[] = [ { label: "Kilo CLI", value: "kilo" }, { label: "OpenCode", value: "opencode" }, { label: "Pi", value: "pi" }, + { label: "Workbuddy", value: "workbuddy" }, { label: "ZCode", value: "zcode" }, { label: "Claude Design", value: "claude-design" } ]; diff --git a/packages/ui/src/pages/home/shared/profiles.ts b/packages/ui/src/pages/home/shared/profiles.ts index 6a67894b..6ce9965c 100644 --- a/packages/ui/src/pages/home/shared/profiles.ts +++ b/packages/ui/src/pages/home/shared/profiles.ts @@ -4,6 +4,7 @@ import grokLogoUrl from "@/assets/agent-logos/grok.ico"; import kiloLogoUrl from "@/assets/agent-logos/kilo.svg"; import openCodeLogoUrl from "@/assets/agent-logos/opencode.ico"; import piLogoUrl from "@/assets/agent-logos/pi.svg"; +import workbuddyLogoUrl from "@/assets/agent-logos/workbuddy.png"; import zcodeLogoUrl from "@/assets/agent-logos/zcode.png"; import moonshotProviderIconUrl from "@/assets/provider-icons/moonshot.ico"; import { @@ -459,7 +460,7 @@ function profileRoutingConfigFromDraft(draft: AddProfileDraft): ProfileRoutingCo } export function createProfileDraft(agent: ProfileConfig["agent"] = "claude-code", name?: string): AddProfileDraft { - const surface = agent === "zcode" || agent === "claude-design" ? "app" : "cli"; + const surface = agent === "workbuddy" || agent === "zcode" || agent === "claude-design" ? "app" : "cli"; return { agent, appPath: "", @@ -488,12 +489,15 @@ export function createProfileDraft(agent: ProfileConfig["agent"] = "claude-code" export function profileDraftWithDetectedAppPath( draft: AddProfileDraft, chatgptAppPath?: string, - opencodeAppPath?: string + opencodeAppPath?: string, + workbuddyAppPath?: string ): AddProfileDraft { const detectedPath = (draft.agent === "codex" ? chatgptAppPath : draft.agent === "opencode" ? opencodeAppPath + : draft.agent === "workbuddy" + ? workbuddyAppPath : "")?.trim() || ""; if (draft.appPath.trim() || !detectedPath) { return draft; @@ -550,7 +554,7 @@ export function createProfileDraftFromProfile(profile: ProfileConfig, botConfigs surface: "app" }; } - const surface = profile.agent === "zcode" ? "app" : normalizeProfileSurfaceForForm(profile.surface); + const surface = profile.agent === "workbuddy" || profile.agent === "zcode" ? "app" : normalizeProfileSurfaceForForm(profile.surface); return { ...createProfileDraft(profile.agent, profile.name), ...createProfileRoutingDraft(profile.routing), @@ -565,7 +569,7 @@ export function createProfileDraftFromProfile(profile: ProfileConfig, botConfigs providerId: profile.providerId ?? "claude-code-router", providerName: profile.providerName ?? "Claude Code Router", scope: normalizeProfileFormScope(profile.scope), - showAllSessions: profile.agent === "zcode" || profile.agent === "opencode" || profile.agent === "kilo" ? false : Boolean(profile.showAllSessions), + showAllSessions: profile.agent === "zcode" || profile.agent === "opencode" || profile.agent === "kilo" || profile.agent === "workbuddy" ? false : Boolean(profile.showAllSessions), surface }; } @@ -663,7 +667,7 @@ export function profileConfigFromDraft( ...(routing ? { routing } : {}), scope: draft.scope, settingsFile: draft.settingsFile, - showAllSessions: draft.agent === "zcode" || draft.agent === "opencode" || draft.agent === "kilo" || draft.agent === "claude-design" ? false : draft.showAllSessions, + showAllSessions: draft.agent === "zcode" || draft.agent === "opencode" || draft.agent === "kilo" || draft.agent === "workbuddy" || draft.agent === "claude-design" ? false : draft.showAllSessions, sonnetModel: draft.sonnetModel, smallFastModel: draft.haikuModel || draft.smallFastModel, surface: draft.surface @@ -1267,9 +1271,9 @@ export function profileSummaryItems( } return [ - { label: t(profile.agent === "kilo" ? "Kilo model" : "Model"), value: modelValue }, + { label: t(profile.agent === "kilo" ? "Kilo model" : profile.agent === "workbuddy" ? "Workbuddy model" : "Model"), value: modelValue }, { label: t("Provider ID"), value: profile.providerId ?? "claude-code-router" }, - ...(profile.agent === "zcode" || profile.agent === "opencode" || profile.agent === "kilo" || !profile.showAllSessions ? [] : [{ label: t("Show all sessions"), value: t("Enabled") }]), + ...(profile.agent === "zcode" || profile.agent === "opencode" || profile.agent === "kilo" || profile.agent === "workbuddy" || !profile.showAllSessions ? [] : [{ label: t("Show all sessions"), value: t("Enabled") }]), ...managedCompactItems, ...routingSummaryItems, ...appPathSummaryItems, @@ -1363,7 +1367,7 @@ export function normalizeProfileItem(profile: ProfileConfig, index: number): Pro providerName: profile.providerName?.trim() || "Claude Code Router", ...(routing ? { routing } : {}), scope, - showAllSessions: agent === "zcode" || agent === "opencode" || agent === "kilo" ? false : Boolean(profile.showAllSessions), + showAllSessions: agent === "zcode" || agent === "opencode" || agent === "kilo" || agent === "workbuddy" ? false : Boolean(profile.showAllSessions), surface }; } @@ -1434,6 +1438,8 @@ export function normalizeUnknownProfileItem(value: Record, inde ? "kilo" : rawAgent === "pi" || rawAgent === "pi-agent" || rawAgent === "pi agent" || rawAgent === "pi-coding-agent" || rawAgent === "pi coding agent" ? "pi" + : rawAgent === "workbuddy" || rawAgent === "work-buddy" || rawAgent === "work buddy" || rawAgent === "workbuddy-agent" || rawAgent === "workbuddy agent" + ? "workbuddy" : rawAgent === "zcode" || rawAgent === "z-code" || rawAgent === "z code" ? "zcode" : rawAgent === "claude-design" || rawAgent === "claude design" || rawAgent === "design" @@ -1530,6 +1536,9 @@ function readUnknownProfileAppPath(value: Record, agent: Profil if (agent === "opencode") { return readUnknownString(value, "openCodeAppPath", "opencodeAppPath", "opencode_app_path"); } + if (agent === "workbuddy") { + return readUnknownString(value, "workbuddyAppPath", "workbuddy_app_path", "workBuddyAppPath", "work_buddy_app_path"); + } return undefined; } @@ -1577,6 +1586,9 @@ export function profileAgentLabel(agent: ProfileConfig["agent"]): string { if (agent === "pi") { return "Pi"; } + if (agent === "workbuddy") { + return "Workbuddy"; + } if (agent === "opencode") { return "OpenCode"; } @@ -1607,7 +1619,7 @@ export function profileSurfaceLabel(surface: ProfileSurface): string { } export function profileOpenSurfaces(profile: ProfileConfig): ProfileOpenSurface[] { - if (profile.agent === "zcode" || profile.agent === "claude-design") { + if (profile.agent === "workbuddy" || profile.agent === "zcode" || profile.agent === "claude-design") { return ["app"]; } if (profile.agent === "grok" || profile.agent === "kimi" || profile.agent === "pi" || profile.agent === "kilo") { @@ -1623,7 +1635,7 @@ export function profileOpenSurfaces(profile: ProfileConfig): ProfileOpenSurface[ return ["cli", "app"]; } -export function profileOpenCommandFallback(profile: ProfileConfig, surface: ProfileOpenSurface = profile.agent === "zcode" || profile.agent === "claude-design" ? "app" : "cli"): string { +export function profileOpenCommandFallback(profile: ProfileConfig, surface: ProfileOpenSurface = profile.agent === "workbuddy" || profile.agent === "zcode" || profile.agent === "claude-design" ? "app" : "cli"): string { const profileRef = profile.name.trim() || profile.id; return ["ccr", shellCommandQuote(profileRef), ...(surface === "app" ? ["app"] : [])].join(" "); } @@ -1659,19 +1671,22 @@ export function profileAgentLogoUrl(agent: ProfileConfig["agent"]): string { if (agent === "kilo") { return kiloLogoUrl; } + if (agent === "workbuddy") { + return workbuddyLogoUrl; + } return codexLogoUrl; } -function normalizeCodexCompatibleAgent(agent: ProfileConfig["agent"]): "codex" | "kilo" | "opencode" | "zcode" { - return agent === "zcode" ? "zcode" : agent === "opencode" ? "opencode" : agent === "kilo" ? "kilo" : "codex"; +function normalizeCodexCompatibleAgent(agent: ProfileConfig["agent"]): "codex" | "kilo" | "opencode" | "workbuddy" | "zcode" { + return agent === "zcode" ? "zcode" : agent === "opencode" ? "opencode" : agent === "kilo" ? "kilo" : agent === "workbuddy" ? "workbuddy" : "codex"; } function normalizeProfileAgent(agent: ProfileConfig["agent"]): ProfileConfig["agent"] { - return agent === "claude-design" ? "claude-design" : agent === "zcode" ? "zcode" : agent === "opencode" ? "opencode" : agent === "kilo" ? "kilo" : agent === "pi" ? "pi" : agent === "grok" ? "grok" : agent === "kimi" ? "kimi" : agent === "codex" ? "codex" : "claude-code"; + return agent === "claude-design" ? "claude-design" : agent === "zcode" ? "zcode" : agent === "workbuddy" ? "workbuddy" : agent === "opencode" ? "opencode" : agent === "kilo" ? "kilo" : agent === "pi" ? "pi" : agent === "grok" ? "grok" : agent === "kimi" ? "kimi" : agent === "codex" ? "codex" : "claude-code"; } function normalizeProfileSurfaceForAgent(agent: ProfileConfig["agent"], surface: unknown): ProfileSurface { - return agent === "zcode" || agent === "claude-design" ? "app" : agent === "grok" || agent === "kimi" || agent === "pi" || agent === "kilo" ? "cli" : normalizeProfileSurface(surface); + return agent === "workbuddy" || agent === "zcode" || agent === "claude-design" ? "app" : agent === "grok" || agent === "kimi" || agent === "pi" || agent === "kilo" ? "cli" : normalizeProfileSurface(surface); } function defaultCodexConfigFile(agent: ProfileConfig["agent"]): string { @@ -1680,7 +1695,9 @@ function defaultCodexConfigFile(agent: ProfileConfig["agent"]): string { : agent === "opencode" ? "~/.config/opencode/opencode.jsonc" : agent === "kilo" - ? "~/.config/kilo/kilo.jsonc" + ? "~/.config/kilo/kilo.jsonc" + : agent === "workbuddy" + ? "~/.workbuddy/config.toml" : agent === "pi" ? "~/.pi/agent" : agent === "claude-design" diff --git a/packages/ui/src/pages/home/shared/usage.ts b/packages/ui/src/pages/home/shared/usage.ts index c40e493d..6bd466be 100644 --- a/packages/ui/src/pages/home/shared/usage.ts +++ b/packages/ui/src/pages/home/shared/usage.ts @@ -180,7 +180,7 @@ export function logSelectOptions(label: string, values: string[], selected: stri } export function normalizeAgentFilterValue(value: string): AgentFilterValue { - return value === "claude-code" || value === "codex" || value === "grok" || value === "kimi" || value === "kilo" || value === "opencode" || value === "pi" || value === "zcode" || value === "claude-design" || value === "unknown" ? value : "all"; + return value === "claude-code" || value === "codex" || value === "grok" || value === "kimi" || value === "kilo" || value === "opencode" || value === "pi" || value === "workbuddy" || value === "zcode" || value === "claude-design" || value === "unknown" ? value : "all"; } export function agentKindLabel(agent: AgentKind): string { @@ -208,6 +208,9 @@ export function agentKindLabel(agent: AgentKind): string { if (agent === "pi") { return "Pi"; } + if (agent === "workbuddy") { + return "Workbuddy"; + } if (agent === "zcode") { return "ZCode"; } diff --git a/packages/ui/test/component/profiles.test.tsx b/packages/ui/test/component/profiles.test.tsx index 0c9c7f35..04bd3003 100644 --- a/packages/ui/test/component/profiles.test.tsx +++ b/packages/ui/test/component/profiles.test.tsx @@ -5,7 +5,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import type { ProfileConfig } from "@ccr/core/contracts/app.ts"; import { AddProfileForm, DeleteProfileDialog, ProfileView } from "@ccr/ui/pages/home/components/profiles.tsx"; import { AppI18nContext, appCopy } from "@ccr/ui/pages/home/shared/i18n.tsx"; -import { createProfileDraft, createProfileDraftFromProfile, isProfileDraftSubmittable, normalizeUnknownProfileItem, profileConfigFromDraft, profileDraftWithDetectedAppPath, profileSummaryItems } from "@ccr/ui/pages/home/shared/profiles.ts"; +import { createProfileDraft, createProfileDraftFromProfile, isProfileDraftSubmittable, normalizeUnknownProfileItem, profileAgentLogoUrl, profileConfigFromDraft, profileDraftWithDetectedAppPath, profileSummaryItems } from "@ccr/ui/pages/home/shared/profiles.ts"; import { appConfigFixture } from "../fixtures/index.ts"; const profile: ProfileConfig = { @@ -440,6 +440,13 @@ test("detected OPENCODE_APP_PATH is used as the OpenCode profile default", () => assert.equal(profileDraftWithDetectedAppPath({ ...draft, appPath: "/custom/opencode" }, undefined, detectedPath).appPath, "/custom/opencode"); }); +test("detected WORKBUDDY_APP_PATH is used as the Workbuddy profile default", () => { + const detectedPath = "/Applications/WorkBuddy AI.app/Contents/MacOS/Electron"; + const draft = profileDraftWithDetectedAppPath(createProfileDraft("workbuddy"), undefined, undefined, detectedPath); + assert.equal(draft.appPath, detectedPath); + assert.equal(profileDraftWithDetectedAppPath({ ...draft, appPath: "/custom/workbuddy" }, undefined, undefined, detectedPath).appPath, "/custom/workbuddy"); +}); + test("Grok CLI profile defaults to a CCR-scoped CLI entry", () => { const draft = createProfileDraft("grok"); @@ -575,3 +582,46 @@ test("Kilo CLI profiles support local CLI configuration", () => { assert.equal(profile?.showAllSessions, false); assert.equal(profile?.surface, "cli"); }); + +test("Workbuddy profiles support local App configuration", () => { + const config = appConfigFixture(); + const draft = createProfileDraft("workbuddy"); + assert.equal(draft.name, "Workbuddy"); + assert.equal(draft.configFile, "~/.workbuddy/config.toml"); + assert.equal(draft.surface, "app"); + assert.equal(isProfileDraftSubmittable(draft), true); + + const html = renderToStaticMarkup( + undefined} + onCreateBot={() => undefined} + providers={config.Providers} + virtualModelProfiles={config.virtualModelProfiles} + /> + ); + assert.match(html, /App only/); + assert.doesNotMatch(html, /CLI only/); + assert.match(html, /WORKBUDDY_APP_PATH/); + + const profile = normalizeUnknownProfileItem({ + agent: "work-buddy", + enabled: true, + id: "workbuddy-work", + model: "Provider/model", + name: "Workbuddy Work", + providerId: "claude-code-router", + scope: "global", + showAllSessions: true, + surface: "app" + }, 0); + assert.equal(profile?.agent, "workbuddy"); + assert.equal(profile?.configFile, "~/.workbuddy/config.toml"); + assert.equal(profile?.showAllSessions, false); + assert.equal(profile?.surface, "app"); + assert.match(profileAgentLogoUrl("workbuddy"), /workbuddy/i); + assert.notEqual(profileAgentLogoUrl("workbuddy"), profileAgentLogoUrl("codex")); +}); diff --git a/tests/e2e/electron-cdp.spec.ts b/tests/e2e/electron-cdp.spec.ts index 309043c1..65923e7d 100644 --- a/tests/e2e/electron-cdp.spec.ts +++ b/tests/e2e/electron-cdp.spec.ts @@ -1,7 +1,7 @@ import { chromium, expect, test, type Browser, type Page } from "@playwright/test"; import { spawn, spawnSync, type ChildProcessByStdio } from "node:child_process"; import electronModule from "electron"; -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import net from "node:net"; import os from "node:os"; import path from "node:path"; @@ -143,6 +143,114 @@ test("starts the managed gateway from IPC without runtime config JSON", async () expect(readGatewayRuntimeMarker(path.join(configDir, "config.sqlite"))).toBeUndefined(); }); +test("applies Workbuddy profiles through the desktop bridge", async () => { + const current = requireRuntime(); + const profileId = "workbuddy-e2e"; + const result = await current.mainPage.evaluate(async (input) => { + const config = await window.ccr?.getConfig(); + if (!config) { + throw new Error("Config bridge is unavailable."); + } + config.APIKEYS = []; + config.Providers = [{ + api_base_url: "http://127.0.0.1:9/v1", + api_key: "e2e-provider-key", + id: "workbuddy-e2e-provider", + models: ["gpt-5-codex"], + name: "Workbuddy E2E Provider", + type: "openai_responses" + }]; + config.preferredProvider = "Workbuddy E2E Provider"; + config.gateway.enabled = false; + config.profile = { + ...config.profile, + enabled: true, + profiles: [{ + agent: "workbuddy", + cliMiddleware: true, + codexCliPath: "", + codexHome: "", + configFile: "~/.workbuddy/config.toml", + configFormat: "separate_profile_files", + enabled: true, + env: {}, + id: input.profileId, + managedCompact: false, + model: "Workbuddy E2E Provider/gpt-5-codex", + name: "Workbuddy E2E", + providerId: "claude-code-router", + providerName: "Claude Code Router", + showAllSessions: true, + scope: "ccr", + surface: "app" + }] + }; + + const saved = await window.ccr?.saveConfig(config, { applyProfile: false }); + const applyResult = await window.ccr?.applyProfile(); + return { applyResult, saved }; + }, { profileId }); + + expect(result.saved?.profile.profiles).toHaveLength(1); + expect(result.saved?.profile.profiles[0]?.agent).toBe("workbuddy"); + expect(result.saved?.profile.profiles[0]?.showAllSessions).toBe(false); + expect(result.saved?.profile.profiles[0]?.surface).toBe("app"); + + const workbuddyStatus = result.applyResult?.clients.find((client) => client.client === "workbuddy"); + expect(workbuddyStatus?.ok, workbuddyStatus?.message).toBe(true); + + const configDir = path.join(current.testHome, ".claude-code-router"); + const profileHome = path.join(configDir, "profiles", profileId, "workbuddy"); + const configFile = path.join(profileHome, "config.toml"); + const launcherFile = path.join( + configDir, + "bin", + process.platform === "win32" + ? `ccr-codex-cli-stdio-${profileId}.cmd` + : `ccr-codex-cli-stdio-${profileId}` + ); + expect(workbuddyStatus?.path).toBe(configFile); + + const toml = readFileSync(configFile, "utf8"); + expect(toml).toContain('model_provider = "claude-code-router"'); + expect(toml).toContain('model = "Workbuddy E2E Provider/gpt-5-codex"'); + expect(toml).toContain(`model_catalog_json = "${path.join(profileHome, "ccr-model-catalog.json")}"`); + expect(toml).toContain(`base_url = "http://${host}:${current.gatewayPort}/v1"`); + expect(toml).toContain('wire_api = "responses"'); + expect(toml).not.toContain("show_all_sessions = true"); + + const workbuddyModelsFile = path.join(profileHome, "models.json"); + expect(existsSync(workbuddyModelsFile)).toBe(true); + const workbuddyModels = JSON.parse(readFileSync(workbuddyModelsFile, "utf8")); + expect(workbuddyModels.availableModels).toEqual(["Workbuddy E2E Provider/gpt-5-codex"]); + expect(workbuddyModels.models).toHaveLength(1); + expect(workbuddyModels.models[0]).toMatchObject({ + apiKey: "${CCR_PROFILE_API_KEY}", + id: "Workbuddy E2E Provider/gpt-5-codex", + supportsToolCall: true, + tags: ["chat", "custom"], + url: `http://${host}:${current.gatewayPort}/v1`, + vendor: "Workbuddy E2E Provider" + }); + + const launcher = readFileSync(launcherFile, "utf8"); + expect(launcher).toContain(profileHome); + expect(launcher).toContain("WORKBUDDY_HOME"); + expect(launcher).toContain("WORKBUDDY_CONFIG_DIR"); + expect(launcher).toContain("CODEBUDDY_CONFIG_DIR"); + expect(launcher).toContain("CCR_REAL_CODEX_CLI_PATH"); + expect(launcher).toContain("codebuddy"); + + const virtualAuthFile = workbuddyVirtualAuthFile(profileHome); + expect(existsSync(virtualAuthFile)).toBe(true); + const virtualSession = JSON.parse(readFileSync(virtualAuthFile, "utf8")); + expect(virtualSession.auth.accessToken).toBe("ccr-local-profile"); + expect(virtualSession.auth.domain).toBe("www.workbuddy.ai"); + expect(virtualSession.account.uid).toBe("ccr-local-profile"); + expect(virtualSession.accounts).toHaveLength(1); + expect(virtualSession.allAccounts).toHaveLength(1); +}); + async function startElectronOverCdp(): Promise { const cdpPort = await findAvailablePort(); const gatewayPort = await findAvailablePort(); @@ -307,6 +415,16 @@ function readSqliteRows(file: string, query: string): Row[] { return JSON.parse(output || "[]") as Row[]; } +function workbuddyVirtualAuthFile(profileHome: string): string { + const virtualHome = path.join(profileHome, ".claude-code-router", "workbuddy-app-home"); + const sharedAuthSegments = process.platform === "darwin" + ? ["Library", "Application Support", "CodeBuddyExtension", "Data", "Public", "auth"] + : process.platform === "win32" + ? ["AppData", "Local", "CodeBuddyExtension", "Data", "Public", "auth"] + : [".local", "share", "CodeBuddyExtension", "Data", "Public", "auth"]; + return path.join(virtualHome, ...sharedAuthSegments, "workbuddy-desktop-ai.info"); +} + function runElectronNode(script: string, file: string, query = ""): string { const result = spawnSync(electronExecutable, ["-e", script], { cwd: projectRoot, From 551fc0e8efd02caaf38a7803f70eadbfbd2a4900 Mon Sep 17 00:00:00 2001 From: musistudio Date: Tue, 11 Aug 2026 10:00:33 +0800 Subject: [PATCH 07/18] Reuse healthy external gateways and decode Claude App routes --- packages/core/src/contracts/app.ts | 1 + .../gateway/application/gateway-service.ts | 54 +++++ .../src/gateway/existing-gateway-probe.ts | 188 ++++++++++++++++++ .../src/observability/request-log-store.ts | 36 +++- packages/core/src/web/management-server.ts | 6 +- .../observability/request-log-store.test.mjs | 79 ++++++++ .../unit/web/web-management-server.test.mjs | 175 +++++++++++++++- .../bundled-plugins/claude-design/index.cjs | 92 +++++++-- packages/electron/src/main/ipc.ts | 4 +- packages/electron/src/main/main-app.ts | 2 +- .../unit/claude-design-plugin-assets.test.ts | 103 +++++++++- 11 files changed, 704 insertions(+), 36 deletions(-) create mode 100644 packages/core/src/gateway/existing-gateway-probe.ts diff --git a/packages/core/src/contracts/app.ts b/packages/core/src/contracts/app.ts index df8c4fe5..ee265d6f 100644 --- a/packages/core/src/contracts/app.ts +++ b/packages/core/src/contracts/app.ts @@ -1809,6 +1809,7 @@ export type GatewayStatus = { coreEndpoint: string; coreManagedExternally?: boolean; endpoint: string; + gatewayManagedExternally?: boolean; lastError?: string; lastStartedAt?: string; networkEndpoints: GatewayNetworkEndpoint[]; diff --git a/packages/core/src/gateway/application/gateway-service.ts b/packages/core/src/gateway/application/gateway-service.ts index f31b70cc..d41a68c5 100644 --- a/packages/core/src/gateway/application/gateway-service.ts +++ b/packages/core/src/gateway/application/gateway-service.ts @@ -12,6 +12,7 @@ import { pluginService } from "@ccr/core/plugins/service"; import { proxyService } from "@ccr/core/proxy/service"; import { ClaudeCodeRouterPlugin } from "@ccr/core/gateway/claude-code-router-plugin"; import { compileCoreGatewayConfig } from "@ccr/core/gateway/core-runtime/config-compiler"; +import { isAddressInUseMessage, probeExistingCcrGateway } from "@ccr/core/gateway/existing-gateway-probe"; import { closeServer, formatError } from "@ccr/core/gateway/http/io"; import { RawTraceSynchronizer } from "@ccr/core/observability/raw-trace-sync"; import { GatewayBillingSynchronizer } from "@ccr/core/usage/billing-sync"; @@ -210,6 +211,7 @@ class GatewayService { this.status = { ...this.status, coreManagedExternally: this.status.coreManagedExternally, + gatewayManagedExternally: undefined, lastStartedAt: new Date().toISOString(), pid: this.child?.pid, state: "running" @@ -226,6 +228,37 @@ class GatewayService { } } + async ensureStarted(config: AppConfig): Promise { + const desiredEndpoint = endpoint(config.gateway.host, config.gateway.port); + const currentStatus = this.getStatus(); + if (currentStatus.state === "running" && currentStatus.endpoint === desiredEndpoint) { + if (currentStatus.gatewayManagedExternally) { + const existingGateway = await probeExistingCcrGateway(config); + if (existingGateway.state === "usable") { + this.markExternalGatewayRunning(config, existingGateway.endpoint); + return this.getStatus(); + } + } + if (!currentStatus.gatewayManagedExternally) { + await this.updateConfig(config); + return this.getStatus(); + } + } + + const status = await this.start(config); + if (status.state !== "error" || !isAddressInUseMessage(status.lastError)) { + return status; + } + + const existingGateway = await probeExistingCcrGateway(config); + if (existingGateway.state !== "usable") { + return status; + } + + this.markExternalGatewayRunning(config, existingGateway.endpoint); + return this.getStatus(); + } + async stop(options: GatewayStopOptions = {}): Promise { const child = this.child; this.child = undefined; @@ -259,6 +292,7 @@ class GatewayService { this.status = { ...this.status, coreManagedExternally: undefined, + gatewayManagedExternally: undefined, pid: undefined, state: "stopped" }; @@ -276,6 +310,11 @@ class GatewayService { async updateConfig(config: AppConfig): Promise { assertLoopbackCoreHost(config.gateway.coreHost); + if (this.status.gatewayManagedExternally) { + this.markExternalGatewayRunning(config, endpoint(config.gateway.host, config.gateway.port)); + return; + } + const scriptValidationErrors = await this.routeScriptRuntime.prepare(config.Router.rules); const nextPlugin = new ClaudeCodeRouterPlugin(config, { scriptRuntime: this.routeScriptRuntime, @@ -293,6 +332,7 @@ class GatewayService { ...this.status, coreEndpoint: endpoint(config.gateway.coreHost, config.gateway.corePort), endpoint: endpoint(config.gateway.host, config.gateway.port), + gatewayManagedExternally: undefined, networkEndpoints: gatewayNetworkEndpoints(config.gateway.host, config.gateway.port) }; } @@ -412,6 +452,20 @@ class GatewayService { return this.requestPipeline.proxyRequest(request, response, path, apiKey); } + private markExternalGatewayRunning(config: AppConfig, externalEndpoint: string): void { + this.config = config; + this.child = undefined; + this.server = undefined; + this.coreAuthToken = ""; + this.status = { + coreEndpoint: endpoint(config.gateway.coreHost, config.gateway.corePort), + endpoint: externalEndpoint, + gatewayManagedExternally: true, + networkEndpoints: gatewayNetworkEndpoints(config.gateway.host, config.gateway.port), + state: "running" + }; + } + } function assertManagedGatewayStartupContinues(child: ChildProcess, startupFailure: Error | undefined): void { diff --git a/packages/core/src/gateway/existing-gateway-probe.ts b/packages/core/src/gateway/existing-gateway-probe.ts new file mode 100644 index 00000000..216ce083 --- /dev/null +++ b/packages/core/src/gateway/existing-gateway-probe.ts @@ -0,0 +1,188 @@ +import type { ApiKeyConfig, AppConfig } from "@ccr/core/contracts/app"; + +export type ExistingCcrGatewayProbe = + | { endpoint: string; reason?: string; state: "unavailable" } + | { endpoint: string; status?: number; state: "not-ccr" } + | { endpoint: string; message?: string; status: number; state: "unauthorized" } + | { endpoint: string; status: number; state: "unusable" } + | { apiKey?: string; endpoint: string; state: "usable" }; + +type ExistingGatewayHttpProbe = { + payload?: unknown; + reason?: string; + status?: number; +}; + +const existingGatewayFetchAttempts = 3; + +export async function probeExistingCcrGateway( + config: Pick +): Promise { + const endpoint = publicGatewayEndpoint(config); + const health = await fetchExistingGateway(endpoint, "/health"); + let ccrGateway = isCcrGatewayHealth(health.payload); + let root: ExistingGatewayHttpProbe | undefined; + + if (!ccrGateway) { + root = await fetchExistingGateway(endpoint, "/"); + ccrGateway = isCcrGatewayRoot(root.payload); + } + if (!ccrGateway) { + if (health.status === undefined && root?.status === undefined) { + return { endpoint, reason: health.reason || root?.reason, state: "unavailable" }; + } + return { endpoint, status: health.status ?? root?.status, state: "not-ccr" }; + } + + const candidates = existingGatewayApiKeyCandidates(config); + if (candidates.length === 0) { + return { endpoint, message: "No configured CCR API key is available.", status: 401, state: "unauthorized" }; + } + + let lastUnauthorized: ExistingGatewayHttpProbe | undefined; + for (const apiKey of candidates) { + const models = await fetchExistingGateway(endpoint, "/v1/models", { + headers: { + authorization: `Bearer ${apiKey.key}`, + "user-agent": "Claude Code" + } + }); + if (models.status === 200) { + return { apiKey: apiKey.key, endpoint, state: "usable" }; + } + if (models.status === 401 || models.status === 403) { + lastUnauthorized = models; + continue; + } + return { endpoint, status: models.status ?? 0, state: "unusable" }; + } + + if (lastUnauthorized?.status === 401 || lastUnauthorized?.status === 403) { + return { + endpoint, + message: readGatewayErrorMessage(lastUnauthorized.payload), + status: lastUnauthorized.status, + state: "unauthorized" + }; + } + + return { endpoint, status: 0, state: "unusable" }; +} + +export function publicGatewayEndpoint(config: Pick): string { + const host = probeGatewayHost(config.gateway.host); + return `http://${formatEndpointHost(host)}:${config.gateway.port}/`; +} + +export function isAddressInUseMessage(message: string | undefined): boolean { + return /\bEADDRINUSE\b/i.test(message || ""); +} + +async function fetchExistingGateway( + endpoint: string, + pathname: string, + init: RequestInit = {} +): Promise { + let reason: string | undefined; + for (let attempt = 0; attempt < existingGatewayFetchAttempts; attempt += 1) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 1200); + try { + const response = await fetch(new URL(pathname, endpoint).toString(), { + ...init, + signal: controller.signal + }); + return { + payload: await readResponseJson(response), + status: response.status + }; + } catch (error) { + reason = formatError(error); + } finally { + clearTimeout(timeout); + } + } + return { reason }; +} + +function existingGatewayApiKeyCandidates(config: Pick): ApiKeyConfig[] { + const candidates = [ + ...(Array.isArray(config.APIKEYS) ? config.APIKEYS : []), + ...(config.APIKEY ? [{ createdAt: "", id: "legacy", key: config.APIKEY, name: "Legacy API Key" }] : []) + ]; + const seen = new Set(); + const result: ApiKeyConfig[] = []; + for (const candidate of candidates) { + const key = candidate.key?.trim(); + if (!key || seen.has(key)) { + continue; + } + seen.add(key); + result.push({ ...candidate, key }); + } + return result; +} + +function isCcrGatewayHealth(value: unknown): boolean { + return isRecord(value) && + typeof value.status === "string" && + typeof value.core === "string"; +} + +function isCcrGatewayRoot(value: unknown): boolean { + return isRecord(value) && + (value.name === "claude-code-router" || + value.plugin === "claude-code-router" || + (value.core === "next-ai-gateway" && Array.isArray(value.endpoints))); +} + +function readGatewayErrorMessage(value: unknown): string | undefined { + if (!isRecord(value)) { + return undefined; + } + const error = value.error; + if (typeof error === "string") { + return error; + } + if (isRecord(error) && typeof error.message === "string") { + return error.message; + } + if (typeof value.message === "string") { + return value.message; + } + return undefined; +} + +async function readResponseJson(response: Response): Promise { + const text = await response.text(); + if (!text.trim()) { + return undefined; + } + try { + return JSON.parse(text) as unknown; + } catch { + return undefined; + } +} + +function probeGatewayHost(host: string): string { + if (!host || host === "0.0.0.0") { + return "127.0.0.1"; + } + if (host === "::") { + return "::1"; + } + return host; +} + +function formatEndpointHost(host: string): string { + return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/core/src/observability/request-log-store.ts b/packages/core/src/observability/request-log-store.ts index 052c21d9..9b825897 100644 --- a/packages/core/src/observability/request-log-store.ts +++ b/packages/core/src/observability/request-log-store.ts @@ -2,6 +2,7 @@ import { mkdirSync } from "node:fs"; import { dirname } from "node:path"; import { StringDecoder } from "node:string_decoder"; import { REQUEST_LOGS_DB_FILE } from "@ccr/core/config/constants"; +import { decodeClaudeAppGatewayRouteId } from "@ccr/core/agents/claude-app/gateway-routes"; import { estimateUsageCostUsd, estimateUsageCostUsdFromLoadedCatalog, @@ -518,9 +519,12 @@ export class RequestLogStore { providerProtocol: input.providerProtocol, usageHint: bodyUsage }) ?? {}; - const route = splitRouteSelector(input.fallbackModel); + const route = splitRequestLogRouteSelector(input.fallbackModel); const bodyModel = requestLogRequestedModel(input.requestBody, input.path); const requestModel = normalizeFilterValue(input.model) ?? bodyModel; + const requestModelForStorage = requestLogStorageModel(requestModel); + const usageRoute = decodedClaudeAppGatewayRouteParts(usage.model); + const usageModelForStorage = usageRoute?.model ?? normalizeFilterValue(usage.model); const requestedModel = normalizeFilterValue(input.requestedModel) ?? bodyModel ?? ""; const resolvedModel = normalizeFilterValue(input.resolvedModel) ?? normalizeFilterValue(input.model) ?? @@ -535,7 +539,8 @@ export class RequestLogStore { normalizeFilterValue(input.providerName) ?? readResponseHeader(input.responseHeaders, "x-gateway-target-provider-name") ?? readResponseHeader(input.responseHeaders, "x-gateway-target-provider") ?? - route.provider; + route.provider ?? + usageRoute?.provider; const inputTokens = normalizeCount(usage.inputTokens); const outputTokens = normalizeCount(usage.outputTokens); const reasoningTokens = normalizeCount(usage.reasoningTokens); @@ -546,7 +551,7 @@ export class RequestLogStore { const totalTokens = normalizeCount(usage.totalTokens) || inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens; - const model = normalizeLabel(usage.model ?? route.model ?? requestModel ?? input.fallbackModel, "unknown"); + const model = normalizeLabel(usageModelForStorage ?? route.model ?? requestModelForStorage ?? input.fallbackModel, "unknown"); const providerName = normalizeLabel(provider, "unknown"); // CCR credential IDs are structured metadata, but their header names also // match the fail-closed secret classifier. Extract them before sanitizing; @@ -785,7 +790,9 @@ export class RequestLogStore { const url = normalizeFilterValue(input.url); const path = normalizeFilterValue(input.path) ?? pathFromUrl(url); const usagePath = path ?? existingUsageContext.path; - const modelFromTrace = normalizeFilterValue(input.model); + const rawModelFromTrace = normalizeFilterValue(input.model); + const modelFromTrace = requestLogStorageModel(rawModelFromTrace); + const resolvedModelFromTrace = requestLogStorageModelSelector(rawModelFromTrace); const responseModelFromTrace = rawInput.responseBodyText === undefined ? undefined : requestLogResponseModel(rawInput.responseBodyText); @@ -806,7 +813,7 @@ export class RequestLogStore { pushValue("url", url); pushValue("provider", providerFromTrace); pushValue("model", modelFromTrace); - pushValue("resolved_model", modelFromTrace); + pushValue("resolved_model", resolvedModelFromTrace); pushValue("response_model", responseModelFromTrace); // The gateway's terminal failure is authoritative, even when it has only // an HTTP error status and no error string. A final-attempt raw failure may @@ -847,7 +854,7 @@ export class RequestLogStore { const totalTokens = normalizeCount(usage.totalTokens) || inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens; - const model = normalizeLabel(usage.model ?? modelFromTrace ?? existingUsageContext.model, "unknown"); + const model = normalizeLabel(requestLogStorageModel(usage.model) ?? modelFromTrace ?? existingUsageContext.model, "unknown"); const provider = normalizeLabel(providerFromTrace ?? existingUsageContext.provider, "unknown"); const costInput = { cacheReadTokens, @@ -4986,6 +4993,23 @@ function parseJson(value: string): unknown | undefined { } } +function decodedClaudeAppGatewayRouteParts(value: string | undefined): { model?: string; provider?: string } | undefined { + const decoded = decodeClaudeAppGatewayRouteId(value ?? ""); + return decoded ? splitRouteSelector(decoded) : undefined; +} + +function requestLogStorageModel(value: string | undefined): string | undefined { + return decodedClaudeAppGatewayRouteParts(value)?.model ?? normalizeFilterValue(value); +} + +function requestLogStorageModelSelector(value: string | undefined): string | undefined { + return normalizeFilterValue(decodeClaudeAppGatewayRouteId(value ?? "") ?? value); +} + +function splitRequestLogRouteSelector(value: string | undefined): { model?: string; provider?: string } { + return splitRouteSelector(decodeClaudeAppGatewayRouteId(value ?? "") ?? value); +} + function splitRouteSelector(value: string | undefined): { model?: string; provider?: string } { const trimmed = value?.trim(); if (!trimmed) { diff --git a/packages/core/src/web/management-server.ts b/packages/core/src/web/management-server.ts index 119e3843..007dcaa8 100644 --- a/packages/core/src/web/management-server.ts +++ b/packages/core/src/web/management-server.ts @@ -366,7 +366,7 @@ const rpcHandlers: Record = { openProfile: async (request) => { const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig()); const config = syncedClaudeAppConfig.config; - const status = await gatewayService.start(config); + const status = await gatewayService.ensureStarted(config); if (status.state !== "running") { throw new Error(status.lastError || "CCR gateway did not start."); } @@ -444,7 +444,7 @@ const rpcHandlers: Record = { startGateway: async () => { const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig()); const config = syncedClaudeAppConfig.config; - const status = await gatewayService.start(config); + const status = await gatewayService.ensureStarted(config); await applyProfileIfServiceRunning(config, status); return status; }, @@ -469,7 +469,7 @@ async function startConfiguredServices(reason: string): Promise { } catch (error) { console.error(`Failed to sync Claude App gateway config during ${reason}: ${formatError(error)}`); } - const status = await gatewayService.start(config); + const status = await gatewayService.ensureStarted(config); if (status.state === "error") { console.error(`Failed to start gateway during ${reason}: ${status.lastError}`); } diff --git a/packages/core/test/integration/observability/request-log-store.test.mjs b/packages/core/test/integration/observability/request-log-store.test.mjs index d78f1a44..fc906171 100644 --- a/packages/core/test/integration/observability/request-log-store.test.mjs +++ b/packages/core/test/integration/observability/request-log-store.test.mjs @@ -316,6 +316,85 @@ test("RequestLogStore keeps list rows lightweight and detail rows complete", asy } }); +test("RequestLogStore stores decoded Claude App route models for observability", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-claude-app-model-test-")); + try { + const store = new RequestLogStore(path.join(dir, "request-logs.sqlite")); + const sessionId = "claude-app-hex-session"; + const routedModel = "Provider/real-model"; + const encodedModel = `anthropic/claude-ccr-h${Buffer.from(routedModel, "utf8").toString("hex")}`; + const startedAt = new Date().toISOString(); + const responseBodyText = [ + "event: message_start", + `data: ${JSON.stringify({ + message: { + model: encodedModel, + usage: { + input_tokens: 3, + output_tokens: 5, + total_tokens: 8 + } + }, + type: "message_start" + })}`, + "", + "event: message_stop", + "data: {\"type\":\"message_stop\"}", + "" + ].join("\n"); + + await store.record({ + completedAt: startedAt, + durationMs: 42, + fallbackModel: routedModel, + method: "POST", + model: routedModel, + path: "/v1/messages", + providerName: "Provider", + providerProtocol: "anthropic_messages", + requestedModel: encodedModel, + requestBody: Buffer.from(JSON.stringify({ + messages: [{ content: "hello", role: "user" }], + model: routedModel + }), "utf8"), + requestHeaders: { + "content-type": "application/json", + "user-agent": "openai-codex test", + "x-codex-session-id": sessionId + }, + requestId: "claude-app-hex-request", + resolvedModel: routedModel, + responseBodyText, + responseHeaders: { "content-type": "text/event-stream" }, + startedAt, + statusCode: 200, + url: "http://127.0.0.1:3456/v1/messages" + }); + + const page = await store.list({ pageSize: 25 }); + assert.equal(page.items.length, 1); + assert.equal(page.items[0].provider, "Provider"); + assert.equal(page.items[0].model, "real-model"); + assert.equal(page.items[0].requestedModel, encodedModel); + assert.equal(page.items[0].resolvedModel, routedModel); + assert.equal(page.items[0].responseModel, encodedModel); + + const selected = await store.analyze({ + range: "30d", + sessionAgent: "codex", + sessionId + }); + assert.equal(selected.selectedSession?.requests[0]?.model, "real-model"); + assert.deepEqual(selected.selectedSession?.session.models, ["real-model"]); + assert.equal( + selected.selectedSession?.trace.runs.find((run) => run.kind === "llm")?.model, + "real-model" + ); + } finally { + rmSync(dir, { force: true, recursive: true }); + } +}); + test("RequestLogStore persists actively reported route hops without synthesizing Core diffs", async () => { const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-route-trace-test-")); try { diff --git a/packages/core/test/unit/web/web-management-server.test.mjs b/packages/core/test/unit/web/web-management-server.test.mjs index b0345925..cae58feb 100644 --- a/packages/core/test/unit/web/web-management-server.test.mjs +++ b/packages/core/test/unit/web/web-management-server.test.mjs @@ -1,8 +1,30 @@ import assert from "node:assert/strict"; -import test from "node:test"; -import { normalizeExternalHttpTarget, startWebManagementServer } from "@ccr/core/web/management-server.ts"; +import { mkdtempSync, rmSync } from "node:fs"; +import { createServer } from "node:http"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import test, { after } from "node:test"; -test("normalizeExternalHttpTarget accepts absolute http, https, and CCR plugin URLs only", () => { +const previousRuntimeEnv = { + appData: process.env.CCR_INTERNAL_APP_DATA_DIR, + home: process.env.CCR_INTERNAL_HOME_DIR, + userData: process.env.CCR_INTERNAL_USER_DATA_DIR +}; +const runtimeRoot = mkdtempSync(path.join(os.tmpdir(), "ccr-web-management-test-")); +process.env.CCR_INTERNAL_APP_DATA_DIR = path.join(runtimeRoot, "app-data"); +process.env.CCR_INTERNAL_HOME_DIR = path.join(runtimeRoot, "home"); +process.env.CCR_INTERNAL_USER_DATA_DIR = path.join(runtimeRoot, "user-data"); + +after(() => { + setOptionalEnv("CCR_INTERNAL_APP_DATA_DIR", previousRuntimeEnv.appData); + setOptionalEnv("CCR_INTERNAL_HOME_DIR", previousRuntimeEnv.home); + setOptionalEnv("CCR_INTERNAL_USER_DATA_DIR", previousRuntimeEnv.userData); + rmSync(runtimeRoot, { force: true, recursive: true }); +}); + +test("normalizeExternalHttpTarget accepts absolute http, https, and CCR plugin URLs only", async () => { + const { normalizeExternalHttpTarget } = await import("@ccr/core/web/management-server.ts"); assert.equal(normalizeExternalHttpTarget(""), undefined); assert.equal(normalizeExternalHttpTarget(undefined), undefined); assert.equal(normalizeExternalHttpTarget("about:blank"), undefined); @@ -14,6 +36,7 @@ test("normalizeExternalHttpTarget accepts absolute http, https, and CCR plugin U }); test("web RPC ignores Origin and Referer when the auth token is valid", async () => { + const { startWebManagementServer } = await import("@ccr/core/web/management-server.ts"); const authToken = "test-web-auth-token"; const runtime = await startWebManagementServer({ authToken, @@ -42,3 +65,149 @@ test("web RPC ignores Origin and Referer when the auth token is valid", async () await runtime.close(); } }); + +test("startGateway reuses an already healthy CCR gateway on the configured port", async () => { + const { saveAppConfig } = await import("@ccr/core/config/config.ts"); + const { createDefaultAppConfig } = await import("@ccr/core/config/default-config.ts"); + const { gatewayService } = await import("@ccr/core/gateway/service.ts"); + const { startWebManagementServer } = await import("@ccr/core/web/management-server.ts"); + await gatewayService.stop(); + const gatewayPort = await findAvailablePort(); + const webAuthToken = "test-web-auth-token"; + const config = createDefaultAppConfig(); + config.gateway.port = gatewayPort; + config.gateway.corePort = await findAvailablePort(); + config.Providers = [{ + api_base_url: "http://127.0.0.1:9/v1", + api_key: "test-provider-key", + id: "test-provider", + models: ["test-model"], + name: "Test Provider", + type: "openai_chat_completions" + }]; + const savedConfig = await saveAppConfig(config); + const externalGateway = createHealthyCcrGateway(savedConfig.APIKEY); + await listen(externalGateway, gatewayPort); + const runtime = await startWebManagementServer({ + authToken: webAuthToken, + host: "127.0.0.1", + port: 0, + startGateway: false + }); + + try { + const payload = await rpc(runtime.url, webAuthToken, "startGateway"); + + assert.equal(payload.ok, true); + assert.equal(payload.value.state, "running", payload.value.lastError); + assert.equal(payload.value.endpoint, `http://127.0.0.1:${gatewayPort}/`); + assert.equal(payload.value.gatewayManagedExternally, true); + assert.equal(gatewayService.getStatus().state, "running"); + assert.equal(gatewayService.getStatus().gatewayManagedExternally, true); + } finally { + await runtime.close(); + await gatewayService.stop(); + await closeServer(externalGateway); + } +}); + +async function rpc(baseUrl, authToken, method, args = []) { + const response = await fetch(new URL("/api/ccr/rpc", baseUrl), { + body: JSON.stringify({ args, method }), + headers: { + "content-type": "application/json", + "x-ccr-web-auth": authToken + }, + method: "POST" + }); + assert.equal(response.status, 200); + return response.json(); +} + +function createHealthyCcrGateway(apiKey) { + return createServer((request, response) => { + const url = new URL(request.url || "/", "http://127.0.0.1"); + if (url.pathname === "/health") { + sendJson(response, 200, { + core: "http://127.0.0.1:3457", + status: "running", + timestamp: new Date().toISOString() + }); + return; + } + if (url.pathname === "/") { + sendJson(response, 200, { + core: "next-ai-gateway", + endpoints: ["GET /v1/models"], + name: "claude-code-router", + plugin: "claude-code-router" + }); + return; + } + if (url.pathname === "/v1/models") { + if (request.headers.authorization !== `Bearer ${apiKey}`) { + sendJson(response, 401, { error: { message: "Invalid API key." } }); + return; + } + sendJson(response, 200, { data: [{ id: "test-model", object: "model" }], object: "list" }); + return; + } + sendJson(response, 404, { error: { message: "Not found." } }); + }); +} + +function sendJson(response, statusCode, payload) { + response.writeHead(statusCode, { "content-type": "application/json" }); + response.end(JSON.stringify(payload)); +} + +async function findAvailablePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : undefined; + server.close(() => { + if (port) { + resolve(port); + } else { + reject(new Error("Failed to allocate a web management test port.")); + } + }); + }); + }); +} + +function setOptionalEnv(key, value) { + if (value === undefined) { + delete process.env[key]; + return; + } + process.env[key] = value; +} + +async function listen(server, port) { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); +} + +async function closeServer(server) { + if (!server.listening) { + return; + } + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} diff --git a/packages/electron/bundled-plugins/claude-design/index.cjs b/packages/electron/bundled-plugins/claude-design/index.cjs index cc8b1c15..b2ffc192 100644 --- a/packages/electron/bundled-plugins/claude-design/index.cjs +++ b/packages/electron/bundled-plugins/claude-design/index.cjs @@ -991,7 +991,7 @@ async function routeMockRequest(runtime, method, url, request, requestBody) { } if (isDesignRestApiRoutePath(path)) { - return await handleDesignRestApi(runtime, method, path, request, requestBody); + return await handleDesignRestApi(runtime, method, path, url, request, requestBody); } if (path.startsWith("/v1/code/")) { @@ -9812,7 +9812,7 @@ function upsertGenericRecord(store, collection, uuid, title, model, data) { store.persist(); } -async function handleDesignRestApi(runtime, method, path, request, requestBody) { +async function handleDesignRestApi(runtime, method, path, url, request, requestBody) { const restPath = normalizeDesignRestPath(path); if (method === "GET" && restPath === "/v1/design/agents") { @@ -9991,19 +9991,18 @@ async function handleDesignRestApi(runtime, method, path, request, requestBody) }); } - const downloadMatch = restPath.match(/^\/v1\/design\/projects\/([^/]+)\/download$/); - if (method === "GET" && downloadMatch) { + const downloadMatch = restPath.match(/^\/v1\/design\/projects\/([^/]+)\/download(?:\/(.+))?$/); + if ((method === "GET" || method === "HEAD") && downloadMatch) { const projectId = decodeURIComponent(downloadMatch[1]); - const manifest = listProjectFileRows(runtime, projectId, "").map((row) => ({ - content_type: row.content_type, - path: row.path, - size: Buffer.from(row.body_base64 || "", "base64").length, - version: row.version - })); - return textResponse(200, JSON.stringify({ files: manifest, project_id: projectId }, null, 2), { - "content-disposition": `attachment; filename="${projectId}.json"`, - "content-type": "application/json; charset=utf-8" - }); + const filePath = designDownloadFilePath(downloadMatch[2], url); + if (filePath) { + const row = getProjectFileRow(runtime, projectId, filePath); + if (!row) { + return jsonResponse(404, { error: { message: `File not found: ${filePath}` } }); + } + return serveProjectFileDownloadResponse(method, row, filePath); + } + return serveProjectArchiveDownloadResponse(method, runtime, projectId); } if (method === "POST" && restPath === "/v1/design/drop-suggestions") { @@ -10170,6 +10169,56 @@ function renderClaudeDesignPendingPreviewHtml() { `; } +function designDownloadFilePath(pathSuffix, url) { + const suffixPath = safelyDecodeDesignPath(pathSuffix); + if (suffixPath) { + return suffixPath; + } + for (const key of ["path", "file", "file_path", "download_path", "asset_path"]) { + const queryPath = safelyDecodeDesignPath(url.searchParams.get(key)); + if (queryPath) { + return queryPath; + } + } + return ""; +} + +function safelyDecodeDesignPath(value) { + const raw = stringValue(value); + if (!raw) { + return ""; + } + try { + return sanitizeProjectFilePath(decodeURIComponent(raw)); + } catch { + return sanitizeProjectFilePath(raw); + } +} + +function serveProjectFileDownloadResponse(method, row, filePath) { + const body = method === "HEAD" ? Buffer.alloc(0) : Buffer.from(row.body_base64 || "", "base64"); + const contentType = row.content_type || guessContentType(filePath); + return binaryResponse(200, body, { + "cache-control": "no-store", + "content-disposition": attachmentContentDisposition(pathModule.posix.basename(filePath) || "download"), + "content-type": contentType + }); +} + +function serveProjectArchiveDownloadResponse(method, runtime, projectId) { + const rows = listProjectFileRows(runtime, projectId, ""); + const files = rows.map((row) => ({ + body: Buffer.from(row.body_base64 || "", "base64"), + path: row.path + })); + const archive = method === "HEAD" ? Buffer.alloc(0) : createZipArchive(files); + return binaryResponse(200, archive, { + "cache-control": "no-store", + "content-disposition": attachmentContentDisposition(`${projectId || "claude-design-project"}.zip`), + "content-type": "application/zip" + }); +} + function serveProjectFileResponse(runtime, projectId, row, filePath) { const body = Buffer.from(row.body_base64 || "", "base64"); const contentType = row.content_type || guessContentType(filePath); @@ -16652,6 +16701,21 @@ function escapeHtmlAttribute(value) { return String(value).replace(/&/g, "&").replace(/"/g, """).replace(/ + `%${char.charCodeAt(0).toString(16).toUpperCase()}` + ); +} + function escapeRegExp(value) { return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } diff --git a/packages/electron/src/main/ipc.ts b/packages/electron/src/main/ipc.ts index 09eedad9..226d0751 100644 --- a/packages/electron/src/main/ipc.ts +++ b/packages/electron/src/main/ipc.ts @@ -195,7 +195,7 @@ ipcMain.handle(IPC_CHANNELS.appOpenProfile, async (_event, request: ProfileOpenR const config = profile.agent === CLAUDE_DESIGN_PLUGIN_ID ? withClaudeDesignRuntimePluginConfig(syncedClaudeAppConfig.config) : syncedClaudeAppConfig.config; - const status = await gatewayService.start(config); + const status = await gatewayService.ensureStarted(config); if (status.state !== "running") { throw new Error(status.lastError || "CCR gateway did not start."); } @@ -366,7 +366,7 @@ ipcMain.handle(IPC_CHANNELS.appRestartGateway, async () => { ipcMain.handle(IPC_CHANNELS.appStartGateway, async () => { const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig()); const config = syncedClaudeAppConfig.config; - const status = await gatewayService.start(config); + const status = await gatewayService.ensureStarted(config); await applyProfileIfServiceRunning(config, status); await builtInBrowserService.syncProxy(config); return status; diff --git a/packages/electron/src/main/main-app.ts b/packages/electron/src/main/main-app.ts index 1c0b5439..979112d3 100644 --- a/packages/electron/src/main/main-app.ts +++ b/packages/electron/src/main/main-app.ts @@ -229,7 +229,7 @@ function startConfiguredServices(reason: string): Promise { } catch (error) { console.error(`Failed to sync launch-at-login setting during ${reason}: ${formatError(error)}`); } - const status = await gatewayService.start(config); + const status = await gatewayService.ensureStarted(config); if (status.state === "error") { console.error(`Failed to start gateway during ${reason}: ${status.lastError}`); } diff --git a/packages/electron/test/unit/claude-design-plugin-assets.test.ts b/packages/electron/test/unit/claude-design-plugin-assets.test.ts index 011fd51e..9fc285c7 100644 --- a/packages/electron/test/unit/claude-design-plugin-assets.test.ts +++ b/packages/electron/test/unit/claude-design-plugin-assets.test.ts @@ -196,7 +196,70 @@ test("Claude Design mock covers local frontend project runtime routes", async () assert.equal(typeof events.stream, "function"); }); -function fakeClaudeDesignRuntime() { +test("Claude Design downloads return concrete files or a project zip", async () => { + const projectId = "project-download-test"; + const pngBody = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x01]); + const runtime = fakeClaudeDesignRuntime([ + { + body: Buffer.from("Test", "utf8"), + contentType: "text/html; charset=utf-8", + path: "index.html", + projectId + }, + { + body: pngBody, + contentType: "image/png", + path: "分享大纲.png", + projectId + } + ]); + + const image = await claudeDesignPlugin.__test.routeMockRequest( + runtime, + "GET", + new URL("http://127.0.0.1/v1/design/projects/project-download-test/download?path=%E5%88%86%E4%BA%AB%E5%A4%A7%E7%BA%B2.png"), + { headers: {} }, + Buffer.alloc(0) + ); + assert.equal(image.status, 200); + assert.equal(image.headers["content-type"], "image/png"); + assert.match(image.headers["content-disposition"], /attachment/); + assert.match(image.headers["content-disposition"], /filename\*=UTF-8''%E5%88%86%E4%BA%AB%E5%A4%A7%E7%BA%B2\.png/); + assert.deepEqual(image.body, pngBody); + + const archive = await claudeDesignPlugin.__test.routeMockRequest( + runtime, + "GET", + new URL("http://127.0.0.1/v1/design/projects/project-download-test/download"), + { headers: {} }, + Buffer.alloc(0) + ); + assert.equal(archive.status, 200); + assert.equal(archive.headers["content-type"], "application/zip"); + assert.match(archive.headers["content-disposition"], /project-download-test\.zip/); + assert.deepEqual(archive.body.subarray(0, 4), Buffer.from([0x50, 0x4b, 0x03, 0x04])); + assert.match(archive.body.toString("utf8"), /index\.html/); + assert.match(archive.body.toString("utf8"), /分享大纲\.png/); +}); + +type FakeProjectFile = { + body: Buffer; + contentType: string; + path: string; + projectId: string; +}; + +function fakeClaudeDesignRuntime(files: FakeProjectFile[] = []) { + const fileRows = files.map((file, index) => ({ + body_base64: file.body.toString("base64"), + content_type: file.contentType, + created_at: "2026-08-10T00:00:00.000Z", + path: file.path, + project_id: file.projectId, + updated_at: `2026-08-10T00:00:${String(index).padStart(2, "0")}.000Z`, + version: 1 + })); + return { assetDir: "", frontendUrl: "http://127.0.0.1:6173/design", @@ -209,19 +272,28 @@ function fakeClaudeDesignRuntime() { pluginId: "claude-design", store: { database: { - prepare() { - return { - bind() { - return this; + prepare(sql: string) { + let rows = fakeClaudeDesignRows(sql, [], fileRows); + let index = -1; + const statement = { + bind(params: unknown[]) { + rows = fakeClaudeDesignRows(sql, Array.from(params || []), fileRows); + index = -1; + return statement; }, free() {}, getAsObject() { - return {}; + return rows[index] || {}; }, step() { - return false; + if (index + 1 >= rows.length) { + return false; + } + index += 1; + return true; } }; + return statement; }, run() {} }, @@ -229,3 +301,20 @@ function fakeClaudeDesignRuntime() { } }; } + +function fakeClaudeDesignRows(sql: string, params: unknown[], fileRows: Record[]) { + if (!sql.includes("FROM claude_design_files")) { + return []; + } + if (sql.includes("WHERE project_id = ? AND path = ?")) { + const [projectId, filePath] = params; + return fileRows.filter((row) => row.project_id === projectId && row.path === filePath); + } + if (sql.includes("WHERE project_id = ?")) { + const [projectId] = params; + return fileRows.filter((row) => row.project_id === projectId).sort((left, right) => + String(left.path).localeCompare(String(right.path)) + ); + } + return []; +} From e38f9bc2dd53618df767595167898ecb923b2e6a Mon Sep 17 00:00:00 2001 From: musistudio Date: Tue, 11 Aug 2026 10:53:45 +0800 Subject: [PATCH 08/18] Refactor router configuration and request handling --- package-lock.json | 705 ++---------------- package.json | 19 +- packages/cli/package.json | 2 +- packages/core/package.json | 4 +- .../gateway/application/gateway-service.ts | 97 ++- .../src/gateway/core-runtime/supervisor.ts | 10 +- .../src/gateway/existing-gateway-probe.ts | 103 ++- .../core/src/gateway/http/request-handler.ts | 45 ++ .../src/gateway/runtime-config-control.ts | 28 + packages/core/src/profiles/launch-service.ts | 7 +- packages/core/src/web/management-server.ts | 27 +- .../profile-model-allowlist-http.test.mjs | 29 + .../test/unit/gateway/gateway-status.test.mjs | 8 + .../profiles/profile-gateway-probe.test.mjs | 48 +- .../unit/web/web-management-server.test.mjs | 56 +- packages/electron/src/main/ipc.ts | 27 +- .../ui/src/pages/home/components/profiles.tsx | 2 +- 17 files changed, 548 insertions(+), 669 deletions(-) create mode 100644 packages/core/src/gateway/runtime-config-control.ts diff --git a/package-lock.json b/package-lock.json index 0e9edad2..a78113d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,8 @@ "electron-updater": "^6.8.9", "node-forge": "^1.4.0", "openai": "^6.27.0", - "undici": "^7.27.2" + "pm2": "^7.0.3", + "undici": "^7.29.0" }, "devDependencies": { "@dnd-kit/core": "^6.3.1", @@ -36,7 +37,7 @@ "electron": "^42.3.3", "electron-builder": "^26.8.1", "esbuild": "^0.27.7", - "js-yaml": "^4.2.0", + "js-yaml": "^4.3.1", "lucide-react": "^1.17.0", "motion": "^12.40.0", "react": "^18.3.1", @@ -1655,98 +1656,6 @@ "node": ">=18" } }, - "node_modules/@pm2/agent": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@pm2/agent/-/agent-2.1.1.tgz", - "integrity": "sha512-0V9ckHWd/HSC8BgAbZSoq8KXUG81X97nSkAxmhKDhmF8vanyaoc1YXwc2KVkbWz82Rg4gjd2n9qiT3i7bdvGrQ==", - "license": "AGPL-3.0", - "dependencies": { - "async": "~3.2.0", - "chalk": "~3.0.0", - "dayjs": "~1.8.24", - "debug": "~4.3.1", - "eventemitter2": "~5.0.1", - "fast-json-patch": "^3.1.0", - "fclone": "~1.0.11", - "pm2-axon": "~4.0.1", - "pm2-axon-rpc": "~0.7.0", - "proxy-agent": "~6.4.0", - "semver": "~7.5.0", - "ws": "~7.5.10" - } - }, - "node_modules/@pm2/agent/node_modules/chalk": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", - "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@pm2/agent/node_modules/dayjs": { - "version": "1.8.36", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.8.36.tgz", - "integrity": "sha512-3VmRXEtw7RZKAf+4Tv1Ym9AGeo8r8+CjDi26x+7SYQil1UqtqdaokhzoEJohqlzt0m5kacJSDhJQkG/LWhpRBw==", - "license": "MIT" - }, - "node_modules/@pm2/agent/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@pm2/agent/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "license": "ISC", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@pm2/agent/node_modules/ws": { - "version": "7.5.11", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", - "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", - "license": "MIT", - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/@pm2/blessed": { "version": "0.1.81", "resolved": "https://registry.npmjs.org/@pm2/blessed/-/blessed-0.1.81.tgz", @@ -1759,78 +1668,6 @@ "node": ">= 0.8.0" } }, - "node_modules/@pm2/io": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@pm2/io/-/io-6.1.0.tgz", - "integrity": "sha512-IxHuYURa3+FQ6BKePlgChZkqABUKFYH6Bwbw7V/pWU1pP6iR1sCI26l7P9ThUEB385ruZn/tZS3CXDUF5IA1NQ==", - "license": "Apache-2", - "dependencies": { - "async": "~2.6.1", - "debug": "~4.3.1", - "eventemitter2": "^6.3.1", - "require-in-the-middle": "^5.0.0", - "semver": "~7.5.4", - "shimmer": "^1.2.0", - "signal-exit": "^3.0.3", - "tslib": "1.9.3" - }, - "engines": { - "node": ">=6.0" - } - }, - "node_modules/@pm2/io/node_modules/async": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", - "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", - "license": "MIT", - "dependencies": { - "lodash": "^4.17.14" - } - }, - "node_modules/@pm2/io/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@pm2/io/node_modules/eventemitter2": { - "version": "6.4.9", - "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz", - "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==", - "license": "MIT" - }, - "node_modules/@pm2/io/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "license": "ISC", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@pm2/io/node_modules/tslib": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.9.3.tgz", - "integrity": "sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ==", - "license": "Apache-2.0" - }, "node_modules/@pm2/js-api": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@pm2/js-api/-/js-api-0.8.1.tgz", @@ -1873,12 +1710,6 @@ } } }, - "node_modules/@pm2/js-api/node_modules/eventemitter2": { - "version": "6.4.9", - "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz", - "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==", - "license": "MIT" - }, "node_modules/@pm2/pm2-version-check": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@pm2/pm2-version-check/-/pm2-version-check-1.0.4.tgz", @@ -2640,15 +2471,6 @@ "amp": "0.3.1" } }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -3202,12 +3024,6 @@ "dev": true, "license": "MIT" }, - "node_modules/bodec": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/bodec/-/bodec-0.1.0.tgz", - "integrity": "sha512-Ylo+MAo5BDUq1KA3f3R/MFhh+g8cnHmo8bz3YPGhI1znrMaf77ol1sfvYJzsw3nTE+Y2GryfDxBaR+AqpAkEHQ==", - "license": "MIT" - }, "node_modules/boolean": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", @@ -3217,18 +3033,6 @@ "license": "MIT", "optional": true }, - "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -3269,6 +3073,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, "license": "MIT" }, "node_modules/builder-util": { @@ -3405,12 +3210,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/charm": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/charm/-/charm-0.1.2.tgz", - "integrity": "sha512-syedaZ9cPe7r3hoQA9twWYKu5AIyCswN5+szkmPBe9ccdLrj4bYaCnLVPTLd2kgVRc7+zoX4tyPgRnFKCj5YjQ==", - "license": "MIT/X11" - }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -3695,12 +3494,6 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, - "node_modules/culvert": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/culvert/-/culvert-0.1.2.tgz", - "integrity": "sha512-yi1x3EAWKjQTreYWeSd98431AV+IEE0qoDyOoaHJ7KJ21gv6HtBXHVLX74opVSGqcR8/AbjJBHAHpcOy2bj5Gg==", - "license": "MIT" - }, "node_modules/d3": { "version": "7.9.0", "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", @@ -4679,18 +4472,6 @@ "node": ">=10.13.0" } }, - "node_modules/enquirer": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz", - "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==", - "license": "MIT", - "dependencies": { - "ansi-colors": "^4.1.1" - }, - "engines": { - "node": ">=8.6" - } - }, "node_modules/env-paths": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", @@ -4725,6 +4506,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4833,7 +4615,9 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, "license": "MIT", + "optional": true, "engines": { "node": ">=10" }, @@ -4894,9 +4678,9 @@ } }, "node_modules/eventemitter2": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-5.0.1.tgz", - "integrity": "sha512-5EM1GHXycJBS6mauYAbVKT1cVs7POKWb2NXD4Vyt8dDqeZa7LaDK1/sjtL+Zb0lzTpSNil4596Dyu97hz37QLg==", + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.9.tgz", + "integrity": "sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==", "license": "MIT" }, "node_modules/eventemitter3": { @@ -4982,9 +4766,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -5039,12 +4823,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fclone": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/fclone/-/fclone-1.0.11.tgz", - "integrity": "sha512-GDqVQezKzRABdeqflsgMr7ktzgF9CyS+p2oe0jJqUY6izSSbhPIQJDpoU4PtGcD7VPM9xh/dVrTu6z1nwgmEGw==", - "license": "MIT" - }, "node_modules/file-selector": { "version": "0.1.19", "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.1.19.tgz", @@ -5116,9 +4894,9 @@ } }, "node_modules/find-my-way": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", - "integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==", + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.7.0.tgz", + "integrity": "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -5250,6 +5028,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5336,18 +5115,6 @@ "node": ">= 14" } }, - "node_modules/git-node-fs": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/git-node-fs/-/git-node-fs-1.0.0.tgz", - "integrity": "sha512-bLQypt14llVXBg0S0u8q8HmU7g9p3ysH+NvVlae5vILuUvs759665HvmR5+wb04KjHyjFcDRxdYb4kyNnluMUQ==", - "license": "MIT" - }, - "node_modules/git-sha1": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/git-sha1/-/git-sha1-0.1.2.tgz", - "integrity": "sha512-2e/nZezdVlyCopOCYHeW0onkbZg7xP1Ad6pndPy1rCygeRykefUS6r7oA5cJRGEFvseiaz5a/qUHFVX1dd6Isg==", - "license": "MIT" - }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -5542,6 +5309,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -5710,9 +5478,9 @@ } }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", "license": "MIT", "engines": { "node": ">= 12" @@ -5739,21 +5507,6 @@ "node": ">=8" } }, - "node_modules/is-core-module": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", - "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", - "license": "MIT", - "dependencies": { - "hasown": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -5861,18 +5614,6 @@ "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/js-git": { - "version": "0.7.8", - "resolved": "https://registry.npmjs.org/js-git/-/js-git-0.7.8.tgz", - "integrity": "sha512-+E5ZH/HeRnoc/LW0AmAyhU+mNcWBzAKE+30+IDMLSLbbK+Tdt02AdkOKq9u15rlJsDEGFqtgckc8ZM59LhhiUA==", - "license": "MIT", - "dependencies": { - "bodec": "^0.1.0", - "culvert": "^0.1.2", - "git-sha1": "^0.1.2", - "pako": "^0.2.5" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5880,9 +5621,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "funding": [ { "type": "github", @@ -5937,8 +5678,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/json5": { "version": "2.2.3", @@ -6346,6 +6086,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -6511,6 +6252,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimatch/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -6582,12 +6335,6 @@ "integrity": "sha512-ST0PnThzWKcgSLyc+ugLVql45PvESt3Ul/wrdV/OPc/6Pr8dbLAIJsN1cIp41FLzbN+srVTNIRn+5Cju0nyV6A==", "license": "MIT" }, - "node_modules/module-details-from-path": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", - "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", - "license": "MIT" - }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", @@ -6660,56 +6407,12 @@ "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", "license": "MIT" }, - "node_modules/mute-stream": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", - "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", - "license": "ISC" - }, "node_modules/napi-build-utils": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", "license": "MIT" }, - "node_modules/needle": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/needle/-/needle-2.4.0.tgz", - "integrity": "sha512-4Hnwzr3mi5L97hMYeNl8wRW/Onhy4nUKR/lVemJ8gJedxxUyBLm9kkrDColJvoSfwi0jCNhD+xCdOtiGDQiRZg==", - "license": "MIT", - "dependencies": { - "debug": "^3.2.6", - "iconv-lite": "^0.4.4", - "sax": "^1.2.4" - }, - "bin": { - "needle": "bin/needle" - }, - "engines": { - "node": ">= 4.4.x" - } - }, - "node_modules/needle/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/needle/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/netmask": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", @@ -6988,12 +6691,6 @@ "node": ">= 14" } }, - "node_modules/pako": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", - "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", - "license": "MIT" - }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -7014,12 +6711,6 @@ "node": ">=8" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "license": "MIT" - }, "node_modules/path-scurry": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", @@ -7093,15 +6784,15 @@ } }, "node_modules/pidusage": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/pidusage/-/pidusage-3.0.2.tgz", - "integrity": "sha512-g0VU+y08pKw5M8EZ2rIGiEBaB8wrQMjYGFfW2QVIfyT8V+fq8YFLkvlz4bz5ljvFDJYNFCWT3PWqcRr2FKO81w==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pidusage/-/pidusage-4.0.1.tgz", + "integrity": "sha512-yCH2dtLHfEBnzlHUJymR/Z1nN2ePG3m392Mv8TFlTP1B0xkpMQNHAnfkY0n2tAi6ceKO6YWhxYfZ96V4vVkh/g==", "license": "MIT", "dependencies": { "safe-buffer": "^5.2.1" }, "engines": { - "node": ">=10" + "node": ">=18" } }, "node_modules/pidusage/node_modules/safe-buffer": { @@ -7240,16 +6931,16 @@ } }, "node_modules/pm2": { - "version": "6.0.14", - "resolved": "https://registry.npmjs.org/pm2/-/pm2-6.0.14.tgz", - "integrity": "sha512-wX1FiFkzuT2H/UUEA8QNXDAA9MMHDsK/3UHj6Dkd5U7kxyigKDA5gyDw78ycTQZAuGCLWyUX5FiXEuVQWafukA==", + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/pm2/-/pm2-7.0.3.tgz", + "integrity": "sha512-zRJOdburpb9OEPB0uqoNT8C1Gp7hPJPVy4Kr67XJNuT9UlMQcOt1WXrYQUmwqKPHk8FyauvP1CPhqoCrCaPw0Q==", "license": "AGPL-3.0", "dependencies": { - "@pm2/agent": "~2.1.1", "@pm2/blessed": "0.1.81", - "@pm2/io": "~6.1.0", - "@pm2/js-api": "~0.8.0", - "@pm2/pm2-version-check": "^1.0.4", + "@pm2/js-api": "0.8.1", + "@pm2/pm2-version-check": "1.0.4", + "amp": "0.3.1", + "amp-message": "0.1.2", "ansis": "4.0.0-node10", "async": "3.2.6", "chokidar": "3.6.0", @@ -7258,22 +6949,15 @@ "croner": "4.1.97", "dayjs": "1.11.15", "debug": "4.4.3", - "enquirer": "2.3.6", - "eventemitter2": "5.0.1", - "fclone": "1.0.11", - "js-yaml": "4.1.1", - "mkdirp": "1.0.4", - "needle": "2.4.0", - "pidusage": "3.0.2", - "pm2-axon": "~4.0.1", - "pm2-axon-rpc": "~0.7.1", - "pm2-deploy": "~1.0.2", - "pm2-multimeter": "^0.1.2", - "promptly": "2.2.0", + "eventemitter2": "6.4.9", + "fast-json-patch": "3.1.1", + "js-yaml": "4.3.0", + "pidusage": "4.0.1", + "pm2-deploy": "1.0.2", + "proxy-agent": "6.5.0", "semver": "7.7.2", - "source-map-support": "0.5.21", - "sprintf-js": "1.1.2", - "vizion": "~2.2.1" + "tx2": "1.0.5", + "ws": "8.21.0" }, "bin": { "pm2": "bin/pm2", @@ -7282,37 +6966,7 @@ "pm2-runtime": "bin/pm2-runtime" }, "engines": { - "node": ">=16.0.0" - }, - "optionalDependencies": { - "pm2-sysmonit": "^1.2.8" - } - }, - "node_modules/pm2-axon": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pm2-axon/-/pm2-axon-4.0.1.tgz", - "integrity": "sha512-kES/PeSLS8orT8dR5jMlNl+Yu4Ty3nbvZRmaAtROuVm9nYYGiaoXqqKQqQYzWQzMYWUKHMQTvBlirjE5GIIxqg==", - "license": "MIT", - "dependencies": { - "amp": "~0.3.1", - "amp-message": "~0.1.1", - "debug": "^4.3.1", - "escape-string-regexp": "^4.0.0" - }, - "engines": { - "node": ">=5" - } - }, - "node_modules/pm2-axon-rpc": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/pm2-axon-rpc/-/pm2-axon-rpc-0.7.1.tgz", - "integrity": "sha512-FbLvW60w+vEyvMjP/xom2UPhUN/2bVpdtLfKJeYM3gwzYhoTEEChCOICfFzxkxuoEleOlnpjie+n1nue91bDQw==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.1" - }, - "engines": { - "node": ">=5" + "node": ">=18.0.0" } }, "node_modules/pm2-deploy": { @@ -7328,93 +6982,12 @@ "node": ">=4.0.0" } }, - "node_modules/pm2-multimeter": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/pm2-multimeter/-/pm2-multimeter-0.1.2.tgz", - "integrity": "sha512-S+wT6XfyKfd7SJIBqRgOctGxaBzUOmVQzTAS+cg04TsEUObJVreha7lvCfX8zzGVr871XwCSnHUU7DQQ5xEsfA==", - "license": "MIT/X11", - "dependencies": { - "charm": "~0.1.1" - } - }, - "node_modules/pm2-sysmonit": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/pm2-sysmonit/-/pm2-sysmonit-1.2.8.tgz", - "integrity": "sha512-ACOhlONEXdCTVwKieBIQLSi2tQZ8eKinhcr9JpZSUAL8Qy0ajIgRtsLxG/lwPOW3JEKqPyw/UaHmTWhUzpP4kA==", - "license": "Apache", - "optional": true, - "dependencies": { - "async": "^3.2.0", - "debug": "^4.3.1", - "pidusage": "^2.0.21", - "systeminformation": "^5.7", - "tx2": "~1.0.4" - } - }, - "node_modules/pm2-sysmonit/node_modules/pidusage": { - "version": "2.0.21", - "resolved": "https://registry.npmjs.org/pidusage/-/pidusage-2.0.21.tgz", - "integrity": "sha512-cv3xAQos+pugVX+BfXpHsbyz/dLzX+lr44zNMsYiGxUw+kV5sgQCIcLd1z+0vq+KyC7dJ+/ts2PsfgWfSC3WXA==", - "license": "MIT", - "optional": true, - "dependencies": { - "safe-buffer": "^5.2.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pm2-sysmonit/node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true - }, "node_modules/pm2/node_modules/commander": { "version": "2.15.1", "resolved": "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz", "integrity": "sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag==", "license": "MIT" }, - "node_modules/pm2/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/pm2/node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", - "license": "MIT", - "bin": { - "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/pm2/node_modules/semver": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", @@ -7427,12 +7000,6 @@ "node": ">=10" } }, - "node_modules/pm2/node_modules/sprintf-js": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.2.tgz", - "integrity": "sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==", - "license": "BSD-3-Clause" - }, "node_modules/polished": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", @@ -7597,15 +7164,6 @@ "node": ">=10" } }, - "node_modules/promptly": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/promptly/-/promptly-2.2.0.tgz", - "integrity": "sha512-aC9j+BZsRSSzEsXBNBwDnAxujdx19HycZoKgRgzWnS8eOHg1asuf9heuLprfbe739zY3IdUQx+Egv6Jn135WHA==", - "license": "MIT", - "dependencies": { - "read": "^1.0.4" - } - }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -7661,19 +7219,19 @@ "license": "MIT" }, "node_modules/proxy-agent": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.4.0.tgz", - "integrity": "sha512-u0piLU+nCOHMgGjRbimiXmA9kM/L9EHh3zL81xCdp7m+Y2pHIsnmbdDoEDoAz5geaonNR6q6+yOPQs6n4T6sBQ==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", "license": "MIT", "dependencies": { - "agent-base": "^7.0.2", + "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", - "https-proxy-agent": "^7.0.3", + "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", - "pac-proxy-agent": "^7.0.1", + "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", - "socks-proxy-agent": "^8.0.2" + "socks-proxy-agent": "^8.0.5" }, "engines": { "node": ">= 14" @@ -7995,18 +7553,6 @@ "node": ">=6" } }, - "node_modules/read": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", - "integrity": "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==", - "license": "ISC", - "dependencies": { - "mute-stream": "~0.0.4" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/read-binary-file-arch": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", @@ -8121,20 +7667,6 @@ "node": ">=0.10.0" } }, - "node_modules/require-in-the-middle": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-5.2.0.tgz", - "integrity": "sha512-efCx3b+0Z69/LGJmm9Yvi4cqEdxnoGnxYxGxBghkkTTFeXRtTCmmhO0AnAfHz59k957uTSuy8WaHqOs8wbYUWg==", - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "module-details-from-path": "^1.0.3", - "resolve": "^1.22.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/resedit": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", @@ -8165,27 +7697,6 @@ "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", "license": "MIT" }, - "node_modules/resolve": { - "version": "1.22.12", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", - "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/resolve-alpn": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", @@ -8526,16 +8037,11 @@ "node": ">=8" } }, - "node_modules/shimmer": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz", - "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==", - "license": "BSD-2-Clause" - }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, "license": "ISC" }, "node_modules/simple-concat": { @@ -8647,6 +8153,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -8666,6 +8173,7 @@ "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", @@ -8813,45 +8321,6 @@ "node": ">=8" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/systeminformation": { - "version": "5.31.12", - "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.12.tgz", - "integrity": "sha512-qnTtO5wHrKeKE/MvQ6iIt6XAV+5fgt/kPIQf27DYgjVQQuHUfWkV4Gu6k04ZpEzAMuyQ3ZsovY7Ivhp+E9JyWw==", - "license": "MIT", - "optional": true, - "os": [ - "darwin", - "linux", - "win32", - "freebsd", - "openbsd", - "netbsd", - "sunos", - "android" - ], - "bin": { - "systeminformation": "lib/cli.js" - }, - "engines": { - "node": ">=8.0.0" - }, - "funding": { - "type": "Buy me a coffee", - "url": "https://www.buymeacoffee.com/systeminfo" - } - }, "node_modules/tailwind-merge": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", @@ -9180,7 +8649,6 @@ "resolved": "https://registry.npmjs.org/tx2/-/tx2-1.0.5.tgz", "integrity": "sha512-sJ24w0y03Md/bxzK4FU8J8JveYYUbSs2FViLJ2D/8bytSiyPRbuE3DyL/9UKYXTZlV3yXq0L8GLlhobTnekCVg==", "license": "MIT", - "optional": true, "dependencies": { "json-stringify-safe": "^5.0.1" } @@ -9214,9 +8682,9 @@ } }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "license": "MIT", "engines": { "node": ">=20.18.1" @@ -9364,30 +8832,6 @@ "@math.gl/web-mercator": "^3.5.5" } }, - "node_modules/vizion": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/vizion/-/vizion-2.2.1.tgz", - "integrity": "sha512-sfAcO2yeSU0CSPFI/DmZp3FsFE9T+8913nv1xWBOyzODv13fwkn6Vl7HqxGpkr9F608M+8SuFId3s+BlZqfXww==", - "license": "Apache-2.0", - "dependencies": { - "async": "^2.6.3", - "git-node-fs": "^1.0.0", - "ini": "^1.3.5", - "js-git": "^0.7.8" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/vizion/node_modules/async": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", - "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", - "license": "MIT", - "dependencies": { - "lodash": "^4.17.14" - } - }, "node_modules/vt-pbf": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz", @@ -9507,6 +8951,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, "license": "ISC" }, "node_modules/yargs": { @@ -9560,7 +9005,7 @@ "@the-next-ai/bot-gateway-sdk": "^0.1.0", "better-sqlite3": "^12.11.1", "node-forge": "^1.4.0", - "undici": "^7.27.2" + "undici": "^7.29.0" }, "bin": { "ccr": "dist/main/cli.js" @@ -9577,8 +9022,8 @@ "@the-next-ai/bot-gateway-sdk": "^0.1.0", "better-sqlite3": "^12.11.1", "node-forge": "^1.4.0", - "pm2": "^6.0.13", - "undici": "^7.27.2" + "pm2": "^7.0.3", + "undici": "^7.29.0" }, "bin": { "ccr-core-server": "dist/main/server.js" diff --git a/package.json b/package.json index 6e48a1d1..465c56f3 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,8 @@ "electron-updater": "^6.8.9", "node-forge": "^1.4.0", "openai": "^6.27.0", - "undici": "^7.27.2" + "pm2": "^7.0.3", + "undici": "^7.29.0" }, "devDependencies": { "@dnd-kit/core": "^6.3.1", @@ -96,7 +97,7 @@ "electron": "^42.3.3", "electron-builder": "^26.8.1", "esbuild": "^0.27.7", - "js-yaml": "^4.2.0", + "js-yaml": "^4.3.1", "lucide-react": "^1.17.0", "motion": "^12.40.0", "react": "^18.3.1", @@ -109,6 +110,18 @@ "typescript": "^5.9.3" }, "overrides": { - "@types/react": "$@types/react" + "@types/react": "$@types/react", + "fast-uri@3.1.2": "3.1.5", + "find-my-way@9.6.0": "9.7.0", + "js-yaml": "$js-yaml", + "minimatch@10.2.5": { + "brace-expansion": "5.0.9" + }, + "pm2": { + "js-yaml": "4.3.1" + }, + "socks": { + "ip-address": "10.5.0" + } } } diff --git a/packages/cli/package.json b/packages/cli/package.json index 111c6ceb..3a63ae10 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -45,6 +45,6 @@ "@the-next-ai/bot-gateway-sdk": "^0.1.0", "better-sqlite3": "^12.11.1", "node-forge": "^1.4.0", - "undici": "^7.27.2" + "undici": "^7.29.0" } } diff --git a/packages/core/package.json b/packages/core/package.json index b954dd71..065d4515 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -20,7 +20,7 @@ "@the-next-ai/bot-gateway-sdk": "^0.1.0", "better-sqlite3": "^12.11.1", "node-forge": "^1.4.0", - "pm2": "^6.0.13", - "undici": "^7.27.2" + "pm2": "^7.0.3", + "undici": "^7.29.0" } } diff --git a/packages/core/src/gateway/application/gateway-service.ts b/packages/core/src/gateway/application/gateway-service.ts index d41a68c5..64cb3230 100644 --- a/packages/core/src/gateway/application/gateway-service.ts +++ b/packages/core/src/gateway/application/gateway-service.ts @@ -6,13 +6,14 @@ import { randomUUID } from "node:crypto"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import type { ApiKeyConfig, AppConfig, GatewayStatus, RouteScriptTestRequest, RouteScriptTestResult, RouteScriptValidationRequest, RouteScriptValidationResult, RouterRule } from "@ccr/core/contracts/app"; import { NO_AVAILABLE_GATEWAY_MODELS_MESSAGE, hasAvailableGatewayModels } from "@ccr/core/contracts/app"; +import { loadAppConfig } from "@ccr/core/config/config"; import { backendService } from "@ccr/core/plugins/backend-service"; import { getSystemProxyUrlForProtocol } from "@ccr/core/proxy/system-proxy-fetch"; import { pluginService } from "@ccr/core/plugins/service"; import { proxyService } from "@ccr/core/proxy/service"; import { ClaudeCodeRouterPlugin } from "@ccr/core/gateway/claude-code-router-plugin"; import { compileCoreGatewayConfig } from "@ccr/core/gateway/core-runtime/config-compiler"; -import { isAddressInUseMessage, probeExistingCcrGateway } from "@ccr/core/gateway/existing-gateway-probe"; +import { isAddressInUseMessage, probeExistingCcrGateway, reloadExistingCcrGatewayConfig } from "@ccr/core/gateway/existing-gateway-probe"; import { closeServer, formatError } from "@ccr/core/gateway/http/io"; import { RawTraceSynchronizer } from "@ccr/core/observability/raw-trace-sync"; import { GatewayBillingSynchronizer } from "@ccr/core/usage/billing-sync"; @@ -21,6 +22,8 @@ import { coreGatewayAuthHeader } from "@ccr/core/gateway/internal/shared"; import type { BrowserAutomationMcpIntegration, BrowserWebSearchMcpIntegration, GatewayStopOptions } from "@ccr/core/gateway/internal/shared"; import { GatewayRequestPipeline } from "@ccr/core/gateway/request/pipeline"; import { GatewayHttpRequestHandler } from "@ccr/core/gateway/http/request-handler"; +import { gatewayRuntimeConfigRevision } from "@ccr/core/gateway/runtime-config-control"; +import { shouldRestartGatewayForRuntimeConfigChange } from "@ccr/core/gateway/runtime-change"; import { RouteScriptRuntime } from "@ccr/core/routing/route-script-runtime"; import { buildRouteScriptInput } from "@ccr/core/routing/route-script-context"; import { compileRouterConfig } from "@ccr/core/routing/config-compiler"; @@ -61,6 +64,10 @@ class GatewayService { getBrowserAutomationMcpIntegration: () => this.browserAutomationMcpIntegration, getConfig: () => this.config, getPlugin: () => this.plugin, + getRuntimeConfigControlStatus: () => ({ + ...(this.runtimeConfigReloadError ? { lastError: this.runtimeConfigReloadError } : {}), + revision: gatewayRuntimeConfigRevision(this.config) + }), getStatus: () => ({ coreEndpoint: this.status.coreEndpoint, coreManagedExternally: this.status.coreManagedExternally, @@ -70,6 +77,9 @@ class GatewayService { handleRawTraceSync: (request, response) => this.rawTraceSynchronizer.handle(request, response), handleBillingUsageSync: (request, response) => this.billingSynchronizer.handle(request, response), proxyRequest: (request, response, path, apiKey) => this.proxyRequest(request, response, path, apiKey), + requestRuntimeConfigReload: (expectedRevision, forceRestart) => { + this.schedulePersistedRuntimeConfigReload(expectedRevision, forceRestart); + }, replayContextArchive: (input) => this.requestPipeline.replayContextArchive(input) }); @@ -90,11 +100,14 @@ class GatewayService { private child?: ChildProcess; private config?: AppConfig; private coreAuthToken = ""; + private externalGatewayApiKey?: string; private plugin?: ClaudeCodeRouterPlugin; private readonly rawTraceSynchronizer = new RawTraceSynchronizer({ getConfig: () => this.config }); private readonly routeScriptRuntime = new RouteScriptRuntime(); + private runtimeConfigReloadError?: string; + private runtimeConfigReloadQueue: Promise = Promise.resolve(); private server?: Server; private status: GatewayStatus = { coreEndpoint: "", @@ -216,6 +229,7 @@ class GatewayService { pid: this.child?.pid, state: "running" }; + this.runtimeConfigReloadError = undefined; return this.status; } catch (error) { await this.stop(); @@ -235,7 +249,12 @@ class GatewayService { if (currentStatus.gatewayManagedExternally) { const existingGateway = await probeExistingCcrGateway(config); if (existingGateway.state === "usable") { - this.markExternalGatewayRunning(config, existingGateway.endpoint); + this.markExternalGatewayRunning(config, existingGateway.endpoint, existingGateway.apiKey); + try { + await this.reloadExternalGatewayConfig(config, false); + } catch { + return this.getStatus(); + } return this.getStatus(); } } @@ -255,14 +274,28 @@ class GatewayService { return status; } - this.markExternalGatewayRunning(config, existingGateway.endpoint); + this.markExternalGatewayRunning(config, existingGateway.endpoint, existingGateway.apiKey); + try { + await this.reloadExternalGatewayConfig(config, false); + } catch { + return this.getStatus(); + } return this.getStatus(); } + async restart(config: AppConfig): Promise { + if (this.status.gatewayManagedExternally) { + await this.reloadExternalGatewayConfig(config, true); + return this.getStatus(); + } + return this.start(config); + } + async stop(options: GatewayStopOptions = {}): Promise { const child = this.child; this.child = undefined; this.coreAuthToken = ""; + this.externalGatewayApiKey = undefined; if (child && !child.killed) { child.kill(); } @@ -311,7 +344,7 @@ class GatewayService { async updateConfig(config: AppConfig): Promise { assertLoopbackCoreHost(config.gateway.coreHost); if (this.status.gatewayManagedExternally) { - this.markExternalGatewayRunning(config, endpoint(config.gateway.host, config.gateway.port)); + await this.reloadExternalGatewayConfig(config, false); return; } @@ -452,11 +485,65 @@ class GatewayService { return this.requestPipeline.proxyRequest(request, response, path, apiKey); } - private markExternalGatewayRunning(config: AppConfig, externalEndpoint: string): void { + private async reloadExternalGatewayConfig(config: AppConfig, forceRestart: boolean): Promise { + const currentEndpoint = this.status.endpoint || endpoint(config.gateway.host, config.gateway.port); + try { + const externalGateway = await reloadExistingCcrGatewayConfig( + currentEndpoint, + config, + this.externalGatewayApiKey, + { forceRestart } + ); + this.markExternalGatewayRunning(config, externalGateway.endpoint, externalGateway.apiKey); + } catch (error) { + const message = `Failed to update the externally managed CCR gateway: ${formatError(error)}`; + this.status = { + ...this.status, + lastError: message, + state: "error" + }; + throw new Error(message, { cause: error }); + } + } + + private schedulePersistedRuntimeConfigReload(expectedRevision: string, forceRestart: boolean): void { + this.runtimeConfigReloadError = undefined; + this.runtimeConfigReloadQueue = this.runtimeConfigReloadQueue.then( + () => this.reloadPersistedRuntimeConfig(expectedRevision, forceRestart), + () => this.reloadPersistedRuntimeConfig(expectedRevision, forceRestart) + ); + } + + private async reloadPersistedRuntimeConfig(expectedRevision: string, forceRestart: boolean): Promise { + try { + const nextConfig = await loadAppConfig(); + const actualRevision = gatewayRuntimeConfigRevision(nextConfig); + if (actualRevision !== expectedRevision) { + throw new Error(`Persisted configuration revision ${actualRevision || "(missing)"} does not match the requested revision ${expectedRevision}.`); + } + const restartRequired = forceRestart || !this.config || + shouldRestartGatewayForRuntimeConfigChange(this.config, nextConfig); + if (restartRequired) { + const status = await this.start(nextConfig); + if (status.state === "error") { + throw new Error(status.lastError || "CCR gateway failed to restart with the updated configuration."); + } + } else { + await this.updateConfig(nextConfig); + } + this.runtimeConfigReloadError = undefined; + } catch (error) { + this.runtimeConfigReloadError = formatError(error); + console.error(`[gateway] Failed to reload persisted runtime configuration: ${this.runtimeConfigReloadError}`); + } + } + + private markExternalGatewayRunning(config: AppConfig, externalEndpoint: string, apiKey?: string): void { this.config = config; this.child = undefined; this.server = undefined; this.coreAuthToken = ""; + this.externalGatewayApiKey = apiKey; this.status = { coreEndpoint: endpoint(config.gateway.coreHost, config.gateway.corePort), endpoint: externalEndpoint, diff --git a/packages/core/src/gateway/core-runtime/supervisor.ts b/packages/core/src/gateway/core-runtime/supervisor.ts index 3cb63e26..ba249a03 100644 --- a/packages/core/src/gateway/core-runtime/supervisor.ts +++ b/packages/core/src/gateway/core-runtime/supervisor.ts @@ -522,7 +522,15 @@ function mergeNoProxy(current: string | undefined, values: string[]): string { } export function endpoint(host: string, port: number): string { - const endpointHost = host === "0.0.0.0" ? "127.0.0.1" : host; + const unwrappedHost = host.startsWith("[") && host.endsWith("]") + ? host.slice(1, -1) + : host; + const connectHost = !unwrappedHost || unwrappedHost === "0.0.0.0" + ? "127.0.0.1" + : unwrappedHost === "::" + ? "::1" + : unwrappedHost; + const endpointHost = connectHost.includes(":") ? `[${connectHost}]` : connectHost; return `http://${endpointHost}:${port}`; } diff --git a/packages/core/src/gateway/existing-gateway-probe.ts b/packages/core/src/gateway/existing-gateway-probe.ts index 216ce083..e31e12cd 100644 --- a/packages/core/src/gateway/existing-gateway-probe.ts +++ b/packages/core/src/gateway/existing-gateway-probe.ts @@ -1,4 +1,6 @@ import type { ApiKeyConfig, AppConfig } from "@ccr/core/contracts/app"; +import { endpoint as gatewayEndpoint } from "@ccr/core/gateway/core-runtime/supervisor"; +import { gatewayRuntimeConfigControlPath, gatewayRuntimeConfigRevision } from "@ccr/core/gateway/runtime-config-control"; export type ExistingCcrGatewayProbe = | { endpoint: string; reason?: string; state: "unavailable" } @@ -14,6 +16,7 @@ type ExistingGatewayHttpProbe = { }; const existingGatewayFetchAttempts = 3; +const runtimeConfigReloadTimeoutMs = 20_000; export async function probeExistingCcrGateway( config: Pick @@ -71,7 +74,78 @@ export async function probeExistingCcrGateway( export function publicGatewayEndpoint(config: Pick): string { const host = probeGatewayHost(config.gateway.host); - return `http://${formatEndpointHost(host)}:${config.gateway.port}/`; + return gatewayEndpoint(host, config.gateway.port); +} + +export async function reloadExistingCcrGatewayConfig( + currentEndpoint: string, + config: AppConfig, + currentApiKey: string | undefined, + options: { forceRestart?: boolean } = {} +): Promise<{ apiKey: string; endpoint: string }> { + const configRevision = gatewayRuntimeConfigRevision(config); + if (!configRevision) { + throw new Error("Cannot determine the saved CCR configuration revision."); + } + const currentKey = currentApiKey?.trim(); + if (!currentKey) { + throw new Error("The API key accepted by the externally managed CCR gateway is unavailable."); + } + + if (options.forceRestart !== true) { + const currentStatus = await fetchExistingGateway(currentEndpoint, gatewayRuntimeConfigControlPath, { + headers: { authorization: `Bearer ${currentKey}` } + }, 1, 700); + if (currentStatus.status === 200 && isRecord(currentStatus.payload) && + currentStatus.payload.revision === configRevision) { + return { apiKey: currentKey, endpoint: publicGatewayEndpoint(config) }; + } + } + + const submission = await fetchExistingGateway(currentEndpoint, gatewayRuntimeConfigControlPath, { + body: JSON.stringify({ + configRevision, + forceRestart: options.forceRestart === true + }), + headers: { + authorization: `Bearer ${currentKey}`, + "content-type": "application/json" + }, + method: "POST" + }); + if (submission.status === 404) { + throw new Error("The running CCR gateway does not support runtime configuration reloads. Restart that gateway process once, then try again."); + } + if (submission.status !== 200 && submission.status !== 202) { + const detail = readGatewayErrorMessage(submission.payload) || submission.reason || + `HTTP ${submission.status ?? 0}`; + throw new Error(`The running CCR gateway rejected the configuration reload request: ${detail}`); + } + + const expectedEndpoint = publicGatewayEndpoint(config); + const apiKeys = externalGatewayReloadApiKeys(config, currentKey); + const deadline = Date.now() + runtimeConfigReloadTimeoutMs; + let lastReason: string | undefined; + while (Date.now() < deadline) { + for (const apiKey of apiKeys) { + const status = await fetchExistingGateway(expectedEndpoint, gatewayRuntimeConfigControlPath, { + headers: { authorization: `Bearer ${apiKey}` } + }, 1, 700); + if (status.status === 200 && isRecord(status.payload)) { + if (status.payload.revision === configRevision) { + return { apiKey, endpoint: expectedEndpoint }; + } + if (typeof status.payload.lastError === "string" && status.payload.lastError.trim()) { + throw new Error(`The running CCR gateway could not apply the saved configuration: ${status.payload.lastError}`); + } + } + if (status.status !== 401 && status.status !== 403) { + lastReason = status.reason || (status.status ? `HTTP ${status.status}` : lastReason); + } + } + await wait(100); + } + throw new Error(`Timed out waiting for the running CCR gateway to load configuration revision ${configRevision}${lastReason ? ` (${lastReason})` : ""}.`); } export function isAddressInUseMessage(message: string | undefined): boolean { @@ -81,12 +155,14 @@ export function isAddressInUseMessage(message: string | undefined): boolean { async function fetchExistingGateway( endpoint: string, pathname: string, - init: RequestInit = {} + init: RequestInit = {}, + attempts = existingGatewayFetchAttempts, + timeoutMs = 1200 ): Promise { let reason: string | undefined; - for (let attempt = 0; attempt < existingGatewayFetchAttempts; attempt += 1) { + for (let attempt = 0; attempt < attempts; attempt += 1) { const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 1200); + const timeout = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(new URL(pathname, endpoint).toString(), { ...init, @@ -105,6 +181,17 @@ async function fetchExistingGateway( return { reason }; } +function externalGatewayReloadApiKeys( + config: Pick, + currentApiKey: string +): string[] { + const apiKeys = existingGatewayApiKeyCandidates(config).map((candidate) => candidate.key); + if (!apiKeys.includes(currentApiKey)) { + apiKeys.push(currentApiKey); + } + return apiKeys; +} + function existingGatewayApiKeyCandidates(config: Pick): ApiKeyConfig[] { const candidates = [ ...(Array.isArray(config.APIKEYS) ? config.APIKEYS : []), @@ -175,10 +262,6 @@ function probeGatewayHost(host: string): string { return host; } -function formatEndpointHost(host: string): string { - return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; -} - function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -186,3 +269,7 @@ function isRecord(value: unknown): value is Record { function formatError(error: unknown): string { return error instanceof Error ? error.message : String(error); } + +async function wait(milliseconds: number): Promise { + await new Promise((resolve) => setTimeout(resolve, milliseconds)); +} diff --git a/packages/core/src/gateway/http/request-handler.ts b/packages/core/src/gateway/http/request-handler.ts index 37010875..9e71a579 100644 --- a/packages/core/src/gateway/http/request-handler.ts +++ b/packages/core/src/gateway/http/request-handler.ts @@ -14,6 +14,7 @@ import { type ContextArchiveReplayExecutor } from "@ccr/core/gateway/context-archive"; import { ccrRemoteControlPathPrefix, ccrRemoteControlService } from "@ccr/core/gateway/remote-control-service"; +import { gatewayRuntimeConfigControlPath } from "@ccr/core/gateway/runtime-config-control"; import { authorize, claudeCodeWifTokenPath, handleClaudeCodeWifTokenRequest, reserveApiKeyLimits } from "@ccr/core/gateway/auth/api-key-authorizer"; import { parseJsonObject, readRequestBody, sendJson } from "@ccr/core/gateway/http/io"; import { shouldRecordRequestLogs } from "@ccr/core/observability/raw-trace-sync"; @@ -27,10 +28,12 @@ export type GatewayHttpRequestHandlerDependencies = { getBrowserAutomationMcpIntegration: () => BrowserAutomationMcpIntegration | undefined; getConfig: () => AppConfig | undefined; getPlugin: () => ClaudeCodeRouterPlugin | undefined; + getRuntimeConfigControlStatus: () => { lastError?: string; revision?: string }; getStatus: () => { coreEndpoint: string; coreManagedExternally?: boolean; endpoint: string; state: string }; handleBillingUsageSync: (request: IncomingMessage, response: ServerResponse) => Promise; handleRawTraceSync: (request: IncomingMessage, response: ServerResponse) => Promise; proxyRequest: (request: IncomingMessage, response: ServerResponse, path: string, apiKey?: ApiKeyConfig) => Promise; + requestRuntimeConfigReload: (expectedRevision: string, forceRestart: boolean) => void; replayContextArchive: ContextArchiveReplayExecutor; }; @@ -40,10 +43,12 @@ export class GatewayHttpRequestHandler { private get browserAutomationMcpIntegration() { return this.dependencies.getBrowserAutomationMcpIntegration(); } private get config() { return this.dependencies.getConfig(); } private get plugin() { return this.dependencies.getPlugin(); } + private get runtimeConfigControlStatus() { return this.dependencies.getRuntimeConfigControlStatus(); } private get status() { return this.dependencies.getStatus(); } private handleBillingUsageSync(request: IncomingMessage, response: ServerResponse) { return this.dependencies.handleBillingUsageSync(request, response); } private handleRawTraceSync(request: IncomingMessage, response: ServerResponse) { return this.dependencies.handleRawTraceSync(request, response); } private proxyRequest(request: IncomingMessage, response: ServerResponse, path: string, apiKey?: ApiKeyConfig) { return this.dependencies.proxyRequest(request, response, path, apiKey); } + private requestRuntimeConfigReload(expectedRevision: string, forceRestart: boolean) { return this.dependencies.requestRuntimeConfigReload(expectedRevision, forceRestart); } private replayContextArchive(input: Parameters[0]) { return this.dependencies.replayContextArchive(input); } async handleRequest(request: IncomingMessage, response: ServerResponse): Promise { @@ -75,6 +80,46 @@ export class GatewayHttpRequestHandler { return; } + if (path === gatewayRuntimeConfigControlPath) { + const authorization = await authorize(request, response, this.config); + if (!authorization.ok) { + return; + } + if (!this.config.APIKEY || authorization.apiKey?.key !== this.config.APIKEY) { + sendJson(response, 403, { error: { message: "The primary CCR API key is required for runtime configuration control." } }); + return; + } + if (request.method === "GET") { + sendJson(response, 200, { + ...this.runtimeConfigControlStatus, + state: this.status.state + }); + return; + } + if (request.method !== "POST") { + sendJson(response, 405, { error: { message: "Method not allowed." } }); + return; + } + const body = parseJsonObject(await readRequestBody(request)); + const expectedRevision = typeof body.configRevision === "string" + ? body.configRevision.trim() + : ""; + if (!/^[a-f0-9]{64}$/i.test(expectedRevision)) { + sendJson(response, 400, { error: { message: "A valid configRevision is required." } }); + return; + } + const forceRestart = body.forceRestart === true; + response.once("finish", () => { + this.requestRuntimeConfigReload(expectedRevision, forceRestart); + }); + sendJson(response, 202, { + accepted: true, + configRevision: expectedRevision, + restarting: forceRestart + }); + return; + } + if (path === ccrRemoteControlPathPrefix || path.startsWith(`${ccrRemoteControlPathPrefix}/`)) { const authorization = await authorize(request, response, this.config); if (!authorization.ok) { diff --git a/packages/core/src/gateway/runtime-config-control.ts b/packages/core/src/gateway/runtime-config-control.ts new file mode 100644 index 00000000..8cafdcec --- /dev/null +++ b/packages/core/src/gateway/runtime-config-control.ts @@ -0,0 +1,28 @@ +import { createHash } from "node:crypto"; +import type { AppConfig } from "@ccr/core/contracts/app"; + +export const gatewayRuntimeConfigControlPath = "/__ccr/runtime/config"; + +export function gatewayRuntimeConfigRevision(config: AppConfig | undefined): string | undefined { + if (!config) { + return undefined; + } + return createHash("sha256") + .update(stableJson(config)) + .digest("hex"); +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(stableJson).join(",")}]`; + } + if (value && typeof value === "object") { + const record = value as Record; + return `{${Object.keys(record) + .sort() + .filter((key) => record[key] !== undefined) + .map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`) + .join(",")}}`; + } + return JSON.stringify(value) ?? "null"; +} diff --git a/packages/core/src/profiles/launch-service.ts b/packages/core/src/profiles/launch-service.ts index 565a4cfc..24d631ac 100644 --- a/packages/core/src/profiles/launch-service.ts +++ b/packages/core/src/profiles/launch-service.ts @@ -14,6 +14,7 @@ import { findRunningOpenCodeAppPid, launchOpenCodeAppProfile, openCodeAppLaunchS import { writeOpenCodeGatewayConfig } from "@ccr/core/agents/opencode/profile-config"; import { codexCliMiddlewareRuntimeScript } from "@ccr/core/agents/codex/cli-middleware-runtime"; import { CONFIGDIR } from "@ccr/core/config/constants"; +import { endpoint } from "@ccr/core/gateway/core-runtime/supervisor"; import { gatewayService } from "@ccr/core/gateway/service"; import { TOOL_HUB_MCP_RUNTIME_FILE_NAME, bundledToolHubMcpEntryPathCandidates } from "@ccr/core/mcp/toolhub-config"; import { mediaToolsGatewayEndpoint } from "@ccr/core/mcp/grok-media-config"; @@ -737,7 +738,7 @@ function isAddressInUseError(message: string | undefined): boolean { function profileGatewayEndpoint(config: AppConfig): string { const host = probeGatewayHost(config.gateway.host); - return `http://${formatEndpointHost(host)}:${config.gateway.port}/`; + return endpoint(host, config.gateway.port); } function probeGatewayHost(host: string): string { @@ -750,10 +751,6 @@ function probeGatewayHost(host: string): string { return host; } -function formatEndpointHost(host: string): string { - return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; -} - function profileGatewayConfigFor(config: AppConfig, profile: ReturnType): AppConfig { const token = findProfileApiKey(config, profile); if (!token) { diff --git a/packages/core/src/web/management-server.ts b/packages/core/src/web/management-server.ts index d91a42c8..b5a1094f 100644 --- a/packages/core/src/web/management-server.ts +++ b/packages/core/src/web/management-server.ts @@ -265,7 +265,17 @@ const rpcHandlers: Record = { const synced = await syncClaudeAppGatewayConfig(baseConfig); const savedConfig = synced.config; let runtimeStatus = gatewayService.getStatus(); - if (synced.configChanged || shouldRestartGatewayForRuntimeConfigChange(previousConfig, savedConfig) || runtimeStatus.state !== "running") { + const restartRequired = synced.configChanged || + shouldRestartGatewayForRuntimeConfigChange(previousConfig, savedConfig) || + runtimeStatus.state !== "running"; + if (runtimeStatus.gatewayManagedExternally) { + if (restartRequired) { + runtimeStatus = await gatewayService.restart(savedConfig); + } else { + await gatewayService.updateConfig(savedConfig); + runtimeStatus = gatewayService.getStatus(); + } + } else if (restartRequired) { runtimeStatus = await gatewayService.start(savedConfig); } else { await gatewayService.updateConfig(savedConfig); @@ -382,14 +392,14 @@ const rpcHandlers: Record = { restartGateway: async () => { const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig()); const config = syncedClaudeAppConfig.config; - const status = await gatewayService.start(config); + const status = await gatewayService.restart(config); await applyProfileIfServiceRunning(config, status); return status; }, restartProxy: async () => { const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig()); const config = syncedClaudeAppConfig.config; - const status = await gatewayService.start(config); + const status = await gatewayService.restart(config); await applyProfileIfServiceRunning(config, status); return proxyService.getStatus(); }, @@ -420,7 +430,16 @@ const rpcHandlers: Record = { const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(savedConfig); savedConfig = syncedClaudeAppConfig.config; let runtimeStatus = gatewayService.getStatus(); - if (syncedClaudeAppConfig.configChanged || shouldRestartGatewayForRuntimeConfigChange(previousConfig, savedConfig)) { + const restartRequired = syncedClaudeAppConfig.configChanged || + shouldRestartGatewayForRuntimeConfigChange(previousConfig, savedConfig); + if (runtimeStatus.gatewayManagedExternally) { + if (restartRequired) { + runtimeStatus = await gatewayService.restart(savedConfig); + } else { + await gatewayService.updateConfig(savedConfig); + runtimeStatus = gatewayService.getStatus(); + } + } else if (restartRequired) { runtimeStatus = await gatewayService.start(savedConfig); } else { await gatewayService.updateConfig(savedConfig); diff --git a/packages/core/test/integration/gateway/profile-model-allowlist-http.test.mjs b/packages/core/test/integration/gateway/profile-model-allowlist-http.test.mjs index 41c72194..212e7b5a 100644 --- a/packages/core/test/integration/gateway/profile-model-allowlist-http.test.mjs +++ b/packages/core/test/integration/gateway/profile-model-allowlist-http.test.mjs @@ -11,6 +11,8 @@ import { waitForTcpListener } from "../../support/loopback-listener.mjs"; test("gateway HTTP model discovery and request access honor profile model allowlists", async (t) => { const config = createProfileAllowlistHttpTestConfig(); const plugin = new ClaudeCodeRouterPlugin(config); + const runtimeConfigRevision = "a".repeat(64); + const runtimeConfigReloads = []; const status = { coreEndpoint: "http://127.0.0.1:1", endpoint: "http://127.0.0.1:0", @@ -27,10 +29,14 @@ test("gateway HTTP model discovery and request access honor profile model allowl getBrowserAutomationMcpIntegration: () => undefined, getConfig: () => config, getPlugin: () => plugin, + getRuntimeConfigControlStatus: () => ({ revision: runtimeConfigRevision }), getStatus: () => status, handleBillingUsageSync: unsupportedHandler, handleRawTraceSync: unsupportedHandler, proxyRequest: (request, response, path, apiKey) => pipeline.proxyRequest(request, response, path, apiKey), + requestRuntimeConfigReload: (expectedRevision, forceRestart) => { + runtimeConfigReloads.push({ expectedRevision, forceRestart }); + }, replayContextArchive: async () => ({ ok: false, statusCode: 404, message: "not configured" }) }); const server = createServer((request, response) => { @@ -56,6 +62,29 @@ test("gateway HTTP model discovery and request access honor profile model allowl const endpoint = `http://127.0.0.1:${serverPort(server)}`; status.endpoint = endpoint; + const runtimeConfigStatus = await fetchJson(`${endpoint}/__ccr/runtime/config`, { + headers: authHeaders("gateway-key") + }); + assert.equal(runtimeConfigStatus.revision, runtimeConfigRevision); + assert.equal(runtimeConfigStatus.state, "running"); + + const deniedRuntimeConfigStatus = await fetch(`${endpoint}/__ccr/runtime/config`, { + headers: authHeaders("profile-alpha-key") + }); + assert.equal(deniedRuntimeConfigStatus.status, 403); + + const runtimeConfigReload = await fetch(`${endpoint}/__ccr/runtime/config`, { + body: JSON.stringify({ configRevision: runtimeConfigRevision, forceRestart: true }), + headers: { + ...authHeaders("gateway-key"), + "content-type": "application/json" + }, + method: "POST" + }); + assert.equal(runtimeConfigReload.status, 202, await runtimeConfigReload.text()); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(runtimeConfigReloads, [{ expectedRevision: runtimeConfigRevision, forceRestart: true }]); + const alphaModels = await fetchJson(`${endpoint}/v1/models`, { headers: authHeaders("profile-alpha-key") }); diff --git a/packages/core/test/unit/gateway/gateway-status.test.mjs b/packages/core/test/unit/gateway/gateway-status.test.mjs index f3cec500..6f485130 100644 --- a/packages/core/test/unit/gateway/gateway-status.test.mjs +++ b/packages/core/test/unit/gateway/gateway-status.test.mjs @@ -11,8 +11,16 @@ import { replacePersistedRuntimeState } from "@ccr/core/config/config-repository.ts"; import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts"; +import { endpoint } from "@ccr/core/gateway/core-runtime/supervisor.ts"; import { gatewayService } from "@ccr/core/gateway/service.ts"; +test("gateway endpoints normalize wildcard and IPv6 hosts", () => { + assert.equal(endpoint("0.0.0.0", 3456), "http://127.0.0.1:3456"); + assert.equal(endpoint("::", 3456), "http://[::1]:3456"); + assert.equal(endpoint("::1", 3456), "http://[::1]:3456"); + assert.equal(endpoint("[::1]", 3456), "http://[::1]:3456"); +}); + test("gateway start persists preflight validation failures for status polling", async () => { await gatewayService.stop(); diff --git a/packages/core/test/unit/profiles/profile-gateway-probe.test.mjs b/packages/core/test/unit/profiles/profile-gateway-probe.test.mjs index 0d178102..e9346fe4 100644 --- a/packages/core/test/unit/profiles/profile-gateway-probe.test.mjs +++ b/packages/core/test/unit/profiles/profile-gateway-probe.test.mjs @@ -1,13 +1,16 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { CLAUDE_CODE_AUTH_MODE_ENV } from "@ccr/core/agents/claude-code/auth-mode.ts"; import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts"; import { ensureProfileGateway } from "@ccr/core/profiles/launch-service.ts"; -function claudeProfileConfig() { +function claudeProfileConfig(authMode = "api-key-helper") { const profile = { agent: "claude-code", enabled: true, - env: {}, + env: { + [CLAUDE_CODE_AUTH_MODE_ENV]: authMode + }, id: "claude-main", model: "Provider/model", name: "Claude Main", @@ -99,6 +102,47 @@ test("existing profile gateway falls back to the root identity response", async } }); +test("existing profile gateway rejects WIF profiles when the root identity lacks the token endpoint", async () => { + const previousFetch = globalThis.fetch; + const paths = []; + globalThis.fetch = async (input) => { + const url = new URL(String(input)); + paths.push(url.pathname); + if (url.pathname === "/health") { + return Response.json({ + core: "http://127.0.0.1:3467", + status: "running", + timestamp: "2026-01-01T00:00:00.000Z" + }); + } + if (url.pathname === "/v1/models") { + return Response.json({ data: [], object: "list" }); + } + if (url.pathname === "/") { + return Response.json({ + endpoints: ["GET /v1/models"], + name: "claude-code-router" + }); + } + throw new Error(`Unexpected gateway probe: ${url.pathname}`); + }; + + try { + const { config, profile } = claudeProfileConfig("wif"); + await assert.rejects( + ensureProfileGateway(config, profile, "Local subscription router", { + reuseExisting: true, + startIfMissing: false + }), + /does not advertise the Claude Code WIF token endpoint/ + ); + + assert.deepEqual(paths, ["/health", "/v1/models", "/"]); + } finally { + globalThis.fetch = previousFetch; + } +}); + test("existing profile gateway retries a transient transport failure", async () => { const previousFetch = globalThis.fetch; const paths = []; diff --git a/packages/core/test/unit/web/web-management-server.test.mjs b/packages/core/test/unit/web/web-management-server.test.mjs index cae58feb..3c60f8ca 100644 --- a/packages/core/test/unit/web/web-management-server.test.mjs +++ b/packages/core/test/unit/web/web-management-server.test.mjs @@ -69,6 +69,7 @@ test("web RPC ignores Origin and Referer when the auth token is valid", async () test("startGateway reuses an already healthy CCR gateway on the configured port", async () => { const { saveAppConfig } = await import("@ccr/core/config/config.ts"); const { createDefaultAppConfig } = await import("@ccr/core/config/default-config.ts"); + const { gatewayRuntimeConfigRevision } = await import("@ccr/core/gateway/runtime-config-control.ts"); const { gatewayService } = await import("@ccr/core/gateway/service.ts"); const { startWebManagementServer } = await import("@ccr/core/web/management-server.ts"); await gatewayService.stop(); @@ -86,7 +87,11 @@ test("startGateway reuses an already healthy CCR gateway on the configured port" type: "openai_chat_completions" }]; const savedConfig = await saveAppConfig(config); - const externalGateway = createHealthyCcrGateway(savedConfig.APIKEY); + const externalGatewayState = { + reloadRequests: [], + revision: gatewayRuntimeConfigRevision(savedConfig) + }; + const externalGateway = createHealthyCcrGateway(savedConfig.APIKEY, externalGatewayState); await listen(externalGateway, gatewayPort); const runtime = await startWebManagementServer({ authToken: webAuthToken, @@ -100,10 +105,25 @@ test("startGateway reuses an already healthy CCR gateway on the configured port" assert.equal(payload.ok, true); assert.equal(payload.value.state, "running", payload.value.lastError); - assert.equal(payload.value.endpoint, `http://127.0.0.1:${gatewayPort}/`); + assert.equal(payload.value.endpoint, `http://127.0.0.1:${gatewayPort}`); assert.equal(payload.value.gatewayManagedExternally, true); assert.equal(gatewayService.getStatus().state, "running"); assert.equal(gatewayService.getStatus().gatewayManagedExternally, true); + + const secondStart = await rpc(runtime.url, webAuthToken, "startGateway"); + assert.equal(secondStart.ok, true); + assert.equal(secondStart.value.endpoint, `http://127.0.0.1:${gatewayPort}`); + assert.equal(secondStart.value.gatewayManagedExternally, true); + + const nextConfig = structuredClone(savedConfig); + nextConfig.Providers[0].name = "Updated Test Provider"; + const reloadCountBeforeSave = externalGatewayState.reloadRequests.length; + const saveResult = await rpc(runtime.url, webAuthToken, "saveConfig", [nextConfig, { applyProfile: false }]); + assert.equal(saveResult.ok, true); + assert.equal(externalGatewayState.reloadRequests.length, reloadCountBeforeSave + 1); + assert.equal(externalGatewayState.reloadRequests.at(-1).forceRestart, true); + assert.equal(externalGatewayState.revision, gatewayRuntimeConfigRevision(saveResult.value)); + assert.equal(gatewayService.getStatus().gatewayManagedExternally, true); } finally { await runtime.close(); await gatewayService.stop(); @@ -124,7 +144,7 @@ async function rpc(baseUrl, authToken, method, args = []) { return response.json(); } -function createHealthyCcrGateway(apiKey) { +function createHealthyCcrGateway(apiKey, state) { return createServer((request, response) => { const url = new URL(request.url || "/", "http://127.0.0.1"); if (url.pathname === "/health") { @@ -152,10 +172,40 @@ function createHealthyCcrGateway(apiKey) { sendJson(response, 200, { data: [{ id: "test-model", object: "model" }], object: "list" }); return; } + if (url.pathname === "/__ccr/runtime/config") { + if (request.headers.authorization !== `Bearer ${apiKey}`) { + sendJson(response, 401, { error: { message: "Invalid API key." } }); + return; + } + if (request.method === "GET") { + sendJson(response, 200, { revision: state.revision, state: "running" }); + return; + } + if (request.method === "POST") { + void readJsonRequest(request).then((body) => { + state.reloadRequests.push(body); + state.revision = body.configRevision; + sendJson(response, 202, { + accepted: true, + configRevision: body.configRevision, + restarting: body.forceRestart === true + }); + }); + return; + } + } sendJson(response, 404, { error: { message: "Not found." } }); }); } +async function readJsonRequest(request) { + const chunks = []; + for await (const chunk of request) { + chunks.push(Buffer.from(chunk)); + } + return JSON.parse(Buffer.concat(chunks).toString("utf8")); +} + function sendJson(response, statusCode, payload) { response.writeHead(statusCode, { "content-type": "application/json" }); response.end(JSON.stringify(payload)); diff --git a/packages/electron/src/main/ipc.ts b/packages/electron/src/main/ipc.ts index 2e1df237..0b2e6abe 100644 --- a/packages/electron/src/main/ipc.ts +++ b/packages/electron/src/main/ipc.ts @@ -220,8 +220,18 @@ ipcMain.handle(IPC_CHANNELS.appApplyClaudeAppGateway, async (_event, config?: Ap const synced = await syncClaudeAppGatewayConfig(baseConfig); const savedConfig = synced.config; let runtimeStatus = gatewayService.getStatus(); + const restartRequired = synced.configChanged || + shouldRestartGatewayForRuntimeConfigChange(previousConfig, savedConfig) || + runtimeStatus.state !== "running"; - if (synced.configChanged || shouldRestartGatewayForRuntimeConfigChange(previousConfig, savedConfig) || runtimeStatus.state !== "running") { + if (runtimeStatus.gatewayManagedExternally) { + if (restartRequired) { + runtimeStatus = await gatewayService.restart(savedConfig); + } else { + await gatewayService.updateConfig(savedConfig); + runtimeStatus = gatewayService.getStatus(); + } + } else if (restartRequired) { runtimeStatus = await gatewayService.start(savedConfig); } else { await gatewayService.updateConfig(savedConfig); @@ -323,7 +333,16 @@ ipcMain.handle(IPC_CHANNELS.appSaveConfig, async (_event, config: AppConfig, opt const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(savedConfig); savedConfig = syncedClaudeAppConfig.config; let runtimeStatus = gatewayService.getStatus(); - if (syncedClaudeAppConfig.configChanged || shouldRestartGatewayForRuntimeConfigChange(previousConfig, savedConfig)) { + const restartRequired = syncedClaudeAppConfig.configChanged || + shouldRestartGatewayForRuntimeConfigChange(previousConfig, savedConfig); + if (runtimeStatus.gatewayManagedExternally) { + if (restartRequired) { + runtimeStatus = await gatewayService.restart(savedConfig); + } else { + await gatewayService.updateConfig(savedConfig); + runtimeStatus = gatewayService.getStatus(); + } + } else if (restartRequired) { runtimeStatus = await gatewayService.start(savedConfig); } else { await gatewayService.updateConfig(savedConfig); @@ -360,7 +379,7 @@ ipcMain.handle(IPC_CHANNELS.appSetOnboardingFinished, async () => { ipcMain.handle(IPC_CHANNELS.appRestartGateway, async () => { const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig()); const config = syncedClaudeAppConfig.config; - const status = await gatewayService.start(config); + const status = await gatewayService.restart(config); await applyProfileIfServiceRunning(config, status); await builtInBrowserService.syncProxy(config); return status; @@ -391,7 +410,7 @@ ipcMain.handle(IPC_CHANNELS.appShowMainWindow, () => { ipcMain.handle(IPC_CHANNELS.appRestartProxy, async () => { const syncedClaudeAppConfig = await syncClaudeAppGatewayConfig(await loadAppConfig()); const config = syncedClaudeAppConfig.config; - const status = await gatewayService.start(config); + const status = await gatewayService.restart(config); await applyProfileIfServiceRunning(config, status); await builtInBrowserService.syncProxy(config); return proxyService.getStatus(); diff --git a/packages/ui/src/pages/home/components/profiles.tsx b/packages/ui/src/pages/home/components/profiles.tsx index 0a6727c3..6d07890e 100644 --- a/packages/ui/src/pages/home/components/profiles.tsx +++ b/packages/ui/src/pages/home/components/profiles.tsx @@ -997,7 +997,7 @@ export function AddProfileForm({ /> {validation.allowedModels ? {t(validation.allowedModels)} : null} - {draft.agent === "codex" && draft.agent !== "workbuddy" ? ( + {draft.agent === "codex" ? (
    {t("Show all sessions")} onChange({ showAllSessions })} /> From 56d7b37c64fef5f5518d8cc5a1b133ed57b80679 Mon Sep 17 00:00:00 2001 From: musi Date: Tue, 11 Aug 2026 12:36:44 +0800 Subject: [PATCH 09/18] Support multiple WorkBuddy models and generic app paths --- packages/core/src/agents/codex/app-launch.ts | 40 ++++++++---- .../agents/codex-app-model-catalog.test.mjs | 63 +++++++++++++++++++ .../ui/src/pages/home/components/profiles.tsx | 15 +---- packages/ui/src/pages/home/shared/i18n.tsx | 2 + packages/ui/src/pages/home/shared/profiles.ts | 2 +- packages/ui/test/component/profiles.test.tsx | 24 ++++++- 6 files changed, 117 insertions(+), 29 deletions(-) diff --git a/packages/core/src/agents/codex/app-launch.ts b/packages/core/src/agents/codex/app-launch.ts index 4fd80e6b..26c9bf21 100644 --- a/packages/core/src/agents/codex/app-launch.ts +++ b/packages/core/src/agents/codex/app-launch.ts @@ -315,7 +315,7 @@ export function writeCodexCompatibleAppModelCatalog( export function writeWorkbuddyModelsConfig( workbuddyConfigDir: string, - profile: Pick, + profile: ProfileConfig, config?: AppConfig ): WorkbuddyModelsConfigWriteResult { const file = path.join(workbuddyConfigDir, "models.json"); @@ -341,21 +341,38 @@ function workbuddySelectedModel( } function workbuddyModelsConfig( - model: string, - profile: Pick, + defaultModel: string, + profile: ProfileConfig, + config?: AppConfig +): Record { + const allowedModels = profileAllowedModels(profile); + const catalogItems = codexCompatibleAppModelCatalog(config, defaultModel, "workbuddy", allowedModels).models; + const models = catalogItems.map((catalogItem) => + workbuddyModelConfig(catalogItem.slug || catalogItem.id || catalogItem.model, defaultModel, profile, catalogItem, config) + ); + if (models.length === 0) { + models.push(workbuddyModelConfig(defaultModel, defaultModel, profile, undefined, config)); + } + return { + availableModels: models.map((item) => String(item.id || "")), + models + }; +} + +function workbuddyModelConfig( + model: string, + defaultModel: string, + profile: Pick, + catalogItem: CodexModelCatalogItem | undefined, config?: AppConfig ): Record { - const catalogItem = codexCompatibleAppModelCatalog(config, model, "workbuddy") - .models.find((item) => item.slug === model || item.id === model || item.model === model); const vendor = workbuddyModelVendor(model, profile.providerName); - const displayName = profile.name?.trim() - ? `${profile.name.trim()} / ${model}` - : `${vendor} / ${model}`; + const displayName = model.includes("/") ? model : `${vendor} / ${model}`; const workbuddyModel: Record = { apiKey: "${CCR_PROFILE_API_KEY}", disabled: false, id: model, - isDefault: true, + isDefault: model === defaultModel, name: displayName, supportsImages: Boolean(catalogItem?.supports_image_detail_original), supportsReasoning: Boolean(catalogItem?.supports_reasoning_summaries), @@ -375,10 +392,7 @@ function workbuddyModelsConfig( supportedEfforts: reasoningEfforts }; } - return { - availableModels: [model], - models: [workbuddyModel] - }; + return workbuddyModel; } function workbuddyModelVendor(model: string, providerName?: string): string { diff --git a/packages/core/test/unit/agents/codex-app-model-catalog.test.mjs b/packages/core/test/unit/agents/codex-app-model-catalog.test.mjs index 85b256f2..3e787994 100644 --- a/packages/core/test/unit/agents/codex-app-model-catalog.test.mjs +++ b/packages/core/test/unit/agents/codex-app-model-catalog.test.mjs @@ -397,6 +397,69 @@ test("WorkBuddy AI app profile writes the virtual desktop auth session", () => { } }); +test("WorkBuddy AI app profile writes every allowed model to models.json", () => { + const configDir = mkdtempSync(path.join(os.tmpdir(), "ccr-workbuddy-app-models-")); + try { + const profile = { + agent: "workbuddy", + availableModels: ["Codex API/gpt-5-codex", "Codex API/gpt-5.1-codex"], + enabled: true, + id: "workbuddy-main", + model: "Codex API/gpt-5-codex", + name: "WorkBuddy Main", + providerId: "claude-code-router", + scope: "ccr", + surface: "app" + }; + + const result = writeCodexCompatibleAppModelCatalog(configDir, profile, { + Providers: [{ + models: ["gpt-5-codex", "gpt-5.1-codex", "gpt-4.1"], + name: "Codex API", + type: "openai_responses" + }] + }); + + const modelsConfig = JSON.parse(readFileSync(result.workbuddyModelsConfig.file, "utf8")); + assert.deepEqual(modelsConfig.availableModels, ["Codex API/gpt-5-codex", "Codex API/gpt-5.1-codex"]); + assert.deepEqual(modelsConfig.models.map((item) => item.id), ["Codex API/gpt-5-codex", "Codex API/gpt-5.1-codex"]); + assert.deepEqual(modelsConfig.models.map((item) => item.name), ["Codex API/gpt-5-codex", "Codex API/gpt-5.1-codex"]); + assert.deepEqual(modelsConfig.models.map((item) => item.isDefault), [true, false]); + } finally { + rmSync(configDir, { force: true, recursive: true }); + } +}); + +test("WorkBuddy AI app profile writes every catalog model when the allowlist is unrestricted", () => { + const configDir = mkdtempSync(path.join(os.tmpdir(), "ccr-workbuddy-app-unrestricted-models-")); + try { + const profile = { + agent: "workbuddy", + enabled: true, + id: "workbuddy-main", + model: "Codex API/gpt-5-codex", + name: "WorkBuddy Main", + providerId: "claude-code-router", + scope: "ccr", + surface: "app" + }; + + const result = writeCodexCompatibleAppModelCatalog(configDir, profile, { + Providers: [{ + models: ["gpt-5-codex", "gpt-5.1-codex", "gpt-4.1"], + name: "Codex API", + type: "openai_responses" + }] + }); + + const modelsConfig = JSON.parse(readFileSync(result.workbuddyModelsConfig.file, "utf8")); + assert.deepEqual(modelsConfig.availableModels, ["Codex API/gpt-5-codex", "Codex API/gpt-5.1-codex", "Codex API/gpt-4.1"]); + assert.deepEqual(modelsConfig.models.map((item) => item.isDefault), [true, false, false]); + } finally { + rmSync(configDir, { force: true, recursive: true }); + } +}); + function withPlatform(platform, callback) { const descriptor = Object.getOwnPropertyDescriptor(process, "platform"); Object.defineProperty(process, "platform", { diff --git a/packages/ui/src/pages/home/components/profiles.tsx b/packages/ui/src/pages/home/components/profiles.tsx index 6d07890e..f6e3aede 100644 --- a/packages/ui/src/pages/home/components/profiles.tsx +++ b/packages/ui/src/pages/home/components/profiles.tsx @@ -1316,18 +1316,9 @@ function profileNumberDraftValid(value: string, min: number, max: number): boole return Number.isFinite(numeric) && numeric >= min && numeric <= max; } -function profileAppPathLabel(agent: ProfileConfig["agent"]): "CLAUDE_APP_PATH" | "CHATGPT_APP_PATH" | "OPENCODE_APP_PATH" | "WORKBUDDY_APP_PATH" | undefined { - if (agent === "claude-code") { - return "CLAUDE_APP_PATH"; - } - if (agent === "codex") { - return "CHATGPT_APP_PATH"; - } - if (agent === "opencode") { - return "OPENCODE_APP_PATH"; - } - if (agent === "workbuddy") { - return "WORKBUDDY_APP_PATH"; +function profileAppPathLabel(agent: ProfileConfig["agent"]): "APP_PATH" | undefined { + if (agent === "claude-code" || agent === "codex" || agent === "opencode" || agent === "workbuddy") { + return "APP_PATH"; } return undefined; } diff --git a/packages/ui/src/pages/home/shared/i18n.tsx b/packages/ui/src/pages/home/shared/i18n.tsx index 51bb6c4f..fa709e4c 100644 --- a/packages/ui/src/pages/home/shared/i18n.tsx +++ b/packages/ui/src/pages/home/shared/i18n.tsx @@ -605,6 +605,7 @@ export const appCopy: Record = { "Arguments": "Arguments", "Working directory": "Working directory", "API key env": "API key env", + "APP_PATH": "APP_PATH", "CLAUDE_APP_PATH": "CLAUDE_APP_PATH", "CHATGPT_APP_PATH": "CHATGPT_APP_PATH", "OPENCODE_APP_PATH": "OPENCODE_APP_PATH", @@ -1751,6 +1752,7 @@ export const appCopy: Record = { "Arguments": "参数", "Working directory": "工作目录", "API key env": "API Key 环境变量", + "APP_PATH": "APP_PATH", "CLAUDE_APP_PATH": "CLAUDE_APP_PATH", "CHATGPT_APP_PATH": "CHATGPT_APP_PATH", "OPENCODE_APP_PATH": "OPENCODE_APP_PATH", diff --git a/packages/ui/src/pages/home/shared/profiles.ts b/packages/ui/src/pages/home/shared/profiles.ts index 4b2989bd..2ab21781 100644 --- a/packages/ui/src/pages/home/shared/profiles.ts +++ b/packages/ui/src/pages/home/shared/profiles.ts @@ -1204,7 +1204,7 @@ export function profileSummaryItems( : []; const appPath = profile.appPath?.trim() || ""; const appPathSummaryItems = appPath && surface !== "cli" && profile.agent !== "zcode" - ? [{ label: t(profile.agent === "claude-code" ? "CLAUDE_APP_PATH" : profile.agent === "opencode" ? "OPENCODE_APP_PATH" : "CHATGPT_APP_PATH"), value: appPath }] + ? [{ label: t("APP_PATH"), value: appPath }] : []; const savedBot = profile.botConfigId ? config.botConfigs.find((item) => item.id === profile.botConfigId) diff --git a/packages/ui/test/component/profiles.test.tsx b/packages/ui/test/component/profiles.test.tsx index 6bfc980c..f29fbe5d 100644 --- a/packages/ui/test/component/profiles.test.tsx +++ b/packages/ui/test/component/profiles.test.tsx @@ -259,7 +259,7 @@ test("AddProfileForm keeps the default model locked in allowed model lists", () assert.doesNotMatch(otherLabel, /disabled=""/); }); -test("AddProfileForm places CLAUDE_APP_PATH below Bot settings", () => { +test("AddProfileForm places APP_PATH below Bot settings", () => { const config = appConfigFixture(); const html = renderToStaticMarkup( { /> ); const botIndex = html.indexOf(">Bot"); - const appPathIndex = html.indexOf("CLAUDE_APP_PATH"); + const appPathIndex = html.indexOf("APP_PATH"); const envIndex = html.indexOf("Environment variables"); assert.ok(botIndex >= 0); @@ -504,6 +504,23 @@ test("profileSummaryItems uses Pi-specific model labels", () => { assert.equal(items[0]?.label, "Pi model"); }); +test("profileSummaryItems uses a generic App path label", () => { + const config = appConfigFixture(); + const items = profileSummaryItems({ + agent: "workbuddy", + appPath: "/Applications/WorkBuddy AI.app/Contents/MacOS/Electron", + enabled: true, + id: "workbuddy-main", + model: "openai/gpt-5.2", + name: "Workbuddy Main", + scope: "ccr", + surface: "app" + }, config, (value) => value); + + assert.equal(items.find((item) => item.value.includes("WorkBuddy AI.app"))?.label, "APP_PATH"); + assert.doesNotMatch(items.map((item) => item.label).join(" "), /CHATGPT_APP_PATH|WORKBUDDY_APP_PATH/); +}); + test("profileSummaryItems omits disabled profile properties from cards", () => { const config = appConfigFixture(); const disabledItems = profileSummaryItems({ @@ -745,7 +762,8 @@ test("Workbuddy profiles support local App configuration", () => { ); assert.match(html, /App only/); assert.doesNotMatch(html, /CLI only/); - assert.match(html, /WORKBUDDY_APP_PATH/); + assert.match(html, /APP_PATH/); + assert.doesNotMatch(html, /WORKBUDDY_APP_PATH/); const profile = normalizeUnknownProfileItem({ agent: "work-buddy", From 3cb188c064621f3b90cc0634b17836f81a8e37e8 Mon Sep 17 00:00:00 2001 From: musistudio Date: Tue, 11 Aug 2026 15:00:59 +0800 Subject: [PATCH 10/18] Report bounded agent analysis and handle missing session details --- packages/core/src/contracts/app.ts | 2 ++ .../src/observability/request-log-store.ts | 16 ++++++--- .../observability/request-log-store.test.mjs | 11 ++++--- .../src/pages/home/components/dashboard.tsx | 29 +++++++++++++++- packages/ui/src/pages/home/shared/i18n.tsx | 12 +++++++ packages/ui/src/pages/home/shared/usage.ts | 2 ++ .../ui/test/component/observability.test.tsx | 33 +++++++++++++++++++ 7 files changed, 96 insertions(+), 9 deletions(-) diff --git a/packages/core/src/contracts/app.ts b/packages/core/src/contracts/app.ts index 85e54bf7..2103e0cb 100644 --- a/packages/core/src/contracts/app.ts +++ b/packages/core/src/contracts/app.ts @@ -2467,6 +2467,8 @@ export type AgentAnalysisSnapshot = { range: UsageStatsRange; recentRequests: AgentAnalysisRequestRow[]; routes: AgentObservabilityRouteRow[]; + requestScanLimit: number; + requestScanTruncated: boolean; scannedRequestCount: number; selectedSession?: AgentAnalysisSessionDetail; sessions: AgentAnalysisSessionRow[]; diff --git a/packages/core/src/observability/request-log-store.ts b/packages/core/src/observability/request-log-store.ts index 11f5cbaa..b84574c9 100644 --- a/packages/core/src/observability/request-log-store.ts +++ b/packages/core/src/observability/request-log-store.ts @@ -1051,6 +1051,7 @@ export class RequestLogStore { const since = getAgentAnalysisSince(range, now); const requestedAgent = normalizeAgentFilter(filter.agent); const analyzed: AnalyzedAgentRequest[] = []; + let requestScanTruncated = false; let scannedRequestCount = 0; const rows = iterateRows( database, @@ -1103,10 +1104,14 @@ export class RequestLogStore { ORDER BY created_at DESC, id DESC LIMIT ? `, - ["%/count_tokens%", since.toISOString(), maxAgentAnalysisRows] + ["%/count_tokens%", since.toISOString(), maxAgentAnalysisRows + 1] ); // Consume and compact each body before advancing so the query never retains all body text at once. for (const row of rows) { + if (scannedRequestCount >= maxAgentAnalysisRows) { + requestScanTruncated = true; + continue; + } scannedRequestCount += 1; const request = toAnalyzedAgentRequest(toRequestLogEntry(row)); if (requestedAgent === "all" || request.agent === requestedAgent) { @@ -1134,6 +1139,8 @@ export class RequestLogStore { range, recentRequests: analysisRequests.slice(-50).reverse().map(stripAnalysisInternals), routes: buildAgentRouteRows(analysisRequests), + requestScanLimit: maxAgentAnalysisRows, + requestScanTruncated, scannedRequestCount, ...(selectedSession ? { selectedSession } : {}), sessions: buildAgentSessionRows(requests), @@ -2686,7 +2693,7 @@ function buildAgentTrace(requests: AnalyzedAgentRequest[]): AgentAnalysisTrace { depth += 1; } - if (request.routeReason && !isInlineModelRouteReason(request.routeReason)) { + if (shouldCreateRouteTraceRun(request.routeReason)) { const run = requestTraceRun({ depth, kind: "route", @@ -2740,8 +2747,9 @@ function buildAgentTrace(requests: AnalyzedAgentRequest[]): AgentAnalysisTrace { }; } -function isInlineModelRouteReason(value: string | undefined): boolean { - return value?.trim().toLowerCase() === "inline-model"; +function shouldCreateRouteTraceRun(value: string | undefined): boolean { + const normalized = value?.trim().toLowerCase(); + return Boolean(normalized && normalized !== "default" && normalized !== "inline-model"); } function buildToolResultMap(requests: AnalyzedAgentRequest[]): Map { diff --git a/packages/core/test/integration/observability/request-log-store.test.mjs b/packages/core/test/integration/observability/request-log-store.test.mjs index fc906171..fb8a9f70 100644 --- a/packages/core/test/integration/observability/request-log-store.test.mjs +++ b/packages/core/test/integration/observability/request-log-store.test.mjs @@ -1065,6 +1065,7 @@ test("RequestLogStore analyzes agent sessions and exposes trace payloads", async }); assert.equal(selected.selectedSession?.trace.toolRunCount, 1); assert.equal(selected.selectedSession?.trace.llmRunCount, 1); + assert.equal(selected.selectedSession?.trace.runs.some((run) => run.kind === "route"), false); assert.equal(selected.selectedSession?.trace.runs.some((run) => run.toolName === "read_file"), true); assert.equal( selected.selectedSession?.trace.runs.find((run) => run.id === selected.selectedSession?.trace.rootRunId)?.status, @@ -1380,7 +1381,7 @@ test("RequestLogStore streams more body text than the bounded worker heap", { } }); -test("RequestLogStore keeps analysis bounded by the maximum row count", { +test("RequestLogStore reports when analysis is bounded by the maximum row count", { skip: isBoundedHeapWorker, timeout: 30000 }, async () => { @@ -1388,7 +1389,7 @@ test("RequestLogStore keeps analysis bounded by the maximum row count", { try { const dbFile = path.join(dir, "request-logs.sqlite"); const store = new RequestLogStore(dbFile); - const requestCount = 5000; + const requestCount = 5001; const startedAt = new Date().toISOString(); await store.list({ pageSize: 1 }); const database = createBetterSqliteDatabase(dbFile); @@ -1427,8 +1428,10 @@ test("RequestLogStore keeps analysis bounded by the maximum row count", { } const analysis = await store.analyze({ range: "30d" }); - assert.equal(analysis.scannedRequestCount, requestCount); - assert.equal(analysis.totals.requestCount, requestCount); + assert.equal(analysis.requestScanLimit, 5000); + assert.equal(analysis.requestScanTruncated, true); + assert.equal(analysis.scannedRequestCount, 5000); + assert.equal(analysis.totals.requestCount, 5000); assert.equal(analysis.sessions[0]?.id, "max-row-session"); } finally { rmSync(dir, { force: true, recursive: true }); diff --git a/packages/ui/src/pages/home/components/dashboard.tsx b/packages/ui/src/pages/home/components/dashboard.tsx index d363b412..4a744753 100644 --- a/packages/ui/src/pages/home/components/dashboard.tsx +++ b/packages/ui/src/pages/home/components/dashboard.tsx @@ -4321,10 +4321,17 @@ export function AgentAnalysisView({
    ) : null} + {snapshot.requestScanTruncated ? ( + + {t("Analysis is limited to the newest")} {formatCompactNumber(snapshot.requestScanLimit)} {t("requests in the selected range.")} + + ) : null} + {selectedSession || snapshot.selectedSession ? ( setSelectedSession(undefined)} detail={snapshot.selectedSession} + loading={loading} selectedSession={selectedSession} /> ) : null} @@ -4343,14 +4350,19 @@ export function AgentAnalysisView({ function AgentSessionDetailCard({ clearSession, detail, + loading, selectedSession }: { clearSession: () => void; detail?: AgentAnalysisSnapshot["selectedSession"]; + loading: boolean; selectedSession?: AgentAnalysisSessionSelection; }) { const t = useAppText(); const session = detail?.session; + const sessionRequestCount = session?.requestCount ?? 0; + const visibleRequestCount = detail?.requests.length ?? 0; + const requestsTruncated = sessionRequestCount > visibleRequestCount; const headerLabel = session ? `${t(agentKindLabel(session.agent))} / ${compactId(session.id)}` : selectedSession @@ -4373,9 +4385,15 @@ function AgentSessionDetailCard({ {!detail ? ( - + ) : (
    ) : (
    {account.message || account.errors?.[0]?.message || t("Unavailable")}
    @@ -2770,18 +2771,22 @@ function ProviderAccountSummaryCard({ const t = useAppText(); const quotaMeters = providerAccountQuotaMeters(account); const balanceMeter = primaryProviderAccountBalanceMeter(account); - const meters = providerAccountMetersForDisplayOrdered(account, providerAccountMeterLimit(dimensions, false, variant)); const showQuotaVisual = providerAccountUsesQuotaVisual(variant) && quotaMeters.length > 0; const primaryMeter = primaryProviderAccountDisplayMeter(account); - const secondaryMeters = primaryMeter - ? meters.filter((meter) => meter !== primaryMeter).slice(0, providerAccountBentoSecondaryLimit(dimensions)) - : []; - const extraMeterCount = primaryMeter - ? Math.max(0, account.meters.length - 1 - secondaryMeters.length) - : Math.max(0, account.meters.length - meters.length); - const primaryProgress = primaryMeter && isProviderAccountQuotaMeter(primaryMeter) ? providerAccountMeterProgress(primaryMeter) : undefined; const cardBentoSpan = bentoSpan ?? providerAccountBentoSpan(account, dimensions); const compactBento = cardBentoSpan.height === 1; + const baseBentoSecondaryLimit = providerAccountBentoSecondaryLimit(dimensions, cardBentoSpan); + const bentoSecondaryLimit = compactBento + ? baseBentoSecondaryLimit + : Math.max(baseBentoSecondaryLimit, providerAccountMeterLimitAvoidingOrphanExtra(account, 1 + baseBentoSecondaryLimit) - 1); + const meterLimit = variant === "cards" && !compactBento + ? Math.max(providerAccountMeterLimit(dimensions, false, variant), 1 + bentoSecondaryLimit) + : providerAccountMeterLimit(dimensions, false, variant); + const meters = providerAccountMetersForDisplayOrdered(account, meterLimit); + const secondaryMeters = primaryMeter + ? meters.filter((meter) => meter !== primaryMeter).slice(0, bentoSecondaryLimit) + : []; + const primaryProgress = primaryMeter && isProviderAccountQuotaMeter(primaryMeter) ? providerAccountMeterProgress(primaryMeter) : undefined; const resizeHandle = editing && onChangeCardSize ? = 2 ? 2 : 1} maxWidth={providerAccountBentoColumnCount(dimensions, 2) >= 2 ? 2 : 1} onResize={onChangeCardSize} /> : null; @@ -2789,27 +2794,35 @@ function ProviderAccountSummaryCard({ if (variant === "cards") { if (compactBento) { return ( -
    -
    -
    - +
    +
    +
    +
    -
    {providerAccountSnapshotLabel(account)}
    +
    {providerAccountSnapshotLabel(account)}
    -
    -
    - {primaryMeter ? formatProviderAccountMeterTitle(primaryMeter, t) : account.message || account.errors?.[0]?.message || t("Unavailable")} -
    - {primaryMeter ?
    {formatProviderAccountMeterValue(primaryMeter, t)}
    : null} -
    {dragHandle || providerAccountShowRefresh(dimensions) ? ( -
    +
    {dragHandle} - {providerAccountShowRefresh(dimensions) ? : null} + {providerAccountShowRefresh(dimensions) ? : null}
    ) : null}
    +
    + {primaryMeter ? ( +
    +
    + {formatProviderAccountMeterTitle(primaryMeter, t)} +
    +
    {formatProviderAccountMeterValue(primaryMeter, t)}
    +
    + ) : ( +
    + {account.message || account.errors?.[0]?.message || t("Unavailable")} +
    + )} +
    {primaryProgress !== undefined && providerAccountShowProgress(dimensions) ? (
    @@ -2857,14 +2870,11 @@ function ProviderAccountSummaryCard({ ) : null}
    - {secondaryMeters.length > 0 || (providerAccountShowExtraCount(dimensions) && extraMeterCount > 0) ? ( + {secondaryMeters.length > 0 ? (
    {secondaryMeters.map((meter) => ( ))} - {providerAccountShowExtraCount(dimensions) && extraMeterCount > 0 ? ( -
    +{extraMeterCount}
    - ) : null}
    ) : null} @@ -2903,9 +2913,6 @@ function ProviderAccountSummaryCard({ {meters.map((meter) => ( ))} - {providerAccountShowExtraCount(dimensions) && account.meters.length > meters.length ? ( -
    +{account.meters.length - meters.length}
    - ) : null}
    ) : (
    {account.message || account.errors?.[0]?.message || t("Unavailable")}
    @@ -3201,10 +3208,14 @@ function providerAccountIconUrl(account: ProviderAccountSnapshot, providers: Gat function ProviderAccountRefreshButton({ account, + className, + iconClassName, onRefresh, refreshing = false }: { account: ProviderAccountSnapshot; + className?: string; + iconClassName?: string; onRefresh?: () => void | Promise; refreshing?: boolean; }) { @@ -3213,7 +3224,7 @@ function ProviderAccountRefreshButton({ return ( ); } @@ -4090,6 +4101,10 @@ function providerAccountMeterLimit(dimensions: OverviewWidgetDimensions, single: return 2; } +function providerAccountMeterLimitAvoidingOrphanExtra(account: ProviderAccountSnapshot, maxCount: number): number { + return account.meters.length - maxCount === 1 ? maxCount + 1 : maxCount; +} + function providerAccountContentPaddingClass(dimensions: OverviewWidgetDimensions): string { return dimensions.height <= 1 || dimensions.width <= 1 ? "p-2" : "p-3"; } @@ -4102,8 +4117,10 @@ function providerAccountBentoGridRowClass(): string { return "auto-rows-fr"; } -function providerAccountBentoSecondaryLimit(dimensions: OverviewWidgetDimensions): number { - if (dimensions.height <= 1 || dimensions.width <= 1) return 0; +function providerAccountBentoSecondaryLimit(dimensions: OverviewWidgetDimensions, span?: ProviderAccountBentoSpan): number { + if (span?.height === 1 || dimensions.height <= 1) return 0; + if (span?.height === 2) return dimensions.height >= 3 || dimensions.width >= 2 ? 2 : 1; + if (dimensions.width <= 1) return 1; if (dimensions.height === 2) return 1; return 2; } @@ -4256,10 +4273,6 @@ function providerAccountShowProgress(dimensions: OverviewWidgetDimensions): bool return dimensions.height >= 1; } -function providerAccountShowExtraCount(dimensions: OverviewWidgetDimensions): boolean { - return dimensions.height >= 3; -} - export function AgentAnalysisView({ agentFilter, error, diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css index ac452fbe..37c067fe 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -678,6 +678,14 @@ --overview-account-accent: #8e8e93; } + .overview-account-bento-tile[data-provider-account-card-layout="compact"] { + background: + linear-gradient(90deg, color-mix(in oklab, var(--overview-account-accent) 5%, transparent), transparent 48%), + linear-gradient(180deg, color-mix(in oklab, var(--card) 84%, var(--muted)), color-mix(in oklab, var(--muted) 54%, var(--card))); + border-color: color-mix(in oklab, var(--overview-account-accent) 16%, var(--border)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, .46); + } + .overview-account-bento-more { background: linear-gradient(135deg, color-mix(in oklab, var(--overview-account-accent) 9%, transparent), transparent 50%), diff --git a/packages/ui/test/component/overview-components.test.tsx b/packages/ui/test/component/overview-components.test.tsx index cbc19bc3..2282b8e0 100644 --- a/packages/ui/test/component/overview-components.test.tsx +++ b/packages/ui/test/component/overview-components.test.tsx @@ -311,12 +311,224 @@ test("OverviewView uses height-aware balance layouts at every account card size" assert.doesNotMatch(cardHtml, /overflow-y-auto|176px/); if (cardSize.layout === "compact") { assert.match(cardHtml, /data-provider-account-compact-meter="true"/); + assert.match(cardHtml, /data-provider-account-compact-brand="true"/); + assert.doesNotMatch(cardHtml, /overview-account-bento-compact-meter/); } else { assert.doesNotMatch(cardHtml, /data-provider-account-compact-meter="true"/); + assert.doesNotMatch(cardHtml, /overview-account-bento-compact-meter/); } } }); +test("OverviewView pins compact account card identity and refresh above long errors", () => { + const errorAccount: ProviderAccountSnapshot = { + message: "Account endpoint returned HTTP 403: {\"code\":\"permission_denied\"}", + meters: [], + provider: "DeepSeek", + source: "standard", + status: "error", + updatedAt: "2026-06-30T00:00:00.000Z" + }; + const html = renderToStaticMarkup( + undefined} + setUsageRange={() => undefined} + usageRange="30d" + usageStats={usageStats("30d")} + onWidgetsChange={() => undefined} + /> + ); + const cardStart = html.indexOf('data-provider-account-sortable-id="DeepSeek"'); + assert.ok(cardStart >= 0); + const nextCardStart = html.indexOf('data-provider-account-sortable-id=', cardStart + 1); + const cardHtml = html.slice(cardStart, nextCardStart >= 0 ? nextCardStart : undefined); + const brandStart = cardHtml.indexOf('data-provider-account-compact-brand="true"'); + const actionsStart = cardHtml.indexOf('data-provider-account-compact-actions="true"'); + const messageStart = cardHtml.indexOf('data-provider-account-compact-message="true"'); + + assert.ok(brandStart >= 0); + assert.ok(actionsStart >= 0); + assert.ok(messageStart >= 0); + assert.ok(brandStart < actionsStart); + assert.ok(actionsStart < messageStart); + assert.match(cardHtml.slice(brandStart, actionsStart), /DeepSeek/); + assert.doesNotMatch(cardHtml.slice(brandStart, actionsStart), /Account endpoint returned HTTP 403/); + assert.match(cardHtml.slice(messageStart), /Account endpoint returned HTTP 403/); + assert.doesNotMatch(cardHtml, /data-provider-account-compact-meter="true"/); +}); + +test("OverviewView shows secondary meters on tall account cards instead of folding to plus one", () => { + const account: ProviderAccountSnapshot = { + credentialId: "test", + meters: [ + { + id: "5h", + kind: "quota", + label: "5 hour quota", + limit: 100, + remaining: 96, + unit: "%", + window: "5h" + }, + { + id: "weekly", + kind: "quota", + label: "Weekly quota", + limit: 100, + remaining: 91, + unit: "%", + window: "weekly" + } + ], + provider: "Zhipu AI (China) - Coding Plan", + source: "standard", + status: "ok", + updatedAt: "2026-08-14T14:04:44.000Z" + }; + const html = renderToStaticMarkup( + undefined} + setUsageRange={() => undefined} + usageRange="30d" + usageStats={usageStats("30d")} + onWidgetsChange={() => undefined} + /> + ); + const cardStart = html.indexOf('data-provider-account-sortable-id="Zhipu AI (China) - Coding Plan::test"'); + assert.ok(cardStart >= 0); + const nextCardStart = html.indexOf('data-provider-account-sortable-id=', cardStart + 1); + const cardHtml = html.slice(cardStart, nextCardStart >= 0 ? nextCardStart : undefined); + + assert.match(cardHtml, /5 hour quota/); + assert.match(cardHtml, /Weekly quota/); + assert.doesNotMatch(cardHtml, />\+1 { + const account: ProviderAccountSnapshot = { + credentialId: "test", + meters: [ + { + id: "5h", + kind: "quota", + label: "5 hour quota", + limit: 100, + remaining: 95, + unit: "%", + window: "5h" + }, + { + id: "weekly", + kind: "quota", + label: "Weekly quota", + limit: 100, + remaining: 91, + unit: "%", + window: "weekly" + }, + { + id: "daily", + kind: "quota", + label: "Daily quota", + limit: 100, + remaining: 88, + unit: "%", + window: "daily" + }, + { + id: "monthly", + kind: "quota", + label: "Monthly quota", + limit: 100, + remaining: 82, + unit: "%", + window: "monthly" + } + ], + provider: "Zhipu AI (China) - Coding Plan", + source: "standard", + status: "ok", + updatedAt: "2026-08-14T14:07:49.000Z" + }; + const html = renderToStaticMarkup( + undefined} + setUsageRange={() => undefined} + usageRange="30d" + usageStats={usageStats("30d")} + onWidgetsChange={() => undefined} + /> + ); + + assert.match(html, /5 hour quota/); + assert.match(html, /Monthly quota/); + assert.doesNotMatch(html, />\+1 { + const account: ProviderAccountSnapshot = { + credentialId: "test", + meters: [ + { id: "5h", kind: "quota", label: "5 hour quota", limit: 100, remaining: 95, unit: "%", window: "5h" }, + { id: "weekly", kind: "quota", label: "Weekly quota", limit: 100, remaining: 91, unit: "%", window: "weekly" }, + { id: "daily", kind: "quota", label: "Daily quota", limit: 100, remaining: 88, unit: "%", window: "daily" }, + { id: "monthly", kind: "quota", label: "Monthly quota", limit: 100, remaining: 82, unit: "%", window: "monthly" }, + { id: "yearly", kind: "quota", label: "Yearly quota", limit: 100, remaining: 80, unit: "%", window: "yearly" }, + { id: "credits", kind: "requests", label: "Request credits", remaining: 12, unit: "credits" } + ], + provider: "Zhipu AI (China) - Coding Plan", + source: "standard", + status: "ok", + updatedAt: "2026-08-14T14:07:49.000Z" + }; + const html = renderToStaticMarkup( + undefined} + setUsageRange={() => undefined} + usageRange="30d" + usageStats={usageStats("30d")} + onWidgetsChange={() => undefined} + /> + ); + + assert.match(html, /5 hour quota/); + assert.doesNotMatch(html, />\+\d+ { const html = renderToStaticMarkup( Date: Fri, 14 Aug 2026 16:24:25 +0800 Subject: [PATCH 16/18] Add provider model refresh control --- packages/ui/src/pages/home/App.tsx | 114 ++++++++++++++++++ .../src/pages/home/components/providers.tsx | 29 ++++- packages/ui/src/pages/home/shared/i18n.tsx | 4 + .../ui/src/pages/home/shared/providers.ts | 4 + .../ui/test/integration/providers.test.ts | 62 ++++++++++ 5 files changed, 211 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/pages/home/App.tsx b/packages/ui/src/pages/home/App.tsx index 4e48dc78..7c4814fe 100644 --- a/packages/ui/src/pages/home/App.tsx +++ b/packages/ui/src/pages/home/App.tsx @@ -142,6 +142,22 @@ async function loadProviderAccountSnapshots(forceRefresh = false): Promise Number.isInteger(item) && item >= 0))]; @@ -1402,6 +1418,103 @@ function App() { }; }, [activeView, onboardingStep, providerAddOpen, providerDraft.apiKey, providerDraft.baseUrl, providerDraft.credentialMode, providerDraft.credentials, providerDraft.presetId, providerDraft.protocol, providerDraft.protocolDetectionMode, providerDraft.providerPlugins]); + async function refreshProviderModels(): Promise { + if (providerProbeLoading) { + return; + } + if (!window.ccr) { + setProviderProbeError(t("Request failed.")); + return; + } + + providerProbeRequestId.current += 1; + providerConnectivityRequestId.current += 1; + const requestId = providerProbeRequestId.current; + const existingProvider = providerEditIndex === undefined ? undefined : draftConfig.Providers[providerEditIndex]; + const refreshProtocols = providerDraft.protocolDetectionMode === "manual" + ? uniqueProviderProtocols(providerDraft.selectedProtocols.length > 0 ? providerDraft.selectedProtocols : [providerDraft.protocol]) + : undefined; + const refreshInputKey = providerRefreshModelsInputKey(providerDraft, refreshProtocols); + + setProviderProbeError(""); + setProviderConnectivityProbe(undefined); + setProviderConnectivityLoading(false); + setProviderProbeLoading(true); + + try { + if (isLocalCodexProviderDraft(providerDraft)) { + if (!window.ccr.probeLocalAgentProvider) { + setProviderProbe(undefined); + return; + } + const result = await window.ccr.probeLocalAgentProvider({ forceRefresh: true, id: localCodexProviderId }); + if (providerProbeRequestId.current !== requestId) { + return; + } + setProviderProbe(result.probe); + setProviderDraft((current) => { + const currentProtocols = current.protocolDetectionMode === "manual" + ? uniqueProviderProtocols(current.selectedProtocols.length > 0 ? current.selectedProtocols : [current.protocol]) + : undefined; + if (!isLocalCodexProviderDraft(current) || providerRefreshModelsInputKey(current, currentProtocols) !== refreshInputKey) { + return current; + } + return applyProviderProbeResult(current, result.probe); + }); + return; + } + + const candidates = providerProbeCandidates(providerDraft).filter(isProviderProbeCandidateReady); + if (candidates.length === 0) { + setProviderProbe(undefined); + setProviderProbeError(t("No endpoint candidates available.")); + return; + } + + const apiKey = providerConnectivityApiKeyFromDraft(providerDraft); + const providerPlugins = providerConnectivityProviderPlugins(providerDraft, draftConfig.providerPlugins, existingProvider); + const result = await probeProviderCandidates(candidates, apiKey, [], { + forceRefresh: true, + mode: "models", + providerPlugins, + protocols: refreshProtocols + }); + if (providerProbeRequestId.current !== requestId) { + return; + } + if (!result) { + setProviderProbe(undefined); + setProviderProbeError(t("Request failed.")); + return; + } + + setProviderProbe(result.probe); + setProviderDraft((current) => { + const currentProtocols = current.protocolDetectionMode === "manual" + ? uniqueProviderProtocols(current.selectedProtocols.length > 0 ? current.selectedProtocols : [current.protocol]) + : undefined; + if (providerRefreshModelsInputKey(current, currentProtocols) !== refreshInputKey) { + return current; + } + return applyProviderProbeResult(current, result.probe); + }); + + if (result.probe.models.length === 0 && !providerProbeHasSupportedProtocol(result.probe)) { + const message = result.probe.protocols.find((item) => item.message)?.message || "Request failed."; + setProviderProbeError(translateAppErrorMessage(copy, message)); + } + } catch (error) { + if (providerProbeRequestId.current === requestId) { + setProviderProbe(undefined); + setProviderProbeError(formatError(error)); + } + } finally { + if (providerProbeRequestId.current === requestId) { + setProviderProbeLoading(false); + } + } + } + async function checkProviderDraft(modelsToCheck?: string[]): Promise { const emptyReport: ProviderConnectivityCheckReport = { failed: [], passed: [], results: [] }; providerConnectivityRequestId.current += 1; @@ -3260,6 +3373,7 @@ function App() { setProviderImportPayload(undefined); }, onCheck: checkProviderDraft, + onRefreshModels: refreshProviderModels, onSubmit: submitProviderDraft, probe: providerProbe, probeLoading: providerProbeLoading, diff --git a/packages/ui/src/pages/home/components/providers.tsx b/packages/ui/src/pages/home/components/providers.tsx index 49908637..4d12c012 100644 --- a/packages/ui/src/pages/home/components/providers.tsx +++ b/packages/ui/src/pages/home/components/providers.tsx @@ -14,7 +14,7 @@ import { providerBrowserConnectorFromDraft, providerBrowserCredentialsOptions, ProviderConnectivityCheckReport, providerCapabilityBaseUrlForProtocol, providerConnectivityApiKeyFromDraft, providerDeepLinkDisplayIcon, providerDraftHasReadyCredentialPool, providerListItemKey, providerMatchesQuery, ProviderPreset, providerPresetIconUrls, providerProbeHasSupportedProtocol, providerDisplayIcon, providerGlobalBaseUrlForProbe, providerModelDisplayName, providerModelDisplayTitle, providerProtocolOptions, providerSelectableProtocolsFromProbe, providerUsageFieldPatch, ProviderUsageFieldTarget, providerUsageMethodOptions, Search, SelectControl, - resolveProviderDeepLinkPreset, ShieldCheck, splitLines, Switch, Tabs, TabsList, TabsTrigger, Textarea, Toggle, translatedProviderProtocolLabel, translateOptions, + RefreshCw, resolveProviderDeepLinkPreset, ShieldCheck, splitLines, Switch, Tabs, TabsList, TabsTrigger, Textarea, Toggle, translatedProviderProtocolLabel, translateOptions, translateProbeProtocolMessage, Trash2, uniqueProviderName, uniqueProviderProtocols, useAppErrorText, useAppText, useEffect, useLayoutEffect, useMemo, useRef, useState, X, isGatewayProviderEnabled, isPlainRecord } from "../shared/index"; @@ -1958,6 +1958,7 @@ export function AddProviderForm({ mode, onCheck, onChange, + onRefreshModels, onIconDetectingChange, onSelectStep, probe, @@ -1975,6 +1976,7 @@ export function AddProviderForm({ mode: "add" | "edit"; onCheck?: () => Promise; onChange: (patch: Partial, resetProbe?: boolean) => void; + onRefreshModels?: () => Promise; onIconDetectingChange?: (detecting: boolean) => void; onSelectStep?: (step: ProviderSetupStepId) => void; probe?: GatewayProviderProbeResult; @@ -2350,6 +2352,7 @@ export function AddProviderForm({ metadata={draft.modelMetadata} onMetadataChange={(modelMetadata) => onChange({ modelMetadata })} onQueryChange={(modelSearch) => onChange({ modelSearch })} + onRefresh={onRefreshModels} onSelectedChange={updateConfiguredModels} query={draft.modelSearch} selected={configuredModels} @@ -3433,6 +3436,7 @@ export function AddProviderDialog({ onCheck, onChange, onClose, + onRefreshModels, onSubmit, probe, probeLoading, @@ -3451,6 +3455,7 @@ export function AddProviderDialog({ onCheck?: (models: string[]) => Promise; onChange: (patch: Partial, resetProbe?: boolean) => void; onClose: () => void; + onRefreshModels?: () => Promise; onSubmit: () => Promise; probe?: GatewayProviderProbeResult; probeLoading: boolean; @@ -3589,6 +3594,7 @@ export function AddProviderDialog({ onCheck={onCheck ? async () => setCheckConfirmOpen(true) : undefined} onChange={onChange} onIconDetectingChange={setIconDetecting} + onRefreshModels={onRefreshModels} onSelectStep={wizardMode ? selectSetupStep : undefined} probe={probe} probeLoading={probeLoading} @@ -3859,6 +3865,7 @@ function ProviderModelPicker({ metadata, onMetadataChange, onQueryChange, + onRefresh, onSelectedChange, query, selected @@ -3870,6 +3877,7 @@ function ProviderModelPicker({ metadata?: NonNullable; onMetadataChange: (value: AddProviderDraft["modelMetadata"]) => void; onQueryChange: (value: string) => void; + onRefresh?: () => void | Promise; onSelectedChange: (value: string[]) => void; query: string; selected: string[]; @@ -3900,6 +3908,7 @@ function ProviderModelPicker({ const customModelControlGap = 8; const customModelEditorWidth = Math.max(customModelButtonWidth, addedControlsWidth); const returningSearchWidth = Math.max(0, customModelEditorWidth - customModelButtonWidth - customModelControlGap); + const refreshLabel = loading ? t("Refreshing provider models") : t("Refresh provider models"); function addCatalogModel(model: string) { if (selectedModelSet.has(model)) { @@ -3971,7 +3980,23 @@ function ProviderModelPicker({
    {t("Provider models")}
    {t("Models detected from this provider")}
    - {loading ? : catalog.length} +
    + {onRefresh ? ( + + ) : null} + {loading ? : catalog.length} +
    diff --git a/packages/ui/src/pages/home/shared/i18n.tsx b/packages/ui/src/pages/home/shared/i18n.tsx index 42e0d0c5..a732451a 100644 --- a/packages/ui/src/pages/home/shared/i18n.tsx +++ b/packages/ui/src/pages/home/shared/i18n.tsx @@ -365,6 +365,8 @@ export const appCopy: Record = { "Models detected from this provider": "Models detected from this provider", "No models added": "No models added", "No provider models": "No provider models", + "Refresh provider models": "Refresh provider models", + "Refreshing provider models": "Refreshing provider models", "This provider did not return a model list. Add model IDs with Custom model.": "This provider did not return a model list. Add model IDs with Custom model.", "No available models": "No available models", "No local login state was found for this agent.": "No local login state was found for this agent.", @@ -1973,6 +1975,8 @@ export const appCopy: Record = { "Models detected from this provider": "从该供应商检测到的模型", "More": "更多", "Provider models": "供应商模型", + "Refresh provider models": "刷新供应商模型", + "Refreshing provider models": "正在刷新供应商模型", "Runtime provider": "运行时供应商", "Used in Agent routing prompts": "用于 Agent 路由提示词", "Read only": "只读", diff --git a/packages/ui/src/pages/home/shared/providers.ts b/packages/ui/src/pages/home/shared/providers.ts index 58b21366..b3d8730e 100644 --- a/packages/ui/src/pages/home/shared/providers.ts +++ b/packages/ui/src/pages/home/shared/providers.ts @@ -1822,7 +1822,9 @@ export async function probeProviderCandidates( apiKey: string, models: string[], options: { + forceRefresh?: boolean; mode?: "connectivity" | "models" | "protocols"; + providerPlugins?: unknown[]; protocols?: GatewayProviderProtocol[]; } = {} ): Promise { @@ -1830,8 +1832,10 @@ export async function probeProviderCandidates( return await window.ccr?.probeProviderCandidates({ apiKey: apiKey || undefined, candidates, + forceRefresh: options.forceRefresh, mode, models: mode === "connectivity" ? models : [], + providerPlugins: options.providerPlugins, protocols: options.protocols }); } diff --git a/packages/ui/test/integration/providers.test.ts b/packages/ui/test/integration/providers.test.ts index 00c78e65..58a5e26a 100644 --- a/packages/ui/test/integration/providers.test.ts +++ b/packages/ui/test/integration/providers.test.ts @@ -640,6 +640,68 @@ test("AddProviderForm renders a two-column model picker", () => { assert.doesNotMatch(html, /Select models/); }); +test("AddProviderForm renders provider model refresh control when available", () => { + const draft = { + ...createProviderDraft([]), + apiKey: "sk-test", + baseUrl: "https://api.example/v1", + name: "Example", + presetId: customProviderPresetId + }; + const probe = { + capabilities: [], + detectedProtocol: "openai_chat_completions" as const, + models: ["model-a", "model-b"], + normalizedBaseUrl: "https://api.example/v1", + protocols: [] + }; + const html = renderToStaticMarkup( + React.createElement(AddProviderForm, { + activeStep: "models", + draft, + error: "", + mode: "edit", + onChange: () => undefined, + onRefreshModels: async () => undefined, + probe, + probeLoading: false, + providers: [] + }) + ); + const loadingHtml = renderToStaticMarkup( + React.createElement(AddProviderForm, { + activeStep: "models", + draft, + error: "", + mode: "edit", + onChange: () => undefined, + onRefreshModels: async () => undefined, + probe, + probeLoading: true, + providers: [] + }) + ); + const withoutRefreshHtml = renderToStaticMarkup( + React.createElement(AddProviderForm, { + activeStep: "models", + draft, + error: "", + mode: "edit", + onChange: () => undefined, + probe, + probeLoading: false, + providers: [] + }) + ); + + assert.match(html, /aria-label="Refresh provider models"/); + assert.match(html, /title="Refresh provider models"/); + assert.match(html, /lucide-refresh-cw/); + assert.match(loadingHtml, /aria-label="Refreshing provider models"[^>]*disabled/); + assert.match(loadingHtml, /lucide-refresh-cw[^"]*animate-spin/); + assert.doesNotMatch(withoutRefreshHtml, /Refresh provider models/); +}); + test("AddProviderForm keeps edit model lists scrollable without blocking dialog scroll chaining", () => { const draft = { ...createProviderDraft([]), From d33cab92427ae8a5f545e053de0982b5e1188252 Mon Sep 17 00:00:00 2001 From: musistudio Date: Fri, 14 Aug 2026 17:31:50 +0800 Subject: [PATCH 17/18] Update ai-gateway dependency to 1.0.17 --- package-lock.json | 12 ++++++------ package.json | 2 +- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index a78113d6..3935e412 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "packages/*" ], "dependencies": { - "@the-next-ai/ai-gateway": "^1.0.16", + "@the-next-ai/ai-gateway": "^1.0.17", "@the-next-ai/bot-gateway-sdk": "^0.1.0", "better-sqlite3": "^12.11.1", "electron-updater": "^6.8.9", @@ -2142,9 +2142,9 @@ } }, "node_modules/@the-next-ai/ai-gateway": { - "version": "1.0.16", - "resolved": "https://registry.npmjs.org/@the-next-ai/ai-gateway/-/ai-gateway-1.0.16.tgz", - "integrity": "sha512-9umpJ3gGROlXXGBHXHwOdhO9TEcQeG6AlnNmbccaUY5v64czQNFEod23mR31UfqLkv+TpHbKrcDtmuZ5//6S6w==", + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@the-next-ai/ai-gateway/-/ai-gateway-1.0.17.tgz", + "integrity": "sha512-KwHiWqBGVSQnbQlxZ41jCKqrOrZdzAMlgRB61AJyjenH9M8hMx/dGsRr0I+vNiU+jBvOvyih64eQRPMgO9QHsQ==", "license": "MIT", "dependencies": { "diff": "^8.0.3", @@ -9001,7 +9001,7 @@ "version": "3.0.20", "license": "MIT", "dependencies": { - "@the-next-ai/ai-gateway": "^1.0.16", + "@the-next-ai/ai-gateway": "^1.0.17", "@the-next-ai/bot-gateway-sdk": "^0.1.0", "better-sqlite3": "^12.11.1", "node-forge": "^1.4.0", @@ -9018,7 +9018,7 @@ "name": "@claude-code-router/core", "version": "3.0.20", "dependencies": { - "@the-next-ai/ai-gateway": "^1.0.16", + "@the-next-ai/ai-gateway": "^1.0.17", "@the-next-ai/bot-gateway-sdk": "^0.1.0", "better-sqlite3": "^12.11.1", "node-forge": "^1.4.0", diff --git a/package.json b/package.json index 465c56f3..430e3ece 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "rebuild:sqlite3": "electron-rebuild -f -w better-sqlite3" }, "dependencies": { - "@the-next-ai/ai-gateway": "^1.0.16", + "@the-next-ai/ai-gateway": "^1.0.17", "@the-next-ai/bot-gateway-sdk": "^0.1.0", "better-sqlite3": "^12.11.1", "electron-updater": "^6.8.9", diff --git a/packages/cli/package.json b/packages/cli/package.json index 3a63ae10..f963fc92 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -41,7 +41,7 @@ "test:integration": "node ../../build/test.mjs cli --scope integration && node ../../build/run-tests.mjs cli" }, "dependencies": { - "@the-next-ai/ai-gateway": "^1.0.16", + "@the-next-ai/ai-gateway": "^1.0.17", "@the-next-ai/bot-gateway-sdk": "^0.1.0", "better-sqlite3": "^12.11.1", "node-forge": "^1.4.0", diff --git a/packages/core/package.json b/packages/core/package.json index 065d4515..ebbbbcc3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -16,7 +16,7 @@ "test:integration": "node ../../build/test.mjs core --scope integration && node ../../build/run-tests.mjs core" }, "dependencies": { - "@the-next-ai/ai-gateway": "^1.0.16", + "@the-next-ai/ai-gateway": "^1.0.17", "@the-next-ai/bot-gateway-sdk": "^0.1.0", "better-sqlite3": "^12.11.1", "node-forge": "^1.4.0", From f2860e165aa582c51e54487cb853f5177e78ead3 Mon Sep 17 00:00:00 2001 From: musistudio Date: Fri, 14 Aug 2026 17:51:29 +0800 Subject: [PATCH 18/18] Release 3.0.21 --- README.md | 8 ++++---- README_zh.md | 8 ++++---- package-lock.json | 12 ++++++------ package.json | 2 +- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- packages/electron/package.json | 2 +- packages/ui/package.json | 2 +- 8 files changed, 19 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index c0a714e0..4d13ee7e 100644 --- a/README.md +++ b/README.md @@ -169,28 +169,28 @@ CCR supports OpenAI Chat / Responses, Anthropic Messages, Gemini Generate Conten
    - + Windows logo
    Windows
    - + Linux logo
    Linux
    - + macOS logo
    macOS (Apple Silicon)
    - + macOS logo
    macOS (Intel) diff --git a/README_zh.md b/README_zh.md index 994aab1f..6eb17a40 100644 --- a/README_zh.md +++ b/README_zh.md @@ -169,28 +169,28 @@ CCR 支持 OpenAI Chat / Responses、Anthropic Messages、Gemini Generate Conten
    - + Windows 图标
    Windows
    - + Linux 图标
    Linux
    - + macOS 图标
    macOS (Apple Silicon)
    - + macOS 图标
    macOS (Intel) diff --git a/package-lock.json b/package-lock.json index 3935e412..39de75e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "claude-code-router-monorepo", - "version": "3.0.20", + "version": "3.0.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-code-router-monorepo", - "version": "3.0.20", + "version": "3.0.21", "license": "MIT", "workspaces": [ "packages/*" @@ -8998,7 +8998,7 @@ }, "packages/cli": { "name": "@musistudio/claude-code-router", - "version": "3.0.20", + "version": "3.0.21", "license": "MIT", "dependencies": { "@the-next-ai/ai-gateway": "^1.0.17", @@ -9016,7 +9016,7 @@ }, "packages/core": { "name": "@claude-code-router/core", - "version": "3.0.20", + "version": "3.0.21", "dependencies": { "@the-next-ai/ai-gateway": "^1.0.17", "@the-next-ai/bot-gateway-sdk": "^0.1.0", @@ -9034,7 +9034,7 @@ }, "packages/electron": { "name": "@claude-code-router/electron", - "version": "3.0.20", + "version": "3.0.21", "dependencies": { "better-sqlite3": "^12.11.1" }, @@ -9044,7 +9044,7 @@ }, "packages/ui": { "name": "@claude-code-router/ui", - "version": "3.0.20", + "version": "3.0.21", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/package.json b/package.json index 430e3ece..acab445f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-code-router-monorepo", - "version": "3.0.20", + "version": "3.0.21", "private": true, "license": "MIT", "description": "Local Claude Code Router gateway with CLI and web management UI.", diff --git a/packages/cli/package.json b/packages/cli/package.json index f963fc92..635b4ab7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@musistudio/claude-code-router", - "version": "3.0.20", + "version": "3.0.21", "license": "MIT", "description": "Local Claude Code Router gateway with CLI and web management UI.", "repository": { diff --git a/packages/core/package.json b/packages/core/package.json index ebbbbcc3..8cb9d818 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@claude-code-router/core", - "version": "3.0.20", + "version": "3.0.21", "private": true, "description": "Claude Code Router core gateway, routing, provider, and storage services.", "main": "dist/main/server.js", diff --git a/packages/electron/package.json b/packages/electron/package.json index db1e6257..5c50cdf4 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -1,6 +1,6 @@ { "name": "@claude-code-router/electron", - "version": "3.0.20", + "version": "3.0.21", "private": true, "description": "Claude Code Router Electron desktop shell.", "author": "musistudio", diff --git a/packages/ui/package.json b/packages/ui/package.json index bee4ad8f..7167c197 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@claude-code-router/ui", - "version": "3.0.20", + "version": "3.0.21", "private": true, "description": "Claude Code Router web management UI.", "scripts": {
    + {requestsTruncated ? ( + + {t("Session detail shows the newest")} {formatCompactNumber(visibleRequestCount)} {t("of")} {formatCompactNumber(sessionRequestCount)} {t("requests.")} + + ) : null} +
    @@ -5001,6 +5019,15 @@ function AnalysisEmptyState({ label }: { label: string }) { ); } +function AnalysisNotice({ children }: { children: ReactNode }) { + return ( +
    + + {children} +
    + ); +} + type UsageAnalysisColumn = { key: "client" | "credentialId" | "model" | "provider"; label: string; diff --git a/packages/ui/src/pages/home/shared/i18n.tsx b/packages/ui/src/pages/home/shared/i18n.tsx index fa709e4c..ca02bb95 100644 --- a/packages/ui/src/pages/home/shared/i18n.tsx +++ b/packages/ui/src/pages/home/shared/i18n.tsx @@ -440,6 +440,8 @@ export const appCopy: Record = { "Idle seconds must be between 30 and 86400.": "Idle seconds must be between 30 and 86400.", "No request logs match the current filters.": "No request logs match the current filters.", "No request logs yet.": "No request logs yet.", + "Session detail shows the newest": "Session detail shows the newest", + "Session not found or outside the selected range": "Session not found or outside the selected range", "Models, paths, routing, bot, compact, and env": "Models, paths, routing, bot, compact, and env", "Paths, routing, bot, compact, and env": "Paths, routing, bot, compact, and env", "Paths, bot, compact, and env": "Paths, bot, compact, and env", @@ -466,6 +468,10 @@ export const appCopy: Record = { "All accounts": "All accounts", "All credentials": "All credentials", "Last updated": "Last updated", + "Analysis is limited to the newest": "Analysis is limited to the newest", + "of": "of", + "requests in the selected range.": "requests in the selected range.", + "requests.": "requests.", "Refresh account": "Refresh account", "Refreshing account": "Refreshing account", "Add widget": "Add widget", @@ -1364,6 +1370,8 @@ export const appCopy: Record = { "Request logs record gateway requests and make payload inspection available.": "请求日志会记录网关请求,并支持查看请求/响应载荷。", "Request timeout ms": "请求超时 ms", "Requests": "请求", + "requests in the selected range.": "条当前范围内的请求。", + "requests.": "条请求。", "Rewrite request parameters": "改写请求参数", "Set": "设置", "Append to array": "追加到数组", @@ -1474,6 +1482,8 @@ export const appCopy: Record = { "All accounts": "所有账户", "All credentials": "全部凭据", "Last updated": "上次更新", + "Analysis is limited to the newest": "分析结果仅包含最新的", + "of": "/", "Refresh account": "刷新账户", "Refreshing account": "正在刷新账户", "Add widget": "添加组件", @@ -1590,6 +1600,8 @@ export const appCopy: Record = { "Sessions": "会话", "Clear session": "清除会话", "Loading session metrics": "正在加载会话指标", + "Session detail shows the newest": "会话详情仅展示最新的", + "Session not found or outside the selected range": "未找到该会话,或它不在当前时间范围内", "Trace Detail": "链路详情", "Trace duration": "链路耗时", "Trace runs": "链路节点", diff --git a/packages/ui/src/pages/home/shared/usage.ts b/packages/ui/src/pages/home/shared/usage.ts index 6bd466be..c9da179d 100644 --- a/packages/ui/src/pages/home/shared/usage.ts +++ b/packages/ui/src/pages/home/shared/usage.ts @@ -38,6 +38,8 @@ export function createEmptyAgentAnalysis(range: UsageStatsRange): AgentAnalysisS range, recentRequests: [], routes: [], + requestScanLimit: 0, + requestScanTruncated: false, scannedRequestCount: 0, sessions: [], subagents: [], diff --git a/packages/ui/test/component/observability.test.tsx b/packages/ui/test/component/observability.test.tsx index 69b33df8..7376f403 100644 --- a/packages/ui/test/component/observability.test.tsx +++ b/packages/ui/test/component/observability.test.tsx @@ -248,8 +248,41 @@ test("AgentAnalysisView keeps session headings horizontal and shows cache rate a assert.match(html, /\$1\.25/); assert.match(html, /\$0\.25/); assert.match(html, /\$0\.75/); + assert.match(html, /会话详情仅展示最新的/); + assert.match(html, /1 \/ 2 条请求/); assert.match(html, /部分失败/); assert.match(html, /border-amber-200/); assert.match(html, /min-w-\[64px\]/); assert.match(html, /whitespace-nowrap/); }); + +test("AgentAnalysisView surfaces bounded analysis and missing session states", () => { + const snapshot = { + ...createEmptyAgentAnalysis("30d"), + requestScanLimit: 5000, + requestScanTruncated: true, + scannedRequestCount: 5000 + }; + + const html = renderToStaticMarkup( + + undefined} + selectedSession={{ agent: "codex", id: "missing-session" }} + setAgentFilter={() => undefined} + setRange={() => undefined} + setSelectedSession={() => undefined} + snapshot={snapshot} + /> + + ); + + assert.match(html, /分析结果仅包含最新的/); + assert.match(html, /5,000/); + assert.match(html, /未找到该会话,或它不在当前时间范围内/); + assert.doesNotMatch(html, /正在加载会话指标/); +}); From 2a7aa9286b6f9e4a48be8410c96925396e60caa3 Mon Sep 17 00:00:00 2001 From: musistudio Date: Tue, 11 Aug 2026 16:15:53 +0800 Subject: [PATCH 11/18] Update router configuration and provider handling --- .../docs/en/configuration/observability.md | 2 +- .../docs/zh/configuration/observability.md | 2 +- packages/core/src/config/constants.ts | 1 + packages/core/src/contracts/app.ts | 24 + packages/core/src/contracts/ipc-channels.ts | 1 + .../core/src/observability/raw-trace-sync.ts | 4 +- .../src/observability/request-log-limits.ts | 8 +- .../src/observability/request-log-runtime.ts | 13 +- .../src/observability/request-log-store.ts | 528 ++++++++++++++++-- .../src/observability/request-log-worker.ts | 40 +- packages/core/src/web/management-server.ts | 4 +- .../request-log-runtime.test.mjs | 77 ++- .../observability/request-log-store.test.mjs | 98 ++++ .../observability/raw-trace-sync.test.mjs | 9 +- packages/electron/src/main/ipc.ts | 3 +- packages/electron/src/main/preload.ts | 3 + .../pages/home/components/network-logs.tsx | 361 ++++++++++-- .../ui/src/pages/home/shared/external.tsx | 3 +- packages/ui/src/pages/home/shared/i18n.tsx | 3 +- .../home/shared/log-body-worker-protocol.ts | 28 +- .../src/pages/home/shared/log-body.worker.ts | 17 +- packages/ui/src/pages/home/shared/logs.ts | 2 + packages/ui/src/types/electron.d.ts | 3 + packages/ui/src/web-client-bridge.ts | 1 + packages/ui/test/unit/network-format.test.ts | 22 + 25 files changed, 1119 insertions(+), 138 deletions(-) diff --git a/docs/src/content/docs/en/configuration/observability.md b/docs/src/content/docs/en/configuration/observability.md index 63d071a6..6651b632 100644 --- a/docs/src/content/docs/en/configuration/observability.md +++ b/docs/src/content/docs/en/configuration/observability.md @@ -33,5 +33,5 @@ Request body / response body logging is governed by three low-level config optio | Option | Default | Description | | --- | --- | --- | | `requestLogBodyCapture` | `all` | Whether to record request and response bodies: `all` records all, `errors` records bodies only for failed requests, `none` records no bodies. | -| `requestLogMaxBodyBytes` | `52428800` (50 MiB) | Maximum bytes for a single request or response body; anything larger is truncated. The hard cap is also 50 MiB. | +| `requestLogMaxBodyBytes` | `52428800` (50 MiB) | In-memory body capture and preview budget for a single request or response body. File-backed raw trace bodies can exceed this limit; the full body is stored in sidecar storage and loaded on demand from the Logs page. | | `requestLogSuccessSampleRate` | `1` | Sampling rate for successful requests, between `0` and `1`. `1` records all, `0.1` records roughly one in ten. | diff --git a/docs/src/content/docs/zh/configuration/observability.md b/docs/src/content/docs/zh/configuration/observability.md index 2ad49756..84724eec 100644 --- a/docs/src/content/docs/zh/configuration/observability.md +++ b/docs/src/content/docs/zh/configuration/observability.md @@ -33,5 +33,5 @@ lead: 本页是日志与观测相关开关和面板能力的配置参考:在 | 配置项 | 默认值 | 说明 | | --- | --- | --- | | `requestLogBodyCapture` | `all` | 控制是否记录请求体和响应体:`all` 记录全部,`errors` 只记录失败请求的 body,`none` 不记录任何 body。 | -| `requestLogMaxBodyBytes` | `52428800`(50 MiB) | 单个请求体或响应体的最大字节数,超出部分会被截断;硬上限同样是 50 MiB。 | +| `requestLogMaxBodyBytes` | `52428800`(50 MiB) | 单个请求体或响应体的内存捕获与预览预算。基于文件的 raw trace body 可以超过该限制;完整 body 会保存到 sidecar 存储,并在日志页按需加载。 | | `requestLogSuccessSampleRate` | `1` | 成功请求的采样率,取 `0` 到 `1` 之间,`1` 表示全部记录,`0.1` 表示记录约十分之一。 | diff --git a/packages/core/src/config/constants.ts b/packages/core/src/config/constants.ts index 3c216a37..5f59b38f 100644 --- a/packages/core/src/config/constants.ts +++ b/packages/core/src/config/constants.ts @@ -27,6 +27,7 @@ export const PROXY_CA_CERT_FILE = path.join(CERTDIR, "ca.pem"); export const PROXY_CA_CERT_DER_FILE = path.join(CERTDIR, "ca.cer"); export const PROXY_CA_KEY_FILE = path.join(CERTDIR, "key.pem"); export const REQUEST_LOGS_DB_FILE = path.join(DATADIR, "request-logs.sqlite"); +export const REQUEST_LOG_BODIES_DIR = path.join(DATADIR, "request-log-bodies"); export const CONTEXT_ARCHIVE_DB_FILE = path.join(DATADIR, "context-archive.sqlite"); export const RAW_TRACE_SPOOL_DIR = path.join(DATADIR, "raw-trace-spool"); export const USAGE_DB_FILE = path.join(DATADIR, "usage.sqlite"); diff --git a/packages/core/src/contracts/app.ts b/packages/core/src/contracts/app.ts index 2103e0cb..4d316a3b 100644 --- a/packages/core/src/contracts/app.ts +++ b/packages/core/src/contracts/app.ts @@ -1935,10 +1935,12 @@ export type ProxyCertificateInstallResult = { export type ProxyNetworkCaptureState = "complete" | "error" | "pending"; export type ProxyNetworkBody = { + bodyRef?: string; contentType?: string; decodedFrom?: string; encoding: "base64" | "utf8"; error?: string; + preview?: boolean; sizeBytes: number; text: string; truncated: boolean; @@ -1993,6 +1995,28 @@ export type RequestLogDetailRequest = { export type RequestLogBody = ProxyNetworkBody; +export type RequestLogBodySide = "request" | "response"; + +export type RequestLogBodyChunkRequest = { + id: number; + length?: number; + offset?: number; + side: RequestLogBodySide; +}; + +export type RequestLogBodyChunk = { + bodyRef?: string; + contentType?: string; + encoding: "base64" | "utf8"; + eof: boolean; + length: number; + nextOffset?: number; + offset: number; + sizeBytes: number; + text: string; + truncated: boolean; +}; + export type RequestLogRetryAttempt = { attempt: number; delayMs: number; diff --git a/packages/core/src/contracts/ipc-channels.ts b/packages/core/src/contracts/ipc-channels.ts index 8b736481..5cb5cdce 100644 --- a/packages/core/src/contracts/ipc-channels.ts +++ b/packages/core/src/contracts/ipc-channels.ts @@ -21,6 +21,7 @@ export const IPC_CHANNELS = { appGetProxyNetworkCaptures: "ccr:app:get-proxy-network-captures", appGetProxyStatus: "ccr:app:get-proxy-status", appGetRequestLogDetail: "ccr:app:get-request-log-detail", + appGetRequestLogBodyChunk: "ccr:app:get-request-log-body-chunk", appGetRequestLogs: "ccr:app:get-request-logs", appGetUpdateStatus: "ccr:app:get-update-status", appGetUsageStats: "ccr:app:get-usage-stats", diff --git a/packages/core/src/observability/raw-trace-sync.ts b/packages/core/src/observability/raw-trace-sync.ts index 02b5f0ed..109c74d3 100644 --- a/packages/core/src/observability/raw-trace-sync.ts +++ b/packages/core/src/observability/raw-trace-sync.ts @@ -16,7 +16,7 @@ import { suppressRequestLogRawTraceBodies, type RequestLogEnqueueResult } from "@ccr/core/observability/request-log-runtime"; -import { resolveRawTraceBodyLimit } from "@ccr/core/observability/request-log-limits"; +import { rawTraceMaxPartBytes, resolveRawTraceBodyLimit } from "@ccr/core/observability/request-log-limits"; import { isRecord, numberValue, stringValue } from "@ccr/core/gateway/internal/value"; import { formatError, parseJsonObject, readHeader, readRequestBody, sendJson } from "@ccr/core/gateway/http/io"; import { endpoint } from "@ccr/core/gateway/core-runtime/supervisor"; @@ -1415,7 +1415,7 @@ export function buildRawTraceConfig(config: AppConfig, rawTraceSyncToken: string return { deleteLocalAfterUpload: false, enabled, - maxPartBytes: maxBodyBytes, + maxPartBytes: rawTraceMaxPartBytes, mode: "wire_raw", spoolDir: RAW_TRACE_SPOOL_DIR, sync: { diff --git a/packages/core/src/observability/request-log-limits.ts b/packages/core/src/observability/request-log-limits.ts index 169d6567..4153eb2e 100644 --- a/packages/core/src/observability/request-log-limits.ts +++ b/packages/core/src/observability/request-log-limits.ts @@ -1,9 +1,11 @@ -// Raw traces are produced outside the request-log runtime, so keep an -// independent hard ceiling as defense in depth if configuration validation is -// bypassed or its public range grows in the future. +// Keep the public body-capture setting bounded for in-memory previews and +// admission accounting. Raw trace sources can write larger parts because the +// request-log store moves them into sidecar files instead of keeping them +// inline in SQLite. export const rawTraceHardMaxBodyBytes = 50 * 1024 * 1024; export const maxRequestLogBodyBytes = rawTraceHardMaxBodyBytes; export const defaultRequestLogBodyBytes = rawTraceHardMaxBodyBytes; +export const rawTraceMaxPartBytes = Number.MAX_SAFE_INTEGER; export function resolveRawTraceBodyLimit(value: number | undefined): number { const configured = value ?? defaultRequestLogBodyBytes; diff --git a/packages/core/src/observability/request-log-runtime.ts b/packages/core/src/observability/request-log-runtime.ts index afc07319..81cb7520 100644 --- a/packages/core/src/observability/request-log-runtime.ts +++ b/packages/core/src/observability/request-log-runtime.ts @@ -10,6 +10,8 @@ import type { AgentAnalysisTracePayloadFullResult, AgentAnalysisTracePayloadRequest, RequestLogDetailRequest, + RequestLogBodyChunk, + RequestLogBodyChunkRequest, RequestLogEntry, RequestLogListFilter, RequestLogPage @@ -309,6 +311,10 @@ export class RequestLogRuntime { return await this.query("getDetail", [request]); } + async getBodyChunk(request: RequestLogBodyChunkRequest): Promise { + return await this.query("getBodyChunk", [request]); + } + async analyze(filter?: AgentAnalysisFilter): Promise { return await this.query("analyze", [filter]); } @@ -1027,12 +1033,17 @@ function constrainRawTraceFiles( export function suppressRequestLogRawTraceBodies( input: RequestLogRawTraceUpdateInput ): RequestLogRawTraceUpdateInput { + const { + requestBodyRef: _requestBodyRef, + responseBodyRef: _responseBodyRef, + ...metadata + } = input; const requestSize = input.requestBodySizeBytes ?? (input.requestBodyText === undefined ? undefined : Buffer.byteLength(input.requestBodyText)); const responseSize = input.responseBodySizeBytes ?? (input.responseBodyText === undefined ? undefined : Buffer.byteLength(input.responseBodyText)); return { - ...input, + ...metadata, ...(requestSize === undefined ? {} : { requestBodySizeBytes: requestSize, requestBodyText: "", diff --git a/packages/core/src/observability/request-log-store.ts b/packages/core/src/observability/request-log-store.ts index b84574c9..9682dc61 100644 --- a/packages/core/src/observability/request-log-store.ts +++ b/packages/core/src/observability/request-log-store.ts @@ -1,7 +1,8 @@ -import { mkdirSync } from "node:fs"; -import { dirname } from "node:path"; +import { randomUUID } from "node:crypto"; +import { closeSync, copyFileSync, existsSync, fstatSync, mkdirSync, openSync, readSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; import { StringDecoder } from "node:string_decoder"; -import { REQUEST_LOGS_DB_FILE } from "@ccr/core/config/constants"; +import { REQUEST_LOG_BODIES_DIR, REQUEST_LOGS_DB_FILE } from "@ccr/core/config/constants"; import { decodeClaudeAppGatewayRouteId } from "@ccr/core/agents/claude-app/gateway-routes"; import { estimateUsageCostUsd, @@ -45,6 +46,9 @@ import type { GatewayProviderProtocol, ProviderModelPricing, RequestLogBody, + RequestLogBodyChunk, + RequestLogBodyChunkRequest, + RequestLogBodySide, RequestLogDetailRequest, RequestLogEntry, RequestLogFilterOptions, @@ -147,6 +151,7 @@ export type RequestLogRawTraceUpdateInput = { path?: string; provider?: string; requestBodyContentType?: string; + requestBodyRef?: string; requestBodySizeBytes?: number; requestBodyText?: string; requestBodyTruncated?: boolean; @@ -154,6 +159,7 @@ export type RequestLogRawTraceUpdateInput = { requestId: string; isStream?: boolean; responseBodyContentType?: string; + responseBodyRef?: string; responseBodySizeBytes?: number; responseBodyText?: string; responseBodyTruncated?: boolean; @@ -299,6 +305,8 @@ type ToolCallStreamState = { }; const maxBodyBytes = maxRequestLogBodyBytes; +const requestLogInlineBodyBytes = 160 * 1024; +const requestLogBodyChunkMaxBytes = 1024 * 1024; const maxAgentAnalysisRows = 5000; const maxAgentSessionDetailRequests = 250; const maxTracePayloadPreviewChars = 1600; @@ -326,7 +334,9 @@ const terminalSseResponseStatuses = new Set([ ]); const requestLogBodyMetadataSelect = ` '' AS request_body_text, - '' AS response_body_text + '' AS response_body_text, + request_body_ref, + response_body_ref `; const emptyAgentAnalysisTotals: AgentAnalysisTotals = { avgDurationMs: 0, @@ -365,7 +375,12 @@ export class RequestLogStore { private revision = 0; private analysisCache?: AgentAnalysisCacheEntry; - constructor(private readonly dbFile: string) {} + constructor( + private readonly dbFile: string, + private readonly bodyDir = dbFile === REQUEST_LOGS_DB_FILE + ? REQUEST_LOG_BODIES_DIR + : join(dirname(dbFile), "request-log-bodies") + ) {} async initialize(): Promise { await this.getDatabase(); @@ -404,6 +419,9 @@ export class RequestLogStore { if (requestId) { const pending = this.takePendingRawTraceUpdate(database, requestId); if (pending) { + if (command.input.captureBody === false) { + deleteRequestLogBodyRefs(this.bodyDir, bodyRefsFromRawTraceInput(pending)); + } const pendingInput = command.input.captureBody === false ? suppressRequestLogRawTraceBodies(pending) : pending; @@ -419,11 +437,12 @@ export class RequestLogStore { if (bundleId && hasProcessedRawTraceBundle(database, bundleId)) { continue; } - const applied = await this.updateFromRawTrace(command.input); + const rawTraceInput = this.prepareRawTraceInput(command.input, command.rawTraceFiles); + const applied = await this.updateFromRawTrace(rawTraceInput); if (bundleId && applied) { - rememberProcessedRawTraceBundle(database, bundleId, command.input.requestId); + rememberProcessedRawTraceBundle(database, bundleId, rawTraceInput.requestId); } else if (!applied) { - this.storePendingRawTraceUpdate(database, command.input); + this.storePendingRawTraceUpdate(database, rawTraceInput); } } database.exec("COMMIT"); @@ -573,7 +592,8 @@ export class RequestLogStore { : await estimateUsageCostUsd(costInput); const capturedRequestBody = bodyFromBuffer( input.requestBody, - headerValue(requestHeaders, "content-type") + headerValue(requestHeaders, "content-type"), + { bodyDir: this.bodyDir, side: "request" } ); const requestBody: RequestLogBody = { ...capturedRequestBody, @@ -584,7 +604,9 @@ export class RequestLogStore { responseBodyText, headerValue(responseHeaders, "content-type"), Boolean(input.responseBodyTruncated), - input.responseBodySizeBytes + input.responseBodySizeBytes, + undefined, + { bodyDir: this.bodyDir, side: "response" } ); const isStream = inferRequestLogIsStream({ path: input.path, @@ -636,13 +658,15 @@ export class RequestLogStore { request_body_content_type, request_body_size_bytes, request_body_truncated, + request_body_ref, response_body_text, response_body_encoding, response_body_content_type, response_body_size_bytes, response_body_truncated, + response_body_ref, error - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); let inserted = false; @@ -687,11 +711,13 @@ export class RequestLogStore { requestBody.contentType ?? "", requestBody.sizeBytes, requestBody.truncated ? 1 : 0, + requestBody.bodyRef ?? "", responseBody.text, responseBody.encoding, responseBody.contentType ?? "", responseBody.sizeBytes, responseBody.truncated ? 1 : 0, + responseBody.bodyRef ?? "", responseError ?? "" ); if (result.changes === 0) return; @@ -774,6 +800,9 @@ export class RequestLogStore { rawInput, finalSuccessful ); + if (captureResolution.bodiesSuppressed) { + deleteRequestLogBodyRefs(this.bodyDir, bodyRefsFromRawTraceInput(rawInput)); + } const input = captureResolution.input; const existingUsageContext = readRequestLogUsageContext(database, requestId); @@ -908,27 +937,33 @@ export class RequestLogStore { url }) ? 1 : 0); } - if (input.requestBodyText !== undefined && ( - captureResolution.bodiesSuppressed || input.requestBodyText.length > 0 || !existingOutcome.hasRequestBody + const shouldApplyRequestBody = input.requestBodyText !== undefined || + Boolean(input.requestBodyRef && (!input.requestBodyTruncated || !existingOutcome.hasRequestBody)); + if (shouldApplyRequestBody && ( + captureResolution.bodiesSuppressed || Boolean(input.requestBodyRef) || (input.requestBodyText?.length ?? 0) > 0 || !existingOutcome.hasRequestBody )) { const requestBody = bodyFromText( - input.requestBodyText, + input.requestBodyText ?? "", input.requestBodyContentType ?? headerValue(mergedRequestHeaders ?? {}, "content-type"), Boolean(input.requestBodyTruncated), input.requestBodySizeBytes, - rawTraceHardMaxBodyBytes + rawTraceHardMaxBodyBytes, + { bodyDir: this.bodyDir, bodyRef: input.requestBodyRef, side: "request" } ); pushBodyValues(sets, params, "request", requestBody); } - if (input.responseBodyText !== undefined && ( - captureResolution.bodiesSuppressed || input.responseBodyText.length > 0 || !existingOutcome.hasResponseBody + const shouldApplyResponseBody = input.responseBodyText !== undefined || + Boolean(input.responseBodyRef && (!input.responseBodyTruncated || !existingOutcome.hasResponseBody)); + if (shouldApplyResponseBody && ( + captureResolution.bodiesSuppressed || Boolean(input.responseBodyRef) || (input.responseBodyText?.length ?? 0) > 0 || !existingOutcome.hasResponseBody )) { const responseBody = bodyFromText( - input.responseBodyText, + input.responseBodyText ?? "", responseBodyContentType, Boolean(input.responseBodyTruncated), input.responseBodySizeBytes, - rawTraceHardMaxBodyBytes + rawTraceHardMaxBodyBytes, + { bodyDir: this.bodyDir, bodyRef: input.responseBodyRef, side: "response" } ); pushBodyValues(sets, params, "response", responseBody); } @@ -1036,6 +1071,75 @@ export class RequestLogStore { return entry; } + async getBodyChunk(request: RequestLogBodyChunkRequest): Promise { + const database = await this.getDatabase(); + const requestLogId = normalizeCount(request.id); + const side = request.side === "response" ? "response" : "request"; + if (requestLogId <= 0) { + return undefined; + } + + const row = queryRows( + database, + ` + SELECT + ${side}_body_text AS body_text, + ${side}_body_encoding AS body_encoding, + ${side}_body_content_type AS body_content_type, + ${side}_body_size_bytes AS body_size_bytes, + ${side}_body_truncated AS body_truncated, + ${side}_body_ref AS body_ref + FROM request_logs + WHERE rowid = ? + LIMIT 1 + `, + [requestLogId] + )[0]; + if (!row) { + return undefined; + } + + const offset = clampInteger(request.offset, 0, Number.MAX_SAFE_INTEGER, 0); + const length = clampInteger(request.length, 1, requestLogBodyChunkMaxBytes, requestLogBodyChunkMaxBytes); + const encoding = String(row.body_encoding ?? "utf8") === "base64" ? "base64" : "utf8"; + const contentType = normalizeFilterValue(String(row.body_content_type ?? "")); + const sizeBytes = normalizeCount(row.body_size_bytes); + const truncated = normalizeCount(row.body_truncated) === 1; + const bodyRef = normalizeFilterValue(String(row.body_ref ?? "")); + + if (bodyRef) { + const filePath = requestLogBodyPath(this.bodyDir, bodyRef); + if (filePath && existsSync(filePath)) { + return readRequestLogBodyChunkFromFile({ + bodyRef, + contentType, + encoding, + filePath, + length, + offset, + sizeBytes, + truncated + }); + } + } + + const text = String(row.body_text ?? ""); + const visible = text.slice(offset, offset + length); + const nextOffset = offset + visible.length; + return { + ...(bodyRef ? { bodyRef } : {}), + contentType, + encoding, + eof: nextOffset >= text.length, + length: visible.length, + ...(nextOffset < text.length ? { nextOffset } : {}), + offset, + sizeBytes: Math.max(sizeBytes, text.length), + text: visible, + truncated + }; + } + async analyze(filter: AgentAnalysisFilter = {}): Promise { const database = await this.getDatabase(); this.pruneOldRequestLogs(database); @@ -1091,11 +1195,13 @@ export class RequestLogStore { request_body_content_type, request_body_size_bytes, request_body_truncated, + request_body_ref, response_body_text, response_body_encoding, response_body_content_type, response_body_size_bytes, response_body_truncated, + response_body_ref, error FROM request_logs WHERE source_usage_id IS NULL @@ -1254,11 +1360,13 @@ export class RequestLogStore { request_body_content_type TEXT NOT NULL DEFAULT '', request_body_size_bytes INTEGER NOT NULL DEFAULT 0, request_body_truncated INTEGER NOT NULL DEFAULT 0, + request_body_ref TEXT NOT NULL DEFAULT '', response_body_text TEXT NOT NULL DEFAULT '', response_body_encoding TEXT NOT NULL DEFAULT 'utf8', response_body_content_type TEXT NOT NULL DEFAULT '', response_body_size_bytes INTEGER NOT NULL DEFAULT 0, response_body_truncated INTEGER NOT NULL DEFAULT 0, + response_body_ref TEXT NOT NULL DEFAULT '', error TEXT NOT NULL DEFAULT '' ); @@ -1349,9 +1457,23 @@ export class RequestLogStore { return; } + const refs = queryRows( + database, + ` + SELECT request_body_ref, response_body_ref + FROM request_logs + WHERE source_usage_id IS NULL AND created_at < ? + `, + [cutoff] + ).flatMap((row) => [ + normalizeFilterValue(String(row.request_body_ref ?? "")), + normalizeFilterValue(String(row.response_body_ref ?? "")) + ]).filter((value): value is string => Boolean(value)); + database.prepare( "DELETE FROM request_logs WHERE source_usage_id IS NULL AND created_at < ?", ).run(cutoff); + deleteRequestLogBodyRefs(this.bodyDir, refs); this.lastRetentionCleanupDay = dayKey; } @@ -1370,7 +1492,7 @@ export class RequestLogStore { update_json = excluded.update_json `).run(requestId, now, serialized.bytes, serialized.json); } - prunePendingRawTraceUpdates(database, now); + prunePendingRawTraceUpdates(database, now, this.bodyDir); } private takePendingRawTraceUpdate(database: SqlDatabase, requestId: string): RequestLogRawTraceUpdateInput | undefined { @@ -1384,6 +1506,49 @@ export class RequestLogStore { const parsed = parseJson(String(row.update_json ?? "")); return isRecord(parsed) ? parsed as RequestLogRawTraceUpdateInput : undefined; } + + private prepareRawTraceInput( + input: RequestLogRawTraceUpdateInput, + rawTraceFiles?: RequestLogRawTraceFiles + ): RequestLogRawTraceUpdateInput { + const next: RequestLogRawTraceUpdateInput = { ...input }; + const requestBody = storeRawTraceBodyFile(this.bodyDir, rawTraceFiles?.requestBody); + if (requestBody) { + next.requestBodyRef = requestBody.bodyRef; + if (!requestBody.truncated) next.requestBodyText ??= requestBody.previewText; + next.requestBodyContentType ??= requestBody.contentType; + next.requestBodySizeBytes = Math.max(normalizeCount(next.requestBodySizeBytes), requestBody.sizeBytes); + next.requestBodyTruncated = Boolean(next.requestBodyTruncated) || requestBody.truncated; + } + const responseBody = storeRawTraceBodyFile(this.bodyDir, rawTraceFiles?.responseBody); + if (responseBody) { + next.responseBodyRef = responseBody.bodyRef; + if (!responseBody.truncated) next.responseBodyText ??= responseBody.previewText; + next.responseBodyContentType ??= responseBody.contentType; + next.responseBodySizeBytes = Math.max(normalizeCount(next.responseBodySizeBytes), responseBody.sizeBytes); + next.responseBodyTruncated = Boolean(next.responseBodyTruncated) || responseBody.truncated; + } + return this.prepareRawTraceTextBodies(next); + } + + private prepareRawTraceTextBodies(input: RequestLogRawTraceUpdateInput): RequestLogRawTraceUpdateInput { + const next: RequestLogRawTraceUpdateInput = { ...input }; + if (!next.requestBodyRef && next.requestBodyText !== undefined) { + const stored = storeBodyBuffer(this.bodyDir, Buffer.from(next.requestBodyText), next.requestBodyRef); + if (stored) { + next.requestBodyRef = stored.bodyRef; + next.requestBodySizeBytes = Math.max(Buffer.byteLength(next.requestBodyText), normalizeCount(next.requestBodySizeBytes)); + } + } + if (!next.responseBodyRef && next.responseBodyText !== undefined) { + const stored = storeBodyBuffer(this.bodyDir, Buffer.from(next.responseBodyText), next.responseBodyRef); + if (stored) { + next.responseBodyRef = stored.bodyRef; + next.responseBodySizeBytes = Math.max(Buffer.byteLength(next.responseBodyText), normalizeCount(next.responseBodySizeBytes)); + } + } + return next; + } } export const requestLogStore = new RequestLogStore(REQUEST_LOGS_DB_FILE); @@ -1430,6 +1595,15 @@ export async function getRequestLogDetail(request: RequestLogDetailRequest): Pro } } +export async function getRequestLogBodyChunk(request: RequestLogBodyChunkRequest): Promise { + try { + return await requestLogRuntime.getBodyChunk(request); + } catch (error) { + console.warn(`[request-log] Failed to read request log body chunk: ${formatError(error)}`); + throw error; + } +} + export async function getAgentAnalysis(filter?: AgentAnalysisFilter): Promise { try { return await requestLogRuntime.analyze(filter); @@ -3452,11 +3626,13 @@ function ensureRequestLogSchema(database: SqlDatabase): void { addColumn("request_body_content_type", "TEXT NOT NULL DEFAULT ''"); addColumn("request_body_size_bytes", "INTEGER NOT NULL DEFAULT 0"); addColumn("request_body_truncated", "INTEGER NOT NULL DEFAULT 0"); + addColumn("request_body_ref", "TEXT NOT NULL DEFAULT ''"); addColumn("response_body_text", "TEXT NOT NULL DEFAULT ''"); addColumn("response_body_encoding", "TEXT NOT NULL DEFAULT 'utf8'"); addColumn("response_body_content_type", "TEXT NOT NULL DEFAULT ''"); addColumn("response_body_size_bytes", "INTEGER NOT NULL DEFAULT 0"); addColumn("response_body_truncated", "INTEGER NOT NULL DEFAULT 0"); + addColumn("response_body_ref", "TEXT NOT NULL DEFAULT ''"); addColumn("error", "TEXT NOT NULL DEFAULT ''"); if (needsModelSummaryMigration) { @@ -4048,11 +4224,13 @@ function readRequestLogById(database: SqlDatabase, id: number): StoredRequestLog request_body_content_type, request_body_size_bytes, request_body_truncated, + request_body_ref, response_body_text, response_body_encoding, response_body_content_type, response_body_size_bytes, response_body_truncated, + response_body_ref, error FROM request_logs WHERE rowid = ? @@ -4126,7 +4304,9 @@ function bodyFromRow(row: Record, prefix: "request" | "respons const encoding = String(row[`${prefix}_body_encoding`] ?? "utf8") === "base64" ? "base64" : "utf8"; const contentType = normalizeFilterValue(String(row[`${prefix}_body_content_type`] ?? "")); + const bodyRef = normalizeFilterValue(String(row[`${prefix}_body_ref`] ?? "")); return { + ...(bodyRef ? { bodyRef, preview: true } : {}), contentType, encoding, sizeBytes, @@ -4183,17 +4363,35 @@ function readDistinctValues(database: SqlDatabase, column: "credential_id" | "mo .filter(Boolean); } -function bodyFromBuffer(buffer: Buffer, contentType?: string): RequestLogBody { +type RequestLogBodyStorageOptions = { + bodyDir: string; + bodyRef?: string; + side: RequestLogBodySide; +}; + +function bodyFromBuffer( + buffer: Buffer, + contentType?: string, + storage?: RequestLogBodyStorageOptions +): RequestLogBody { const compacted = compactBase64ImagePayloads(buffer); const exceedsCaptureLimit = compacted.buffer.byteLength > maxBodyBytes; const data = exceedsCaptureLimit ? compacted.buffer.subarray(0, maxBodyBytes) : compacted.buffer; const textLike = isTextLikeContentType(contentType); + const stored = textLike && storage + ? storeBodyBuffer(storage.bodyDir, buffer, storage.bodyRef, compacted.buffer) + : undefined; + const text = textLike + ? stored?.previewText ?? data.toString("utf8") + : data.toString("base64"); + const truncated = !stored && (compacted.compacted || exceedsCaptureLimit); return { + ...(stored ? { bodyRef: stored.bodyRef, preview: true } : {}), contentType, encoding: textLike ? "utf8" : "base64", sizeBytes: buffer.byteLength, - text: textLike ? data.toString("utf8") : data.toString("base64"), - truncated: compacted.compacted || exceedsCaptureLimit + text, + truncated }; } @@ -4202,20 +4400,29 @@ function bodyFromText( contentType?: string, alreadyTruncated = false, originalSizeBytes?: number, - captureLimitBytes = maxBodyBytes + captureLimitBytes = maxBodyBytes, + storage?: RequestLogBodyStorageOptions ): RequestLogBody { const buffer = Buffer.from(text); const sizeBytes = Math.max(buffer.byteLength, normalizeCount(originalSizeBytes)); const compacted = compactBase64ImagePayloads(buffer); - const truncated = alreadyTruncated || compacted.compacted || buffer.byteLength < sizeBytes || - compacted.buffer.byteLength > captureLimitBytes; const exceedsCaptureLimit = compacted.buffer.byteLength > captureLimitBytes; const data = exceedsCaptureLimit ? compacted.buffer.subarray(0, captureLimitBytes) : compacted.buffer; + const stored = storage + ? storeBodyBuffer(storage.bodyDir, buffer, storage.bodyRef, compacted.buffer) + : undefined; + const truncated = alreadyTruncated || (!stored && ( + compacted.compacted || + buffer.byteLength < sizeBytes || + exceedsCaptureLimit + )); + const bodyText = stored?.previewText ?? (exceedsCaptureLimit ? new StringDecoder("utf8").write(data) : data.toString("utf8")); return { + ...(stored ? { bodyRef: stored.bodyRef, preview: true } : {}), contentType, encoding: "utf8", sizeBytes, - text: exceedsCaptureLimit ? new StringDecoder("utf8").write(data) : data.toString("utf8"), + text: bodyText, truncated }; } @@ -4236,6 +4443,231 @@ function pushBodyValues( params.push(body.sizeBytes); sets.push(`${prefix}_body_truncated = ?`); params.push(body.truncated ? 1 : 0); + sets.push(`${prefix}_body_ref = ?`); + params.push(body.bodyRef ?? ""); +} + +type StoredRequestLogBodyFile = { + bodyRef: string; + contentType?: string; + previewText: string; + sizeBytes: number; + truncated: boolean; +}; + +function storeRawTraceBodyFile( + bodyDir: string, + file: RequestLogRawTraceFile | undefined +): StoredRequestLogBodyFile | undefined { + if (!file || file.sizeBytes <= 0 || !existsSync(file.filePath)) { + return undefined; + } + const bodyRef = createRequestLogBodyRef(); + const target = requestLogBodyPath(bodyDir, bodyRef, true); + if (!target) { + return undefined; + } + copyFileSync(file.filePath, target); + const storedBytes = statSync(target).size; + const sizeBytes = Math.max(file.sizeBytes, storedBytes); + return { + bodyRef, + contentType: file.contentType, + previewText: readRequestLogBodyPreview(target), + sizeBytes, + truncated: Boolean(file.truncated) || storedBytes < sizeBytes + }; +} + +function storeBodyBuffer( + bodyDir: string, + buffer: Buffer, + existingBodyRef?: string, + previewBuffer = buffer +): { bodyRef: string; previewText: string } | undefined { + const shouldStore = Boolean(existingBodyRef) || buffer.byteLength > requestLogInlineBodyBytes; + if (!shouldStore) { + return undefined; + } + const bodyRef = normalizeBodyRef(existingBodyRef) ?? createRequestLogBodyRef(); + const target = requestLogBodyPath(bodyDir, bodyRef, true); + if (!target) { + return undefined; + } + if (existingBodyRef && existsSync(target)) { + return { + bodyRef, + previewText: previewBuffer.byteLength > 0 + ? createRequestLogBodyPreviewText(previewBuffer) + : readRequestLogBodyPreview(target) + }; + } + if (!existingBodyRef || !existsSync(target)) { + writeFileSync(target, buffer); + } + return { + bodyRef, + previewText: createRequestLogBodyPreviewText(buffer) + }; +} + +function readRequestLogBodyChunkFromFile({ + bodyRef, + contentType, + encoding, + filePath, + length, + offset, + sizeBytes, + truncated +}: { + bodyRef: string; + contentType?: string; + encoding: "base64" | "utf8"; + filePath: string; + length: number; + offset: number; + sizeBytes: number; + truncated: boolean; +}): RequestLogBodyChunk { + const descriptor = openSync(filePath, "r"); + try { + const storedBytes = fstatSync(descriptor).size; + const boundedOffset = Math.max(0, Math.min(offset, storedBytes)); + const readLength = Math.max(0, Math.min( + encoding === "utf8" ? Math.max(length, 4) : length, + storedBytes - boundedOffset + )); + const buffer = Buffer.allocUnsafe(readLength); + let bytesRead = 0; + while (bytesRead < readLength) { + const count = readSync(descriptor, buffer, bytesRead, readLength - bytesRead, boundedOffset + bytesRead); + if (count === 0) break; + bytesRead += count; + } + const data = bytesRead === buffer.byteLength ? buffer : buffer.subarray(0, bytesRead); + const safeLength = encoding === "utf8" && boundedOffset + data.byteLength < storedBytes + ? validUtf8PrefixLength(data) + : data.byteLength; + const safeData = safeLength > 0 ? data.subarray(0, safeLength) : data; + const nextOffset = boundedOffset + safeData.byteLength; + return { + bodyRef, + contentType, + encoding, + eof: nextOffset >= storedBytes, + length: safeData.byteLength, + ...(nextOffset < storedBytes ? { nextOffset } : {}), + offset: boundedOffset, + sizeBytes: Math.max(sizeBytes, storedBytes), + text: encoding === "base64" ? safeData.toString("base64") : new StringDecoder("utf8").write(safeData), + truncated + }; + } finally { + closeSync(descriptor); + } +} + +function validUtf8PrefixLength(buffer: Buffer): number { + if (buffer.byteLength === 0) return 0; + let leadIndex = buffer.byteLength - 1; + while (leadIndex >= 0 && (buffer[leadIndex] & 0xc0) === 0x80) { + leadIndex -= 1; + } + if (leadIndex < 0) return 0; + const lead = buffer[leadIndex]; + if ((lead & 0x80) === 0) return buffer.byteLength; + const continuationBytes = buffer.byteLength - leadIndex - 1; + const expectedContinuationBytes = (lead & 0xe0) === 0xc0 + ? 1 + : (lead & 0xf0) === 0xe0 + ? 2 + : (lead & 0xf8) === 0xf0 + ? 3 + : 0; + if (expectedContinuationBytes === 0) return leadIndex; + return continuationBytes >= expectedContinuationBytes ? buffer.byteLength : leadIndex; +} + +function readRequestLogBodyPreview(filePath: string): string { + const descriptor = openSync(filePath, "r"); + try { + const size = fstatSync(descriptor).size; + if (size <= requestLogInlineBodyBytes) { + const buffer = Buffer.allocUnsafe(size); + readSync(descriptor, buffer, 0, size, 0); + return new StringDecoder("utf8").write(buffer); + } + const headBytes = Math.floor(requestLogInlineBodyBytes * 0.65); + const tailBytes = requestLogInlineBodyBytes - headBytes; + const head = Buffer.allocUnsafe(headBytes); + const tail = Buffer.allocUnsafe(tailBytes); + const headRead = readSync(descriptor, head, 0, headBytes, 0); + const tailRead = readSync(descriptor, tail, 0, tailBytes, Math.max(0, size - tailBytes)); + return createRequestLogPreviewFromParts( + head.subarray(0, headRead), + tail.subarray(0, tailRead), + Math.max(0, size - headRead - tailRead) + ); + } finally { + closeSync(descriptor); + } +} + +function createRequestLogBodyPreviewText(buffer: Buffer): string { + if (buffer.byteLength <= requestLogInlineBodyBytes) { + return new StringDecoder("utf8").write(buffer); + } + const headBytes = Math.floor(requestLogInlineBodyBytes * 0.65); + const tailBytes = requestLogInlineBodyBytes - headBytes; + return createRequestLogPreviewFromParts( + buffer.subarray(0, headBytes), + buffer.subarray(Math.max(0, buffer.byteLength - tailBytes)), + Math.max(0, buffer.byteLength - headBytes - tailBytes) + ); +} + +function createRequestLogPreviewFromParts(head: Buffer, tail: Buffer, omittedBytes: number): string { + return [ + new StringDecoder("utf8").write(head), + "", + `... ${omittedBytes} bytes omitted from preview ...`, + "", + new StringDecoder("utf8").write(tail) + ].join("\n"); +} + +function createRequestLogBodyRef(): string { + return randomUUID(); +} + +function requestLogBodyPath(bodyDir: string, bodyRef: string, createDirectory = false): string | undefined { + const normalized = normalizeBodyRef(bodyRef); + if (!normalized) { + return undefined; + } + const shard = normalized.slice(0, 2); + const directory = join(bodyDir, shard); + if (createDirectory) { + mkdirSync(directory, { recursive: true }); + } + return join(directory, normalized); +} + +function normalizeBodyRef(value: string | undefined): string | undefined { + const normalized = value?.trim(); + if (!normalized || !/^[A-Za-z0-9._-]+$/.test(normalized)) { + return undefined; + } + return normalized; +} + +function deleteRequestLogBodyRefs(bodyDir: string, refs: string[]): void { + for (const ref of refs) { + const filePath = requestLogBodyPath(bodyDir, ref); + if (!filePath) continue; + rmSync(filePath, { force: true }); + } } function isTextLikeContentType(contentType: string | undefined): boolean { @@ -4292,6 +4724,8 @@ function readRequestLogStoredOutcome(database: SqlDatabase, requestId: string): gateway_status_code, length(request_body_text) AS request_body_chars, length(response_body_text) AS response_body_chars, + request_body_ref, + response_body_ref, ok, status_code FROM request_logs @@ -4306,8 +4740,8 @@ function readRequestLogStoredOutcome(database: SqlDatabase, requestId: string): gatewayError: String(row?.gateway_error ?? ""), gatewayOk: normalizeCount(row?.gateway_ok) === 1, gatewayStatusCode: normalizeCount(row?.gateway_status_code), - hasRequestBody: normalizeCount(row?.request_body_chars) > 0, - hasResponseBody: normalizeCount(row?.response_body_chars) > 0, + hasRequestBody: normalizeCount(row?.request_body_chars) > 0 || Boolean(normalizeFilterValue(String(row?.request_body_ref ?? ""))), + hasResponseBody: normalizeCount(row?.response_body_chars) > 0 || Boolean(normalizeFilterValue(String(row?.response_body_ref ?? ""))), ok: normalizeCount(row?.ok) === 1, statusCode: normalizeCount(row?.status_code) }; @@ -4359,12 +4793,14 @@ function withBoundedRawTraceBodyTexts( ...(requestBodyText === undefined ? {} : { requestBodySizeBytes: Math.max(requestBytes, normalizeCount(input.requestBodySizeBytes)), requestBodyText, - requestBodyTruncated: Boolean(input.requestBodyTruncated) || Buffer.byteLength(requestBodyText) < requestBytes + requestBodyTruncated: Boolean(input.requestBodyTruncated) || + (!input.requestBodyRef && Buffer.byteLength(requestBodyText) < requestBytes) }), ...(responseBodyText === undefined ? {} : { responseBodySizeBytes: Math.max(responseBytes, normalizeCount(input.responseBodySizeBytes)), responseBodyText, - responseBodyTruncated: Boolean(input.responseBodyTruncated) || Buffer.byteLength(responseBodyText) < responseBytes + responseBodyTruncated: Boolean(input.responseBodyTruncated) || + (!input.responseBodyRef && Buffer.byteLength(responseBodyText) < responseBytes) }) }; } @@ -4389,14 +4825,14 @@ function withoutRawTraceBodyTexts(input: RequestLogRawTraceUpdateInput): Request Buffer.byteLength(requestBodyText), normalizeCount(input.requestBodySizeBytes) ), - requestBodyTruncated: true + requestBodyTruncated: Boolean(input.requestBodyTruncated) || !input.requestBodyRef }), ...(responseBodyText === undefined ? {} : { responseBodySizeBytes: Math.max( Buffer.byteLength(responseBodyText), normalizeCount(input.responseBodySizeBytes) ), - responseBodyTruncated: true + responseBodyTruncated: Boolean(input.responseBodyTruncated) || !input.responseBodyRef }) }; } @@ -4405,13 +4841,19 @@ function rawTraceHasBodyText(input: RequestLogRawTraceUpdateInput): boolean { return input.requestBodyText !== undefined || input.responseBodyText !== undefined; } -function prunePendingRawTraceUpdates(database: SqlDatabase, now: number): void { +function prunePendingRawTraceUpdates(database: SqlDatabase, now: number, bodyDir?: string): void { + const expiredRows = queryRows( + database, + "SELECT update_json FROM request_log_pending_updates WHERE received_at < ?", + [now - pendingRawTraceTtlMs] + ); + if (bodyDir) deleteRequestLogBodyRefs(bodyDir, bodyRefsFromPendingRawTraceRows(expiredRows)); database.prepare("DELETE FROM request_log_pending_updates WHERE received_at < ?") .run(now - pendingRawTraceTtlMs); const rows = queryRows( database, ` - SELECT request_id, update_bytes + SELECT request_id, update_bytes, update_json FROM request_log_pending_updates ORDER BY received_at DESC, request_id DESC ` @@ -4423,6 +4865,7 @@ function prunePendingRawTraceUpdates(database: SqlDatabase, now: number): void { const bytes = normalizeCount(row.update_bytes); if (retainedEntries >= maxPendingRawTraceEntries || retainedBytes + bytes > maxPendingRawTraceTotalBytes) { + if (bodyDir) deleteRequestLogBodyRefs(bodyDir, bodyRefsFromPendingRawTraceRows([row])); remove.run(String(row.request_id ?? "")); continue; } @@ -4431,6 +4874,21 @@ function prunePendingRawTraceUpdates(database: SqlDatabase, now: number): void { } } +function bodyRefsFromPendingRawTraceRows(rows: Record[]): string[] { + return rows.flatMap((row) => { + const parsed = parseJson(String(row.update_json ?? "")); + if (!isRecord(parsed)) return []; + return bodyRefsFromRawTraceInput(parsed as RequestLogRawTraceUpdateInput); + }); +} + +function bodyRefsFromRawTraceInput(input: RequestLogRawTraceUpdateInput): string[] { + return [ + normalizeFilterValue(String(input.requestBodyRef ?? "")), + normalizeFilterValue(String(input.responseBodyRef ?? "")) + ].filter((value): value is string => Boolean(value)); +} + function readRequestHeadersForRequestId(database: SqlDatabase, requestId: string): Record { const row = queryRows(database, "SELECT request_headers FROM request_logs WHERE request_id = ? LIMIT 1", [requestId])[0]; return row ? parseHeaderJson(row.request_headers) : {}; diff --git a/packages/core/src/observability/request-log-worker.ts b/packages/core/src/observability/request-log-worker.ts index f0385df8..17f5c87d 100644 --- a/packages/core/src/observability/request-log-worker.ts +++ b/packages/core/src/observability/request-log-worker.ts @@ -6,6 +6,7 @@ import { RAW_TRACE_SPOOL_DIR } from "@ccr/core/config/constants"; import { RequestLogStore, type RequestLogRawTraceFile, + type RequestLogRawTraceFiles, type RequestLogRecordInput, type RequestLogStoreWriteCommand } from "@ccr/core/observability/request-log-store"; @@ -13,6 +14,11 @@ import { resolveRawTraceBodyLimit } from "@ccr/core/observability/request-log-li import { compactBase64ImagePayloads } from "@ccr/core/observability/request-log-body"; import { preloadUsagePriceCatalog } from "@ccr/core/models/pricing-service"; +type RevivedRawTraceBody = RequestLogRawTraceFile & { + text: string; + textTruncated: boolean; +}; + type WorkerConfiguration = { dbFile: string; mode: "query" | "writer"; @@ -80,6 +86,9 @@ async function handleMessage(message: WorkerMessage): Promise { case "getDetail": result = await store.getDetail(args[0] as Parameters[0]); break; + case "getBodyChunk": + result = await store.getBodyChunk(args[0] as Parameters[0]); + break; case "getTracePayload": result = await store.getTracePayload(args[0] as Parameters[0]); break; @@ -140,9 +149,16 @@ function reviveCommand(command: RequestLogStoreWriteCommand): RequestLogStoreWri if (command.kind === "raw-trace-update") { const input = { ...command.input }; const maxBodyBytes = resolveRawTraceBodyLimit(command.rawTraceFiles?.maxBodyBytes); + const rawTraceFiles: RequestLogRawTraceFiles | undefined = command.rawTraceFiles + ? { + cleanupDirectory: command.rawTraceFiles.cleanupDirectory, + maxBodyBytes: command.rawTraceFiles.maxBodyBytes + } + : undefined; if (command.rawTraceFiles?.requestBody) { const body = readRawTraceBody(command.rawTraceFiles.requestBody, maxBodyBytes); if (body) { + if (rawTraceFiles) rawTraceFiles.requestBody = fileMetadataFromRawTraceBody(body); input.requestBodyContentType = body.contentType ?? input.requestBodyContentType; input.requestBodySizeBytes = body.sizeBytes; input.requestBodyTruncated = body.truncated; @@ -152,6 +168,7 @@ function reviveCommand(command: RequestLogStoreWriteCommand): RequestLogStoreWri if (command.rawTraceFiles?.responseBody) { const body = readRawTraceBody(command.rawTraceFiles.responseBody, maxBodyBytes); if (body) { + if (rawTraceFiles) rawTraceFiles.responseBody = fileMetadataFromRawTraceBody(body); input.responseBodyContentType = body.contentType ?? input.responseBodyContentType; input.responseBodySizeBytes = body.sizeBytes; input.responseBodyTruncated = body.truncated; @@ -161,6 +178,7 @@ function reviveCommand(command: RequestLogStoreWriteCommand): RequestLogStoreWri return { input, kind: "raw-trace-update", + ...(rawTraceFiles ? { rawTraceFiles } : {}), sequence: command.sequence }; } @@ -174,10 +192,19 @@ function reviveCommand(command: RequestLogStoreWriteCommand): RequestLogStoreWri }; } +function fileMetadataFromRawTraceBody(body: RevivedRawTraceBody): RequestLogRawTraceFile { + return { + contentType: body.contentType, + filePath: body.filePath, + sizeBytes: body.sizeBytes, + truncated: body.truncated + }; +} + function readRawTraceBody( file: RequestLogRawTraceFile, maxBodyBytes: number -): (RequestLogRawTraceFile & { text: string }) | undefined { +): RevivedRawTraceBody | undefined { let descriptor: number | undefined; try { const filePath = verifiedRawTracePath(file.filePath); @@ -193,13 +220,14 @@ function readRawTraceBody( } const captured = offset === buffer.byteLength ? buffer : buffer.subarray(0, offset); const compacted = compactBase64ImagePayloads(captured); + const sourceSizeBytes = Math.max(file.sizeBytes, storedBytes); return { ...file, filePath, - sizeBytes: Math.max(file.sizeBytes, storedBytes), + sizeBytes: sourceSizeBytes, text: new StringDecoder("utf8").write(compacted.buffer), - truncated: Boolean(file.truncated) || compacted.compacted || - offset < Math.max(file.sizeBytes, storedBytes) + textTruncated: offset < storedBytes, + truncated: Boolean(file.truncated) || storedBytes < sourceSizeBytes }; } catch (error) { if (errorCode(error) === "ENOENT") return undefined; @@ -209,8 +237,8 @@ function readRawTraceBody( } } -function shouldApplyRawTraceBodyText(body: RequestLogRawTraceFile & { text: string }): boolean { - if (!body.truncated) return true; +function shouldApplyRawTraceBodyText(body: RevivedRawTraceBody): boolean { + if (!body.truncated && !body.textTruncated) return true; const contentType = body.contentType?.split(";", 1)[0].trim().toLowerCase() ?? ""; const firstContentIndex = skipJsonWhitespace(body.text, 0); const firstContent = body.text.charCodeAt(firstContentIndex); diff --git a/packages/core/src/web/management-server.ts b/packages/core/src/web/management-server.ts index b5a1094f..6e232297 100644 --- a/packages/core/src/web/management-server.ts +++ b/packages/core/src/web/management-server.ts @@ -40,7 +40,7 @@ import { getPluginMarketplace } from "@ccr/core/plugins/marketplace"; import { ensureProxyCertificateAuthority } from "@ccr/core/proxy/certificates"; import { proxyService } from "@ccr/core/proxy/service"; import { listMcpServerTools } from "@ccr/core/mcp/tool-discovery"; -import { closeRequestLogRuntime, getAgentAnalysis, getAgentTracePayload, getRequestLogDetail, getRequestLogs } from "@ccr/core/observability/request-log-store"; +import { closeRequestLogRuntime, getAgentAnalysis, getAgentTracePayload, getRequestLogBodyChunk, getRequestLogDetail, getRequestLogs } from "@ccr/core/observability/request-log-store"; import { shouldRecordRequestLogs } from "@ccr/core/observability/raw-trace-sync"; import { getUsageStats } from "@ccr/core/usage/store"; import { gatewayService } from "@ccr/core/gateway/service"; @@ -78,6 +78,7 @@ import type { ProviderCatalogModelsRequest, ProviderIconDetectionRequest, ProviderManifestFetchRequest, + RequestLogBodyChunkRequest, RequestLogDetailRequest, RequestLogListFilter, RouteScriptTestRequest, @@ -334,6 +335,7 @@ const rpcHandlers: Record = { getProxyNetworkCaptures: () => proxyService.getNetworkCaptures(), getProxyStatus: () => proxyService.getStatus(), getRequestLogDetail: (request) => getRequestLogDetail(request as RequestLogDetailRequest), + getRequestLogBodyChunk: (request) => getRequestLogBodyChunk(request as RequestLogBodyChunkRequest), getRequestLogs: (filter) => getRequestLogs(filter as RequestLogListFilter | undefined), getUpdateStatus: () => unsupportedUpdateStatus, getUsageStats: (range, filter) => getUsageStats(range as UsageStatsRange | undefined, filter as UsageStatsFilter | undefined), diff --git a/packages/core/test/integration/observability/request-log-runtime.test.mjs b/packages/core/test/integration/observability/request-log-runtime.test.mjs index 95e317bf..073f78bf 100644 --- a/packages/core/test/integration/observability/request-log-runtime.test.mjs +++ b/packages/core/test/integration/observability/request-log-runtime.test.mjs @@ -323,13 +323,14 @@ test("RequestLogRuntime persists unmatched raw trace updates across worker resta } }); -test("RequestLogStore retains a bounded prefix for oversized unmatched raw trace bodies", async () => { +test("RequestLogStore stores oversized unmatched raw trace bodies in sidecar storage", async () => { const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-pending-entry-budget-test-")); const store = new RequestLogStore(path.join(dir, "request-logs.sqlite")); try { + const body = "s".repeat(3 * 1024 * 1024); await store.writeBatch([{ input: { - requestBodyText: "s".repeat(3 * 1024 * 1024), + requestBodyText: body, requestId: "oversized-pending-raw" }, kind: "raw-trace-update", @@ -344,25 +345,29 @@ test("RequestLogStore retains a bounded prefix for oversized unmatched raw trace const page = await store.list({ pageSize: 25 }); const detail = await store.getDetail({ id: page.items[0].id }); - assert.match(detail.requestBody.text, /^s+$/); - assert.equal(Buffer.byteLength(detail.requestBody.text), 512 * 1024); - assert.equal(detail.requestBody.sizeBytes, 3 * 1024 * 1024); - assert.equal(detail.requestBody.truncated, true); + assert.ok(detail.requestBody.bodyRef); + assert.equal(detail.requestBody.preview, true); + assert.match(detail.requestBody.text, /bytes omitted from preview/); + assert.equal(detail.requestBody.sizeBytes, Buffer.byteLength(body)); + assert.equal(detail.requestBody.truncated, false); + assert.equal(await readStoredBodyText(store, page.items[0].id, "request"), body); } finally { await store.close(); rmSync(dir, { force: true, recursive: true }); } }); -test("RequestLogStore bounds unmatched request and response bodies independently", async () => { +test("RequestLogStore stores unmatched request and response bodies independently", async () => { const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-pending-body-budget-test-")); const store = new RequestLogStore(path.join(dir, "request-logs.sqlite")); try { + const requestBody = "q".repeat(3 * 1024 * 1024); + const responseBody = "p".repeat(3 * 1024 * 1024); await store.writeBatch([{ input: { - requestBodyText: "q".repeat(3 * 1024 * 1024), + requestBodyText: requestBody, requestId: "oversized-pending-pair", - responseBodyText: "p".repeat(3 * 1024 * 1024) + responseBodyText: responseBody }, kind: "raw-trace-update", sequence: 1 @@ -376,12 +381,14 @@ test("RequestLogStore bounds unmatched request and response bodies independently const page = await store.list({ pageSize: 25 }); const detail = await store.getDetail({ id: page.items[0].id }); - assert.equal(Buffer.byteLength(detail.requestBody.text), 512 * 1024); - assert.equal(Buffer.byteLength(detail.responseBody.text), 512 * 1024); - assert.equal(detail.requestBody.sizeBytes, 3 * 1024 * 1024); - assert.equal(detail.responseBody.sizeBytes, 3 * 1024 * 1024); - assert.equal(detail.requestBody.truncated, true); - assert.equal(detail.responseBody.truncated, true); + assert.ok(detail.requestBody.bodyRef); + assert.ok(detail.responseBody.bodyRef); + assert.equal(detail.requestBody.sizeBytes, Buffer.byteLength(requestBody)); + assert.equal(detail.responseBody.sizeBytes, Buffer.byteLength(responseBody)); + assert.equal(detail.requestBody.truncated, false); + assert.equal(detail.responseBody.truncated, false); + assert.equal(await readStoredBodyText(store, page.items[0].id, "request"), requestBody); + assert.equal(await readStoredBodyText(store, page.items[0].id, "response"), responseBody); } finally { await store.close(); rmSync(dir, { force: true, recursive: true }); @@ -423,7 +430,9 @@ test("RequestLogStore enforces a total byte budget for unmatched raw trace rows" const oldest = await store.getDetail({ id: oldestEntry.id }); const newest = await store.getDetail({ id: newestEntry.id }); assert.match(oldest.requestBody.text, /worker-model/); - assert.match(newest.requestBody.text, /^(?:17)+$/); + assert.ok(newest.requestBody.bodyRef); + assert.match(newest.requestBody.text, /bytes omitted from preview/); + assert.match(await readStoredBodyText(store, newestEntry.id, "request"), /^(?:17)+$/); } finally { await store.close(); rmSync(dir, { force: true, recursive: true }); @@ -460,7 +469,8 @@ test("RequestLogRuntime preserves original response sizes when normal body captu assert.equal(metadataEntry.resolvedModel, "resolved-model"); assert.equal(metadataEntry.responseModel, "response-model"); assert.equal(truncated.responseBody.sizeBytes, Buffer.byteLength(fullResponse)); - assert.equal(Buffer.byteLength(truncated.responseBody.text), 512 * 1024); + assert.ok(truncated.responseBody.bodyRef); + assert.equal(Buffer.byteLength(await readStoredBodyText(runtime, truncatedEntry.id, "response")), 512 * 1024); assert.equal(truncated.responseBody.truncated, true); assert.equal(metadata.responseBody.sizeBytes, Buffer.byteLength("exists")); assert.equal(metadata.responseBody.text, ""); @@ -586,7 +596,7 @@ test("RequestLogRuntime keeps the record body-removal policy for later file-back } }); -test("RequestLogRuntime retains inline raw trace bodies below the default safety limit", async () => { +test("RequestLogRuntime stores large inline raw trace bodies in sidecar storage", async () => { const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-runtime-raw-body-test-")); const runtime = createRuntime(dir); try { @@ -604,7 +614,9 @@ test("RequestLogRuntime retains inline raw trace bodies below the default safety const page = await runtime.list({ pageSize: 25 }); const detail = await runtime.getDetail({ id: page.items[0].id }); assert.equal(detail.responseBody.sizeBytes, Buffer.byteLength(body)); - assert.equal(detail.responseBody.text.length, body.length); + assert.ok(detail.responseBody.bodyRef); + assert.match(detail.responseBody.text, /bytes omitted from preview/); + assert.equal(await readStoredBodyText(runtime, page.items[0].id, "response"), body); assert.equal(detail.responseBody.truncated, false); } finally { await runtime.close({ timeoutMs: 5_000 }); @@ -612,7 +624,7 @@ test("RequestLogRuntime retains inline raw trace bodies below the default safety } }); -test("RequestLogRuntime bounds file-backed raw bodies and cleans bundles only after ACK", async () => { +test("RequestLogRuntime stores file-backed raw bodies and cleans bundles only after ACK", async () => { const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-runtime-raw-file-test-")); const spoolDir = path.join(dir, "raw-trace-spool"); const bundleDir = path.join(spoolDir, "bundle"); @@ -643,8 +655,10 @@ test("RequestLogRuntime bounds file-backed raw bodies and cleans bundles only af const page = await runtime.list({ pageSize: 25 }); const detail = await runtime.getDetail({ id: page.items[0].id }); assert.equal(detail.responseBody.sizeBytes, Buffer.byteLength(body)); - assert.equal(detail.responseBody.text.length, 256 * 1024); - assert.equal(detail.responseBody.truncated, true); + assert.ok(detail.responseBody.bodyRef); + assert.match(detail.responseBody.text, /bytes omitted from preview/); + assert.equal(await readStoredBodyText(runtime, page.items[0].id, "response"), body); + assert.equal(detail.responseBody.truncated, false); } finally { await runtime.close({ timeoutMs: 5_000 }); rmSync(dir, { force: true, recursive: true }); @@ -685,8 +699,11 @@ test("RequestLogRuntime compacts Base64 images while reading file-backed raw tra const detail = await runtime.getDetail({ id: page.items[0].id }); const captured = JSON.parse(detail.requestBody.text); assert.equal(detail.requestBody.sizeBytes, Buffer.byteLength(body)); - assert.equal(detail.requestBody.truncated, true); + assert.ok(detail.requestBody.bodyRef); + assert.equal(detail.requestBody.truncated, false); assert.match(captured.image_url.url, /^data:image\/jpeg;base64,\[base64 image omitted from log;/); + const fullBody = JSON.parse(await readStoredBodyText(runtime, page.items[0].id, "request")); + assert.equal(fullBody.image_url.url, `data:image/jpeg;base64,${"A".repeat(512 * 1024)}`); assert.equal(existsSync(bundleDir), false); } finally { await runtime.close({ timeoutMs: 5_000 }); @@ -1621,6 +1638,20 @@ function createRuntime(dir, options = {}) { }); } +async function readStoredBodyText(source, id, side) { + let offset = 0; + const chunks = []; + while (true) { + const chunk = await source.getBodyChunk({ id, length: 512 * 1024, offset, side }); + assert.ok(chunk, `expected ${side} body chunk at offset ${offset}`); + chunks.push(chunk.text); + if (chunk.eof) break; + assert.ok(chunk.nextOffset > offset); + offset = chunk.nextOffset; + } + return chunks.join(""); +} + function createRecord(requestId) { const now = new Date().toISOString(); return { diff --git a/packages/core/test/integration/observability/request-log-store.test.mjs b/packages/core/test/integration/observability/request-log-store.test.mjs index fb8a9f70..34c56705 100644 --- a/packages/core/test/integration/observability/request-log-store.test.mjs +++ b/packages/core/test/integration/observability/request-log-store.test.mjs @@ -316,6 +316,104 @@ test("RequestLogStore keeps list rows lightweight and detail rows complete", asy } }); +test("RequestLogStore keeps large request bodies in sidecar storage and reads them by chunk", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-sidecar-test-")); + let store; + try { + store = new RequestLogStore(path.join(dir, "request-logs.sqlite")); + const body = JSON.stringify({ + messages: [{ content: "hello", role: "user" }], + model: "request-model", + padding: "中🙂".repeat(40 * 1024), + tail: "request-tail" + }); + const response = JSON.stringify({ + model: "response-model", + padding: "y".repeat(220 * 1024), + tail: "response-tail", + usage: { + input_tokens: 3, + output_tokens: 4, + total_tokens: 7 + } + }); + + await store.record({ + completedAt: new Date().toISOString(), + durationMs: 42, + method: "POST", + path: "/v1/messages", + providerName: "test-provider", + requestBody: Buffer.from(body, "utf8"), + requestHeaders: { "content-type": "application/json" }, + requestId: "request-log-sidecar-test", + responseBodyText: response, + responseHeaders: { "content-type": "application/json" }, + startedAt: new Date().toISOString(), + statusCode: 200, + url: "http://127.0.0.1:3456/v1/messages" + }); + + const page = await store.list({ pageSize: 25 }); + assert.equal(page.items.length, 1); + assert.equal(page.items[0].requestBody.text, ""); + assert.equal(page.items[0].requestBody.preview, true); + assert.ok(page.items[0].requestBody.bodyRef); + + const detail = await store.getDetail({ id: page.items[0].id }); + assert.ok(detail); + assert.equal(detail.requestBody.preview, true); + assert.equal(detail.requestBody.truncated, false); + assert.match(detail.requestBody.text, /bytes omitted from preview/); + assert.match(detail.requestBody.text, /request-tail/); + assert.equal(detail.responseBody?.preview, true); + assert.equal(detail.responseBody?.truncated, false); + assert.match(detail.responseBody?.text ?? "", /response-tail/); + + const requestChunk = await store.getBodyChunk({ + id: detail.id, + length: 512 * 1024, + offset: 0, + side: "request" + }); + assert.ok(requestChunk); + assert.equal(requestChunk.eof, true); + assert.equal(requestChunk.truncated, false); + assert.equal(requestChunk.text, body); + + let unicodeOffset = 0; + const unicodeChunks = []; + while (true) { + const unicodeChunk = await store.getBodyChunk({ + id: detail.id, + length: 4097, + offset: unicodeOffset, + side: "request" + }); + assert.ok(unicodeChunk); + unicodeChunks.push(unicodeChunk.text); + if (unicodeChunk.eof) break; + assert.ok(unicodeChunk.nextOffset > unicodeOffset); + unicodeOffset = unicodeChunk.nextOffset; + } + assert.equal(unicodeChunks.join(""), body); + + const responseChunk = await store.getBodyChunk({ + id: detail.id, + length: 512 * 1024, + offset: 0, + side: "response" + }); + assert.ok(responseChunk); + assert.equal(responseChunk.eof, true); + assert.equal(responseChunk.truncated, false); + assert.equal(responseChunk.text, response); + } finally { + await store?.close(); + rmSync(dir, { force: true, recursive: true }); + } +}); + test("RequestLogStore stores decoded Claude App route models for observability", async () => { const dir = mkdtempSync(path.join(tmpdir(), "ccr-request-log-claude-app-model-test-")); try { diff --git a/packages/core/test/unit/observability/raw-trace-sync.test.mjs b/packages/core/test/unit/observability/raw-trace-sync.test.mjs index 0ff38e1b..beb98857 100644 --- a/packages/core/test/unit/observability/raw-trace-sync.test.mjs +++ b/packages/core/test/unit/observability/raw-trace-sync.test.mjs @@ -6,7 +6,7 @@ import { Readable } from "node:stream"; import test from "node:test"; import { createDefaultAppConfig } from "@ccr/core/config/default-config.ts"; import { rawTraceSyncHeader } from "@ccr/core/gateway/internal/shared.ts"; -import { rawTraceHardMaxBodyBytes } from "@ccr/core/observability/request-log-limits.ts"; +import { rawTraceHardMaxBodyBytes, rawTraceMaxPartBytes } from "@ccr/core/observability/request-log-limits.ts"; import { applyRawTraceRequestLogPolicy, buildRawTraceConfig, @@ -117,15 +117,16 @@ test("raw trace defers body persistence when the upstream status is unknown", () assert.equal(policy.update.requestBodyText, "private request body"); }); -test("raw trace source defaults to the 50 MB hard body ceiling", () => { +test("raw trace source keeps raw body parts unbounded for sidecar storage", () => { const config = createConfig(); const previous = process.env.CCR_RAW_TRACE_ENABLED; process.env.CCR_RAW_TRACE_ENABLED = "1"; try { const rawTrace = buildRawTraceConfig(config, "sync-token"); - assert.equal(rawTrace.maxPartBytes, rawTraceHardMaxBodyBytes); + assert.equal(rawTrace.maxPartBytes, rawTraceMaxPartBytes); + assert.ok(rawTrace.maxPartBytes > rawTraceHardMaxBodyBytes); config.observability.requestLogMaxBodyBytes = Number.MAX_SAFE_INTEGER; - assert.equal(buildRawTraceConfig(config, "sync-token").maxPartBytes, rawTraceHardMaxBodyBytes); + assert.equal(buildRawTraceConfig(config, "sync-token").maxPartBytes, rawTraceMaxPartBytes); } finally { if (previous === undefined) delete process.env.CCR_RAW_TRACE_ENABLED; else process.env.CCR_RAW_TRACE_ENABLED = previous; diff --git a/packages/electron/src/main/ipc.ts b/packages/electron/src/main/ipc.ts index 0b2e6abe..ce64a740 100644 --- a/packages/electron/src/main/ipc.ts +++ b/packages/electron/src/main/ipc.ts @@ -47,7 +47,7 @@ import { getPluginMarketplace } from "@ccr/core/plugins/marketplace"; import { ensureProxyCertificateAuthority } from "@ccr/core/proxy/certificates"; import { proxyService } from "@ccr/core/proxy/service"; import { listMcpServerTools } from "@ccr/core/mcp/tool-discovery"; -import { getAgentAnalysis, getAgentTracePayload, getRequestLogDetail, getRequestLogs } from "@ccr/core/observability/request-log-store"; +import { getAgentAnalysis, getAgentTracePayload, getRequestLogBodyChunk, getRequestLogDetail, getRequestLogs } from "@ccr/core/observability/request-log-store"; import trayController from "./tray-controller"; import { appUpdateService } from "./update-service"; import { getUsageStats } from "@ccr/core/usage/store"; @@ -124,6 +124,7 @@ ipcMain.handle(IPC_CHANNELS.appGetProxyNetworkCaptures, () => proxyService.getNe ipcMain.handle(IPC_CHANNELS.appGetProxyStatus, () => proxyService.getStatus()); ipcMain.handle(IPC_CHANNELS.appGetPluginMarketplace, () => getPluginMarketplace()); ipcMain.handle(IPC_CHANNELS.appGetRequestLogDetail, (_event, request) => getRequestLogDetail(request)); +ipcMain.handle(IPC_CHANNELS.appGetRequestLogBodyChunk, (_event, request) => getRequestLogBodyChunk(request)); ipcMain.handle(IPC_CHANNELS.appGetRequestLogs, (_event, filter?: RequestLogListFilter) => getRequestLogs(filter)); ipcMain.handle(IPC_CHANNELS.appGetUpdateStatus, () => appUpdateService.getStatus()); ipcMain.handle(IPC_CHANNELS.appGetUsageStats, (_event, range?: UsageStatsRange, filter?: UsageStatsFilter) => getUsageStats(range, filter)); diff --git a/packages/electron/src/main/preload.ts b/packages/electron/src/main/preload.ts index 1df40f43..3d713478 100644 --- a/packages/electron/src/main/preload.ts +++ b/packages/electron/src/main/preload.ts @@ -71,6 +71,8 @@ import type { ProxyNetworkSnapshot, ProxyStatus, RequestLogDetailRequest, + RequestLogBodyChunk, + RequestLogBodyChunkRequest, RequestLogEntry, RequestLogListFilter, RequestLogPage, @@ -129,6 +131,7 @@ contextBridge.exposeInMainWorld("ccr", { getProxyNetworkCaptures: () => invoke(IPC_CHANNELS.appGetProxyNetworkCaptures) as Promise, getProxyStatus: () => invoke(IPC_CHANNELS.appGetProxyStatus) as Promise, getRequestLogDetail: (request: RequestLogDetailRequest) => invoke(IPC_CHANNELS.appGetRequestLogDetail, request) as Promise, + getRequestLogBodyChunk: (request: RequestLogBodyChunkRequest) => invoke(IPC_CHANNELS.appGetRequestLogBodyChunk, request) as Promise, getRequestLogs: (filter?: RequestLogListFilter) => invoke(IPC_CHANNELS.appGetRequestLogs, filter) as Promise, getUpdateStatus: () => invoke(IPC_CHANNELS.appGetUpdateStatus) as Promise, getUsageStats: (range?: UsageStatsRange, filter?: UsageStatsFilter) => invoke(IPC_CHANNELS.appGetUsageStats, range, filter) as Promise, diff --git a/packages/ui/src/pages/home/components/network-logs.tsx b/packages/ui/src/pages/home/components/network-logs.tsx index 4f41907a..e7c13a44 100644 --- a/packages/ui/src/pages/home/components/network-logs.tsx +++ b/packages/ui/src/pages/home/components/network-logs.tsx @@ -12,7 +12,7 @@ import { logResolvedRouteModel, logSelectOptions, motion, MoveRight, Network, networkCodeLabel, networkExchangeMatchesQuery, networkHeaderRows, networkLifecycleLabel, networkQueryRows, networkRowId, networkSummaryRows, Pause, Play, ProxyNetworkBody, ProxyNetworkExchange, ProxyNetworkSnapshot, ProxyStatus, - ReactNode, ReactPointerEvent, RefreshCw, RequestLogBody, RequestLogEntry, RequestLogListFilter, + ReactNode, ReactPointerEvent, RefreshCw, RequestLogBody, RequestLogBodyChunk, RequestLogEntry, RequestLogListFilter, RequestLogPage, requestLogPageSizeOptions, RequestLogStatusFilter, requestLogStatusOptions, Search, Select, translateOptions, Trash2, useAppNumberLocale, useAppText, useCallback, useEffect, useMemo, useRef, useState @@ -24,6 +24,7 @@ type NetworkResponseTab = "body" | "header" | "raw"; const logJsonAutoExpandEntryLimit = 60; const logJsonContainerPreviewLimit = 80; const logJsonAutoExpandTextLimit = 160 * 1024; +const logBodyAutoLoadJsonBytes = 2 * 1024 * 1024; const logBodyWorkerFilterDebounceMs = 180; type LogTableColumnId = "time" | "status" | "stream" | "model" | "credential" | "tokens" | "duration"; type LogTableColumn = { @@ -992,12 +993,14 @@ function LogExpandedDetails({
    ) : null}
    - + @@ -1510,6 +1513,14 @@ type LogBodyPanelView = FormattedLogBody & { visible: string; }; +type LogBodyChunkPanelView = { + bodyKey: string; + chunk?: RequestLogBodyChunk; + error: string; + loading: boolean; + previousOffsets: number[]; +}; + function useLogBodyWorkerView( body: RequestLogBody | undefined, bodyKey: string, @@ -1743,6 +1754,8 @@ function LogJsonPanel({ className, headerEmptyLabel = "No values", headers, + requestLogId, + side, subtitle, title }: { @@ -1750,6 +1763,8 @@ function LogJsonPanel({ className?: string; headerEmptyLabel?: string; headers?: Record; + requestLogId: number; + side: "request" | "response"; subtitle?: string; title: string; }) { @@ -1758,23 +1773,55 @@ function LogJsonPanel({ const [preferTextBody, setPreferTextBody] = useState(false); const [bodyMode, setBodyMode] = useState("preview"); const [fullscreenOpen, setFullscreenOpen] = useState(false); + const [loadedBody, setLoadedBody] = useState(); + const [chunkView, setChunkView] = useState(); + const [fullBodyLoading, setFullBodyLoading] = useState(false); + const [fullBodyError, setFullBodyError] = useState(""); const [query, setQuery] = useState(""); - const bodyKey = logBodyCacheKey(body); - const bodyView = useLogBodyWorkerView(body, bodyKey, bodyMode, query); + const fullBodyLoadIdRef = useRef(0); + const sourceBodyKey = logBodyCacheKey(body); + const effectiveBody = loadedBody && loadedBody.bodyRef && loadedBody.bodyRef === body?.bodyRef + ? loadedBody + : body; + const bodyKey = logBodyCacheKey(effectiveBody); + const bodyView = useLogBodyWorkerView(effectiveBody, bodyKey, bodyMode, query); + const chunkViewActive = chunkView?.bodyKey === sourceBodyKey; + const toolbarBodyView = fullBodyLoading || fullBodyError + ? { + ...bodyView, + error: fullBodyError || bodyView.error, + loading: fullBodyLoading || bodyView.loading, + mode: fullBodyLoading ? "full" as const : bodyView.mode + } + : bodyView; + const displayedToolbarBodyView = chunkViewActive + ? { + ...toolbarBodyView, + error: chunkView.error || toolbarBodyView.error, + loading: chunkView.loading, + mode: "full" as const, + preview: false + } + : toolbarBodyView; const formatted = bodyView.text; const visible = bodyView.visible; const headerRows = useMemo(() => networkHeaderRows(headers ?? {}), [headers]); - const [expandedJsonPaths, setExpandedJsonPaths] = useState>(() => createInitialVisibleJsonPaths(bodyView)); - const showJsonTree = bodyView.json !== undefined && query.trim() === "" && !preferTextBody && !bodyView.preview; + const [expandedJsonPaths, setExpandedJsonPaths] = useState>(() => createInitialVisibleJsonPaths(bodyView, side)); + const showJsonTree = bodyView.json !== undefined && query.trim() === "" && !preferTextBody; useEffect(() => { + fullBodyLoadIdRef.current += 1; + setLoadedBody(undefined); + setChunkView(undefined); + setFullBodyLoading(false); + setFullBodyError(""); setPreferTextBody(false); setBodyMode("preview"); - }, [bodyKey]); + }, [sourceBodyKey]); useEffect(() => { - setExpandedJsonPaths(createInitialVisibleJsonPaths(bodyView)); - }, [bodyView.bodyKey, bodyView.json, bodyView.text]); + setExpandedJsonPaths(createInitialVisibleJsonPaths(bodyView, side)); + }, [bodyView.bodyKey, bodyView.json, bodyView.text, side]); useEffect(() => { if (!fullscreenOpen) { @@ -1801,6 +1848,134 @@ function LogJsonPanel({ }); } + async function loadBodyChunk(offset: number) { + if (!effectiveBody?.bodyRef || !window.ccr?.getRequestLogBodyChunk) { + setBodyMode("full"); + return; + } + const loadId = fullBodyLoadIdRef.current + 1; + fullBodyLoadIdRef.current = loadId; + setBodyMode("full"); + setChunkView((current) => ({ + bodyKey: sourceBodyKey, + chunk: current?.bodyKey === sourceBodyKey ? current.chunk : undefined, + error: "", + loading: true, + previousOffsets: nextChunkPreviousOffsets(current, sourceBodyKey, offset) + })); + try { + const chunk = await window.ccr.getRequestLogBodyChunk({ + id: requestLogId, + length: 1024 * 1024, + offset, + side + }); + if (fullBodyLoadIdRef.current !== loadId) { + return; + } + if (!chunk) { + throw new Error(t("Request log body is not available.")); + } + setChunkView({ + bodyKey: sourceBodyKey, + chunk, + error: "", + loading: false, + previousOffsets: nextChunkPreviousOffsets(chunkView, sourceBodyKey, offset) + }); + } catch (error) { + if (fullBodyLoadIdRef.current === loadId) { + setChunkView((current) => ({ + bodyKey: sourceBodyKey, + chunk: current?.bodyKey === sourceBodyKey ? current.chunk : undefined, + error: error instanceof Error ? error.message : String(error), + loading: false, + previousOffsets: current?.bodyKey === sourceBodyKey ? current.previousOffsets : [] + })); + } + } + } + + async function loadInlineFullBody() { + if (!effectiveBody?.bodyRef || !window.ccr?.getRequestLogBodyChunk) { + return; + } + const loadId = fullBodyLoadIdRef.current + 1; + fullBodyLoadIdRef.current = loadId; + setFullBodyLoading(true); + setFullBodyError(""); + try { + const chunks: string[] = []; + let offset = 0; + let lastChunk: RequestLogBodyChunk | undefined; + while (true) { + const chunk = await window.ccr.getRequestLogBodyChunk({ + id: requestLogId, + length: 1024 * 1024, + offset, + side + }); + if (fullBodyLoadIdRef.current !== loadId) { + return; + } + if (!chunk) { + throw new Error(t("Request log body is not available.")); + } + chunks.push(chunk.text); + lastChunk = chunk; + if (chunk.eof) { + break; + } + offset = chunk.nextOffset ?? offset + chunk.length; + await new Promise((resolve) => window.requestAnimationFrame(() => resolve())); + } + setLoadedBody({ + ...effectiveBody, + ...(lastChunk?.bodyRef ? { bodyRef: lastChunk.bodyRef } : {}), + contentType: lastChunk?.contentType ?? effectiveBody.contentType, + encoding: lastChunk?.encoding ?? effectiveBody.encoding, + preview: false, + sizeBytes: lastChunk?.sizeBytes ?? effectiveBody.sizeBytes, + text: chunks.join(""), + truncated: Boolean(lastChunk?.truncated) + }); + setBodyMode("full"); + } catch (error) { + if (fullBodyLoadIdRef.current === loadId) { + setFullBodyError(error instanceof Error ? error.message : String(error)); + } + } finally { + if (fullBodyLoadIdRef.current === loadId) setFullBodyLoading(false); + } + } + + useEffect(() => { + if ( + !effectiveBody?.bodyRef || + !effectiveBody.preview || + loadedBody || + fullBodyLoading || + fullBodyError || + bodyMode !== "preview" + ) { + return; + } + if (effectiveBody.sizeBytes <= logBodyAutoLoadJsonBytes && isJsonLikeLogBody(effectiveBody)) { + void loadInlineFullBody(); + return; + } + if (!chunkViewActive && !chunkView?.loading) { + void loadBodyChunk(0); + } + }, [bodyMode, chunkView, chunkViewActive, effectiveBody, fullBodyError, fullBodyLoading, loadedBody]); + + const displayedCopyText = chunkViewActive + ? chunkView.chunk?.text ?? "" + : formatted; + const displayedVisible = chunkViewActive + ? filterStaticLogBodyText(chunkView.chunk?.text ?? "", query) + : visible; + return (
    @@ -1827,9 +2002,8 @@ function LogJsonPanel({ {selectedTab === "body" ? ( <> setBodyMode("full")} + body={effectiveBody} + bodyView={displayedToolbarBodyView} onQueryChange={setQuery} onToggleTextBody={() => setPreferTextBody((current) => !current)} preferTextBody={preferTextBody} @@ -1838,27 +2012,32 @@ function LogJsonPanel({ /> setFullscreenOpen(true)} > - + {chunkViewActive ? ( + + ) : ( + + )} {fullscreenOpen ? ( setFullscreenOpen(false)} - onLoadFullBody={() => setBodyMode("full")} + onLoadChunk={loadBodyChunk} onQueryChange={setQuery} onToggleJsonPath={toggleJsonPath} onToggleTextBody={() => setPreferTextBody((current) => !current)} @@ -1867,7 +2046,7 @@ function LogJsonPanel({ showJsonTree={showJsonTree} subtitle={subtitle} title={title} - visible={visible} + visible={displayedVisible} value={bodyView.json} /> ) : null} @@ -1885,7 +2064,6 @@ function LogJsonPanel({ function LogJsonBodyToolbar({ body, bodyView, - onLoadFullBody, onQueryChange, onToggleTextBody, preferTextBody, @@ -1894,7 +2072,6 @@ function LogJsonBodyToolbar({ }: { body?: RequestLogBody; bodyView: LogBodyPanelView; - onLoadFullBody: () => void; onQueryChange: (value: string) => void; onToggleTextBody: () => void; preferTextBody: boolean; @@ -1902,12 +2079,8 @@ function LogJsonBodyToolbar({ title: string; }) { const t = useAppText(); - const canLoadFullBody = bodyView.preview && bodyView.large && query.trim() === ""; const canToggleJsonText = bodyView.json !== undefined && query.trim() === ""; - const showToggleButton = canLoadFullBody || canToggleJsonText; - const toggleLabel = canLoadFullBody - ? bodyView.loading && bodyView.mode === "full" ? t("Loading full payload...") : t("Show full content") - : preferTextBody ? "JSON" : t("Show full content"); + const toggleLabel = preferTextBody ? "JSON" : t("Text"); return (
    @@ -1921,11 +2094,10 @@ function LogJsonBodyToolbar({ value={query} />
    - {showToggleButton ? ( + {canToggleJsonText ? ( + + {rangeLabel ? {rangeLabel} : null} + {chunkView.loading ? {t("Loading full payload...")} : null} + {chunkView.error ? {chunkView.error} : null} +
    +
    {content}
    +
    + ); +} + function LogJsonFullscreenViewer({ body, bodyView, + chunkView, copyLabel, copyText, expandedJsonPaths, onClose, - onLoadFullBody, + onLoadChunk, onQueryChange, onToggleJsonPath, onToggleTextBody, @@ -1982,11 +2211,12 @@ function LogJsonFullscreenViewer({ }: { body?: RequestLogBody; bodyView: LogBodyPanelView; + chunkView?: LogBodyChunkPanelView; copyLabel: string; copyText: string; expandedJsonPaths: Set; onClose: () => void; - onLoadFullBody: () => void; + onLoadChunk: (offset: number) => void | Promise; onQueryChange: (value: string) => void; onToggleJsonPath: (path: string) => void; onToggleTextBody: () => void; @@ -2024,7 +2254,6 @@ function LogJsonFullscreenViewer({
    - + {chunkView ? ( + + ) : ( + + )}
    @@ -2053,9 +2286,11 @@ function logBodyCacheKey(body: RequestLogBody | undefined): string { } const text = body.text ?? ""; return [ + body.bodyRef ?? "", body.encoding ?? "", body.contentType ?? "", body.sizeBytes, + body.preview ? "preview" : "full", body.truncated ? "truncated" : "complete", text.length, text.slice(0, 96), @@ -2063,10 +2298,42 @@ function logBodyCacheKey(body: RequestLogBody | undefined): string { ].join("\u001f"); } -function createInitialVisibleJsonPaths(bodyView: FormattedLogBody): Set { +function isJsonLikeLogBody(body: RequestLogBody | undefined): boolean { + if (!body || body.encoding === "base64") { + return false; + } + const contentType = body.contentType?.split(";", 1)[0]?.trim().toLowerCase() ?? ""; + if (contentType === "application/json" || contentType.endsWith("+json")) { + return true; + } + const first = body.text.trimStart().charAt(0); + return first === "{" || first === "["; +} + +function nextChunkPreviousOffsets( + current: LogBodyChunkPanelView | undefined, + bodyKey: string, + nextOffset: number +): number[] { + if (!current || current.bodyKey !== bodyKey || !current.chunk) { + return []; + } + if (nextOffset > current.chunk.offset) { + return [...current.previousOffsets, current.chunk.offset]; + } + if (nextOffset < current.chunk.offset) { + return current.previousOffsets.slice(0, -1); + } + return current.previousOffsets; +} + +function createInitialVisibleJsonPaths(bodyView: FormattedLogBody, side?: "request" | "response"): Set { if (!isJsonContainer(bodyView.json)) { return new Set(); } + if (side === "request") { + return new Set(["$"]); + } if ( bodyView.text.length > logJsonAutoExpandTextLimit || jsonContainerEntryCount(bodyView.json, logJsonAutoExpandEntryLimit + 1) > logJsonAutoExpandEntryLimit diff --git a/packages/ui/src/pages/home/shared/external.tsx b/packages/ui/src/pages/home/shared/external.tsx index 8eac742e..f5599ffd 100644 --- a/packages/ui/src/pages/home/shared/external.tsx +++ b/packages/ui/src/pages/home/shared/external.tsx @@ -206,6 +206,7 @@ import type { ProxyNetworkSnapshot, ProxyStatus, RequestLogBody, + RequestLogBodyChunk, RequestLogEntry, RequestLogListFilter, RequestLogPage, @@ -569,7 +570,7 @@ export type { ProviderAccountMeter, ProviderAccountStandardConnectorConfig, ProviderAccountSnapshot, ProviderAccountTestPath, ProviderAccountTestResult, ProviderDeepLinkPayload, ProviderDeepLinkRequest, ProviderCredentialConfig, ProfileConfig, ProfileOpenSurface, ProfileRuntimeStatus, CodexProfileConfigFormat, ProfileScope, ProfileSurface, ProxyCertificateInstallResult, ProxyCertificateStatus, ProxyNetworkBody, - ProxyNetworkExchange, ProxyNetworkSnapshot, ProxyStatus, RequestLogBody, RequestLogEntry, RequestLogListFilter, RequestLogPage, + ProxyNetworkExchange, ProxyNetworkSnapshot, ProxyStatus, RequestLogBody, RequestLogBodyChunk, RequestLogEntry, RequestLogListFilter, RequestLogPage, RequestLogStatusFilter, RouterConfig, RouterFallbackConfig, RouterFallbackMode, RouterRule, RouterRuleType, TrayComponentVariants, TrayBalanceProgressConfig, TrayWidgetConfig, TrayWidgetType, TrayWidgetVariant, TrayWindowModuleId, UsageComparisonRow, UsageSeriesPoint, UsageStatsFilter, UsageStatsRange, UsageStatsSnapshot, UsageTotals, VirtualModelBaseModelMode, VirtualModelExecutionMode, VirtualModelFusionCustomToolConfig, VirtualModelFusionVisionConfig, VirtualModelFusionWebSearchConfig, VirtualModelFusionWebSearchProvider, VirtualModelProfileConfig, VirtualModelToolVisibility, ProviderIdentitySafetyIssue, ProviderPreset, ProviderPresetEndpoint diff --git a/packages/ui/src/pages/home/shared/i18n.tsx b/packages/ui/src/pages/home/shared/i18n.tsx index ca02bb95..42e0d0c5 100644 --- a/packages/ui/src/pages/home/shared/i18n.tsx +++ b/packages/ui/src/pages/home/shared/i18n.tsx @@ -249,6 +249,7 @@ export const appCopy: Record = { "No request headers": "No request headers", "No response headers": "No response headers", "筛选 JSON...": "Filter JSON...", + "Text": "Text", "body": "Body", "header": "Headers", "入": "in", @@ -1593,7 +1594,7 @@ export const appCopy: Record = { "Yes": "是", "Set as default provider": "设为默认供应商", "Show credential settings": "显示凭据配置", - "Show full content": "查看完整内容", + "Text": "文本", "Session": "会话", "Session Detail": "会话详情", "Session Requests": "会话请求", diff --git a/packages/ui/src/pages/home/shared/log-body-worker-protocol.ts b/packages/ui/src/pages/home/shared/log-body-worker-protocol.ts index 0c905ed9..6bbbebfc 100644 --- a/packages/ui/src/pages/home/shared/log-body-worker-protocol.ts +++ b/packages/ui/src/pages/home/shared/log-body-worker-protocol.ts @@ -1,5 +1,5 @@ import type { RequestLogBody } from "@ccr/core/contracts/app"; -import type { FormattedLogBody } from "./logs"; +import { formatLogBodyView, type FormattedLogBody } from "./logs"; export const logBodyLargeTextThreshold = 256 * 1024; export const logBodyPreviewTextLimit = 160 * 1024; @@ -69,7 +69,7 @@ export function isLargeLogBody( if (!body) { return false; } - return Math.max(body.sizeBytes, body.text.length) > threshold; + return Boolean(body.preview) || Math.max(body.sizeBytes, body.text.length) > threshold; } export function createLogBodyPreviewText( @@ -101,3 +101,27 @@ export function createLogBodyPreviewText( tail ].join("\n"); } + +export function formatLogBodyForWorker( + body: RequestLogBody | undefined, + mode: LogBodyFormatMode, + largeTextThreshold = logBodyLargeTextThreshold, + previewTextLimit = logBodyPreviewTextLimit +): FormattedLogBody & { + large: boolean; + preview: boolean; + sourceSizeBytes: number; +} { + const large = isLargeLogBody(body, largeTextThreshold); + const preview = large && mode !== "full"; + const formattedBodyView = formatLogBodyView(body); + const bodyView = preview && formattedBodyView.json === undefined + ? { text: createLogBodyPreviewText(body, previewTextLimit) } + : formattedBodyView; + return { + ...bodyView, + large, + preview, + sourceSizeBytes: body?.sizeBytes ?? 0 + }; +} diff --git a/packages/ui/src/pages/home/shared/log-body.worker.ts b/packages/ui/src/pages/home/shared/log-body.worker.ts index 3dace470..375ab82b 100644 --- a/packages/ui/src/pages/home/shared/log-body.worker.ts +++ b/packages/ui/src/pages/home/shared/log-body.worker.ts @@ -1,6 +1,5 @@ import { - createLogBodyPreviewText, - isLargeLogBody, + formatLogBodyForWorker, logBodyLargeTextThreshold, logBodyPreviewTextLimit, type LogBodyFilterRequest, @@ -9,7 +8,7 @@ import { type LogBodyWorkerRequest, type LogBodyWorkerResponse } from "./log-body-worker-protocol"; -import { filterLogText, formatLogBodyView, type FormattedLogBody } from "./logs"; +import { filterLogText, type FormattedLogBody } from "./logs"; type CachedFormattedBody = FormattedLogBody & { bodyKey: string; @@ -50,12 +49,12 @@ worker.onmessage = (event: MessageEvent) => { function formatBody(request: LogBodyFormatRequest): LogBodyFormatResult { const threshold = request.largeTextThreshold ?? logBodyLargeTextThreshold; const previewLimit = request.previewTextLimit ?? logBodyPreviewTextLimit; - const large = isLargeLogBody(request.body, threshold); - const preview = large && request.mode !== "full"; - const bodyView = preview - ? { text: createLogBodyPreviewText(request.body, previewLimit) } - : formatLogBodyView(request.body); - const sourceSizeBytes = request.body?.sizeBytes ?? 0; + const { large, preview, sourceSizeBytes, ...bodyView } = formatLogBodyForWorker( + request.body, + request.mode, + threshold, + previewLimit + ); const visible = filterLogText(bodyView.text, request.query); cachedBody = { diff --git a/packages/ui/src/pages/home/shared/logs.ts b/packages/ui/src/pages/home/shared/logs.ts index 56e9574f..d15ced2c 100644 --- a/packages/ui/src/pages/home/shared/logs.ts +++ b/packages/ui/src/pages/home/shared/logs.ts @@ -126,7 +126,9 @@ export function logBodyKey(body: RequestLogBody | undefined): string { return "missing"; } return JSON.stringify([ + body.bodyRef ?? "", body.encoding ?? "", + body.preview ? "preview" : "full", body.sizeBytes, body.text ?? "" ]); diff --git a/packages/ui/src/types/electron.d.ts b/packages/ui/src/types/electron.d.ts index a38c1084..a747d727 100644 --- a/packages/ui/src/types/electron.d.ts +++ b/packages/ui/src/types/electron.d.ts @@ -70,6 +70,8 @@ import type { ProxyNetworkSnapshot, ProxyStatus, RequestLogDetailRequest, + RequestLogBodyChunk, + RequestLogBodyChunkRequest, RequestLogEntry, RequestLogListFilter, RequestLogPage, @@ -117,6 +119,7 @@ declare global { getProxyNetworkCaptures: () => Promise; getProxyStatus: () => Promise; getRequestLogDetail: (request: RequestLogDetailRequest) => Promise; + getRequestLogBodyChunk: (request: RequestLogBodyChunkRequest) => Promise; getRequestLogs: (filter?: RequestLogListFilter) => Promise; getUpdateStatus: () => Promise; getUsageStats: (range?: UsageStatsRange, filter?: UsageStatsFilter) => Promise; diff --git a/packages/ui/src/web-client-bridge.ts b/packages/ui/src/web-client-bridge.ts index f1fef70e..df32c637 100644 --- a/packages/ui/src/web-client-bridge.ts +++ b/packages/ui/src/web-client-bridge.ts @@ -133,6 +133,7 @@ const webClientBridge: CcrApi = { getProxyNetworkCaptures: () => rpc("getProxyNetworkCaptures") as ReturnType, getProxyStatus: () => rpc("getProxyStatus") as ReturnType, getRequestLogDetail: (request) => rpc("getRequestLogDetail", [request]) as ReturnType, + getRequestLogBodyChunk: (request) => rpc("getRequestLogBodyChunk", [request]) as ReturnType, getRequestLogs: (filter) => rpc("getRequestLogs", [filter]) as ReturnType, getUpdateStatus: () => rpc("getUpdateStatus") as ReturnType, getUsageStats: (range, filter) => rpc("getUsageStats", [range, filter]) as ReturnType, diff --git a/packages/ui/test/unit/network-format.test.ts b/packages/ui/test/unit/network-format.test.ts index 0b2d9581..4d6ab59b 100644 --- a/packages/ui/test/unit/network-format.test.ts +++ b/packages/ui/test/unit/network-format.test.ts @@ -1,5 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; +import type { RequestLogBody } from "@ccr/core/contracts/app"; +import { formatLogBodyForWorker } from "@ccr/ui/pages/home/shared/log-body-worker-protocol.ts"; import { formatRouteTracePath } from "@ccr/ui/pages/home/shared/network.ts"; test("route trace paths use request and response dotted notation", () => { @@ -28,3 +30,23 @@ test("route trace paths use request and response dotted notation", () => { "request.url" ); }); + +test("request log preview bodies still format parseable JSON as JSON", () => { + const body: RequestLogBody = { + bodyRef: "preview-json-body", + contentType: "application/json", + encoding: "utf8", + preview: true, + sizeBytes: 512 * 1024, + text: JSON.stringify({ messages: [{ role: "user", content: "hello" }], model: "test-model" }), + truncated: false + }; + + const view = formatLogBodyForWorker(body, "preview", 256 * 1024, 160 * 1024); + assert.equal(view.preview, true); + assert.deepEqual(view.json, { + messages: [{ role: "user", content: "hello" }], + model: "test-model" + }); + assert.match(view.text, /"model": "test-model"/); +}); From 013965d95f15d604b7e7b4619710574e87638b4c Mon Sep 17 00:00:00 2001 From: musi Date: Fri, 14 Aug 2026 08:37:29 +0800 Subject: [PATCH 12/18] Refactor codebase and remove obsolete implementation --- README.md | 39 +++--- README_zh.md | 39 +++--- docs/src/content/docs/en/agentclaw.md | 9 +- docs/src/content/docs/en/agentclaw/setup.md | 4 +- .../en/configuration/agents/claude-code.md | 71 ++++------ .../en/configuration/agents/claude-design.md | 14 +- .../docs/en/configuration/agents/codex.md | 62 ++++----- .../docs/en/configuration/agents/grok.md | 56 +++----- .../docs/en/configuration/agents/kilo.md | 51 +++---- .../docs/en/configuration/agents/kimi.md | 52 +++----- .../docs/en/configuration/agents/opencode.md | 55 +++----- .../docs/en/configuration/agents/pi.md | 50 +++---- .../docs/en/configuration/agents/workbuddy.md | 89 +++++++++++++ .../docs/en/configuration/agents/zcode.md | 47 +++---- .../content/docs/en/configuration/profiles.md | 124 +++++++++--------- docs/src/content/docs/en/guides.md | 2 +- .../content/docs/en/guides/agent-profile.md | 31 +++-- docs/src/content/docs/zh/agentclaw.md | 7 +- docs/src/content/docs/zh/agentclaw/setup.md | 4 +- .../zh/configuration/agents/claude-code.md | 71 ++++------ .../zh/configuration/agents/claude-design.md | 16 +-- .../docs/zh/configuration/agents/codex.md | 64 ++++----- .../docs/zh/configuration/agents/grok.md | 56 +++----- .../docs/zh/configuration/agents/kilo.md | 51 +++---- .../docs/zh/configuration/agents/kimi.md | 52 +++----- .../docs/zh/configuration/agents/opencode.md | 55 +++----- .../docs/zh/configuration/agents/pi.md | 50 +++---- .../docs/zh/configuration/agents/workbuddy.md | 89 +++++++++++++ .../docs/zh/configuration/agents/zcode.md | 47 +++---- .../content/docs/zh/configuration/profiles.md | 122 ++++++++--------- docs/src/content/docs/zh/guides.md | 2 +- .../content/docs/zh/guides/agent-profile.md | 31 +++-- docs/src/docs-structure.ts | 8 ++ 33 files changed, 756 insertions(+), 764 deletions(-) create mode 100644 docs/src/content/docs/en/configuration/agents/workbuddy.md create mode 100644 docs/src/content/docs/zh/configuration/agents/workbuddy.md diff --git a/README.md b/README.md index bca3b4b1..c0a714e0 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ ### Manage every agent and provider from one place. -Connect Claude Code, Claude Design, Codex, Grok CLI, Kimi CLI, Kilo Code, OpenCode, Pi, ZCode, and compatible API clients to the providers you choose—then route, fail over, extend, and observe every request from one app. +Connect Claude Code, Claude Design, Codex, Grok CLI, Kimi CLI, Kilo Code, OpenCode, Pi, ZCode, WorkBuddy, and compatible API clients to the providers you choose—then route, fail over, extend, and observe every request from one app.

    Download Desktop @@ -59,7 +59,7 @@ Connect Claude Code, Claude Design, Codex, Grok CLI, Kimi CLI, Kilo Code, OpenCo ## Why use Claude Code Router? -Claude Code Router (CCR) is a local model gateway and control plane for coding agents. It gives Claude Code, Claude Design, Codex, Grok CLI, Kimi CLI, Kilo Code, OpenCode, Pi, ZCode, and compatible API clients **one stable local endpoint**, while you manage the providers, models, accounts, routing rules, and tools behind it from one place. +Claude Code Router (CCR) is a local model gateway and control plane for coding agents. It gives Claude Code, Claude Design, Codex, Grok CLI, Kimi CLI, Kilo Code, OpenCode, Pi, ZCode, WorkBuddy, and compatible API clients **one stable local endpoint**, while you manage the providers, models, accounts, routing rules, and tools behind it from one place. Use CCR to: @@ -78,71 +78,78 @@ CCR supports OpenAI Chat / Responses, Anthropic Messages, Gemini Generate Conten - - - - - - - - + + - - - +
    + Claude Code logo
    Claude Code (CLI & APP)
    + Codex logo
    Codex (CLI & APP)
    + Grok CLI logo
    Grok CLI (CLI)
    + Kimi CLI logo
    Kimi CLI (CLI)
    + Kilo Code logo
    Kilo Code (CLI)
    +
    OpenCode logo
    OpenCode (CLI & APP)
    + Pi logo
    Pi (CLI)
    + ZCode logo
    ZCode (APP)
    + Claude Design logo
    Claude Design (APP)
    + + WorkBuddy logo +
    + WorkBuddy (APP) +
    +
    @@ -198,7 +205,7 @@ CCR supports OpenAI Chat / Responses, Anthropic Messages, Gemini Generate Conten 2. Open **Providers → Add Provider**. Choose a built-in preset or a custom endpoint, enter the API key, select the protocol and models, then save. 3. Open **Server** and click **Start**. The local model gateway listens on `http://127.0.0.1:3456` by default. -4. Open **Agent Config**, choose Claude Code, Claude Design, Codex, Grok CLI, Kimi CLI, Kilo Code, OpenCode, Pi, or ZCode, select a model, and apply the profile. +4. Open **Agent Config**, choose Claude Code, Claude Design, Codex, Grok CLI, Kimi CLI, Kilo Code, OpenCode, Pi, ZCode, or WorkBuddy, select a model, and apply the profile. 5. Start using your agent. Open **Logs** to confirm the resolved provider, model, status, tokens, latency, and errors. Your agent is now connected to CCR. To add conditions, retries, request rewrites, or fallback models, open **Routing**. @@ -236,7 +243,7 @@ Windows app packaging must run on Windows x64 because `better-sqlite3` ships a n ## How it works ```text -Claude Code · Claude Design · Codex · Grok CLI · Kimi CLI · Kilo Code · OpenCode · Pi · ZCode · Compatible API clients +Claude Code · Claude Design · Codex · Grok CLI · Kimi CLI · Kilo Code · OpenCode · Pi · ZCode · WorkBuddy · Compatible API clients │ ▼ Claude Code Router :3456 @@ -250,7 +257,7 @@ Claude Code · Claude Design · Codex · Grok CLI · Kimi CLI · Kilo Code · Op | Area | Highlights | | --- | --- | -| **Agents** | Profiles for Claude Code, Claude Design, Codex, Grok CLI, Kimi CLI, Kilo Code, OpenCode, Pi, and ZCode; model overrides; scopes; environment settings; CLI and app launch entries; multi-instance workflows | +| **Agents** | Profiles for Claude Code, Claude Design, Codex, Grok CLI, Kimi CLI, Kilo Code, OpenCode, Pi, ZCode, and WorkBuddy; model overrides; scopes; environment settings; CLI and app launch entries; multi-instance workflows | | **Providers** | Presets and custom endpoints; protocol probing; model discovery; connectivity checks; local login import where supported; single keys and credential pools | | **Models & routing** | Searchable catalog; model descriptions for task selection; conditions on headers and bodies; prefixes; rewrites; retries; ordered fallbacks | | **Tools & extensions** | Fusion models; ToolHub; built-in browser automation; Chrome login-state import; wrapper and core gateway plugins; local routes and virtual models | diff --git a/README_zh.md b/README_zh.md index d9dd857a..994aab1f 100644 --- a/README_zh.md +++ b/README_zh.md @@ -36,7 +36,7 @@ ### 在一个地方,管理你所有的 Agent 与 Provider -让 Claude Code、Claude Design、Codex、Grok CLI、Kimi CLI、Kilo Code、OpenCode、Pi、ZCode 和兼容 API 客户端连接你选择的供应商,并在一个应用里完成每次请求的路由、降级、增强与观测。 +让 Claude Code、Claude Design、Codex、Grok CLI、Kimi CLI、Kilo Code、OpenCode、Pi、ZCode、WorkBuddy 和兼容 API 客户端连接你选择的供应商,并在一个应用里完成每次请求的路由、降级、增强与观测。

    下载桌面端 @@ -59,7 +59,7 @@ ## 为什么使用 Claude Code Router? -Claude Code Router(CCR)是面向编程 Agent 的本地模型网关与控制平面。它为 Claude Code、Claude Design、Codex、Grok CLI、Kimi CLI、Kilo Code、OpenCode、Pi、ZCode 和兼容 API 客户端提供**一个稳定的本地入口**,让你在一个地方管理入口背后的供应商、模型、账号、路由规则与工具。 +Claude Code Router(CCR)是面向编程 Agent 的本地模型网关与控制平面。它为 Claude Code、Claude Design、Codex、Grok CLI、Kimi CLI、Kilo Code、OpenCode、Pi、ZCode、WorkBuddy 和兼容 API 客户端提供**一个稳定的本地入口**,让你在一个地方管理入口背后的供应商、模型、账号、路由规则与工具。 你可以使用 CCR: @@ -78,71 +78,78 @@ CCR 支持 OpenAI Chat / Responses、Anthropic Messages、Gemini Generate Conten - - - - - - - - + + - - - +
    + Claude Code 图标
    Claude Code (CLI & APP)
    + Codex 图标
    Codex (CLI & APP)
    + Grok CLI 图标
    Grok CLI (CLI)
    + Kimi CLI 图标
    Kimi CLI (CLI)
    + Kilo Code 图标
    Kilo Code (CLI)
    +
    OpenCode 图标
    OpenCode (CLI & APP)
    + Pi 图标
    Pi (CLI)
    + ZCode 图标
    ZCode (APP)
    + Claude Design 图标
    Claude Design (APP)
    + + WorkBuddy 图标 +
    + WorkBuddy (APP) +
    +
    @@ -198,7 +205,7 @@ CCR 支持 OpenAI Chat / Responses、Anthropic Messages、Gemini Generate Conten 2. 打开 **供应商 → 添加供应商**。选择内置预设或自定义端点,填写 API Key,选择协议与模型,然后保存。 3. 打开 **服务** 并点击 **启动**。本地模型网关默认监听 `http://127.0.0.1:3456`。 -4. 打开 **Agent配置**,选择 Claude Code、Claude Design、Codex、Grok CLI、Kimi CLI、Kilo Code、OpenCode、Pi 或 ZCode,指定模型并应用配置档案。 +4. 打开 **Agent配置**,选择 Claude Code、Claude Design、Codex、Grok CLI、Kimi CLI、Kilo Code、OpenCode、Pi、ZCode 或 WorkBuddy,指定模型并应用配置档案。 5. 开始使用 Agent。在 **日志** 中确认最终供应商、模型、状态、Token、耗时与错误。 现在 Agent 已经连接到 CCR。如需增加条件规则、自动重试、请求改写或 Fallback 模型,请打开 **路由**。 @@ -236,7 +243,7 @@ Windows App 打包必须在 Windows x64 上运行,因为 `better-sqlite3` 包 ## 工作方式 ```text -Claude Code · Claude Design · Codex · Grok CLI · Kimi CLI · Kilo Code · OpenCode · Pi · ZCode · 兼容 API 客户端 +Claude Code · Claude Design · Codex · Grok CLI · Kimi CLI · Kilo Code · OpenCode · Pi · ZCode · WorkBuddy · 兼容 API 客户端 │ ▼ Claude Code Router :3456 @@ -250,7 +257,7 @@ Claude Code · Claude Design · Codex · Grok CLI · Kimi CLI · Kilo Code · Op | 能力领域 | 功能亮点 | | --- | --- | -| **Agent** | Claude Code、Claude Design、Codex、Grok CLI、Kimi CLI、Kilo Code、OpenCode、Pi 和 ZCode 配置档案;模型覆盖;作用范围;环境变量;CLI / App 启动入口;多开工作流 | +| **Agent** | Claude Code、Claude Design、Codex、Grok CLI、Kimi CLI、Kilo Code、OpenCode、Pi、ZCode 和 WorkBuddy 配置档案;模型覆盖;作用范围;环境变量;CLI / App 启动入口;多开工作流 | | **供应商** | 内置预设和自定义端点;协议探测;模型发现;连通性检测;按支持情况导入本机登录态;单 Key 与凭据池 | | **模型与路由** | 可搜索模型目录;用于任务选择的模型描述;Header / Body 条件;模型前缀;请求改写;重试;有序 Fallback | | **工具与扩展** | Fusion 模型;ToolHub;内置浏览器自动化;Chrome 登录态导入;wrapper / core gateway plugin;本地路由与虚拟模型 | diff --git a/docs/src/content/docs/en/agentclaw.md b/docs/src/content/docs/en/agentclaw.md index eeccbc89..03281864 100644 --- a/docs/src/content/docs/en/agentclaw.md +++ b/docs/src/content/docs/en/agentclaw.md @@ -7,7 +7,7 @@ lead: "AgentClaw connects an agent running locally under CCR to your IM apps: th AgentClaw is CCR's agent relay capability. A local agent managed by CCR keeps its workspace, login state, config files, model routing, and tool permissions; AgentClaw exposes a remote entry point for it through an IM bot. From Slack, Discord, Telegram, LINE, Weixin, WeCom, Feishu, or DingTalk, you can watch agent output, continue the conversation, answer permission requests, or take over after your computer locks. -Handoff means that after your screen locks and the idle threshold passes, further agent interaction moves to IM, where you can continue from your phone or another device. Handoff relies on a companion worker that follows the App lifecycle: opening an Agent App from CCR starts its worker, and closing the App stops the worker and the Bot connection. CLI-only agents can still route model requests through CCR, but they do not forward messages to IM. +Handoff means that after your screen locks and the idle threshold passes, further agent interaction moves to IM, where you can continue from your phone or another device. Keep the Agent App open while using IM relay. CLI-only agents can still route model requests through CCR, but they do not forward messages to IM. ## When to use it @@ -29,7 +29,7 @@ Handoff means that after your screen locks and the idle threshold passes, furthe | Session | An agent-native conversation inside a Project | | Companion worker | The relay process that follows the managed Agent App lifecycle and handles IM messages, Projects/Sessions, queueing, attachments, and diagnostics | -AgentClaw shares the lifecycle of the Agent App opened by CCR. Opening Claude, Codex, ZCode, or OpenCode App starts its companion worker; closing the App stops both the worker and the Bot connection. CLI-only agents can route model requests through CCR but do not forward Bot messages. +AgentClaw follows the Agent App opened by CCR. Closing the App stops the Bot connection. CLI-only agents can route model requests through CCR but do not forward Bot messages. ## Support matrix @@ -39,9 +39,10 @@ AgentClaw shares the lifecycle of the Agent App opened by CCR. Opening Claude, C | Codex / ChatGPT App | Full Bot forwarding, handoff, Projects/Sessions, queueing, model settings, usage, and attachments | | OpenCode App | Full Bot forwarding, handoff, and Projects/Sessions; messages run through OpenCode CLI under the same config | | ZCode App | Full Bot forwarding, handoff, and native Session discovery | +| WorkBuddy App | Full Bot forwarding, handoff, and WorkBuddy companion relay | | Claude Code, Codex CLI | Can use CCR model routing; Bot forwarding is not active yet | | Grok CLI, Kimi CLI | Can use CCR model routing; these are CLI-only and do not enter AgentClaw relay | -| Other local agents | They need a CCR-managed App entry plus a companion worker before they can act as AgentClaw executors | +| Other local agents | They need a CCR-managed App entry before they can act as AgentClaw executors | ## Three modes @@ -78,7 +79,7 @@ AgentClaw exposes only `/project` and `/session` as public command domains. Othe | `/session name

    +{account.meters.length - meters.length}
    - ) : null}