Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
815 changes: 815 additions & 0 deletions apps/server/src/providers/gemini-provider.ts

Large diffs are not rendered by default.

22 changes: 19 additions & 3 deletions apps/server/src/providers/provider-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@

import { BaseProvider } from './base-provider.js';
import type { InstallationStatus, ModelDefinition } from './types.js';
import { isCursorModel, isCodexModel, isOpencodeModel, type ModelProvider } from '@automaker/types';
import {
isCursorModel,
isCodexModel,
isOpencodeModel,
isGeminiModel,
type ModelProvider,
} from '@automaker/types';
import * as fs from 'fs';
import * as path from 'path';

Expand All @@ -16,6 +22,7 @@ const DISCONNECTED_MARKERS: Record<string, string> = {
codex: '.codex-disconnected',
cursor: '.cursor-disconnected',
opencode: '.opencode-disconnected',
gemini: '.gemini-disconnected',
};

/**
Expand Down Expand Up @@ -239,8 +246,8 @@ export class ProviderFactory {
model.modelString === modelId ||
model.id.endsWith(`-${modelId}`) ||
model.modelString.endsWith(`-${modelId}`) ||
model.modelString === modelId.replace(/^(claude|cursor|codex)-/, '') ||
model.modelString === modelId.replace(/-(claude|cursor|codex)$/, '')
model.modelString === modelId.replace(/^(claude|cursor|codex|gemini)-/, '') ||
model.modelString === modelId.replace(/-(claude|cursor|codex|gemini)$/, '')
) {
return model.supportsVision ?? true;
}
Expand All @@ -267,6 +274,7 @@ import { ClaudeProvider } from './claude-provider.js';
import { CursorProvider } from './cursor-provider.js';
import { CodexProvider } from './codex-provider.js';
import { OpencodeProvider } from './opencode-provider.js';
import { GeminiProvider } from './gemini-provider.js';

// Register Claude provider
registerProvider('claude', {
Expand Down Expand Up @@ -301,3 +309,11 @@ registerProvider('opencode', {
canHandleModel: (model: string) => isOpencodeModel(model),
priority: 3, // Between codex (5) and claude (0)
});

// Register Gemini provider
registerProvider('gemini', {
factory: () => new GeminiProvider(),
aliases: ['google'],
canHandleModel: (model: string) => isGeminiModel(model),
priority: 4, // Between opencode (3) and codex (5)
});
Comment on lines +313 to +319
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Update provider-count tests for the new Gemini registration.
CI reports failures because provider counts now return 5 instead of 4. Please update the test expectations for getAllProviders() and checkAllProviders() to account for Gemini.

🤖 Prompt for AI Agents
In `@apps/server/src/providers/provider-factory.ts` around lines 307 - 313, Tests
expecting 4 providers must be updated to account for the new Gemini
registration: find assertions and helpers that check provider counts (e.g.,
calls to getAllProviders() and checkAllProviders(), any EXPECTED_PROVIDER_COUNT
or similar constants) and change expected values from 4 to 5 (or update
snapshots that assert provider list length), and add/adjust any assertions that
validate provider identities to include 'gemini' (or its alias 'google') so the
tests reflect the new provider registration in
factory.registerProvider('gemini', ...).

Comment on lines +313 to +319
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== Checking provider-factory.ts for Gemini and other provider priorities ==="
rg -n "priority:" --type ts apps/server/src/providers/provider-factory.ts

echo ""
echo "=== Looking for test file with gemini-3-pro routing test ==="
fd -e test.ts -e spec.ts | xargs grep -l "gemini-3-pro" 2>/dev/null | head -5

Repository: AutoMaker-Org/automaker

Length of output: 559


🏁 Script executed:

#!/bin/bash
echo "=== Checking isGeminiModel and isCursorModel implementation ==="
rg -n "isGeminiModel|isCursorModel" --type ts -A 10 libs/types/src/provider-utils.ts

echo ""
echo "=== Looking for Cursor model map definitions ==="
rg -n "CursorModel|cursorModels" --type ts libs/ -A 5 | head -50

Repository: AutoMaker-Org/automaker

Length of output: 4737


🏁 Script executed:

#!/bin/bash
echo "=== Checking if Cursor provider includes gemini models ==="
rg -n "gemini" --type ts libs/ apps/ | grep -i "cursor\|model" | head -20

Repository: AutoMaker-Org/automaker

Length of output: 1697


🏁 Script executed:

#!/bin/bash
echo "=== Checking test file lines 156-159 ==="
sed -n '150,165p' apps/server/tests/unit/providers/provider-factory.test.ts

echo ""
echo "=== Checking if gemini-3-pro is in GEMINI_MODEL_MAP ==="
rg -n "gemini-3-pro|gemini-3-flash" --type ts libs/types/src/gemini-models.ts -B 2 -A 2

echo ""
echo "=== Checking LEGACY_CURSOR_MODEL_MAP for gemini models ==="
rg -n "LEGACY_CURSOR_MODEL_MAP|gemini-3" --type ts libs/types/src/cursor-models.ts | head -20

Repository: AutoMaker-Org/automaker

Length of output: 1758


🏁 Script executed:

#!/bin/bash
echo "=== Check full GEMINI_MODEL_MAP structure ==="
cat libs/types/src/gemini-models.ts | head -50

Repository: AutoMaker-Org/automaker

Length of output: 1483


🏁 Script executed:

#!/bin/bash
echo "=== Verify the actual check order and model routing logic ==="
sed -n '160,195p' libs/types/src/provider-utils.ts

Repository: AutoMaker-Org/automaker

Length of output: 1163


🏁 Script executed:

#!/bin/bash
echo "=== Check ProviderFactory.getProviderForModel implementation ==="
rg -n "getProviderForModel" --type ts apps/server/src/providers/provider-factory.ts -A 20

Repository: AutoMaker-Org/automaker

Length of output: 2817


🏁 Script executed:

#!/bin/bash
echo "=== Check if there's special handling that overrides getModelProvider ==="
rg -n "gemini-3-pro|LEGACY_CURSOR_MODEL_MAP" --type ts apps/server/src/providers/ -B 3 -A 3

Repository: AutoMaker-Org/automaker

Length of output: 140


🏁 Script executed:

#!/bin/bash
echo "=== Check Cursor provider's canHandleModel implementation ==="
rg -n "canHandleModel" --type ts apps/server/src/providers/provider-factory.ts -B 5 -A 5 | grep -A 5 -B 5 "cursor"

Repository: AutoMaker-Org/automaker

Length of output: 440


🏁 Script executed:

#!/bin/bash
echo "=== Get full provider registrations with canHandleModel details ==="
sed -n '280,330p' apps/server/src/providers/provider-factory.ts

Repository: AutoMaker-Org/automaker

Length of output: 1357


Correct the line number reference for the test assertion.

The test at lines 160-163 (not 156-159) confirms that gemini-3-pro routes to CursorProvider. This is the correct behavior given the provider registration logic: getProviderForModelName() iterates through providers sorted by priority (descending), checking each canHandleModel function. Since Cursor has priority 10 and Gemini has priority 4, Cursor's canHandleModel is evaluated first. Both return true for gemini-3-pro (it's a legacy Cursor model in LEGACY_CURSOR_MODEL_MAP and starts with the gemini- prefix), but Cursor wins due to its higher priority.

The architectural concern remains valid: gemini-3-pro and gemini-3-flash are legacy Cursor model aliases, so they intentionally route to Cursor. If the Gemini CLI should handle its native models preferentially in the future, consider removing these models from LEGACY_CURSOR_MODEL_MAP or adjusting the logic in isCursorModel.

🤖 Prompt for AI Agents
In `@apps/server/src/providers/provider-factory.ts` around lines 313 - 319, The
test comment references the wrong line numbers for the assertion about
gemini-3-pro routing; update the test's assertion comment to point to lines
160-163 (not 156-159) and/or adjust any inline note near the assertion that
mentions line numbers so it correctly documents that getProviderForModelName()
chooses Cursor over Gemini due to Cursor's higher priority; check related
symbols LEGACY_CURSOR_MODEL_MAP, isCursorModel, getProviderForModelName, and the
provider registration for 'gemini' (registerProvider('gemini', ... priority: 4))
to ensure the comment accurately reflects the current provider priority and
legacy model mapping behavior.

8 changes: 8 additions & 0 deletions apps/server/src/routes/setup/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ import { createDeauthCursorHandler } from './routes/deauth-cursor.js';
import { createAuthOpencodeHandler } from './routes/auth-opencode.js';
import { createDeauthOpencodeHandler } from './routes/deauth-opencode.js';
import { createOpencodeStatusHandler } from './routes/opencode-status.js';
import { createGeminiStatusHandler } from './routes/gemini-status.js';
import { createAuthGeminiHandler } from './routes/auth-gemini.js';
import { createDeauthGeminiHandler } from './routes/deauth-gemini.js';
import {
createGetOpencodeModelsHandler,
createRefreshOpencodeModelsHandler,
Expand Down Expand Up @@ -72,6 +75,11 @@ export function createSetupRoutes(): Router {
router.post('/auth-opencode', createAuthOpencodeHandler());
router.post('/deauth-opencode', createDeauthOpencodeHandler());

// Gemini CLI routes
router.get('/gemini-status', createGeminiStatusHandler());
router.post('/auth-gemini', createAuthGeminiHandler());
router.post('/deauth-gemini', createDeauthGeminiHandler());

// OpenCode Dynamic Model Discovery routes
router.get('/opencode/models', createGetOpencodeModelsHandler());
router.post('/opencode/models/refresh', createRefreshOpencodeModelsHandler());
Expand Down
42 changes: 42 additions & 0 deletions apps/server/src/routes/setup/routes/auth-gemini.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* POST /auth-gemini endpoint - Connect Gemini CLI to the app
*/

import type { Request, Response } from 'express';
import { getErrorMessage, logError } from '../common.js';
import * as fs from 'fs/promises';
import * as path from 'path';

const DISCONNECTED_MARKER_FILE = '.gemini-disconnected';

/**
* Creates handler for POST /api/setup/auth-gemini
* Removes the disconnection marker to allow Gemini CLI to be used
*/
export function createAuthGeminiHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const projectRoot = process.cwd();
const automakerDir = path.join(projectRoot, '.automaker');
const markerPath = path.join(automakerDir, DISCONNECTED_MARKER_FILE);

// Remove the disconnection marker if it exists
try {
await fs.unlink(markerPath);
} catch {
// File doesn't exist, nothing to remove
}

res.json({
success: true,
message: 'Gemini CLI connected to app',
});
} catch (error) {
logError(error, 'Auth Gemini failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}
42 changes: 42 additions & 0 deletions apps/server/src/routes/setup/routes/deauth-gemini.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* POST /deauth-gemini endpoint - Disconnect Gemini CLI from the app
*/

import type { Request, Response } from 'express';
import { getErrorMessage, logError } from '../common.js';
import * as fs from 'fs/promises';
import * as path from 'path';

const DISCONNECTED_MARKER_FILE = '.gemini-disconnected';

/**
* Creates handler for POST /api/setup/deauth-gemini
* Creates a marker file to disconnect Gemini CLI from the app
*/
export function createDeauthGeminiHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {
const projectRoot = process.cwd();
const automakerDir = path.join(projectRoot, '.automaker');

// Ensure .automaker directory exists
await fs.mkdir(automakerDir, { recursive: true });

const markerPath = path.join(automakerDir, DISCONNECTED_MARKER_FILE);

// Create the disconnection marker
await fs.writeFile(markerPath, 'Gemini CLI disconnected from app');

res.json({
success: true,
message: 'Gemini CLI disconnected from app',
});
} catch (error) {
logError(error, 'Deauth Gemini failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}
79 changes: 79 additions & 0 deletions apps/server/src/routes/setup/routes/gemini-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* GET /gemini-status endpoint - Get Gemini CLI installation and auth status
*/

import type { Request, Response } from 'express';
import { GeminiProvider } from '../../../providers/gemini-provider.js';
import { getErrorMessage, logError } from '../common.js';
import * as fs from 'fs/promises';
import * as path from 'path';

const DISCONNECTED_MARKER_FILE = '.gemini-disconnected';

async function isGeminiDisconnectedFromApp(): Promise<boolean> {
try {
const projectRoot = process.cwd();
const markerPath = path.join(projectRoot, '.automaker', DISCONNECTED_MARKER_FILE);
await fs.access(markerPath);
return true;
} catch {
return false;
}
}

/**
* Creates handler for GET /api/setup/gemini-status
* Returns Gemini CLI installation and authentication status
*/
export function createGeminiStatusHandler() {
const installCommand = 'npm install -g @google/gemini-cli';
const loginCommand = 'gemini';

return async (_req: Request, res: Response): Promise<void> => {
try {
// Check if user has manually disconnected from the app
if (await isGeminiDisconnectedFromApp()) {
res.json({
success: true,
installed: true,
version: null,
path: null,
auth: {
authenticated: false,
method: 'none',
hasApiKey: false,
},
installCommand,
loginCommand,
});
return;
}

const provider = new GeminiProvider();
const status = await provider.detectInstallation();
const auth = await provider.checkAuth();

res.json({
success: true,
installed: status.installed,
version: status.version || null,
path: status.path || null,
auth: {
authenticated: auth.authenticated,
method: auth.method,
hasApiKey: auth.hasApiKey || false,
hasEnvApiKey: auth.hasEnvApiKey || false,
error: auth.error,
},
installCommand,
loginCommand,
});
} catch (error) {
logError(error, 'Get Gemini status failed');
res.status(500).json({
success: false,
error: getErrorMessage(error),
});
}
};
}
19 changes: 16 additions & 3 deletions apps/server/tests/unit/providers/provider-factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import { ClaudeProvider } from '@/providers/claude-provider.js';
import { CursorProvider } from '@/providers/cursor-provider.js';
import { CodexProvider } from '@/providers/codex-provider.js';
import { OpencodeProvider } from '@/providers/opencode-provider.js';
import { GeminiProvider } from '@/providers/gemini-provider.js';

describe('provider-factory.ts', () => {
let consoleSpy: any;
let detectClaudeSpy: any;
let detectCursorSpy: any;
let detectCodexSpy: any;
let detectOpencodeSpy: any;
let detectGeminiSpy: any;

beforeEach(() => {
consoleSpy = {
Expand All @@ -30,6 +32,9 @@ describe('provider-factory.ts', () => {
detectOpencodeSpy = vi
.spyOn(OpencodeProvider.prototype, 'detectInstallation')
.mockResolvedValue({ installed: true });
detectGeminiSpy = vi
.spyOn(GeminiProvider.prototype, 'detectInstallation')
.mockResolvedValue({ installed: true });
});

afterEach(() => {
Expand All @@ -38,6 +43,7 @@ describe('provider-factory.ts', () => {
detectCursorSpy.mockRestore();
detectCodexSpy.mockRestore();
detectOpencodeSpy.mockRestore();
detectGeminiSpy.mockRestore();
});

describe('getProviderForModel', () => {
Expand Down Expand Up @@ -166,9 +172,15 @@ describe('provider-factory.ts', () => {
expect(hasClaudeProvider).toBe(true);
});

it('should return exactly 4 providers', () => {
it('should return exactly 5 providers', () => {
const providers = ProviderFactory.getAllProviders();
expect(providers).toHaveLength(5);
});

it('should include GeminiProvider', () => {
const providers = ProviderFactory.getAllProviders();
expect(providers).toHaveLength(4);
const hasGeminiProvider = providers.some((p) => p instanceof GeminiProvider);
expect(hasGeminiProvider).toBe(true);
});

it('should include CursorProvider', () => {
Expand Down Expand Up @@ -206,7 +218,8 @@ describe('provider-factory.ts', () => {
expect(keys).toContain('cursor');
expect(keys).toContain('codex');
expect(keys).toContain('opencode');
expect(keys).toHaveLength(4);
expect(keys).toContain('gemini');
expect(keys).toHaveLength(5);
});

it('should include cursor status', async () => {
Expand Down
1 change: 1 addition & 0 deletions apps/ui/src/components/ui/provider-icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,7 @@ export const PROVIDER_ICON_COMPONENTS: Record<
cursor: CursorIcon,
codex: OpenAIIcon,
opencode: OpenCodeIcon,
gemini: GeminiIcon,
};

/**
Expand Down
28 changes: 26 additions & 2 deletions apps/ui/src/components/views/board-view/shared/model-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,16 @@ import {
CURSOR_MODEL_MAP,
CODEX_MODEL_MAP,
OPENCODE_MODELS as OPENCODE_MODEL_CONFIGS,
GEMINI_MODEL_MAP,
} from '@automaker/types';
import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react';
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
import {
AnthropicIcon,
CursorIcon,
OpenAIIcon,
OpenCodeIcon,
GeminiIcon,
} from '@/components/ui/provider-icon';

export type ModelOption = {
id: string; // All model IDs use canonical prefixed format (e.g., "claude-sonnet", "cursor-auto")
Expand Down Expand Up @@ -118,13 +125,29 @@ export const OPENCODE_MODELS: ModelOption[] = OPENCODE_MODEL_CONFIGS.map((config
}));

/**
* All available models (Claude + Cursor + Codex + OpenCode)
* Gemini models derived from GEMINI_MODEL_MAP
* Model IDs already have 'gemini-' prefix (like Cursor models)
*/
export const GEMINI_MODELS: ModelOption[] = Object.entries(GEMINI_MODEL_MAP).map(
([id, config]) => ({
id, // IDs already have gemini- prefix (e.g., 'gemini-2.5-flash')
label: config.label,
description: config.description,
badge: config.supportsThinking ? 'Thinking' : 'Speed',
provider: 'gemini' as ModelProvider,
hasThinking: config.supportsThinking,
})
);

/**
* All available models (Claude + Cursor + Codex + OpenCode + Gemini)
*/
export const ALL_MODELS: ModelOption[] = [
...CLAUDE_MODELS,
...CURSOR_MODELS,
...CODEX_MODELS,
...OPENCODE_MODELS,
...GEMINI_MODELS,
];

export const THINKING_LEVELS: ThinkingLevel[] = ['none', 'low', 'medium', 'high', 'ultrathink'];
Expand Down Expand Up @@ -171,4 +194,5 @@ export const PROFILE_ICONS: Record<string, React.ComponentType<{ className?: str
Cursor: CursorIcon,
Codex: OpenAIIcon,
OpenCode: OpenCodeIcon,
Gemini: GeminiIcon,
};
3 changes: 3 additions & 0 deletions apps/ui/src/components/views/settings-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
CursorSettingsTab,
CodexSettingsTab,
OpencodeSettingsTab,
GeminiSettingsTab,
} from './settings-view/providers';
import { MCPServersSection } from './settings-view/mcp-servers';
import { PromptCustomizationSection } from './settings-view/prompts';
Expand Down Expand Up @@ -123,6 +124,8 @@ export function SettingsView() {
return <CodexSettingsTab />;
case 'opencode-provider':
return <OpencodeSettingsTab />;
case 'gemini-provider':
return <GeminiSettingsTab />;
case 'providers':
case 'claude': // Backwards compatibility - redirect to claude-provider
return <ClaudeSettingsTab />;
Expand Down
Loading