Merge pull request #142 from Codonyat/add-gondola-provider
Add Gondola provider
13 files · +247 -4
M docs/providers.md +4 -0
| @@ -112,6 +112,7 @@ show instructions and should be configured with environment variables. | |||
| 112 | 112 | | Together AI | `TOGETHER_API_KEY` | `together` | | |
| 113 | 113 | | Hugging Face | `HF_TOKEN` | `huggingface` | | |
| 114 | 114 | | OpenRouter | `OPENROUTER_API_KEY` | `openrouter` | | |
| 115 | + | | Gondola | `GONDOLA_API_KEY` | `gondola` | | |
| 115 | 116 | | Mistral | `MISTRAL_API_KEY` | `mistral` | | |
| 116 | 117 | | ZAI | `ZAI_API_KEY` | `zai` | | |
| 117 | 118 | | Xiaomi MiMo | `XIAOMI_API_KEY` | `xiaomi` | | |
| @@ -129,6 +130,9 @@ show instructions and should be configured with environment variables. | |||
| 129 | 130 | | Cloudflare AI Gateway | `CLOUDFLARE_API_KEY` | `cloudflare-ai-gateway` | | |
| 130 | 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 | 136 | Example: | |
| 133 | 137 | ||
| 134 | 138 | ```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 | ```bash | 138 | ```bash | ||
M packages/agent/build.go +7 -1
| @@ -185,6 +185,8 @@ func defaultModelForProvider(prov string) string { | |||
| 185 | 185 | return "moonshotai/Kimi-K2-Instruct" | |
| 186 | 186 | case "openrouter": | |
| 187 | 187 | return "anthropic/claude-sonnet-4.5" | |
| 188 | + | case "gondola": | |
| 189 | + | return "kimi-k3" | |
| 188 | 190 | case "mistral": | |
| 189 | 191 | return "mistral-large-latest" | |
| 190 | 192 | case "zai": | |
| @@ -226,7 +228,7 @@ func defaultModelForProvider(prov string) string { | |||
| 226 | 228 | var knownProviders = []string{ | |
| 227 | 229 | "anthropic", "openai", "openai-codex", "openai-responses", "kimi", "deepseek", "google", "ollama", provider.LlamaCPPProviderID, | |
| 228 | 230 | "moonshotai", "moonshotai-cn", | |
| 229 | - | "cerebras", "groq", "xai", "together", "huggingface", "openrouter", | |
| 231 | + | "cerebras", "groq", "xai", "together", "huggingface", "openrouter", "gondola", | |
| 230 | 232 | "mistral", "zai", | |
| 231 | 233 | "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp", | |
| 232 | 234 | "minimax", "minimax-cn", | |
| @@ -837,6 +839,8 @@ func (r Resolved) NewClient() provider.Client { | |||
| 837 | 839 | return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL)) | |
| 838 | 840 | case "openrouter": | |
| 839 | 841 | return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL)) | |
| 842 | + | case "gondola": | |
| 843 | + | return wrap(provider.NewGondola(r.Credential, r.BaseURL)) | |
| 840 | 844 | case "zai": | |
| 841 | 845 | return wrap(provider.NewZAI(r.Credential, r.BaseURL)) | |
| 842 | 846 | case "xiaomi": | |
| @@ -1079,6 +1083,8 @@ func envVarName(provider string) string { | |||
| 1079 | 1083 | return "HF" | |
| 1080 | 1084 | case "openrouter": | |
| 1081 | 1085 | return "OPENROUTER" | |
| 1086 | + | case "gondola": | |
| 1087 | + | return "GONDOLA" | |
| 1082 | 1088 | case "mistral": | |
| 1083 | 1089 | return "MISTRAL" | |
| 1084 | 1090 | 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 | |||
| 415 | 415 | if v := os.Getenv("OPENROUTER_API_KEY"); v != "" { | |
| 416 | 416 | 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 | 422 | case "mistral": | |
| 419 | 423 | if v := os.Getenv("MISTRAL_API_KEY"); v != "" { | |
| 420 | 424 | 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", "", nil | 416 | 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", "", nil | 424 | return v, "apikey", "", nil | ||
M packages/agent/modelsync.go +7 -0
| @@ -217,6 +217,13 @@ func refreshModels() { | |||
| 217 | 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 | 228 | if len(all) == 0 { | |
| 222 | 229 | 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 | return | 229 | return | ||
M packages/provider/auth/auth_test.go +29 -0
| @@ -2,6 +2,7 @@ package auth | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "io" | |
| 5 | 6 | "net" | |
| 6 | 7 | "net/http" | |
| 7 | 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 | 52 | func TestStoreRoundTrip(t *testing.T) { | |
| 24 | 53 | dir := t.TempDir() | |
| 25 | 54 | 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 ( | |||
| 12 | 12 | // ProbeAPIKey verifies that key is valid for provider by making a | |
| 13 | 13 | // lightweight authenticated request. Returns nil on success. | |
| 14 | 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 | 19 | if key == "" { | |
| 16 | 20 | return fmt.Errorf("empty key") | |
| 17 | 21 | } | |
| 18 | - | c := &http.Client{Timeout: 15 * time.Second} | |
| 19 | 22 | var req *http.Request | |
| 20 | 23 | var err error | |
| 21 | 24 | ||
| @@ -99,6 +102,12 @@ func ProbeAPIKey(ctx context.Context, provider, key string) error { | |||
| 99 | 102 | return err | |
| 100 | 103 | } | |
| 101 | 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 | 111 | case "huggingface": | |
| 103 | 112 | req, err = http.NewRequestWithContext(ctx, "GET", "https://router.huggingface.co/v1/models", nil) | |
| 104 | 113 | if err != nil { | |
| @@ -12,10 +12,13 @@ import ( | |||||
| 12 | // ProbeAPIKey verifies that key is valid for provider by making a | 12 | // 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.Request | 22 | var req *http.Request | ||
| 20 | var err error | 23 | var err error | ||
| 21 | 24 | ||||
| @@ -99,6 +102,12 @@ func ProbeAPIKey(ctx context.Context, provider, key string) error { | |||||
| 99 | return err | 102 | 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 { | |||
| 138 | 138 | return []string{ | |
| 139 | 139 | "anthropic", "openai", "kimi", "deepseek", "google", "llama.cpp", | |
| 140 | 140 | "moonshotai", "moonshotai-cn", "groq", "cerebras", "xai", "together", | |
| 141 | - | "huggingface", "openrouter", "mistral", "zai", | |
| 141 | + | "huggingface", "openrouter", "gondola", "mistral", "zai", | |
| 142 | 142 | "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp", | |
| 143 | 143 | "minimax", "minimax-cn", "fireworks", "vercel-ai-gateway", | |
| 144 | 144 | "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 { | |||
| 227 | 227 | 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 | 235 | // DiscoverOpenRouter lists models from OpenRouter's public /models | |
| 233 | 236 | // endpoint (no auth). Per-token USD prices are converted to USD per 1M | |
| @@ -320,6 +323,76 @@ func openrouterSupportsReasoning(params []string) bool { | |||
| 320 | 323 | 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 | 396 | // perMillionTokens converts a per-token USD price string to USD per 1M | |
| 324 | 397 | // tokens. Empty or unparseable values become 0. | |
| 325 | 398 | func perMillionTokens(s string) float64 { | |
| @@ -227,7 +227,10 @@ func looksLikeChatModel(id string) bool { | |||||
| 227 | return false | 227 | 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 /models | 235 | // DiscoverOpenRouter lists models from OpenRouter's public /models | ||
| 233 | // endpoint (no auth). Per-token USD prices are converted to USD per 1M | 236 | // endpoint (no auth). Per-token USD prices are converted to USD per 1M | ||
| @@ -320,6 +323,76 @@ func openrouterSupportsReasoning(params []string) bool { | |||||
| 320 | return false | 323 | 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 1M | 396 | // 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 { | |||
| 116 | 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 | 125 | // NewOpenCode is the opencode.ai Zen endpoint. Mixed APIs upstream; this | |
| 120 | 126 | // constructor wires the openai-completions flavor only. Models that need | |
| 121 | 127 | // 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; this | 125 | // NewOpenCode is the opencode.ai Zen endpoint. Mixed APIs upstream; this | ||
| 120 | // constructor wires the openai-completions flavor only. Models that need | 126 | // constructor wires the openai-completions flavor only. Models that need | ||
| 121 | // the anthropic-messages flavor under the same provider should be built | 127 | // 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 { | |||
| 37 | 37 | return "Hugging Face" | |
| 38 | 38 | case "openrouter": | |
| 39 | 39 | return "OpenRouter" | |
| 40 | + | case "gondola": | |
| 41 | + | return "Gondola" | |
| 40 | 42 | case "mistral": | |
| 41 | 43 | return "Mistral" | |
| 42 | 44 | 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{ | |||
| 254 | 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 | 275 | // ---- Speculative: Anthropic ---- | |
| 258 | 276 | { | |
| 259 | 277 | 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) { | |||
| 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 | 370 | func TestOpenAICompatAnthropicReasoningEffort(t *testing.T) { | |
| 350 | 371 | c := NewOpenRouter("token", "").(*openaiClient) | |
| 351 | 372 | 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{ | ||