From 40537e596b5a51c6c0933c127ebd16c05d5a1498 Mon Sep 17 00:00:00 2001 From: Armando Navarro Date: Mon, 31 Aug 2026 17:11:09 -0700 Subject: [PATCH 1/2] fix(schematics): emit AgentPlatformBackend from the v21 AI migration `ng update` rewrites an AngularFire 20 app's `getVertexAI(...)` calls, and it emitted `new VertexAIBackend()`, a class the Firebase SDK now deprecates. A call that passed a `location` option keeps it, carried into the backend class's constructor. A call that passed none, or a falsy one, moves to the 'global' location, so the migration warns once for each file whose region changed, and separately when a location is an expression only runtime can resolve. When the workspace calls the Live API the warning says so, because Firebase does not serve those models from the global location. The emitted class needs firebase 12.17.0, above the declared ^12.4.0 minimum, so one spec ties the emitted class to that minimum and four more compile the migration's output against the installed SDK. All five fail until the minimum rises. --- docs/ai.md | 2 +- docs/version-21-upgrade.md | 29 +- src/schematics/migration.json | 2 +- .../update/v21/vertexai-to-ai.jasmine.ts | 396 +++++++++++++++--- .../update/v21/vertexai-to-ai/index.ts | 96 ++++- .../update/v21/vertexai-to-ai/passes.ts | 37 +- .../update/v21/vertexai-to-ai/safety.ts | 4 +- .../update/v21/vertexai-to-ai/tables.ts | 14 +- .../update/v21/vertexai-to-ai/types.ts | 31 +- .../update/v21/vertexai-to-ai/vertex-edits.ts | 41 +- 10 files changed, 567 insertions(+), 85 deletions(-) diff --git a/docs/ai.md b/docs/ai.md index 248b54825..105cf7a6c 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -8,7 +8,7 @@ Firebase AI Logic gives you access to the latest generative AI models from Googl [Learn more](https://firebase.google.com/docs/ai-logic) -> Firebase AI Logic was previously called **Vertex AI in Firebase**. If you are upgrading from AngularFire 20, the module moved from `@angular/fire/vertexai` to `@angular/fire/ai` and most symbols were renamed (`provideVertexAI` to `provideAI`, `VertexAI` to `AI`). One is not a rename: plain `getAI()` uses the Gemini Developer API backend, so the old `getVertexAI()` maps to `getAI(app, { backend: new VertexAIBackend() })`. Running `ng update @angular/fire` rewrites all of this for you and keeps your app on the Vertex AI backend. See the [AngularFire 20 to 21 upgrade guide](./version-21-upgrade.md). +> Firebase AI Logic was previously called **Vertex AI in Firebase**. If you are upgrading from AngularFire 20, the module moved from `@angular/fire/vertexai` to `@angular/fire/ai` and most symbols were renamed (`provideVertexAI` to `provideAI`, `VertexAI` to `AI`). One is not a rename: plain `getAI()` uses the Gemini Developer API backend, so the old `getVertexAI()` maps to `getAI(app, { backend: new AgentPlatformBackend() })`. Running `ng update @angular/fire` rewrites all of this for you and keeps your app on the same backend, though a call that passed no `location` moves from `us-central1` to `global`. See the [AngularFire 20 to 21 upgrade guide](./version-21-upgrade.md). ## Dependency Injection diff --git a/docs/version-21-upgrade.md b/docs/version-21-upgrade.md index 253b28ea4..fa6865f61 100644 --- a/docs/version-21-upgrade.md +++ b/docs/version-21-upgrade.md @@ -20,7 +20,7 @@ The Vertex AI module has been renamed to Firebase AI Logic. The `@angular/fire/v | Before (`@angular/fire/vertexai`) | After (`@angular/fire/ai`) | |---|---| -| `getVertexAI(app?, { location? })` | `getAI(app, { backend: new VertexAIBackend(location?) })` | +| `getVertexAI(app?, { location? })` | `getAI(app, { backend: new AgentPlatformBackend(location?) })` | | `provideVertexAI` | `provideAI` | | `VertexAI` | `AI` | | `VertexAIError` | `AIError` | @@ -30,7 +30,30 @@ The Vertex AI module has been renamed to Firebase AI Logic. The `@angular/fire/v | `vertexAIInstance$` | `AIInstance$` | | `VertexAIModule` | `AIModule` | -**`getVertexAI` is not a plain rename.** `getAI` already existed alongside it, and a plain `getAI()` call talks to the Gemini Developer API backend, not to Vertex AI. The equivalent of `getVertexAI()` is `getAI(app, { backend: new VertexAIBackend() })`, which is what the migration writes, so your app keeps calling the Vertex AI backend it was configured, enabled, and billed for. A `location` option moves into the `VertexAIBackend` constructor. +### `getVertexAI` is not a plain rename + +`getAI` already existed alongside it, and a plain `getAI()` call talks to the Gemini Developer API backend, not to the Agent Platform Gemini API (formerly Vertex AI). The equivalent of `getVertexAI()` is `getAI(app, { backend: new AgentPlatformBackend() })`, which is what the migration writes, so your app keeps calling the backend it was configured, enabled, and billed for. A `location` option moves into the `AgentPlatformBackend` constructor. + +### The default region changes + +`getVertexAI()` with no `location` resolved to `us-central1`. `AgentPlatformBackend` with no argument resolves to `global`. Firebase suggests `global` when your model supports it, and the Gemini 3.x models are only available there, which is why the migration writes it. To keep the region you had instead, write `new AgentPlatformBackend('us-central1')`. + +Review this before deploying if your project has a data residency requirement. + +#### Which calls move, and what the migration tells you + +- A call that passed a non-empty `location` string keeps it, so its region does not move. +- A call that passed no `location`, or an empty one, moves to `global`. `getVertexAI` treated an empty value as `us-central1`, and `AgentPlatformBackend` treats it as `global`. The migration drops the empty value rather than writing it through, and warns. +- A call whose `location` is an expression the migration cannot read gets its own warning, because only your runtime knows whether that value is empty. +- A call whose options the migration cannot rewrite at all, such as a quoted `{ 'location': ... }` key, is left as written and warned about separately. Its import still moves, so it stops compiling until you migrate it by hand. + +Each of those warnings is emitted once per file, so a file that has both a moved region and an unreadable `location` produces two. + +#### If your app uses the Live API, do not stay on `global` + +Firebase does not support the `global` location for the Live API models, so `getLiveGenerativeModel` and `startAudioConversation` stop working there. When the migration sees either of them anywhere in your workspace it says so in the region warning. Pass `'us-central1'`, or another supported location, to `AgentPlatformBackend`. + +### What the migration rewrites for you `ng update @angular/fire` rewrites these imports and identifiers for you and logs every `getVertexAI` call it rewrites. `getGenerativeModel` keeps its name. @@ -42,7 +65,7 @@ Code the migration cannot rewrite safely is left in place with a warning. The im - **Options it cannot read:** when the `getVertexAI` call's options are not a literal `{ location }` object, or that literal references other symbols the migration is also rewriting. - **The function used as a value:** when `getVertexAI` is stored or passed around rather than called directly. -- **Name collisions:** when a local declaration in the file reuses an imported symbol's name, or the file already gets `getAI` or `VertexAIBackend` from a source other than AI Logic. +- **Name collisions:** when a local declaration in the file reuses an imported symbol's name, or the file already gets `getAI` or `AgentPlatformBackend` from a source other than AI Logic. - **Wildcard re-exports:** when a file has `export * from '@angular/fire/vertexai'`, that line stays as written, because rewriting it would silently rename your re-exported public symbols. Replace it with named re-exports by hand. A file where a named `getVertexAI` import is used in a way that cannot be rewritten keeps every use of its named `getVertexAI` imports in place (namespace-style `ns.getVertexAI(...)` calls are judged per call), and each skipped call is logged. diff --git a/src/schematics/migration.json b/src/schematics/migration.json index 7c040e6a1..0a353baef 100644 --- a/src/schematics/migration.json +++ b/src/schematics/migration.json @@ -8,7 +8,7 @@ }, "migration-v21": { "version": "21.0.0", - "description": "Align the workspace's firebase dependency with the range @angular/fire 21 requires, and rewrite Vertex AI imports to Firebase AI Logic (getVertexAI callers keep the Vertex AI backend)", + "description": "Align the workspace's firebase dependency with the range @angular/fire 21 requires, and rewrite Vertex AI imports to Firebase AI Logic (getVertexAI callers keep the same backend, and a call that passed no location moves from us-central1 to global)", "factory": "./update/v21#ngUpdate" }, "ng-post-upgate": { diff --git a/src/schematics/update/v21/vertexai-to-ai.jasmine.ts b/src/schematics/update/v21/vertexai-to-ai.jasmine.ts index 6501e42c6..0de0563a4 100644 --- a/src/schematics/update/v21/vertexai-to-ai.jasmine.ts +++ b/src/schematics/update/v21/vertexai-to-ai.jasmine.ts @@ -1,7 +1,11 @@ +import { resolve } from 'path'; import { logging } from '@angular-devkit/core'; import { HostTree, SchematicContext } from '@angular-devkit/schematics'; +import { minVersion, satisfies } from 'semver'; import * as typescript from 'typescript'; +import { firebaseVersionRange } from '../../common.js'; import { applyEdits, rewriteVertexAIToAI } from './vertexai-to-ai/index.js'; +import { BACKEND_CLASS, BACKEND_CLASS_FIREBASE_FLOOR } from './vertexai-to-ai/tables.js'; import 'jasmine'; const context = { logger: new logging.Logger('test') } as unknown as SchematicContext; @@ -39,8 +43,8 @@ describe('rewriteVertexAIToAI', () => { expect(changed).toBe(true); const out = tree.readText('src/app/foo.ts'); - expect(out).toContain(`import { provideAI, getAI, VertexAIBackend, AI } from '@angular/fire/ai';`); - expect(out).toContain('provideAI(() => getAI(undefined, { backend: new VertexAIBackend() }))'); + expect(out).toContain(`import { provideAI, getAI, AgentPlatformBackend, AI } from '@angular/fire/ai';`); + expect(out).toContain('provideAI(() => getAI(undefined, { backend: new AgentPlatformBackend() }))'); expect(out).toContain('inject(AI)'); expect(out).not.toContain('getVertexAI'); expect(out).not.toContain('provideVertexAI'); @@ -58,11 +62,11 @@ describe('rewriteVertexAIToAI', () => { rewriteVertexAIToAI(tree, context, typescript); const out = tree.readText('src/app/foo.ts'); - expect(out).toContain(`import { getAI, VertexAIBackend } from '@angular/fire/ai';`); - expect(out).toContain('getAI(getApp(), { backend: new VertexAIBackend() })'); + expect(out).toContain(`import { getAI, AgentPlatformBackend } from '@angular/fire/ai';`); + expect(out).toContain('getAI(getApp(), { backend: new AgentPlatformBackend() })'); }); - it('moves a literal location option into the VertexAIBackend constructor', () => { + it('moves a literal location option into the AgentPlatformBackend constructor', () => { const source = [ `import { getVertexAI } from '@angular/fire/vertexai';`, `import { getApp } from '@angular/fire/app';`, @@ -73,7 +77,7 @@ describe('rewriteVertexAIToAI', () => { rewriteVertexAIToAI(tree, context, typescript); expect(tree.readText('src/app/foo.ts')) - .toContain(`getAI(getApp(), { backend: new VertexAIBackend('europe-west1') })`); + .toContain(`getAI(getApp(), { backend: new AgentPlatformBackend('europe-west1') })`); }); it('replaces an empty options literal with the backend object', () => { @@ -87,7 +91,295 @@ describe('rewriteVertexAIToAI', () => { rewriteVertexAIToAI(tree, context, typescript); expect(tree.readText('src/app/foo.ts')) - .toContain('getAI(getApp(), { backend: new VertexAIBackend() })'); + .toContain('getAI(getApp(), { backend: new AgentPlatformBackend() })'); + }); + + /* The migration writes an import of BACKEND_CLASS into the user's source. The emitted symbol + * has to exist at the BOTTOM of the declared range, not just in the version we build against. */ + it('only emits a backend class that exists across the whole declared firebase range', () => { + const requiredFloor = BACKEND_CLASS_FIREBASE_FLOOR[BACKEND_CLASS]; + + expect(requiredFloor) + .withContext(`${BACKEND_CLASS} has no entry in BACKEND_CLASS_FIREBASE_FLOOR, so its floor is unstated`) + .toBeDefined(); + expect(satisfies(minVersion(firebaseVersionRange), `>=${requiredFloor}`)) + .withContext( + `the migration emits ${BACKEND_CLASS}, absent before firebase ${requiredFloor}, ` + + `but firebaseVersionRange is ${firebaseVersionRange}, so a workspace at the bottom of that ` + + 'range gets rewritten source that does not compile. Raise the floor before shipping this.', + ) + .toBeTrue(); + }); + + describe('output compiles', () => { + const compilerOptions: typescript.CompilerOptions = { + strict: true, + noEmit: true, + skipLibCheck: true, + target: typescript.ScriptTarget.ES2022, + module: typescript.ModuleKind.ESNext, + moduleResolution: typescript.ModuleResolutionKind.Bundler, + // Ambient @types packages are irrelevant here and drag in unrelated diagnostics. + types: [], + }; + + /** + * Type-check one migrated file against the firebase actually installed. The file is served + * from memory at a real path inside the repo so that `firebase/ai` resolves through + * node_modules the way it would in a user's workspace. + */ + const compile = (source: string): string[] => { + const filePath = resolve('migrated-output.ts'); + const host = typescript.createCompilerHost(compilerOptions, true); + const readFromDisk = host.getSourceFile.bind(host); + const existsOnDisk = host.fileExists.bind(host); + const contentsOnDisk = host.readFile.bind(host); + host.getSourceFile = (name, languageVersion, onError, shouldCreate) => name === filePath + ? typescript.createSourceFile(name, source, languageVersion, true) + : readFromDisk(name, languageVersion, onError, shouldCreate); + host.fileExists = name => name === filePath || existsOnDisk(name); + host.readFile = name => name === filePath ? source : contentsOnDisk(name); + const program = typescript.createProgram([filePath], compilerOptions, host); + return typescript.getPreEmitDiagnostics(program) + .map(diagnostic => `TS${diagnostic.code}: ${typescript.flattenDiagnosticMessageText(diagnostic.messageText, ' ')}`); + }; + + /** Imports come from `firebase/vertexai` rather than `@angular/fire/vertexai` so the rewritten + * file imports `firebase/ai`, which resolves without any path mapping. */ + const migrate = (body: string, imports = `import { getVertexAI } from 'firebase/vertexai';`) => { + const tree = treeWith({ 'src/app/foo.ts': [imports, `import { getApp } from 'firebase/app';`, body].join('\n') }); + rewriteVertexAIToAI(tree, context, typescript); + return tree.readText('src/app/foo.ts'); + }; + + it('emits a call that type-checks when no location was passed', () => { + expect(compile(migrate(`export const ai = getVertexAI(getApp());`))).toEqual([]); + }); + + it('emits a call that type-checks when a location was passed', () => { + expect(compile(migrate(`export const ai = getVertexAI(getApp(), { location: 'europe-west1' });`))).toEqual([]); + }); + + /* The empty string is dropped rather than written through, so this is the regression test + * for emitting a dead argument the constructor's signature rejects. */ + it('emits a call that type-checks when the location was empty', () => { + expect(compile(migrate(`export const ai = getVertexAI(getApp(), { location: '' });`))).toEqual([]); + }); + + it('emits a namespace call that type-checks', () => { + const migrated = migrate( + `export const ai = vertexai.getVertexAI(getApp());`, + `import * as vertexai from 'firebase/vertexai';`, + ); + expect(compile(migrated)).toEqual([]); + }); + }); + + it('warns that the region moved when the call passed no location', () => { + const { context: spiedContext, warn } = contextWithLogSpies(); + const source = [ + `import { getVertexAI } from '@angular/fire/vertexai';`, + `export const vertex = getVertexAI();`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, spiedContext, typescript); + + const warnText = warn.calls.allArgs().map(callArgs => String(callArgs[0])).join('\n'); + expect(warnText).toContain('this call now passes no location, so the region changed from us-central1 to global'); + expect(warnText).toContain(`new AgentPlatformBackend('us-central1')`); + }); + + it('does not warn about the region when the call passed an explicit location', () => { + const { context: spiedContext, warn } = contextWithLogSpies(); + const source = [ + `import { getVertexAI } from '@angular/fire/vertexai';`, + `import { getApp } from '@angular/fire/app';`, + `export const vertex = getVertexAI(getApp(), { location: 'us-central1' });`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, spiedContext, typescript); + + // The explicit location is carried into the constructor, so nothing about the region changes. + expect(tree.readText('src/app/foo.ts')) + .toContain(`getAI(getApp(), { backend: new AgentPlatformBackend('us-central1') })`); + const warnText = warn.calls.allArgs().map(callArgs => String(callArgs[0])).join('\n'); + expect(warnText).not.toContain('the region changed'); + }); + + it('warns that the region moved when the location is a falsy literal the new backend ignores', () => { + // getVertexAI used to select 'us-central1', but AgentPlatformBackend now selects global. + for (const falsyLocation of ['undefined', `''`]) { + const { context: spiedContext, warn } = contextWithLogSpies(); + const source = [ + `import { getVertexAI } from '@angular/fire/vertexai';`, + `import { getApp } from '@angular/fire/app';`, + `export const vertex = getVertexAI(getApp(), { location: ${falsyLocation} });`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, spiedContext, typescript); + + const warnText = warn.calls.allArgs().map(callArgs => String(callArgs[0])).join('\n'); + expect(warnText) + .withContext(`location: ${falsyLocation}`) + .toContain('no location, so the region changed from us-central1 to global'); + } + }); + + it('warns that the region depends on runtime when the location is an expression', () => { + const { context: spiedContext, warn } = contextWithLogSpies(); + const source = [ + `import { getVertexAI } from '@angular/fire/vertexai';`, + `import { getApp } from '@angular/fire/app';`, + `declare const settings: { region: string };`, + `export const vertex = getVertexAI(getApp(), { location: settings.region });`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, spiedContext, typescript); + + expect(tree.readText('src/app/foo.ts')) + .toContain('new AgentPlatformBackend(settings.region)'); + const warnText = warn.calls.allArgs().map(callArgs => String(callArgs[0])).join('\n'); + expect(warnText).toContain('a location this migration cannot resolve'); + // The region is not asserted to have changed, because only runtime knows. + expect(warnText).not.toContain('the region changed from us-central1 to global'); + }); + + it('warns once per file however many calls moved region, and counts the rest', () => { + const { context: spiedContext, warn } = contextWithLogSpies(); + const source = [ + `import { getVertexAI } from '@angular/fire/vertexai';`, + `export const a = getVertexAI();`, + `export const b = getVertexAI();`, + `export const c = getVertexAI();`, + `export const d = getVertexAI();`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, spiedContext, typescript); + + const regionWarnings = warn.calls.allArgs() + .map(callArgs => String(callArgs[0])) + .filter(text => text.includes('the region changed from us-central1 to global')); + expect(regionWarnings.length).toBe(1); + expect(regionWarnings[0]).toContain('this call and 3 others in this file now pass'); + }); + + it('uses the singular form when exactly one other call moved region', () => { + const { context: spiedContext, warn } = contextWithLogSpies(); + const source = [ + `import { getVertexAI } from '@angular/fire/vertexai';`, + `export const a = getVertexAI();`, + `export const b = getVertexAI();`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, spiedContext, typescript); + + const warnText = warn.calls.allArgs().map(callArgs => String(callArgs[0])).join('\n'); + expect(warnText).toContain('this call and 1 other in this file'); + }); + + it('does not claim the region changed for a shorthand location, which only runtime resolves', () => { + const { context: spiedContext, warn } = contextWithLogSpies(); + const source = [ + `import { getVertexAI } from '@angular/fire/vertexai';`, + `import { getApp } from '@angular/fire/app';`, + `declare const location: string;`, + `export const a = getVertexAI(getApp(), { location });`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, spiedContext, typescript); + + const warnText = warn.calls.allArgs().map(callArgs => String(callArgs[0])).join('\n'); + /* Telling a user holding 'europe-west1' that they moved to global, and to pin us-central1, + * would move them off their own region. */ + expect(warnText).not.toContain('the region changed from us-central1 to global'); + expect(warnText).toContain('a location this migration cannot resolve'); + }); + + it('names the backend the way the rewritten file binds it, for a namespace import', () => { + const { context: spiedContext, warn } = contextWithLogSpies(); + const source = [ + `import * as vai from '@angular/fire/vertexai';`, + `export const a = vai.getVertexAI();`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, spiedContext, typescript); + + const warnText = warn.calls.allArgs().map(callArgs => String(callArgs[0])).join('\n'); + // A bare AgentPlatformBackend is not bound in this file, so advising it would not compile. + expect(warnText).toContain(`Pass new vai.AgentPlatformBackend('us-central1')`); + }); + + for (const liveApiEntryPoint of ['getLiveGenerativeModel', 'startAudioConversation']) { + it(`escalates the region warning when the workspace calls ${liveApiEntryPoint}`, () => { + const { context: spiedContext, warn } = contextWithLogSpies(); + const tree = treeWith({ + // Deliberately a different file: the Live API call rarely sits beside the getVertexAI one. + 'src/app/live.ts': [ + `import { ${liveApiEntryPoint} } from '@angular/fire/ai';`, + `export const model = ${liveApiEntryPoint};`, + ].join('\n'), + 'src/app/foo.ts': [ + `import { getVertexAI } from '@angular/fire/vertexai';`, + `export const a = getVertexAI();`, + ].join('\n'), + /* Sorts last and mentions no Live API call. One matching file anywhere has to be enough, + * so a later non-matching file must not undo the match above. */ + 'src/app/zzz-unrelated.ts': `export const unrelated = 1;`, + }); + + rewriteVertexAIToAI(tree, spiedContext, typescript); + + const warnText = warn.calls.allArgs().map(callArgs => String(callArgs[0])).join('\n'); + expect(warnText).toContain('This workspace calls the Live API'); + expect(warnText).toContain('does not support in the global location'); + expect(warnText).toContain('Pass a location that supports the Live API to AgentPlatformBackend'); + expect(warnText).not.toContain(`new AgentPlatformBackend('us-central1')`); + }); + } + + it('escalates the runtime-resolved location warning too when the workspace calls the Live API', () => { + const { context: spiedContext, warn } = contextWithLogSpies(); + const tree = treeWith({ + 'src/app/foo.ts': [ + `import { getVertexAI } from '@angular/fire/vertexai';`, + `import { getApp } from '@angular/fire/app';`, + `declare const settings: { region: string };`, + `export const a = getVertexAI(getApp(), { location: settings.region });`, + ].join('\n'), + 'src/app/live.ts': [ + `import { startAudioConversation } from '@angular/fire/ai';`, + `export const model = startAudioConversation;`, + ].join('\n'), + }); + + rewriteVertexAIToAI(tree, spiedContext, typescript); + + const warnText = warn.calls.allArgs().map(callArgs => String(callArgs[0])).join('\n'); + expect(warnText).toContain('a location this migration cannot resolve'); + expect(warnText).toContain('This workspace calls the Live API'); + }); + + it('leaves the region warning unescalated when the workspace has no Live API call', () => { + const { context: spiedContext, warn } = contextWithLogSpies(); + const source = [ + `import { getVertexAI } from '@angular/fire/vertexai';`, + `export const a = getVertexAI();`, + ].join('\n'); + const tree = treeWith({ 'src/app/foo.ts': source }); + + rewriteVertexAIToAI(tree, spiedContext, typescript); + + const warnText = warn.calls.allArgs().map(callArgs => String(callArgs[0])).join('\n'); + expect(warnText).toContain('the region changed from us-central1 to global'); + expect(warnText).not.toContain('does not support in the global location'); }); it('logs each rewritten getVertexAI call', () => { @@ -102,7 +394,7 @@ describe('rewriteVertexAIToAI', () => { expect(info).toHaveBeenCalledTimes(1); expect(info.calls.mostRecent().args[0]).toContain('/src/app/foo.ts:2'); - expect(info.calls.mostRecent().args[0]).toContain('Vertex AI backend'); + expect(info.calls.mostRecent().args[0]).toContain('Agent Platform Gemini API'); }); it('leaves a getVertexAI call with non-literal options in place and warns', () => { @@ -161,10 +453,10 @@ describe('rewriteVertexAIToAI', () => { rewriteVertexAIToAI(tree, context, typescript); const out = tree.readText('src/app/foo.ts'); - expect(out).toContain(`import { getAI, VertexAIBackend } from '@angular/fire/ai';`); + expect(out).toContain(`import { getAI, AgentPlatformBackend } from '@angular/fire/ai';`); // The plain getAI call is untouched, and only the getVertexAI call gets the backend pin. expect(out).toContain('const genAI = getAI();'); - expect(out).toContain('const vertex = getAI(undefined, { backend: new VertexAIBackend() });'); + expect(out).toContain('const vertex = getAI(undefined, { backend: new AgentPlatformBackend() });'); }); it('drops an unused getVertexAI specifier when getAI is already imported', () => { @@ -188,8 +480,8 @@ describe('rewriteVertexAIToAI', () => { rewriteVertexAIToAI(tree, context, typescript); const out = tree.readText('src/app/foo.ts'); - expect(out).toContain(`import { getAI as gv, VertexAIBackend } from '@angular/fire/ai';`); - expect(out).toContain('gv(getApp(), { backend: new VertexAIBackend() })'); + expect(out).toContain(`import { getAI as gv, AgentPlatformBackend } from '@angular/fire/ai';`); + expect(out).toContain('gv(getApp(), { backend: new AgentPlatformBackend() })'); }); it('leaves a getVertexAI re-export in place and warns', () => { @@ -229,8 +521,8 @@ describe('rewriteVertexAIToAI', () => { rewriteVertexAIToAI(tree, context, typescript); const out = tree.readText('src/app/foo.ts'); - expect(out).toContain(`import { getAI, VertexAIBackend, AIError } from 'firebase/ai';`); - expect(out).toContain('getAI(getApp(), { backend: new VertexAIBackend() })'); + expect(out).toContain(`import { getAI, AgentPlatformBackend, AIError } from 'firebase/ai';`); + expect(out).toContain('getAI(getApp(), { backend: new AgentPlatformBackend() })'); expect(out).toContain('instanceof AIError'); }); @@ -270,7 +562,7 @@ describe('rewriteVertexAIToAI', () => { const out = tree.readText('src/app/foo.ts'); expect(out).toContain(`import * as vai from '@angular/fire/ai';`); - expect(out).toContain('vai.provideAI(() => vai.getAI(undefined, { backend: new vai.VertexAIBackend() }))'); + expect(out).toContain('vai.provideAI(() => vai.getAI(undefined, { backend: new vai.AgentPlatformBackend() }))'); }); it('leaves unchanged symbols alone', () => { @@ -380,7 +672,7 @@ describe('rewriteVertexAIToAI', () => { const out = tree.readText('src/app/foo.ts'); expect(out).toContain('fire.AI'); - expect(out).toContain('fire.getAI(undefined, { backend: new fire.VertexAIBackend() })'); + expect(out).toContain('fire.getAI(undefined, { backend: new fire.AgentPlatformBackend() })'); expect(out).not.toContain('getVertexAI'); }); @@ -440,7 +732,7 @@ describe('rewriteVertexAIToAI', () => { const out = tree.readText('src/app/foo.ts'); expect(out).toContain('get getVertexAI()'); - expect(out).toContain('getAI(undefined, { backend: new VertexAIBackend() })'); + expect(out).toContain('getAI(undefined, { backend: new AgentPlatformBackend() })'); }); it('does not treat a destructuring property key as a usage', () => { @@ -457,7 +749,7 @@ describe('rewriteVertexAIToAI', () => { // the property key read from obj is not the import, so it is left untouched expect(out).toContain('const { getVertexAI: local } = obj;'); // the direct call is still rewritten - expect(out).toContain('getAI(undefined, { backend: new VertexAIBackend() })'); + expect(out).toContain('getAI(undefined, { backend: new AgentPlatformBackend() })'); }); it('leaves a binding initializer that hands the function around, and warns', () => { @@ -534,7 +826,7 @@ describe('rewriteVertexAIToAI', () => { expect(warn).toHaveBeenCalledTimes(1); }); - it('adds VertexAIBackend once for two getVertexAI specifiers in one import', () => { + it('adds AgentPlatformBackend once for two getVertexAI specifiers in one import', () => { const source = [ `import { getVertexAI, getVertexAI as gv2 } from '@angular/fire/vertexai';`, `export const a = getVertexAI();`, @@ -546,12 +838,12 @@ describe('rewriteVertexAIToAI', () => { const out = tree.readText('src/app/foo.ts'); const importLine = out.split('\n')[0]; - expect(importLine).toBe(`import { getAI, VertexAIBackend, getAI as gv2 } from '@angular/fire/ai';`); - expect(out).toContain('const a = getAI(undefined, { backend: new VertexAIBackend() });'); - expect(out).toContain('const b = gv2(undefined, { backend: new VertexAIBackend() });'); + expect(importLine).toBe(`import { getAI, AgentPlatformBackend, getAI as gv2 } from '@angular/fire/ai';`); + expect(out).toContain('const a = getAI(undefined, { backend: new AgentPlatformBackend() });'); + expect(out).toContain('const b = gv2(undefined, { backend: new AgentPlatformBackend() });'); }); - it('adds VertexAIBackend once when two old entry points are imported', () => { + it('adds AgentPlatformBackend once when two old entry points are imported', () => { const source = [ `import { getVertexAI } from '@angular/fire/vertexai';`, `import { getVertexAI as fbGet } from 'firebase/vertexai';`, @@ -563,10 +855,10 @@ describe('rewriteVertexAIToAI', () => { rewriteVertexAIToAI(tree, context, typescript); const out = tree.readText('src/app/foo.ts'); - expect(out).toContain(`import { getAI, VertexAIBackend } from '@angular/fire/ai';`); + expect(out).toContain(`import { getAI, AgentPlatformBackend } from '@angular/fire/ai';`); expect(out).toContain(`import { getAI as fbGet } from 'firebase/ai';`); // One import plus two constructor calls: the backend class is never double-imported. - expect((out.match(/VertexAIBackend/g) || []).length).toBe(3); + expect((out.match(/AgentPlatformBackend/g) || []).length).toBe(3); }); it('repurposes the specifier when getAI is imported from the new entry point already', () => { @@ -581,9 +873,9 @@ describe('rewriteVertexAIToAI', () => { rewriteVertexAIToAI(tree, context, typescript); const out = tree.readText('src/app/foo.ts'); - expect(out).toContain(`import { VertexAIBackend } from '@angular/fire/ai';`); + expect(out).toContain(`import { AgentPlatformBackend } from '@angular/fire/ai';`); expect(out).toContain('const a = getAI();'); - expect(out).toContain('const vertex = getAI(undefined, { backend: new VertexAIBackend() });'); + expect(out).toContain('const vertex = getAI(undefined, { backend: new AgentPlatformBackend() });'); }); it('leaves getVertexAI unmigrated when getAI is imported from an unrelated module, and warns', () => { @@ -606,7 +898,7 @@ describe('rewriteVertexAIToAI', () => { expect(warnText).toContain('already bound here from a source other than AI Logic'); expect(warnText).toContain('fails to compile there'); // The skipped call's own warning names the real cause, the foreign binding. - expect(warnText).toContain('binds getAI or VertexAIBackend from another source'); + expect(warnText).toContain('binds getAI or AgentPlatformBackend from another source'); }); it('leaves getVertexAI unmigrated when the file declares its own getAI, and warns', () => { @@ -654,7 +946,7 @@ describe('rewriteVertexAIToAI', () => { expect(tree.readText('src/app/foo.ts')).toContain('reg = { provideVertexAI: provideAI };'); }); - it('moves a shorthand location option into the VertexAIBackend constructor', () => { + it('moves a shorthand location option into the AgentPlatformBackend constructor', () => { const source = [ `import { getVertexAI } from '@angular/fire/vertexai';`, `import { getApp } from '@angular/fire/app';`, @@ -666,7 +958,7 @@ describe('rewriteVertexAIToAI', () => { rewriteVertexAIToAI(tree, context, typescript); expect(tree.readText('src/app/foo.ts')) - .toContain('getAI(getApp(), { backend: new VertexAIBackend(location) })'); + .toContain('getAI(getApp(), { backend: new AgentPlatformBackend(location) })'); }); it('leaves a call whose location option references a rewritten symbol, and warns', () => { @@ -739,11 +1031,15 @@ describe('rewriteVertexAIToAI', () => { const out = tree.readText('src/app/foo.ts'); // VertexAIOptions has no drop-in successor, so it keeps its name (a loud break) while the // rest of the file still migrates. - expect(out).toContain(`import { getAI, VertexAIBackend, VertexAIOptions } from '@angular/fire/ai';`); + expect(out).toContain(`import { getAI, AgentPlatformBackend, VertexAIOptions } from '@angular/fire/ai';`); expect(out).toContain('let options: VertexAIOptions | undefined;'); - expect(out).toContain('getAI(undefined, { backend: new VertexAIBackend() })'); - expect(warn).toHaveBeenCalledTimes(1); - expect(warn.calls.mostRecent().args[0]).toContain('no longer exists in the new entry point'); + expect(out).toContain('getAI(undefined, { backend: new AgentPlatformBackend() })'); + /* Counted per message, not as a total: a total lets a lost region warning and a duplicated + * removed-symbol warning cancel each other out. */ + const warnTexts = warn.calls.allArgs().map(callArgs => String(callArgs[0])); + expect(warnTexts.filter(text => text.includes('no longer exists in the new entry point')).length).toBe(1); + expect(warnTexts.filter(text => text.includes('the region changed from us-central1 to global')).length).toBe(1); + expect(warnTexts.length).toBe(2); }); it('leaves getImagenModel in place and warns that the Imagen API is gone', () => { @@ -758,15 +1054,19 @@ describe('rewriteVertexAIToAI', () => { rewriteVertexAIToAI(tree, spiedContext, typescript); const out = tree.readText('src/app/foo.ts'); - expect(out).toContain(`import { getAI, VertexAIBackend, getImagenModel } from '@angular/fire/ai';`); + expect(out).toContain(`import { getAI, AgentPlatformBackend, getImagenModel } from '@angular/fire/ai';`); + expect(out).toContain('getAI(undefined, { backend: new AgentPlatformBackend() })'); expect(out).toContain(`getImagenModel(a, { model: 'imagen-3.0-generate-002' });`); - expect(warn).toHaveBeenCalledTimes(1); - expect(warn.calls.mostRecent().args[0]).toContain('Imagen models were shut down'); + // Counted per message so warning messages don't cancel each other out. + const warnTexts = warn.calls.allArgs().map(callArgs => String(callArgs[0])); + expect(warnTexts.filter(text => text.includes('Imagen models were shut down')).length).toBe(1); + expect(warnTexts.filter(text => text.includes('the region changed from us-central1 to global')).length).toBe(1); + expect(warnTexts.length).toBe(2); }); - it('reuses an existing VertexAIBackend import instead of adding a second one', () => { + it('reuses an existing AgentPlatformBackend import instead of adding a second one', () => { const source = [ - `import { VertexAIBackend } from '@angular/fire/ai';`, + `import { AgentPlatformBackend } from '@angular/fire/ai';`, `import { getVertexAI } from '@angular/fire/vertexai';`, `export const a = getVertexAI();`, ].join('\n'); @@ -775,11 +1075,11 @@ describe('rewriteVertexAIToAI', () => { rewriteVertexAIToAI(tree, context, typescript); const out = tree.readText('src/app/foo.ts'); - expect(out).toContain(`import { VertexAIBackend } from '@angular/fire/ai';`); + expect(out).toContain(`import { AgentPlatformBackend } from '@angular/fire/ai';`); expect(out).toContain(`import { getAI } from '@angular/fire/ai';`); - expect(out).toContain('getAI(undefined, { backend: new VertexAIBackend() })'); + expect(out).toContain('getAI(undefined, { backend: new AgentPlatformBackend() })'); // The pre-existing import plus one constructor call: no duplicate binding. - expect((out.match(/VertexAIBackend/g) || []).length).toBe(2); + expect((out.match(/AgentPlatformBackend/g) || []).length).toBe(2); }); it('rewrites a call whose location reads a property that merely shares a rewritten name', () => { @@ -797,7 +1097,7 @@ describe('rewriteVertexAIToAI', () => { const out = tree.readText('src/app/foo.ts'); // `settings.provideVertexAI` is a property NAME, which pass 2 never edits, so there is no // overlap and the call must still rewrite. - expect(out).toContain('getAI(getApp(), { backend: new VertexAIBackend(settings.provideVertexAI) })'); + expect(out).toContain('getAI(getApp(), { backend: new AgentPlatformBackend(settings.provideVertexAI) })'); expect(out).toContain('provideAI(() => 1)'); }); @@ -813,7 +1113,7 @@ describe('rewriteVertexAIToAI', () => { rewriteVertexAIToAI(tree, context, typescript); expect(tree.readText('src/app/foo.ts')) - .toContain('getAI(getApp(), { backend: new VertexAIBackend(build({ getVertexAI: true })) })'); + .toContain('getAI(getApp(), { backend: new AgentPlatformBackend(build({ getVertexAI: true })) })'); }); it('rewrites a namespace call whose location reads a non-renamed namespace member', () => { @@ -827,7 +1127,7 @@ describe('rewriteVertexAIToAI', () => { // `vai.DEFAULT_LOCATION` gets no edit (DEFAULT_LOCATION is not renamed), so no overlap. expect(tree.readText('src/app/foo.ts')) - .toContain('vai.getAI(undefined, { backend: new vai.VertexAIBackend(vai.DEFAULT_LOCATION) })'); + .toContain('vai.getAI(undefined, { backend: new vai.AgentPlatformBackend(vai.DEFAULT_LOCATION) })'); }); it('warns about a removed symbol reached through a namespace import', () => { @@ -859,8 +1159,8 @@ describe('rewriteVertexAIToAI', () => { // a boundary offset. They compose, and must not trip the conflicting-edits backstop. expect(changed).toBe(true); const out = tree.readText('src/app/foo.ts'); - expect(out).toContain(`import { getAI as gv, VertexAIBackend, getAI } from '@angular/fire/ai';`); - expect(out).toContain('gv(undefined, { backend: new VertexAIBackend() })'); + expect(out).toContain(`import { getAI as gv, AgentPlatformBackend, getAI } from '@angular/fire/ai';`); + expect(out).toContain('gv(undefined, { backend: new AgentPlatformBackend() })'); expect(out).toContain('const keep = getAI;'); }); @@ -889,7 +1189,7 @@ describe('rewriteVertexAIToAI', () => { expect(changed).toBe(true); expect(tree.readText('src/app/foo.ts')) - .toContain('getAI(undefined, { backend: new VertexAIBackend() })'); + .toContain('getAI(undefined, { backend: new AgentPlatformBackend() })'); }); it('skips a file with syntax errors instead of editing its broken tree, and warns', () => { diff --git a/src/schematics/update/v21/vertexai-to-ai/index.ts b/src/schematics/update/v21/vertexai-to-ai/index.ts index 413edaf2b..40aad43a2 100644 --- a/src/schematics/update/v21/vertexai-to-ai/index.ts +++ b/src/schematics/update/v21/vertexai-to-ai/index.ts @@ -10,12 +10,28 @@ import { overwriteIfExists, safeReadJSON } from '../../../common.js'; import { resolveTypescript } from './compiler.js'; import { collectUsageEdits, scanDeclarations } from './passes.js'; import { collectInjectionConflicts, collectShadowedNames } from './safety.js'; -import { MODULE_SPECIFIER_REWRITES, REMOVED_SYMBOL_GUIDANCE } from './tables.js'; -import type { FileContext, FileRewrite, TextEdit, VertexClassification, VertexImport } from './types.js'; +import { BACKEND_CLASS, MODULE_SPECIFIER_REWRITES, REMOVED_SYMBOL_GUIDANCE } from './tables.js'; +import type { FileContext, FileRewrite, RegionChange, TextEdit, VertexClassification, VertexImport } from './types.js'; import { buildVertexEdits } from './vertex-edits.js'; const UPGRADE_GUIDE = 'see the AngularFire v21 upgrade guide (docs/version-21-upgrade.md)'; +/** The Live API entry points `@angular/fire/ai` exposes. A workspace calling either of them cannot + * run in `global`, so a region move to `global` breaks it rather than merely relocating it. */ +const LIVE_API_ENTRY_POINTS = ['getLiveGenerativeModel', 'startAudioConversation']; + +/** Appended to a region warning only when the workspace calls the Live API, so the common case + * stays short and the case that actually breaks says why. */ +const LIVE_API_CAVEAT = + 'This workspace calls the Live API, which Firebase does not support in the global location, ' + + 'so leaving this on global stops getLiveGenerativeModel and startAudioConversation from working. '; + +/** What to tell the reader to write. Without the Live API they are restoring a region we know they + * had, so it is named. With it they are choosing a region we cannot know, so it is not. */ +const regionRemedy = (backendReference: string, usesLiveApi: boolean): string => usesLiveApi + ? `Pass a location that supports the Live API to ${backendReference}, ${UPGRADE_GUIDE}` + : `Pass new ${backendReference}('us-central1') to keep the region you had, ${UPGRADE_GUIDE}`; + /** One line of the migration log: a position in the file plus the message for it. */ interface LogEntry { level: 'info' | 'warn'; @@ -64,6 +80,8 @@ const collectEditsForSourceFile = (fileContext: FileContext): FileRewrite => { return { edits: scan.edits.concat(vertexEdits.edits), vertexCallPositions: vertexEdits.callPositions, + defaultedLocations: vertexEdits.defaultedLocations, + conditionalLocations: vertexEdits.conditionalLocations, blockedCallPositions: vertexEdits.blockedCallPositions, unsupportedVertexUsages: vertex.unsupported, shadowedImports: scan.shadowedImports, @@ -73,16 +91,58 @@ const collectEditsForSourceFile = (fileContext: FileContext): FileRewrite => { }; }; +/** Opens both region warnings: "this call ", or "this call and N others in this file ". + * The two warnings need different verbs, so both forms are passed in. */ +const regionWarningSubject = (others: readonly RegionChange[], singular: string, plural: string): string => + others.length === 0 + ? `this call ${singular}` + : `this call and ${others.length} other${others.length === 1 ? '' : 's'} in this file ${plural}`; + +/** The one region warning a file earns, or nothing. getVertexAI defaulted to `us-central1` and + * AgentPlatformBackend defaults to `global`, so every rewritten call that passed no location + * changes region. Reported once per file. */ +const regionChangeLogEntry = (rewrite: FileRewrite, usesLiveApi: boolean): LogEntry[] => { + const [change, ...others] = rewrite.defaultedLocations; + if (change === undefined) { return []; } + return [{ + level: 'warn', + position: change.position, + text: `${regionWarningSubject(others, 'now passes', 'now pass')} no location, ` + + 'so the region changed from us-central1 to global. ' + + `us-central1 was getVertexAI's default and global is ${BACKEND_CLASS}'s. ` + + (usesLiveApi ? LIVE_API_CAVEAT : '') + + regionRemedy(change.backendReference, usesLiveApi), + }]; +}; + +/** The one warning a file earns for a location that only runtime can resolve. An expression that + * turns out falsy used to select `us-central1` and now selects `global`. Static analysis cannot + * tell which, so this says what to check rather than asserting a change. */ +const conditionalRegionLogEntry = (rewrite: FileRewrite, usesLiveApi: boolean): LogEntry[] => { + const [change, ...others] = rewrite.conditionalLocations; + if (change === undefined) { return []; } + return [{ + level: 'warn', + position: change.position, + text: `${regionWarningSubject(others, 'passes', 'pass')} a location this migration cannot resolve. ` + + 'An empty value used to mean us-central1 and now means global, so the region changes if that expression is ever empty. ' + + (usesLiveApi ? LIVE_API_CAVEAT : '') + + `Check it, ${UPGRADE_GUIDE}`, + }]; +}; + /** - * The log lines one file's rewrite produces: an info per rewritten getVertexAI call and a warn - * per site deliberately left for manual migration. + * The log lines one file's rewrite produces: an info per rewritten getVertexAI call, one warn when + * the rewrite moved the file's region, and a warn per site deliberately left for manual migration. */ -const rewriteLogEntries = (rewrite: FileRewrite): LogEntry[] => [ +const rewriteLogEntries = (rewrite: FileRewrite, usesLiveApi: boolean): LogEntry[] => [ ...rewrite.vertexCallPositions.map((position): LogEntry => ({ level: 'info', position, - text: 'rewrote getVertexAI(...) to getAI(..., { backend: new VertexAIBackend(...) }) to keep the call on the Vertex AI backend', + text: `rewrote getVertexAI(...) to getAI(..., { backend: new ${BACKEND_CLASS}(...) }) to keep the call on the Agent Platform Gemini API (formerly Vertex AI)`, })), + ...regionChangeLogEntry(rewrite, usesLiveApi), + ...conditionalRegionLogEntry(rewrite, usesLiveApi), ...rewrite.unsupportedVertexUsages.map((usage): LogEntry => ({ level: 'warn', position: usage.position, @@ -102,7 +162,7 @@ const rewriteLogEntries = (rewrite: FileRewrite): LogEntry[] => [ level: 'warn', position, text: rewrite.injectionConflicts.length > 0 - ? 'left a rewritable getVertexAI call unrewritten because this file binds getAI or VertexAIBackend from another source (see that warning). Every use of the file\'s named getVertexAI imports is kept together so the pieces stay consistent, migrate them by hand' + ? `left a rewritable getVertexAI call unrewritten because this file binds getAI or ${BACKEND_CLASS} from another source (see that warning). Every use of the file's named getVertexAI imports is kept together so the pieces stay consistent, migrate them by hand` : 'left a rewritable getVertexAI call unrewritten because another getVertexAI use in this file cannot be rewritten (see its own warning). Every use of the file\'s named getVertexAI imports is kept together so the pieces stay consistent, migrate them by hand', })), ...rewrite.starExportPositions.map((position): LogEntry => ({ @@ -122,7 +182,7 @@ const rewriteLogEntries = (rewrite: FileRewrite): LogEntry[] => [ * * @returns true when the file changed. */ -const rewriteFile = (host: Tree, context: SchematicContext, compiler: typeof ts, filePath: string, content: string): boolean => { +const rewriteFile = (host: Tree, context: SchematicContext, compiler: typeof ts, filePath: string, content: string, usesLiveApi: boolean): boolean => { const fileContext: FileContext = { compiler, sourceFile: compiler.createSourceFile(filePath, content, compiler.ScriptTarget.Latest, true), @@ -135,7 +195,7 @@ const rewriteFile = (host: Tree, context: SchematicContext, compiler: typeof ts, return false; } const rewrite = collectEditsForSourceFile(fileContext); - for (const entry of rewriteLogEntries(rewrite)) { + for (const entry of rewriteLogEntries(rewrite, usesLiveApi)) { const line = fileContext.sourceFile.getLineAndCharacterOfPosition(entry.position).line + 1; context.logger[entry.level](`${filePath}:${line}: ${entry.text}`); } @@ -213,7 +273,7 @@ const shouldVisit = (filePath: string, srcRoots: string[]): boolean => * `ng update` migration step: rewrite a workspace's Vertex AI imports and usages onto Firebase AI * Logic. Visits the TypeScript files under each project's source root and edits any that import from * an old entry point. getVertexAI calls keep their backend: they become - * `getAI(app, { backend: new VertexAIBackend(location?) })`, and every rewritten or skipped + * `getAI(app, { backend: new AgentPlatformBackend(location?) })`, and every rewritten or skipped * getVertexAI site is logged. * * @param compiler the TypeScript compiler to parse with. Defaults to resolving the workspace's @@ -232,6 +292,20 @@ export const rewriteVertexAIToAI = (host: Tree, context: SchematicContext, compi return false; } + /* Whether the workspace calls the Live API decides how loud the region warning has to be, and a + * Live API call usually sits in a different file from the getVertexAI call being rewritten. So it + * is answered across the whole tree before any file is rewritten, rather than per file. */ + let usesLiveApi = false; + host.visit(filePath => { + if (usesLiveApi || !shouldVisit(filePath, srcRoots)) { + return; + } + const content = host.read(filePath)?.toString(); + /* Accumulated with `||=` so one matching file anywhere decides it. A plain assignment would + * let the last file visited overwrite an earlier match, leaving the answer up to walk order. */ + usesLiveApi ||= content !== undefined && LIVE_API_ENTRY_POINTS.some(entryPoint => content.includes(entryPoint)); + }); + let changed = false; host.visit(filePath => { if (!shouldVisit(filePath, srcRoots)) { @@ -249,7 +323,7 @@ export const rewriteVertexAIToAI = (host: Tree, context: SchematicContext, compi ); return; } - changed = rewriteFile(host, context, resolvedCompiler, filePath, content) || changed; + changed = rewriteFile(host, context, resolvedCompiler, filePath, content, usesLiveApi) || changed; }); return changed; }; diff --git a/src/schematics/update/v21/vertexai-to-ai/passes.ts b/src/schematics/update/v21/vertexai-to-ai/passes.ts index aa2c9e43f..92a4ce85b 100644 --- a/src/schematics/update/v21/vertexai-to-ai/passes.ts +++ b/src/schematics/update/v21/vertexai-to-ai/passes.ts @@ -20,6 +20,8 @@ interface OptionsClassification { supported: boolean; /** The `location` property's source text, when present. */ locationText?: string; + /** Whether that text leaves the emitted backend on its own `global` default (see types.ts). */ + locationFallback?: 'certain' | 'possible'; /** True when the literal contains an edit site pass 2 would also rewrite (see containsEditSite). */ conflicting?: boolean; } @@ -364,17 +366,39 @@ const classifyOptionsArgument = (optionsArgument: ts.Expression, fileContext: Fi tsc.isIdentifier(soleProperty.name) && soleProperty.name.text === 'location' ) { - return { supported: true, locationText: soleProperty.initializer.getText(sourceFile) }; + return { + supported: true, + locationText: soleProperty.initializer.getText(sourceFile), + locationFallback: classifyLocationFallback(soleProperty.initializer, fileContext), + }; } if (soleProperty && tsc.isShorthandPropertyAssignment(soleProperty) && soleProperty.name.text === 'location') { - return { supported: true, locationText: 'location' }; + return { supported: true, locationText: 'location', locationFallback: 'possible' }; } return { supported: false }; }; +/** + * Whether a written `location` still leaves the emitted backend on its own `global` default. + * + * On a falsy location, `getVertexAI` defaulted to 'us-central1', but `AgentPlatformBackend` + * defaults to the 'global' location. This is a region change the caller cannot see. + */ +const classifyLocationFallback = ( + initializer: ts.Expression, + { compiler: tsc }: FileContext, +): 'certain' | 'possible' | undefined => { + // Old options typed this as `location?: string`, whose falsy values are `''` and `undefined`. + if (tsc.isStringLiteral(initializer) || tsc.isNoSubstitutionTemplateLiteral(initializer)) { + return initializer.text === '' ? 'certain' : undefined; + } + if (tsc.isIdentifier(initializer) && initializer.text === 'undefined') { return 'certain'; } + return 'possible'; +}; + /** * Decide whether one getVertexAI call's argument shape can be rewritten to - * `getAI(app, { backend: new VertexAIBackend(location?) })` without guessing. + * `getAI(app, { backend: new AgentPlatformBackend(location?) })` without guessing. * * Supported shapes: no arguments, an app argument alone, and an app argument plus an object * literal whose only property is `location` (or an empty literal). Anything else (a spread, a @@ -401,7 +425,12 @@ const classifyVertexCall = ( if (callArguments.length === 2 && !hasSpread) { const options = classifyOptionsArgument(callArguments[1], fileContext, editableBindings); if (options.supported) { - vertex.supportedCalls.push({ call, ...target, locationText: options.locationText }); + vertex.supportedCalls.push({ + call, + ...target, + locationText: options.locationText, + locationFallback: options.locationFallback, + }); return; } conflicting = options.conflicting === true; diff --git a/src/schematics/update/v21/vertexai-to-ai/safety.ts b/src/schematics/update/v21/vertexai-to-ai/safety.ts index 020da17f3..b23a53159 100644 --- a/src/schematics/update/v21/vertexai-to-ai/safety.ts +++ b/src/schematics/update/v21/vertexai-to-ai/safety.ts @@ -1,5 +1,5 @@ // The analyses that decide when rewriting is NOT safe: local declarations that shadow a -// tracked import name, and bindings of the injected names (getAI, VertexAIBackend) that come +// tracked import name, and bindings of the injected names (getAI, AgentPlatformBackend) that come // from somewhere other than AI Logic. Both matter because the passes match identifiers by // text without scope analysis, and the design rule is to leave code whole and warn rather // than ever change behavior silently. @@ -138,7 +138,7 @@ export const collectShadowedNames = (fileContext: FileContext): Set => { }; /** - * Bindings of getAI or VertexAIBackend that do NOT come from an AI Logic module: a lexical + * Bindings of getAI or AgentPlatformBackend that do NOT come from an AI Logic module: a lexical * declaration, or any other import shape. The rewrite injects references to these names, so a * foreign binding would capture the rewritten calls or collide with the injected import. The * file's getVertexAI imports are left unmigrated instead. diff --git a/src/schematics/update/v21/vertexai-to-ai/tables.ts b/src/schematics/update/v21/vertexai-to-ai/tables.ts index 2a6729b72..787cd405e 100644 --- a/src/schematics/update/v21/vertexai-to-ai/tables.ts +++ b/src/schematics/update/v21/vertexai-to-ai/tables.ts @@ -35,9 +35,19 @@ export const REMOVED_SYMBOL_GUIDANCE: Record = { export const GET_VERTEX_AI = 'getVertexAI'; export const GET_AI = 'getAI'; -export const BACKEND_CLASS = 'VertexAIBackend'; +export const BACKEND_CLASS = 'AgentPlatformBackend'; -// Modules whose getAI / VertexAIBackend are (or become, once rewritten) the AI Logic ones. A +/** The firebase floor needed by each backend class the migration can emit. + * + * AgentPlatformBackend arrived in 12.17.0. VertexAIBackend is present at 12.4.0, the bottom of the + * range this package declares, so that is a safe bound, rather than the release that introduced it. + */ +export const BACKEND_CLASS_FIREBASE_FLOOR: Record = { + VertexAIBackend: '12.4.0', + AgentPlatformBackend: '12.17.0', +}; + +// Modules whose getAI / AgentPlatformBackend are (or become, once rewritten) the AI Logic ones. A // binding of those names from anywhere else must not be captured by the rewritten calls. export const AI_MODULE_SPECIFIERS = new Set([ ...Object.keys(MODULE_SPECIFIER_REWRITES), diff --git a/src/schematics/update/v21/vertexai-to-ai/types.ts b/src/schematics/update/v21/vertexai-to-ai/types.ts index 05b29da04..d6c64cb09 100644 --- a/src/schematics/update/v21/vertexai-to-ai/types.ts +++ b/src/schematics/update/v21/vertexai-to-ai/types.ts @@ -41,12 +41,29 @@ export interface SupportedVertexCall { call: ts.CallExpression; /** The `location` option's source text when the call passed `{ location: ... }`, else undefined. */ locationText?: string; + + /** + * Falsy locations fall back to the `global` default. + * 'certain' is for falsy literals, 'possible' is for expressions resolved at runtime. + */ + locationFallback?: 'certain' | 'possible'; /** Set for `getVertexAI(...)` through a named import, undefined for `ns.getVertexAI(...)`. */ binding?: VertexImport; - /** Set for `ns.getVertexAI(...)`. The backend class is then reached as `ns.VertexAIBackend`. */ + /** Set for `ns.getVertexAI(...)`. The backend class is then reached as `ns.AgentPlatformBackend`. */ namespaceAccess?: ts.PropertyAccessExpression; } +/** One rewritten call whose region may have moved, and how that call names the backend class. */ +export interface RegionChange { + position: number; + /** + * How the emitted call reaches the class: a bare `AgentPlatformBackend` for a named import, or + * `ns.AgentPlatformBackend` for a namespace one. The line the warning tells the reader to type + * has to use the same form, or someone who copies it writes a name their file does not bind. + */ + backendReference: string; +} + /** What classifying every getVertexAI reference in a file produced. */ export interface VertexClassification { supportedCalls: SupportedVertexCall[]; @@ -107,9 +124,13 @@ export interface VertexEdits { edits: TextEdit[]; /** Offsets of getVertexAI calls rewritten to the backend-preserving getAI form, for the log. */ callPositions: number[]; + /** Rewritten calls whose region changes due to no valid location being specified. */ + defaultedLocations: RegionChange[]; + /** Rewritten calls whose location depends on a value known only at runtime. */ + conditionalLocations: RegionChange[]; /** Offsets of rewritable calls skipped because the file's getVertexAI edits are blocked. */ blockedCallPositions: number[]; - /** Whether a getAI / VertexAIBackend binding from a non AI Logic source blocked the edits. */ + /** Whether a getAI / AgentPlatformBackend binding from a non AI Logic source blocked the edits. */ injectionBlocked: boolean; } @@ -118,11 +139,15 @@ export interface FileRewrite { edits: TextEdit[]; /** Offsets of getVertexAI calls rewritten to the backend-preserving getAI form, for the log. */ vertexCallPositions: number[]; + /** Rewritten calls whose region moves from `us-central1` to `global`, for the log. */ + defaultedLocations: RegionChange[]; + /** Rewritten calls whose region moves only when their location expression is falsy. */ + conditionalLocations: RegionChange[]; /** Offsets of rewritable calls skipped because the file's getVertexAI edits are blocked. */ blockedCallPositions: number[]; unsupportedVertexUsages: UnsupportedVertexUsage[]; shadowedImports: ShadowedImport[]; - /** Bindings of getAI / VertexAIBackend from a non AI Logic source that blocked the rewrite. */ + /** Bindings of getAI / AgentPlatformBackend from a non AI Logic source that blocked the rewrite. */ injectionConflicts: ShadowedImport[]; /** `export * from ''` statements, always left for manual migration. */ starExportPositions: number[]; diff --git a/src/schematics/update/v21/vertexai-to-ai/vertex-edits.ts b/src/schematics/update/v21/vertexai-to-ai/vertex-edits.ts index 59c9e42e3..d14561b8b 100644 --- a/src/schematics/update/v21/vertexai-to-ai/vertex-edits.ts +++ b/src/schematics/update/v21/vertexai-to-ai/vertex-edits.ts @@ -1,17 +1,17 @@ // Builders for the getVertexAI edits: the backend-preserving call rewrite, the import -// specifier rename/repurpose/removal, and the VertexAIBackend import, with file-wide +// specifier rename/repurpose/removal, and the AgentPlatformBackend import, with file-wide // dedup so no binding is ever injected twice. import type * as ts from 'typescript'; import { BACKEND_CLASS, GET_AI, GET_VERTEX_AI } from './tables.js'; -import type { DeclarationScan, FileContext, ShadowedImport, SupportedVertexCall, TextEdit, VertexClassification, VertexEdits, VertexImport } from './types.js'; +import type { DeclarationScan, FileContext, RegionChange, ShadowedImport, SupportedVertexCall, TextEdit, VertexClassification, VertexEdits, VertexImport } from './types.js'; /** * Turn the classified getVertexAI references into edits: rewritten calls, the import specifier - * rename/replacement/removal, and the VertexAIBackend import. Every named-binding edit in the + * rename/replacement/removal, and the AgentPlatformBackend import. Every named-binding edit in the * file is skipped, so the binding stays coherent (the old import then fails to compile loudly, * and the log says why), when either a named-binding reference was unsupported or the file binds - * getAI / VertexAIBackend from a non AI Logic source that the injected references would hit. + * getAI / AgentPlatformBackend from a non AI Logic source that the injected references would hit. * * @returns the getVertexAI edits plus the rewritten and skipped call positions for the log. */ @@ -24,6 +24,8 @@ export const buildVertexEdits = ( const { sourceFile } = fileContext; const edits: TextEdit[] = []; const callPositions: number[] = []; + const defaultedLocations: RegionChange[] = []; + const conditionalLocations: RegionChange[] = []; const blockedCallPositions: number[] = []; const injectionBlocked = injectionConflicts.length > 0 && scan.vertexImports.length > 0; const bindingBlocked = vertex.unsupported.some(usage => usage.origin === 'binding') || injectionBlocked; @@ -35,7 +37,7 @@ export const buildVertexEdits = ( blockedCallPositions.push(supported.call.getStart(sourceFile)); continue; } - // Namespace calls reach the backend as `ns.VertexAIBackend`, named imports as a bare name. + // Namespace calls reach the backend as `ns.AgentPlatformBackend`, named imports as a bare name. const backendReference = supported.namespaceAccess ? `${supported.namespaceAccess.expression.getText(sourceFile)}.${BACKEND_CLASS}` : BACKEND_CLASS; @@ -52,9 +54,17 @@ export const buildVertexEdits = ( const nameNode = supported.namespaceAccess.name; edits.push({ start: nameNode.getStart(sourceFile), end: nameNode.getEnd(), replacement: GET_AI }); } - // Pin the call's arguments to the Vertex AI backend. + // Give the call the backend argument that keeps it on the same API getVertexAI used. edits.push(...vertexArgumentEdits(supported, fileContext, backendReference)); callPositions.push(supported.call.getStart(sourceFile)); + + // A region can change when no valid location is specified, since defaults differ. + const regionChange: RegionChange = { position: supported.call.getStart(sourceFile), backendReference }; + if (supported.locationText === undefined || supported.locationFallback === 'certain') { + defaultedLocations.push(regionChange); + } else if (supported.locationFallback === 'possible') { + conditionalLocations.push(regionChange); + } } if (!bindingBlocked) { @@ -70,7 +80,14 @@ export const buildVertexEdits = ( } } } - return { edits, callPositions, blockedCallPositions, injectionBlocked }; + return { + edits, + callPositions, + defaultedLocations, + conditionalLocations, + blockedCallPositions, + injectionBlocked, + }; }; /** Every local name bound by any import declaration in the file (default, namespace, and named). */ @@ -100,11 +117,15 @@ const importedLocalNames = (fileContext: FileContext): Set => { }; /** - * Build the argument edits that pin one rewritten call to the Vertex AI backend. + * Build the argument edits that put one rewritten call on the AI Logic backend class, carrying + * its own location across when it has one worth keeping. */ const vertexArgumentEdits = (supported: SupportedVertexCall, fileContext: FileContext, backendReference: string): TextEdit[] => { const { sourceFile } = fileContext; - const backendObject = `{ backend: new ${backendReference}(${supported.locationText ?? ''}) }`; + /* Both backend classes run `if (location)` over their own default, so a location they are + * certain to ignore is dropped instead of being written through as a dead argument. */ + const locationArgument = supported.locationFallback === 'certain' ? '' : supported.locationText ?? ''; + const backendObject = `{ backend: new ${backendReference}(${locationArgument}) }`; const callArguments = supported.call.arguments; if (callArguments.length === 0) { // getAI's app parameter is optional but positional, so the options need an explicit undefined. @@ -120,7 +141,7 @@ const vertexArgumentEdits = (supported: SupportedVertexCall, fileContext: FileCo /** * Build the import-specifier edits for one named getVertexAI import: rename it to getAI, or, when - * the file already binds a local getAI, remove or repurpose it, and add the VertexAIBackend import + * the file already binds a local getAI, remove or repurpose it, and add the AgentPlatformBackend import * when the caller says a rewritten call needs it (at most once per file). */ const vertexSpecifierEdits = (vertexImport: VertexImport, addBackend: boolean, fileHasGetAILocal: boolean, fileContext: FileContext): TextEdit[] => { From 452c50168bfeb7dd8231e9ce8d30f2be09a5b65f Mon Sep 17 00:00:00 2001 From: Armando Navarro Date: Tue, 1 Sep 2026 21:22:21 -0700 Subject: [PATCH 2/2] test(schematics): key the compile host on the path TypeScript asks for The four "output compiles" specs failed on Windows only. `path.resolve` returns backslashes there, TypeScript rewrites a root name's separators before asking the compiler host for that file, and the host's exact-match comparison then missed its own in-memory file. Every Windows job reported TS6053 for a file it had been handed. Reproduced on macOS by keying the host on a backslash path, since TypeScript's slash rewriting is not limited to Windows. --- src/schematics/update/v21/vertexai-to-ai.jasmine.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/schematics/update/v21/vertexai-to-ai.jasmine.ts b/src/schematics/update/v21/vertexai-to-ai.jasmine.ts index 0de0563a4..e52f9339c 100644 --- a/src/schematics/update/v21/vertexai-to-ai.jasmine.ts +++ b/src/schematics/update/v21/vertexai-to-ai.jasmine.ts @@ -129,7 +129,7 @@ describe('rewriteVertexAIToAI', () => { * node_modules the way it would in a user's workspace. */ const compile = (source: string): string[] => { - const filePath = resolve('migrated-output.ts'); + const filePath = resolve('migrated-output.ts').replace(/\\/g, '/'); const host = typescript.createCompilerHost(compilerOptions, true); const readFromDisk = host.getSourceFile.bind(host); const existsOnDisk = host.fileExists.bind(host);