Fix Gondola authentication and model discovery
- commit
- 47675d41a4dac4923ed4a1098ef5ef5c9829d23d
- author
- patriceckhart · · 2026-08-11 14:19 UTC
- parent
- a93091b
- signature
- unsigned
9 files · +209 -9
M docs/providers.md +3 -0
| @@ -130,6 +130,9 @@ show instructions and should be configured with environment variables. | |||
| 130 | 130 | | Cloudflare AI Gateway | `CLOUDFLARE_API_KEY` | `cloudflare-ai-gateway` | | |
| 131 | 131 | | Azure OpenAI Responses | `AZURE_OPENAI_API_KEY` | `azure-openai-responses` | | |
| 132 | 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 | + | ||
| 133 | 136 | Example: | |
| 134 | 137 | ||
| 135 | 138 | ```bash | |
| @@ -130,6 +130,9 @@ show instructions and should be configured with environment variables. | |||||
| 130 | | Cloudflare AI Gateway | `CLOUDFLARE_API_KEY` | `cloudflare-ai-gateway` | | 130 | | Cloudflare AI Gateway | `CLOUDFLARE_API_KEY` | `cloudflare-ai-gateway` | | ||
| 131 | | Azure OpenAI Responses | `AZURE_OPENAI_API_KEY` | `azure-openai-responses` | | 131 | | Azure OpenAI Responses | `AZURE_OPENAI_API_KEY` | `azure-openai-responses` | | ||
| 132 | 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 | + | ||||
| 133 | Example: | 136 | Example: | ||
| 134 | 137 | ||||
| 135 | ```bash | 138 | ```bash | ||
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 +5 -2
| @@ -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 | ||
| @@ -100,7 +103,7 @@ func ProbeAPIKey(ctx context.Context, provider, key string) error { | |||
| 100 | 103 | } | |
| 101 | 104 | req.Header.Set("authorization", "Bearer "+key) | |
| 102 | 105 | case "gondola": | |
| 103 | - | req, err = http.NewRequestWithContext(ctx, "GET", "https://api.gondola-ai.com/v1/models", nil) | |
| 106 | + | req, err = http.NewRequestWithContext(ctx, "GET", "https://api.gondola-ai.com/v1/balance", nil) | |
| 104 | 107 | if err != nil { | |
| 105 | 108 | return err | |
| 106 | 109 | } | |
| @@ -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 | ||||
| @@ -100,7 +103,7 @@ func ProbeAPIKey(ctx context.Context, provider, key string) error { | |||||
| 100 | } | 103 | } | ||
| 101 | req.Header.Set("authorization", "Bearer "+key) | 104 | req.Header.Set("authorization", "Bearer "+key) | ||
| 102 | case "gondola": | 105 | case "gondola": | ||
| 103 | - | req, err = http.NewRequestWithContext(ctx, "GET", "https://api.gondola-ai.com/v1/models", nil) | 106 | + | req, err = http.NewRequestWithContext(ctx, "GET", "https://api.gondola-ai.com/v1/balance", nil) |
| 104 | if err != nil { | 107 | if err != nil { | ||
| 105 | return err | 108 | return err | ||
| 106 | } | 109 | } | ||
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 +1 -1
| @@ -119,7 +119,7 @@ func NewOpenRouter(apiKey, baseURL string) Client { | |||
| 119 | 119 | // NewGondola: Gondola, a USDC-paid marketplace gateway for Venice AI | |
| 120 | 120 | // inference. OpenAI Chat Completions-compatible. | |
| 121 | 121 | func NewGondola(apiKey, baseURL string) Client { | |
| 122 | - | return NewOpenAICompat("gondola", apiKey, baseURL, "https://api.gondola-ai.com/v1") | |
| 122 | + | return NewOpenAICompat("gondola", apiKey, baseURL, gondolaDefaultBaseURL) | |
| 123 | 123 | } | |
| 124 | 124 | ||
| 125 | 125 | // NewOpenCode is the opencode.ai Zen endpoint. Mixed APIs upstream; this | |
| @@ -119,7 +119,7 @@ func NewOpenRouter(apiKey, baseURL string) Client { | |||||
| 119 | // NewGondola: Gondola, a USDC-paid marketplace gateway for Venice AI | 119 | // NewGondola: Gondola, a USDC-paid marketplace gateway for Venice AI | ||
| 120 | // inference. OpenAI Chat Completions-compatible. | 120 | // inference. OpenAI Chat Completions-compatible. | ||
| 121 | func NewGondola(apiKey, baseURL string) Client { | 121 | func NewGondola(apiKey, baseURL string) Client { | ||
| 122 | - | return NewOpenAICompat("gondola", apiKey, baseURL, "https://api.gondola-ai.com/v1") | 122 | + | return NewOpenAICompat("gondola", apiKey, baseURL, gondolaDefaultBaseURL) |
| 123 | } | 123 | } | ||
| 124 | 124 | ||||
| 125 | // NewOpenCode is the opencode.ai Zen endpoint. Mixed APIs upstream; this | 125 | // NewOpenCode is the opencode.ai Zen endpoint. Mixed APIs upstream; this | ||
M packages/provider/models.go +5 -5
| @@ -261,15 +261,15 @@ var Catalog = []Model{ | |||
| 261 | 261 | // from https://api.gondola-ai.com/v1/models (2026-08-11). | |
| 262 | 262 | { | |
| 263 | 263 | Provider: "gondola", ID: "kimi-k3", DisplayName: "Kimi K3 (Gondola)", | |
| 264 | - | ContextWindow: 1000000, MaxOutput: 131072, Reasoning: true, AdaptiveThinking: true, | |
| 265 | - | PriceInput: 1.1475, PriceOutput: 5.7375, | |
| 266 | - | BaseURL: "https://api.gondola-ai.com/v1", | |
| 264 | + | ContextWindow: 1000000, MaxOutput: 131072, Reasoning: true, | |
| 265 | + | PriceInput: 1.1475, PriceOutput: 5.7375, PriceCacheRead: 0.11475, | |
| 266 | + | BaseURL: gondolaDefaultBaseURL, | |
| 267 | 267 | }, | |
| 268 | 268 | { | |
| 269 | 269 | Provider: "gondola", ID: "claude-opus-5", DisplayName: "Claude Opus 5 (Gondola)", | |
| 270 | 270 | ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, | |
| 271 | - | PriceInput: 1.836, PriceOutput: 9.18, | |
| 272 | - | BaseURL: "https://api.gondola-ai.com/v1", | |
| 271 | + | PriceInput: 1.836, PriceOutput: 9.18, PriceCacheRead: 0.1836, PriceCacheWrite: 2.295, | |
| 272 | + | BaseURL: gondolaDefaultBaseURL, | |
| 273 | 273 | }, | |
| 274 | 274 | ||
| 275 | 275 | // ---- Speculative: Anthropic ---- | |
| @@ -261,15 +261,15 @@ var Catalog = []Model{ | |||||
| 261 | // from https://api.gondola-ai.com/v1/models (2026-08-11). | 261 | // from https://api.gondola-ai.com/v1/models (2026-08-11). | ||
| 262 | { | 262 | { | ||
| 263 | Provider: "gondola", ID: "kimi-k3", DisplayName: "Kimi K3 (Gondola)", | 263 | Provider: "gondola", ID: "kimi-k3", DisplayName: "Kimi K3 (Gondola)", | ||
| 264 | - | ContextWindow: 1000000, MaxOutput: 131072, Reasoning: true, AdaptiveThinking: true, | 264 | + | ContextWindow: 1000000, MaxOutput: 131072, Reasoning: true, |
| 265 | - | PriceInput: 1.1475, PriceOutput: 5.7375, | 265 | + | PriceInput: 1.1475, PriceOutput: 5.7375, PriceCacheRead: 0.11475, |
| 266 | - | BaseURL: "https://api.gondola-ai.com/v1", | 266 | + | BaseURL: gondolaDefaultBaseURL, |
| 267 | }, | 267 | }, | ||
| 268 | { | 268 | { | ||
| 269 | Provider: "gondola", ID: "claude-opus-5", DisplayName: "Claude Opus 5 (Gondola)", | 269 | Provider: "gondola", ID: "claude-opus-5", DisplayName: "Claude Opus 5 (Gondola)", | ||
| 270 | ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, | 270 | ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, AdaptiveThinking: true, | ||
| 271 | - | PriceInput: 1.836, PriceOutput: 9.18, | 271 | + | PriceInput: 1.836, PriceOutput: 9.18, PriceCacheRead: 0.1836, PriceCacheWrite: 2.295, |
| 272 | - | BaseURL: "https://api.gondola-ai.com/v1", | 272 | + | BaseURL: gondolaDefaultBaseURL, |
| 273 | }, | 273 | }, | ||
| 274 | 274 | ||||
| 275 | // ---- Speculative: Anthropic ---- | 275 | // ---- Speculative: Anthropic ---- | ||
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{ | ||