From 2f725c863ac5a7383cf0a1405258a65eed91076d Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Wed, 22 Jul 2026 20:56:15 +0800 Subject: [PATCH 1/3] perf(startup): lazy-load Google GenAI SDK on first use Co-authored-by: Qwen-Coder --- .../2026-07-22-lazy-google-genai-loading.md | 98 +++++++++ .../core/src/agents/runtime/agent-core.ts | 2 +- packages/core/src/confirmation-bus/types.ts | 2 +- packages/core/src/core/baseLlmClient.ts | 2 +- packages/core/src/core/client.ts | 2 +- .../core/src/core/contentGenerator.test.ts | 189 +++++++++++++++--- packages/core/src/core/contentGenerator.ts | 136 ++++++++++--- packages/core/src/core/geminiChat.ts | 2 +- packages/core/src/core/geminiRequest.ts | 2 +- packages/core/src/core/genai-compat.test.ts | 49 +++++ packages/core/src/core/genai-compat.ts | 79 ++++++++ packages/core/src/core/turn.ts | 18 +- .../core/src/services/chatRecordingService.ts | 13 +- packages/core/src/tools/mcp-client.ts | 2 +- scripts/check-serve-fast-path-bundle.js | 4 + .../serve-fast-path-bundle-check.test.js | 34 ++++ 16 files changed, 546 insertions(+), 88 deletions(-) create mode 100644 docs/design/2026-07-22-lazy-google-genai-loading.md create mode 100644 packages/core/src/core/genai-compat.test.ts create mode 100644 packages/core/src/core/genai-compat.ts diff --git a/docs/design/2026-07-22-lazy-google-genai-loading.md b/docs/design/2026-07-22-lazy-google-genai-loading.md new file mode 100644 index 00000000000..4813f6e39ce --- /dev/null +++ b/docs/design/2026-07-22-lazy-google-genai-loading.md @@ -0,0 +1,98 @@ +# Lazy `@google/genai` loading + +- **Issue**: #7264 candidate 3 +- **Scope**: ACP cold-start import closure +- **Status**: implemented and validated + +## Problem + +The bundled ACP runtime currently reaches the `@google/genai` Node entry through nine eager runtime import sites. The SDK contributes 755,788 bytes to a shared 1,196,331-byte chunk containing 77 inputs, including `google-auth-library` and `gaxios`. Because the ACP bootstrap imports the full CLI entry before answering `initialize`, this chunk is parsed and evaluated even though bootstrap deliberately skips Gemini client initialization and MCP discovery. + +Changing the eager imports to `import()` is not sufficient. ACP session creation calls `ensureAuthenticated()` and `createContentGenerator()` before returning the session response. The existing provider imports and `LoggingContentGenerator` construction would therefore load the SDK during `newSession`, moving work out of `channel.initialize` without improving process-to-first-session. + +## Design + +### Lightweight synchronous compatibility values + +Core orchestration uses only a small synchronous subset of the SDK outside provider implementations: `FinishReason`, `FunctionCallingConfigMode`, `createUserContent`, and `createModelContent`. A package-local compatibility module provides those values while retaining SDK types as type-only imports. Its content conversion mirrors the SDK's validation and output shape so existing callers keep the same behavior without evaluating the SDK. + +Provider implementations continue to use the official SDK classes. In particular, this change does not copy or replace `GenerateContentResponse`. + +### Single-flight lazy content generator + +`createContentGenerator()` still validates configuration, preloads the runtime fetch implementation, and performs Qwen OAuth credential acquisition at its current point in the session lifecycle. It returns a private lazy `ContentGenerator` whose memoized loader constructs the selected provider and wraps it in `LoggingContentGenerator` on the first asynchronous content-generator operation. + +All four asynchronous operations share the same loader promise: + +- `generateContent` +- `generateContentStream` +- `countTokens` +- `embedContent` + +Concurrent first calls therefore import and construct the provider once. `useSummarizedThinking()` remains synchronous and is supplied from the selected provider's known behavior: true for Gemini/Vertex and false for OpenAI, Qwen OAuth, and Anthropic. + +Qwen OAuth credential acquisition remains eager within `createContentGenerator()`. An expired or missing cached credential therefore continues to reject ACP session creation rather than producing an apparently usable session that fails only on its first prompt. + +Dynamic-import failures retain the existing background-update restart message, although provider-chunk failures now surface on first generator use. An auth refresh replaces the lazy generator, which also provides the retry boundary after a failed loader. + +### MCP first use + +`mcpToTool` is loaded dynamically inside `discoverTools()`. This preserves the SDK's pagination, duplicate-name handling, callable-tool fallback, and MCP usage header side effect. Configurations with MCP servers may therefore evaluate `@google/genai` during background MCP discovery before the first model prompt. This is an intentional first-use exception: replacing `mcpToTool` would duplicate experimental SDK behavior and materially widen the regression surface. + +The guaranteed boundary is that `@google/genai` is absent from the ACP bootstrap static closure. With no configured MCP server, it remains unloaded through session creation and loads on the first `ContentGenerator` operation. + +### Bundle guard + +The serve fast-path metafile guard adds `@google/genai` to the ACP forbidden-package list. Dynamic chunks remain allowed. This makes a future static re-import fail CI with its output import path. + +## Downstream consumer audit + +There are three direct production creation paths. `Config.refreshAuth()` owns the main-session generator. `BaseLlmClient` owns cached per-model generators for routed side requests. `createRuntimeContentGeneratorView()` owns dedicated generators used by the in-process agent backend, subagent manager, and forked agents. Each path stores and consumes only the `ContentGenerator` interface, so the private lazy wrapper preserves its ownership and routing boundary. + +The interface consumers call only `generateContent`, `generateContentStream`, `countTokens`, `embedContent`, and `useSummarizedThinking`. The main chat path, prompt hooks, memory/goal/side queries, vision routing, subagents, and session resume do not inspect the concrete provider or unwrap `LoggingContentGenerator`; a repository-wide search found no production `instanceof` or `getWrapped()` caller. MCP tool discovery is separate from generator ownership and keeps the SDK-provided `mcpToTool` adapter behind its own first-use import. + +## Alternatives rejected + +- **Only make the current imports dynamic**: improves `channel.initialize` but loads the same SDK during `newSession`, so it does not address process-to-first-session. +- **Delay `GeminiClient.initialize()` itself**: changes chat construction, resume, tool registration, session readiness, and authentication error timing. +- **Copy `GenerateContentResponse`**: risks prototype and getter drift across SDK upgrades and changes the runtime objects returned by OpenAI and Anthropic adapters. +- **Replace `mcpToTool` locally**: duplicates an experimental SDK adapter and drops or must reproduce its process-global MCP telemetry behavior. +- **Import undocumented SDK internals**: `@google/genai` exposes no supported lightweight subpath for these helpers and classes. + +## Compatibility and failure paths + +- Provider validation remains in `createContentGenerator()`. +- Qwen OAuth credential checks remain before ACP session registration. +- The first loader is single-flight across concurrent prompts and side queries. +- An already-aborted first request may still complete module evaluation, because ESM imports are not cancellable; the provider receives the original aborted signal afterward. +- Model configuration is captured by reference as today, so same-provider model changes made before first use are observed by the provider constructor. +- Auth/provider changes rebuild the lazy generator through the existing `refreshAuth()` path. +- A missing dynamic chunk after a background CLI update produces the existing restart guidance. + +## Verification + +Unit tests cover helper parity, deferred construction, Qwen credential timing, single-flight behavior, provider-specific summarized-thinking values, deferred module failures, and MCP discovery behavior. The bundled metafile must show `@google/genai` absent from the ACP static closure while retaining it in dynamic provider/MCP chunks. + +The 2C4G acceptance run follows #7264: 30 paired serial cold starts, `channel.initialize` P50/P95, process-to-first-session, preheated/warm behavior, concurrent first sessions, telemetry on/off, and peak RSS. Because this change moves work later, it additionally records session-response-to-first-token and process-to-first-token for an immediate first prompt. A startup win that is fully repaid as a first-token regression is reported rather than treated as a successful optimization. + +## Results + +The control was the then-current `origin/main` at `dd2552018a72a2b5795977211f06435711e5f99a`, which already includes the lazy telemetry/protocol work and the lazy-undici change. The candidate was the exact final working-tree bundle. Both were built from the same lockfile and tested on the supplied Alibaba Cloud host with 2 vCPUs, approximately 3.5 GiB RAM, no swap, and bundled Node.js 22.23.1. + +The ACP static closure dropped from 14,279,497 bytes to 13,280,177 bytes (999,320 bytes). The control closure contained 755,788 bytes attributed directly to `@google/genai`; the candidate contained zero. The SDK remains present in dynamic chunks for provider and MCP first use. + +With telemetry enabled to an outfile, 30 alternating paired cold starts produced: + +| Metric | Control P50 / P95 | Candidate P50 / P95 | P50 delta | +| ------------------------ | ------------------ | ------------------- | --------- | +| `channel.initialize` | 984.9 / 1010.6 ms | 954.8 / 972.5 ms | -30.1 ms | +| cold `POST /session` | 1293.1 / 1316.0 ms | 1252.4 / 1291.3 ms | -40.7 ms | +| process to first session | 1924.6 / 1951.1 ms | 1858.7 / 1901.0 ms | -65.9 ms | +| `phase.gemini_import` | 536.3 / 550.2 ms | 517.2 / 526.5 ms | -19.1 ms | +| peak RSS | 414.6 / 427.1 MiB | 406.5 / 420.5 MiB | -8.0 MiB | + +After a three-second preheat, `channel.initialize` remained 32.7 ms faster at P50, while `POST /session` improved by 4.8 ms. Concurrent first sessions, telemetry disabled, and legacy single-session mode all succeeded; every process tree was cleaned up and telemetry-disabled mode emitted zero records. + +An additional telemetry-off run issued an immediate real OpenAI-compatible prompt in 30 alternating pairs. All 60 prompts completed. Process-to-session improved by 53.4 ms at P50 and the candidate was faster in 28 of 30 pairs. Prompt-to-first-token was effectively neutral under model-network variance: candidate P50 was 24.2 ms faster and candidate was faster in 16 of 30 pairs; P95 was 297.6 ms slower because both variants had unrelated multi-second network outliers. End-to-end process-to-first-token P50 improved by 57.6 ms, with candidate faster in 19 of 30 pairs. This rules out a demonstrated median cost shift, but the first-token tail is not attributable enough to claim an additional model-call performance win. + +Raw summaries remain on the benchmark host under `/root/qwen-7264-c3-20260722/results/session-final-exact/2026-07-22T12-34-56.362Z` and `/root/qwen-7264-c3-20260722/results/prompt-formal/2026-07-22T12-24-13.276Z`. diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index 89e245ef893..c8574ba3052 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -57,7 +57,7 @@ import { finalizeToolResponses, type ToolResponseBudgetEntry, } from '../../utils/tool-response-finalizer.js'; -import { FinishReason } from '@google/genai'; +import { FinishReason } from '../../core/genai-compat.js'; import type { Content, Part, diff --git a/packages/core/src/confirmation-bus/types.ts b/packages/core/src/confirmation-bus/types.ts index cf68fc93905..65754f7c655 100644 --- a/packages/core/src/confirmation-bus/types.ts +++ b/packages/core/src/confirmation-bus/types.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { type FunctionCall } from '@google/genai'; +import type { FunctionCall } from '@google/genai'; import type { ToolConfirmationOutcome, ToolConfirmationPayload, diff --git a/packages/core/src/core/baseLlmClient.ts b/packages/core/src/core/baseLlmClient.ts index 8c5e1802e3d..84672a6829a 100644 --- a/packages/core/src/core/baseLlmClient.ts +++ b/packages/core/src/core/baseLlmClient.ts @@ -14,7 +14,7 @@ import type { Tool, Schema, } from '@google/genai'; -import { FunctionCallingConfigMode } from '@google/genai'; +import { FunctionCallingConfigMode } from './genai-compat.js'; import type { Config } from '../config/config.js'; import type { ContentGenerator, diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 798fc767241..1e80b342e40 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -5,7 +5,6 @@ */ // External dependencies -import { createUserContent } from '@google/genai'; import type { Content, GenerateContentConfig, @@ -14,6 +13,7 @@ import type { PartListUnion, Tool, } from '@google/genai'; +import { createUserContent } from './genai-compat.js'; import process from 'node:process'; // Config diff --git a/packages/core/src/core/contentGenerator.test.ts b/packages/core/src/core/contentGenerator.test.ts index bc33213ed56..0367569337d 100644 --- a/packages/core/src/core/contentGenerator.test.ts +++ b/packages/core/src/core/contentGenerator.test.ts @@ -12,36 +12,46 @@ import { } from './contentGenerator.js'; import { GoogleGenAI } from '@google/genai'; import type { Config } from '../config/config.js'; -import { LoggingContentGenerator } from './loggingContentGenerator/index.js'; vi.mock('@google/genai'); const openaiMockState = vi.hoisted(() => ({ importError: null as Error | null, generatorError: null as Error | null, + createCount: 0, })); const qwenMockState = vi.hoisted(() => ({ oauthError: null as Error | null, + oauthCount: 0, + constructorCount: 0, })); -vi.mock('./openaiContentGenerator/index.js', () => { - if (openaiMockState.importError) { - throw openaiMockState.importError; - } - - return { - createOpenAIContentGenerator: () => { - if (openaiMockState.generatorError) { - throw openaiMockState.generatorError; - } - return {}; - }, - }; -}); +vi.mock('./openaiContentGenerator/index.js', () => ({ + createOpenAIContentGenerator: () => { + if (openaiMockState.importError) { + throw openaiMockState.importError; + } + if (openaiMockState.generatorError) { + throw openaiMockState.generatorError; + } + openaiMockState.createCount += 1; + return { + generateContent: async () => ({}), + generateContentStream: async () => + (async function* () { + yield {}; + })(), + countTokens: async () => ({ totalTokens: 1 }), + embedContent: async () => ({ embeddings: [] }), + useSummarizedThinking: () => false, + }; + }, +})); vi.mock('../qwen/qwenOAuth2.js', () => ({ getQwenOAuthClient: async () => { + qwenMockState.oauthCount += 1; if (qwenMockState.oauthError) { throw qwenMockState.oauthError; } @@ -50,11 +60,33 @@ vi.mock('../qwen/qwenOAuth2.js', () => ({ })); vi.mock('../qwen/qwenContentGenerator.js', () => ({ - QwenContentGenerator: class {}, + QwenContentGenerator: class { + constructor() { + qwenMockState.constructorCount += 1; + } + + async countTokens() { + return { totalTokens: 1 }; + } + + useSummarizedThinking() { + return false; + } + }, })); describe('createContentGenerator', () => { - it('should create a Gemini content generator', async () => { + beforeEach(() => { + vi.clearAllMocks(); + openaiMockState.importError = null; + openaiMockState.generatorError = null; + openaiMockState.createCount = 0; + qwenMockState.oauthError = null; + qwenMockState.oauthCount = 0; + qwenMockState.constructorCount = 0; + }); + + it('should defer Gemini content generator creation until first use', async () => { const mockConfig = { getUsageStatisticsEnabled: () => true, getContentGeneratorConfig: () => ({}), @@ -64,7 +96,9 @@ describe('createContentGenerator', () => { } as unknown as Config; const mockGenerator = { - models: {}, + models: { + countTokens: vi.fn().mockResolvedValue({ totalTokens: 1 }), + }, } as unknown as GoogleGenAI; vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never); const generator = await createContentGenerator( @@ -75,6 +109,14 @@ describe('createContentGenerator', () => { }, mockConfig, ); + expect(GoogleGenAI).not.toHaveBeenCalled(); + expect(generator.useSummarizedThinking()).toBe(true); + + await generator.countTokens({ + model: 'test-model', + contents: 'hello', + }); + expect(GoogleGenAI).toHaveBeenCalledWith({ apiKey: 'test-api-key', vertexai: undefined, @@ -85,10 +127,6 @@ describe('createContentGenerator', () => { }, }, }); - // We expect it to be a LoggingContentGenerator wrapping a GeminiContentGenerator - expect(generator).toBeInstanceOf(LoggingContentGenerator); - const wrapped = (generator as LoggingContentGenerator).getWrapped(); - expect(wrapped).toBeDefined(); }); it('should create a Gemini content generator with client install id logging disabled', async () => { @@ -100,7 +138,9 @@ describe('createContentGenerator', () => { getSessionId: () => 'test-session', } as unknown as Config; const mockGenerator = { - models: {}, + models: { + countTokens: vi.fn().mockResolvedValue({ totalTokens: 1 }), + }, } as unknown as GoogleGenAI; vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never); const generator = await createContentGenerator( @@ -111,6 +151,11 @@ describe('createContentGenerator', () => { }, mockConfig, ); + expect(GoogleGenAI).not.toHaveBeenCalled(); + await generator.countTokens({ + model: 'test-model', + contents: 'hello', + }); expect(GoogleGenAI).toHaveBeenCalledWith({ apiKey: 'test-api-key', vertexai: undefined, @@ -120,7 +165,80 @@ describe('createContentGenerator', () => { }, }, }); - expect(generator).toBeInstanceOf(LoggingContentGenerator); + }); + + it('loads a provider once across concurrent first calls', async () => { + const mockConfig = { + getUsageStatisticsEnabled: () => false, + getContentGeneratorConfig: () => ({}), + getCliVersion: () => '1.0.0', + getTelemetryEnabled: () => false, + getSessionId: () => 'test-session', + } as unknown as Config; + const generator = await createContentGenerator( + { + model: 'test-model', + apiKey: 'test-key', + authType: AuthType.USE_OPENAI, + }, + mockConfig, + ); + + expect(openaiMockState.createCount).toBe(0); + expect(generator.useSummarizedThinking()).toBe(false); + await Promise.all([ + generator.countTokens({ model: 'test-model', contents: 'one' }), + generator.countTokens({ model: 'test-model', contents: 'two' }), + ]); + expect(openaiMockState.createCount).toBe(1); + }); + + it('checks Qwen credentials before deferring provider creation', async () => { + const mockConfig = { + getUsageStatisticsEnabled: () => false, + getContentGeneratorConfig: () => ({}), + getCliVersion: () => '1.0.0', + getTelemetryEnabled: () => false, + getSessionId: () => 'test-session', + } as unknown as Config; + const generator = await createContentGenerator( + { + model: 'test-model', + authType: AuthType.QWEN_OAUTH, + }, + mockConfig, + true, + ); + + expect(qwenMockState.oauthCount).toBe(1); + expect(qwenMockState.constructorCount).toBe(0); + expect(generator.useSummarizedThinking()).toBe(false); + await generator.countTokens({ model: 'test-model', contents: 'hello' }); + expect(qwenMockState.constructorCount).toBe(1); + }); + + it('rejects Qwen credential failures before returning a lazy generator', async () => { + const mockConfig = { + getUsageStatisticsEnabled: () => false, + getContentGeneratorConfig: () => ({}), + getCliVersion: () => '1.0.0', + getTelemetryEnabled: () => false, + getSessionId: () => 'test-session', + } as unknown as Config; + qwenMockState.oauthError = new Error('cached credentials are missing'); + + await expect( + createContentGenerator( + { + model: 'test-model', + authType: AuthType.QWEN_OAUTH, + }, + mockConfig, + true, + ), + ).rejects.toThrow('cached credentials are missing'); + expect(qwenMockState.oauthCount).toBe(1); + expect(qwenMockState.constructorCount).toBe(0); }); it('should throw when the config has no authType', async () => { @@ -176,7 +294,10 @@ describe('createContentGenerator - ERR_MODULE_NOT_FOUND handling', () => { beforeEach(() => { openaiMockState.importError = null; openaiMockState.generatorError = null; + openaiMockState.createCount = 0; qwenMockState.oauthError = null; + qwenMockState.oauthCount = 0; + qwenMockState.constructorCount = 0; vi.resetModules(); }); @@ -188,7 +309,7 @@ describe('createContentGenerator - ERR_MODULE_NOT_FOUND handling', () => { openaiMockState.importError = moduleError; try { - await createContentGenerator( + const generator = await createContentGenerator( { model: 'test-model', apiKey: 'test-key', @@ -196,6 +317,7 @@ describe('createContentGenerator - ERR_MODULE_NOT_FOUND handling', () => { }, mockConfig, ); + await generator.countTokens({ model: 'test-model', contents: 'hello' }); expect.unreachable('should have thrown'); } catch (error) { expect(error).toBeInstanceOf(Error); @@ -211,15 +333,16 @@ describe('createContentGenerator - ERR_MODULE_NOT_FOUND handling', () => { it('should re-throw non-module errors unchanged', async () => { openaiMockState.generatorError = new Error('network timeout'); + const generator = await createContentGenerator( + { + model: 'test-model', + apiKey: 'test-key', + authType: AuthType.USE_OPENAI, + }, + mockConfig, + ); await expect( - createContentGenerator( - { - model: 'test-model', - apiKey: 'test-key', - authType: AuthType.USE_OPENAI, - }, - mockConfig, - ), + generator.countTokens({ model: 'test-model', contents: 'hello' }), ).rejects.toThrow('network timeout'); }); diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index 9f0f2cbc8cd..44ef79d5a79 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -13,7 +13,6 @@ import type { GenerateContentResponse, } from '@google/genai'; import type { Config } from '../config/config.js'; -import { LoggingContentGenerator } from './loggingContentGenerator/index.js'; import type { ConfigSource, ConfigSourceKind, @@ -354,6 +353,66 @@ function getModuleNotFoundError( return undefined; } +function wrapProviderLoadError(error: unknown, authType: AuthType): unknown { + const moduleNotFoundError = getModuleNotFoundError(error); + if (!moduleNotFoundError) { + return error; + } + + return new Error( + `Qwen Code was updated in the background and needs to be restarted.\n` + + `Please exit and restart Qwen Code to use the '${authType}' provider.`, + { cause: moduleNotFoundError }, + ); +} + +class LazyContentGenerator implements ContentGenerator { + private generatorPromise?: Promise; + + constructor( + private readonly loader: () => Promise, + private readonly summarizedThinking: boolean, + ) {} + + private getGenerator(): Promise { + this.generatorPromise ??= this.loader(); + return this.generatorPromise; + } + + async generateContent( + request: GenerateContentParameters, + userPromptId: string, + ): Promise { + return (await this.getGenerator()).generateContent(request, userPromptId); + } + + async generateContentStream( + request: GenerateContentParameters, + userPromptId: string, + ): Promise> { + return (await this.getGenerator()).generateContentStream( + request, + userPromptId, + ); + } + + async countTokens( + request: CountTokensParameters, + ): Promise { + return (await this.getGenerator()).countTokens(request); + } + + async embedContent( + request: EmbedContentParameters, + ): Promise { + return (await this.getGenerator()).embedContent(request); + } + + useSummarizedThinking(): boolean { + return this.summarizedThinking; + } +} + export async function createContentGenerator( generatorConfig: ContentGeneratorConfig, config: Config, @@ -374,32 +433,32 @@ export async function createContentGenerator( // (issue #7264). await preloadRuntimeFetchModule(); - let baseGenerator: ContentGenerator; + let loadBaseGenerator: () => Promise; try { if (authType === AuthType.USE_OPENAI) { - const { createOpenAIContentGenerator } = await import( - './openaiContentGenerator/index.js' - ); - baseGenerator = createOpenAIContentGenerator(generatorConfig, config); + loadBaseGenerator = async () => { + const { createOpenAIContentGenerator } = await import( + './openaiContentGenerator/index.js' + ); + return createOpenAIContentGenerator(generatorConfig, config); + }; } else if (authType === AuthType.QWEN_OAUTH) { const { getQwenOAuthClient: getQwenOauthClient } = await import( '../qwen/qwenOAuth2.js' ); - const { QwenContentGenerator } = await import( - '../qwen/qwenContentGenerator.js' - ); try { const qwenClient = await getQwenOauthClient( config, isInitialAuth ? { requireCachedCredentials: true } : undefined, ); - baseGenerator = new QwenContentGenerator( - qwenClient, - generatorConfig, - config, - ); + loadBaseGenerator = async () => { + const { QwenContentGenerator } = await import( + '../qwen/qwenContentGenerator.js' + ); + return new QwenContentGenerator(qwenClient, generatorConfig, config); + }; } catch (error) { if (getModuleNotFoundError(error)) { throw error; @@ -407,34 +466,47 @@ export async function createContentGenerator( throw new Error(error instanceof Error ? error.message : String(error)); } } else if (authType === AuthType.USE_ANTHROPIC) { - const { createAnthropicContentGenerator } = await import( - './anthropicContentGenerator/index.js' - ); - baseGenerator = createAnthropicContentGenerator(generatorConfig, config); + loadBaseGenerator = async () => { + const { createAnthropicContentGenerator } = await import( + './anthropicContentGenerator/index.js' + ); + return createAnthropicContentGenerator(generatorConfig, config); + }; } else if ( authType === AuthType.USE_GEMINI || authType === AuthType.USE_VERTEX_AI ) { - const { createGeminiContentGenerator } = await import( - './geminiContentGenerator/index.js' - ); - baseGenerator = createGeminiContentGenerator(generatorConfig, config); + loadBaseGenerator = async () => { + const { createGeminiContentGenerator } = await import( + './geminiContentGenerator/index.js' + ); + return createGeminiContentGenerator(generatorConfig, config); + }; } else { throw new Error( `Error creating contentGenerator: Unsupported authType: ${authType}`, ); } } catch (error) { - const moduleNotFoundError = getModuleNotFoundError(error); - if (moduleNotFoundError) { - throw new Error( - `Qwen Code was updated in the background and needs to be restarted.\n` + - `Please exit and restart Qwen Code to use the '${authType}' provider.`, - { cause: moduleNotFoundError }, - ); - } - throw error; + throw wrapProviderLoadError(error, authType); } - return new LoggingContentGenerator(baseGenerator, config, generatorConfig); + return new LazyContentGenerator( + async () => { + try { + const [baseGenerator, { LoggingContentGenerator }] = await Promise.all([ + loadBaseGenerator(), + import('./loggingContentGenerator/index.js'), + ]); + return new LoggingContentGenerator( + baseGenerator, + config, + generatorConfig, + ); + } catch (error) { + throw wrapProviderLoadError(error, authType); + } + }, + authType === AuthType.USE_GEMINI || authType === AuthType.USE_VERTEX_AI, + ); } diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 846a5dbcedc..b9d497717aa 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -17,7 +17,7 @@ import type { Tool, GenerateContentResponseUsageMetadata, } from '@google/genai'; -import { createUserContent, FinishReason } from '@google/genai'; +import { createUserContent, FinishReason } from './genai-compat.js'; import { enforceFunctionResponseBudget } from '../utils/tool-response-finalizer.js'; import { retryWithBackoff, diff --git a/packages/core/src/core/geminiRequest.ts b/packages/core/src/core/geminiRequest.ts index f3c52fbb6cb..73a1873c15e 100644 --- a/packages/core/src/core/geminiRequest.ts +++ b/packages/core/src/core/geminiRequest.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { type PartListUnion } from '@google/genai'; +import type { PartListUnion } from '@google/genai'; import { partToString } from '../utils/partUtils.js'; /** diff --git a/packages/core/src/core/genai-compat.test.ts b/packages/core/src/core/genai-compat.test.ts new file mode 100644 index 00000000000..235f90b740c --- /dev/null +++ b/packages/core/src/core/genai-compat.test.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + createModelContent as createSdkModelContent, + createUserContent as createSdkUserContent, + FinishReason as SdkFinishReason, + FunctionCallingConfigMode as SdkFunctionCallingConfigMode, +} from '@google/genai'; +import { + createModelContent, + createUserContent, + FinishReason, + FunctionCallingConfigMode, +} from './genai-compat.js'; + +describe('genai compatibility values', () => { + it('matches the SDK values used by core orchestration', () => { + expect(FinishReason).toEqual({ + STOP: SdkFinishReason.STOP, + MAX_TOKENS: SdkFinishReason.MAX_TOKENS, + }); + expect(FunctionCallingConfigMode).toEqual({ + ANY: SdkFunctionCallingConfigMode.ANY, + }); + }); + + it.each([ + 'hello', + { text: 'hello' }, + ['hello', { inlineData: { mimeType: 'text/plain', data: 'aGVsbG8=' } }], + ])('matches SDK content conversion for %j', (value) => { + expect(createUserContent(value)).toEqual(createSdkUserContent(value)); + expect(createModelContent(value)).toEqual(createSdkModelContent(value)); + }); + + it.each([ + [[], 'partOrString cannot be an empty array'], + [{ role: 'user', parts: [] }, 'partOrString must be a Part object'], + [[{ invalid: true }], 'element in PartUnion must be a Part object'], + ])('matches SDK validation for %j', (value, message) => { + expect(() => createUserContent(value as never)).toThrow(message); + expect(() => createSdkUserContent(value as never)).toThrow(message); + }); +}); diff --git a/packages/core/src/core/genai-compat.ts b/packages/core/src/core/genai-compat.ts new file mode 100644 index 00000000000..af57f7eed14 --- /dev/null +++ b/packages/core/src/core/genai-compat.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + Content, + FinishReason as GenAiFinishReason, + FunctionCallingConfigMode as GenAiFunctionCallingConfigMode, + Part, + PartListUnion, +} from '@google/genai'; + +export type FinishReason = GenAiFinishReason; +export const FinishReason = { + STOP: 'STOP' as GenAiFinishReason, + MAX_TOKENS: 'MAX_TOKENS' as GenAiFinishReason, +} as const; + +export type FunctionCallingConfigMode = GenAiFunctionCallingConfigMode; +export const FunctionCallingConfigMode = { + ANY: 'ANY' as GenAiFunctionCallingConfigMode, +} as const; + +function isPart(value: unknown): value is Part { + return ( + typeof value === 'object' && + value !== null && + ('fileData' in value || + 'text' in value || + 'functionCall' in value || + 'functionResponse' in value || + 'inlineData' in value || + 'videoMetadata' in value || + 'codeExecutionResult' in value || + 'executableCode' in value) + ); +} + +function toParts(partOrString: PartListUnion): Part[] { + if (typeof partOrString === 'string') { + return [{ text: partOrString }]; + } + + if (isPart(partOrString)) { + return [partOrString]; + } + + if (!Array.isArray(partOrString)) { + throw new Error('partOrString must be a Part object, string, or array'); + } + + if (partOrString.length === 0) { + throw new Error('partOrString cannot be an empty array'); + } + + return partOrString.map((part) => { + if (typeof part === 'string') { + return { text: part }; + } + if (isPart(part)) { + return part; + } + throw new Error('element in PartUnion must be a Part object or string'); + }); +} + +function createContent(role: 'user' | 'model', value: PartListUnion): Content { + return { role, parts: toParts(value) }; +} + +export function createUserContent(value: PartListUnion): Content { + return createContent('user', value); +} + +export function createModelContent(value: PartListUnion): Content { + return createContent('model', value); +} diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index 723e7ee35a6..67b4833a5a6 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -4,16 +4,16 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - FinishReason, - type Content, - type Part, - type PartListUnion, - type GenerateContentResponse, - type FunctionCall, - type FunctionDeclaration, - type GenerateContentResponseUsageMetadata, +import type { + Content, + Part, + PartListUnion, + GenerateContentResponse, + FunctionCall, + FunctionDeclaration, + GenerateContentResponseUsageMetadata, } from '@google/genai'; +import { FinishReason } from './genai-compat.js'; import type { ToolCallConfirmationDetails, ToolArtifact, diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index cad9845a501..a572291f306 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -8,14 +8,13 @@ import { type Config } from '../config/config.js'; import path from 'node:path'; import fs from 'node:fs'; import { randomUUID } from 'node:crypto'; -import { - type PartListUnion, - type Content, - type FunctionDeclaration, - type GenerateContentResponseUsageMetadata, - createUserContent, - createModelContent, +import type { + PartListUnion, + Content, + FunctionDeclaration, + GenerateContentResponseUsageMetadata, } from '@google/genai'; +import { createModelContent, createUserContent } from '../core/genai-compat.js'; import * as jsonl from '../utils/jsonl-utils.js'; import { getGitBranch } from '../utils/gitUtils.js'; import { createDebugLogger } from '../utils/debugLogger.js'; diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index 57ea7fcc563..35655f7ce20 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -45,7 +45,6 @@ export { } from './mcp-status.js'; import type { FunctionDeclaration } from '@google/genai'; -import { mcpToTool } from '@google/genai'; import { existsSync } from 'node:fs'; import { basename } from 'node:path'; import { pathToFileURL } from 'node:url'; @@ -1241,6 +1240,7 @@ export async function discoverTools( opts?: { applyConfigFilters?: boolean }, ): Promise { try { + const { mcpToTool } = await import('@google/genai'); const mcpCallableTool = mcpToTool(mcpClient, { timeout: mcpServerConfig.timeout ?? MCP_DEFAULT_TIMEOUT_MSEC, }); diff --git a/scripts/check-serve-fast-path-bundle.js b/scripts/check-serve-fast-path-bundle.js index 4b0c3b1954a..4e3317d5da9 100644 --- a/scripts/check-serve-fast-path-bundle.js +++ b/scripts/check-serve-fast-path-bundle.js @@ -213,6 +213,10 @@ const FORBIDDEN_ACP_PACKAGES = [ // candidate 4); a static re-import anywhere in the ACP closure would pull // ~1 MiB per bundled copy back into every cold start. { label: 'undici vendor package', packageName: 'undici' }, + // Provider implementations and MCP discovery load the Google GenAI SDK on + // first use (issue #7264 candidate 3). Keep its SDK and Google auth graph + // out of the ACP bootstrap closure. + { label: 'Google GenAI SDK', packageName: '@google/genai' }, ]; export function normalizeMetafilePath(filePath) { diff --git a/scripts/tests/serve-fast-path-bundle-check.test.js b/scripts/tests/serve-fast-path-bundle-check.test.js index 11817e4709c..782950a3981 100644 --- a/scripts/tests/serve-fast-path-bundle-check.test.js +++ b/scripts/tests/serve-fast-path-bundle-check.test.js @@ -469,6 +469,40 @@ describe('ACP import boundary check', () => { expect(findAcpImportBoundaryOffenders(metafile)).toEqual([]); }); + it('reports a statically imported Google GenAI SDK', () => { + const metafile = makeMetafile({ + 'dist/chunks/acp-agent.js': output({ + inputs: ['packages/cli/src/acp-integration/acpAgent.ts'], + imports: [staticImport('dist/chunks/google-genai.js')], + }), + 'dist/chunks/google-genai.js': output({ + bytes: 1_196_331, + inputs: ['node_modules/@google/genai/dist/node/index.mjs'], + }), + }); + + expect(findAcpImportBoundaryOffenders(metafile)).toEqual([ + expect.objectContaining({ + label: 'Google GenAI SDK', + matchedInput: 'node_modules/@google/genai/dist/node/index.mjs', + }), + ]); + }); + + it('allows the Google GenAI SDK behind dynamic imports', () => { + const metafile = makeMetafile({ + 'dist/chunks/acp-agent.js': output({ + inputs: ['packages/cli/src/acp-integration/acpAgent.ts'], + imports: [dynamicImport('dist/chunks/google-genai.js')], + }), + 'dist/chunks/google-genai.js': output({ + inputs: ['node_modules/@google/genai/dist/node/index.mjs'], + }), + }); + + expect(findAcpImportBoundaryOffenders(metafile)).toEqual([]); + }); + it('reads a metafile path and returns ACP boundary offenders', () => { const tempDir = mkdtempSync(join(tmpdir(), 'acp-import-boundary-')); try { From 8ca1571f6366453bbd2d67cbe5d6d1d5a89c1670 Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Wed, 22 Jul 2026 21:24:43 +0800 Subject: [PATCH 2/3] codex: address PR review feedback (#7512) Co-authored-by: Qwen-Coder --- packages/core/src/core/genai-compat.test.ts | 2 +- packages/core/src/core/genai-compat.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core/src/core/genai-compat.test.ts b/packages/core/src/core/genai-compat.test.ts index 235f90b740c..ddb93293e98 100644 --- a/packages/core/src/core/genai-compat.test.ts +++ b/packages/core/src/core/genai-compat.test.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ diff --git a/packages/core/src/core/genai-compat.ts b/packages/core/src/core/genai-compat.ts index af57f7eed14..ef4ae8d043b 100644 --- a/packages/core/src/core/genai-compat.ts +++ b/packages/core/src/core/genai-compat.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2026 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ @@ -13,6 +13,7 @@ import type { } from '@google/genai'; export type FinishReason = GenAiFinishReason; +// Keep runtime values limited to the subset used outside provider adapters. export const FinishReason = { STOP: 'STOP' as GenAiFinishReason, MAX_TOKENS: 'MAX_TOKENS' as GenAiFinishReason, From b67c64770589a2439fcf07e9b1c3eca4bb105eba Mon Sep 17 00:00:00 2001 From: doudouOUC Date: Wed, 22 Jul 2026 21:56:09 +0800 Subject: [PATCH 3/3] codex: address PR review feedback (#7512) Co-authored-by: Qwen-Coder --- .../2026-07-22-lazy-google-genai-loading.md | 2 - .../core/src/core/contentGenerator.test.ts | 69 +++++++++---------- packages/core/src/core/genai-compat.ts | 3 + scripts/check-serve-fast-path-bundle.js | 4 +- 4 files changed, 39 insertions(+), 39 deletions(-) diff --git a/docs/design/2026-07-22-lazy-google-genai-loading.md b/docs/design/2026-07-22-lazy-google-genai-loading.md index 4813f6e39ce..26f145269a0 100644 --- a/docs/design/2026-07-22-lazy-google-genai-loading.md +++ b/docs/design/2026-07-22-lazy-google-genai-loading.md @@ -94,5 +94,3 @@ With telemetry enabled to an outfile, 30 alternating paired cold starts produced After a three-second preheat, `channel.initialize` remained 32.7 ms faster at P50, while `POST /session` improved by 4.8 ms. Concurrent first sessions, telemetry disabled, and legacy single-session mode all succeeded; every process tree was cleaned up and telemetry-disabled mode emitted zero records. An additional telemetry-off run issued an immediate real OpenAI-compatible prompt in 30 alternating pairs. All 60 prompts completed. Process-to-session improved by 53.4 ms at P50 and the candidate was faster in 28 of 30 pairs. Prompt-to-first-token was effectively neutral under model-network variance: candidate P50 was 24.2 ms faster and candidate was faster in 16 of 30 pairs; P95 was 297.6 ms slower because both variants had unrelated multi-second network outliers. End-to-end process-to-first-token P50 improved by 57.6 ms, with candidate faster in 19 of 30 pairs. This rules out a demonstrated median cost shift, but the first-token tail is not attributable enough to claim an additional model-call performance win. - -Raw summaries remain on the benchmark host under `/root/qwen-7264-c3-20260722/results/session-final-exact/2026-07-22T12-34-56.362Z` and `/root/qwen-7264-c3-20260722/results/prompt-formal/2026-07-22T12-24-13.276Z`. diff --git a/packages/core/src/core/contentGenerator.test.ts b/packages/core/src/core/contentGenerator.test.ts index 0367569337d..0496fea7693 100644 --- a/packages/core/src/core/contentGenerator.test.ts +++ b/packages/core/src/core/contentGenerator.test.ts @@ -16,7 +16,6 @@ import type { Config } from '../config/config.js'; vi.mock('@google/genai'); const openaiMockState = vi.hoisted(() => ({ - importError: null as Error | null, generatorError: null as Error | null, createCount: 0, })); @@ -29,9 +28,6 @@ const qwenMockState = vi.hoisted(() => ({ vi.mock('./openaiContentGenerator/index.js', () => ({ createOpenAIContentGenerator: () => { - if (openaiMockState.importError) { - throw openaiMockState.importError; - } if (openaiMockState.generatorError) { throw openaiMockState.generatorError; } @@ -78,7 +74,6 @@ vi.mock('../qwen/qwenContentGenerator.js', () => ({ describe('createContentGenerator', () => { beforeEach(() => { vi.clearAllMocks(); - openaiMockState.importError = null; openaiMockState.generatorError = null; openaiMockState.createCount = 0; qwenMockState.oauthError = null; @@ -292,7 +287,6 @@ describe('createContentGenerator - ERR_MODULE_NOT_FOUND handling', () => { } as unknown as Config; beforeEach(() => { - openaiMockState.importError = null; openaiMockState.generatorError = null; openaiMockState.createCount = 0; qwenMockState.oauthError = null; @@ -301,35 +295,6 @@ describe('createContentGenerator - ERR_MODULE_NOT_FOUND handling', () => { vi.resetModules(); }); - it('should throw friendly restart message with cause when dynamic import fails with ERR_MODULE_NOT_FOUND', async () => { - const moduleError = new Error( - "Cannot find module './openaiContentGenerator-STALE.js'", - ); - (moduleError as NodeJS.ErrnoException).code = 'ERR_MODULE_NOT_FOUND'; - openaiMockState.importError = moduleError; - - try { - const generator = await createContentGenerator( - { - model: 'test-model', - apiKey: 'test-key', - authType: AuthType.USE_OPENAI, - }, - mockConfig, - ); - await generator.countTokens({ model: 'test-model', contents: 'hello' }); - expect.unreachable('should have thrown'); - } catch (error) { - expect(error).toBeInstanceOf(Error); - const err = error as Error; - expect(err.message).toMatch( - /updated in the background and needs to be restarted/, - ); - expect(err.message).toMatch(/openai/); - expect(err.cause).toBe(moduleError); - } - }); - it('should re-throw non-module errors unchanged', async () => { openaiMockState.generatorError = new Error('network timeout'); @@ -370,6 +335,40 @@ describe('createContentGenerator - ERR_MODULE_NOT_FOUND handling', () => { expect(err.cause).toBe(moduleError); } }); + + it('should throw friendly restart message with cause when dynamic import fails with ERR_MODULE_NOT_FOUND', async () => { + const moduleError = new Error( + "Cannot find module './openaiContentGenerator-STALE.js'", + ); + (moduleError as NodeJS.ErrnoException).code = 'ERR_MODULE_NOT_FOUND'; + vi.doMock('./openaiContentGenerator/index.js', () => { + throw moduleError; + }); + const { createContentGenerator: createWithMissingProvider } = await import( + './contentGenerator.js' + ); + + try { + const generator = await createWithMissingProvider( + { + model: 'test-model', + apiKey: 'test-key', + authType: AuthType.USE_OPENAI, + }, + mockConfig, + ); + await generator.countTokens({ model: 'test-model', contents: 'hello' }); + expect.unreachable('should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(Error); + const err = error as Error; + expect(err.message).toMatch( + /updated in the background and needs to be restarted/, + ); + expect(err.message).toMatch(/openai/); + expect(err.cause).toBe(moduleError); + } + }); }); describe('createContentGeneratorConfig', () => { diff --git a/packages/core/src/core/genai-compat.ts b/packages/core/src/core/genai-compat.ts index ef4ae8d043b..b22b365f1bc 100644 --- a/packages/core/src/core/genai-compat.ts +++ b/packages/core/src/core/genai-compat.ts @@ -24,6 +24,9 @@ export const FunctionCallingConfigMode = { ANY: 'ANY' as GenAiFunctionCallingConfigMode, } as const; +// Content conversion is adapted from @google/genai 2.6.0's `_isPart` and +// `_toParts` helpers (Copyright 2025 Google LLC, Apache-2.0); re-check parity +// on SDK upgrades. function isPart(value: unknown): value is Part { return ( typeof value === 'object' && diff --git a/scripts/check-serve-fast-path-bundle.js b/scripts/check-serve-fast-path-bundle.js index 4e3317d5da9..79821ef2b73 100644 --- a/scripts/check-serve-fast-path-bundle.js +++ b/scripts/check-serve-fast-path-bundle.js @@ -214,8 +214,8 @@ const FORBIDDEN_ACP_PACKAGES = [ // ~1 MiB per bundled copy back into every cold start. { label: 'undici vendor package', packageName: 'undici' }, // Provider implementations and MCP discovery load the Google GenAI SDK on - // first use (issue #7264 candidate 3). Keep its SDK and Google auth graph - // out of the ACP bootstrap closure. + // first use (issue #7264 candidate 3). Keep the SDK out of the ACP bootstrap + // closure. { label: 'Google GenAI SDK', packageName: '@google/genai' }, ];