From 94a191873ddc5fbe85c544d40094c564c25a6d50 Mon Sep 17 00:00:00 2001 From: Dennis Jeong <3719829+w0nche0l@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:48:58 -0400 Subject: [PATCH] fix(agent): make the models-barrel import in turn-context type-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime namespace import of '@openrouter/sdk/models' existed only to read EasyInputMessageRoleUser.User (the string 'user'), but it put the entire Speakeasy models barrel — hundreds of modules of top-level Zod schema construction — on the static import path of '@openrouter/agent/tool' (tool -> agent-tool -> conversation-state -> turn-context). Consumers that bundle the tool subpath into Cloudflare Workers paid ~200ms of startup CPU per worker, which pushed large workers past the 1s script-validation ceiling (error 10021) and forced OpenRouterTeam/openrouter-web#33740 to revert the 0.9.0 adoption. Make the import type-only, inline the role literal (behavior identical, still typechecked against models.EasyInputMessage), and add a unit test that walks the static runtime import graph of the hot subpaths (/tool, /tool-types, /stop-conditions) and fails if any of them reaches '@openrouter/sdk' at runtime again. Co-authored-by: Cursor --- .changeset/tool-subpath-startup-cost.md | 7 ++ packages/agent/src/lib/turn-context.ts | 8 +- .../tests/unit/startup-import-closure.test.ts | 111 ++++++++++++++++++ 3 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 .changeset/tool-subpath-startup-cost.md create mode 100644 packages/agent/tests/unit/startup-import-closure.test.ts diff --git a/.changeset/tool-subpath-startup-cost.md b/.changeset/tool-subpath-startup-cost.md new file mode 100644 index 00000000..ca925be5 --- /dev/null +++ b/.changeset/tool-subpath-startup-cost.md @@ -0,0 +1,7 @@ +--- +"@openrouter/agent": patch +--- + +Remove the runtime `@openrouter/sdk/models` import from `turn-context.ts`. The namespace import existed only to read `EasyInputMessageRoleUser.User` (the string `'user'`), but it made every consumer that statically imports `@openrouter/agent/tool` (via `agent-tool` → `conversation-state` → `turn-context`) evaluate the entire Speakeasy models barrel — hundreds of modules of top-level Zod schema construction — at module load. On Cloudflare Workers this added ~200ms of startup CPU per worker and pushed large workers past the 1s script-validation ceiling (error 10021). + +The import is now type-only (erased at compile time) and the role literal is inlined, keeping behavior identical. A new unit test walks the static runtime import graph of the hot subpaths (`/tool`, `/tool-types`, `/stop-conditions`) and fails if any of them ever reaches `@openrouter/sdk` at runtime again. diff --git a/packages/agent/src/lib/turn-context.ts b/packages/agent/src/lib/turn-context.ts index 04aa3879..e4f42220 100644 --- a/packages/agent/src/lib/turn-context.ts +++ b/packages/agent/src/lib/turn-context.ts @@ -1,4 +1,8 @@ -import * as models from '@openrouter/sdk/models'; +// Type-only: a runtime import of the models barrel would evaluate ~600 +// Speakeasy modules of top-level Zod schema construction in every consumer +// that statically reaches the `tool` subpath (~+200ms of Cloudflare Worker +// startup CPU). See the startup-import-closure unit test. +import type * as models from '@openrouter/sdk/models'; import type { TurnContext } from './tool-types.js'; /** @@ -67,7 +71,7 @@ export function normalizeInputToArray(input: models.InputsUnion): Array { + it.each(HOT_SUBPATH_ENTRY_FILES)('%s never runtime-imports @openrouter/sdk', (entryFile) => { + const { files, externals } = walkRuntimeImportClosure(path.join(PACKAGE_ROOT, entryFile)); + + const forbidden = [ + ...externals, + ].filter( + (specifier) => !ALLOWED_EXTERNAL_PREFIXES.some((prefix) => specifier.startsWith(prefix)), + ); + + expect( + forbidden, + `runtime import closure: ${[ + ...files, + ].join(', ')}`, + ).toEqual([]); + }); +}); + +interface RuntimeImportClosure { + files: ReadonlySet; + externals: ReadonlySet; +} + +/** + * Collects the static runtime import graph starting at `entryPath`. + * Follows `import ... from`, `export ... from`, and bare side-effect imports. + * Skips `import type` / `export type` (erased by tsc) and dynamic `import()` + * (deferred by bundlers). Relative edges are traversed; bare specifiers are + * recorded as externals. + */ +function walkRuntimeImportClosure(entryPath: string): RuntimeImportClosure { + const edgePattern = /(?:^|\n)\s*(import|export)\s+(type\s)?([^'"]*?from\s*)?['"]([^'"]+)['"]/g; + const files = new Set(); + const externals = new Set(); + const queue = [ + entryPath, + ]; + + while (queue.length > 0) { + const file = queue.pop(); + if (file === undefined || files.has(file)) { + continue; + } + files.add(file); + const source = readFileSync(file, 'utf8'); + for (const match of source.matchAll(edgePattern)) { + const [, keyword, typeModifier, fromClause, specifier] = match; + const isTypeOnly = typeModifier !== undefined; + const isBareExport = keyword === 'export' && fromClause === undefined; + if (isTypeOnly || isBareExport || specifier === undefined) { + continue; + } + if (specifier.startsWith('.')) { + queue.push(resolveRelativeTsImport(path.dirname(file), specifier)); + } else { + externals.add(specifier); + } + } + } + return { + files, + externals, + }; +} + +/** Maps a `./module.js` ESM specifier back to its TypeScript source file. */ +function resolveRelativeTsImport(fromDir: string, specifier: string): string { + const resolved = path.resolve(fromDir, specifier.replace(/\.js$/, '.ts')); + if (existsSync(resolved)) { + return resolved; + } + return path.resolve(fromDir, specifier); +}