Skip to content

Commit 29737b1

Browse files
committed
Add a Gemini engine that talks to Vertex AI
engine: gemini shells out to the Gemini CLI instead of Codex, so an adopter can leave Azure. The reusable workflow takes optional Vertex project, location, and API key, and only requires Azure for Codex.
1 parent b0f5205 commit 29737b1

9 files changed

Lines changed: 489 additions & 21 deletions

File tree

‎.github/workflows/aixgo.yml‎

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,20 @@ on:
3838
required: true
3939
type: string
4040
azure-openai-endpoint:
41-
description: Azure OpenAI endpoint, for example https://resource.openai.azure.com.
42-
required: true
41+
description: Azure OpenAI endpoint, for example https://resource.openai.azure.com. Required for the default Codex engine; unused when engine is claude or gemini.
42+
required: false
4343
type: string
44+
default: ""
45+
vertex-project:
46+
description: Google Cloud project for Vertex AI. Required when engine is gemini.
47+
required: false
48+
type: string
49+
default: ""
50+
vertex-location:
51+
description: Vertex AI location, for example us-central1.
52+
required: false
53+
type: string
54+
default: us-central1
4455
model:
4556
description: Engine model or deployment name passed to the Aixgo CLI.
4657
default: gpt-5.4
@@ -53,7 +64,9 @@ on:
5364
type: string
5465
secrets:
5566
azure-openai-api-key:
56-
required: true
67+
required: false
68+
vertex-api-key:
69+
required: false
5770
github-app-private-key:
5871
required: true
5972

@@ -104,6 +117,9 @@ jobs:
104117
env:
105118
AIXGO_AZURE_OPENAI_ENDPOINT: ${{ inputs['azure-openai-endpoint'] }}
106119
AIXGO_AZURE_OPENAI_API_KEY: ${{ secrets['azure-openai-api-key'] }}
120+
AIXGO_VERTEX_PROJECT: ${{ inputs['vertex-project'] }}
121+
AIXGO_VERTEX_LOCATION: ${{ inputs['vertex-location'] }}
122+
AIXGO_VERTEX_API_KEY: ${{ secrets['vertex-api-key'] }}
107123
AIXGO_GH_APP_CLIENT_ID: ${{ inputs['github-app-client-id'] }}
108124
AIXGO_GH_APP_PRIVATE_KEY: ${{ secrets['github-app-private-key'] }}
109125
run: aixgo-code preflight --repo-dir . --actions
@@ -128,13 +144,21 @@ jobs:
128144
run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
129145

130146
- name: Install Codex CLI
147+
if: ${{ inputs['azure-openai-endpoint'] != '' }}
131148
run: npm install -g @openai/codex@0.146.0
132149

150+
- name: Install Gemini CLI
151+
if: ${{ inputs['vertex-project'] != '' }}
152+
run: npm install -g @google/gemini-cli@0.60.0
153+
133154
- name: Run Aixgo
134155
env:
135156
GH_TOKEN: ${{ steps.app-token.outputs.token }}
136157
AIXGO_AZURE_OPENAI_ENDPOINT: ${{ inputs['azure-openai-endpoint'] }}
137158
AIXGO_AZURE_OPENAI_API_KEY: ${{ secrets['azure-openai-api-key'] }}
159+
AIXGO_VERTEX_PROJECT: ${{ inputs['vertex-project'] }}
160+
AIXGO_VERTEX_LOCATION: ${{ inputs['vertex-location'] }}
161+
AIXGO_VERTEX_API_KEY: ${{ secrets['vertex-api-key'] }}
138162
COMMENT_BODY: ${{ inputs['comment-body'] }}
139163
# --base is explicit because actions/checkout never creates origin/HEAD,
140164
# which is the CLI's default base. The CLI resolves that case on its own
@@ -198,6 +222,9 @@ jobs:
198222
env:
199223
AIXGO_AZURE_OPENAI_ENDPOINT: ${{ inputs['azure-openai-endpoint'] }}
200224
AIXGO_AZURE_OPENAI_API_KEY: ${{ secrets['azure-openai-api-key'] }}
225+
AIXGO_VERTEX_PROJECT: ${{ inputs['vertex-project'] }}
226+
AIXGO_VERTEX_LOCATION: ${{ inputs['vertex-location'] }}
227+
AIXGO_VERTEX_API_KEY: ${{ secrets['vertex-api-key'] }}
201228
AIXGO_GH_APP_CLIENT_ID: ${{ inputs['github-app-client-id'] }}
202229
AIXGO_GH_APP_PRIVATE_KEY: ${{ secrets['github-app-private-key'] }}
203230
run: aixgo-code preflight --repo-dir . --actions
@@ -222,13 +249,21 @@ jobs:
222249
run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
223250

224251
- name: Install Codex CLI
252+
if: ${{ inputs['azure-openai-endpoint'] != '' }}
225253
run: npm install -g @openai/codex@0.146.0
226254

255+
- name: Install Gemini CLI
256+
if: ${{ inputs['vertex-project'] != '' }}
257+
run: npm install -g @google/gemini-cli@0.60.0
258+
227259
- name: Address review feedback
228260
env:
229261
GH_TOKEN: ${{ steps.app-token.outputs.token }}
230262
AIXGO_AZURE_OPENAI_ENDPOINT: ${{ inputs['azure-openai-endpoint'] }}
231263
AIXGO_AZURE_OPENAI_API_KEY: ${{ secrets['azure-openai-api-key'] }}
264+
AIXGO_VERTEX_PROJECT: ${{ inputs['vertex-project'] }}
265+
AIXGO_VERTEX_LOCATION: ${{ inputs['vertex-location'] }}
266+
AIXGO_VERTEX_API_KEY: ${{ secrets['vertex-api-key'] }}
232267
COMMENT_BODY: ${{ inputs['comment-body'] }}
233268
run: |
234269
if [ -n "${COMMENT_BODY}" ]; then

‎CHANGELOG.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,18 @@
33
All notable changes are recorded here and summarized again in the matching
44
GitHub release notes for each tag.
55

6+
## Unreleased
7+
8+
- **Gemini on Vertex AI** (`engine: gemini`). The loop, roles, and gate are
9+
unchanged. The adapter shells out to the Gemini CLI (`gemini -p`) and reads
10+
`AIXGO_VERTEX_PROJECT`, `AIXGO_VERTEX_LOCATION` (default `us-central1`), and
11+
`AIXGO_VERTEX_API_KEY`. Azure credentials are not required on this path.
12+
- The reusable workflow accepts optional `vertex-project`, `vertex-location`,
13+
and `vertex-api-key`, and the Azure endpoint/key are no longer required
14+
inputs. A Codex adopter who already passes them is unchanged. A Gemini
15+
adopter omits Azure and passes the Vertex values instead. The workflow
16+
installs `@google/gemini-cli@0.60.0` when `vertex-project` is set.
17+
618
## v0.5.0
719

820
**Breaking. The binary is `aixgo-code`, not `aixgo`.** The Aixgo framework's

‎cmd/aixgo-code/main.go‎

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030
"github.com/aixgo-dev/code/internal/engine"
3131
"github.com/aixgo-dev/code/internal/engine/claude"
3232
"github.com/aixgo-dev/code/internal/engine/codex"
33+
"github.com/aixgo-dev/code/internal/engine/gemini"
3334
forge2 "github.com/aixgo-dev/code/internal/forge"
3435
"github.com/aixgo-dev/code/internal/forge/dryrun"
3536
forgegh "github.com/aixgo-dev/code/internal/forge/gh"
@@ -115,8 +116,11 @@ flags (both commands):
115116
--state-dir where worktrees and the generated config live
116117
117118
environment (engine settings; never committed to a repo):
118-
AIXGO_AZURE_OPENAI_ENDPOINT e.g. https://<resource>.openai.azure.com
119+
AIXGO_AZURE_OPENAI_ENDPOINT e.g. https://<resource>.openai.azure.com (codex)
119120
AIXGO_AZURE_OPENAI_API_KEY the key, read by name and never written to disk
121+
AIXGO_VERTEX_PROJECT Google Cloud project (gemini / Vertex)
122+
AIXGO_VERTEX_LOCATION Vertex location, default us-central1
123+
AIXGO_VERTEX_API_KEY Vertex API key, read by name and never written to disk
120124
`, buildinfo.Version)
121125
}
122126

@@ -165,10 +169,30 @@ var selftestWorkflowTemplate string
165169
// Azure endpoint when the engine needs one. It is the single implementation:
166170
// `prepare` calls it before a run, and `preflight` calls it so a workflow can
167171
// fail early without a second copy of these rules written in shell.
172+
func usesAzure(cfg *config.Config) bool {
173+
if cfg == nil {
174+
return true
175+
}
176+
switch cfg.Engine {
177+
case "claude", "gemini":
178+
return false
179+
}
180+
return true
181+
}
182+
168183
func engineEnv(cfg *config.Config) (string, error) {
169184
if cfg != nil && cfg.Engine == "claude" {
170185
return "", nil
171186
}
187+
if cfg != nil && cfg.Engine == "gemini" {
188+
if err := requireSet("AIXGO_VERTEX_PROJECT", sectionVariable); err != nil {
189+
return "", err
190+
}
191+
if err := requireSet("AIXGO_VERTEX_API_KEY", sectionSecret); err != nil {
192+
return "", err
193+
}
194+
return "", nil
195+
}
172196
if err := requireSet("AIXGO_AZURE_OPENAI_ENDPOINT", sectionVariable); err != nil {
173197
return "", err
174198
}
@@ -246,9 +270,15 @@ func newVCS(self string) *vcsgit.Git {
246270
// the sandbox start rather than widening it. A run that still cannot sandbox
247271
// stops and a human finishes the work; it never falls back to running
248272
// unconfined. See docs/faq.md and the self-test.
249-
func newRunner(cfg *config.Config, codexHome string) engine.Runner {
273+
func newRunner(cfg *config.Config, codexHome, model string) engine.Runner {
250274
if cfg.Engine == "claude" {
251-
return claude.New()
275+
return &claude.Runner{Model: model}
276+
}
277+
if cfg.Engine == "gemini" {
278+
if model == "" || model == "gpt-5.4" {
279+
model = "gemini-2.5-pro"
280+
}
281+
return &gemini.Runner{Model: model}
252282
}
253283
r := codex.New(codexHome)
254284
if mode := os.Getenv("AIXGO_SANDBOX"); mode != "" {
@@ -602,7 +632,7 @@ func prepare(name string, argv []string) (*commonFlags, []string, error) {
602632
}
603633

604634
codexHome := filepath.Join(*stateDir, "codex-home")
605-
if cfg.Engine != "claude" {
635+
if usesAzure(cfg) {
606636
if _, err := codex.WriteConfig(codexHome, codex.ProviderConfig{
607637
Model: *model,
608638
BaseURL: endpoint + "/openai/v1",
@@ -638,7 +668,7 @@ func prepare(name string, argv []string) (*commonFlags, []string, error) {
638668
}
639669

640670
deps := app.Deps{
641-
Runner: newRunner(cfg, codexHome),
671+
Runner: newRunner(cfg, codexHome, *model),
642672
// Self, when set, filters the agent's own review feedback out of the fix
643673
// loop. Empty for local runs, where the operator is a human, not the bot.
644674
Forge: forgeForLoop,

‎cmd/aixgo-code/main_test.go‎

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/aixgo-dev/code/internal/domain"
1010
"github.com/aixgo-dev/code/internal/engine/claude"
1111
"github.com/aixgo-dev/code/internal/engine/codex"
12+
"github.com/aixgo-dev/code/internal/engine/gemini"
1213
forge2 "github.com/aixgo-dev/code/internal/forge"
1314
"github.com/aixgo-dev/code/internal/forge/dryrun"
1415
forgefake "github.com/aixgo-dev/code/internal/forge/fake"
@@ -462,6 +463,25 @@ func TestEngineEnvSkipsAzureForClaude(t *testing.T) {
462463
}
463464
}
464465

466+
func TestEngineEnvRequiresVertexForGemini(t *testing.T) {
467+
t.Setenv("AIXGO_AZURE_OPENAI_ENDPOINT", "")
468+
t.Setenv("AIXGO_AZURE_OPENAI_API_KEY", "")
469+
t.Setenv("AIXGO_VERTEX_PROJECT", "")
470+
t.Setenv("AIXGO_VERTEX_API_KEY", "")
471+
if _, err := engineEnv(&config.Config{Engine: "gemini"}); err == nil || !strings.Contains(err.Error(), "AIXGO_VERTEX_PROJECT") {
472+
t.Fatal("gemini without a project must name AIXGO_VERTEX_PROJECT")
473+
}
474+
t.Setenv("AIXGO_VERTEX_PROJECT", "simplycubed-agents")
475+
if _, err := engineEnv(&config.Config{Engine: "gemini"}); err == nil || !strings.Contains(err.Error(), "AIXGO_VERTEX_API_KEY") {
476+
t.Fatal("gemini without a key must name AIXGO_VERTEX_API_KEY")
477+
}
478+
t.Setenv("AIXGO_VERTEX_API_KEY", "k")
479+
got, err := engineEnv(&config.Config{Engine: "gemini"})
480+
if err != nil || got != "" {
481+
t.Fatalf("engineEnv(gemini) = %q, %v", got, err)
482+
}
483+
}
484+
465485
func TestPreflightCmd(t *testing.T) {
466486
t.Run("reports ok when config and engine settings are present", func(t *testing.T) {
467487
t.Setenv("AIXGO_AZURE_OPENAI_ENDPOINT", "https://r.openai.azure.com")
@@ -487,6 +507,20 @@ func TestPreflightCmd(t *testing.T) {
487507
}
488508
})
489509

510+
t.Run("allows gemini with Vertex settings and no Azure", func(t *testing.T) {
511+
t.Setenv("AIXGO_AZURE_OPENAI_ENDPOINT", "")
512+
t.Setenv("AIXGO_AZURE_OPENAI_API_KEY", "")
513+
t.Setenv("AIXGO_VERTEX_PROJECT", "simplycubed-agents")
514+
t.Setenv("AIXGO_VERTEX_API_KEY", "k")
515+
var out bytes.Buffer
516+
if err := preflightCmd([]string{"--repo-dir", repoWithConfigBody(t, "gate: make check\nengine: gemini\nappName: acme-code\n")}, &out); err != nil {
517+
t.Fatalf("unexpected error: %v", err)
518+
}
519+
if !strings.Contains(out.String(), "preflight ok") {
520+
t.Fatalf("output = %q", out.String())
521+
}
522+
})
523+
490524
// The whole point of preflight is naming what is wrong, so each failure
491525
// asserts the message identifies the thing the operator has to fix.
492526
t.Run("names the missing config", func(t *testing.T) {
@@ -673,6 +707,24 @@ func TestPrepare(t *testing.T) {
673707
}
674708
})
675709

710+
t.Run("allows gemini with Vertex settings and skips codex config", func(t *testing.T) {
711+
t.Setenv("AIXGO_AZURE_OPENAI_ENDPOINT", "")
712+
t.Setenv("AIXGO_AZURE_OPENAI_API_KEY", "")
713+
t.Setenv("AIXGO_VERTEX_PROJECT", "simplycubed-agents")
714+
t.Setenv("AIXGO_VERTEX_API_KEY", "k")
715+
stateDir := t.TempDir()
716+
c, _, err := prepare("run", []string{"--repo-dir", repoWithConfigBody(t, "gate: make check\nengine: gemini\nappName: acme-code\n"), "--state-dir", stateDir})
717+
if err != nil {
718+
t.Fatalf("unexpected error: %v", err)
719+
}
720+
if _, ok := c.deps.Runner.(*gemini.Runner); !ok {
721+
t.Fatalf("expected the Gemini runner, got %#v", c.deps.Runner)
722+
}
723+
if _, err := os.Stat(filepath.Join(stateDir, "codex-home", "config.toml")); !errors.Is(err, os.ErrNotExist) {
724+
t.Fatalf("gemini runs must not render a codex config, stat err = %v", err)
725+
}
726+
})
727+
676728
t.Run("rejects an unknown flag", func(t *testing.T) {
677729
if _, _, err := prepare("run", []string{"--nope"}); err == nil {
678730
t.Fatal("expected an error for an unknown flag")
@@ -1017,15 +1069,30 @@ func TestCommandCmdRejectsABodyFlagWithNoValue(t *testing.T) {
10171069
// Engine selection is a documented config key and had no test. The default
10181070
// matters most: an existing repository must keep the engine it already had.
10191071
func TestNewRunnerSelectsTheEngine(t *testing.T) {
1020-
if _, ok := newRunner(&config.Config{}, t.TempDir()).(*codex.Runner); !ok {
1072+
if _, ok := newRunner(&config.Config{}, t.TempDir(), "").(*codex.Runner); !ok {
10211073
t.Fatal("the default engine must be codex")
10221074
}
1023-
if _, ok := newRunner(&config.Config{Engine: "claude"}, t.TempDir()).(*claude.Runner); !ok {
1075+
if _, ok := newRunner(&config.Config{Engine: "claude"}, t.TempDir(), "").(*claude.Runner); !ok {
10241076
t.Fatal("engine: claude must select the Claude adapter")
10251077
}
1078+
g, ok := newRunner(&config.Config{Engine: "gemini"}, t.TempDir(), "").(*gemini.Runner)
1079+
if !ok {
1080+
t.Fatal("engine: gemini must select the Gemini adapter")
1081+
}
1082+
if g.Model != "gemini-2.5-pro" {
1083+
t.Fatalf("gemini default model = %q, want gemini-2.5-pro", g.Model)
1084+
}
1085+
g2, _ := newRunner(&config.Config{Engine: "gemini"}, t.TempDir(), "gpt-5.4").(*gemini.Runner)
1086+
if g2.Model != "gemini-2.5-pro" {
1087+
t.Fatalf("the Codex default must not be forwarded to Gemini: %q", g2.Model)
1088+
}
1089+
g3, _ := newRunner(&config.Config{Engine: "gemini"}, t.TempDir(), "gemini-2.5-flash").(*gemini.Runner)
1090+
if g3.Model != "gemini-2.5-flash" {
1091+
t.Fatalf("an explicit gemini model must be kept: %q", g3.Model)
1092+
}
10261093
// An unrecognised engine falls back rather than failing later with an
10271094
// error that says nothing about the cause.
1028-
if _, ok := newRunner(&config.Config{Engine: "nope"}, t.TempDir()).(*codex.Runner); !ok {
1095+
if _, ok := newRunner(&config.Config{Engine: "nope"}, t.TempDir(), "").(*codex.Runner); !ok {
10291096
t.Fatal("an unknown engine must fall back to the default")
10301097
}
10311098
}
@@ -1034,15 +1101,15 @@ func TestNewRunnerSelectsTheEngine(t *testing.T) {
10341101
// widen it themselves. Nothing in this repository sets it, so the test asserts
10351102
// both that the default is untouched and that an explicit value is honoured.
10361103
func TestNewRunnerHonoursTheSandboxOverride(t *testing.T) {
1037-
r, ok := newRunner(&config.Config{}, t.TempDir()).(*codex.Runner)
1104+
r, ok := newRunner(&config.Config{}, t.TempDir(), "").(*codex.Runner)
10381105
if !ok {
10391106
t.Fatal("expected the codex runner")
10401107
}
10411108
if r.Sandbox != "workspace-write" {
10421109
t.Fatalf("the sandbox must stay on by default, got %q", r.Sandbox)
10431110
}
10441111
t.Setenv("AIXGO_SANDBOX", "read-only")
1045-
r2, _ := newRunner(&config.Config{}, t.TempDir()).(*codex.Runner)
1112+
r2, _ := newRunner(&config.Config{}, t.TempDir(), "").(*codex.Runner)
10461113
if r2.Sandbox != "read-only" {
10471114
t.Fatalf("an explicit sandbox mode must be honoured, got %q", r2.Sandbox)
10481115
}

‎docs/setup.md‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ You need:
1818
- Go installed locally so you can run `go install`
1919
- `gh` authenticated against the repository you want to onboard
2020
- write access on that repository
21-
- an Azure OpenAI endpoint and API key
21+
- model credentials: an Azure OpenAI endpoint and API key (Codex, the default),
22+
or a Vertex AI project and API key (`engine: gemini`)
2223

2324
## Install and configure
2425

@@ -276,6 +277,22 @@ engine: claude
276277
aixgo-code run owner/repo#123 --repo-dir .
277278
```
278279

280+
To use Gemini on Vertex AI instead, set `engine: gemini` and the Vertex
281+
variables. The reusable workflow installs the Gemini CLI when
282+
`vertex-project` is passed, and does not need Azure:
283+
284+
```yaml
285+
gate: make check
286+
engine: gemini
287+
```
288+
289+
```sh
290+
export AIXGO_VERTEX_PROJECT="<project>"
291+
export AIXGO_VERTEX_LOCATION="us-central1" # optional, this is the default
292+
export AIXGO_VERTEX_API_KEY="<key>"
293+
aixgo-code run owner/repo#123 --repo-dir .
294+
```
295+
279296
### GitHub Actions
280297

281298
For the hosted-in-your-GitHub path, the caller workflow in your repository
@@ -293,9 +310,12 @@ passes:
293310
The reusable workflow installs the CLI, exports the endpoint and key for the
294311
job, and runs `aixgo-code run` or `aixgo-code address`.
295312

296-
That hosted path is still Codex-on-Azure only. The reusable workflow installs
297-
the Codex CLI, not the Claude CLI, and its inputs and secrets still require the
298-
Azure endpoint and API key.
313+
The hosted path follows `.github/aixgo.yml` `engine:`. Codex (the default)
314+
still needs the Azure endpoint and key, and the workflow installs the Codex
315+
CLI. `engine: gemini` needs `vertex-project`, `vertex-location`, and
316+
`vertex-api-key` instead, and the workflow installs the Gemini CLI. Claude
317+
is still a local-CLI path; the reusable workflow does not install the Claude
318+
CLI.
299319

300320
## Watching it run before it writes
301321

0 commit comments

Comments
 (0)