Compare commits

...

3 Commits

Author SHA1 Message Date
Robin Newhouse 903dd0ec1c fix: address Greptile review feedback
- Fix partial vertex flags (provider-only or missing model) now correctly
  enter quick-setup path and error instead of silently falling to
  interactive mode (hangs in CI/CD non-TTY environments)
- Convert handleProviderSelect if/else chain to switch statement
- Replace modelid goBack if/else chain with lookup table
- Update error message to include --modelid in required flags
2026-03-24 16:05:53 -07:00
Robin Newhouse 89287dbcf6 fix: ensure partial vertex flags error instead of falling to interactive mode
When running `cline auth -p vertex -m <model>` without --vertex-project-id
and --vertex-region, the process now correctly enters the quick-setup path
and exits with a clear error message instead of silently launching
interactive mode (which hangs in non-TTY/CI environments).
2026-03-24 14:21:25 -07:00
Robin Newhouse 07a2d1721e feat(cli): add Vertex AI provider configuration support
Add interactive and headless configuration flow for GCP Vertex AI in the CLI.
Vertex requires project ID and region (uses ADC for auth, no API key needed).

- Create VertexSetup component (project ID input + region picker)
- Add applyVertexConfig utility in provider-config.ts
- Wire into SettingsPanelContent and AuthView (interactive flows)
- Add --vertex-project-id and --vertex-region flags to auth command
- Support headless quick setup: cline auth -p vertex -m <model> --vertex-project-id <id> --vertex-region <region>
- Add unit tests for new CLI flags
2026-03-24 14:12:41 -07:00
6 changed files with 415 additions and 27 deletions
+58 -21
View File
@@ -20,12 +20,13 @@ import { useOcaAuth } from "../hooks/useOcaAuth"
import { useScrollableList } from "../hooks/useScrollableList"
import { type DetectedSources, detectImportSources, type ImportSource } from "../utils/import-configs"
import { isEnterKey, isMouseEscapeSequence } from "../utils/input"
import { applyBedrockConfig, applyProviderConfig } from "../utils/provider-config"
import { applyBedrockConfig, applyProviderConfig, applyVertexConfig } from "../utils/provider-config"
import { useValidProviders } from "../utils/providers"
import { ApiKeyInput } from "./ApiKeyInput"
import { StaticRobotFrame } from "./AsciiMotionCli"
import { BedrockCustomModelFlow } from "./BedrockCustomModelFlow"
import { type BedrockConfig, BedrockSetup } from "./BedrockSetup"
import { type VertexConfig, VertexSetup } from "./VertexSetup"
import {
FeaturedModelPicker,
getFeaturedModelAtIndex,
@@ -52,6 +53,7 @@ type AuthStep =
| "cline_model"
| "openai_codex_auth"
| "bedrock"
| "vertex"
| "import"
| "bedrock_custom"
@@ -177,6 +179,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
const [importSources, setImportSources] = useState<DetectedSources>({ codex: false, opencode: false })
const [importSource, setImportSource] = useState<ImportSource | null>(null)
const [bedrockConfig, setBedrockConfig] = useState<BedrockConfig | null>(null)
const [vertexConfig, setVertexConfig] = useState<VertexConfig | null>(null)
// OCA auth hook - enabled when step is oca_auth
const handleOcaAuthSuccess = useCallback(async () => {
@@ -369,16 +372,23 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
const handleProviderSelect = useCallback(
(value: string) => {
setSelectedProvider(value)
if (value === "oca") {
// Show employee check screen before starting auth
setStep("oca_employee_check")
} else if (value === "openai-codex") {
setStep("openai_codex_auth")
startOpenAiCodexAuth()
} else if (value === "bedrock") {
setStep("bedrock")
} else {
setStep("apikey")
switch (value) {
case "oca":
setStep("oca_employee_check")
break
case "openai-codex":
setStep("openai_codex_auth")
startOpenAiCodexAuth()
break
case "bedrock":
setStep("bedrock")
break
case "vertex":
setStep("vertex")
break
default:
setStep("apikey")
break
}
},
[startOcaAuth, startOpenAiCodexAuth],
@@ -434,6 +444,12 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
modelId: model,
controller,
})
} else if (selectedProvider === "vertex" && vertexConfig) {
await applyVertexConfig({
vertexConfig,
modelId: model,
controller,
})
} else {
await applyProviderConfig({
providerId: selectedProvider,
@@ -454,7 +470,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
setStep("error")
}
},
[selectedProvider, apiKey, bedrockConfig, controller],
[selectedProvider, apiKey, bedrockConfig, vertexConfig, controller],
)
const handleModelIdSubmit = useCallback(
@@ -502,6 +518,11 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
setStep("modelid")
}, [])
const handleVertexComplete = useCallback((config: VertexConfig) => {
setVertexConfig(config)
setStep("modelid")
}, [])
const handleImportComplete = useCallback(() => {
setStep("success")
}, [])
@@ -567,18 +588,17 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
setApiKey("")
setStep("provider")
break
case "modelid":
case "modelid": {
setModelId("")
// Go back to cline_model if we came from there (Cline provider)
if (selectedProvider === "cline") {
setStep("cline_model")
} else if (selectedProvider === "bedrock") {
// Bedrock skips the API key step — go back to Bedrock setup
setStep("bedrock")
} else {
setStep("apikey")
// Each provider has a different step before model selection
const prevStep: Record<string, AuthStep> = {
cline: "cline_model",
bedrock: "bedrock",
vertex: "vertex",
}
setStep(prevStep[selectedProvider] ?? "apikey")
break
}
case "baseurl":
setBaseUrl("")
setStep("modelid")
@@ -604,6 +624,10 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
setBedrockConfig(null)
setStep("provider")
break
case "vertex":
setVertexConfig(null)
setStep("provider")
break
case "import":
setImportSource(null)
setStep("menu")
@@ -786,6 +810,18 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
/>
)
case "vertex":
return (
<VertexSetup
isActive={step === "vertex"}
onCancel={() => {
setVertexConfig(null)
setStep("provider")
}}
onComplete={handleVertexComplete}
/>
)
case "bedrock_custom":
return (
<BedrockCustomModelFlow
@@ -837,6 +873,7 @@ export const AuthView: React.FC<AuthViewProps> = ({ controller, onComplete, onEr
"cline_model",
"openai_codex_auth",
"bedrock",
"vertex",
"error",
].includes(step)
+41 -2
View File
@@ -28,10 +28,11 @@ import { useStdinContext } from "../context/StdinContext"
import { useClineFeaturedModels } from "../hooks/useClineFeaturedModels"
import { useOcaAuth } from "../hooks/useOcaAuth"
import { isMouseEscapeSequence } from "../utils/input"
import { applyBedrockConfig, applyProviderConfig } from "../utils/provider-config"
import { applyBedrockConfig, applyProviderConfig, applyVertexConfig } from "../utils/provider-config"
import { ApiKeyInput } from "./ApiKeyInput"
import { BedrockCustomModelFlow } from "./BedrockCustomModelFlow"
import { type BedrockConfig, BedrockSetup } from "./BedrockSetup"
import { type VertexConfig, VertexSetup } from "./VertexSetup"
import { Checkbox } from "./Checkbox"
import {
FeaturedModelPicker,
@@ -167,6 +168,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
const [isPickingLanguage, setIsPickingLanguage] = useState(false)
const [isEnteringApiKey, setIsEnteringApiKey] = useState(false)
const [isConfiguringBedrock, setIsConfiguringBedrock] = useState(false)
const [isConfiguringVertex, setIsConfiguringVertex] = useState(false)
const [isWaitingForCodexAuth, setIsWaitingForCodexAuth] = useState(false)
const [isShowingOcaEmployeeCheck, setIsShowingOcaEmployeeCheck] = useState(false)
const [codexAuthError, setCodexAuthError] = useState<string | null>(null)
@@ -1150,6 +1152,14 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
return
}
// Special handling for Vertex - needs project ID and region
if (providerId === "vertex") {
setPendingProvider(providerId)
setIsPickingProvider(false)
setIsConfiguringVertex(true)
return
}
// Check if this provider needs an API key
const keyField = ProviderToApiKeyMap[providerId as ApiProvider]
if (keyField) {
@@ -1205,6 +1215,21 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
[controller, refreshModelIds],
)
// Handle Vertex configuration complete
const handleVertexComplete = useCallback(
(vertexConfig: VertexConfig) => {
// Update UI state first for responsiveness
setProvider("vertex")
refreshModelIds()
setIsConfiguringVertex(false)
setPendingProvider(null)
// Apply config and rebuild API handler in background
applyVertexConfig({ vertexConfig, controller })
},
[controller, refreshModelIds],
)
// Handle saving edited value
const handleSave = useCallback(() => {
const item = items[selectedIndex]
@@ -1433,7 +1458,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
return
}
},
{ isActive: isRawModeSupported && !isEnteringApiKey && !isConfiguringBedrock && !isShowingOcaEmployeeCheck },
{ isActive: isRawModeSupported && !isEnteringApiKey && !isConfiguringBedrock && !isConfiguringVertex && !isShowingOcaEmployeeCheck },
)
// Render content
@@ -1484,6 +1509,19 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
)
}
if (isConfiguringVertex) {
return (
<VertexSetup
isActive={isConfiguringVertex}
onCancel={() => {
setIsConfiguringVertex(false)
setPendingProvider(null)
}}
onComplete={handleVertexComplete}
/>
)
}
if (isWaitingForCodexAuth) {
return (
<Box flexDirection="column">
@@ -1814,6 +1852,7 @@ export const SettingsPanelContent: React.FC<SettingsPanelContentProps> = ({
isPickingLanguage ||
isEnteringApiKey ||
isConfiguringBedrock ||
isConfiguringVertex ||
isWaitingForCodexAuth ||
!!codexAuthError ||
isPickingOrganization ||
+207
View File
@@ -0,0 +1,207 @@
import VertexData from "@shared/providers/vertex.json"
import { Box, Text, useInput } from "ink"
import React, { useCallback, useMemo, useState } from "react"
import { COLORS } from "../constants/colors"
import { useStdinContext } from "../context/StdinContext"
import { useScrollableList } from "../hooks/useScrollableList"
import { isMouseEscapeSequence } from "../utils/input"
type VertexStep = "project_id" | "region"
export interface VertexConfig {
vertexProjectId: string
vertexRegion: string
}
interface VertexSetupProps {
isActive: boolean
onComplete: (config: VertexConfig) => void
onCancel: () => void
}
const VERTEX_REGIONS = VertexData.regions
const REGION_ROWS = 8
/**
* Inline text input for the project ID field
*/
const ProjectIdInput: React.FC<{
label: string
value: string
onChange: (value: string) => void
onSubmit: () => void
onCancel: () => void
isActive: boolean
placeholder?: string
hint?: string
}> = ({ label, value, onChange, onSubmit, onCancel, isActive, placeholder, hint }) => {
const { isRawModeSupported } = useStdinContext()
useInput(
(input, key) => {
if (isMouseEscapeSequence(input)) return
if (key.escape) {
onCancel()
} else if (key.return) {
onSubmit()
} else if (key.backspace || key.delete) {
onChange(value.slice(0, -1))
} else if (input && !key.ctrl && !key.meta) {
onChange(value + input)
}
},
{ isActive: isActive && isRawModeSupported },
)
const description = hint || (placeholder ? `e.g. ${placeholder}` : undefined)
return (
<Box flexDirection="column">
<Text color="white">{label}</Text>
{description && <Text color="gray">{description}</Text>}
<Text> </Text>
<Box>
<Text color="white">{value}</Text>
<Text inverse> </Text>
</Box>
<Text> </Text>
<Text color="gray">Enter to continue, Esc to go back</Text>
</Box>
)
}
export const VertexSetup: React.FC<VertexSetupProps> = ({ isActive, onComplete, onCancel }) => {
const { isRawModeSupported } = useStdinContext()
const [step, setStep] = useState<VertexStep>("project_id")
const [projectId, setProjectId] = useState("")
const [regionSearch, setRegionSearch] = useState("")
const [regionIndex, setRegionIndex] = useState(0)
const filteredRegions = useMemo(() => {
const search = regionSearch.toLowerCase().trim()
if (!search) {
return VERTEX_REGIONS
}
return VERTEX_REGIONS.filter((r) => r.toLowerCase().includes(search))
}, [regionSearch])
const {
visibleStart: regionVisibleStart,
visibleCount: regionVisibleCount,
showTopIndicator: showRegionTop,
showBottomIndicator: showRegionBottom,
} = useScrollableList(filteredRegions.length, regionIndex, REGION_ROWS)
const visibleRegions = useMemo(
() => filteredRegions.slice(regionVisibleStart, regionVisibleStart + regionVisibleCount),
[filteredRegions, regionVisibleStart, regionVisibleCount],
)
const goBack = useCallback(() => {
switch (step) {
case "project_id":
onCancel()
break
case "region":
setStep("project_id")
break
}
}, [step, onCancel])
const getSelectedRegion = useCallback(() => {
if (filteredRegions.length > 0 && regionIndex >= 0 && regionIndex < filteredRegions.length) {
return filteredRegions[regionIndex]
}
return regionSearch.trim() || "us-east5"
}, [filteredRegions, regionIndex, regionSearch])
const finish = useCallback(() => {
const config: VertexConfig = {
vertexProjectId: projectId.trim(),
vertexRegion: getSelectedRegion(),
}
onComplete(config)
}, [projectId, getSelectedRegion, onComplete])
useInput(
(input, key) => {
if (isMouseEscapeSequence(input)) return
if (step === "region") {
if (key.escape) {
goBack()
} else if (key.upArrow && filteredRegions.length > 0) {
setRegionIndex((prev) => (prev > 0 ? prev - 1 : filteredRegions.length - 1))
} else if (key.downArrow && filteredRegions.length > 0) {
setRegionIndex((prev) => (prev < filteredRegions.length - 1 ? prev + 1 : 0))
} else if (key.return && (filteredRegions.length > 0 || regionSearch.trim())) {
finish()
} else if (key.backspace || key.delete) {
setRegionSearch((prev) => prev.slice(0, -1))
setRegionIndex(0)
} else if (input && !key.ctrl && !key.meta) {
setRegionSearch((prev) => prev + input)
setRegionIndex(0)
}
}
},
{ isActive: isActive && isRawModeSupported && step === "region" },
)
if (step === "project_id") {
return (
<ProjectIdInput
hint="Your Google Cloud project ID (e.g. my-gcp-project)"
isActive={isActive}
label="Google Cloud Project ID"
onCancel={goBack}
onChange={setProjectId}
onSubmit={() => {
if (projectId.trim()) setStep("region")
}}
placeholder="my-gcp-project"
value={projectId}
/>
)
}
if (step === "region") {
return (
<Box flexDirection="column">
<Text color="white">Google Cloud Region</Text>
<Text> </Text>
<Box>
<Text color="gray">Search or enter custom region: </Text>
<Text color="white">{regionSearch}</Text>
<Text inverse> </Text>
</Box>
<Text> </Text>
{showRegionTop && <Text color="gray">... {regionVisibleStart} more above</Text>}
{visibleRegions.map((region, i) => {
const actualIndex = regionVisibleStart + i
return (
<Box key={region}>
<Text color={actualIndex === regionIndex ? COLORS.primaryBlue : undefined}>
{actualIndex === regionIndex ? " " : " "}
{region}
</Text>
</Box>
)
})}
{showRegionBottom && (
<Text color="gray">
... {filteredRegions.length - regionVisibleStart - regionVisibleCount} more below
</Text>
)}
<Text> </Text>
<Text color="gray">Type to search, arrows to navigate, Enter to select, Esc to go back</Text>
</Box>
)
}
return null
}
+26
View File
@@ -62,6 +62,8 @@ describe("CLI Commands", () => {
.option("-k, --apikey <key>", "API key")
.option("-m, --modelid <id>", "Model ID")
.option("-b, --baseurl <url>", "Base URL")
.option("--vertex-project-id <id>", "Google Cloud Project ID")
.option("--vertex-region <region>", "Google Cloud Region")
.option("-v, --verbose", "Verbose output")
.option("-c, --cwd <path>", "Working directory")
.option("--config <path>", "Configuration directory")
@@ -340,6 +342,30 @@ describe("CLI Commands", () => {
expect(authCmd.opts().apikey).toBe("key123")
expect(authCmd.opts().modelid).toBe("claude-sonnet-4-20250514")
})
it("should parse --vertex-project-id option", () => {
const authCmd = program.commands.find((c) => c.name() === "auth")!
const args = ["--vertex-project-id", "my-gcp-project"]
authCmd.parse(args, { from: "user" })
expect(authCmd.opts().vertexProjectId).toBe("my-gcp-project")
})
it("should parse --vertex-region option", () => {
const authCmd = program.commands.find((c) => c.name() === "auth")!
const args = ["--vertex-region", "us-east5"]
authCmd.parse(args, { from: "user" })
expect(authCmd.opts().vertexRegion).toBe("us-east5")
})
it("should parse vertex quick setup flags together", () => {
const authCmd = program.commands.find((c) => c.name() === "auth")!
const args = ["-p", "vertex", "-m", "gemini-3-flash-preview", "--vertex-project-id", "my-project", "--vertex-region", "us-east5"]
authCmd.parse(args, { from: "user" })
expect(authCmd.opts().provider).toBe("vertex")
expect(authCmd.opts().modelid).toBe("gemini-3-flash-preview")
expect(authCmd.opts().vertexProjectId).toBe("my-project")
expect(authCmd.opts().vertexRegion).toBe("us-east5")
})
})
describe("mcp command", () => {
+41 -4
View File
@@ -715,9 +715,16 @@ async function showConfig(options: { config?: string }) {
*/
async function performQuickAuthSetup(
ctx: CliContext,
options: { provider: string; apikey: string; modelid: string; baseurl?: string },
options: {
provider: string
apikey: string
modelid: string
baseurl?: string
vertexProjectId?: string
vertexRegion?: string
},
): Promise<{ success: boolean; error?: string }> {
const { provider, apikey, modelid, baseurl } = options
const { provider, apikey, modelid, baseurl, vertexProjectId, vertexRegion } = options
const normalizedProvider = provider.toLowerCase().trim()
@@ -733,6 +740,24 @@ async function performQuickAuthSetup(
}
}
if (normalizedProvider === "vertex") {
if (!modelid || !vertexProjectId || !vertexRegion) {
return {
success: false,
error: "Vertex provider requires --modelid, --vertex-project-id, and --vertex-region flags.",
}
}
const { applyVertexConfig } = await import("./utils/provider-config")
await applyVertexConfig({
vertexConfig: { vertexProjectId, vertexRegion },
modelId: modelid,
controller: ctx.controller,
})
StateManager.get().setGlobalState("welcomeViewCompleted", true)
await StateManager.get().flushPendingState()
return { success: true }
}
if (baseurl && !["openai", "openai-native"].includes(normalizedProvider)) {
return { success: false, error: "Base URL is only supported for OpenAI and OpenAI-compatible providers" }
}
@@ -758,13 +783,21 @@ async function runAuth(options: {
apikey?: string
modelid?: string
baseurl?: string
vertexProjectId?: string
vertexRegion?: string
verbose?: boolean
cwd?: string
config?: string
}) {
const ctx = await initializeCli({ ...options, enableAuth: true })
const hasQuickSetupFlags = options.provider && options.apikey && options.modelid
// Vertex uses project-id + region instead of API key (no apikey required).
// Treat any vertex provider invocation with partial flags as a quick-setup attempt
// so the error path inside performQuickAuthSetup is reached instead of silently
// falling to interactive mode (which hangs in non-TTY/CI environments).
const isVertexProvider = options.provider?.toLowerCase() === "vertex"
const isVertexAttempt = isVertexProvider && (!!options.modelid || !!options.vertexProjectId || !!options.vertexRegion)
const hasQuickSetupFlags = isVertexAttempt || (options.provider && options.apikey && options.modelid)
telemetryService.captureHostEvent("auth_command", hasQuickSetupFlags ? "quick_setup" : "interactive")
@@ -772,9 +805,11 @@ async function runAuth(options: {
if (hasQuickSetupFlags) {
const result = await performQuickAuthSetup(ctx, {
provider: options.provider!,
apikey: options.apikey!,
apikey: options.apikey || "",
modelid: options.modelid!,
baseurl: options.baseurl,
vertexProjectId: options.vertexProjectId,
vertexRegion: options.vertexRegion,
})
if (!result.success) {
@@ -871,6 +906,8 @@ program
.option("-k, --apikey <key>", "API key for the provider")
.option("-m, --modelid <id>", "Model ID to configure (e.g., gpt-4o, claude-sonnet-4-6, kimi-k2.5)")
.option("-b, --baseurl <url>", "Base URL (optional, only for openai provider)")
.option("--vertex-project-id <id>", "Google Cloud Project ID (for vertex provider)")
.option("--vertex-region <region>", "Google Cloud Region (for vertex provider)")
.option("-v, --verbose", "Show verbose output")
.option("-c, --cwd <path>", "Working directory for the task")
.option("--config <path>", "Path to Cline configuration directory")
+42
View File
@@ -12,6 +12,7 @@ import { refreshVercelAiGatewayModels } from "@/core/controller/models/refreshVe
import { StateManager } from "@/core/storage/StateManager"
import type { BedrockConfig } from "../components/BedrockSetup"
import { getDefaultModelId } from "../components/ModelPicker"
import type { VertexConfig } from "../components/VertexSetup"
export interface ApplyProviderConfigOptions {
providerId: string
@@ -150,3 +151,44 @@ export async function applyBedrockConfig(options: ApplyBedrockConfigOptions): Pr
controller.task.api = buildApiHandler({ ...apiConfig, ulid: controller.task.ulid }, currentMode)
}
}
export interface ApplyVertexConfigOptions {
vertexConfig: VertexConfig
modelId?: string
controller?: Controller
}
/**
* Apply Vertex AI provider configuration to state.
* Handles GCP-specific fields (project ID, region).
* Authentication uses Google Application Default Credentials (ADC).
*/
export async function applyVertexConfig(options: ApplyVertexConfigOptions): Promise<void> {
const { vertexConfig, modelId, controller } = options
const stateManager = StateManager.get()
const config: Record<string, string> = {
actModeApiProvider: "vertex",
planModeApiProvider: "vertex",
vertexProjectId: vertexConfig.vertexProjectId,
vertexRegion: vertexConfig.vertexRegion,
}
const finalModelId = modelId || getDefaultModelId("vertex")
if (finalModelId) {
const actModelKey = getProviderModelIdKey("vertex" as ApiProvider, "act")
const planModelKey = getProviderModelIdKey("vertex" as ApiProvider, "plan")
if (actModelKey) config[actModelKey] = finalModelId
if (planModelKey) config[planModelKey] = finalModelId
}
stateManager.setApiConfiguration(config)
await stateManager.flushPendingState()
if (controller?.task) {
const currentMode = stateManager.getGlobalSettingsKey("mode")
const apiConfig = stateManager.getApiConfiguration()
controller.task.api = buildApiHandler({ ...apiConfig, ulid: controller.task.ulid }, currentMode)
}
}