mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 05:19:33 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a918d83b54 |
No files matched your search
@@ -78,19 +78,6 @@ zero exec --output-format stream-json < turns.jsonl
|
||||
<td align="center"><a href="https://concentrate.ai/"><strong>Concentrate</strong></a></td>
|
||||
<td align="center"><a href="https://exa.ai/"><strong>Exa</strong></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://www.gmicloud.ai/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/gmi-cloud-logo-dark.svg">
|
||||
<img src="docs/assets/gmi-cloud-logo.svg" alt="GMI Cloud logo" width="120">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://www.gmicloud.ai/"><strong>GMI Cloud</strong></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Why Zero
|
||||
@@ -203,7 +190,6 @@ export GEMINI_API_KEY=...
|
||||
export AIMLAPI_API_KEY=...
|
||||
export LONGCAT_API_KEY=...
|
||||
export FIREWORKS_API_KEY=...
|
||||
export GMI_API_KEY=...
|
||||
export MINIMAX_API_KEY=...
|
||||
export MINIMAXI_API_KEY=...
|
||||
```
|
||||
@@ -226,12 +212,6 @@ To configure Fireworks AI directly, run:
|
||||
zero providers setup fireworks --set-active
|
||||
```
|
||||
|
||||
To configure GMI Cloud directly, run:
|
||||
|
||||
```bash
|
||||
zero providers setup gmicloud --set-active
|
||||
```
|
||||
|
||||
MiniMax presets use the Anthropic-compatible endpoints for the global and China
|
||||
regions:
|
||||
|
||||
|
||||
@@ -76,19 +76,6 @@ zero exec --output-format stream-json < turns.jsonl
|
||||
<td align="center"><a href="https://concentrate.ai/"><strong>Concentrate</strong></a></td>
|
||||
<td align="center"><a href="https://exa.ai/"><strong>Exa</strong></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" width="150" height="80">
|
||||
<a href="https://www.gmicloud.ai/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/gmi-cloud-logo-dark.svg">
|
||||
<img src="docs/assets/gmi-cloud-logo.svg" alt="GMI Cloud logo" width="120">
|
||||
</picture>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><a href="https://www.gmicloud.ai/"><strong>GMI Cloud</strong></a></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 为什么选择 Zero
|
||||
@@ -176,7 +163,6 @@ export OPENAI_API_KEY=sk-...
|
||||
export ANTHROPIC_API_KEY=...
|
||||
export GEMINI_API_KEY=...
|
||||
export LONGCAT_API_KEY=...
|
||||
export GMI_API_KEY=...
|
||||
```
|
||||
|
||||
要直接配置美团 LongCat(LongCat-2.0),运行:
|
||||
@@ -185,12 +171,6 @@ export GMI_API_KEY=...
|
||||
zero providers setup longcat --set-active
|
||||
```
|
||||
|
||||
要直接配置 GMI Cloud,运行:
|
||||
|
||||
```bash
|
||||
zero providers setup gmicloud --set-active
|
||||
```
|
||||
|
||||
对于本地模型,运行 Ollama、LM Studio 或 [Atomic Chat](https://atomic.chat) 桌面应用,
|
||||
然后使用 `zero setup` 或 `zero providers detect`。使用 Atomic Chat 时,请先加载模型并启用
|
||||
本地 OpenAI 兼容 API(默认地址为 `http://127.0.0.1:1337/v1`),再选择 `atomic-chat-local`。
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# Local per-model completion limits
|
||||
|
||||
Add `modelRPM` to the existing Zero config file:
|
||||
|
||||
```json
|
||||
{"modelRPM": {"gpt-4.1": 30, "claude-sonnet-4.5": 15}}
|
||||
```
|
||||
|
||||
Keys resolve through the model registry; custom IDs match exactly after trimming
|
||||
whitespace. Missing keys and zero mean unlimited. Negative limits and conflicting
|
||||
aliases are configuration errors. Project config may add or tighten a user cap,
|
||||
but cannot relax it. Changes take effect on the next Zero process start.
|
||||
|
||||
For `zero exec` and interactive agent turns, admission happens immediately before
|
||||
the wrapped turn session's `Stream`. N admissions fit in any sliding 60-second
|
||||
window; N+1 returns a local rate-limit error before entering that method. The
|
||||
error reports when a slot expires. Exec uses its existing provider-error exit
|
||||
code plus a local-limit hint. There is no sleep or hidden retry. Failed admitted
|
||||
requests still consume a slot.
|
||||
|
||||
Sessions and model switches share a limiter in one process. Separate processes
|
||||
have independent windows; restarting clears them. This is not an account-wide
|
||||
cap. It counts Stream admissions, not all HTTP requests: setup/prewarm,
|
||||
compaction, provider-internal retries, discovery and direct calls outside that
|
||||
boundary are not counted. No TPM, cost budgets, persistence, cross-process
|
||||
coordination or new runtime/Python dependency is introduced.
|
||||
@@ -6,7 +6,6 @@ Zero.
|
||||
## User Docs
|
||||
|
||||
- [Install](INSTALL.md)
|
||||
- [Local per-model completion limits](MODEL_RPM.md)
|
||||
- [Update flow](UPDATE.md)
|
||||
- [OAuth logins and subscription-backed providers](oauth-subscriptions.md)
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 34 718 165" width="718" height="165" fill="#fff">
|
||||
<path d="M123.292 132.486h-33.1c-2.644 0-4.808-2.395-4.808-5.363v-21.436c0-2.95 2.148-5.363 4.809-5.363h33.099c2.645 0 4.808-2.395 4.808-5.363V73.525c0-2.95 2.147-5.363 4.808-5.363h33.099c2.646 0 4.809-2.395 4.809-5.363V41.363c0-2.95-2.148-5.363-4.809-5.363H90.208c-2.645 0-4.808 2.395-4.808 5.363V62.8c0 2.95-2.147 5.363-4.808 5.363h-33.1c-2.645 0-4.808 2.395-4.808 5.363v21.436c0 2.95-2.147 5.363-4.808 5.363H4.808c-2.645 0-4.808 2.395-4.808 5.363v21.436c0 2.95 2.147 5.363 4.808 5.363h33.1c2.645 0 4.808 2.395 4.808 5.363v21.436c0 2.95 2.147 5.363 4.808 5.363h33.099c2.645 0 4.808 2.395 4.808 5.363v21.436c0 2.95 2.148 5.363 4.809 5.363h75.798c2.646 0 4.809-2.395 4.809-5.363v-21.436c0-2.95-2.148-5.363-4.809-5.363H132.94c-2.646 0-4.809-2.395-4.809-5.363v-21.436c0-2.95-2.147-5.363-4.808-5.363z"/>
|
||||
<path d="M251.408 100.341c2.646 0 4.809 2.395 4.809 5.363v21.436c0 2.95-2.148 5.363-4.809 5.363H218.31c-2.646 0-4.809 2.395-4.809 5.363v21.436c0 2.95-2.147 5.363-4.808 5.363h-33.099c-2.645 0-4.808-2.395-4.808-5.363v-53.58c0-2.951 2.147-5.364 4.808-5.364h75.799zM510.68 40.105h-28.795v153.33h28.795zM717.43 40.105h-28.795v153.33h28.795z"/>
|
||||
<path d="M532.41 40.105h-28.83l35.391 153.33H567.8zM653.242 40.105h-28.795v153.33h28.795z"/>
|
||||
<path d="M596.594 193.435h-28.795l35.39-153.33h28.795zM452.028 114.062v27.042s-.139.174-.191.261h-83.069V114.08h83.295-.035z"/>
|
||||
<path d="M452.028 141.347v35.651c-17.479 12.306-39.834 19.769-62.953 19.769-59.673 0-97.858-35.981-97.858-80.362s34.54-80.362 97.858-80.362c22.511 0 45.127 7.689 62.953 20.36v35.685c-13.105-16.42-37.856-28.743-62.953-28.743-44.052 0-70.695 20.846-70.695 53.077 0 32.232 28.691 53.077 70.695 53.077 25.323 0 49.727-11.906 62.744-28.1h.191v-.035z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB |
@@ -1,7 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 34 718 165" width="718" height="165" fill="#0D0E10">
|
||||
<path d="M123.292 132.486h-33.1c-2.644 0-4.808-2.395-4.808-5.363v-21.436c0-2.95 2.148-5.363 4.809-5.363h33.099c2.645 0 4.808-2.395 4.808-5.363V73.525c0-2.95 2.147-5.363 4.808-5.363h33.099c2.646 0 4.809-2.395 4.809-5.363V41.363c0-2.95-2.148-5.363-4.809-5.363H90.208c-2.645 0-4.808 2.395-4.808 5.363V62.8c0 2.95-2.147 5.363-4.808 5.363h-33.1c-2.645 0-4.808 2.395-4.808 5.363v21.436c0 2.95-2.147 5.363-4.808 5.363H4.808c-2.645 0-4.808 2.395-4.808 5.363v21.436c0 2.95 2.147 5.363 4.808 5.363h33.1c2.645 0 4.808 2.395 4.808 5.363v21.436c0 2.95 2.147 5.363 4.808 5.363h33.099c2.645 0 4.808 2.395 4.808 5.363v21.436c0 2.95 2.148 5.363 4.809 5.363h75.798c2.646 0 4.809-2.395 4.809-5.363v-21.436c0-2.95-2.148-5.363-4.809-5.363H132.94c-2.646 0-4.809-2.395-4.809-5.363v-21.436c0-2.95-2.147-5.363-4.808-5.363z"/>
|
||||
<path d="M251.408 100.341c2.646 0 4.809 2.395 4.809 5.363v21.436c0 2.95-2.148 5.363-4.809 5.363H218.31c-2.646 0-4.809 2.395-4.809 5.363v21.436c0 2.95-2.147 5.363-4.808 5.363h-33.099c-2.645 0-4.808-2.395-4.808-5.363v-53.58c0-2.951 2.147-5.364 4.808-5.364h75.799zM510.68 40.105h-28.795v153.33h28.795zM717.43 40.105h-28.795v153.33h28.795z"/>
|
||||
<path d="M532.41 40.105h-28.83l35.391 153.33H567.8zM653.242 40.105h-28.795v153.33h28.795z"/>
|
||||
<path d="M596.594 193.435h-28.795l35.39-153.33h28.795zM452.028 114.062v27.042s-.139.174-.191.261h-83.069V114.08h83.295-.035z"/>
|
||||
<path d="M452.028 141.347v35.651c-17.479 12.306-39.834 19.769-62.953 19.769-59.673 0-97.858-35.981-97.858-80.362s34.54-80.362 97.858-80.362c22.511 0 45.127 7.689 62.953 20.36v35.685c-13.105-16.42-37.856-28.743-62.953-28.743-44.052 0-70.695 20.846-70.695 53.077 0 32.232 28.691 53.077 70.695 53.077 25.323 0 49.727-11.906 62.744-28.1h.191v-.035z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.8 KiB |
+1
-3
@@ -968,9 +968,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
// From the engine the session's commands run through, so the notice
|
||||
// describes the sandbox they actually get.
|
||||
startupNotices := []string{sandboxEngine.DegradedNotice()}
|
||||
modelRPM := zeroruntime.NewModelRPMLimiter(resolved.ModelRPM)
|
||||
return deps.runTUI(context.Background(), tui.Options{
|
||||
ModelRPM: modelRPM,
|
||||
Cwd: workspaceRoot,
|
||||
Version: version,
|
||||
Theme: theme,
|
||||
@@ -993,7 +991,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
if provider == nil {
|
||||
return nil
|
||||
}
|
||||
if optimized, ok := providers.ConfiguredTurnSessions(profile, provider, providers.Options{ModelRPM: modelRPM}); ok {
|
||||
if optimized, ok := providers.OptimizedTurnSessions(profile, provider, providers.Options{}); ok {
|
||||
return optimized
|
||||
}
|
||||
return providers.DefaultTurnSessions(profile, provider, providers.Options{})
|
||||
|
||||
@@ -343,10 +343,8 @@ func TestRunNoArgsFallsBackToUsableProviderWhenNoneMarkedActive(t *testing.T) {
|
||||
// config.json has providers configured but none marked active (e.g. a
|
||||
// blank/stale activeProvider field) — Resolve returns the successfully
|
||||
// normalized providers list alongside ErrNoActiveProvider.
|
||||
return config.ResolvedConfig{
|
||||
Providers: []config.ProviderProfile{usable},
|
||||
ModelRPM: map[string]int{"gpt-test": 1},
|
||||
}, fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "")
|
||||
return config.ResolvedConfig{Providers: []config.ProviderProfile{usable}},
|
||||
fmt.Errorf("%w: active provider %q not found", config.ErrNoActiveProvider, "")
|
||||
},
|
||||
newProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
|
||||
providerProfile = profile
|
||||
@@ -376,10 +374,6 @@ func TestRunNoArgsFallsBackToUsableProviderWhenNoneMarkedActive(t *testing.T) {
|
||||
}
|
||||
if providerProfile.Name != "work" {
|
||||
t.Fatalf("provider used = %q, want fallback to the usable saved provider %q", providerProfile.Name, "work")
|
||||
|
||||
}
|
||||
if launchedOptions.ModelRPM == nil {
|
||||
t.Fatal("ModelRPM = nil, want limiter preserved through provider fallback")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,12 +10,12 @@ import (
|
||||
|
||||
// runConfigNotify implements `zero config notify`: with no flags it prints the
|
||||
// stored mode/focusMode; --mode/--focus update them via the same
|
||||
// config.UpdateNotify writer the TUI /notify command uses, so all surfaces stay
|
||||
// config.SetNotify writer the TUI /notify command uses, so all surfaces stay
|
||||
// in lockstep; --reset blanks both fields so the TUI's effective default
|
||||
// applies again (an unconfigured headless run stays silent).
|
||||
//
|
||||
// The command manages a user preference, so it talks ONLY to the user's own
|
||||
// config file (config.UserNotify / config.UpdateNotify) and never runs the full
|
||||
// config file (config.UserNotify / config.SetNotify) and never runs the full
|
||||
// config resolution: resolving providers would fail with ErrNoActiveProvider
|
||||
// for a brand-new user, locking them out of setting notifications before they
|
||||
// have even configured a provider (CodeRabbit review, PR #1001). The display
|
||||
|
||||
@@ -463,20 +463,20 @@ func runExec(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) in
|
||||
// escalation so post-switch turns are attributed to the escalated model.
|
||||
currentModel := resolved.Provider.Model
|
||||
// Optimized OpenAI turn sessions (ZERO_OPENAI_TURN_SESSION, default off). nil
|
||||
// when gated off or ineligible, unless a configured RPM limiter needs to
|
||||
// wrap the default adapter. The limiter is shared across model switches.
|
||||
sessionOptions := providers.Options{ModelRPM: zeroruntime.NewModelRPMLimiter(resolved.ModelRPM)}
|
||||
turnSessions, _ := providers.ConfiguredTurnSessions(resolved.Provider, provider, sessionOptions)
|
||||
// when gated off or the profile is ineligible: agent.Run then wraps the
|
||||
// provider in its default adapter. This is the run's STARTING session provider
|
||||
// and is used whether or not escalation is enabled.
|
||||
turnSessions, _ := providers.OptimizedTurnSessions(resolved.Provider, provider, providers.Options{})
|
||||
// Both switchers come from one shared builder so exec and the interactive TUI
|
||||
// cannot drift on the nil contracts the agent loop depends on. The session
|
||||
// switcher preserves the starting transport and shares the RPM limiter.
|
||||
// switcher is nil unless this run STARTED optimized, which is what keeps a
|
||||
// default-adapter run on the default adapter.
|
||||
var modelSwitcher func(context.Context, string) (agent.Provider, error)
|
||||
var modelSessionSwitcher func(context.Context, string) (zeroruntime.TurnSessionProvider, error)
|
||||
if options.allowEscalation {
|
||||
modelSwitcher, modelSessionSwitcher = providers.EscalationSwitchers(
|
||||
resolved.Provider, provider, deps.newProvider,
|
||||
func(modelID string) { currentModel = modelID },
|
||||
sessionOptions,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/mcp"
|
||||
"github.com/Gitlawb/zero/internal/tools"
|
||||
"github.com/Gitlawb/zero/internal/tui"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type rpmExecProvider struct{ calls int }
|
||||
|
||||
func (p *rpmExecProvider) StreamCompletion(context.Context, zeroruntime.CompletionRequest) (<-chan zeroruntime.StreamEvent, error) {
|
||||
p.calls++
|
||||
ch := make(chan zeroruntime.StreamEvent, 4)
|
||||
if p.calls == 1 {
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallStart, ToolCallID: "fixture", ToolName: "read_file"}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallDelta, ToolCallID: "fixture", ArgumentsFragment: `{"path":"missing-fixture.txt"}`}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventToolCallEnd, ToolCallID: "fixture"}
|
||||
} else {
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventText, Content: "unexpected second completion"}
|
||||
}
|
||||
ch <- zeroruntime.StreamEvent{Type: zeroruntime.StreamEventDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
func rpmTestDeps(t *testing.T, p *rpmExecProvider) appDeps {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for _, k := range []string{"HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME"} {
|
||||
t.Setenv(k, root)
|
||||
}
|
||||
return appDeps{getwd: func() (string, error) { return root, nil }, resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
|
||||
c := execResolvedConfig()
|
||||
c.ModelRPM = map[string]int{c.Provider.Model: 1}
|
||||
return c, nil
|
||||
}, newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) { return p, nil }, registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
|
||||
return noopMCPRuntime{}, nil
|
||||
}}
|
||||
}
|
||||
func TestRunExecRPMRefusesSecondCompletion(t *testing.T) {
|
||||
p := &rpmExecProvider{}
|
||||
deps := rpmTestDeps(t, p)
|
||||
var out, err bytes.Buffer
|
||||
code := runWithDeps([]string{"exec", "fixture"}, &out, &err, deps)
|
||||
if code != exitProvider || p.calls != 1 || !strings.Contains(err.String(), "Local modelRPM cap reached") {
|
||||
t.Fatalf("exit=%d calls=%d stdout=%s stderr=%s", code, p.calls, out.String(), err.String())
|
||||
}
|
||||
}
|
||||
func TestInteractiveRPMFactorySharesWindow(t *testing.T) {
|
||||
p := &rpmExecProvider{}
|
||||
deps := rpmTestDeps(t, p)
|
||||
launched := false
|
||||
deps.runTUI = func(ctx context.Context, o tui.Options) int {
|
||||
launched = true
|
||||
if o.ModelRPM == nil {
|
||||
t.Fatal("TUI escalation lacks limiter")
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
ss := o.NewTurnSessionProvider(o.ProviderProfile, p)
|
||||
s, e := ss.OpenTurnSession(ctx)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
_, e = s.Stream(ctx, zeroruntime.CompletionRequest{})
|
||||
var hit *zeroruntime.RPMLimitError
|
||||
if (i == 0 && e != nil) || (i == 1 && !errors.As(e, &hit)) {
|
||||
t.Fatalf("run=%d err=%v", i, e)
|
||||
}
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
var out, err bytes.Buffer
|
||||
code := runWithDeps(nil, &out, &err, deps)
|
||||
if !launched || code != exitSuccess || p.calls != 1 {
|
||||
t.Fatalf("launched=%v exit=%d calls=%d stderr=%s", launched, code, p.calls, err.String())
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,6 @@ func TestProviderCatalogRuntimeProvidersShowOnboardingSetupHints(t *testing.T) {
|
||||
{id: "groq", name: "Groq", transport: "openai-compatible", defaultModel: "llama-3.3-70b-versatile"},
|
||||
{id: "longcat", name: "LongCat", transport: "openai-compatible", defaultModel: "LongCat-2.0"},
|
||||
{id: "fireworks", name: "Fireworks AI", transport: "openai-compatible", defaultModel: "accounts/fireworks/models/kimi-k2p7-code"},
|
||||
{id: "gmicloud", name: "GMI Cloud", transport: "openai-compatible", defaultModel: "Qwen/Qwen3.8-Max"},
|
||||
{id: "ollama-cloud", name: "Ollama Cloud", transport: "openai-compatible", defaultModel: "qwen3-coder:480b"},
|
||||
{id: "ollama", name: "Ollama Local", transport: "openai-compatible", defaultModel: "llama3.1"},
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/Gitlawb/zero/internal/modelregistry"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func normalizeModelRPM(limits map[string]int) (map[string]int, error) {
|
||||
if len(limits) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
registry, err := modelregistry.DefaultRegistry()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]int, len(limits))
|
||||
for key, n := range limits {
|
||||
model := strings.TrimSpace(key)
|
||||
if model == "" || n < 0 {
|
||||
return nil, fmt.Errorf("invalid modelRPM entry %q: model must be non-empty and limit >= 0", key)
|
||||
}
|
||||
if id, ok := registry.ResolveID(model); ok {
|
||||
model = id
|
||||
}
|
||||
if old, exists := out[model]; exists && old != n {
|
||||
return nil, fmt.Errorf("conflicting modelRPM entries resolve to %q", model)
|
||||
}
|
||||
out[model] = n
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Project config can tighten a user cap but cannot relax it.
|
||||
func mergeModelRPM(dst *map[string]int, src map[string]int, tighten bool) {
|
||||
if len(src) == 0 {
|
||||
return
|
||||
}
|
||||
if *dst == nil {
|
||||
*dst = make(map[string]int)
|
||||
}
|
||||
for model, n := range src {
|
||||
old := (*dst)[model]
|
||||
if !tighten || old == 0 || (n > 0 && n < old) {
|
||||
(*dst)[model] = n
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestModelRPMRoundTrip(t *testing.T) {
|
||||
var c FileConfig
|
||||
if e := json.Unmarshal([]byte(`{"modelRPM":{" OPENAI:GPT-4.1 ":2,"custom":0},"future":true}`), &c); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
want := map[string]int{"gpt-4.1": 2, "custom": 0}
|
||||
if !reflect.DeepEqual(c.ModelRPM, want) {
|
||||
t.Fatal(c.ModelRPM)
|
||||
}
|
||||
b, e := json.Marshal(c)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
var d FileConfig
|
||||
if e = json.Unmarshal(b, &d); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if !reflect.DeepEqual(d.ModelRPM, want) || string(d.Extra["future"]) != "true" {
|
||||
t.Fatalf("round trip=%s", b)
|
||||
}
|
||||
}
|
||||
func TestModelRPMInvalid(t *testing.T) {
|
||||
for _, s := range []string{`{"modelRPM":{"":1}}`, `{"modelRPM":{"a":-1}}`, `{"modelRPM":{"a":1.5}}`, `{"modelRPM":{"a":"2"}}`, `{"modelRPM":{"gpt-4.1":1,"openai:gpt-4.1":2}}`} {
|
||||
var c FileConfig
|
||||
if e := json.Unmarshal([]byte(s), &c); e == nil {
|
||||
t.Fatalf("accepted %s", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestModelRPMProjectCanOnlyTighten(t *testing.T) {
|
||||
user := writeConfig(t, `{"modelRPM":{"gpt-4.1":2},"activeProvider":"test","providers":[{"name":"test","provider":"openai","model":"gpt-4.1"}]}`)
|
||||
for _, n := range []string{"0", "1", "5"} {
|
||||
project := writeConfig(t, `{"modelRPM":{"openai:gpt-4.1":`+n+`}}`)
|
||||
c, e := Resolve(ResolveOptions{UserConfigPath: user, ProjectConfigPath: project, Env: map[string]string{}})
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
want := 2
|
||||
if n == "1" {
|
||||
want = 1
|
||||
}
|
||||
if c.ModelRPM["gpt-4.1"] != want {
|
||||
t.Fatal(c.ModelRPM)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,9 +104,6 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
// trusted global-config merge, which must keep honouring the setting.
|
||||
commandConfig.Sandbox.Enabled = nil
|
||||
commandConfig.CrossSessionInbound = ""
|
||||
// External provider commands cannot relax the user cap.
|
||||
mergeModelRPM(&cfg.ModelRPM, commandConfig.ModelRPM, true)
|
||||
commandConfig.ModelRPM = nil
|
||||
mergeConfig(&cfg, commandConfig)
|
||||
}
|
||||
|
||||
@@ -166,7 +163,6 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
// dropping them would let the TUI's unconfigured notify default overwrite
|
||||
// a stored explicit opt-out on startup (maintainer review, PR #1001).
|
||||
partial := ResolvedConfig{
|
||||
ModelRPM: cfg.ModelRPM,
|
||||
Providers: providers,
|
||||
MaxTurns: cfg.MaxTurns,
|
||||
MCP: cfg.MCP,
|
||||
@@ -184,7 +180,6 @@ func Resolve(options ResolveOptions) (ResolvedConfig, error) {
|
||||
}
|
||||
|
||||
return ResolvedConfig{
|
||||
ModelRPM: cfg.ModelRPM,
|
||||
ActiveProvider: active.Name,
|
||||
Providers: providers,
|
||||
Provider: active,
|
||||
@@ -253,7 +248,6 @@ func loadConfigFile(path string) (FileConfig, error) {
|
||||
}
|
||||
|
||||
func mergeConfig(dst *FileConfig, src FileConfig) {
|
||||
mergeModelRPM(&dst.ModelRPM, src.ModelRPM, false)
|
||||
if activeProvider := strings.TrimSpace(src.ActiveProvider); activeProvider != "" {
|
||||
dst.ActiveProvider = activeProvider
|
||||
}
|
||||
@@ -315,7 +309,6 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
|
||||
}
|
||||
|
||||
func mergeProjectConfig(dst *FileConfig, src FileConfig) error {
|
||||
mergeModelRPM(&dst.ModelRPM, src.ModelRPM, true)
|
||||
if activeProvider := strings.TrimSpace(src.ActiveProvider); activeProvider != "" {
|
||||
dst.ActiveProvider = activeProvider
|
||||
}
|
||||
|
||||
@@ -1109,34 +1109,6 @@ func TestResolveProviderCommandOverridesEnvProviderFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveProviderCommandCannotRelaxUserModelRPM(t *testing.T) {
|
||||
userPath := writeConfig(t, `{
|
||||
"modelRPM": {
|
||||
"gpt-command": 5
|
||||
}
|
||||
}`)
|
||||
|
||||
command := writeCommand(t, commandScript{
|
||||
Stdout: `{"activeProvider":"cmd","providers":[{"name":"cmd","provider":"openai","apiKey":"sk-command","model":"gpt-command"}],"modelRPM":{"gpt-command":10}}`,
|
||||
})
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{
|
||||
UserConfigPath: userPath,
|
||||
ProviderCommand: command,
|
||||
Env: map[string]string{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
|
||||
if got := resolved.ModelRPM["gpt-command"]; got != 5 {
|
||||
t.Fatalf(
|
||||
"ModelRPM[gpt-command] = %d, want user cap 5 preserved against provider-command relaxation",
|
||||
got,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAllowsNoConfiguredProviders(t *testing.T) {
|
||||
resolved, err := Resolve(ResolveOptions{Env: map[string]string{}})
|
||||
if err != nil {
|
||||
@@ -1194,13 +1166,10 @@ func TestResolveKeepsNormalizedProvidersWhenNoneMarkedActive(t *testing.T) {
|
||||
// activeProvider is blank/stale — a caller like the interactive TUI still
|
||||
// needs the normalized list to fall back to an already-usable provider
|
||||
// instead of forcing a full re-onboarding wizard.
|
||||
path := writeConfig(t, `{
|
||||
"modelRPM":{"gpt-test":1},
|
||||
"providers":[
|
||||
{"name":"work","provider_kind":"openai","apiKey":"sk-test","model":"gpt-test"},
|
||||
{"name":"other","provider_kind":"openai","apiKey":"sk-other","model":"gpt-test"}
|
||||
]
|
||||
}`)
|
||||
path := writeConfig(t, `{"providers":[
|
||||
{"name":"work","provider_kind":"openai","apiKey":"sk-test","model":"gpt-test"},
|
||||
{"name":"other","provider_kind":"openai","apiKey":"sk-other","model":"gpt-test"}
|
||||
]}`)
|
||||
|
||||
resolved, err := Resolve(ResolveOptions{ProjectConfigPath: path, Env: map[string]string{}})
|
||||
if !errors.Is(err, ErrNoActiveProvider) {
|
||||
@@ -1209,9 +1178,6 @@ func TestResolveKeepsNormalizedProvidersWhenNoneMarkedActive(t *testing.T) {
|
||||
if len(resolved.Providers) != 2 {
|
||||
t.Fatalf("Providers = %#v, want the 2 normalized profiles preserved despite the error", resolved.Providers)
|
||||
}
|
||||
if got := resolved.ModelRPM["gpt-test"]; got != 1 {
|
||||
t.Fatalf("ModelRPM[gpt-test] = %d, want 1 preserved despite ErrNoActiveProvider", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTrimsProviderProfileAliasesBeforeFallback(t *testing.T) {
|
||||
|
||||
@@ -364,7 +364,6 @@ func (cfg *ToolsConfig) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
type FileConfig struct {
|
||||
ModelRPM map[string]int `json:"modelRPM,omitempty"`
|
||||
ActiveProvider string `json:"activeProvider,omitempty"`
|
||||
Providers []ProviderProfile `json:"providers,omitempty"`
|
||||
MaxTurns int `json:"maxTurns,omitempty"`
|
||||
@@ -390,7 +389,6 @@ type FileConfig struct {
|
||||
|
||||
func (cfg FileConfig) MarshalJSON() ([]byte, error) {
|
||||
type rawConfig struct {
|
||||
ModelRPM map[string]int `json:"modelRPM,omitempty"`
|
||||
ActiveProvider string `json:"activeProvider,omitempty"`
|
||||
Providers []ProviderProfile `json:"providers,omitempty"`
|
||||
MaxTurns int `json:"maxTurns,omitempty"`
|
||||
@@ -407,7 +405,6 @@ func (cfg FileConfig) MarshalJSON() ([]byte, error) {
|
||||
Sessions *SessionsConfig `json:"sessions,omitempty"`
|
||||
}
|
||||
raw := rawConfig{
|
||||
ModelRPM: cfg.ModelRPM,
|
||||
ActiveProvider: cfg.ActiveProvider,
|
||||
Providers: cfg.Providers,
|
||||
MaxTurns: cfg.MaxTurns,
|
||||
@@ -486,7 +483,6 @@ type Overrides struct {
|
||||
}
|
||||
|
||||
type ResolvedConfig struct {
|
||||
ModelRPM map[string]int
|
||||
ActiveProvider string
|
||||
Providers []ProviderProfile
|
||||
Provider ProviderProfile
|
||||
@@ -555,7 +551,6 @@ type MCPOAuthConfig struct {
|
||||
|
||||
func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
type rawConfig struct {
|
||||
ModelRPM map[string]int `json:"modelRPM"`
|
||||
ActiveProvider string `json:"activeProvider"`
|
||||
Providers []ProviderProfile `json:"providers"`
|
||||
MaxTurns int `json:"maxTurns"`
|
||||
@@ -591,11 +586,6 @@ func (cfg *FileConfig) UnmarshalJSON(data []byte) error {
|
||||
extra = nil
|
||||
}
|
||||
cfg.ActiveProvider = raw.ActiveProvider
|
||||
limits, err := normalizeModelRPM(raw.ModelRPM)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.ModelRPM = limits
|
||||
cfg.Providers = raw.Providers
|
||||
// A negative maxTurns is unambiguously invalid; without this it would be
|
||||
// silently dropped by the `MaxTurns > 0` merge gates and fall back to the
|
||||
|
||||
@@ -879,6 +879,24 @@ func UpdateNotify(path string, merge func(current NotifyConfig) NotifyConfig) (r
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// SetNotify replaces the stored notification preference with value. It is a
|
||||
// thin wrapper over the transactional UpdateNotify so existing callers keep
|
||||
// their replace semantics under the same lock and byte-preserving write.
|
||||
func SetNotify(path string, value NotifyConfig) (FileConfig, error) {
|
||||
next, err := UpdateNotify(path, func(NotifyConfig) NotifyConfig { return value })
|
||||
if err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
// Re-read the full config so callers that inspect unrelated fields (and
|
||||
// existing tests) keep their FileConfig-shaped result.
|
||||
cfg, err := loadConfigFile(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
cfg.Notify = next
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SetPet persists only the terminal-pet preference while preserving every
|
||||
// unrelated user setting through the config writer's atomic replace path.
|
||||
func SetPet(path string, pet string) (result FileConfig, err error) {
|
||||
|
||||
@@ -428,23 +428,6 @@ func TestSetThemePreservesUnknownTopLevelFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// setNotify replaces the stored notify block with value through UpdateNotify
|
||||
// and returns the re-read config, so the tests below can assert on a
|
||||
// FileConfig-shaped result. Every product surface uses UpdateNotify's merge
|
||||
// form directly; this replace form exists only for these tests.
|
||||
func setNotify(path string, value NotifyConfig) (FileConfig, error) {
|
||||
next, err := UpdateNotify(path, func(NotifyConfig) NotifyConfig { return value })
|
||||
if err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
cfg, err := loadConfigFile(path)
|
||||
if err != nil {
|
||||
return FileConfig{}, err
|
||||
}
|
||||
cfg.Notify = next
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func TestSetNotifyPersistsValidValues(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{
|
||||
@@ -454,9 +437,9 @@ func TestSetNotifyPersistsValidValues(t *testing.T) {
|
||||
},
|
||||
}, 0o600)
|
||||
|
||||
cfg, err := setNotify(path, NotifyConfig{Mode: " both ", FocusMode: " unfocused "})
|
||||
cfg, err := SetNotify(path, NotifyConfig{Mode: " both ", FocusMode: " unfocused "})
|
||||
if err != nil {
|
||||
t.Fatalf("setNotify() error = %v", err)
|
||||
t.Fatalf("SetNotify() error = %v", err)
|
||||
}
|
||||
if cfg.Notify.Mode != "both" || cfg.Notify.FocusMode != "unfocused" {
|
||||
t.Fatalf("Notify = %+v, want mode=both focusMode=unfocused (trimmed)", cfg.Notify)
|
||||
@@ -466,14 +449,14 @@ func TestSetNotifyPersistsValidValues(t *testing.T) {
|
||||
t.Fatalf("persisted Notify = %+v, want mode=both focusMode=unfocused", persisted.Notify)
|
||||
}
|
||||
if persisted.ActiveProvider != "openai" || len(persisted.Providers) != 1 {
|
||||
t.Fatalf("provider config was not preserved by setNotify: %#v", persisted)
|
||||
t.Fatalf("provider config was not preserved by SetNotify: %#v", persisted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetNotifyRejectsInvalidMode(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{ActiveProvider: "openai"}, 0o600)
|
||||
if _, err := setNotify(path, NotifyConfig{Mode: "loud", FocusMode: "unfocused"}); err == nil {
|
||||
if _, err := SetNotify(path, NotifyConfig{Mode: "loud", FocusMode: "unfocused"}); err == nil {
|
||||
t.Fatal("expected error for invalid notify.mode")
|
||||
}
|
||||
}
|
||||
@@ -481,25 +464,25 @@ func TestSetNotifyRejectsInvalidMode(t *testing.T) {
|
||||
func TestSetNotifyRejectsInvalidFocusMode(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{ActiveProvider: "openai"}, 0o600)
|
||||
if _, err := setNotify(path, NotifyConfig{Mode: "off", FocusMode: "sideways"}); err == nil {
|
||||
if _, err := SetNotify(path, NotifyConfig{Mode: "off", FocusMode: "sideways"}); err == nil {
|
||||
t.Fatal("expected error for invalid notify.focusMode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetNotifyRejectsEmptyConfigPath(t *testing.T) {
|
||||
if _, err := setNotify("", NotifyConfig{Mode: "off"}); err == nil {
|
||||
if _, err := SetNotify("", NotifyConfig{Mode: "off"}); err == nil {
|
||||
t.Fatal("expected error for empty config path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetNotifyBlankValuesPreservedAsDefaults(t *testing.T) {
|
||||
// An empty mode/focusMode stored on disk is a valid "use the built-in
|
||||
// defaults" signal — setNotify must not reject blanks, and they must round
|
||||
// defaults" signal — SetNotify must not reject blanks, and they must round
|
||||
// trip unchanged.
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
writeConfigFixture(t, path, FileConfig{ActiveProvider: "openai"}, 0o600)
|
||||
if _, err := setNotify(path, NotifyConfig{}); err != nil {
|
||||
t.Fatalf("setNotify({}) should accept blank values, got error: %v", err)
|
||||
if _, err := SetNotify(path, NotifyConfig{}); err != nil {
|
||||
t.Fatalf("SetNotify({}) should accept blank values, got error: %v", err)
|
||||
}
|
||||
persisted := readConfigFixture(t, path)
|
||||
if persisted.Notify.Mode != "" || persisted.Notify.FocusMode != "" {
|
||||
@@ -511,7 +494,7 @@ func TestSetNotifyBlankValuesPreservedAsDefaults(t *testing.T) {
|
||||
// values whose EXPLICIT presence matters. The typed serializer's omitempty
|
||||
// cannot round-trip tools.deferThreshold: 0 (explicit "never defer" vs unset
|
||||
// "use default 3") or an MCP server's disabled: false (explicitly enabled), so
|
||||
// UpdateNotify edits only the notify member's bytes instead. The
|
||||
// SetNotify/UpdateNotify edit only the notify member's bytes instead. The
|
||||
// assertions run against the RAW file, because reading through FileConfig
|
||||
// would hide exactly the presence loss being tested.
|
||||
func TestSetNotifyPreservesExplicitUnrelatedValues(t *testing.T) {
|
||||
@@ -534,8 +517,8 @@ func TestSetNotifyPreservesExplicitUnrelatedValues(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := setNotify(path, NotifyConfig{Mode: "off", FocusMode: "always"}); err != nil {
|
||||
t.Fatalf("setNotify: %v", err)
|
||||
if _, err := SetNotify(path, NotifyConfig{Mode: "off", FocusMode: "always"}); err != nil {
|
||||
t.Fatalf("SetNotify: %v", err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
@@ -578,8 +561,8 @@ func TestSetNotifyResetRemovesEveryDuplicateNotifyMember(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := setNotify(path, NotifyConfig{}); err != nil {
|
||||
t.Fatalf("setNotify({}) reset: %v", err)
|
||||
if _, err := SetNotify(path, NotifyConfig{}); err != nil {
|
||||
t.Fatalf("SetNotify({}) reset: %v", err)
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
@@ -603,8 +586,8 @@ func TestSetNotifyResetRemovesEveryDuplicateNotifyMember(t *testing.T) {
|
||||
if err := os.WriteFile(path, []byte(duplicated), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := setNotify(path, NotifyConfig{Mode: "off", FocusMode: "always"}); err != nil {
|
||||
t.Fatalf("setNotify replace: %v", err)
|
||||
if _, err := SetNotify(path, NotifyConfig{Mode: "off", FocusMode: "always"}); err != nil {
|
||||
t.Fatalf("SetNotify replace: %v", err)
|
||||
}
|
||||
raw, err = os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -1411,7 +1394,7 @@ func TestUpdateNotifyRacesExistingConfigMutatorWithoutLoss(t *testing.T) {
|
||||
// fields of duplicate JSON members into one struct, so a full replacement that
|
||||
// omits a field could inherit it from an earlier duplicate —
|
||||
// {"notify":{"mode":"off"},"notify":{"focusMode":"focused"}} +
|
||||
// setNotify({FocusMode:"always"}) must yield blank/always, not off/always.
|
||||
// SetNotify({FocusMode:"always"}) must yield blank/always, not off/always.
|
||||
// The editor removes every duplicate and inserts the replacement fresh.
|
||||
func TestSetNotifyReplacementInheritsNoFieldFromEarlierDuplicate(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "zero.json")
|
||||
@@ -1420,8 +1403,8 @@ func TestSetNotifyReplacementInheritsNoFieldFromEarlierDuplicate(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := setNotify(path, NotifyConfig{FocusMode: "always"}); err != nil {
|
||||
t.Fatalf("setNotify: %v", err)
|
||||
if _, err := SetNotify(path, NotifyConfig{FocusMode: "always"}); err != nil {
|
||||
t.Fatalf("SetNotify: %v", err)
|
||||
}
|
||||
stored, err := UserNotify(path)
|
||||
if err != nil {
|
||||
@@ -1454,8 +1437,8 @@ func TestSetNotifySingleMemberReplacedInPlace(t *testing.T) {
|
||||
if err := os.WriteFile(path, []byte(original), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := setNotify(path, NotifyConfig{Mode: "off", FocusMode: "always"}); err != nil {
|
||||
t.Fatalf("setNotify: %v", err)
|
||||
if _, err := SetNotify(path, NotifyConfig{Mode: "off", FocusMode: "always"}); err != nil {
|
||||
t.Fatalf("SetNotify: %v", err)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
|
||||
@@ -24,7 +24,6 @@ const (
|
||||
Connectivity
|
||||
ModelNotFound
|
||||
ContextOverflow
|
||||
LocalRPM
|
||||
)
|
||||
|
||||
// providerMarkers are the prefixes the provider layer attaches to every
|
||||
@@ -60,8 +59,6 @@ func Classify(err error) Category {
|
||||
return Unknown
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(m, "rate limit error: local rpm limit for"):
|
||||
return LocalRPM
|
||||
case containsAny(m, "auth error:", "unauthorized", "api key", "api_key", "invalid_api_key",
|
||||
"authentication", "permission denied", "forbidden") || containsStatusCode(m, "401", "403"):
|
||||
return Auth
|
||||
@@ -92,8 +89,6 @@ func TUIHint(err error) string {
|
||||
return "API key rejected — run /provider to re-check your credentials"
|
||||
case RateLimit:
|
||||
return "Rate limited — wait a moment, or switch model with /model"
|
||||
case LocalRPM:
|
||||
return "Local modelRPM cap reached — wait for the window to expire, or switch model with /model"
|
||||
case Connectivity:
|
||||
return "Can't reach the provider — run /doctor --connectivity"
|
||||
case ModelNotFound:
|
||||
@@ -114,8 +109,6 @@ func CLIHint(err error) string {
|
||||
return "API key rejected — run `zero setup`, `zero auth openrouter` for OpenRouter, or set the provider's API key"
|
||||
case RateLimit:
|
||||
return "Rate limited — wait a moment, or switch model with --model"
|
||||
case LocalRPM:
|
||||
return "Local modelRPM cap reached — wait for the window to expire, or switch model with --model"
|
||||
case Connectivity:
|
||||
return "Can't reach the provider — run `zero doctor`"
|
||||
case ModelNotFound:
|
||||
|
||||
@@ -17,7 +17,6 @@ func TestClassify(t *testing.T) {
|
||||
{"raw 401", "provider request error: 401 Unauthorized", Auth},
|
||||
{"invalid api key", "provider request error: invalid_api_key: incorrect key provided", Auth},
|
||||
{"rate limit prefix", "rate limit error: 429 too many requests", RateLimit},
|
||||
{"local RPM", `rate limit error: local RPM limit for "gpt-4.1" (2 requests/60s); retry in 12s`, LocalRPM},
|
||||
{"overloaded", "provider error: model is overloaded, please retry", RateLimit},
|
||||
{"resource exhausted gemini", "provider stream error: rpc error: code = ResourceExhausted desc = quota exceeded", RateLimit},
|
||||
{"context length openai", "provider request error: this model's maximum context length is 128000 tokens", ContextOverflow},
|
||||
|
||||
@@ -143,10 +143,6 @@ var descriptors = []Descriptor{
|
||||
openAICompat("dashscope", "DashScope", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "qwen-plus", []string{"DASHSCOPE_API_KEY", "QWEN_API_KEY"}, "qwen"),
|
||||
openAICompat("moonshot", "Moonshot AI", "https://api.moonshot.ai/v1", "kimi-k2-0905-preview", []string{"MOONSHOT_API_KEY"}, "kimi"),
|
||||
openAICompat("atlascloud", "Atlas Cloud", "https://api.atlascloud.ai/v1", "qwen/qwen3.5-flash", []string{"ATLASCLOUD_API_KEY"}, "atlas cloud", "atlas"),
|
||||
// GMI Cloud — OpenAI-compatible inference. The id matches the models.dev
|
||||
// provider id so the remote catalog resolves without a mapping; GMI's own
|
||||
// docs name the key GMI_API_KEY, models.dev names it GMICLOUD_API_KEY.
|
||||
openAICompat("gmicloud", "GMI Cloud", "https://api.gmi-serving.com/v1", "Qwen/Qwen3.8-Max", []string{"GMI_API_KEY", "GMICLOUD_API_KEY"}, "gmi cloud", "gmi"),
|
||||
openAICompat("longcat", "LongCat", "https://api.longcat.chat/openai", "LongCat-2.0", []string{"LONGCAT_API_KEY"}, "meituan longcat", "meituan", "longcat-2.0"),
|
||||
openAICompat("nvidia-nim", "NVIDIA NIM", "https://integrate.api.nvidia.com/v1", "nvidia/llama-3.1-nemotron-70b-instruct", []string{"NVIDIA_API_KEY"}, "nvidia nim"),
|
||||
anthropicCompat("minimax", "MiniMax", "https://api.minimax.io/anthropic", "MiniMax-M3", []string{"MINIMAX_API_KEY"}, "mini-max", "mini_max"),
|
||||
|
||||
@@ -27,7 +27,6 @@ var expectedCatalogIDs = []string{
|
||||
"dashscope",
|
||||
"moonshot",
|
||||
"atlascloud",
|
||||
"gmicloud",
|
||||
"longcat",
|
||||
"nvidia-nim",
|
||||
"minimax",
|
||||
@@ -202,31 +201,6 @@ func TestAtlasCloudDescriptor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGMICloudDescriptor(t *testing.T) {
|
||||
descriptor, err := Require("gmicloud")
|
||||
if err != nil {
|
||||
t.Fatalf("Require(gmicloud) error = %v", err)
|
||||
}
|
||||
if descriptor.Name != "GMI Cloud" {
|
||||
t.Fatalf("Name = %q, want GMI Cloud", descriptor.Name)
|
||||
}
|
||||
if descriptor.DefaultBaseURL != "https://api.gmi-serving.com/v1" {
|
||||
t.Fatalf("DefaultBaseURL = %q, want GMI Cloud OpenAI-compatible endpoint", descriptor.DefaultBaseURL)
|
||||
}
|
||||
if descriptor.DefaultModel != "Qwen/Qwen3.8-Max" {
|
||||
t.Fatalf("DefaultModel = %q, want Qwen/Qwen3.8-Max", descriptor.DefaultModel)
|
||||
}
|
||||
if descriptor.Transport != TransportOpenAICompatible {
|
||||
t.Fatalf("Transport = %q, want %q", descriptor.Transport, TransportOpenAICompatible)
|
||||
}
|
||||
if !descriptor.RequiresAuth {
|
||||
t.Fatal("RequiresAuth = false, want true")
|
||||
}
|
||||
if !reflect.DeepEqual(descriptor.AuthEnvVars, []string{"GMI_API_KEY", "GMICLOUD_API_KEY"}) {
|
||||
t.Fatalf("AuthEnvVars = %#v, want GMI_API_KEY then GMICLOUD_API_KEY", descriptor.AuthEnvVars)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogDescriptorsExposeRequiredDefaults(t *testing.T) {
|
||||
for _, descriptor := range All() {
|
||||
if descriptor.ID == "" {
|
||||
@@ -347,9 +321,6 @@ func TestLookupNormalizesIDsAndAliases(t *testing.T) {
|
||||
"mini_max": "minimax",
|
||||
"Moonshot": "moonshot",
|
||||
"Atlas Cloud": "atlascloud",
|
||||
"GMI Cloud": "gmicloud",
|
||||
"gmi-cloud": "gmicloud",
|
||||
"gmi": "gmicloud",
|
||||
"nvidia nim": "nvidia-nim",
|
||||
"xiaomi mimo": "xiaomi-mimo",
|
||||
"custom_openai_compatible": "custom-openai-compatible",
|
||||
@@ -400,7 +371,7 @@ func TestListByTransportPreservesCatalogOrder(t *testing.T) {
|
||||
TransportBedrock: {"bedrock"},
|
||||
TransportVertex: {"vertex"},
|
||||
TransportAnthropicCompat: {"minimax", "minimaxi-cn", "opencode-go-anthropic-compatible", "custom-anthropic-compatible"},
|
||||
TransportOpenAICompat: {"gitlawb-opengateway", "aimlapi", "ollama-cloud", "ollama", "lmstudio", "atomic-chat-local", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "fireworks", "dashscope", "moonshot", "atlascloud", "gmicloud", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
|
||||
TransportOpenAICompat: {"gitlawb-opengateway", "aimlapi", "ollama-cloud", "ollama", "lmstudio", "atomic-chat-local", "openrouter", "huggingface", "chatgpt", "groq", "deepseek", "together", "fireworks", "dashscope", "moonshot", "atlascloud", "longcat", "nvidia-nim", "mistral", "github", "xai", "venice", "xiaomi-mimo", "bankr", "zai", "zai-cn", "kilocode", "opencode", "opencode-go", "atomic-chat", "chatgpt-proxy", "custom-openai-compatible"},
|
||||
}
|
||||
|
||||
for transport, wantIDs := range cases {
|
||||
|
||||
@@ -149,12 +149,6 @@ var curatedModels = map[string][]Model{
|
||||
{ID: "qwen/qwen3.5-flash", Description: "catalog default"},
|
||||
{ID: "deepseek-ai/deepseek-v4-pro", Description: "reasoning model"},
|
||||
},
|
||||
"gmicloud": {
|
||||
{ID: "Qwen/Qwen3.8-Max", Description: "catalog default"},
|
||||
{ID: "Qwen/Qwen3.8-Flash", Description: "fast model"},
|
||||
{ID: "deepseek-ai/DeepSeek-V4-Pro", Description: "reasoning model"},
|
||||
{ID: "deepseek-ai/DeepSeek-V4-Flash", Description: "fast coding model"},
|
||||
},
|
||||
"nvidia-nim": {
|
||||
{ID: "nvidia/llama-3.1-nemotron-70b-instruct", Description: "catalog default"},
|
||||
{ID: "meta/llama-3.1-70b-instruct", Description: "general model"},
|
||||
|
||||
@@ -48,11 +48,6 @@ func TestModelsAreProviderScoped(t *testing.T) {
|
||||
want: []string{"qwen/qwen3.5-flash", "deepseek-ai/deepseek-v4-pro"},
|
||||
notWant: []string{"gpt-4.1", "claude-sonnet-4.5", "openai/gpt-4.1"},
|
||||
},
|
||||
{
|
||||
provider: "gmicloud",
|
||||
want: []string{"Qwen/Qwen3.8-Max", "Qwen/Qwen3.8-Flash", "deepseek-ai/DeepSeek-V4-Pro", "deepseek-ai/DeepSeek-V4-Flash"},
|
||||
notWant: []string{"gpt-4.1", "claude-sonnet-4.5", "deepseek-ai/deepseek-v4-pro"},
|
||||
},
|
||||
{
|
||||
provider: "mistral",
|
||||
want: []string{"mistral-large-latest", "codestral-latest"},
|
||||
|
||||
@@ -22,15 +22,14 @@ import (
|
||||
// An error is reported to the loop, which records a note and continues on the
|
||||
// current model.
|
||||
//
|
||||
// The session switcher is installed for an optimized start or configured RPM
|
||||
// limits. Default-adapter starts retain that transport after escalation.
|
||||
// The same limiter wraps switched sessions so switching cannot reset a cap.
|
||||
// The session switcher comes back nil unless the run STARTED optimized. A run
|
||||
// that began on the default adapter stays on it, so escalation cannot quietly
|
||||
// change the transport underneath a session.
|
||||
func EscalationSwitchers(
|
||||
profile config.ProviderProfile,
|
||||
provider zeroruntime.Provider,
|
||||
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error),
|
||||
onSwitch func(modelID string),
|
||||
sessionOptions ...Options,
|
||||
) (
|
||||
func(context.Context, string) (zeroruntime.Provider, error),
|
||||
func(context.Context, string) (zeroruntime.TurnSessionProvider, error),
|
||||
@@ -38,10 +37,6 @@ func EscalationSwitchers(
|
||||
if newProvider == nil {
|
||||
return nil, nil
|
||||
}
|
||||
options := Options{}
|
||||
if len(sessionOptions) > 0 {
|
||||
options = sessionOptions[0]
|
||||
}
|
||||
// The escalated profile is the run's profile with the model replaced, so the
|
||||
// credential, base URL and headers travel with it. Callers pass a newProvider
|
||||
// that already applies the stored key, which is why there is no per-site key
|
||||
@@ -67,8 +62,8 @@ func EscalationSwitchers(
|
||||
return switchedProvider, nil
|
||||
}
|
||||
|
||||
turnSessions, _ := OptimizedTurnSessions(profile, provider, options)
|
||||
if turnSessions == nil && options.ModelRPM == nil {
|
||||
turnSessions, _ := OptimizedTurnSessions(profile, provider, Options{})
|
||||
if turnSessions == nil {
|
||||
return modelSwitcher, nil
|
||||
}
|
||||
|
||||
@@ -83,14 +78,12 @@ func EscalationSwitchers(
|
||||
if onSwitch != nil {
|
||||
onSwitch(modelID)
|
||||
}
|
||||
if turnSessions != nil {
|
||||
if optimized, ok := OptimizedTurnSessions(switchedProfile, switchedProvider, options); ok {
|
||||
return limitTurnSessions(switchedProfile, optimized, options), nil
|
||||
}
|
||||
if optimized, ok := OptimizedTurnSessions(switchedProfile, switchedProvider, Options{}); ok {
|
||||
return optimized, nil
|
||||
}
|
||||
// Ineligible target: the default adapter, but carrying the switched
|
||||
// model's own capability projection rather than the original's.
|
||||
return limitTurnSessions(switchedProfile, DefaultTurnSessions(switchedProfile, switchedProvider, options), options), nil
|
||||
return DefaultTurnSessions(switchedProfile, switchedProvider, Options{}), nil
|
||||
}
|
||||
return modelSwitcher, sessionSwitcher
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
|
||||
// Options configures provider construction.
|
||||
type Options struct {
|
||||
ModelRPM *zeroruntime.ModelRPMLimiter
|
||||
UserAgent string
|
||||
HTTPClient *http.Client
|
||||
ModelRegistry *modelregistry.Registry
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type rpmRoundTripper struct{ calls atomic.Int64 }
|
||||
|
||||
func (r *rpmRoundTripper) RoundTrip(q *http.Request) (*http.Response, error) {
|
||||
r.calls.Add(1)
|
||||
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": {"text/event-stream"}}, Body: io.NopCloser(strings.NewReader("data: [DONE]\n\n")), Request: q}, nil
|
||||
}
|
||||
func TestConfiguredRPMBlocksNPlusOneBeforeHTTP(t *testing.T) {
|
||||
for _, opt := range []string{"0", "1"} {
|
||||
t.Run("optimized="+opt, func(t *testing.T) {
|
||||
t.Setenv(openaiTurnSessionEnv, opt)
|
||||
r := &rpmRoundTripper{}
|
||||
o := Options{HTTPClient: &http.Client{Transport: r}, ModelRPM: zeroruntime.NewModelRPMLimiter(map[string]int{"gpt-4.1": 2})}
|
||||
profile := config.ProviderProfile{Name: "test", ProviderKind: config.ProviderKindOpenAI, Model: "openai:gpt-4.1", APIKey: "fixture-only"}
|
||||
p, e := New(profile, o)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if i == 2 {
|
||||
profile.Model = "gpt-4.1"
|
||||
}
|
||||
ss, ok := ConfiguredTurnSessions(profile, p, o)
|
||||
if !ok {
|
||||
t.Fatal("missing wrapper")
|
||||
}
|
||||
s, e := ss.OpenTurnSession(context.Background())
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
ch, e := s.Stream(context.Background(), zeroruntime.CompletionRequest{Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "fixture"}}})
|
||||
if i == 2 {
|
||||
var hit *zeroruntime.RPMLimitError
|
||||
if !errors.As(e, &hit) || ch != nil {
|
||||
t.Fatalf("N+1 was not denied: %v", e)
|
||||
}
|
||||
} else {
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
for range ch {
|
||||
}
|
||||
}
|
||||
if e := s.Close(); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
if r.calls.Load() != 2 {
|
||||
t.Fatalf("transport=%d want 2", r.calls.Load())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestEscalationSharesRPMWhenStartingUnoptimized(t *testing.T) {
|
||||
t.Setenv(openaiTurnSessionEnv, "0")
|
||||
o := Options{ModelRPM: zeroruntime.NewModelRPMLimiter(map[string]int{"gpt-4.1": 1})}
|
||||
profile := config.ProviderProfile{Name: "test", ProviderKind: config.ProviderKindOpenAI, Model: "gpt-4.1"}
|
||||
p := escalationStubProvider{}
|
||||
ss, _ := ConfiguredTurnSessions(profile, p, o)
|
||||
s, e := ss.OpenTurnSession(context.Background())
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, e = s.Stream(context.Background(), zeroruntime.CompletionRequest{}); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
_, switcher := EscalationSwitchers(profile, p, func(config.ProviderProfile) (zeroruntime.Provider, error) { return p, nil }, nil, o)
|
||||
if switcher == nil {
|
||||
t.Fatal("missing shared session switcher")
|
||||
}
|
||||
ss, e = switcher(context.Background(), "openai:gpt-4.1")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
s, e = ss.OpenTurnSession(context.Background())
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
_, e = s.Stream(context.Background(), zeroruntime.CompletionRequest{})
|
||||
var hit *zeroruntime.RPMLimitError
|
||||
if !errors.As(e, &hit) {
|
||||
t.Fatalf("switch reset cap: %v", e)
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/trace"
|
||||
@@ -90,22 +89,6 @@ func ContentStallTimeout(idleTimeout time.Duration) time.Duration {
|
||||
// entirely (streams may then hang until the HTTP/transport layer gives up).
|
||||
const streamIdleTimeoutEnv = "ZERO_STREAM_IDLE_TIMEOUT"
|
||||
|
||||
// bareSecondsDuration converts a positive second count into a time.Duration.
|
||||
// time.Duration is int64 nanoseconds, so a count that passes strconv.Atoi can
|
||||
// still overflow when multiplied by time.Second and wrap to 0, which would
|
||||
// disable a timeout that should have stayed at its default. ok is false when
|
||||
// the product does not fit.
|
||||
func bareSecondsDuration(secs int) (time.Duration, bool) {
|
||||
if secs <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
const maxSecs = int64(math.MaxInt64) / int64(time.Second)
|
||||
if int64(secs) > maxSecs {
|
||||
return 0, false
|
||||
}
|
||||
return time.Duration(secs) * time.Second, true
|
||||
}
|
||||
|
||||
// ResolveStreamIdleTimeout selects the effective stream idle timeout. Precedence:
|
||||
// an explicit positive option (e.g. set by a test) wins; otherwise the
|
||||
// ZERO_STREAM_IDLE_TIMEOUT env override if set and valid; otherwise
|
||||
@@ -122,59 +105,15 @@ func ResolveStreamIdleTimeout(option time.Duration) time.Duration {
|
||||
if d, err := time.ParseDuration(raw); err == nil && d > 0 {
|
||||
return d
|
||||
}
|
||||
if secs, err := strconv.Atoi(raw); err == nil {
|
||||
if d, ok := bareSecondsDuration(secs); ok {
|
||||
return d
|
||||
}
|
||||
if secs, err := strconv.Atoi(raw); err == nil && secs > 0 {
|
||||
return time.Duration(secs) * time.Second
|
||||
}
|
||||
// Unparseable / non-positive / overflow: fall through to the default
|
||||
// rather than silently disabling the watchdog.
|
||||
// Unparseable / non-positive: fall through to the default rather than
|
||||
// silently disabling the watchdog on a typo.
|
||||
}
|
||||
return DefaultStreamIdleTimeout
|
||||
}
|
||||
|
||||
// DefaultResponseHeaderTimeout is how long the shared HTTP transport waits for
|
||||
// a response header after the request is written. 120s, not 60s: a slow cloud
|
||||
// proxy (e.g. ollama `*:cloud`) can withhold its 200 response header until the
|
||||
// upstream model emits a first token, so a 60s cap risked aborting a
|
||||
// legitimately-slow-but-alive request. 120s still bounds a truly dead reused
|
||||
// connection (which never responds) while tolerating slow header delivery; slow
|
||||
// first tokens after the header are covered by the idle + content-stall
|
||||
// watchdogs.
|
||||
const DefaultResponseHeaderTimeout = 120 * time.Second
|
||||
|
||||
// responseHeaderTimeoutEnv is the global override for the response header
|
||||
// timeout. It accepts the same forms as ZERO_STREAM_IDLE_TIMEOUT: a Go duration
|
||||
// ("5m", "300s", "90s") or a bare number of seconds ("300"). A value of "0",
|
||||
// "off", "none", or "disabled" removes the limit entirely (a connection that
|
||||
// never answers may then wait until the request context ends). Useful when a
|
||||
// local model server needs longer than DefaultResponseHeaderTimeout to produce
|
||||
// the first byte, for example a cold model load on a throttled Ollama.
|
||||
const responseHeaderTimeoutEnv = "ZERO_RESPONSE_HEADER_TIMEOUT"
|
||||
|
||||
// ResolveResponseHeaderTimeout selects the effective response header timeout:
|
||||
// the ZERO_RESPONSE_HEADER_TIMEOUT env override if set and valid, otherwise
|
||||
// DefaultResponseHeaderTimeout. A returned value <= 0 means no limit.
|
||||
func ResolveResponseHeaderTimeout() time.Duration {
|
||||
if raw := strings.TrimSpace(os.Getenv(responseHeaderTimeoutEnv)); raw != "" {
|
||||
switch strings.ToLower(raw) {
|
||||
case "0", "off", "none", "disabled":
|
||||
return 0
|
||||
}
|
||||
if d, err := time.ParseDuration(raw); err == nil && d > 0 {
|
||||
return d
|
||||
}
|
||||
if secs, err := strconv.Atoi(raw); err == nil {
|
||||
if d, ok := bareSecondsDuration(secs); ok {
|
||||
return d
|
||||
}
|
||||
}
|
||||
// Unparseable / non-positive / overflow: fall through to the default
|
||||
// rather than silently removing the limit.
|
||||
}
|
||||
return DefaultResponseHeaderTimeout
|
||||
}
|
||||
|
||||
// NormalizeBaseURL trims trailing slashes and validates an HTTP API base URL.
|
||||
func NormalizeBaseURL(baseURL string, defaultBaseURL string, label string) (string, error) {
|
||||
baseURL = strings.TrimSpace(baseURL)
|
||||
@@ -188,18 +127,7 @@ func NormalizeBaseURL(baseURL string, defaultBaseURL string, label string) (stri
|
||||
return baseURL, nil
|
||||
}
|
||||
|
||||
// sharedHTTPClients holds one stall-hardened client per resolved response-header
|
||||
// timeout. The timeout is read when HTTPClient is called, not at package init:
|
||||
// a test (or a process that sets ZERO_RESPONSE_HEADER_TIMEOUT before building
|
||||
// a provider) must see that value on the transport it actually dials. The
|
||||
// transport field is never rewritten after creation, so concurrent requests
|
||||
// keep the conn pool of the timeout they resolved.
|
||||
var sharedHTTPClients = struct {
|
||||
mu sync.Mutex
|
||||
byTimeout map[time.Duration]*http.Client
|
||||
}{}
|
||||
|
||||
// stallHardenedClient builds the client used when a provider supplies none.
|
||||
// sharedHTTPClient is the process-wide client used when a provider supplies none.
|
||||
// It tunes the default transport to defeat the stale-pooled-connection hang: Go
|
||||
// keeps idle keep-alive connections in a pool, and a later request can reuse one
|
||||
// the server/NAT has silently dropped. Because the model call is a POST (non-
|
||||
@@ -231,11 +159,15 @@ var sharedHTTPClients = struct {
|
||||
// to the minutes-long stalls this avoids — and this doesn't touch
|
||||
// Linux/Windows, where the underlying OS doesn't keep dead/degraded
|
||||
// pooled connections around as long.
|
||||
func stallHardenedClient(headerTimeout time.Duration) *http.Client {
|
||||
var sharedHTTPClient = func() *http.Client {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
// DefaultResponseHeaderTimeout (120s) unless ZERO_RESPONSE_HEADER_TIMEOUT
|
||||
// overrides it; see the constant for why the default is 120s and not 60s.
|
||||
transport.ResponseHeaderTimeout = headerTimeout
|
||||
// 120s, not 60s: a slow cloud proxy (e.g. ollama `*:cloud`) can withhold its
|
||||
// 200 response header until the upstream model emits a first token, so a 60s cap
|
||||
// risked aborting a legitimately-slow-but-alive request. 120s still bounds a
|
||||
// truly dead reused connection (which never responds) while tolerating slow
|
||||
// header delivery; slow first tokens after the header are covered by the idle +
|
||||
// content-stall watchdogs.
|
||||
transport.ResponseHeaderTimeout = 120 * time.Second
|
||||
transport.IdleConnTimeout = 30 * time.Second
|
||||
// Periodically close idle connections to prevent stale HTTP/2
|
||||
// connections from causing PROTOCOL_ERROR on the next request.
|
||||
@@ -251,22 +183,7 @@ func stallHardenedClient(headerTimeout time.Duration) *http.Client {
|
||||
}
|
||||
transport.DisableKeepAlives = runtime.GOOS == "darwin"
|
||||
return &http.Client{Transport: transport}
|
||||
}
|
||||
|
||||
func sharedStallClient() *http.Client {
|
||||
timeout := ResolveResponseHeaderTimeout()
|
||||
sharedHTTPClients.mu.Lock()
|
||||
defer sharedHTTPClients.mu.Unlock()
|
||||
if sharedHTTPClients.byTimeout == nil {
|
||||
sharedHTTPClients.byTimeout = make(map[time.Duration]*http.Client)
|
||||
}
|
||||
if client := sharedHTTPClients.byTimeout[timeout]; client != nil {
|
||||
return client
|
||||
}
|
||||
client := stallHardenedClient(timeout)
|
||||
sharedHTTPClients.byTimeout[timeout] = client
|
||||
return client
|
||||
}
|
||||
}()
|
||||
|
||||
const defaultIdleConnCloseInterval = 30 * time.Second
|
||||
|
||||
@@ -304,7 +221,7 @@ func HTTPClient(client *http.Client) *http.Client {
|
||||
if client != nil {
|
||||
return client
|
||||
}
|
||||
return sharedStallClient()
|
||||
return sharedHTTPClient
|
||||
}
|
||||
|
||||
// SendEvent writes a provider event without blocking cancellation cleanup.
|
||||
|
||||
@@ -340,10 +340,6 @@ func TestStreamTimeoutMessage(t *testing.T) {
|
||||
// response-header wait + shorter idle-conn reuse) that defeats the macOS stale-
|
||||
// pooled-connection hang; an explicit client is returned untouched.
|
||||
func TestHTTPClientReturnsStallHardenedSharedClient(t *testing.T) {
|
||||
// Package init must not bake the timeout. An ambient
|
||||
// ZERO_RESPONSE_HEADER_TIMEOUT would otherwise make this assertion
|
||||
// depend on whatever the process inherited.
|
||||
t.Setenv("ZERO_RESPONSE_HEADER_TIMEOUT", "")
|
||||
got := HTTPClient(nil)
|
||||
if got == nil {
|
||||
t.Fatal("HTTPClient(nil) returned nil")
|
||||
@@ -378,66 +374,6 @@ func TestHTTPClientReturnsStallHardenedSharedClient(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The shared transport must enforce the timeout ResolveResponseHeaderTimeout
|
||||
// returns. Checking the resolver alone stays green if the client still carries
|
||||
// the value captured at init.
|
||||
func TestHTTPClientTransportUsesResolvedHeaderTimeout(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
delay, err := time.ParseDuration(r.URL.Query().Get("delay"))
|
||||
if err != nil {
|
||||
http.Error(w, "bad delay", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
time.Sleep(delay)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
t.Setenv("ZERO_RESPONSE_HEADER_TIMEOUT", "200ms")
|
||||
short := HTTPClient(nil)
|
||||
tr, ok := short.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("transport type = %T, want *http.Transport", short.Transport)
|
||||
}
|
||||
if tr.ResponseHeaderTimeout != ResolveResponseHeaderTimeout() || tr.ResponseHeaderTimeout != 200*time.Millisecond {
|
||||
t.Fatalf("ResponseHeaderTimeout = %v, resolver = %v, want 200ms", tr.ResponseHeaderTimeout, ResolveResponseHeaderTimeout())
|
||||
}
|
||||
started := time.Now()
|
||||
resp, err := short.Get(srv.URL + "?delay=1s")
|
||||
if resp != nil {
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("slow header succeeded; the transport did not apply the 200ms timeout")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "timeout awaiting response headers") {
|
||||
t.Fatalf("error = %v, want the transport's response-header timeout", err)
|
||||
}
|
||||
if elapsed := time.Since(started); elapsed >= 800*time.Millisecond {
|
||||
t.Fatalf("header wait took %v; the transport waited on the handler instead of the resolved timeout", elapsed)
|
||||
}
|
||||
|
||||
t.Setenv("ZERO_RESPONSE_HEADER_TIMEOUT", "2s")
|
||||
long := HTTPClient(nil)
|
||||
if long == short {
|
||||
t.Fatal("a new resolved timeout reused the transport that still has the previous timeout")
|
||||
}
|
||||
if got := long.Transport.(*http.Transport).ResponseHeaderTimeout; got != 2*time.Second {
|
||||
t.Fatalf("ResponseHeaderTimeout = %v, want 2s", got)
|
||||
}
|
||||
resp, err = long.Get(srv.URL + "?delay=50ms")
|
||||
if err != nil {
|
||||
t.Fatalf("header within the resolved timeout failed: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, want 204", resp.StatusCode)
|
||||
}
|
||||
if again := HTTPClient(nil); again != long {
|
||||
t.Fatal("the same resolved timeout must keep one shared client")
|
||||
}
|
||||
}
|
||||
|
||||
// startIdleConnCloser periodically closes idle pooled connections so stale HTTP/2
|
||||
// connections are not reused across long idle periods.
|
||||
func TestStartIdleConnCloserClosesIdleConnections(t *testing.T) {
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
package providerio
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestResolveResponseHeaderTimeout(t *testing.T) {
|
||||
const env = "ZERO_RESPONSE_HEADER_TIMEOUT"
|
||||
|
||||
t.Run("default when env is unset or empty", func(t *testing.T) {
|
||||
t.Setenv(env, "")
|
||||
if got := ResolveResponseHeaderTimeout(); got != DefaultResponseHeaderTimeout {
|
||||
t.Fatalf("got %v, want default %v", got, DefaultResponseHeaderTimeout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("default keeps the value that was previously hardcoded", func(t *testing.T) {
|
||||
if DefaultResponseHeaderTimeout != 120*time.Second {
|
||||
t.Fatalf("default is %v; this override must not change the 120s default", DefaultResponseHeaderTimeout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("env Go duration", func(t *testing.T) {
|
||||
t.Setenv(env, "240s")
|
||||
if got := ResolveResponseHeaderTimeout(); got != 240*time.Second {
|
||||
t.Fatalf("got %v, want 240s", got)
|
||||
}
|
||||
t.Setenv(env, "5m")
|
||||
if got := ResolveResponseHeaderTimeout(); got != 5*time.Minute {
|
||||
t.Fatalf("got %v, want 5m", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("env bare seconds", func(t *testing.T) {
|
||||
t.Setenv(env, "300")
|
||||
if got := ResolveResponseHeaderTimeout(); got != 300*time.Second {
|
||||
t.Fatalf("got %v, want 300s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("env value is trimmed", func(t *testing.T) {
|
||||
t.Setenv(env, " 90s ")
|
||||
if got := ResolveResponseHeaderTimeout(); got != 90*time.Second {
|
||||
t.Fatalf("got %v, want 90s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("env removes the limit", func(t *testing.T) {
|
||||
for _, value := range []string{"0", "off", "none", "disabled", "OFF", "Disabled"} {
|
||||
t.Setenv(env, value)
|
||||
if got := ResolveResponseHeaderTimeout(); got != 0 {
|
||||
t.Fatalf("%q: got %v, want 0 (no limit)", value, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bare seconds that overflow time.Duration keep the default", func(t *testing.T) {
|
||||
t.Setenv(env, "36028797018963968")
|
||||
if got := ResolveResponseHeaderTimeout(); got != DefaultResponseHeaderTimeout {
|
||||
t.Fatalf("got %v, want default %v (overflow must not become a zero timeout)", got, DefaultResponseHeaderTimeout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid env falls back to default, not unlimited", func(t *testing.T) {
|
||||
for _, value := range []string{"banana", "-5s", "-1", "1.5x"} {
|
||||
t.Setenv(env, value)
|
||||
if got := ResolveResponseHeaderTimeout(); got != DefaultResponseHeaderTimeout {
|
||||
t.Fatalf("%q: got %v, want default %v (a typo must not remove the limit)", value, got, DefaultResponseHeaderTimeout)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -49,13 +49,6 @@ func TestResolveStreamIdleTimeout(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bare seconds that overflow time.Duration keep the default", func(t *testing.T) {
|
||||
t.Setenv(env, "36028797018963968")
|
||||
if got := ResolveStreamIdleTimeout(0); got != DefaultStreamIdleTimeout {
|
||||
t.Fatalf("got %v, want default %v (overflow must not disable the watchdog)", got, DefaultStreamIdleTimeout)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid env falls back to default, not disabled", func(t *testing.T) {
|
||||
t.Setenv(env, "banana")
|
||||
if got := ResolveStreamIdleTimeout(0); got != DefaultStreamIdleTimeout {
|
||||
|
||||
@@ -101,25 +101,3 @@ func DefaultTurnSessions(profile config.ProviderProfile, provider zeroruntime.Pr
|
||||
}
|
||||
return zeroruntime.NewProviderTurnSessionProvider(provider, caps)
|
||||
}
|
||||
|
||||
// ConfiguredTurnSessions preserves transport selection and wraps the starting
|
||||
// session when RPM is configured. Without either feature, the default is nil.
|
||||
func ConfiguredTurnSessions(profile config.ProviderProfile, p zeroruntime.Provider, o Options) (zeroruntime.TurnSessionProvider, bool) {
|
||||
s, ok := OptimizedTurnSessions(profile, p, o)
|
||||
if o.ModelRPM == nil {
|
||||
return s, ok
|
||||
}
|
||||
if !ok {
|
||||
s = DefaultTurnSessions(profile, p, o)
|
||||
}
|
||||
return limitTurnSessions(profile, s, o), true
|
||||
}
|
||||
func limitTurnSessions(profile config.ProviderProfile, s zeroruntime.TurnSessionProvider, o Options) zeroruntime.TurnSessionProvider {
|
||||
model := strings.TrimSpace(profile.Model)
|
||||
if registry, err := defaultRegistry(o.ModelRegistry); err == nil {
|
||||
if id, ok := registry.ResolveID(model); ok {
|
||||
model = id
|
||||
}
|
||||
}
|
||||
return o.ModelRPM.Wrap(model, s)
|
||||
}
|
||||
@@ -89,7 +89,6 @@ type model struct {
|
||||
// allowEscalation mirrors Options.AllowEscalation: it gates the per-run model
|
||||
// switchers, and the caller gates the escalate_model tool on the same flag.
|
||||
allowEscalation bool
|
||||
modelRPM *zeroruntime.ModelRPMLimiter
|
||||
newProvider func(config.ProviderProfile) (zeroruntime.Provider, error)
|
||||
newTurnSessionProvider func(config.ProviderProfile, zeroruntime.Provider) zeroruntime.TurnSessionProvider
|
||||
probeProviderHealth func(context.Context, providerhealth.Options) providerhealth.Result
|
||||
@@ -1056,7 +1055,6 @@ func newModel(ctx context.Context, options Options) model {
|
||||
sandboxSetupCommand: options.SandboxSetupCommand,
|
||||
agentOptions: options.AgentOptions,
|
||||
allowEscalation: options.AllowEscalation,
|
||||
modelRPM: options.ModelRPM,
|
||||
sessionCompactor: options.SessionCompactor,
|
||||
runtimeMessageSink: options.RuntimeMessageSink,
|
||||
permissionMode: permissionMode,
|
||||
@@ -5683,7 +5681,6 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
|
||||
// currentModel: every usage event after a real escalation is billed
|
||||
// to the escalated model, not the one the run started on.
|
||||
func(modelID string) { usageModelID = modelID },
|
||||
providers.Options{ModelRPM: m.modelRPM},
|
||||
)
|
||||
}
|
||||
options.SupportsVision = func(modelID string) bool {
|
||||
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
|
||||
// Options configures the reusable Zero terminal UI shell.
|
||||
type Options struct {
|
||||
ModelRPM *zeroruntime.ModelRPMLimiter
|
||||
Cwd string
|
||||
Version string // CLI build version, shown on the home screen; empty hides it
|
||||
UserConfigPath string
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
package zeroruntime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ModelRPMLimiter shares a sliding 60-second window across turn sessions in
|
||||
// one runtime process. Separate processes do not share state.
|
||||
type ModelRPMLimiter struct {
|
||||
mu sync.Mutex
|
||||
limits map[string]int
|
||||
windows map[string][]time.Time
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewModelRPMLimiter copies validated canonical-model limits. Zero/unset is off.
|
||||
func NewModelRPMLimiter(limits map[string]int) *ModelRPMLimiter {
|
||||
active := make(map[string]int)
|
||||
for m, n := range limits {
|
||||
if n > 0 {
|
||||
active[m] = n
|
||||
}
|
||||
}
|
||||
if len(active) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &ModelRPMLimiter{limits: active, windows: make(map[string][]time.Time), now: time.Now}
|
||||
}
|
||||
|
||||
// RPMLimitError denotes rejection before the wrapped Stream method is called.
|
||||
type RPMLimitError struct {
|
||||
Model string
|
||||
Limit int
|
||||
RetryAfter time.Duration
|
||||
}
|
||||
|
||||
func (e *RPMLimitError) Error() string {
|
||||
retry := e.RetryAfter.Round(time.Second)
|
||||
if retry < e.RetryAfter {
|
||||
retry += time.Second
|
||||
}
|
||||
return fmt.Sprintf("rate limit error: local RPM limit for %q (%d requests/60s); retry in %s", e.Model, e.Limit, retry)
|
||||
}
|
||||
|
||||
// Wrap gates Stream admissions, not setup/prewarm, compaction or transport retries.
|
||||
func (l *ModelRPMLimiter) Wrap(model string, p TurnSessionProvider) TurnSessionProvider {
|
||||
if l == nil || l.limits[model] == 0 || p == nil {
|
||||
return p
|
||||
}
|
||||
return rpmProvider{TurnSessionProvider: p, limiter: l, model: model}
|
||||
}
|
||||
func (l *ModelRPMLimiter) admit(ctx context.Context, model string) error {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
now := l.now()
|
||||
w := l.windows[model]
|
||||
first := 0
|
||||
for first < len(w) && !w[first].After(now.Add(-time.Minute)) {
|
||||
first++
|
||||
}
|
||||
w = w[:copy(w, w[first:])]
|
||||
l.windows[model] = w
|
||||
if len(w) >= l.limits[model] {
|
||||
return &RPMLimitError{Model: model, Limit: l.limits[model], RetryAfter: w[0].Add(time.Minute).Sub(now)}
|
||||
}
|
||||
l.windows[model] = append(w, now)
|
||||
return nil
|
||||
}
|
||||
|
||||
type rpmProvider struct {
|
||||
TurnSessionProvider
|
||||
limiter *ModelRPMLimiter
|
||||
model string
|
||||
}
|
||||
|
||||
func (p rpmProvider) OpenTurnSession(ctx context.Context) (TurnSession, error) {
|
||||
s, err := p.TurnSessionProvider.OpenTurnSession(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rpmSession{TurnSession: s, limiter: p.limiter, model: p.model}, nil
|
||||
}
|
||||
|
||||
type rpmSession struct {
|
||||
TurnSession
|
||||
limiter *ModelRPMLimiter
|
||||
model string
|
||||
}
|
||||
|
||||
func (s rpmSession) Stream(ctx context.Context, r CompletionRequest) (<-chan StreamEvent, error) {
|
||||
if err := s.limiter.admit(ctx, s.model); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.TurnSession.Stream(ctx, r)
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package zeroruntime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type rpmTransport struct{ calls atomic.Int64 }
|
||||
|
||||
func (p *rpmTransport) StreamCompletion(context.Context, CompletionRequest) (<-chan StreamEvent, error) {
|
||||
p.calls.Add(1)
|
||||
ch := make(chan StreamEvent)
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
func rpmSessionFor(t *testing.T, l *ModelRPMLimiter, m string, p Provider) TurnSession {
|
||||
t.Helper()
|
||||
s, e := l.Wrap(m, NewProviderTurnSessionProvider(p, ProviderCapabilities{Model: m})).OpenTurnSession(context.Background())
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
return s
|
||||
}
|
||||
func TestRPMRejectsNPlusOneBeforeTransportAcrossSessions(t *testing.T) {
|
||||
l := NewModelRPMLimiter(map[string]int{"m": 3})
|
||||
clock := time.Unix(0, 0)
|
||||
l.now = func() time.Time { return clock }
|
||||
p := &rpmTransport{}
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, e := rpmSessionFor(t, l, "m", p).Stream(context.Background(), CompletionRequest{}); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
s := rpmSessionFor(t, l, "m", p)
|
||||
ch, e := s.Stream(context.Background(), CompletionRequest{})
|
||||
var hit *RPMLimitError
|
||||
if !errors.As(e, &hit) || ch != nil || hit.RetryAfter != time.Minute {
|
||||
t.Fatalf("N+1: stream=%v error=%v", ch, e)
|
||||
}
|
||||
if p.calls.Load() != 3 {
|
||||
t.Fatalf("transport calls=%d, want 3", p.calls.Load())
|
||||
}
|
||||
clock = clock.Add(time.Minute)
|
||||
if _, e = s.Stream(context.Background(), CompletionRequest{}); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
func TestRPMSlidingWindow(t *testing.T) {
|
||||
l := NewModelRPMLimiter(map[string]int{"m": 2})
|
||||
clock := time.Unix(59, 0)
|
||||
l.now = func() time.Time { return clock }
|
||||
s := rpmSessionFor(t, l, "m", &rpmTransport{})
|
||||
for i := 0; i < 2; i++ {
|
||||
if _, e := s.Stream(context.Background(), CompletionRequest{}); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
clock = time.Unix(60, 0)
|
||||
_, e := s.Stream(context.Background(), CompletionRequest{})
|
||||
var hit *RPMLimitError
|
||||
if !errors.As(e, &hit) || hit.RetryAfter != 59*time.Second {
|
||||
t.Fatalf("minute boundary reset window: %v", e)
|
||||
}
|
||||
}
|
||||
func TestRPMConcurrentAdmissions(t *testing.T) {
|
||||
l := NewModelRPMLimiter(map[string]int{"m": 7})
|
||||
l.now = func() time.Time { return time.Unix(0, 0) }
|
||||
p := &rpmTransport{}
|
||||
var grants, unexpected atomic.Int64
|
||||
var wg sync.WaitGroup
|
||||
start := make(chan struct{})
|
||||
for i := 0; i < 100; i++ {
|
||||
s := rpmSessionFor(t, l, "m", p)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, e := s.Stream(context.Background(), CompletionRequest{})
|
||||
var hit *RPMLimitError
|
||||
if e == nil {
|
||||
grants.Add(1)
|
||||
} else if !errors.As(e, &hit) {
|
||||
unexpected.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
if grants.Load() != 7 || p.calls.Load() != 7 || unexpected.Load() != 0 {
|
||||
t.Fatalf("grants=%d transport=%d unexpected=%d", grants.Load(), p.calls.Load(), unexpected.Load())
|
||||
}
|
||||
}
|
||||
func TestRPMCancellationFailedAttemptsAndIndependentModels(t *testing.T) {
|
||||
l := NewModelRPMLimiter(map[string]int{"a": 1, "b": 1})
|
||||
p := &recordingProvider{err: errors.New("fixture transport failure")}
|
||||
s := rpmSessionFor(t, l, "a", p)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, e := s.Stream(ctx, CompletionRequest{}); !errors.Is(e, context.Canceled) {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, e := s.Stream(context.Background(), CompletionRequest{}); !errors.Is(e, p.err) {
|
||||
t.Fatal(e)
|
||||
}
|
||||
_, e := s.Stream(context.Background(), CompletionRequest{})
|
||||
var hit *RPMLimitError
|
||||
if !errors.As(e, &hit) || len(p.requests) != 1 {
|
||||
t.Fatalf("failed attempt refunded: %v", e)
|
||||
}
|
||||
_, _ = rpmSessionFor(t, l, "b", p).Stream(context.Background(), CompletionRequest{})
|
||||
for i := 0; i < 3; i++ {
|
||||
_, _ = rpmSessionFor(t, l, "unlimited", p).Stream(context.Background(), CompletionRequest{})
|
||||
}
|
||||
if len(p.requests) != 5 {
|
||||
t.Fatal("other model incorrectly blocked")
|
||||
}
|
||||
}
|
||||
func TestRPMDisabled(t *testing.T) {
|
||||
l := NewModelRPMLimiter(map[string]int{"m": 0})
|
||||
p := &providerTurnSessionProvider{provider: &rpmTransport{}}
|
||||
if l != nil || l.Wrap("m", p) != p {
|
||||
t.Fatal("disabled limit altered provider")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user