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
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ It is a single Go binary with zero dependencies. 15 MB distroless image. Two por
```mermaid
flowchart LR
A[Agent<br/><i>bearer token</i>] -->|request| P[cllama-passthrough<br/><b>identity → route → swap key</b><br/><i>extract usage → record cost</i>]
P -->|real key| U[Provider<br/><i>OpenAI · Anthropic<br/>OpenRouter · Ollama</i>]
P -->|real key| U[Provider<br/><i>OpenAI · Anthropic<br/>OpenRouter · Google · Ollama</i>]
U -->|response| P
P -->|response| A
P --- D[:8081 dashboard<br/><i>providers · pod · costs · api</i>]
Expand Down Expand Up @@ -84,6 +84,7 @@ Or with Docker:
docker run -p 8080:8080 -p 8081:8081 \
-e ANTHROPIC_API_KEY=sk-ant-... \
-e OPENROUTER_API_KEY=sk-or-... \
-e GEMINI_API_KEY=sk-gemini-... \
-v ./context:/claw/context:ro \
ghcr.io/mostlydev/cllama:latest
```
Expand All @@ -105,6 +106,9 @@ docker run -p 8080:8080 -p 8081:8081 \
| `OPENAI_API_KEY` | | Provider key override |
| `ANTHROPIC_API_KEY` | | Provider key override |
| `OPENROUTER_API_KEY` | | Provider key override |
| `GEMINI_API_KEY` | | Primary Google Gemini provider key override |
| `GOOGLE_API_KEY` | | Lower-priority alias for the Google Gemini provider key |
| `GOOGLE_BASE_URL` | | Override for Google's OpenAI-compatible base URL |

Environment variables override keys saved via the web UI.

Expand Down Expand Up @@ -153,6 +157,11 @@ When orchestrated by Clawdapus, `claw up` generates all of this — tokens via `
"api_key": "sk-or-...",
"auth": "bearer"
},
"google": {
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
"api_key": "sk-gemini-...",
"auth": "bearer"
},
"ollama": {
"base_url": "http://ollama:11434/v1",
"auth": "none"
Expand All @@ -161,7 +170,7 @@ When orchestrated by Clawdapus, `claw up` generates all of this — tokens via `
}
```

Auth schemes: `bearer` (OpenAI, OpenRouter), `x-api-key` (Anthropic), `none` (Ollama, local models).
Auth schemes: `bearer` (OpenAI, OpenRouter, Google), `x-api-key` (Anthropic), `none` (Ollama, local models).

---

Expand Down
13 changes: 9 additions & 4 deletions internal/cost/pricing.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,15 @@ func DefaultPricing() *Pricing {
},
"openrouter": {
// OpenRouter passes through to upstream providers; rates match origin pricing.
"anthropic/claude-sonnet-4": {InputPerMTok: 3.0, OutputPerMTok: 15.0},
"anthropic/claude-haiku-3-5": {InputPerMTok: 0.80, OutputPerMTok: 4.0},
"google/gemini-2.5-pro": {InputPerMTok: 1.25, OutputPerMTok: 10.0},
"google/gemini-2.5-flash": {InputPerMTok: 0.15, OutputPerMTok: 0.60},
"anthropic/claude-sonnet-4": {InputPerMTok: 3.0, OutputPerMTok: 15.0},
"anthropic/claude-haiku-3-5": {InputPerMTok: 0.80, OutputPerMTok: 4.0},
"google/gemini-2.5-pro": {InputPerMTok: 1.25, OutputPerMTok: 10.0},
"google/gemini-2.5-flash": {InputPerMTok: 0.15, OutputPerMTok: 0.60},
},
// Google pricing is simplified to the standard <=200k-token text tier.
"google": {
"gemini-2.5-pro": {InputPerMTok: 1.25, OutputPerMTok: 10.0},
"gemini-2.5-flash": {InputPerMTok: 0.30, OutputPerMTok: 2.50},
},
}}
}
22 changes: 22 additions & 0 deletions internal/cost/pricing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,28 @@ func TestLookupOpenAIModel(t *testing.T) {
}
}

func TestLookupGoogleGeminiFlashModel(t *testing.T) {
p := DefaultPricing()
rate, ok := p.Lookup("google", "gemini-2.5-flash")
if !ok {
t.Fatal("expected to find google/gemini-2.5-flash")
}
if rate.InputPerMTok != 0.30 || rate.OutputPerMTok != 2.50 {
t.Fatalf("unexpected google/gemini-2.5-flash rates: %+v", rate)
}
}

func TestLookupGoogleGeminiProModel(t *testing.T) {
p := DefaultPricing()
rate, ok := p.Lookup("google", "gemini-2.5-pro")
if !ok {
t.Fatal("expected to find google/gemini-2.5-pro")
}
if rate.InputPerMTok != 1.25 || rate.OutputPerMTok != 10.0 {
t.Fatalf("unexpected google/gemini-2.5-pro rates: %+v", rate)
}
}

func TestComputeCost(t *testing.T) {
rate := Rate{InputPerMTok: 3.0, OutputPerMTok: 15.0}
cost := rate.Compute(1000, 500)
Expand Down
10 changes: 10 additions & 0 deletions internal/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ var knownProviders = 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",
"ollama": "http://ollama:11434/v1",
}

Expand All @@ -92,13 +93,17 @@ var envKeyMap = map[string]string{
"ANTHROPIC_API_KEY_1": "anthropic",
"OPENROUTER_API_KEY": "openrouter",
"OPENROUTER_API_KEY_1": "openrouter",
"GEMINI_API_KEY": "google",
"GEMINI_API_KEY_1": "google",
"GOOGLE_API_KEY": "google",
}

var envBaseURLMap = map[string]string{
"OPENAI_BASE_URL": "openai",
"XAI_BASE_URL": "xai",
"ANTHROPIC_BASE_URL": "anthropic",
"OPENROUTER_BASE_URL": "openrouter",
"GOOGLE_BASE_URL": "google",
"OLLAMA_BASE_URL": "ollama",
}

Expand Down Expand Up @@ -270,6 +275,11 @@ func (r *Registry) LoadFromEnv() {
{"OPENROUTER_API_KEY", "seed:OPENROUTER_API_KEY", "primary"},
{"OPENROUTER_API_KEY_1", "seed:OPENROUTER_API_KEY_1", "backup-1"},
},
"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"},
},
}

for provName, defs := range envKeysByProvider {
Expand Down
107 changes: 107 additions & 0 deletions internal/provider/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,113 @@ func TestLoadFromEnvAppliesXAIBaseURLOverride(t *testing.T) {
}
}

func TestLoadFromEnvSeedsGoogleProviderFromGeminiKey(t *testing.T) {
t.Setenv("GEMINI_API_KEY", "sk-gemini-primary")

r := NewRegistry("")
r.LoadFromEnv()

p, err := r.Get("google")
if err != nil {
t.Fatalf("google: %v", err)
}
if p.APIKey != "sk-gemini-primary" {
t.Fatalf("expected google key from GEMINI_API_KEY, got %q", p.APIKey)
}
if p.BaseURL != "https://generativelanguage.googleapis.com/v1beta/openai" {
t.Fatalf("unexpected google base URL: %q", p.BaseURL)
}
if p.Auth != "bearer" {
t.Fatalf("expected google auth=bearer, got %q", p.Auth)
}
if p.APIFormat != "openai" {
t.Fatalf("expected google api_format=openai, got %q", p.APIFormat)
}

state := r.All()["google"]
if state == nil {
t.Fatal("expected google provider state")
}
if state.ActiveKeyID != "seed:GEMINI_API_KEY" {
t.Fatalf("active_key_id = %q, want seed:GEMINI_API_KEY", state.ActiveKeyID)
}
if len(state.Keys) != 1 || state.Keys[0].ID != "seed:GEMINI_API_KEY" {
t.Fatalf("expected single google seed key, got %+v", state.Keys)
}
}

func TestLoadFromEnvSeedsGoogleProviderFromGoogleAlias(t *testing.T) {
t.Setenv("GOOGLE_API_KEY", "sk-google-alias")

r := NewRegistry("")
r.LoadFromEnv()

p, err := r.Get("google")
if err != nil {
t.Fatalf("google: %v", err)
}
if p.APIKey != "sk-google-alias" {
t.Fatalf("expected google key from GOOGLE_API_KEY, got %q", p.APIKey)
}

state := r.All()["google"]
if state == nil {
t.Fatal("expected google provider state")
}
if state.ActiveKeyID != "seed:GOOGLE_API_KEY" {
t.Fatalf("active_key_id = %q, want seed:GOOGLE_API_KEY", state.ActiveKeyID)
}
if len(state.Keys) != 1 || state.Keys[0].ID != "seed:GOOGLE_API_KEY" {
t.Fatalf("expected GOOGLE_API_KEY alias to seed google provider, got %+v", state.Keys)
}
}

func TestLoadFromEnvPrefersGeminiKeyOverGoogleAlias(t *testing.T) {
t.Setenv("GEMINI_API_KEY", "sk-gemini-primary")
t.Setenv("GOOGLE_API_KEY", "sk-google-alias")

r := NewRegistry("")
r.LoadFromEnv()

p, err := r.Get("google")
if err != nil {
t.Fatalf("google: %v", err)
}
if p.APIKey != "sk-gemini-primary" {
t.Fatalf("expected GEMINI_API_KEY to win, got %q", p.APIKey)
}

state := r.All()["google"]
if state == nil {
t.Fatal("expected google provider state")
}
if state.ActiveKeyID != "seed:GEMINI_API_KEY" {
t.Fatalf("active_key_id = %q, want seed:GEMINI_API_KEY", state.ActiveKeyID)
}
if len(state.Keys) != 2 {
t.Fatalf("expected 2 google keys, got %+v", state.Keys)
}
if state.Keys[0].ID != "seed:GEMINI_API_KEY" || state.Keys[1].ID != "seed:GOOGLE_API_KEY" {
t.Fatalf("expected GEMINI primary then GOOGLE alias ordering, got %+v", state.Keys)
}
}

func TestLoadFromEnvAppliesGoogleBaseURLOverride(t *testing.T) {
t.Setenv("GEMINI_API_KEY", "sk-gemini-primary")
t.Setenv("GOOGLE_BASE_URL", "https://proxy.example.test/google")

r := NewRegistry("")
r.LoadFromEnv()

p, err := r.Get("google")
if err != nil {
t.Fatalf("google: %v", err)
}
if p.BaseURL != "https://proxy.example.test/google" {
t.Fatalf("expected google base URL override, got %q", p.BaseURL)
}
}

func TestGetUnknownProviderErrors(t *testing.T) {
r := NewRegistry("")
_, err := r.Get("nonexistent")
Expand Down
109 changes: 104 additions & 5 deletions internal/proxy/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -854,11 +854,21 @@ func injectManagedOpenAITools(payload map[string]any, agentCtx *agentctx.AgentCo
payload["stream"] = false
delete(payload, "stream_options")
}
payload["tools"] = buildOpenAIToolSchemas(agentCtx.Tools.Tools)
tools := existingOpenAIToolSchemas(payload)
for _, tool := range buildOpenAIToolSchemas(agentCtx.Tools.Tools) {
tools = append(tools, tool)
}
payload["tools"] = tools
if _, ok := payload["tool_choice"]; !ok {
if toolChoice, ok := legacyFunctionCallToToolChoice(payload["function_call"]); ok {
payload["tool_choice"] = toolChoice
}
}
if toolChoice, ok := payload["tool_choice"]; ok {
payload["tool_choice"] = rewriteManagedOpenAIToolChoice(toolChoice, agentCtx)
}
delete(payload, "functions")
delete(payload, "function_call")
delete(payload, "tool_choice")
delete(payload, "parallel_tool_calls")
return nil
}

Expand All @@ -869,15 +879,104 @@ func injectManagedAnthropicTools(payload map[string]any, agentCtx *agentctx.Agen
if requestedStream(payload) {
payload["stream"] = false
}
payload["tools"] = buildAnthropicToolSchemas(agentCtx.Tools.Tools)
delete(payload, "tool_choice")
tools, _ := payload["tools"].([]any)
merged := append([]any{}, tools...)
for _, tool := range buildAnthropicToolSchemas(agentCtx.Tools.Tools) {
merged = append(merged, tool)
}
payload["tools"] = merged
if toolChoice, ok := payload["tool_choice"]; ok {
payload["tool_choice"] = rewriteManagedAnthropicToolChoice(toolChoice, agentCtx)
}
return nil
}

func hasManagedTools(agentCtx *agentctx.AgentContext) bool {
return agentCtx != nil && agentCtx.Tools != nil && len(agentCtx.Tools.Tools) > 0
}

func existingOpenAIToolSchemas(payload map[string]any) []any {
var tools []any
if existing, ok := payload["tools"].([]any); ok {
tools = append(tools, existing...)
}
if functions, ok := payload["functions"].([]any); ok {
for _, raw := range functions {
function, _ := raw.(map[string]any)
if function == nil {
continue
}
tools = append(tools, map[string]any{
"type": "function",
"function": function,
})
}
}
return tools
}

func legacyFunctionCallToToolChoice(raw any) (any, bool) {
switch typed := raw.(type) {
case string:
value := strings.TrimSpace(typed)
if value == "" {
return nil, false
}
return value, true
case map[string]any:
name, _ := typed["name"].(string)
name = strings.TrimSpace(name)
if name == "" {
return nil, false
}
return map[string]any{
"type": "function",
"function": map[string]any{
"name": name,
},
}, true
default:
return nil, false
}
}

func rewriteManagedOpenAIToolChoice(toolChoice any, agentCtx *agentctx.AgentContext) any {
choice, _ := toolChoice.(map[string]any)
if choice == nil {
return toolChoice
}
function, _ := choice["function"].(map[string]any)
if function == nil {
return toolChoice
}
name, _ := function["name"].(string)
resolved, ok := resolveManagedTool(agentCtx, name)
if !ok {
return toolChoice
}
function["name"] = resolved.PresentedName
choice["function"] = function
return choice
}

func rewriteManagedAnthropicToolChoice(toolChoice any, agentCtx *agentctx.AgentContext) any {
choice, _ := toolChoice.(map[string]any)
if choice == nil {
return toolChoice
}
kind, _ := choice["type"].(string)
if !strings.EqualFold(strings.TrimSpace(kind), "tool") {
return toolChoice
}
name, _ := choice["name"].(string)
resolved, ok := resolveManagedTool(agentCtx, name)
if !ok {
return toolChoice
}
choice["name"] = resolved.PresentedName
return choice
}

func buildOpenAIToolSchemas(tools []agentctx.ToolManifestEntry) []map[string]any {
schemas := make([]map[string]any, 0, len(tools))
for _, tool := range tools {
Expand Down
Loading