detached e900970

Merge pull request #142 from Codonyat/add-gondola-provider

Add Gondola provider
commit
e90097009b4d63bbebff6468e4f2e07fca6c049b
author
Patric Eckhart · · 2026-08-13 14:58 UTC
committer
GitHub · · 2026-08-13 14:58 UTC
merge of
2ddba2d + 91cc912 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.3.43
13 files · +247 -4
M docs/providers.md +4 -0
@@ -112,6 +112,7 @@ show instructions and should be configured with environment variables.
112112 | Together AI | `TOGETHER_API_KEY` | `together` |
113113 | Hugging Face | `HF_TOKEN` | `huggingface` |
114114 | OpenRouter | `OPENROUTER_API_KEY` | `openrouter` |
115+| Gondola | `GONDOLA_API_KEY` | `gondola` |
115116 | Mistral | `MISTRAL_API_KEY` | `mistral` |
116117 | ZAI | `ZAI_API_KEY` | `zai` |
117118 | Xiaomi MiMo | `XIAOMI_API_KEY` | `xiaomi` |
@@ -129,6 +130,9 @@ show instructions and should be configured with environment variables.
129130 | Cloudflare AI Gateway | `CLOUDFLARE_API_KEY` | `cloudflare-ai-gateway` |
130131 | Azure OpenAI Responses | `AZURE_OPENAI_API_KEY` | `azure-openai-responses` |
131132
133+When Gondola credentials are available, zot refreshes its public text-model
134+catalog in the background and adds the discovered models to `/model`.
135+
132136 Example:
133137
134138 ```bash
@@ -112,6 +112,7 @@ show instructions and should be configured with environment variables.
112 | Together AI | `TOGETHER_API_KEY` | `together` |112 | Together AI | `TOGETHER_API_KEY` | `together` |
113 | Hugging Face | `HF_TOKEN` | `huggingface` |113 | Hugging Face | `HF_TOKEN` | `huggingface` |
114 | OpenRouter | `OPENROUTER_API_KEY` | `openrouter` |114 | OpenRouter | `OPENROUTER_API_KEY` | `openrouter` |
115+| Gondola | `GONDOLA_API_KEY` | `gondola` |
115 | Mistral | `MISTRAL_API_KEY` | `mistral` |116 | Mistral | `MISTRAL_API_KEY` | `mistral` |
116 | ZAI | `ZAI_API_KEY` | `zai` |117 | ZAI | `ZAI_API_KEY` | `zai` |
117 | Xiaomi MiMo | `XIAOMI_API_KEY` | `xiaomi` |118 | Xiaomi MiMo | `XIAOMI_API_KEY` | `xiaomi` |
@@ -129,6 +130,9 @@ show instructions and should be configured with environment variables.
129 | Cloudflare AI Gateway | `CLOUDFLARE_API_KEY` | `cloudflare-ai-gateway` |130 | Cloudflare AI Gateway | `CLOUDFLARE_API_KEY` | `cloudflare-ai-gateway` |
130 | Azure OpenAI Responses | `AZURE_OPENAI_API_KEY` | `azure-openai-responses` |131 | Azure OpenAI Responses | `AZURE_OPENAI_API_KEY` | `azure-openai-responses` |
131 132
133+When Gondola credentials are available, zot refreshes its public text-model
134+catalog in the background and adds the discovered models to `/model`.
135+
132 Example:136 Example:
133 137
134 ```bash138 ```bash
M packages/agent/build.go +7 -1
@@ -185,6 +185,8 @@ func defaultModelForProvider(prov string) string {
185185 return "moonshotai/Kimi-K2-Instruct"
186186 case "openrouter":
187187 return "anthropic/claude-sonnet-4.5"
188+ case "gondola":
189+ return "kimi-k3"
188190 case "mistral":
189191 return "mistral-large-latest"
190192 case "zai":
@@ -226,7 +228,7 @@ func defaultModelForProvider(prov string) string {
226228 var knownProviders = []string{
227229 "anthropic", "openai", "openai-codex", "openai-responses", "kimi", "deepseek", "google", "ollama", provider.LlamaCPPProviderID,
228230 "moonshotai", "moonshotai-cn",
229- "cerebras", "groq", "xai", "together", "huggingface", "openrouter",
231+ "cerebras", "groq", "xai", "together", "huggingface", "openrouter", "gondola",
230232 "mistral", "zai",
231233 "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp",
232234 "minimax", "minimax-cn",
@@ -837,6 +839,8 @@ func (r Resolved) NewClient() provider.Client {
837839 return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL))
838840 case "openrouter":
839841 return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL))
842+ case "gondola":
843+ return wrap(provider.NewGondola(r.Credential, r.BaseURL))
840844 case "zai":
841845 return wrap(provider.NewZAI(r.Credential, r.BaseURL))
842846 case "xiaomi":
@@ -1079,6 +1083,8 @@ func envVarName(provider string) string {
10791083 return "HF"
10801084 case "openrouter":
10811085 return "OPENROUTER"
1086+ case "gondola":
1087+ return "GONDOLA"
10821088 case "mistral":
10831089 return "MISTRAL"
10841090 case "zai":
@@ -185,6 +185,8 @@ func defaultModelForProvider(prov string) string {
185 return "moonshotai/Kimi-K2-Instruct"185 return "moonshotai/Kimi-K2-Instruct"
186 case "openrouter":186 case "openrouter":
187 return "anthropic/claude-sonnet-4.5"187 return "anthropic/claude-sonnet-4.5"
188+ case "gondola":
189+ return "kimi-k3"
188 case "mistral":190 case "mistral":
189 return "mistral-large-latest"191 return "mistral-large-latest"
190 case "zai":192 case "zai":
@@ -226,7 +228,7 @@ func defaultModelForProvider(prov string) string {
226 var knownProviders = []string{228 var knownProviders = []string{
227 "anthropic", "openai", "openai-codex", "openai-responses", "kimi", "deepseek", "google", "ollama", provider.LlamaCPPProviderID,229 "anthropic", "openai", "openai-codex", "openai-responses", "kimi", "deepseek", "google", "ollama", provider.LlamaCPPProviderID,
228 "moonshotai", "moonshotai-cn",230 "moonshotai", "moonshotai-cn",
229- "cerebras", "groq", "xai", "together", "huggingface", "openrouter",231+ "cerebras", "groq", "xai", "together", "huggingface", "openrouter", "gondola",
230 "mistral", "zai",232 "mistral", "zai",
231 "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp",233 "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp",
232 "minimax", "minimax-cn",234 "minimax", "minimax-cn",
@@ -837,6 +839,8 @@ func (r Resolved) NewClient() provider.Client {
837 return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL))839 return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL))
838 case "openrouter":840 case "openrouter":
839 return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL))841 return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL))
842+ case "gondola":
843+ return wrap(provider.NewGondola(r.Credential, r.BaseURL))
840 case "zai":844 case "zai":
841 return wrap(provider.NewZAI(r.Credential, r.BaseURL))845 return wrap(provider.NewZAI(r.Credential, r.BaseURL))
842 case "xiaomi":846 case "xiaomi":
@@ -1079,6 +1083,8 @@ func envVarName(provider string) string {
1079 return "HF"1083 return "HF"
1080 case "openrouter":1084 case "openrouter":
1081 return "OPENROUTER"1085 return "OPENROUTER"
1086+ case "gondola":
1087+ return "GONDOLA"
1082 case "mistral":1088 case "mistral":
1083 return "MISTRAL"1089 return "MISTRAL"
1084 case "zai":1090 case "zai":
M packages/agent/config.go +4 -0
@@ -415,6 +415,10 @@ func resolveCredentialFull(ctx context.Context, provider, explicit string, comma
415415 if v := os.Getenv("OPENROUTER_API_KEY"); v != "" {
416416 return v, "apikey", "", nil
417417 }
418+ case "gondola":
419+ if v := os.Getenv("GONDOLA_API_KEY"); v != "" {
420+ return v, "apikey", "", nil
421+ }
418422 case "mistral":
419423 if v := os.Getenv("MISTRAL_API_KEY"); v != "" {
420424 return v, "apikey", "", nil
@@ -415,6 +415,10 @@ func resolveCredentialFull(ctx context.Context, provider, explicit string, comma
415 if v := os.Getenv("OPENROUTER_API_KEY"); v != "" {415 if v := os.Getenv("OPENROUTER_API_KEY"); v != "" {
416 return v, "apikey", "", nil416 return v, "apikey", "", nil
417 }417 }
418+ case "gondola":
419+ if v := os.Getenv("GONDOLA_API_KEY"); v != "" {
420+ return v, "apikey", "", nil
421+ }
418 case "mistral":422 case "mistral":
419 if v := os.Getenv("MISTRAL_API_KEY"); v != "" {423 if v := os.Getenv("MISTRAL_API_KEY"); v != "" {
420 return v, "apikey", "", nil424 return v, "apikey", "", nil
M packages/agent/modelsync.go +7 -0
@@ -217,6 +217,13 @@ func refreshModels() {
217217 all = append(all, live...)
218218 }
219219 }
220+ if _, _, err := resolveCredentialForBackground(ctx, "gondola"); err == nil {
221+ // Gondola's catalog is also public. Gate discovery on a credential so
222+ // its text models only fill the picker for users of the provider.
223+ if live, err := provider.DiscoverGondola(ctx, ""); err == nil {
224+ all = append(all, live...)
225+ }
226+ }
220227
221228 if len(all) == 0 {
222229 return
@@ -217,6 +217,13 @@ func refreshModels() {
217 all = append(all, live...)217 all = append(all, live...)
218 }218 }
219 }219 }
220+ if _, _, err := resolveCredentialForBackground(ctx, "gondola"); err == nil {
221+ // Gondola's catalog is also public. Gate discovery on a credential so
222+ // its text models only fill the picker for users of the provider.
223+ if live, err := provider.DiscoverGondola(ctx, ""); err == nil {
224+ all = append(all, live...)
225+ }
226+ }
220 227
221 if len(all) == 0 {228 if len(all) == 0 {
222 return229 return
M packages/provider/auth/auth_test.go +29 -0
@@ -2,6 +2,7 @@ package auth
22
33 import (
44 "context"
5+ "io"
56 "net"
67 "net/http"
78 "net/url"
@@ -20,6 +21,34 @@ func TestProbeAPIKeyAcceptsExtraProviderWithoutProbe(t *testing.T) {
2021 }
2122 }
2223
24+func TestProbeGondolaAPIKeyUsesAuthenticatedBalanceEndpoint(t *testing.T) {
25+ client := &http.Client{Transport: authRoundTripFunc(func(req *http.Request) (*http.Response, error) {
26+ if req.URL.String() != "https://api.gondola-ai.com/v1/balance" {
27+ t.Fatalf("probe URL = %q", req.URL.String())
28+ }
29+ if got := req.Header.Get("authorization"); got != "Bearer gnd_test" {
30+ t.Fatalf("authorization = %q", got)
31+ }
32+ return &http.Response{
33+ StatusCode: http.StatusUnauthorized,
34+ Body: io.NopCloser(strings.NewReader(`{"error":"invalid key"}`)),
35+ Header: make(http.Header),
36+ Request: req,
37+ }, nil
38+ })}
39+
40+ err := probeAPIKey(context.Background(), "gondola", "gnd_test", client)
41+ if err == nil || !strings.Contains(err.Error(), "rejected the key") {
42+ t.Fatalf("probeAPIKey error = %v", err)
43+ }
44+}
45+
46+type authRoundTripFunc func(*http.Request) (*http.Response, error)
47+
48+func (f authRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
49+ return f(req)
50+}
51+
2352 func TestStoreRoundTrip(t *testing.T) {
2453 dir := t.TempDir()
2554 s := NewStore(filepath.Join(dir, "auth.json"))
@@ -2,6 +2,7 @@ package auth
2 2
3 import (3 import (
4 "context"4 "context"
5+ "io"
5 "net"6 "net"
6 "net/http"7 "net/http"
7 "net/url"8 "net/url"
@@ -20,6 +21,34 @@ func TestProbeAPIKeyAcceptsExtraProviderWithoutProbe(t *testing.T) {
20 }21 }
21 }22 }
22 23
24+func TestProbeGondolaAPIKeyUsesAuthenticatedBalanceEndpoint(t *testing.T) {
25+ client := &http.Client{Transport: authRoundTripFunc(func(req *http.Request) (*http.Response, error) {
26+ if req.URL.String() != "https://api.gondola-ai.com/v1/balance" {
27+ t.Fatalf("probe URL = %q", req.URL.String())
28+ }
29+ if got := req.Header.Get("authorization"); got != "Bearer gnd_test" {
30+ t.Fatalf("authorization = %q", got)
31+ }
32+ return &http.Response{
33+ StatusCode: http.StatusUnauthorized,
34+ Body: io.NopCloser(strings.NewReader(`{"error":"invalid key"}`)),
35+ Header: make(http.Header),
36+ Request: req,
37+ }, nil
38+ })}
39+
40+ err := probeAPIKey(context.Background(), "gondola", "gnd_test", client)
41+ if err == nil || !strings.Contains(err.Error(), "rejected the key") {
42+ t.Fatalf("probeAPIKey error = %v", err)
43+ }
44+}
45+
46+type authRoundTripFunc func(*http.Request) (*http.Response, error)
47+
48+func (f authRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
49+ return f(req)
50+}
51+
23 func TestStoreRoundTrip(t *testing.T) {52 func TestStoreRoundTrip(t *testing.T) {
24 dir := t.TempDir()53 dir := t.TempDir()
25 s := NewStore(filepath.Join(dir, "auth.json"))54 s := NewStore(filepath.Join(dir, "auth.json"))
M packages/provider/auth/probe.go +10 -1
@@ -12,10 +12,13 @@ import (
1212 // ProbeAPIKey verifies that key is valid for provider by making a
1313 // lightweight authenticated request. Returns nil on success.
1414 func ProbeAPIKey(ctx context.Context, provider, key string) error {
15+ return probeAPIKey(ctx, provider, key, &http.Client{Timeout: 15 * time.Second})
16+}
17+
18+func probeAPIKey(ctx context.Context, provider, key string, c *http.Client) error {
1519 if key == "" {
1620 return fmt.Errorf("empty key")
1721 }
18- c := &http.Client{Timeout: 15 * time.Second}
1922 var req *http.Request
2023 var err error
2124
@@ -99,6 +102,12 @@ func ProbeAPIKey(ctx context.Context, provider, key string) error {
99102 return err
100103 }
101104 req.Header.Set("authorization", "Bearer "+key)
105+ case "gondola":
106+ req, err = http.NewRequestWithContext(ctx, "GET", "https://api.gondola-ai.com/v1/balance", nil)
107+ if err != nil {
108+ return err
109+ }
110+ req.Header.Set("authorization", "Bearer "+key)
102111 case "huggingface":
103112 req, err = http.NewRequestWithContext(ctx, "GET", "https://router.huggingface.co/v1/models", nil)
104113 if err != nil {
@@ -12,10 +12,13 @@ import (
12 // ProbeAPIKey verifies that key is valid for provider by making a12 // ProbeAPIKey verifies that key is valid for provider by making a
13 // lightweight authenticated request. Returns nil on success.13 // lightweight authenticated request. Returns nil on success.
14 func ProbeAPIKey(ctx context.Context, provider, key string) error {14 func ProbeAPIKey(ctx context.Context, provider, key string) error {
15+ return probeAPIKey(ctx, provider, key, &http.Client{Timeout: 15 * time.Second})
16+}
17+
18+func probeAPIKey(ctx context.Context, provider, key string, c *http.Client) error {
15 if key == "" {19 if key == "" {
16 return fmt.Errorf("empty key")20 return fmt.Errorf("empty key")
17 }21 }
18- c := &http.Client{Timeout: 15 * time.Second}
19 var req *http.Request22 var req *http.Request
20 var err error23 var err error
21 24
@@ -99,6 +102,12 @@ func ProbeAPIKey(ctx context.Context, provider, key string) error {
99 return err102 return err
100 }103 }
101 req.Header.Set("authorization", "Bearer "+key)104 req.Header.Set("authorization", "Bearer "+key)
105+ case "gondola":
106+ req, err = http.NewRequestWithContext(ctx, "GET", "https://api.gondola-ai.com/v1/balance", nil)
107+ if err != nil {
108+ return err
109+ }
110+ req.Header.Set("authorization", "Bearer "+key)
102 case "huggingface":111 case "huggingface":
103 req, err = http.NewRequestWithContext(ctx, "GET", "https://router.huggingface.co/v1/models", nil)112 req, err = http.NewRequestWithContext(ctx, "GET", "https://router.huggingface.co/v1/models", nil)
104 if err != nil {113 if err != nil {
M packages/provider/auth/server.go +1 -1
@@ -138,7 +138,7 @@ func APIKeyProviders() []string {
138138 return []string{
139139 "anthropic", "openai", "kimi", "deepseek", "google", "llama.cpp",
140140 "moonshotai", "moonshotai-cn", "groq", "cerebras", "xai", "together",
141- "huggingface", "openrouter", "mistral", "zai",
141+ "huggingface", "openrouter", "gondola", "mistral", "zai",
142142 "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp",
143143 "minimax", "minimax-cn", "fireworks", "vercel-ai-gateway",
144144 "opencode", "opencode-go", "amazon-bedrock", "google-vertex", "azure-openai-responses",
@@ -138,7 +138,7 @@ func APIKeyProviders() []string {
138 return []string{138 return []string{
139 "anthropic", "openai", "kimi", "deepseek", "google", "llama.cpp",139 "anthropic", "openai", "kimi", "deepseek", "google", "llama.cpp",
140 "moonshotai", "moonshotai-cn", "groq", "cerebras", "xai", "together",140 "moonshotai", "moonshotai-cn", "groq", "cerebras", "xai", "together",
141- "huggingface", "openrouter", "mistral", "zai",141+ "huggingface", "openrouter", "gondola", "mistral", "zai",
142 "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp",142 "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp",
143 "minimax", "minimax-cn", "fireworks", "vercel-ai-gateway",143 "minimax", "minimax-cn", "fireworks", "vercel-ai-gateway",
144 "opencode", "opencode-go", "amazon-bedrock", "google-vertex", "azure-openai-responses",144 "opencode", "opencode-go", "amazon-bedrock", "google-vertex", "azure-openai-responses",
M packages/provider/discover.go +74 -1
@@ -227,7 +227,10 @@ func looksLikeChatModel(id string) bool {
227227 return false
228228 }
229229
230-const openrouterDefaultBaseURL = "https://openrouter.ai/api/v1"
230+const (
231+ openrouterDefaultBaseURL = "https://openrouter.ai/api/v1"
232+ gondolaDefaultBaseURL = "https://api.gondola-ai.com/v1"
233+)
231234
232235 // DiscoverOpenRouter lists models from OpenRouter's public /models
233236 // endpoint (no auth). Per-token USD prices are converted to USD per 1M
@@ -320,6 +323,76 @@ func openrouterSupportsReasoning(params []string) bool {
320323 return false
321324 }
322325
326+// DiscoverGondola lists text models from Gondola's public catalog. Gondola
327+// reports prices directly in USD per 1M tokens, matching Model's units.
328+func DiscoverGondola(ctx context.Context, baseURL string) ([]Model, error) {
329+ if baseURL == "" {
330+ baseURL = gondolaDefaultBaseURL
331+ }
332+ baseURL = strings.TrimRight(baseURL, "/")
333+ client := &http.Client{Timeout: 15 * time.Second}
334+ req, err := http.NewRequestWithContext(ctx, "GET", baseURL+"/models", nil)
335+ if err != nil {
336+ return nil, err
337+ }
338+ resp, err := client.Do(req)
339+ if err != nil {
340+ return nil, err
341+ }
342+ defer resp.Body.Close()
343+ body, _ := io.ReadAll(resp.Body)
344+ if resp.StatusCode != http.StatusOK {
345+ return nil, fmt.Errorf("gondola discover http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
346+ }
347+ var page struct {
348+ Data []struct {
349+ ID string `json:"id"`
350+ DisplayName string `json:"display_name"`
351+ Modality string `json:"modality"`
352+ Endpoint string `json:"endpoint"`
353+ ContextLength int `json:"context_length"`
354+ MaxCompletionTokens int `json:"max_completion_tokens"`
355+ SupportsReasoning bool `json:"supports_reasoning"`
356+ Pricing struct {
357+ Input float64 `json:"input_usd_per_mtok"`
358+ Output float64 `json:"output_usd_per_mtok"`
359+ CacheRead float64 `json:"cached_input_usd_per_mtok"`
360+ CacheWrite float64 `json:"cache_write_usd_per_mtok"`
361+ } `json:"pricing"`
362+ } `json:"data"`
363+ }
364+ if err := json.Unmarshal(body, &page); err != nil {
365+ return nil, fmt.Errorf("gondola discover parse: %w", err)
366+ }
367+ out := make([]Model, 0, len(page.Data))
368+ for _, d := range page.Data {
369+ if d.ID == "" || d.Modality != "text" || d.Endpoint != "/v1/chat/completions" {
370+ continue
371+ }
372+ display := d.DisplayName
373+ if display == "" {
374+ display = d.ID
375+ }
376+ model := Model{ID: d.ID}
377+ out = append(out, Model{
378+ Provider: "gondola",
379+ ID: d.ID,
380+ DisplayName: display,
381+ ContextWindow: d.ContextLength,
382+ MaxOutput: d.MaxCompletionTokens,
383+ Reasoning: d.SupportsReasoning,
384+ AdaptiveThinking: usesAdaptiveThinking(model),
385+ PriceInput: d.Pricing.Input,
386+ PriceOutput: d.Pricing.Output,
387+ PriceCacheRead: d.Pricing.CacheRead,
388+ PriceCacheWrite: d.Pricing.CacheWrite,
389+ BaseURL: baseURL,
390+ Source: "live",
391+ })
392+ }
393+ return out, nil
394+}
395+
323396 // perMillionTokens converts a per-token USD price string to USD per 1M
324397 // tokens. Empty or unparseable values become 0.
325398 func perMillionTokens(s string) float64 {
@@ -227,7 +227,10 @@ func looksLikeChatModel(id string) bool {
227 return false227 return false
228 }228 }
229 229
230-const openrouterDefaultBaseURL = "https://openrouter.ai/api/v1"230+const (
231+ openrouterDefaultBaseURL = "https://openrouter.ai/api/v1"
232+ gondolaDefaultBaseURL = "https://api.gondola-ai.com/v1"
233+)
231 234
232 // DiscoverOpenRouter lists models from OpenRouter's public /models235 // DiscoverOpenRouter lists models from OpenRouter's public /models
233 // endpoint (no auth). Per-token USD prices are converted to USD per 1M236 // endpoint (no auth). Per-token USD prices are converted to USD per 1M
@@ -320,6 +323,76 @@ func openrouterSupportsReasoning(params []string) bool {
320 return false323 return false
321 }324 }
322 325
326+// DiscoverGondola lists text models from Gondola's public catalog. Gondola
327+// reports prices directly in USD per 1M tokens, matching Model's units.
328+func DiscoverGondola(ctx context.Context, baseURL string) ([]Model, error) {
329+ if baseURL == "" {
330+ baseURL = gondolaDefaultBaseURL
331+ }
332+ baseURL = strings.TrimRight(baseURL, "/")
333+ client := &http.Client{Timeout: 15 * time.Second}
334+ req, err := http.NewRequestWithContext(ctx, "GET", baseURL+"/models", nil)
335+ if err != nil {
336+ return nil, err
337+ }
338+ resp, err := client.Do(req)
339+ if err != nil {
340+ return nil, err
341+ }
342+ defer resp.Body.Close()
343+ body, _ := io.ReadAll(resp.Body)
344+ if resp.StatusCode != http.StatusOK {
345+ return nil, fmt.Errorf("gondola discover http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
346+ }
347+ var page struct {
348+ Data []struct {
349+ ID string `json:"id"`
350+ DisplayName string `json:"display_name"`
351+ Modality string `json:"modality"`
352+ Endpoint string `json:"endpoint"`
353+ ContextLength int `json:"context_length"`
354+ MaxCompletionTokens int `json:"max_completion_tokens"`
355+ SupportsReasoning bool `json:"supports_reasoning"`
356+ Pricing struct {
357+ Input float64 `json:"input_usd_per_mtok"`
358+ Output float64 `json:"output_usd_per_mtok"`
359+ CacheRead float64 `json:"cached_input_usd_per_mtok"`
360+ CacheWrite float64 `json:"cache_write_usd_per_mtok"`
361+ } `json:"pricing"`
362+ } `json:"data"`
363+ }
364+ if err := json.Unmarshal(body, &page); err != nil {
365+ return nil, fmt.Errorf("gondola discover parse: %w", err)
366+ }
367+ out := make([]Model, 0, len(page.Data))
368+ for _, d := range page.Data {
369+ if d.ID == "" || d.Modality != "text" || d.Endpoint != "/v1/chat/completions" {
370+ continue
371+ }
372+ display := d.DisplayName
373+ if display == "" {
374+ display = d.ID
375+ }
376+ model := Model{ID: d.ID}
377+ out = append(out, Model{
378+ Provider: "gondola",
379+ ID: d.ID,
380+ DisplayName: display,
381+ ContextWindow: d.ContextLength,
382+ MaxOutput: d.MaxCompletionTokens,
383+ Reasoning: d.SupportsReasoning,
384+ AdaptiveThinking: usesAdaptiveThinking(model),
385+ PriceInput: d.Pricing.Input,
386+ PriceOutput: d.Pricing.Output,
387+ PriceCacheRead: d.Pricing.CacheRead,
388+ PriceCacheWrite: d.Pricing.CacheWrite,
389+ BaseURL: baseURL,
390+ Source: "live",
391+ })
392+ }
393+ return out, nil
394+}
395+
323 // perMillionTokens converts a per-token USD price string to USD per 1M396 // perMillionTokens converts a per-token USD price string to USD per 1M
324 // tokens. Empty or unparseable values become 0.397 // tokens. Empty or unparseable values become 0.
325 func perMillionTokens(s string) float64 {398 func perMillionTokens(s string) float64 {
A packages/provider/discover_gondola_test.go +64 -0
@@ -0,0 +1,64 @@
1+package provider
2+
3+import (
4+ "context"
5+ "fmt"
6+ "net/http"
7+ "net/http/httptest"
8+ "testing"
9+)
10+
11+func TestDiscoverGondolaMapsTextModels(t *testing.T) {
12+ const body = `{"data":[
13+ {"id":"kimi-k3","display_name":"Kimi K3","modality":"text","endpoint":"/v1/chat/completions",
14+ "context_length":1000000,"max_completion_tokens":131072,"supports_reasoning":true,
15+ "pricing":{"input_usd_per_mtok":1.1475,"output_usd_per_mtok":5.7375,"cached_input_usd_per_mtok":0.11475}},
16+ {"id":"claude-opus-5","display_name":"Claude Opus 5","modality":"text","endpoint":"/v1/chat/completions",
17+ "context_length":1000000,"max_completion_tokens":128000,"supports_reasoning":true,
18+ "pricing":{"input_usd_per_mtok":1.836,"output_usd_per_mtok":9.18,"cached_input_usd_per_mtok":0.1836,"cache_write_usd_per_mtok":2.295}},
19+ {"id":"image","display_name":"Image","modality":"image","endpoint":"/v1/images/generations"},
20+ {"id":"wrong-endpoint","display_name":"Wrong endpoint","modality":"text","endpoint":"/v1/messages"},
21+ {"id":"","display_name":"Missing ID","modality":"text","endpoint":"/v1/chat/completions"}
22+ ]}`
23+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
24+ if r.URL.Path != "/models" {
25+ t.Fatalf("request path = %q", r.URL.Path)
26+ }
27+ w.Header().Set("content-type", "application/json")
28+ fmt.Fprint(w, body)
29+ }))
30+ defer srv.Close()
31+
32+ models, err := DiscoverGondola(context.Background(), srv.URL+"/")
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ if len(models) != 2 {
37+ t.Fatalf("models = %+v", models)
38+ }
39+
40+ kimi := models[0]
41+ if kimi.Provider != "gondola" || kimi.ID != "kimi-k3" || kimi.DisplayName != "Kimi K3" ||
42+ kimi.ContextWindow != 1000000 || kimi.MaxOutput != 131072 || !kimi.Reasoning || kimi.AdaptiveThinking ||
43+ kimi.PriceInput != 1.1475 || kimi.PriceOutput != 5.7375 || kimi.PriceCacheRead != 0.11475 ||
44+ kimi.BaseURL != srv.URL || kimi.Source != "live" {
45+ t.Fatalf("kimi model = %+v", kimi)
46+ }
47+
48+ opus := models[1]
49+ if opus.ID != "claude-opus-5" || !opus.AdaptiveThinking || opus.PriceCacheRead != 0.1836 || opus.PriceCacheWrite != 2.295 {
50+ t.Fatalf("opus model = %+v", opus)
51+ }
52+}
53+
54+func TestDiscoverGondolaReportsHTTPError(t *testing.T) {
55+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
56+ http.Error(w, "unavailable", http.StatusServiceUnavailable)
57+ }))
58+ defer srv.Close()
59+
60+ _, err := DiscoverGondola(context.Background(), srv.URL)
61+ if err == nil {
62+ t.Fatal("expected discovery error")
63+ }
64+}
@@ -0,0 +1,64 @@
1+package provider
2+
3+import (
4+ "context"
5+ "fmt"
6+ "net/http"
7+ "net/http/httptest"
8+ "testing"
9+)
10+
11+func TestDiscoverGondolaMapsTextModels(t *testing.T) {
12+ const body = `{"data":[
13+ {"id":"kimi-k3","display_name":"Kimi K3","modality":"text","endpoint":"/v1/chat/completions",
14+ "context_length":1000000,"max_completion_tokens":131072,"supports_reasoning":true,
15+ "pricing":{"input_usd_per_mtok":1.1475,"output_usd_per_mtok":5.7375,"cached_input_usd_per_mtok":0.11475}},
16+ {"id":"claude-opus-5","display_name":"Claude Opus 5","modality":"text","endpoint":"/v1/chat/completions",
17+ "context_length":1000000,"max_completion_tokens":128000,"supports_reasoning":true,
18+ "pricing":{"input_usd_per_mtok":1.836,"output_usd_per_mtok":9.18,"cached_input_usd_per_mtok":0.1836,"cache_write_usd_per_mtok":2.295}},
19+ {"id":"image","display_name":"Image","modality":"image","endpoint":"/v1/images/generations"},
20+ {"id":"wrong-endpoint","display_name":"Wrong endpoint","modality":"text","endpoint":"/v1/messages"},
21+ {"id":"","display_name":"Missing ID","modality":"text","endpoint":"/v1/chat/completions"}
22+ ]}`
23+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
24+ if r.URL.Path != "/models" {
25+ t.Fatalf("request path = %q", r.URL.Path)
26+ }
27+ w.Header().Set("content-type", "application/json")
28+ fmt.Fprint(w, body)
29+ }))
30+ defer srv.Close()
31+
32+ models, err := DiscoverGondola(context.Background(), srv.URL+"/")
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ if len(models) != 2 {
37+ t.Fatalf("models = %+v", models)
38+ }
39+
40+ kimi := models[0]
41+ if kimi.Provider != "gondola" || kimi.ID != "kimi-k3" || kimi.DisplayName != "Kimi K3" ||
42+ kimi.ContextWindow != 1000000 || kimi.MaxOutput != 131072 || !kimi.Reasoning || kimi.AdaptiveThinking ||
43+ kimi.PriceInput != 1.1475 || kimi.PriceOutput != 5.7375 || kimi.PriceCacheRead != 0.11475 ||
44+ kimi.BaseURL != srv.URL || kimi.Source != "live" {
45+ t.Fatalf("kimi model = %+v", kimi)
46+ }
47+
48+ opus := models[1]
49+ if opus.ID != "claude-opus-5" || !opus.AdaptiveThinking || opus.PriceCacheRead != 0.1836 || opus.PriceCacheWrite != 2.295 {
50+ t.Fatalf("opus model = %+v", opus)
51+ }
52+}
53+
54+func TestDiscoverGondolaReportsHTTPError(t *testing.T) {
55+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
56+ http.Error(w, "unavailable", http.StatusServiceUnavailable)
57+ }))
58+ defer srv.Close()
59+
60+ _, err := DiscoverGondola(context.Background(), srv.URL)
61+ if err == nil {
62+ t.Fatal("expected discovery error")
63+ }
64+}
M packages/provider/extra_providers.go +6 -0
@@ -116,6 +116,12 @@ func NewOpenRouter(apiKey, baseURL string) Client {
116116 return NewOpenAICompat("openrouter", apiKey, baseURL, openrouterDefaultBaseURL)
117117 }
118118
119+// NewGondola: Gondola, a USDC-paid marketplace gateway for Venice AI
120+// inference. OpenAI Chat Completions-compatible.
121+func NewGondola(apiKey, baseURL string) Client {
122+ return NewOpenAICompat("gondola", apiKey, baseURL, gondolaDefaultBaseURL)
123+}
124+
119125 // NewOpenCode is the opencode.ai Zen endpoint. Mixed APIs upstream; this
120126 // constructor wires the openai-completions flavor only. Models that need
121127 // the anthropic-messages flavor under the same provider should be built
@@ -116,6 +116,12 @@ func NewOpenRouter(apiKey, baseURL string) Client {
116 return NewOpenAICompat("openrouter", apiKey, baseURL, openrouterDefaultBaseURL)116 return NewOpenAICompat("openrouter", apiKey, baseURL, openrouterDefaultBaseURL)
117 }117 }
118 118
119+// NewGondola: Gondola, a USDC-paid marketplace gateway for Venice AI
120+// inference. OpenAI Chat Completions-compatible.
121+func NewGondola(apiKey, baseURL string) Client {
122+ return NewOpenAICompat("gondola", apiKey, baseURL, gondolaDefaultBaseURL)
123+}
124+
119 // NewOpenCode is the opencode.ai Zen endpoint. Mixed APIs upstream; this125 // NewOpenCode is the opencode.ai Zen endpoint. Mixed APIs upstream; this
120 // constructor wires the openai-completions flavor only. Models that need126 // constructor wires the openai-completions flavor only. Models that need
121 // the anthropic-messages flavor under the same provider should be built127 // the anthropic-messages flavor under the same provider should be built
M packages/provider/labels.go +2 -0
@@ -37,6 +37,8 @@ func ProviderLabel(id string) string {
3737 return "Hugging Face"
3838 case "openrouter":
3939 return "OpenRouter"
40+ case "gondola":
41+ return "Gondola"
4042 case "mistral":
4143 return "Mistral"
4244 case "zai":
@@ -37,6 +37,8 @@ func ProviderLabel(id string) string {
37 return "Hugging Face"37 return "Hugging Face"
38 case "openrouter":38 case "openrouter":
39 return "OpenRouter"39 return "OpenRouter"
40+ case "gondola":
41+ return "Gondola"
40 case "mistral":42 case "mistral":
41 return "Mistral"43 return "Mistral"
42 case "zai":44 case "zai":
M packages/provider/models.go +18 -0
@@ -254,6 +254,24 @@ var Catalog = []Model{
254254 BaseURL: openrouterDefaultBaseURL,
255255 },
256256
257+ // ---- Gondola ----
258+ // Seed entries only: kimi-k3 is the default returned by
259+ // defaultModelForProvider so it resolves offline. Gondola is a USDC-paid
260+ // marketplace gateway for Venice AI inference; prices/limits pulled live
261+ // from https://api.gondola-ai.com/v1/models (2026-08-11).
262+ {
263+ Provider: "gondola", ID: "kimi-k3", DisplayName: "Kimi K3 (Gondola)",
264+ ContextWindow: 1000000, MaxOutput: 131072, Reasoning: true,
265+ PriceInput: 1.1475, PriceOutput: 5.7375, PriceCacheRead: 0.11475,
266+ BaseURL: gondolaDefaultBaseURL,
267+ },
268+ {
269+ Provider: "gondola", ID: "claude-opus-5", DisplayName: "Claude Opus 5 (Gondola)",
270+ ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true,
271+ PriceInput: 1.836, PriceOutput: 9.18, PriceCacheRead: 0.1836, PriceCacheWrite: 2.295,
272+ BaseURL: gondolaDefaultBaseURL,
273+ },
274+
257275 // ---- Speculative: Anthropic ----
258276 {
259277 Provider: "anthropic", ID: "claude-opus-4-5", DisplayName: "Claude Opus 4.5 (latest)",
@@ -254,6 +254,24 @@ var Catalog = []Model{
254 BaseURL: openrouterDefaultBaseURL,254 BaseURL: openrouterDefaultBaseURL,
255 },255 },
256 256
257+ // ---- Gondola ----
258+ // Seed entries only: kimi-k3 is the default returned by
259+ // defaultModelForProvider so it resolves offline. Gondola is a USDC-paid
260+ // marketplace gateway for Venice AI inference; prices/limits pulled live
261+ // from https://api.gondola-ai.com/v1/models (2026-08-11).
262+ {
263+ Provider: "gondola", ID: "kimi-k3", DisplayName: "Kimi K3 (Gondola)",
264+ ContextWindow: 1000000, MaxOutput: 131072, Reasoning: true,
265+ PriceInput: 1.1475, PriceOutput: 5.7375, PriceCacheRead: 0.11475,
266+ BaseURL: gondolaDefaultBaseURL,
267+ },
268+ {
269+ Provider: "gondola", ID: "claude-opus-5", DisplayName: "Claude Opus 5 (Gondola)",
270+ ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true,
271+ PriceInput: 1.836, PriceOutput: 9.18, PriceCacheRead: 0.1836, PriceCacheWrite: 2.295,
272+ BaseURL: gondolaDefaultBaseURL,
273+ },
274+
257 // ---- Speculative: Anthropic ----275 // ---- Speculative: Anthropic ----
258 {276 {
259 Provider: "anthropic", ID: "claude-opus-4-5", DisplayName: "Claude Opus 4.5 (latest)",277 Provider: "anthropic", ID: "claude-opus-4-5", DisplayName: "Claude Opus 4.5 (latest)",
M packages/provider/provider_test.go +21 -0
@@ -346,6 +346,27 @@ func TestClaudeSonnet5Catalog(t *testing.T) {
346346 }
347347 }
348348
349+func TestGondolaSeedCatalog(t *testing.T) {
350+ kimi, err := FindModel("gondola", "kimi-k3")
351+ if err != nil {
352+ t.Fatal(err)
353+ }
354+ if kimi.ContextWindow != 1000000 || kimi.MaxOutput != 131072 || !kimi.Reasoning || kimi.AdaptiveThinking {
355+ t.Fatalf("unexpected Kimi K3 model: %+v", kimi)
356+ }
357+ if kimi.PriceInput != 1.1475 || kimi.PriceOutput != 5.7375 || kimi.PriceCacheRead != 0.11475 {
358+ t.Fatalf("unexpected Kimi K3 pricing: %+v", kimi)
359+ }
360+
361+ opus, err := FindModel("gondola", "claude-opus-5")
362+ if err != nil {
363+ t.Fatal(err)
364+ }
365+ if !opus.AdaptiveThinking || opus.PriceInput != 1.836 || opus.PriceOutput != 9.18 || opus.PriceCacheRead != 0.1836 || opus.PriceCacheWrite != 2.295 {
366+ t.Fatalf("unexpected Claude Opus 5 model: %+v", opus)
367+ }
368+}
369+
349370 func TestOpenAICompatAnthropicReasoningEffort(t *testing.T) {
350371 c := NewOpenRouter("token", "").(*openaiClient)
351372 wire, err := c.buildRequest(Request{
@@ -346,6 +346,27 @@ func TestClaudeSonnet5Catalog(t *testing.T) {
346 }346 }
347 }347 }
348 348
349+func TestGondolaSeedCatalog(t *testing.T) {
350+ kimi, err := FindModel("gondola", "kimi-k3")
351+ if err != nil {
352+ t.Fatal(err)
353+ }
354+ if kimi.ContextWindow != 1000000 || kimi.MaxOutput != 131072 || !kimi.Reasoning || kimi.AdaptiveThinking {
355+ t.Fatalf("unexpected Kimi K3 model: %+v", kimi)
356+ }
357+ if kimi.PriceInput != 1.1475 || kimi.PriceOutput != 5.7375 || kimi.PriceCacheRead != 0.11475 {
358+ t.Fatalf("unexpected Kimi K3 pricing: %+v", kimi)
359+ }
360+
361+ opus, err := FindModel("gondola", "claude-opus-5")
362+ if err != nil {
363+ t.Fatal(err)
364+ }
365+ if !opus.AdaptiveThinking || opus.PriceInput != 1.836 || opus.PriceOutput != 9.18 || opus.PriceCacheRead != 0.1836 || opus.PriceCacheWrite != 2.295 {
366+ t.Fatalf("unexpected Claude Opus 5 model: %+v", opus)
367+ }
368+}
369+
349 func TestOpenAICompatAnthropicReasoningEffort(t *testing.T) {370 func TestOpenAICompatAnthropicReasoningEffort(t *testing.T) {
350 c := NewOpenRouter("token", "").(*openaiClient)371 c := NewOpenRouter("token", "").(*openaiClient)
351 wire, err := c.buildRequest(Request{372 wire, err := c.buildRequest(Request{

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close