Merge pull request #201 from harryvgiunta/yolo-auto-provider
feat: add Yolo-Auto provider
16 files · +475 -7
M README.md +1 -1
| @@ -18,7 +18,7 @@ | |||
| 18 | 18 | Yet another coding agent harness, lightweight and written (vibe-slopped) in go. | |
| 19 | 19 | ||
| 20 | 20 | - one static binary. | |
| 21 | - | - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models. | |
| 21 | + | - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Yolo-Auto, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models. | |
| 22 | 22 | - built-in tools (read, write, edit, bash, glob). | |
| 23 | 23 | - three run modes (interactive tui, print, json). | |
| 24 | 24 | - built-in telegram bot. | |
| @@ -18,7 +18,7 @@ | |||||
| 18 | Yet another coding agent harness, lightweight and written (vibe-slopped) in go. | 18 | Yet another coding agent harness, lightweight and written (vibe-slopped) in go. | ||
| 19 | 19 | ||||
| 20 | - one static binary. | 20 | - one static binary. | ||
| 21 | - | - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models. | 21 | + | - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Yolo-Auto, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models. |
| 22 | - built-in tools (read, write, edit, bash, glob). | 22 | - built-in tools (read, write, edit, bash, glob). | ||
| 23 | - three run modes (interactive tui, print, json). | 23 | - three run modes (interactive tui, print, json). | ||
| 24 | - built-in telegram bot. | 24 | - built-in telegram bot. | ||
M docs/providers.md +16 -0
| @@ -181,6 +181,7 @@ show instructions and should be configured with environment variables. | |||
| 181 | 181 | | Hugging Face | `HF_TOKEN` | `huggingface` | | |
| 182 | 182 | | OpenRouter | `OPENROUTER_API_KEY` | `openrouter` | | |
| 183 | 183 | | Gondola | `GONDOLA_API_KEY` | `gondola` | | |
| 184 | + | | Yolo-Auto | `YOLO_AUTO_API_KEY` | `yolo-auto` | | |
| 184 | 185 | | Mistral | `MISTRAL_API_KEY` | `mistral` | | |
| 185 | 186 | | ZAI | `ZAI_API_KEY` | `zai` | | |
| 186 | 187 | | Xiaomi MiMo | `XIAOMI_API_KEY` | `xiaomi` | | |
| @@ -201,6 +202,21 @@ show instructions and should be configured with environment variables. | |||
| 201 | 202 | When Gondola credentials are available, zot refreshes its public text-model | |
| 202 | 203 | catalog in the background and adds the discovered models to `/model`. | |
| 203 | 204 | ||
| 205 | + | Yolo-Auto is an OpenAI-compatible gateway with flat-rate plans, so no per-token | |
| 206 | + | prices are shown. The default model, `qwen3.8-flash`, is available on free and | |
| 207 | + | paid plans. The optional `yolo` route requires a paid plan. These models and | |
| 208 | + | `yolo-small` ship with conservative client limits. | |
| 209 | + | ||
| 210 | + | With `YOLO_AUTO_API_KEY` set or a key saved through `/login`, zot lists | |
| 211 | + | `/v1/models` in the background on each catalog refresh, independently of the | |
| 212 | + | six-hour public catalog cache. Discovered models use the key's plan-bounded | |
| 213 | + | context windows and advertised reasoning levels, replacing the seed metadata. | |
| 214 | + | The last discovered metadata is cached for startup resolution until the | |
| 215 | + | background refresh completes. If discovery fails, zot falls back to the seed | |
| 216 | + | models and conservative limits instead of retaining the previous key's live | |
| 217 | + | metadata. Create a key at https://yolo-auto.com and see | |
| 218 | + | https://yolo-auto.com/docs for the API reference. | |
| 219 | + | ||
| 204 | 220 | Example: | |
| 205 | 221 | ||
| 206 | 222 | ```bash | |
| @@ -181,6 +181,7 @@ show instructions and should be configured with environment variables. | |||||
| 181 | | Hugging Face | `HF_TOKEN` | `huggingface` | | 181 | | Hugging Face | `HF_TOKEN` | `huggingface` | | ||
| 182 | | OpenRouter | `OPENROUTER_API_KEY` | `openrouter` | | 182 | | OpenRouter | `OPENROUTER_API_KEY` | `openrouter` | | ||
| 183 | | Gondola | `GONDOLA_API_KEY` | `gondola` | | 183 | | Gondola | `GONDOLA_API_KEY` | `gondola` | | ||
| 184 | + | | Yolo-Auto | `YOLO_AUTO_API_KEY` | `yolo-auto` | | |||
| 184 | | Mistral | `MISTRAL_API_KEY` | `mistral` | | 185 | | Mistral | `MISTRAL_API_KEY` | `mistral` | | ||
| 185 | | ZAI | `ZAI_API_KEY` | `zai` | | 186 | | ZAI | `ZAI_API_KEY` | `zai` | | ||
| 186 | | Xiaomi MiMo | `XIAOMI_API_KEY` | `xiaomi` | | 187 | | Xiaomi MiMo | `XIAOMI_API_KEY` | `xiaomi` | | ||
| @@ -201,6 +202,21 @@ show instructions and should be configured with environment variables. | |||||
| 201 | When Gondola credentials are available, zot refreshes its public text-model | 202 | When Gondola credentials are available, zot refreshes its public text-model | ||
| 202 | catalog in the background and adds the discovered models to `/model`. | 203 | catalog in the background and adds the discovered models to `/model`. | ||
| 203 | 204 | ||||
| 205 | + | Yolo-Auto is an OpenAI-compatible gateway with flat-rate plans, so no per-token | |||
| 206 | + | prices are shown. The default model, `qwen3.8-flash`, is available on free and | |||
| 207 | + | paid plans. The optional `yolo` route requires a paid plan. These models and | |||
| 208 | + | `yolo-small` ship with conservative client limits. | |||
| 209 | + | ||||
| 210 | + | With `YOLO_AUTO_API_KEY` set or a key saved through `/login`, zot lists | |||
| 211 | + | `/v1/models` in the background on each catalog refresh, independently of the | |||
| 212 | + | six-hour public catalog cache. Discovered models use the key's plan-bounded | |||
| 213 | + | context windows and advertised reasoning levels, replacing the seed metadata. | |||
| 214 | + | The last discovered metadata is cached for startup resolution until the | |||
| 215 | + | background refresh completes. If discovery fails, zot falls back to the seed | |||
| 216 | + | models and conservative limits instead of retaining the previous key's live | |||
| 217 | + | metadata. Create a key at https://yolo-auto.com and see | |||
| 218 | + | https://yolo-auto.com/docs for the API reference. | |||
| 219 | + | ||||
| 204 | Example: | 220 | Example: | ||
| 205 | 221 | ||||
| 206 | ```bash | 222 | ```bash | ||
M packages/agent/build.go +7 -1
| @@ -193,6 +193,8 @@ func defaultModelForProvider(prov string) string { | |||
| 193 | 193 | return "anthropic/claude-sonnet-4.5" | |
| 194 | 194 | case "gondola": | |
| 195 | 195 | return "kimi-k3" | |
| 196 | + | case "yolo-auto": | |
| 197 | + | return "qwen3.8-flash" | |
| 196 | 198 | case "mistral": | |
| 197 | 199 | return "mistral-large-latest" | |
| 198 | 200 | case "zai": | |
| @@ -234,7 +236,7 @@ func defaultModelForProvider(prov string) string { | |||
| 234 | 236 | var knownProviders = []string{ | |
| 235 | 237 | "anthropic", "openai", "openai-codex", "openai-responses", "kimi", "deepseek", "google", "ollama", provider.LlamaCPPProviderID, provider.LMStudioProviderID, | |
| 236 | 238 | "moonshotai", "moonshotai-cn", | |
| 237 | - | "cerebras", "groq", "xai", "together", "huggingface", "openrouter", "gondola", | |
| 239 | + | "cerebras", "groq", "xai", "together", "huggingface", "openrouter", "gondola", "yolo-auto", | |
| 238 | 240 | "mistral", "zai", | |
| 239 | 241 | "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp", | |
| 240 | 242 | "minimax", "minimax-cn", | |
| @@ -900,6 +902,8 @@ func (r Resolved) NewClient() provider.Client { | |||
| 900 | 902 | return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL)) | |
| 901 | 903 | case "gondola": | |
| 902 | 904 | return wrap(provider.NewGondola(r.Credential, r.BaseURL)) | |
| 905 | + | case "yolo-auto": | |
| 906 | + | return wrap(provider.NewYoloAuto(r.Credential, r.BaseURL)) | |
| 903 | 907 | case "zai": | |
| 904 | 908 | return wrap(provider.NewZAI(r.Credential, r.BaseURL)) | |
| 905 | 909 | case "xiaomi": | |
| @@ -1209,6 +1213,8 @@ func envVarName(provider string) string { | |||
| 1209 | 1213 | return "OPENROUTER" | |
| 1210 | 1214 | case "gondola": | |
| 1211 | 1215 | return "GONDOLA" | |
| 1216 | + | case "yolo-auto": | |
| 1217 | + | return "YOLO_AUTO" | |
| 1212 | 1218 | case "mistral": | |
| 1213 | 1219 | return "MISTRAL" | |
| 1214 | 1220 | case "zai": | |
| @@ -193,6 +193,8 @@ func defaultModelForProvider(prov string) string { | |||||
| 193 | return "anthropic/claude-sonnet-4.5" | 193 | return "anthropic/claude-sonnet-4.5" | ||
| 194 | case "gondola": | 194 | case "gondola": | ||
| 195 | return "kimi-k3" | 195 | return "kimi-k3" | ||
| 196 | + | case "yolo-auto": | |||
| 197 | + | return "qwen3.8-flash" | |||
| 196 | case "mistral": | 198 | case "mistral": | ||
| 197 | return "mistral-large-latest" | 199 | return "mistral-large-latest" | ||
| 198 | case "zai": | 200 | case "zai": | ||
| @@ -234,7 +236,7 @@ func defaultModelForProvider(prov string) string { | |||||
| 234 | var knownProviders = []string{ | 236 | var knownProviders = []string{ | ||
| 235 | "anthropic", "openai", "openai-codex", "openai-responses", "kimi", "deepseek", "google", "ollama", provider.LlamaCPPProviderID, provider.LMStudioProviderID, | 237 | "anthropic", "openai", "openai-codex", "openai-responses", "kimi", "deepseek", "google", "ollama", provider.LlamaCPPProviderID, provider.LMStudioProviderID, | ||
| 236 | "moonshotai", "moonshotai-cn", | 238 | "moonshotai", "moonshotai-cn", | ||
| 237 | - | "cerebras", "groq", "xai", "together", "huggingface", "openrouter", "gondola", | 239 | + | "cerebras", "groq", "xai", "together", "huggingface", "openrouter", "gondola", "yolo-auto", |
| 238 | "mistral", "zai", | 240 | "mistral", "zai", | ||
| 239 | "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp", | 241 | "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp", | ||
| 240 | "minimax", "minimax-cn", | 242 | "minimax", "minimax-cn", | ||
| @@ -900,6 +902,8 @@ func (r Resolved) NewClient() provider.Client { | |||||
| 900 | return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL)) | 902 | return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL)) | ||
| 901 | case "gondola": | 903 | case "gondola": | ||
| 902 | return wrap(provider.NewGondola(r.Credential, r.BaseURL)) | 904 | return wrap(provider.NewGondola(r.Credential, r.BaseURL)) | ||
| 905 | + | case "yolo-auto": | |||
| 906 | + | return wrap(provider.NewYoloAuto(r.Credential, r.BaseURL)) | |||
| 903 | case "zai": | 907 | case "zai": | ||
| 904 | return wrap(provider.NewZAI(r.Credential, r.BaseURL)) | 908 | return wrap(provider.NewZAI(r.Credential, r.BaseURL)) | ||
| 905 | case "xiaomi": | 909 | case "xiaomi": | ||
| @@ -1209,6 +1213,8 @@ func envVarName(provider string) string { | |||||
| 1209 | return "OPENROUTER" | 1213 | return "OPENROUTER" | ||
| 1210 | case "gondola": | 1214 | case "gondola": | ||
| 1211 | return "GONDOLA" | 1215 | return "GONDOLA" | ||
| 1216 | + | case "yolo-auto": | |||
| 1217 | + | return "YOLO_AUTO" | |||
| 1212 | case "mistral": | 1218 | case "mistral": | ||
| 1213 | return "MISTRAL" | 1219 | return "MISTRAL" | ||
| 1214 | case "zai": | 1220 | case "zai": | ||
M packages/agent/config.go +4 -0
| @@ -466,6 +466,10 @@ func resolveCredentialFull(ctx context.Context, provider, explicit string, comma | |||
| 466 | 466 | if v := os.Getenv("GONDOLA_API_KEY"); v != "" { | |
| 467 | 467 | return v, "apikey", "", nil | |
| 468 | 468 | } | |
| 469 | + | case "yolo-auto": | |
| 470 | + | if v := os.Getenv("YOLO_AUTO_API_KEY"); v != "" { | |
| 471 | + | return v, "apikey", "", nil | |
| 472 | + | } | |
| 469 | 473 | case "mistral": | |
| 470 | 474 | if v := os.Getenv("MISTRAL_API_KEY"); v != "" { | |
| 471 | 475 | return v, "apikey", "", nil | |
| @@ -466,6 +466,10 @@ func resolveCredentialFull(ctx context.Context, provider, explicit string, comma | |||||
| 466 | if v := os.Getenv("GONDOLA_API_KEY"); v != "" { | 466 | if v := os.Getenv("GONDOLA_API_KEY"); v != "" { | ||
| 467 | return v, "apikey", "", nil | 467 | return v, "apikey", "", nil | ||
| 468 | } | 468 | } | ||
| 469 | + | case "yolo-auto": | |||
| 470 | + | if v := os.Getenv("YOLO_AUTO_API_KEY"); v != "" { | |||
| 471 | + | return v, "apikey", "", nil | |||
| 472 | + | } | |||
| 469 | case "mistral": | 473 | case "mistral": | ||
| 470 | if v := os.Getenv("MISTRAL_API_KEY"); v != "" { | 474 | if v := os.Getenv("MISTRAL_API_KEY"); v != "" { | ||
| 471 | return v, "apikey", "", nil | 475 | return v, "apikey", "", nil | ||
M packages/agent/modelsync.go +28 -4
| @@ -206,9 +206,17 @@ func refreshModels() { | |||
| 206 | 206 | cached, _ := provider.LoadCache(ModelCachePath()) | |
| 207 | 207 | cacheFresh := cached.IsFresh() | |
| 208 | 208 | ||
| 209 | + | // Refresh per-key limits even while the public catalog cache is fresh. | |
| 210 | + | // Use a separate deadline so slow public discovery cannot starve this call. | |
| 211 | + | yoloCtx, yoloCancel := context.WithTimeout(context.Background(), 15*time.Second) | |
| 212 | + | var yoloModels []provider.Model | |
| 213 | + | if cred, _, err := resolveCredentialForBackground(yoloCtx, "yolo-auto"); err == nil { | |
| 214 | + | yoloModels, _ = provider.DiscoverYoloAuto(yoloCtx, cred, "") | |
| 215 | + | } | |
| 216 | + | yoloCancel() | |
| 217 | + | ||
| 209 | 218 | ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) | |
| 210 | 219 | defer cancel() | |
| 211 | - | ||
| 212 | 220 | var all []provider.Model | |
| 213 | 221 | var openrouterCred string | |
| 214 | 222 | var haveOpenRouter bool | |
| @@ -272,18 +280,34 @@ func refreshModels() { | |||
| 272 | 280 | } | |
| 273 | 281 | } | |
| 274 | 282 | ||
| 275 | - | if len(all) == 0 { | |
| 276 | - | return | |
| 277 | - | } | |
| 283 | + | // Do not carry forward another key's models or limits, even on failure. | |
| 284 | + | all = mergeYoloAutoModels(all, yoloModels) | |
| 278 | 285 | provider.SetLiveModels(all) | |
| 279 | 286 | // Keep explicit metadata overrides above refreshed server catalogs. | |
| 280 | 287 | LoadUserModels() | |
| 288 | + | if len(all) == 0 { | |
| 289 | + | // Clear old account entries without marking an empty catalog fresh. | |
| 290 | + | _ = provider.SaveCache(ModelCachePath(), provider.ModelCache{}) | |
| 291 | + | return | |
| 292 | + | } | |
| 281 | 293 | _ = provider.SaveCache(ModelCachePath(), provider.ModelCache{ | |
| 282 | 294 | FetchedAt: modelCacheFetchedAt(cached, !cacheFresh, time.Now().UTC()), | |
| 283 | 295 | Models: all, | |
| 284 | 296 | }) | |
| 285 | 297 | } | |
| 286 | 298 | ||
| 299 | + | // mergeYoloAutoModels replaces account metadata without disturbing the public | |
| 300 | + | // catalog. Cached entries aid offline resolution, but never suppress discovery. | |
| 301 | + | func mergeYoloAutoModels(existing, live []provider.Model) []provider.Model { | |
| 302 | + | out := make([]provider.Model, 0, len(existing)+len(live)) | |
| 303 | + | for _, model := range existing { | |
| 304 | + | if model.Provider != "yolo-auto" { | |
| 305 | + | out = append(out, model) | |
| 306 | + | } | |
| 307 | + | } | |
| 308 | + | return append(out, live...) | |
| 309 | + | } | |
| 310 | + | ||
| 287 | 311 | func modelCacheFetchedAt(cached provider.ModelCache, catalogRefreshed bool, now time.Time) time.Time { | |
| 288 | 312 | if catalogRefreshed { | |
| 289 | 313 | return now | |
| @@ -206,9 +206,17 @@ func refreshModels() { | |||||
| 206 | cached, _ := provider.LoadCache(ModelCachePath()) | 206 | cached, _ := provider.LoadCache(ModelCachePath()) | ||
| 207 | cacheFresh := cached.IsFresh() | 207 | cacheFresh := cached.IsFresh() | ||
| 208 | 208 | ||||
| 209 | + | // Refresh per-key limits even while the public catalog cache is fresh. | |||
| 210 | + | // Use a separate deadline so slow public discovery cannot starve this call. | |||
| 211 | + | yoloCtx, yoloCancel := context.WithTimeout(context.Background(), 15*time.Second) | |||
| 212 | + | var yoloModels []provider.Model | |||
| 213 | + | if cred, _, err := resolveCredentialForBackground(yoloCtx, "yolo-auto"); err == nil { | |||
| 214 | + | yoloModels, _ = provider.DiscoverYoloAuto(yoloCtx, cred, "") | |||
| 215 | + | } | |||
| 216 | + | yoloCancel() | |||
| 217 | + | ||||
| 209 | ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) | 218 | ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) | ||
| 210 | defer cancel() | 219 | defer cancel() | ||
| 211 | - | ||||
| 212 | var all []provider.Model | 220 | var all []provider.Model | ||
| 213 | var openrouterCred string | 221 | var openrouterCred string | ||
| 214 | var haveOpenRouter bool | 222 | var haveOpenRouter bool | ||
| @@ -272,18 +280,34 @@ func refreshModels() { | |||||
| 272 | } | 280 | } | ||
| 273 | } | 281 | } | ||
| 274 | 282 | ||||
| 275 | - | if len(all) == 0 { | 283 | + | // Do not carry forward another key's models or limits, even on failure. |
| 276 | - | return | 284 | + | all = mergeYoloAutoModels(all, yoloModels) |
| 277 | - | } | |||
| 278 | provider.SetLiveModels(all) | 285 | provider.SetLiveModels(all) | ||
| 279 | // Keep explicit metadata overrides above refreshed server catalogs. | 286 | // Keep explicit metadata overrides above refreshed server catalogs. | ||
| 280 | LoadUserModels() | 287 | LoadUserModels() | ||
| 288 | + | if len(all) == 0 { | |||
| 289 | + | // Clear old account entries without marking an empty catalog fresh. | |||
| 290 | + | _ = provider.SaveCache(ModelCachePath(), provider.ModelCache{}) | |||
| 291 | + | return | |||
| 292 | + | } | |||
| 281 | _ = provider.SaveCache(ModelCachePath(), provider.ModelCache{ | 293 | _ = provider.SaveCache(ModelCachePath(), provider.ModelCache{ | ||
| 282 | FetchedAt: modelCacheFetchedAt(cached, !cacheFresh, time.Now().UTC()), | 294 | FetchedAt: modelCacheFetchedAt(cached, !cacheFresh, time.Now().UTC()), | ||
| 283 | Models: all, | 295 | Models: all, | ||
| 284 | }) | 296 | }) | ||
| 285 | } | 297 | } | ||
| 286 | 298 | ||||
| 299 | + | // mergeYoloAutoModels replaces account metadata without disturbing the public | |||
| 300 | + | // catalog. Cached entries aid offline resolution, but never suppress discovery. | |||
| 301 | + | func mergeYoloAutoModels(existing, live []provider.Model) []provider.Model { | |||
| 302 | + | out := make([]provider.Model, 0, len(existing)+len(live)) | |||
| 303 | + | for _, model := range existing { | |||
| 304 | + | if model.Provider != "yolo-auto" { | |||
| 305 | + | out = append(out, model) | |||
| 306 | + | } | |||
| 307 | + | } | |||
| 308 | + | return append(out, live...) | |||
| 309 | + | } | |||
| 310 | + | ||||
| 287 | func modelCacheFetchedAt(cached provider.ModelCache, catalogRefreshed bool, now time.Time) time.Time { | 311 | func modelCacheFetchedAt(cached provider.ModelCache, catalogRefreshed bool, now time.Time) time.Time { | ||
| 288 | if catalogRefreshed { | 312 | if catalogRefreshed { | ||
| 289 | return now | 313 | return now | ||
A packages/agent/yolo_auto_test.go +126 -0
| @@ -0,0 +1,126 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "io" | |
| 6 | + | "net/http" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | type yoloAutoTestTransport func(*http.Request) (*http.Response, error) | |
| 15 | + | ||
| 16 | + | func (f yoloAutoTestTransport) RoundTrip(r *http.Request) (*http.Response, error) { | |
| 17 | + | return f(r) | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func TestYoloAutoDefaultAvailableOffline(t *testing.T) { | |
| 21 | + | id := defaultModelForProvider("yolo-auto") | |
| 22 | + | if id != "qwen3.8-flash" { | |
| 23 | + | t.Fatalf("default = %q, want free-plan model", id) | |
| 24 | + | } | |
| 25 | + | if _, err := provider.FindModel("yolo-auto", id); err != nil { | |
| 26 | + | t.Fatal(err) | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestYoloAutoRefreshIgnoresSharedCache(t *testing.T) { | |
| 31 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |
| 32 | + | t.Setenv("OPENROUTER_API_KEY", "") | |
| 33 | + | t.Setenv("YOLO_AUTO_API_KEY", "test-pro") | |
| 34 | + | cachedAt := time.Now().UTC() | |
| 35 | + | if err := provider.SaveCache(ModelCachePath(), provider.ModelCache{ | |
| 36 | + | FetchedAt: cachedAt, | |
| 37 | + | Models: []provider.Model{ | |
| 38 | + | {Provider: "openai", ID: "cached-public-model"}, | |
| 39 | + | {Provider: "yolo-auto", ID: "old-account-model", ContextWindow: 262144}, | |
| 40 | + | }, | |
| 41 | + | }); err != nil { | |
| 42 | + | t.Fatal(err) | |
| 43 | + | } | |
| 44 | + | provider.SetLiveModels(nil) | |
| 45 | + | t.Cleanup(func() { provider.SetLiveModels(nil) }) | |
| 46 | + | LoadCachedModels() | |
| 47 | + | ||
| 48 | + | original := http.DefaultTransport | |
| 49 | + | t.Cleanup(func() { http.DefaultTransport = original }) | |
| 50 | + | calls := 0 | |
| 51 | + | http.DefaultTransport = yoloAutoTestTransport(func(r *http.Request) (*http.Response, error) { | |
| 52 | + | if r.URL.String() != "https://yolo-auto.com/v1/models" { | |
| 53 | + | return nil, fmt.Errorf("unexpected discovery request: %s", r.URL) | |
| 54 | + | } | |
| 55 | + | calls++ | |
| 56 | + | body := `{"data":[{"id":"qwen3.8-flash","context_length":262144,"thinking":["high"]},{"id":"pro-only"}]}` | |
| 57 | + | switch r.Header.Get("Authorization") { | |
| 58 | + | case "Bearer test-pro": | |
| 59 | + | case "Bearer test-free": | |
| 60 | + | body = `{"data":[{"id":"qwen3.8-flash","context_length":131072,"thinking":["high"]}]}` | |
| 61 | + | default: | |
| 62 | + | return nil, fmt.Errorf("unexpected credential") | |
| 63 | + | } | |
| 64 | + | return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil | |
| 65 | + | }) | |
| 66 | + | ||
| 67 | + | for _, tc := range []struct { | |
| 68 | + | key string | |
| 69 | + | window int | |
| 70 | + | }{ | |
| 71 | + | {"test-pro", 262144}, | |
| 72 | + | {"test-free", 131072}, | |
| 73 | + | } { | |
| 74 | + | t.Setenv("YOLO_AUTO_API_KEY", tc.key) | |
| 75 | + | refreshModels() | |
| 76 | + | model, err := provider.FindModel("yolo-auto", "qwen3.8-flash") | |
| 77 | + | if err != nil { | |
| 78 | + | t.Fatal(err) | |
| 79 | + | } | |
| 80 | + | if model.ContextWindow != tc.window { | |
| 81 | + | t.Errorf("context = %d, want %d", model.ContextWindow, tc.window) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | if calls != 2 { | |
| 85 | + | t.Errorf("discovery calls = %d, want 2 despite fresh cache", calls) | |
| 86 | + | } | |
| 87 | + | if _, err := provider.FindModel("yolo-auto", "pro-only"); err == nil { | |
| 88 | + | t.Error("previous account model survived refresh") | |
| 89 | + | } | |
| 90 | + | cached, err := provider.LoadCache(ModelCachePath()) | |
| 91 | + | if err != nil { | |
| 92 | + | t.Fatal(err) | |
| 93 | + | } | |
| 94 | + | if !cached.FetchedAt.Equal(cachedAt) { | |
| 95 | + | t.Error("account refresh extended public cache lifetime") | |
| 96 | + | } | |
| 97 | + | for _, model := range cached.Models { | |
| 98 | + | if model.Provider == "yolo-auto" && (model.ID != "qwen3.8-flash" || model.ContextWindow != 131072) { | |
| 99 | + | t.Errorf("previous account metadata survived in cache: %+v", model) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | if _, err := provider.FindModel("yolo-auto", "old-account-model"); err == nil { | |
| 103 | + | t.Error("previous cached account model survived refresh") | |
| 104 | + | } | |
| 105 | + | if _, err := provider.FindModel("openai", "cached-public-model"); err != nil { | |
| 106 | + | t.Error("unrelated cached model lost") | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // Failed discovery and logout must drop the previous account's metadata. | |
| 110 | + | for _, key := range []string{"test-invalid", ""} { | |
| 111 | + | t.Setenv("YOLO_AUTO_API_KEY", "test-pro") | |
| 112 | + | refreshModels() | |
| 113 | + | t.Setenv("YOLO_AUTO_API_KEY", key) | |
| 114 | + | refreshModels() | |
| 115 | + | model, err := provider.FindModel("yolo-auto", "qwen3.8-flash") | |
| 116 | + | if err != nil { | |
| 117 | + | t.Fatal(err) | |
| 118 | + | } | |
| 119 | + | if model.Source != "catalog" || len(model.ReasoningLevelMap) != 0 { | |
| 120 | + | t.Errorf("account metadata survived failed discovery or logout: %+v", model) | |
| 121 | + | } | |
| 122 | + | if _, err := provider.FindModel("yolo-auto", "pro-only"); err == nil { | |
| 123 | + | t.Error("previous account model survived failed discovery or logout") | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | } | |
| @@ -0,0 +1,126 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "io" | |||
| 6 | + | "net/http" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | type yoloAutoTestTransport func(*http.Request) (*http.Response, error) | |||
| 15 | + | ||||
| 16 | + | func (f yoloAutoTestTransport) RoundTrip(r *http.Request) (*http.Response, error) { | |||
| 17 | + | return f(r) | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func TestYoloAutoDefaultAvailableOffline(t *testing.T) { | |||
| 21 | + | id := defaultModelForProvider("yolo-auto") | |||
| 22 | + | if id != "qwen3.8-flash" { | |||
| 23 | + | t.Fatalf("default = %q, want free-plan model", id) | |||
| 24 | + | } | |||
| 25 | + | if _, err := provider.FindModel("yolo-auto", id); err != nil { | |||
| 26 | + | t.Fatal(err) | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestYoloAutoRefreshIgnoresSharedCache(t *testing.T) { | |||
| 31 | + | t.Setenv("ZOT_HOME", t.TempDir()) | |||
| 32 | + | t.Setenv("OPENROUTER_API_KEY", "") | |||
| 33 | + | t.Setenv("YOLO_AUTO_API_KEY", "test-pro") | |||
| 34 | + | cachedAt := time.Now().UTC() | |||
| 35 | + | if err := provider.SaveCache(ModelCachePath(), provider.ModelCache{ | |||
| 36 | + | FetchedAt: cachedAt, | |||
| 37 | + | Models: []provider.Model{ | |||
| 38 | + | {Provider: "openai", ID: "cached-public-model"}, | |||
| 39 | + | {Provider: "yolo-auto", ID: "old-account-model", ContextWindow: 262144}, | |||
| 40 | + | }, | |||
| 41 | + | }); err != nil { | |||
| 42 | + | t.Fatal(err) | |||
| 43 | + | } | |||
| 44 | + | provider.SetLiveModels(nil) | |||
| 45 | + | t.Cleanup(func() { provider.SetLiveModels(nil) }) | |||
| 46 | + | LoadCachedModels() | |||
| 47 | + | ||||
| 48 | + | original := http.DefaultTransport | |||
| 49 | + | t.Cleanup(func() { http.DefaultTransport = original }) | |||
| 50 | + | calls := 0 | |||
| 51 | + | http.DefaultTransport = yoloAutoTestTransport(func(r *http.Request) (*http.Response, error) { | |||
| 52 | + | if r.URL.String() != "https://yolo-auto.com/v1/models" { | |||
| 53 | + | return nil, fmt.Errorf("unexpected discovery request: %s", r.URL) | |||
| 54 | + | } | |||
| 55 | + | calls++ | |||
| 56 | + | body := `{"data":[{"id":"qwen3.8-flash","context_length":262144,"thinking":["high"]},{"id":"pro-only"}]}` | |||
| 57 | + | switch r.Header.Get("Authorization") { | |||
| 58 | + | case "Bearer test-pro": | |||
| 59 | + | case "Bearer test-free": | |||
| 60 | + | body = `{"data":[{"id":"qwen3.8-flash","context_length":131072,"thinking":["high"]}]}` | |||
| 61 | + | default: | |||
| 62 | + | return nil, fmt.Errorf("unexpected credential") | |||
| 63 | + | } | |||
| 64 | + | return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil | |||
| 65 | + | }) | |||
| 66 | + | ||||
| 67 | + | for _, tc := range []struct { | |||
| 68 | + | key string | |||
| 69 | + | window int | |||
| 70 | + | }{ | |||
| 71 | + | {"test-pro", 262144}, | |||
| 72 | + | {"test-free", 131072}, | |||
| 73 | + | } { | |||
| 74 | + | t.Setenv("YOLO_AUTO_API_KEY", tc.key) | |||
| 75 | + | refreshModels() | |||
| 76 | + | model, err := provider.FindModel("yolo-auto", "qwen3.8-flash") | |||
| 77 | + | if err != nil { | |||
| 78 | + | t.Fatal(err) | |||
| 79 | + | } | |||
| 80 | + | if model.ContextWindow != tc.window { | |||
| 81 | + | t.Errorf("context = %d, want %d", model.ContextWindow, tc.window) | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | if calls != 2 { | |||
| 85 | + | t.Errorf("discovery calls = %d, want 2 despite fresh cache", calls) | |||
| 86 | + | } | |||
| 87 | + | if _, err := provider.FindModel("yolo-auto", "pro-only"); err == nil { | |||
| 88 | + | t.Error("previous account model survived refresh") | |||
| 89 | + | } | |||
| 90 | + | cached, err := provider.LoadCache(ModelCachePath()) | |||
| 91 | + | if err != nil { | |||
| 92 | + | t.Fatal(err) | |||
| 93 | + | } | |||
| 94 | + | if !cached.FetchedAt.Equal(cachedAt) { | |||
| 95 | + | t.Error("account refresh extended public cache lifetime") | |||
| 96 | + | } | |||
| 97 | + | for _, model := range cached.Models { | |||
| 98 | + | if model.Provider == "yolo-auto" && (model.ID != "qwen3.8-flash" || model.ContextWindow != 131072) { | |||
| 99 | + | t.Errorf("previous account metadata survived in cache: %+v", model) | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | if _, err := provider.FindModel("yolo-auto", "old-account-model"); err == nil { | |||
| 103 | + | t.Error("previous cached account model survived refresh") | |||
| 104 | + | } | |||
| 105 | + | if _, err := provider.FindModel("openai", "cached-public-model"); err != nil { | |||
| 106 | + | t.Error("unrelated cached model lost") | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // Failed discovery and logout must drop the previous account's metadata. | |||
| 110 | + | for _, key := range []string{"test-invalid", ""} { | |||
| 111 | + | t.Setenv("YOLO_AUTO_API_KEY", "test-pro") | |||
| 112 | + | refreshModels() | |||
| 113 | + | t.Setenv("YOLO_AUTO_API_KEY", key) | |||
| 114 | + | refreshModels() | |||
| 115 | + | model, err := provider.FindModel("yolo-auto", "qwen3.8-flash") | |||
| 116 | + | if err != nil { | |||
| 117 | + | t.Fatal(err) | |||
| 118 | + | } | |||
| 119 | + | if model.Source != "catalog" || len(model.ReasoningLevelMap) != 0 { | |||
| 120 | + | t.Errorf("account metadata survived failed discovery or logout: %+v", model) | |||
| 121 | + | } | |||
| 122 | + | if _, err := provider.FindModel("yolo-auto", "pro-only"); err == nil { | |||
| 123 | + | t.Error("previous account model survived failed discovery or logout") | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | } | |||
M packages/provider/auth/probe.go +6 -0
| @@ -108,6 +108,12 @@ func probeAPIKey(ctx context.Context, provider, key string, c *http.Client) erro | |||
| 108 | 108 | return err | |
| 109 | 109 | } | |
| 110 | 110 | req.Header.Set("authorization", "Bearer "+key) | |
| 111 | + | case "yolo-auto": | |
| 112 | + | req, err = http.NewRequestWithContext(ctx, "GET", "https://yolo-auto.com/v1/models", nil) | |
| 113 | + | if err != nil { | |
| 114 | + | return err | |
| 115 | + | } | |
| 116 | + | req.Header.Set("authorization", "Bearer "+key) | |
| 111 | 117 | case "huggingface": | |
| 112 | 118 | req, err = http.NewRequestWithContext(ctx, "GET", "https://router.huggingface.co/v1/models", nil) | |
| 113 | 119 | if err != nil { | |
| @@ -108,6 +108,12 @@ func probeAPIKey(ctx context.Context, provider, key string, c *http.Client) erro | |||||
| 108 | return err | 108 | return err | ||
| 109 | } | 109 | } | ||
| 110 | req.Header.Set("authorization", "Bearer "+key) | 110 | req.Header.Set("authorization", "Bearer "+key) | ||
| 111 | + | case "yolo-auto": | |||
| 112 | + | req, err = http.NewRequestWithContext(ctx, "GET", "https://yolo-auto.com/v1/models", nil) | |||
| 113 | + | if err != nil { | |||
| 114 | + | return err | |||
| 115 | + | } | |||
| 116 | + | req.Header.Set("authorization", "Bearer "+key) | |||
| 111 | case "huggingface": | 117 | case "huggingface": | ||
| 112 | req, err = http.NewRequestWithContext(ctx, "GET", "https://router.huggingface.co/v1/models", nil) | 118 | req, err = http.NewRequestWithContext(ctx, "GET", "https://router.huggingface.co/v1/models", nil) | ||
| 113 | if err != nil { | 119 | 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", "lmstudio", | |
| 140 | 140 | "moonshotai", "moonshotai-cn", "groq", "cerebras", "xai", "together", | |
| 141 | - | "huggingface", "openrouter", "gondola", "mistral", "zai", | |
| 141 | + | "huggingface", "openrouter", "gondola", "yolo-auto", "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", "lmstudio", | 139 | "anthropic", "openai", "kimi", "deepseek", "google", "llama.cpp", "lmstudio", | ||
| 140 | "moonshotai", "moonshotai-cn", "groq", "cerebras", "xai", "together", | 140 | "moonshotai", "moonshotai-cn", "groq", "cerebras", "xai", "together", | ||
| 141 | - | "huggingface", "openrouter", "gondola", "mistral", "zai", | 141 | + | "huggingface", "openrouter", "gondola", "yolo-auto", "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 +89 -0
| @@ -230,6 +230,14 @@ func looksLikeChatModel(id string) bool { | |||
| 230 | 230 | const ( | |
| 231 | 231 | openrouterDefaultBaseURL = "https://openrouter.ai/api/v1" | |
| 232 | 232 | gondolaDefaultBaseURL = "https://api.gondola-ai.com/v1" | |
| 233 | + | yoloAutoDefaultBaseURL = "https://yolo-auto.com/v1" | |
| 234 | + | ||
| 235 | + | // Yolo-Auto's documented conservative client limits. The provider is | |
| 236 | + | // flat-rate with plan-bounded windows, so the window is a safe floor | |
| 237 | + | // rather than a ceiling; /v1/models reports the per-key window and | |
| 238 | + | // DiscoverYoloAuto prefers it. | |
| 239 | + | yoloAutoContextWindow = 131072 | |
| 240 | + | yoloAutoMaxOutput = 32768 | |
| 233 | 241 | ) | |
| 234 | 242 | ||
| 235 | 243 | // DiscoverOpenRouter lists models from OpenRouter's public /models | |
| @@ -458,6 +466,87 @@ func DiscoverGondola(ctx context.Context, baseURL string) ([]Model, error) { | |||
| 458 | 466 | return out, nil | |
| 459 | 467 | } | |
| 460 | 468 | ||
| 469 | + | // DiscoverYoloAuto lists the models a Yolo-Auto key can reach from the | |
| 470 | + | // provider's OpenAI-compatible /v1/models endpoint. Each entry reports the | |
| 471 | + | // plan-bounded context window and, when the alias supports them, the | |
| 472 | + | // thinking levels it accepts. | |
| 473 | + | func DiscoverYoloAuto(ctx context.Context, apiKey, baseURL string) ([]Model, error) { | |
| 474 | + | if baseURL == "" { | |
| 475 | + | baseURL = yoloAutoDefaultBaseURL | |
| 476 | + | } | |
| 477 | + | baseURL = strings.TrimRight(baseURL, "/") | |
| 478 | + | client := &http.Client{Timeout: 15 * time.Second} | |
| 479 | + | req, err := http.NewRequestWithContext(ctx, "GET", baseURL+"/models", nil) | |
| 480 | + | if err != nil { | |
| 481 | + | return nil, err | |
| 482 | + | } | |
| 483 | + | if apiKey != "" { | |
| 484 | + | req.Header.Set("authorization", "Bearer "+apiKey) | |
| 485 | + | } | |
| 486 | + | resp, err := client.Do(req) | |
| 487 | + | if err != nil { | |
| 488 | + | return nil, err | |
| 489 | + | } | |
| 490 | + | defer resp.Body.Close() | |
| 491 | + | body, _ := io.ReadAll(resp.Body) | |
| 492 | + | if resp.StatusCode != http.StatusOK { | |
| 493 | + | return nil, fmt.Errorf("yolo-auto discover http %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) | |
| 494 | + | } | |
| 495 | + | var page struct { | |
| 496 | + | Data []struct { | |
| 497 | + | ID string `json:"id"` | |
| 498 | + | ContextLength int `json:"context_length"` | |
| 499 | + | MaxModelLen int `json:"max_model_len"` | |
| 500 | + | Thinking []string `json:"thinking"` | |
| 501 | + | } `json:"data"` | |
| 502 | + | } | |
| 503 | + | if err := json.Unmarshal(body, &page); err != nil { | |
| 504 | + | return nil, fmt.Errorf("yolo-auto discover parse: %w", err) | |
| 505 | + | } | |
| 506 | + | out := make([]Model, 0, len(page.Data)) | |
| 507 | + | for _, d := range page.Data { | |
| 508 | + | id := strings.TrimSpace(d.ID) | |
| 509 | + | if id == "" || strings.Contains(strings.ToLower(id), "embed") { | |
| 510 | + | continue | |
| 511 | + | } | |
| 512 | + | contextWindow := d.ContextLength | |
| 513 | + | if contextWindow == 0 { | |
| 514 | + | contextWindow = d.MaxModelLen | |
| 515 | + | } | |
| 516 | + | if contextWindow == 0 { | |
| 517 | + | contextWindow = yoloAutoContextWindow | |
| 518 | + | } | |
| 519 | + | out = append(out, Model{ | |
| 520 | + | Provider: "yolo-auto", | |
| 521 | + | ID: id, | |
| 522 | + | DisplayName: id, | |
| 523 | + | ContextWindow: contextWindow, | |
| 524 | + | MaxOutput: yoloAutoMaxOutput, | |
| 525 | + | Reasoning: len(d.Thinking) > 0, | |
| 526 | + | ReasoningLevelMap: yoloAutoReasoningLevels(d.Thinking), | |
| 527 | + | BaseURL: baseURL, | |
| 528 | + | Source: "live", | |
| 529 | + | }) | |
| 530 | + | } | |
| 531 | + | return out, nil | |
| 532 | + | } | |
| 533 | + | ||
| 534 | + | // yoloAutoReasoningLevels restricts the generic chat-completions defaults to | |
| 535 | + | // the endpoint's advertised efforts, including minimal and xhigh when offered. | |
| 536 | + | func yoloAutoReasoningLevels(thinking []string) map[string]string { | |
| 537 | + | levels := make(map[string]string) | |
| 538 | + | for _, level := range reasoningLevelOrder[1:] { | |
| 539 | + | levels[level] = "" | |
| 540 | + | } | |
| 541 | + | for _, effort := range thinking { | |
| 542 | + | level := NormalizeReasoning(effort) | |
| 543 | + | if _, known := levels[level]; known { | |
| 544 | + | levels[level] = level | |
| 545 | + | } | |
| 546 | + | } | |
| 547 | + | return levels | |
| 548 | + | } | |
| 549 | + | ||
| 461 | 550 | // perMillionTokens converts a per-token USD price string to USD per 1M | |
| 462 | 551 | // tokens. Empty or unparseable values become 0. | |
| 463 | 552 | func perMillionTokens(s string) float64 { | |
| @@ -230,6 +230,14 @@ func looksLikeChatModel(id string) bool { | |||||
| 230 | const ( | 230 | const ( | ||
| 231 | openrouterDefaultBaseURL = "https://openrouter.ai/api/v1" | 231 | openrouterDefaultBaseURL = "https://openrouter.ai/api/v1" | ||
| 232 | gondolaDefaultBaseURL = "https://api.gondola-ai.com/v1" | 232 | gondolaDefaultBaseURL = "https://api.gondola-ai.com/v1" | ||
| 233 | + | yoloAutoDefaultBaseURL = "https://yolo-auto.com/v1" | |||
| 234 | + | ||||
| 235 | + | // Yolo-Auto's documented conservative client limits. The provider is | |||
| 236 | + | // flat-rate with plan-bounded windows, so the window is a safe floor | |||
| 237 | + | // rather than a ceiling; /v1/models reports the per-key window and | |||
| 238 | + | // DiscoverYoloAuto prefers it. | |||
| 239 | + | yoloAutoContextWindow = 131072 | |||
| 240 | + | yoloAutoMaxOutput = 32768 | |||
| 233 | ) | 241 | ) | ||
| 234 | 242 | ||||
| 235 | // DiscoverOpenRouter lists models from OpenRouter's public /models | 243 | // DiscoverOpenRouter lists models from OpenRouter's public /models | ||
| @@ -458,6 +466,87 @@ func DiscoverGondola(ctx context.Context, baseURL string) ([]Model, error) { | |||||
| 458 | return out, nil | 466 | return out, nil | ||
| 459 | } | 467 | } | ||
| 460 | 468 | ||||
| 469 | + | // DiscoverYoloAuto lists the models a Yolo-Auto key can reach from the | |||
| 470 | + | // provider's OpenAI-compatible /v1/models endpoint. Each entry reports the | |||
| 471 | + | // plan-bounded context window and, when the alias supports them, the | |||
| 472 | + | // thinking levels it accepts. | |||
| 473 | + | func DiscoverYoloAuto(ctx context.Context, apiKey, baseURL string) ([]Model, error) { | |||
| 474 | + | if baseURL == "" { | |||
| 475 | + | baseURL = yoloAutoDefaultBaseURL | |||
| 476 | + | } | |||
| 477 | + | baseURL = strings.TrimRight(baseURL, "/") | |||
| 478 | + | client := &http.Client{Timeout: 15 * time.Second} | |||
| 479 | + | req, err := http.NewRequestWithContext(ctx, "GET", baseURL+"/models", nil) | |||
| 480 | + | if err != nil { | |||
| 481 | + | return nil, err | |||
| 482 | + | } | |||
| 483 | + | if apiKey != "" { | |||
| 484 | + | req.Header.Set("authorization", "Bearer "+apiKey) | |||
| 485 | + | } | |||
| 486 | + | resp, err := client.Do(req) | |||
| 487 | + | if err != nil { | |||
| 488 | + | return nil, err | |||
| 489 | + | } | |||
| 490 | + | defer resp.Body.Close() | |||
| 491 | + | body, _ := io.ReadAll(resp.Body) | |||
| 492 | + | if resp.StatusCode != http.StatusOK { | |||
| 493 | + | return nil, fmt.Errorf("yolo-auto discover http %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) | |||
| 494 | + | } | |||
| 495 | + | var page struct { | |||
| 496 | + | Data []struct { | |||
| 497 | + | ID string `json:"id"` | |||
| 498 | + | ContextLength int `json:"context_length"` | |||
| 499 | + | MaxModelLen int `json:"max_model_len"` | |||
| 500 | + | Thinking []string `json:"thinking"` | |||
| 501 | + | } `json:"data"` | |||
| 502 | + | } | |||
| 503 | + | if err := json.Unmarshal(body, &page); err != nil { | |||
| 504 | + | return nil, fmt.Errorf("yolo-auto discover parse: %w", err) | |||
| 505 | + | } | |||
| 506 | + | out := make([]Model, 0, len(page.Data)) | |||
| 507 | + | for _, d := range page.Data { | |||
| 508 | + | id := strings.TrimSpace(d.ID) | |||
| 509 | + | if id == "" || strings.Contains(strings.ToLower(id), "embed") { | |||
| 510 | + | continue | |||
| 511 | + | } | |||
| 512 | + | contextWindow := d.ContextLength | |||
| 513 | + | if contextWindow == 0 { | |||
| 514 | + | contextWindow = d.MaxModelLen | |||
| 515 | + | } | |||
| 516 | + | if contextWindow == 0 { | |||
| 517 | + | contextWindow = yoloAutoContextWindow | |||
| 518 | + | } | |||
| 519 | + | out = append(out, Model{ | |||
| 520 | + | Provider: "yolo-auto", | |||
| 521 | + | ID: id, | |||
| 522 | + | DisplayName: id, | |||
| 523 | + | ContextWindow: contextWindow, | |||
| 524 | + | MaxOutput: yoloAutoMaxOutput, | |||
| 525 | + | Reasoning: len(d.Thinking) > 0, | |||
| 526 | + | ReasoningLevelMap: yoloAutoReasoningLevels(d.Thinking), | |||
| 527 | + | BaseURL: baseURL, | |||
| 528 | + | Source: "live", | |||
| 529 | + | }) | |||
| 530 | + | } | |||
| 531 | + | return out, nil | |||
| 532 | + | } | |||
| 533 | + | ||||
| 534 | + | // yoloAutoReasoningLevels restricts the generic chat-completions defaults to | |||
| 535 | + | // the endpoint's advertised efforts, including minimal and xhigh when offered. | |||
| 536 | + | func yoloAutoReasoningLevels(thinking []string) map[string]string { | |||
| 537 | + | levels := make(map[string]string) | |||
| 538 | + | for _, level := range reasoningLevelOrder[1:] { | |||
| 539 | + | levels[level] = "" | |||
| 540 | + | } | |||
| 541 | + | for _, effort := range thinking { | |||
| 542 | + | level := NormalizeReasoning(effort) | |||
| 543 | + | if _, known := levels[level]; known { | |||
| 544 | + | levels[level] = level | |||
| 545 | + | } | |||
| 546 | + | } | |||
| 547 | + | return levels | |||
| 548 | + | } | |||
| 549 | + | ||||
| 461 | // perMillionTokens converts a per-token USD price string to USD per 1M | 550 | // perMillionTokens converts a per-token USD price string to USD per 1M | ||
| 462 | // tokens. Empty or unparseable values become 0. | 551 | // tokens. Empty or unparseable values become 0. | ||
| 463 | func perMillionTokens(s string) float64 { | 552 | func perMillionTokens(s string) float64 { | ||
A packages/provider/discover_yolo_auto_test.go +125 -0
| @@ -0,0 +1,125 @@ | |||
| 1 | + | package provider | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "net/http" | |
| 7 | + | "net/http/httptest" | |
| 8 | + | "slices" | |
| 9 | + | "testing" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestDiscoverYoloAutoListsKeyModels(t *testing.T) { | |
| 13 | + | const body = `{"object":"list","data":[ | |
| 14 | + | {"id":"yolo","object":"model","context_length":262144,"max_model_len":262144, | |
| 15 | + | "thinking":["minimal","low","medium","high","xhigh"]}, | |
| 16 | + | {"id":"yolo-small","object":"model","context_length":262144,"max_model_len":262144, | |
| 17 | + | "thinking":null}, | |
| 18 | + | {"id":"text-embedding-3-small","object":"model"} | |
| 19 | + | ]}` | |
| 20 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 21 | + | if r.URL.Path != "/models" { | |
| 22 | + | t.Fatalf("request path = %q", r.URL.Path) | |
| 23 | + | } | |
| 24 | + | if got := r.Header.Get("authorization"); got != "Bearer yolo_test" { | |
| 25 | + | t.Fatalf("authorization = %q", got) | |
| 26 | + | } | |
| 27 | + | w.Header().Set("content-type", "application/json") | |
| 28 | + | fmt.Fprint(w, body) | |
| 29 | + | })) | |
| 30 | + | defer srv.Close() | |
| 31 | + | ||
| 32 | + | models, err := DiscoverYoloAuto(context.Background(), "yolo_test", 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 | + | yolo := models[0] | |
| 41 | + | if yolo.Provider != "yolo-auto" || yolo.ID != "yolo" || yolo.DisplayName != "yolo" || | |
| 42 | + | yolo.ContextWindow != 262144 || yolo.MaxOutput != yoloAutoMaxOutput || !yolo.Reasoning || | |
| 43 | + | yolo.BaseURL != srv.URL || yolo.Source != "live" { | |
| 44 | + | t.Fatalf("yolo model = %+v", yolo) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | small := models[1] | |
| 48 | + | if small.ID != "yolo-small" || small.ContextWindow != 262144 || small.Reasoning { | |
| 49 | + | t.Fatalf("second model = %+v", small) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestDiscoverYoloAutoFallsBackToDocumentedLimits(t *testing.T) { | |
| 54 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 55 | + | w.Header().Set("content-type", "application/json") | |
| 56 | + | fmt.Fprint(w, `{"data":[{"id":"yolo"}]}`) | |
| 57 | + | })) | |
| 58 | + | defer srv.Close() | |
| 59 | + | ||
| 60 | + | models, err := DiscoverYoloAuto(context.Background(), "yolo_test", srv.URL) | |
| 61 | + | if err != nil { | |
| 62 | + | t.Fatal(err) | |
| 63 | + | } | |
| 64 | + | if len(models) != 1 || models[0].ContextWindow != yoloAutoContextWindow { | |
| 65 | + | t.Fatalf("models = %+v", models) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func TestYoloAutoDiscoveryReachesActiveCatalogAndWire(t *testing.T) { | |
| 70 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 71 | + | fmt.Fprint(w, `{"data":[ | |
| 72 | + | {"id":"yolo","context_length":262144,"thinking":["minimal","low","medium","high","xhigh"]}, | |
| 73 | + | {"id":"qwen3.8-flash","context_length":65536,"thinking":["high"]}, | |
| 74 | + | {"id":"yolo-small","max_model_len":65536,"thinking":null} | |
| 75 | + | ]}`) | |
| 76 | + | })) | |
| 77 | + | defer srv.Close() | |
| 78 | + | models, err := DiscoverYoloAuto(context.Background(), "test", srv.URL) | |
| 79 | + | if err != nil { | |
| 80 | + | t.Fatal(err) | |
| 81 | + | } | |
| 82 | + | SetLiveModels(models) | |
| 83 | + | t.Cleanup(func() { SetLiveModels(nil) }) | |
| 84 | + | for _, discovered := range models { | |
| 85 | + | active, err := FindModel("yolo-auto", discovered.ID) | |
| 86 | + | if err != nil { | |
| 87 | + | t.Fatal(err) | |
| 88 | + | } | |
| 89 | + | if active.ContextWindow != discovered.ContextWindow || active.Reasoning != discovered.Reasoning || active.BaseURL != srv.URL { | |
| 90 | + | t.Errorf("active metadata does not match discovery: %+v", active) | |
| 91 | + | } | |
| 92 | + | if active.ID == "yolo" && !slices.Equal(AvailableReasoningLevels(active), []string{"", "minimum", "low", "medium", "high", "xhigh"}) { | |
| 93 | + | t.Errorf("available levels = %q", AvailableReasoningLevels(active)) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | client := NewYoloAuto("test", srv.URL).(*openaiClient) | |
| 97 | + | for _, tc := range []struct{ model, level, want string }{ | |
| 98 | + | {"yolo", "minimum", "minimal"}, | |
| 99 | + | {"yolo", "xhigh", "xhigh"}, | |
| 100 | + | {"yolo", "max", "xhigh"}, | |
| 101 | + | {"yolo", "off", ""}, | |
| 102 | + | {"qwen3.8-flash", "low", "high"}, | |
| 103 | + | {"yolo-small", "high", ""}, | |
| 104 | + | } { | |
| 105 | + | wire, err := client.buildRequest(Request{Model: tc.model, Reasoning: tc.level}) | |
| 106 | + | if err != nil { | |
| 107 | + | t.Fatal(err) | |
| 108 | + | } | |
| 109 | + | if wire.ReasoningEffort != tc.want { | |
| 110 | + | t.Errorf("%s/%s effort = %q, want %q", tc.model, tc.level, wire.ReasoningEffort, tc.want) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func TestDiscoverYoloAutoReportsHTTPError(t *testing.T) { | |
| 116 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |
| 117 | + | http.Error(w, "unauthorized", http.StatusUnauthorized) | |
| 118 | + | })) | |
| 119 | + | defer srv.Close() | |
| 120 | + | ||
| 121 | + | _, err := DiscoverYoloAuto(context.Background(), "yolo_test", srv.URL) | |
| 122 | + | if err == nil { | |
| 123 | + | t.Fatal("expected discovery error") | |
| 124 | + | } | |
| 125 | + | } | |
| @@ -0,0 +1,125 @@ | |||||
| 1 | + | package provider | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "net/http" | |||
| 7 | + | "net/http/httptest" | |||
| 8 | + | "slices" | |||
| 9 | + | "testing" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestDiscoverYoloAutoListsKeyModels(t *testing.T) { | |||
| 13 | + | const body = `{"object":"list","data":[ | |||
| 14 | + | {"id":"yolo","object":"model","context_length":262144,"max_model_len":262144, | |||
| 15 | + | "thinking":["minimal","low","medium","high","xhigh"]}, | |||
| 16 | + | {"id":"yolo-small","object":"model","context_length":262144,"max_model_len":262144, | |||
| 17 | + | "thinking":null}, | |||
| 18 | + | {"id":"text-embedding-3-small","object":"model"} | |||
| 19 | + | ]}` | |||
| 20 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 21 | + | if r.URL.Path != "/models" { | |||
| 22 | + | t.Fatalf("request path = %q", r.URL.Path) | |||
| 23 | + | } | |||
| 24 | + | if got := r.Header.Get("authorization"); got != "Bearer yolo_test" { | |||
| 25 | + | t.Fatalf("authorization = %q", got) | |||
| 26 | + | } | |||
| 27 | + | w.Header().Set("content-type", "application/json") | |||
| 28 | + | fmt.Fprint(w, body) | |||
| 29 | + | })) | |||
| 30 | + | defer srv.Close() | |||
| 31 | + | ||||
| 32 | + | models, err := DiscoverYoloAuto(context.Background(), "yolo_test", 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 | + | yolo := models[0] | |||
| 41 | + | if yolo.Provider != "yolo-auto" || yolo.ID != "yolo" || yolo.DisplayName != "yolo" || | |||
| 42 | + | yolo.ContextWindow != 262144 || yolo.MaxOutput != yoloAutoMaxOutput || !yolo.Reasoning || | |||
| 43 | + | yolo.BaseURL != srv.URL || yolo.Source != "live" { | |||
| 44 | + | t.Fatalf("yolo model = %+v", yolo) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | small := models[1] | |||
| 48 | + | if small.ID != "yolo-small" || small.ContextWindow != 262144 || small.Reasoning { | |||
| 49 | + | t.Fatalf("second model = %+v", small) | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestDiscoverYoloAutoFallsBackToDocumentedLimits(t *testing.T) { | |||
| 54 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 55 | + | w.Header().Set("content-type", "application/json") | |||
| 56 | + | fmt.Fprint(w, `{"data":[{"id":"yolo"}]}`) | |||
| 57 | + | })) | |||
| 58 | + | defer srv.Close() | |||
| 59 | + | ||||
| 60 | + | models, err := DiscoverYoloAuto(context.Background(), "yolo_test", srv.URL) | |||
| 61 | + | if err != nil { | |||
| 62 | + | t.Fatal(err) | |||
| 63 | + | } | |||
| 64 | + | if len(models) != 1 || models[0].ContextWindow != yoloAutoContextWindow { | |||
| 65 | + | t.Fatalf("models = %+v", models) | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func TestYoloAutoDiscoveryReachesActiveCatalogAndWire(t *testing.T) { | |||
| 70 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 71 | + | fmt.Fprint(w, `{"data":[ | |||
| 72 | + | {"id":"yolo","context_length":262144,"thinking":["minimal","low","medium","high","xhigh"]}, | |||
| 73 | + | {"id":"qwen3.8-flash","context_length":65536,"thinking":["high"]}, | |||
| 74 | + | {"id":"yolo-small","max_model_len":65536,"thinking":null} | |||
| 75 | + | ]}`) | |||
| 76 | + | })) | |||
| 77 | + | defer srv.Close() | |||
| 78 | + | models, err := DiscoverYoloAuto(context.Background(), "test", srv.URL) | |||
| 79 | + | if err != nil { | |||
| 80 | + | t.Fatal(err) | |||
| 81 | + | } | |||
| 82 | + | SetLiveModels(models) | |||
| 83 | + | t.Cleanup(func() { SetLiveModels(nil) }) | |||
| 84 | + | for _, discovered := range models { | |||
| 85 | + | active, err := FindModel("yolo-auto", discovered.ID) | |||
| 86 | + | if err != nil { | |||
| 87 | + | t.Fatal(err) | |||
| 88 | + | } | |||
| 89 | + | if active.ContextWindow != discovered.ContextWindow || active.Reasoning != discovered.Reasoning || active.BaseURL != srv.URL { | |||
| 90 | + | t.Errorf("active metadata does not match discovery: %+v", active) | |||
| 91 | + | } | |||
| 92 | + | if active.ID == "yolo" && !slices.Equal(AvailableReasoningLevels(active), []string{"", "minimum", "low", "medium", "high", "xhigh"}) { | |||
| 93 | + | t.Errorf("available levels = %q", AvailableReasoningLevels(active)) | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | client := NewYoloAuto("test", srv.URL).(*openaiClient) | |||
| 97 | + | for _, tc := range []struct{ model, level, want string }{ | |||
| 98 | + | {"yolo", "minimum", "minimal"}, | |||
| 99 | + | {"yolo", "xhigh", "xhigh"}, | |||
| 100 | + | {"yolo", "max", "xhigh"}, | |||
| 101 | + | {"yolo", "off", ""}, | |||
| 102 | + | {"qwen3.8-flash", "low", "high"}, | |||
| 103 | + | {"yolo-small", "high", ""}, | |||
| 104 | + | } { | |||
| 105 | + | wire, err := client.buildRequest(Request{Model: tc.model, Reasoning: tc.level}) | |||
| 106 | + | if err != nil { | |||
| 107 | + | t.Fatal(err) | |||
| 108 | + | } | |||
| 109 | + | if wire.ReasoningEffort != tc.want { | |||
| 110 | + | t.Errorf("%s/%s effort = %q, want %q", tc.model, tc.level, wire.ReasoningEffort, tc.want) | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func TestDiscoverYoloAutoReportsHTTPError(t *testing.T) { | |||
| 116 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { | |||
| 117 | + | http.Error(w, "unauthorized", http.StatusUnauthorized) | |||
| 118 | + | })) | |||
| 119 | + | defer srv.Close() | |||
| 120 | + | ||||
| 121 | + | _, err := DiscoverYoloAuto(context.Background(), "yolo_test", srv.URL) | |||
| 122 | + | if err == nil { | |||
| 123 | + | t.Fatal("expected discovery error") | |||
| 124 | + | } | |||
| 125 | + | } | |||
M packages/provider/extra_providers.go +6 -0
| @@ -122,6 +122,12 @@ func NewGondola(apiKey, baseURL string) Client { | |||
| 122 | 122 | return NewOpenAICompat("gondola", apiKey, baseURL, gondolaDefaultBaseURL) | |
| 123 | 123 | } | |
| 124 | 124 | ||
| 125 | + | // NewYoloAuto: Yolo-Auto, an OpenAI Chat Completions-compatible gateway | |
| 126 | + | // addressed by the `yolo` and `yolo-small` model aliases. | |
| 127 | + | func NewYoloAuto(apiKey, baseURL string) Client { | |
| 128 | + | return NewOpenAICompat("yolo-auto", apiKey, baseURL, yoloAutoDefaultBaseURL) | |
| 129 | + | } | |
| 130 | + | ||
| 125 | 131 | // NewOpenCode is the opencode.ai Zen endpoint. Mixed APIs upstream; this | |
| 126 | 132 | // constructor wires the openai-completions flavor only. Models that need | |
| 127 | 133 | // the anthropic-messages flavor under the same provider should be built | |
| @@ -122,6 +122,12 @@ func NewGondola(apiKey, baseURL string) Client { | |||||
| 122 | return NewOpenAICompat("gondola", apiKey, baseURL, gondolaDefaultBaseURL) | 122 | return NewOpenAICompat("gondola", apiKey, baseURL, gondolaDefaultBaseURL) | ||
| 123 | } | 123 | } | ||
| 124 | 124 | ||||
| 125 | + | // NewYoloAuto: Yolo-Auto, an OpenAI Chat Completions-compatible gateway | |||
| 126 | + | // addressed by the `yolo` and `yolo-small` model aliases. | |||
| 127 | + | func NewYoloAuto(apiKey, baseURL string) Client { | |||
| 128 | + | return NewOpenAICompat("yolo-auto", apiKey, baseURL, yoloAutoDefaultBaseURL) | |||
| 129 | + | } | |||
| 130 | + | ||||
| 125 | // NewOpenCode is the opencode.ai Zen endpoint. Mixed APIs upstream; this | 131 | // NewOpenCode is the opencode.ai Zen endpoint. Mixed APIs upstream; this | ||
| 126 | // constructor wires the openai-completions flavor only. Models that need | 132 | // constructor wires the openai-completions flavor only. Models that need | ||
| 127 | // the anthropic-messages flavor under the same provider should be built | 133 | // the anthropic-messages flavor under the same provider should be built | ||
M packages/provider/labels.go +2 -0
| @@ -41,6 +41,8 @@ func ProviderLabel(id string) string { | |||
| 41 | 41 | return "OpenRouter" | |
| 42 | 42 | case "gondola": | |
| 43 | 43 | return "Gondola" | |
| 44 | + | case "yolo-auto": | |
| 45 | + | return "Yolo-Auto" | |
| 44 | 46 | case "mistral": | |
| 45 | 47 | return "Mistral" | |
| 46 | 48 | case "zai": | |
| @@ -41,6 +41,8 @@ func ProviderLabel(id string) string { | |||||
| 41 | return "OpenRouter" | 41 | return "OpenRouter" | ||
| 42 | case "gondola": | 42 | case "gondola": | ||
| 43 | return "Gondola" | 43 | return "Gondola" | ||
| 44 | + | case "yolo-auto": | |||
| 45 | + | return "Yolo-Auto" | |||
| 44 | case "mistral": | 46 | case "mistral": | ||
| 45 | return "Mistral" | 47 | return "Mistral" | ||
| 46 | case "zai": | 48 | case "zai": | ||
M packages/provider/merge.go +6 -0
| @@ -28,6 +28,12 @@ func MergeCatalog(live []Model) []Model { | |||
| 28 | 28 | for _, l := range live { | |
| 29 | 29 | k := byKey(l.Provider, l.ID) | |
| 30 | 30 | if s, ok := staticIndex[k]; ok { | |
| 31 | + | // Yolo-Auto reports authoritative, account-specific limits and | |
| 32 | + | // capabilities. Its seed entries are only offline fallbacks. | |
| 33 | + | if l.Provider == "yolo-auto" { | |
| 34 | + | staticIndex[k] = l | |
| 35 | + | continue | |
| 36 | + | } | |
| 31 | 37 | // Keep static prices & context window (live endpoint rarely | |
| 32 | 38 | // exposes these), but mark as live and non-speculative. | |
| 33 | 39 | s.Source = "live" | |
| @@ -28,6 +28,12 @@ func MergeCatalog(live []Model) []Model { | |||||
| 28 | for _, l := range live { | 28 | for _, l := range live { | ||
| 29 | k := byKey(l.Provider, l.ID) | 29 | k := byKey(l.Provider, l.ID) | ||
| 30 | if s, ok := staticIndex[k]; ok { | 30 | if s, ok := staticIndex[k]; ok { | ||
| 31 | + | // Yolo-Auto reports authoritative, account-specific limits and | |||
| 32 | + | // capabilities. Its seed entries are only offline fallbacks. | |||
| 33 | + | if l.Provider == "yolo-auto" { | |||
| 34 | + | staticIndex[k] = l | |||
| 35 | + | continue | |||
| 36 | + | } | |||
| 31 | // Keep static prices & context window (live endpoint rarely | 37 | // Keep static prices & context window (live endpoint rarely | ||
| 32 | // exposes these), but mark as live and non-speculative. | 38 | // exposes these), but mark as live and non-speculative. | ||
| 33 | s.Source = "live" | 39 | s.Source = "live" | ||
M packages/provider/models.go +26 -0
| @@ -272,6 +272,32 @@ var Catalog = []Model{ | |||
| 272 | 272 | BaseURL: gondolaDefaultBaseURL, | |
| 273 | 273 | }, | |
| 274 | 274 | ||
| 275 | + | // ---- Yolo-Auto ---- | |
| 276 | + | // Seed entries only: `qwen3.8-flash` is the free and paid default returned by | |
| 277 | + | // defaultModelForProvider so it resolves offline. Yolo-Auto is an | |
| 278 | + | // OpenAI-compatible gateway on flat-rate plans with no per-token | |
| 279 | + | // billing, so no per-token prices are listed. The limits are the | |
| 280 | + | // provider's documented conservative client settings; the full catalog, | |
| 281 | + | // including each key's plan-bounded window, comes from /v1/models | |
| 282 | + | // (DiscoverYoloAuto). | |
| 283 | + | { | |
| 284 | + | Provider: "yolo-auto", ID: "qwen3.8-flash", DisplayName: "Qwen3.8 Flash", | |
| 285 | + | ContextWindow: yoloAutoContextWindow, MaxOutput: yoloAutoMaxOutput, | |
| 286 | + | Reasoning: true, | |
| 287 | + | BaseURL: yoloAutoDefaultBaseURL, | |
| 288 | + | }, | |
| 289 | + | { | |
| 290 | + | Provider: "yolo-auto", ID: "yolo", DisplayName: "Yolo", | |
| 291 | + | ContextWindow: yoloAutoContextWindow, MaxOutput: yoloAutoMaxOutput, | |
| 292 | + | Reasoning: true, | |
| 293 | + | BaseURL: yoloAutoDefaultBaseURL, | |
| 294 | + | }, | |
| 295 | + | { | |
| 296 | + | Provider: "yolo-auto", ID: "yolo-small", DisplayName: "Yolo Small", | |
| 297 | + | ContextWindow: yoloAutoContextWindow, MaxOutput: yoloAutoMaxOutput, | |
| 298 | + | BaseURL: yoloAutoDefaultBaseURL, | |
| 299 | + | }, | |
| 300 | + | ||
| 275 | 301 | // ---- Anthropic / newer families ---- | |
| 276 | 302 | { | |
| 277 | 303 | Provider: "anthropic", ID: "claude-opus-4-5", DisplayName: "Claude Opus 4.5 (latest)", | |
| @@ -272,6 +272,32 @@ var Catalog = []Model{ | |||||
| 272 | BaseURL: gondolaDefaultBaseURL, | 272 | BaseURL: gondolaDefaultBaseURL, | ||
| 273 | }, | 273 | }, | ||
| 274 | 274 | ||||
| 275 | + | // ---- Yolo-Auto ---- | |||
| 276 | + | // Seed entries only: `qwen3.8-flash` is the free and paid default returned by | |||
| 277 | + | // defaultModelForProvider so it resolves offline. Yolo-Auto is an | |||
| 278 | + | // OpenAI-compatible gateway on flat-rate plans with no per-token | |||
| 279 | + | // billing, so no per-token prices are listed. The limits are the | |||
| 280 | + | // provider's documented conservative client settings; the full catalog, | |||
| 281 | + | // including each key's plan-bounded window, comes from /v1/models | |||
| 282 | + | // (DiscoverYoloAuto). | |||
| 283 | + | { | |||
| 284 | + | Provider: "yolo-auto", ID: "qwen3.8-flash", DisplayName: "Qwen3.8 Flash", | |||
| 285 | + | ContextWindow: yoloAutoContextWindow, MaxOutput: yoloAutoMaxOutput, | |||
| 286 | + | Reasoning: true, | |||
| 287 | + | BaseURL: yoloAutoDefaultBaseURL, | |||
| 288 | + | }, | |||
| 289 | + | { | |||
| 290 | + | Provider: "yolo-auto", ID: "yolo", DisplayName: "Yolo", | |||
| 291 | + | ContextWindow: yoloAutoContextWindow, MaxOutput: yoloAutoMaxOutput, | |||
| 292 | + | Reasoning: true, | |||
| 293 | + | BaseURL: yoloAutoDefaultBaseURL, | |||
| 294 | + | }, | |||
| 295 | + | { | |||
| 296 | + | Provider: "yolo-auto", ID: "yolo-small", DisplayName: "Yolo Small", | |||
| 297 | + | ContextWindow: yoloAutoContextWindow, MaxOutput: yoloAutoMaxOutput, | |||
| 298 | + | BaseURL: yoloAutoDefaultBaseURL, | |||
| 299 | + | }, | |||
| 300 | + | ||||
| 275 | // ---- Anthropic / newer families ---- | 301 | // ---- Anthropic / newer families ---- | ||
| 276 | { | 302 | { | ||
| 277 | Provider: "anthropic", ID: "claude-opus-4-5", DisplayName: "Claude Opus 4.5 (latest)", | 303 | Provider: "anthropic", ID: "claude-opus-4-5", DisplayName: "Claude Opus 4.5 (latest)", | ||
M packages/provider/openai.go +4 -0
| @@ -275,6 +275,10 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |||
| 275 | 275 | // values and take precedence over conservative protocol defaults. | |
| 276 | 276 | effort = reasoning | |
| 277 | 277 | } | |
| 278 | + | if c.Name() == "yolo-auto" && effort == "minimum" { | |
| 279 | + | // The advertised wire value is minimal, not zot's UI spelling. | |
| 280 | + | effort = "minimal" | |
| 281 | + | } | |
| 278 | 282 | if effort != "" { | |
| 279 | 283 | out.ReasoningEffort = effort | |
| 280 | 284 | } | |
| @@ -275,6 +275,10 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |||||
| 275 | // values and take precedence over conservative protocol defaults. | 275 | // values and take precedence over conservative protocol defaults. | ||
| 276 | effort = reasoning | 276 | effort = reasoning | ||
| 277 | } | 277 | } | ||
| 278 | + | if c.Name() == "yolo-auto" && effort == "minimum" { | |||
| 279 | + | // The advertised wire value is minimal, not zot's UI spelling. | |||
| 280 | + | effort = "minimal" | |||
| 281 | + | } | |||
| 278 | if effort != "" { | 282 | if effort != "" { | ||
| 279 | out.ReasoningEffort = effort | 283 | out.ReasoningEffort = effort | ||
| 280 | } | 284 | } | ||
M packages/provider/provider_test.go +28 -0
| @@ -511,6 +511,34 @@ func TestGondolaSeedCatalog(t *testing.T) { | |||
| 511 | 511 | } | |
| 512 | 512 | } | |
| 513 | 513 | ||
| 514 | + | func TestYoloAutoSeedCatalog(t *testing.T) { | |
| 515 | + | // The free-plan default must resolve without a live refresh. | |
| 516 | + | flash, err := FindModel("yolo-auto", "qwen3.8-flash") | |
| 517 | + | if err != nil { | |
| 518 | + | t.Fatal(err) | |
| 519 | + | } | |
| 520 | + | if flash.ContextWindow != yoloAutoContextWindow || flash.MaxOutput != yoloAutoMaxOutput || | |
| 521 | + | !flash.Reasoning || flash.BaseURL != yoloAutoDefaultBaseURL { | |
| 522 | + | t.Fatalf("unexpected Qwen model: %+v", flash) | |
| 523 | + | } | |
| 524 | + | yolo, err := FindModel("yolo-auto", "yolo") | |
| 525 | + | if err != nil { | |
| 526 | + | t.Fatal(err) | |
| 527 | + | } | |
| 528 | + | if yolo.ContextWindow != yoloAutoContextWindow || yolo.MaxOutput != yoloAutoMaxOutput || | |
| 529 | + | !yolo.Reasoning || yolo.BaseURL != yoloAutoDefaultBaseURL { | |
| 530 | + | t.Fatalf("unexpected Yolo model: %+v", yolo) | |
| 531 | + | } | |
| 532 | + | ||
| 533 | + | small, err := FindModel("yolo-auto", "yolo-small") | |
| 534 | + | if err != nil { | |
| 535 | + | t.Fatal(err) | |
| 536 | + | } | |
| 537 | + | if small.BaseURL != yoloAutoDefaultBaseURL || small.Reasoning { | |
| 538 | + | t.Fatalf("unexpected Yolo Small model: %+v", small) | |
| 539 | + | } | |
| 540 | + | } | |
| 541 | + | ||
| 514 | 542 | func TestOpenAICompatAnthropicReasoningEffort(t *testing.T) { | |
| 515 | 543 | c := NewOpenRouter("token", "").(*openaiClient) | |
| 516 | 544 | wire, err := c.buildRequest(Request{ | |
| @@ -511,6 +511,34 @@ func TestGondolaSeedCatalog(t *testing.T) { | |||||
| 511 | } | 511 | } | ||
| 512 | } | 512 | } | ||
| 513 | 513 | ||||
| 514 | + | func TestYoloAutoSeedCatalog(t *testing.T) { | |||
| 515 | + | // The free-plan default must resolve without a live refresh. | |||
| 516 | + | flash, err := FindModel("yolo-auto", "qwen3.8-flash") | |||
| 517 | + | if err != nil { | |||
| 518 | + | t.Fatal(err) | |||
| 519 | + | } | |||
| 520 | + | if flash.ContextWindow != yoloAutoContextWindow || flash.MaxOutput != yoloAutoMaxOutput || | |||
| 521 | + | !flash.Reasoning || flash.BaseURL != yoloAutoDefaultBaseURL { | |||
| 522 | + | t.Fatalf("unexpected Qwen model: %+v", flash) | |||
| 523 | + | } | |||
| 524 | + | yolo, err := FindModel("yolo-auto", "yolo") | |||
| 525 | + | if err != nil { | |||
| 526 | + | t.Fatal(err) | |||
| 527 | + | } | |||
| 528 | + | if yolo.ContextWindow != yoloAutoContextWindow || yolo.MaxOutput != yoloAutoMaxOutput || | |||
| 529 | + | !yolo.Reasoning || yolo.BaseURL != yoloAutoDefaultBaseURL { | |||
| 530 | + | t.Fatalf("unexpected Yolo model: %+v", yolo) | |||
| 531 | + | } | |||
| 532 | + | ||||
| 533 | + | small, err := FindModel("yolo-auto", "yolo-small") | |||
| 534 | + | if err != nil { | |||
| 535 | + | t.Fatal(err) | |||
| 536 | + | } | |||
| 537 | + | if small.BaseURL != yoloAutoDefaultBaseURL || small.Reasoning { | |||
| 538 | + | t.Fatalf("unexpected Yolo Small model: %+v", small) | |||
| 539 | + | } | |||
| 540 | + | } | |||
| 541 | + | ||||
| 514 | func TestOpenAICompatAnthropicReasoningEffort(t *testing.T) { | 542 | func TestOpenAICompatAnthropicReasoningEffort(t *testing.T) { | ||
| 515 | c := NewOpenRouter("token", "").(*openaiClient) | 543 | c := NewOpenRouter("token", "").(*openaiClient) | ||
| 516 | wire, err := c.buildRequest(Request{ | 544 | wire, err := c.buildRequest(Request{ | ||