Skip to content

Commit cbd02ea

Browse files
committed
feat: Support Gemini via Vertex AI
Note: I needed to use a forked version of web-auth-library due to the latter only supporting ESM upstream.
1 parent c24fa9e commit cbd02ea

File tree

8 files changed

+234
-29
lines changed

8 files changed

+234
-29
lines changed

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,8 @@
4343
"packageManager": "[email protected]",
4444
"dependencies": {
4545
"puppeteer": "^19.7.2"
46+
},
47+
"resolutions": {
48+
"web-auth-library": "getappmap/web-auth-library#v1.0.3-cjs"
4649
}
4750
}

packages/cli/src/cmds/index/aiEnvVar.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
export const AI_KEY_ENV_VARS = ['OPENAI_API_KEY', 'AZURE_OPENAI_API_KEY', 'ANTHROPIC_API_KEY'];
1+
export const AI_KEY_ENV_VARS = [
2+
'GOOGLE_WEB_CREDENTIALS',
3+
'OPENAI_API_KEY',
4+
'AZURE_OPENAI_API_KEY',
5+
'ANTHROPIC_API_KEY',
6+
];
27

38
export default function detectAIEnvVar(): string | undefined {
49
return Object.keys(process.env).find((key) => AI_KEY_ENV_VARS.includes(key));

packages/cli/src/cmds/index/index.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,7 @@ import LocalNavie from '../../rpc/explain/navie/navie-local';
2424
import RemoteNavie from '../../rpc/explain/navie/navie-remote';
2525
import { InteractionEvent } from '@appland/navie/dist/interaction-history';
2626
import { update } from '../../rpc/file/update';
27-
28-
const AI_KEY_ENV_VARS = ['OPENAI_API_KEY', 'AZURE_OPENAI_API_KEY'];
27+
import { AI_KEY_ENV_VARS } from './aiEnvVar';
2928

3029
export const command = 'index';
3130
export const describe =

packages/cli/src/rpc/llmConfiguration.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,14 @@ function openAIBaseURL(): string | undefined {
4747
return baseUrl;
4848
}
4949

50+
const DEFAULT_BASE_URLS = {
51+
anthropic: 'https://api.anthropic.com/v1/',
52+
'vertex-ai': 'https://googleapis.com',
53+
openai: undefined,
54+
} as const;
55+
5056
export function getLLMConfiguration(): LLMConfiguration {
51-
const baseUrl =
52-
SELECTED_BACKEND === 'anthropic' ? 'https://api.anthropic.com/v1/' : openAIBaseURL();
57+
const baseUrl = (SELECTED_BACKEND && DEFAULT_BASE_URLS[SELECTED_BACKEND]) ?? openAIBaseURL();
5358

5459
return {
5560
baseUrl,

packages/navie/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"dependencies": {
4242
"@langchain/anthropic": "^0.3.1",
4343
"@langchain/core": "^0.2.27",
44+
"@langchain/google-vertexai-web": "^0.1.0",
4445
"@langchain/openai": "^0.2.7",
4546
"fast-xml-parser": "^4.4.0",
4647
"js-yaml": "^4.1.0",
Lines changed: 19 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { warn } from 'node:console';
22

3+
import GoogleVertexAICompletionService from './google-vertexai-completion-service';
34
import OpenAICompletionService from './openai-completion-service';
45
import AnthropicCompletionService from './anthropic-completion-service';
56
import CompletionService from './completion-service';
@@ -10,47 +11,41 @@ interface Options {
1011
modelName: string;
1112
temperature: number;
1213
trajectory: Trajectory;
14+
backend?: Backend;
1315
}
1416

15-
type Backend = 'anthropic' | 'openai';
17+
const BACKENDS = {
18+
anthropic: AnthropicCompletionService,
19+
openai: OpenAICompletionService,
20+
'vertex-ai': GoogleVertexAICompletionService,
21+
} as const;
1622

17-
function defaultBackend(): Backend {
18-
return 'ANTHROPIC_API_KEY' in process.env ? 'anthropic' : 'openai';
19-
}
23+
type Backend = keyof typeof BACKENDS;
2024

21-
function environmentBackend(): Backend | undefined {
25+
function determineCompletionBackend(): Backend {
2226
switch (process.env.APPMAP_NAVIE_COMPLETION_BACKEND) {
2327
case 'anthropic':
2428
case 'openai':
29+
case 'vertex-ai':
2530
return process.env.APPMAP_NAVIE_COMPLETION_BACKEND;
2631
default:
27-
return undefined;
32+
// pass
2833
}
34+
if ('ANTHROPIC_API_KEY' in process.env) return 'anthropic';
35+
if ('GOOGLE_WEB_CREDENTIALS' in process.env) return 'vertex-ai';
36+
if ('OPENAI_API_KEY' in process.env) return 'openai';
37+
return 'openai'; // fallback
2938
}
3039

31-
export const SELECTED_BACKEND: Backend = environmentBackend() ?? defaultBackend();
40+
export const SELECTED_BACKEND: Backend = determineCompletionBackend();
3241

3342
export default function createCompletionService({
3443
modelName,
3544
temperature,
3645
trajectory,
46+
backend = determineCompletionBackend(),
3747
}: Options): CompletionService {
38-
const backend = environmentBackend() ?? defaultBackend();
3948
const messageTokenReducerService = new MessageTokenReducerService();
40-
if (backend === 'anthropic') {
41-
warn('Using Anthropic AI backend');
42-
return new AnthropicCompletionService(
43-
modelName,
44-
temperature,
45-
trajectory,
46-
messageTokenReducerService
47-
);
48-
}
49-
warn('Using OpenAI backend');
50-
return new OpenAICompletionService(
51-
modelName,
52-
temperature,
53-
trajectory,
54-
messageTokenReducerService
55-
);
49+
warn(`Using completion service ${backend}`);
50+
return new BACKENDS[backend](modelName, temperature, trajectory, messageTokenReducerService);
5651
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { warn } from 'node:console';
2+
import { isNativeError } from 'node:util/types';
3+
4+
import { ChatVertexAI, type ChatVertexAIInput } from '@langchain/google-vertexai-web';
5+
import { zodResponseFormat } from 'openai/helpers/zod';
6+
import { z } from 'zod';
7+
8+
import Trajectory from '../lib/trajectory';
9+
import Message from '../message';
10+
import CompletionService, {
11+
CompleteOptions,
12+
Completion,
13+
CompletionRetries,
14+
CompletionRetryDelay,
15+
convertToMessage,
16+
mergeSystemMessages,
17+
Usage,
18+
} from './completion-service';
19+
20+
export default class GoogleVertexAICompletionService implements CompletionService {
21+
constructor(
22+
public readonly modelName: string,
23+
public readonly temperature: number,
24+
private trajectory: Trajectory
25+
) {}
26+
27+
// Construct a model with non-default options. There doesn't seem to be a way to configure
28+
// the model parameters at invocation time like with OpenAI.
29+
private buildModel(options?: ChatVertexAIInput): ChatVertexAI {
30+
return new ChatVertexAI({
31+
model: this.modelName,
32+
temperature: this.temperature,
33+
streaming: true,
34+
maxOutputTokens: 8192,
35+
...options,
36+
});
37+
}
38+
39+
get miniModelName(): string {
40+
const miniModel = process.env.APPMAP_NAVIE_MINI_MODEL;
41+
return miniModel ?? 'gemini-1.5-flash-002';
42+
}
43+
44+
// Request a JSON object with a given JSON schema.
45+
async json<Schema extends z.ZodType>(
46+
messages: Message[],
47+
schema: Schema,
48+
options?: CompleteOptions
49+
): Promise<z.infer<Schema> | undefined> {
50+
const model = this.buildModel({
51+
...options,
52+
streaming: false,
53+
responseMimeType: 'application/json',
54+
});
55+
const sentMessages = mergeSystemMessages([
56+
...messages,
57+
{
58+
role: 'system',
59+
content: `Use the following JSON schema for your response:\n\n${JSON.stringify(
60+
zodResponseFormat(schema, 'requestedObject').json_schema.schema,
61+
null,
62+
2
63+
)}`,
64+
},
65+
]);
66+
67+
for (const message of sentMessages) this.trajectory.logSentMessage(message);
68+
69+
const response = await model.invoke(sentMessages.map(convertToMessage));
70+
71+
this.trajectory.logReceivedMessage({
72+
role: 'assistant',
73+
content: JSON.stringify(response),
74+
});
75+
76+
const sanitizedContent = response.content.toString().replace(/^`{3,}[^\s]*?$/gm, '');
77+
const parsed = JSON.parse(sanitizedContent) as unknown;
78+
schema.parse(parsed);
79+
return parsed;
80+
}
81+
82+
async *complete(messages: readonly Message[], options?: { temperature?: number }): Completion {
83+
const usage = new Usage();
84+
const model = this.buildModel(options);
85+
const sentMessages: Message[] = mergeSystemMessages(messages);
86+
const tokens = new Array<string>();
87+
for (const message of sentMessages) this.trajectory.logSentMessage(message);
88+
89+
const maxAttempts = CompletionRetries;
90+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
91+
try {
92+
// eslint-disable-next-line no-await-in-loop
93+
const response = await model.stream(sentMessages.map(convertToMessage));
94+
95+
// eslint-disable-next-line @typescript-eslint/naming-convention, no-await-in-loop
96+
for await (const { content, usage_metadata } of response) {
97+
yield content.toString();
98+
tokens.push(content.toString());
99+
if (usage_metadata) {
100+
usage.promptTokens += usage_metadata.input_tokens;
101+
usage.completionTokens += usage_metadata.output_tokens;
102+
}
103+
}
104+
105+
this.trajectory.logReceivedMessage({
106+
role: 'assistant',
107+
content: tokens.join(''),
108+
});
109+
110+
break;
111+
} catch (cause) {
112+
if (attempt < maxAttempts - 1 && tokens.length === 0) {
113+
const nextAttempt = CompletionRetryDelay * 2 ** attempt;
114+
warn(`Received ${JSON.stringify(cause)}, retrying in ${nextAttempt}ms`);
115+
await new Promise<void>((resolve) => {
116+
setTimeout(resolve, nextAttempt);
117+
});
118+
continue;
119+
}
120+
throw new Error(
121+
`Failed to complete after ${attempt + 1} attempt(s): ${errorMessage(cause)}`,
122+
{
123+
cause,
124+
}
125+
);
126+
}
127+
}
128+
129+
warn(usage.toString());
130+
return usage;
131+
}
132+
}
133+
134+
function errorMessage(err: unknown): string {
135+
if (isNativeError(err)) return err.cause ? errorMessage(err.cause) : err.message;
136+
return String(err);
137+
}

yarn.lock

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,7 @@ __metadata:
457457
dependencies:
458458
"@langchain/anthropic": ^0.3.1
459459
"@langchain/core": ^0.2.27
460+
"@langchain/google-vertexai-web": ^0.1.0
460461
"@langchain/openai": ^0.2.7
461462
"@tsconfig/node-lts": ^20.1.3
462463
"@types/jest": ^29.4.1
@@ -7039,6 +7040,41 @@ __metadata:
70397040
languageName: node
70407041
linkType: hard
70417042

7043+
"@langchain/google-common@npm:~0.1.0":
7044+
version: 0.1.1
7045+
resolution: "@langchain/google-common@npm:0.1.1"
7046+
dependencies:
7047+
uuid: ^10.0.0
7048+
zod-to-json-schema: ^3.22.4
7049+
peerDependencies:
7050+
"@langchain/core": ">=0.2.21 <0.4.0"
7051+
checksum: e460a08eaf5e6902c3cb7e8deb9edddcdb46c6bc38657ee1050d05ab5f17bf864bf298a9f00cc41e2824f8c072d79c1dca9b84a7ce64ebcf5a5357af14f5b9d9
7052+
languageName: node
7053+
linkType: hard
7054+
7055+
"@langchain/google-vertexai-web@npm:^0.1.0":
7056+
version: 0.1.0
7057+
resolution: "@langchain/google-vertexai-web@npm:0.1.0"
7058+
dependencies:
7059+
"@langchain/google-webauth": ~0.1.0
7060+
peerDependencies:
7061+
"@langchain/core": ">=0.2.21 <0.4.0"
7062+
checksum: 8c32499e4070ddf28de26e3e4354c60303921e0be84aa68bbcbbeecd5e79e78354fb940708dcfc94efbc67f51893e51039288d78418ec00ec3f64a6cb1e5b20e
7063+
languageName: node
7064+
linkType: hard
7065+
7066+
"@langchain/google-webauth@npm:~0.1.0":
7067+
version: 0.1.0
7068+
resolution: "@langchain/google-webauth@npm:0.1.0"
7069+
dependencies:
7070+
"@langchain/google-common": ~0.1.0
7071+
web-auth-library: ^1.0.3
7072+
peerDependencies:
7073+
"@langchain/core": ">=0.2.21 <0.4.0"
7074+
checksum: 90d7c04f95e9950ec5fb39a779352f145efa319d2003564b82a183809ef92d64f8f878999e5cb9c75b1bfda83e38c9b650946c928b1d137dd8bf0bebbaddca74
7075+
languageName: node
7076+
linkType: hard
7077+
70427078
"@langchain/openai@npm:>=0.1.0 <0.3.0, @langchain/openai@npm:^0.2.7":
70437079
version: 0.2.7
70447080
resolution: "@langchain/openai@npm:0.2.7"
@@ -28417,6 +28453,13 @@ __metadata:
2841728453
languageName: node
2841828454
linkType: hard
2841928455

28456+
"jose@npm:>= 4.12.0 < 5.0.0":
28457+
version: 4.15.9
28458+
resolution: "jose@npm:4.15.9"
28459+
checksum: 41abe1c99baa3cf8a78ebbf93da8f8e50e417b7a26754c4afa21865d87527b8ac2baf66de2c5f6accc3f7d7158658dae7364043677236ea1d07895b040097f15
28460+
languageName: node
28461+
linkType: hard
28462+
2842028463
"joycon@npm:^3.0.1":
2842128464
version: 3.1.1
2842228465
resolution: "joycon@npm:3.1.1"
@@ -36936,6 +36979,13 @@ [email protected]:
3693636979
languageName: node
3693736980
linkType: hard
3693836981

36982+
"rfc4648@npm:^1.5.2":
36983+
version: 1.5.3
36984+
resolution: "rfc4648@npm:1.5.3"
36985+
checksum: 19c81d502582e377125b00fbd7a5cdb0e351f9a1e40182fa9f608b48e1ab852d211b75facb2f4f3fa17f7c6ebc2ef4acca61ae7eb7fbcfa4768f11d2db678116
36986+
languageName: node
36987+
linkType: hard
36988+
3693936989
"rfdc@npm:^1.3.0":
3694036990
version: 1.3.0
3694136991
resolution: "rfdc@npm:1.3.0"
@@ -41849,6 +41899,16 @@ typescript@~4.4.3:
4184941899
languageName: node
4185041900
linkType: hard
4185141901

41902+
"web-auth-library@getappmap/web-auth-library#v1.0.3-cjs":
41903+
version: 1.0.3
41904+
resolution: "web-auth-library@https://github.com/getappmap/web-auth-library.git#commit=f60401541b00795465224c5da786fd273dbc9459"
41905+
dependencies:
41906+
jose: ">= 4.12.0 < 5.0.0"
41907+
rfc4648: ^1.5.2
41908+
checksum: 8031779036fdba6f7eba1638e73368a11b7745a26cab2401998878e767e83fcd88c5b0270ddce140fd1f1822e177b09619a856368295a644af256a2d2d721b88
41909+
languageName: node
41910+
linkType: hard
41911+
4185241912
"web-streams-polyfill@npm:4.0.0-beta.3":
4185341913
version: 4.0.0-beta.3
4185441914
resolution: "web-streams-polyfill@npm:4.0.0-beta.3"

0 commit comments

Comments
 (0)