Unverified 提交 4db6d63b authored 作者: Md Rakibul Islam Rocky's avatar Md Rakibul Islam Rocky 提交者: GitHub

Add Google Vertex AI provider (#1163)

# Summary * Adds first-class **Google Vertex AI provider** using `@ai-sdk/google-vertex`. * Supports **Gemini 2.5** models and partner **MaaS (Model Garden)** models via full publisher IDs. * New **Vertex-specific settings UI** for Project, Location, and Service Account JSON. * Implements a **“thinking” toggle** for Gemini 2.5 Flash * Pro: always on * Flash: toggleable * Flash Lite: none * Fixes *“AI not found”* for Vertex built-ins by mapping to `publishers/google` paths. * Hardens **cross-platform file ops** and ensures all tests pass. --- # What’s New ### Vertex AI Provider * Uses `@ai-sdk/google-vertex` with `googleAuthOptions.credentials` from pasted Service Account JSON. * Configurable **project** and **location**. * Base URL → `/projects/{project}/locations/{location}` * Built-ins: `publishers/google/models/<id>` * Partner MaaS: `publishers/<partner>/models/...` ### Built-in Vertex Models * `gemini-2.5-pro` * `gemini-2.5-flash` * `gemini-2.5-flash-lite` ### Thinking Behavior * Vertex + Google marked as thinking-capable. * Pro: always thinking * Flash: toggle in UI * Flash Lite: none ### Vertex Settings UI * New **Google Vertex AI panel** for Project ID, Location, Service Account JSON. * Keys encrypted like other secrets. --- # Fixes * **Model resolution:** built-ins auto-map to `publishers/google/models/<id>`. * **Partner MaaS support:** full publisher IDs work directly (e.g. DeepSeek). * **Cross-platform paths:** normalize file ops with `toPosixPath`, preserve `safeJoin` semantics. --- # Why This Is Better * Users can select **Vertex alongside other providers**. * **More models** available through Model Garden. * **Dedicated setup UI** reduces misconfig. * **Thinking toggle** gives control over cost vs. reasoning depth. --- # Files Changed * **Provider & Models**: `language_model_helpers.ts`, `get_model_client.ts` * **Streaming**: `chat_stream_handlers.ts` * **Schemas & Encryption**: `schemas.ts`, `settings.ts` * **Settings UI**: `VertexConfiguration.tsx`, `ApiKeyConfiguration.tsx` * **Models UI**: `ModelsSection.tsx` (Flash toggle) * **Setup Detection**: `useLanguageModelProviders.ts` * **Path Utils**: `path_utils.ts`, `response_processor.ts` * **Deps**: `package.json` → `@ai-sdk/google-vertex@3.0.16` --- # Tests & Validation * **TypeScript**: `npm run ts` → * **Lint**: `npm run lint` → * **Unit tests**: `npm test` → 231 passed, 0 failed --- # Migration / Notes * No breaking changes. * For Vertex usage: * Ensure Vertex AI API is enabled. * Service Account needs `roles/aiplatform.user`. * Region must support model (e.g. `us-central1`). * Thinking toggle currently affects **only** Gemini 2.5 Flash. --- # Manual QA 1. Configure Vertex with Project/Location/Service Account JSON. 2. Test built-ins: * `gemini-2.5-pro` * `gemini-2.5-flash` (toggle on/off) * `gemini-2.5-flash-lite` 3. Test MaaS partner model (e.g., DeepSeek) via full publisher ID. 4. Verify other providers remain unaffected. <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds a first-class Google Vertex AI provider with Gemini 2.5 models, a Vertex settings panel, and a “thinking” toggle for Gemini 2.5 Flash. Also fixes model resolution for Vertex and hardens cross-platform file operations. - **New Features** - Vertex AI provider via @ai-sdk/google-vertex with project, location, and service account JSON. - Built-in models: gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-lite. - “Thinking” support: Pro always on; Flash toggle in Models UI; Flash Lite none. - MaaS partners supported via full publisher paths (e.g., publishers/<partner>/models/...). - Vertex settings UI with encrypted service account key storage. - **Bug Fixes** - Built-in Vertex models auto-map to publishers/google/models/<id>. - Consistent file ops across platforms using toPosixPath. - Vertex readiness detection requires project/location/service account JSON. - Streaming “thinking” behavior respects Vertex Flash toggle and Pro always-on. <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: 's avatarMd Rakibul Islam Rocky <mdrirocky08@gmail.com> Co-authored-by: 's avatargraphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> Co-authored-by: 's avatarWill Chen <willchen90@gmail.com>
上级 e962964a
差异被折叠。
......@@ -88,6 +88,7 @@
"@ai-sdk/anthropic": "^2.0.4",
"@ai-sdk/azure": "^2.0.17",
"@ai-sdk/google": "^2.0.6",
"@ai-sdk/google-vertex": "3.0.16",
"@ai-sdk/openai": "2.0.15",
"@ai-sdk/openai-compatible": "^1.0.8",
"@ai-sdk/provider-utils": "^3.0.3",
......
......@@ -7,6 +7,7 @@ import {
AccordionTrigger,
} from "@/components/ui/accordion";
import { AzureConfiguration } from "./AzureConfiguration";
import { VertexConfiguration } from "./VertexConfiguration";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { UserSettings } from "@/lib/schemas";
......@@ -51,6 +52,10 @@ export function ApiKeyConfiguration({
if (provider === "azure") {
return <AzureConfiguration envVars={envVars} />;
}
// Special handling for Google Vertex AI which uses service account credentials
if (provider === "vertex") {
return <VertexConfiguration />;
}
const envApiKey = envVarName ? envVars[envVarName] : undefined;
const userApiKey = settings?.providerSettings?.[provider]?.apiKey?.value;
......
......@@ -75,8 +75,20 @@ export function ProviderSettingsPage({ provider }: ProviderSettingsPageProps) {
? !!(envVars["AZURE_API_KEY"] && envVars["AZURE_RESOURCE_NAME"])
: false;
// Special handling for Vertex configuration status
const vertexSettings = settings?.providerSettings?.vertex as any;
const isVertexConfigured = Boolean(
vertexSettings?.projectId &&
vertexSettings?.location &&
vertexSettings?.serviceAccountKey?.value,
);
const isConfigured =
provider === "azure" ? isAzureConfigured : isValidUserKey || hasEnvKey; // Configured if either is set
provider === "azure"
? isAzureConfigured
: provider === "vertex"
? isVertexConfigured
: isValidUserKey || hasEnvKey; // Configured if either is set
// --- Save Handler ---
const handleSaveKey = async () => {
......
import { useState, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Info, CheckCircle2 } from "lucide-react";
import { useSettings } from "@/hooks/useSettings";
import type { UserSettings, VertexProviderSetting } from "@/lib/schemas";
export function VertexConfiguration() {
const { settings, updateSettings } = useSettings();
const existing =
(settings?.providerSettings?.vertex as VertexProviderSetting) ?? {};
const [projectId, setProjectId] = useState(existing.projectId || "");
const [location, setLocation] = useState(existing.location || "");
const [serviceAccountKey, setServiceAccountKey] = useState(
existing.serviceAccountKey?.value || "",
);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
useEffect(() => {
setProjectId(existing.projectId || "");
setLocation(existing.location || "");
setServiceAccountKey(existing.serviceAccountKey?.value || "");
}, [settings?.providerSettings?.vertex]);
const onSave = async () => {
setError(null);
setSaved(false);
try {
// If provided, ensure the service account JSON parses
if (serviceAccountKey) {
JSON.parse(serviceAccountKey);
}
} catch (e: any) {
setError("Service account JSON is invalid: " + e.message);
return;
}
setSaving(true);
try {
const settingsUpdate: Partial<UserSettings> = {
providerSettings: {
...settings?.providerSettings,
vertex: {
...existing,
projectId: projectId.trim() || undefined,
location: location || undefined,
serviceAccountKey: serviceAccountKey
? { value: serviceAccountKey }
: undefined,
},
},
};
await updateSettings(settingsUpdate);
setSaved(true);
} catch (e: any) {
setError(e?.message || "Failed to save Vertex settings");
} finally {
setSaving(false);
}
};
const isConfigured = Boolean(
(projectId.trim() && location && serviceAccountKey) ||
(existing.projectId &&
existing.location &&
existing.serviceAccountKey?.value),
);
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Project ID</label>
<Input
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
placeholder="your-gcp-project-id"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Location</label>
<Input
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="us-central1"
/>
<p className="mt-1 text-xs text-muted-foreground">
If you see a "model not found" error, try a different region. Some
partner models (MaaS) are only available in specific locations
(e.g., us-central1, us-west2).
</p>
</div>
<div>
<label className="block text-sm font-medium mb-1">
Service Account JSON Key
</label>
<Textarea
value={serviceAccountKey}
onChange={(e) => setServiceAccountKey(e.target.value)}
placeholder="Paste the full JSON contents of your service account key here"
className="min-h-40"
/>
</div>
</div>
<div className="flex items-center gap-2">
<Button onClick={onSave} disabled={saving}>
{saving ? "Saving..." : "Save Settings"}
</Button>
{saved && !error && (
<span className="flex items-center text-green-600 text-sm">
<CheckCircle2 className="h-4 w-4 mr-1" /> Saved
</span>
)}
</div>
{!isConfigured && (
<Alert variant="default">
<Info className="h-4 w-4" />
<AlertTitle>Configuration Required</AlertTitle>
<AlertDescription>
Provide Project, Location, and a service account JSON key with
Vertex AI access.
</AlertDescription>
</Alert>
)}
{error && (
<Alert variant="destructive">
<AlertTitle>Save Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</div>
);
}
......@@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query";
import { IpcClient } from "@/ipc/ipc_client";
import type { LanguageModelProvider } from "@/ipc/ipc_types";
import { useSettings } from "./useSettings";
import { cloudProviders } from "@/lib/schemas";
import { cloudProviders, VertexProviderSetting } from "@/lib/schemas";
export function useLanguageModelProviders() {
const ipcClient = IpcClient.getInstance();
......@@ -20,6 +20,18 @@ export function useLanguageModelProviders() {
if (queryResult.isLoading) {
return false;
}
// Vertex uses service account credentials instead of an API key
if (provider === "vertex") {
const vertexSettings = providerSettings as VertexProviderSetting;
if (
vertexSettings?.serviceAccountKey?.value &&
vertexSettings?.projectId &&
vertexSettings?.location
) {
return true;
}
return false;
}
if (providerSettings?.apiKey?.value) {
return true;
}
......
......@@ -667,28 +667,53 @@ This conversation includes one or more image attachments. When the user uploads
} else {
logger.log("sending AI request");
}
// Build provider options with correct Google/Vertex thinking config gating
const providerOptions: Record<string, any> = {
"dyad-engine": {
dyadRequestId,
},
"dyad-gateway": getExtraProviderOptions(
modelClient.builtinProviderId,
settings,
),
openai: {
reasoningSummary: "auto",
} satisfies OpenAIResponsesProviderOptions,
};
// Conditionally include Google thinking config only for supported models
const selectedModelName = settings.selectedModel.name || "";
const providerId = modelClient.builtinProviderId;
const isVertex = providerId === "vertex";
const isGoogle = providerId === "google";
const isPartnerModel = selectedModelName.includes("/");
const isGeminiModel = selectedModelName.startsWith("gemini");
const isFlashLite = selectedModelName.includes("flash-lite");
// Keep Google provider behavior unchanged: always include includeThoughts
if (isGoogle) {
providerOptions.google = {
thinkingConfig: {
includeThoughts: true,
},
} satisfies GoogleGenerativeAIProviderOptions;
}
// Vertex-specific fix: only enable thinking on supported Gemini models
if (isVertex && isGeminiModel && !isFlashLite && !isPartnerModel) {
providerOptions.google = {
thinkingConfig: {
includeThoughts: true,
},
} satisfies GoogleGenerativeAIProviderOptions;
}
return streamText({
maxOutputTokens: await getMaxTokens(settings.selectedModel),
temperature: await getTemperature(settings.selectedModel),
maxRetries: 2,
model: modelClient.model,
providerOptions: {
"dyad-engine": {
dyadRequestId,
},
"dyad-gateway": getExtraProviderOptions(
modelClient.builtinProviderId,
settings,
),
google: {
thinkingConfig: {
includeThoughts: true,
},
} satisfies GoogleGenerativeAIProviderOptions,
openai: {
reasoningSummary: "auto",
} satisfies OpenAIResponsesProviderOptions,
},
providerOptions,
system: systemPrompt,
messages: chatMessages.filter((m) => m.content),
onError: (error: any) => {
......
......@@ -8,6 +8,7 @@ import { eq } from "drizzle-orm";
export const PROVIDERS_THAT_SUPPORT_THINKING: (keyof typeof MODEL_OPTIONS)[] = [
"google",
"vertex",
"auto",
];
......@@ -144,6 +145,26 @@ export const MODEL_OPTIONS: Record<string, ModelOption[]> = {
dollarSigns: 2,
},
],
vertex: [
// Vertex Gemini 2.5 Pro
{
name: "gemini-2.5-pro",
displayName: "Gemini 2.5 Pro",
description: "Vertex Gemini 2.5 Pro",
maxOutputTokens: 65_536 - 1,
contextWindow: 1_048_576,
temperature: 0,
},
// Vertex Gemini 2.5 Flash
{
name: "gemini-2.5-flash",
displayName: "Gemini 2.5 Flash",
description: "Vertex Gemini 2.5 Flash",
maxOutputTokens: 65_536 - 1,
contextWindow: 1_048_576,
temperature: 0,
},
],
openrouter: [
{
name: "qwen/qwen3-coder",
......@@ -270,6 +291,14 @@ export const CLOUD_PROVIDERS: Record<
websiteUrl: "https://aistudio.google.com/app/apikey",
gatewayPrefix: "gemini/",
},
vertex: {
displayName: "Google Vertex AI",
hasFreeTier: false,
websiteUrl: "https://console.cloud.google.com/vertex-ai",
// Use the same gateway prefix as Google Gemini for Dyad Pro compatibility.
gatewayPrefix: "gemini/",
secondary: true,
},
openrouter: {
displayName: "OpenRouter",
hasFreeTier: true,
......
import { createOpenAI } from "@ai-sdk/openai";
import { createGoogleGenerativeAI as createGoogle } from "@ai-sdk/google";
import { createAnthropic } from "@ai-sdk/anthropic";
import { createVertex as createGoogleVertex } from "@ai-sdk/google-vertex";
import { azure } from "@ai-sdk/azure";
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import type { LargeLanguageModel, UserSettings } from "../../lib/schemas";
import type {
LargeLanguageModel,
UserSettings,
VertexProviderSetting,
} from "../../lib/schemas";
import { getEnvVar } from "./read_env";
import log from "electron-log";
import { getLanguageModelProviders } from "../shared/language_model_helpers";
......@@ -216,6 +221,45 @@ function getRegularModelClient(
backupModelClients: [],
};
}
case "vertex": {
// Vertex uses Google service account credentials with project/location
const vertexSettings = settings.providerSettings?.[
model.provider
] as VertexProviderSetting;
const project = vertexSettings?.projectId;
const location = vertexSettings?.location;
const serviceAccountKey = vertexSettings?.serviceAccountKey?.value;
// Use a baseURL that does NOT pin to publishers/google so that
// full publisher model IDs (e.g. publishers/deepseek-ai/models/...) work.
const regionHost = `${location === "global" ? "" : `${location}-`}aiplatform.googleapis.com`;
const baseURL = `https://${regionHost}/v1/projects/${project}/locations/${location}`;
const provider = createGoogleVertex({
project,
location,
baseURL,
googleAuthOptions: serviceAccountKey
? {
// Expecting the user to paste the full JSON of the service account key
credentials: JSON.parse(serviceAccountKey),
}
: undefined,
});
return {
modelClient: {
// For built-in Google models on Vertex, the path must include
// publishers/google/models/<model>. For partner MaaS models the
// full publisher path is already included.
model: provider(
model.name.includes("/")
? model.name
: `publishers/google/models/${model.name}`,
),
builtinProviderId: providerId,
},
backupModelClients: [],
};
}
case "openrouter": {
const provider = createOpenRouter({ apiKey });
return {
......
......@@ -30,6 +30,7 @@ const providers = [
"openai",
"anthropic",
"google",
"vertex",
"auto",
"openrouter",
"ollama",
......@@ -57,15 +58,35 @@ export type LargeLanguageModel = z.infer<typeof LargeLanguageModelSchema>;
/**
* Zod schema for provider settings
* Regular providers use only apiKey. Vertex has additional optional fields.
*/
export const ProviderSettingSchema = z.object({
export const RegularProviderSettingSchema = z.object({
apiKey: SecretSchema.optional(),
});
export const VertexProviderSettingSchema = z.object({
// We make this undefined so that it makes existing callsites easier.
apiKey: z.undefined(),
projectId: z.string().optional(),
location: z.string().optional(),
serviceAccountKey: SecretSchema.optional(),
});
export const ProviderSettingSchema = z.union([
// Must use more specific type first!
// Zod uses the first type that matches.
VertexProviderSettingSchema,
RegularProviderSettingSchema,
]);
/**
* Type derived from the ProviderSettingSchema
*/
export type ProviderSetting = z.infer<typeof ProviderSettingSchema>;
export type RegularProviderSetting = z.infer<
typeof RegularProviderSettingSchema
>;
export type VertexProviderSetting = z.infer<typeof VertexProviderSettingSchema>;
export const RuntimeModeSchema = z.enum(["web-sandbox", "local-node", "unset"]);
export type RuntimeMode = z.infer<typeof RuntimeModeSchema>;
......
import fs from "node:fs";
import path from "node:path";
import { getUserDataPath } from "../paths/paths";
import { UserSettingsSchema, type UserSettings, Secret } from "../lib/schemas";
import {
UserSettingsSchema,
type UserSettings,
Secret,
VertexProviderSetting,
} from "../lib/schemas";
import { safeStorage } from "electron";
import { v4 as uuidv4 } from "uuid";
import log from "electron-log";
......@@ -114,6 +119,17 @@ export function readSettings(): UserSettings {
encryptionType,
};
}
// Decrypt Vertex service account key if present
const v = combinedSettings.providerSettings[
provider
] as VertexProviderSetting;
if (provider === "vertex" && v?.serviceAccountKey) {
const encryptionType = v.serviceAccountKey.encryptionType;
v.serviceAccountKey = {
value: decrypt(v.serviceAccountKey),
encryptionType,
};
}
}
// Validate and merge with defaults
......@@ -171,6 +187,11 @@ export function writeSettings(settings: Partial<UserSettings>): void {
newSettings.providerSettings[provider].apiKey.value,
);
}
// Encrypt Vertex service account key if present
const v = newSettings.providerSettings[provider] as VertexProviderSetting;
if (provider === "vertex" && v?.serviceAccountKey) {
v.serviceAccountKey = encrypt(v.serviceAccountKey.value);
}
}
const validatedSettings = UserSettingsSchema.parse(newSettings);
fs.writeFileSync(filePath, JSON.stringify(validatedSettings, null, 2));
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论