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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ Do not assume older docs mentioning only a subset are current.
- Hermes gateway log (inside container): `/root/.hermes/logs/gateway.log` — shows all received Discord events. Zero entries after startup means the bot is connected but not receiving messages (stale gateway session or missing `MESSAGE_CONTENT` intent).
- cllama context mount (`agentctx`) currently holds only `AgentsMD`, `ClawdapusMD`, and `Metadata` (for bearer token auth). No outbound service credentials, no feed manifests, no decoration config.
- cllama session history: `claw up` bind-mounts `.claw-session-history/` → `/claw/session-history` in the cllama container when cllama is enabled. cllama writes `<dir>/<agent-id>/history.jsonl` — one entry per successful 2xx completion. This is infrastructure-owned (proxy-written). Agents have no read API against it in Phase 1. Distinct from `/claw/memory`, which is runner-owned. Both surfaces are persistent across container restarts AND driver migrations (`CLAW_TYPE` changes).
- Provider API keys for cllama-managed services belong in `x-claw.cllama-env`, not regular agent `environment:` blocks.
- Provider API keys for cllama-managed services belong in `x-claw.cllama-env`, not regular agent `environment:` blocks. Native Gemini uses `GEMINI_API_KEY` as the primary env name and also accepts `GOOGLE_API_KEY` as a lower-priority alias.
- For cllama-enabled `count > 1` services, bearer tokens and context are per ordinal, not per base service.
- `compose.generated.yml` and `Dockerfile.generated` are generated artifacts. Inspect them, but do not hand-edit them as source.
- OpenClaw config and cron paths are mounted as directories, not single files, because the runtime performs atomic rewrites.
Expand Down
9 changes: 8 additions & 1 deletion cmd/claw/compose_up.go
Original file line number Diff line number Diff line change
Expand Up @@ -2705,6 +2705,9 @@ var seedKeyDefs = []seedKeyDef{
{"ANTHROPIC_API_KEY_1", "anthropic", "seed:ANTHROPIC_API_KEY_1", "backup-1"},
{"OPENROUTER_API_KEY", "openrouter", "seed:OPENROUTER_API_KEY", "primary"},
{"OPENROUTER_API_KEY_1", "openrouter", "seed:OPENROUTER_API_KEY_1", "backup-1"},
{"GEMINI_API_KEY", "google", "seed:GEMINI_API_KEY", "primary"},
{"GEMINI_API_KEY_1", "google", "seed:GEMINI_API_KEY_1", "backup-1"},
{"GOOGLE_API_KEY", "google", "seed:GOOGLE_API_KEY", "backup-2"},
}

// v2ProviderFile is the providers.json v2 on-disk shape (write path only).
Expand Down Expand Up @@ -2740,6 +2743,7 @@ var defaultBaseURLs = map[string]string{
"xai": "https://api.x.ai/v1",
"anthropic": "https://api.anthropic.com/v1",
"openrouter": "https://openrouter.ai/api/v1",
"google": "https://generativelanguage.googleapis.com/v1beta/openai",
}

var defaultAuths = map[string]string{
Expand Down Expand Up @@ -2790,6 +2794,7 @@ func mergeProviderSeeds(authDir string, p *pod.Pod) error {
"XAI_BASE_URL": "xai",
"ANTHROPIC_BASE_URL": "anthropic",
"OPENROUTER_BASE_URL": "openrouter",
"GOOGLE_BASE_URL": "google",
}
customBaseURLs := make(map[string]string)
for envKey, prov := range baseURLEnvMap {
Expand Down Expand Up @@ -3004,8 +3009,10 @@ func loadOrGenerateUIToken(authDir string) (string, error) {
func isProviderKey(key string) bool {
switch key {
case "OPENAI_API_KEY", "OPENAI_API_KEY_1", "OPENAI_API_KEY_2",
"XAI_API_KEY", "XAI_API_KEY_1",
"ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY_1",
"OPENROUTER_API_KEY", "OPENROUTER_API_KEY_1":
"OPENROUTER_API_KEY", "OPENROUTER_API_KEY_1",
"GEMINI_API_KEY", "GEMINI_API_KEY_1", "GOOGLE_API_KEY":
return true
}
return strings.HasPrefix(key, "PROVIDER_API_KEY")
Expand Down
127 changes: 127 additions & 0 deletions cmd/claw/compose_up_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2143,17 +2143,29 @@ func TestCollectProxyTypes(t *testing.T) {
func TestStripLLMKeys(t *testing.T) {
env := map[string]string{
"OPENAI_API_KEY": "sk-real",
"XAI_API_KEY": "sk-xai",
"ANTHROPIC_API_KEY": "sk-ant",
"GEMINI_API_KEY": "sk-gemini",
"GOOGLE_API_KEY": "sk-google",
"DISCORD_BOT_TOKEN": "keep",
"LOG_LEVEL": "info",
}
stripLLMKeys(env)
if _, ok := env["OPENAI_API_KEY"]; ok {
t.Error("should strip OPENAI_API_KEY")
}
if _, ok := env["XAI_API_KEY"]; ok {
t.Error("should strip XAI_API_KEY")
}
if _, ok := env["ANTHROPIC_API_KEY"]; ok {
t.Error("should strip ANTHROPIC_API_KEY")
}
if _, ok := env["GEMINI_API_KEY"]; ok {
t.Error("should strip GEMINI_API_KEY")
}
if _, ok := env["GOOGLE_API_KEY"]; ok {
t.Error("should strip GOOGLE_API_KEY")
}
if env["DISCORD_BOT_TOKEN"] != "keep" {
t.Error("should keep non-LLM keys")
}
Expand All @@ -2167,10 +2179,15 @@ func TestIsProviderKey(t *testing.T) {
{"OPENAI_API_KEY", true},
{"OPENAI_API_KEY_1", true},
{"OPENAI_API_KEY_2", true},
{"XAI_API_KEY", true},
{"XAI_API_KEY_1", true},
{"ANTHROPIC_API_KEY", true},
{"ANTHROPIC_API_KEY_1", true},
{"OPENROUTER_API_KEY", true},
{"OPENROUTER_API_KEY_1", true},
{"GEMINI_API_KEY", true},
{"GEMINI_API_KEY_1", true},
{"GOOGLE_API_KEY", true},
{"PROVIDER_API_KEY_CUSTOM", true},
{"DISCORD_BOT_TOKEN", false},
{"LOG_LEVEL", false},
Expand Down Expand Up @@ -2295,6 +2312,116 @@ func TestMergeProviderSeedsWritesXAIProvider(t *testing.T) {
}
}

func TestMergeProviderSeedsWritesGoogleProvider(t *testing.T) {
dir := t.TempDir()
p := &pod.Pod{
Services: map[string]*pod.Service{
"analyst": {
Claw: &pod.ClawBlock{
CllamaEnv: map[string]string{
"GEMINI_API_KEY": "gemini-primary",
"GOOGLE_API_KEY": "google-alias",
"GOOGLE_BASE_URL": "https://proxy.example.test/google",
},
},
},
},
}
if err := mergeProviderSeeds(dir, p); err != nil {
t.Fatalf("mergeProviderSeeds: %v", err)
}

data, err := os.ReadFile(filepath.Join(dir, "providers.json"))
if err != nil {
t.Fatalf("read providers.json: %v", err)
}

var probe struct {
Providers map[string]struct {
BaseURL string `json:"base_url"`
ActiveKeyID string `json:"active_key_id"`
Keys []struct {
ID string `json:"id"`
Secret string `json:"secret"`
} `json:"keys"`
} `json:"providers"`
}
if err := json.Unmarshal(data, &probe); err != nil {
t.Fatalf("parse providers.json: %v", err)
}

google, ok := probe.Providers["google"]
if !ok {
t.Fatal("google missing from output")
}
if google.BaseURL != "https://proxy.example.test/google" {
t.Fatalf("expected google base URL override, got %q", google.BaseURL)
}
if google.ActiveKeyID != "seed:GEMINI_API_KEY" {
t.Fatalf("expected google active key to prefer GEMINI_API_KEY, got %q", google.ActiveKeyID)
}
if len(google.Keys) != 2 {
t.Fatalf("expected 2 google keys, got %d", len(google.Keys))
}
if google.Keys[0].ID != "seed:GEMINI_API_KEY" || google.Keys[0].Secret != "gemini-primary" {
t.Fatalf("unexpected primary google key: %+v", google.Keys[0])
}
if google.Keys[1].ID != "seed:GOOGLE_API_KEY" || google.Keys[1].Secret != "google-alias" {
t.Fatalf("unexpected alias google key: %+v", google.Keys[1])
}
}

func TestMergeProviderSeedsUsesGoogleAliasWhenGeminiMissing(t *testing.T) {
dir := t.TempDir()
p := &pod.Pod{
Services: map[string]*pod.Service{
"analyst": {
Claw: &pod.ClawBlock{
CllamaEnv: map[string]string{
"GOOGLE_API_KEY": "google-alias",
},
},
},
},
}
if err := mergeProviderSeeds(dir, p); err != nil {
t.Fatalf("mergeProviderSeeds: %v", err)
}

data, err := os.ReadFile(filepath.Join(dir, "providers.json"))
if err != nil {
t.Fatalf("read providers.json: %v", err)
}

var probe struct {
Providers map[string]struct {
BaseURL string `json:"base_url"`
ActiveKeyID string `json:"active_key_id"`
Keys []struct {
ID string `json:"id"`
Secret string `json:"secret"`
} `json:"keys"`
} `json:"providers"`
}
if err := json.Unmarshal(data, &probe); err != nil {
t.Fatalf("parse providers.json: %v", err)
}

google, ok := probe.Providers["google"]
if !ok {
t.Fatal("google missing from output")
}
if google.BaseURL != "https://generativelanguage.googleapis.com/v1beta/openai" {
t.Fatalf("expected default google base URL, got %q", google.BaseURL)
}
if google.ActiveKeyID != "seed:GOOGLE_API_KEY" {
t.Fatalf("expected GOOGLE_API_KEY alias to become active key, got %q", google.ActiveKeyID)
}
if len(google.Keys) != 1 || google.Keys[0].ID != "seed:GOOGLE_API_KEY" || google.Keys[0].Secret != "google-alias" {
t.Fatalf("unexpected google alias seed output: %+v", google.Keys)
}
}

func TestMergeProviderSeedsPreservesExistingRuntimeKeys(t *testing.T) {
dir := t.TempDir()

Expand Down
160 changes: 160 additions & 0 deletions docs/plans/2026-04-06-gemini-provider-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# First-Class Google Gemini Provider Support

**Date:** 2026-04-06
**Status:** Draft
**Issue:** #119
**Execution plan:** `docs/plans/2026-04-08-119-gemini-provider-support.md`
**Scope:** cllama provider registry, compose_up env seeding, cost tracking

## Problem

Clawdapus has no native `google` provider. Gemini models work today only through OpenRouter (`openrouter/google/gemini-2.5-flash`). Operators who want direct Gemini API access — for cost separation, latency, or independence from OpenRouter — have no supported path.

Three code locations need the provider added:

1. **cllama provider registry** (`cllama/internal/provider/provider.go`) — `knownProviders`, `envKeyMap`, `envBaseURLMap`, `defaultAuth`, `defaultAPIFormat`, `LoadFromEnv`
2. **compose_up env seeding** (`cmd/claw/compose_up.go`) — `seedKeyDefs`, `isProviderKey`, base URL map
3. **cllama cost tracking** (`cllama/internal/cost/pricing.go`) — already has OpenRouter Google pricing, needs direct `google` provider entries

## Google Gemini API Compatibility

Google provides an OpenAI-compatible endpoint at `https://generativelanguage.googleapis.com/v1beta/openai/`. This means:
- Auth: `bearer` (standard `Authorization: Bearer <key>` header)
- API format: `openai` (standard chat completions)
- Model refs: `google/gemini-2.5-flash`, `google/gemini-2.5-pro`, etc.

The cllama proxy's `splitModel` function already handles the `provider/model` split correctly. A request for `google/gemini-2.5-flash` would split to provider=`google`, upstream model=`gemini-2.5-flash`, and route to the Google base URL.

## Implementation Steps

### Step 1: cllama Provider Registry

**File:** `cllama/internal/provider/provider.go`

Add `google` to `knownProviders`:
```go
var knownProviders = map[string]string{
"openai": "https://api.openai.com/v1",
"xai": "https://api.x.ai/v1",
"anthropic": "https://api.anthropic.com/v1",
"openrouter": "https://openrouter.ai/api/v1",
"ollama": "http://ollama:11434/v1",
"google": "https://generativelanguage.googleapis.com/v1beta/openai",
}
```

Add env key mappings to `envKeyMap`:
```go
"GEMINI_API_KEY": "google",
"GEMINI_API_KEY_1": "google",
"GOOGLE_API_KEY": "google",
```

Add base URL mapping to `envBaseURLMap`:
```go
"GOOGLE_BASE_URL": "google",
```

Add Google to `LoadFromEnv` key definitions:
```go
"google": {
{"GEMINI_API_KEY", "seed:GEMINI_API_KEY", "primary"},
{"GEMINI_API_KEY_1", "seed:GEMINI_API_KEY_1", "backup-1"},
{"GOOGLE_API_KEY", "seed:GOOGLE_API_KEY", "backup-2"},
},
```

`GEMINI_API_KEY` takes priority over `GOOGLE_API_KEY` because it's more specific. `GOOGLE_API_KEY` is accepted as an alias since some tooling uses that name.

Auth and API format defaults: `google` uses `bearer` auth and `openai` format — both are the defaults in `defaultAuth` and `defaultAPIFormat`, so no changes needed there.

### Step 2: compose_up Env Seeding

**File:** `cmd/claw/compose_up.go`

Add to `seedKeyDefs`:
```go
{"GEMINI_API_KEY", "google", "seed:GEMINI_API_KEY", "primary"},
{"GEMINI_API_KEY_1", "google", "seed:GEMINI_API_KEY_1", "backup-1"},
{"GOOGLE_API_KEY", "google", "seed:GOOGLE_API_KEY", "backup-2"},
```

Add to `isProviderKey`:
```go
case "OPENAI_API_KEY", "OPENAI_API_KEY_1", "OPENAI_API_KEY_2",
"ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY_1",
"OPENROUTER_API_KEY", "OPENROUTER_API_KEY_1",
"GEMINI_API_KEY", "GEMINI_API_KEY_1", "GOOGLE_API_KEY":
return true
```

Add to the base URL env map (around line 2748):
```go
"GOOGLE_BASE_URL": "google",
```

### Step 3: Cost Tracking

**File:** `cllama/internal/cost/pricing.go`

Add direct `google` provider pricing alongside the existing OpenRouter Google entries:

```go
"google": {
"gemini-2.5-pro": {InputPerMTok: 1.25, OutputPerMTok: 10.0},
"gemini-2.5-flash": {InputPerMTok: 0.15, OutputPerMTok: 0.60},
},
```

These match the OpenRouter pass-through rates already in-tree (lines 72-73).

### Step 4: Tests

**File:** `cllama/internal/provider/provider_test.go`

- Test `LoadFromEnv` picks up `GEMINI_API_KEY` and registers a `google` provider with correct base URL
- Test `GOOGLE_API_KEY` fallback when `GEMINI_API_KEY` is absent
- Test that `google` provider has `bearer` auth and `openai` api_format
- Test `Get("google")` returns correct provider after env loading

**File:** `cmd/claw/compose_up_test.go`

- Test `isProviderKey` returns true for `GEMINI_API_KEY`, `GEMINI_API_KEY_1`, `GOOGLE_API_KEY`
- Test that `seedKeyDefs` includes google entries (if tested directly)

**File:** `cllama/internal/cost/pricing_test.go` (if exists)

- Test direct `google/gemini-2.5-flash` pricing lookup returns expected rates

### Step 5: Documentation

- Update `site/guide/cllama.md` to list `google` as a supported provider
- Add example showing direct Gemini configuration:

```yaml
x-claw:
cllama-defaults:
env:
GEMINI_API_KEY: "${GEMINI_API_KEY}"

services:
analyst:
x-claw:
models:
primary: google/gemini-2.5-flash
```

- Update `AGENTS.md` gotchas to note that both `GEMINI_API_KEY` and `GOOGLE_API_KEY` are recognized

## What This Does NOT Change

- Proxy handler (`cllama/internal/proxy/handler.go`) — `splitModel` already handles `google/model` correctly
- Model policy (`cllama/internal/proxy/modelpolicy.go`) — provider-agnostic, works with any provider prefix
- Driver configs — all drivers use `shared.CollectProviders(rc.Models)` which extracts the provider from model refs; `google` will be collected automatically
- OpenRouter routing — `openrouter/google/gemini-*` continues to work as before (routed to OpenRouter, not to Google directly)

## Risks

- **Gemini API compatibility**: Google's OpenAI-compatible endpoint is in `v1beta`. If the endpoint path changes, only `knownProviders["google"]` needs updating. The `GOOGLE_BASE_URL` env override provides an escape hatch.
- **Key naming collision**: `GOOGLE_API_KEY` is a common env var name used by other Google services (Maps, Cloud, etc.). Making `GEMINI_API_KEY` the primary and `GOOGLE_API_KEY` a lower-priority alias mitigates accidental key leakage into the wrong service.
- **cllama submodule boundary**: Steps 1, 3, and 4 (provider tests) touch the cllama submodule. This requires a commit inside `cllama/`, then updating the submodule pointer in the main repo.
Loading