Compare commits

...
37 changed files with 1011 additions and 148 deletions

No files matched your search

+1 -1
View File
@@ -429,7 +429,7 @@ func newCompactionState(options Options, task *taskState) *compactionState {
preserveLast: options.CompactionPreserveLast,
onUsage: options.OnUsage,
task: task,
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow}),
planner: newContextPlanner(contextPlannerConfig{contextWindow: options.ContextWindow, serviceTier: options.ServiceTier}),
trace: options.Trace,
}
return state
+2
View File
@@ -9,6 +9,7 @@ import (
type contextPlannerConfig struct {
contextWindow int
promptCacheKey string
serviceTier string
promptParts systemPromptParts
}
@@ -62,6 +63,7 @@ func (planner *contextPlanner) plan(messages []zeroruntime.Message, toolDefs []z
Messages: requestMessages,
Tools: planner.snapshotTools(toolDefs, fingerprint),
ReasoningEffort: reasoningEffort,
ServiceTier: planner.config.serviceTier,
PromptCacheKey: planner.config.promptCacheKey,
}
breakdown := MeasureContext(request.Messages, request.Tools, planner.config.contextWindow)
+2
View File
@@ -25,6 +25,7 @@ func TestContextPlannerPreservesProviderRequest(t *testing.T) {
planner := newContextPlanner(contextPlannerConfig{
contextWindow: 128_000,
promptCacheKey: "session-1",
serviceTier: "priority",
promptParts: systemPromptParts{prompt: "system", baseInstructions: "system"},
})
@@ -33,6 +34,7 @@ func TestContextPlannerPreservesProviderRequest(t *testing.T) {
Messages: copyMessages(messages),
Tools: toolDefs,
ReasoningEffort: "medium",
ServiceTier: "priority",
PromptCacheKey: "session-1",
}
if !reflect.DeepEqual(plan.Request, want) {
+17 -6
View File
@@ -144,9 +144,10 @@ func TestRunSwitchesProviderOnEscalationRequest(t *testing.T) {
var switchedTo string
switchCount := 0
result, err := Run(context.Background(), "go", firstProvider, Options{
Registry: registry,
Model: "claude-sonnet-4.5",
MaxTurns: 4,
Registry: registry,
Model: "claude-sonnet-4.5",
ServiceTier: "priority",
MaxTurns: 4,
ModelSwitcher: func(_ context.Context, modelID string) (Provider, error) {
switchCount++
switchedTo = modelID
@@ -167,9 +168,15 @@ func TestRunSwitchesProviderOnEscalationRequest(t *testing.T) {
if len(firstProvider.requests) != 1 {
t.Fatalf("expected first provider to handle exactly the escalation turn, got %d requests", len(firstProvider.requests))
}
if got := firstProvider.requests[0].ServiceTier; got != "priority" {
t.Fatalf("initial request service tier = %q, want priority", got)
}
if len(secondProvider.requests) != 1 {
t.Fatalf("expected second provider to handle the post-switch turn, got %d requests", len(secondProvider.requests))
}
if got := secondProvider.requests[0].ServiceTier; got != "" {
t.Fatalf("post-switch request retained service tier %q", got)
}
if result.FinalAnswer != "answered on the upgraded model" {
t.Fatalf("expected final answer from the swapped provider, got %q", result.FinalAnswer)
}
@@ -211,9 +218,10 @@ func TestRunEscalationSwitcherErrorIsNonFatal(t *testing.T) {
provider := &mockProvider{turns: escalateThenAnswerTurns("recovered")}
result, err := Run(context.Background(), "go", provider, Options{
Registry: registry,
Model: "claude-sonnet-4.5",
MaxTurns: 4,
Registry: registry,
Model: "claude-sonnet-4.5",
ServiceTier: "priority",
MaxTurns: 4,
ModelSwitcher: func(_ context.Context, _ string) (Provider, error) {
return nil, errors.New("provider build blew up")
},
@@ -225,6 +233,9 @@ func TestRunEscalationSwitcherErrorIsNonFatal(t *testing.T) {
if len(provider.requests) != 2 {
t.Fatalf("expected the run to continue on the original provider, got %d requests", len(provider.requests))
}
if got := provider.requests[1].ServiceTier; got != "priority" {
t.Fatalf("failed-switch request service tier = %q, want priority", got)
}
if result.FinalAnswer != "recovered" {
t.Fatalf("expected final answer after non-fatal switch error, got %q", result.FinalAnswer)
}
+6
View File
@@ -186,6 +186,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
planner := newContextPlanner(contextPlannerConfig{
contextWindow: options.ContextWindow,
promptCacheKey: options.SessionID,
serviceTier: options.ServiceTier,
promptParts: promptParts,
})
messages := zeroruntime.SeedMessagesWithImages(promptParts.prompt, prompt, options.Images)
@@ -829,6 +830,11 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
_ = session.Prewarm(ctx)
provider = sessionProvider{session: session}
options.Model = turnRequestedModel
// Service tiers are model capabilities. A successful escalation may
// target a model that does not support the tier selected before the
// switch, so let the destination use its default.
options.ServiceTier = ""
planner.config.serviceTier = ""
options.Trace.Counter(trace.CounterModelSwitches, 1)
// KNOWN LIMITATION (deferred): the compactor's context-window budget
// is fixed at run start from options.ContextWindow and is NOT updated
+1
View File
@@ -325,6 +325,7 @@ type Options struct {
ProviderName string
Model string
ReasoningEffort string
ServiceTier string
Cwd string
SystemPrompt string
// TransientSystemPrompt adds trusted runtime guidance for this run only.
+2 -48
View File
@@ -1,13 +1,8 @@
package cli
import (
"context"
"errors"
"net/http"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/providers/providerio"
)
@@ -28,46 +23,5 @@ import (
// ProviderProfile.OAuthLoginCandidates so the runtime resolver, the Codex account
// resolver, and the onboarding presence check never diverge.
func oauthLoginForProfile(profile config.ProviderProfile) (providerio.TokenResolver, string) {
candidates := profile.OAuthLoginCandidates()
if len(candidates) == 0 {
return nil, ""
}
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
return nil, ""
}
_, key, ok := oauth.FirstStored(store, candidates)
if !ok {
// No login under any candidate (or unreadable/invalid keys) → API-key
// auth, no resolver.
return nil, ""
}
manager, err := oauth.NewManager(oauth.ManagerOptions{
Store: store,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
// Refreshing a token the user logged into (possibly a preset provider like
// xAI) re-resolves that provider's OAuth config, which needs the preset.
AllowPresets: true,
})
if err != nil {
return nil, ""
}
resolver := func(ctx context.Context, forceRefresh bool) (string, string, bool, error) {
var token string
var rerr error
if forceRefresh {
token, rerr = manager.Handle401(ctx, key)
} else {
token, rerr = manager.GetFresh(ctx, key)
}
if errors.Is(rerr, oauth.ErrNoToken) {
// The login was removed since construction → fall back to the API key.
return "", "", false, nil
}
if rerr != nil {
return "", "", false, rerr
}
return "Authorization", "Bearer " + token, true, nil
}
return resolver, key
return providers.OAuthLoginForProfile(profile)
}
+12
View File
@@ -73,6 +73,18 @@ func runProvidersModels(args []string, stdout io.Writer, stderr io.Writer, deps
if description := strings.TrimSpace(model.Description); description != "" {
entry["description"] = description
}
if len(model.ReasoningEfforts) > 0 {
entry["reasoning_efforts"] = append([]string{}, model.ReasoningEfforts...)
}
if effort := strings.TrimSpace(model.DefaultReasoningEffort); effort != "" {
entry["default_reasoning_effort"] = effort
}
if len(model.ServiceTiers) > 0 {
entry["service_tiers"] = append([]string{}, model.ServiceTiers...)
}
if tier := strings.TrimSpace(model.DefaultServiceTier); tier != "" {
entry["default_service_tier"] = tier
}
items = append(items, entry)
}
payload := map[string]any{
+4 -1
View File
@@ -29,6 +29,9 @@ const (
// newer Anthropic models). Defined so a capability catalog that lists "max"
// has a constant to map to; no curated model lists it yet.
ReasoningEffortMax ReasoningEffort = "max"
// ReasoningEffortUltra is advertised by models whose provider exposes an
// additional tier above max.
ReasoningEffortUltra ReasoningEffort = "ultra"
)
type ModelCapability string
@@ -410,7 +413,7 @@ func ValidRuntimeProviderKind(provider ProviderKind) bool {
func ValidReasoningEffort(effort ReasoningEffort) bool {
switch effort {
case ReasoningEffortNone, ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh, ReasoningEffortXHigh, ReasoningEffortMax:
case ReasoningEffortNone, ReasoningEffortMinimal, ReasoningEffortLow, ReasoningEffortMedium, ReasoningEffortHigh, ReasoningEffortXHigh, ReasoningEffortMax, ReasoningEffortUltra:
return true
default:
return false
+17 -11
View File
@@ -8,17 +8,21 @@ import (
)
type Model struct {
ID string
Description string
ContextWindow int
ToolCall bool
Reasoning bool
InputModalities []string
OutputModalities []string
InputCost float64
OutputCost float64
Tags []string
Source string
ID string
Description string
ContextWindow int
ToolCall bool
Reasoning bool
ReasoningEfforts []string
DefaultReasoningEffort string
ServiceTiers []string
DefaultServiceTier string
InputModalities []string
OutputModalities []string
InputCost float64
OutputCost float64
Tags []string
Source string
}
const minimaxModelSource = "https://platform.minimax.io/docs/api-reference/api-overview"
@@ -270,6 +274,8 @@ func dedupeModels(defaultModel string, models []Model) []Model {
}
model.InputModalities = append([]string{}, model.InputModalities...)
model.OutputModalities = append([]string{}, model.OutputModalities...)
model.ReasoningEfforts = append([]string{}, model.ReasoningEfforts...)
model.ServiceTiers = append([]string{}, model.ServiceTiers...)
model.Tags = append([]string{}, model.Tags...)
seen[model.ID] = true
result = append(result, model)
@@ -122,6 +122,25 @@ func TestModelsDoNotAliasMutableCatalogState(t *testing.T) {
}
}
func TestDedupeModelsDoesNotAliasCapabilitySlices(t *testing.T) {
models := []Model{{
ID: "reasoner",
ReasoningEfforts: []string{"low", "high"},
ServiceTiers: []string{"priority"},
}}
first := dedupeModels("reasoner", models)
first[0].ReasoningEfforts[0] = "mutated"
first[0].ServiceTiers[0] = "mutated"
second := dedupeModels("reasoner", models)
if got, want := second[0].ReasoningEfforts[0], "low"; got != want {
t.Fatalf("reasoning efforts alias source state: got %q, want %q", got, want)
}
if got, want := second[0].ServiceTiers[0], "priority"; got != want {
t.Fatalf("service tiers alias source state: got %q, want %q", got, want)
}
}
func modelIDs(models []Model) []string {
ids := make([]string, 0, len(models))
for _, model := range models {
+54 -11
View File
@@ -230,6 +230,11 @@ type remoteModel struct {
Tools bool `json:"tools"`
// ReasoningRaw accepts both a boolean (models.dev) and OpenRouter's object form.
ReasoningRaw json.RawMessage `json:"reasoning"`
ReasoningEfforts []string `json:"reasoning_efforts"`
DefaultReasoning string `json:"default_reasoning_effort"`
ServiceTiers []string `json:"service_tiers"`
AdditionalSpeed []string `json:"additional_speed_tiers"`
DefaultServiceTier string `json:"default_service_tier"`
Free bool `json:"free"`
IsFree bool `json:"is_free"`
InputCost float64 `json:"input_cost"`
@@ -309,6 +314,8 @@ func (model remoteModel) toModel(key string, source string) Model {
containsFold(model.SupportedParameters, "reasoning") ||
containsFold(model.SupportedParameters, "reasoning_effort") ||
containsFold(model.SupportedParameters, "include_reasoning")
reasoningEfforts := model.supportedReasoningEfforts()
serviceTiers := normalizedServiceTiers(model.ServiceTiers, model.AdditionalSpeed)
// Prefer the short display name for aggregator catalogs (OpenRouter's
// description field is multi-sentence marketing copy). models.dev and
@@ -319,20 +326,56 @@ func (model remoteModel) toModel(key string, source string) Model {
}
return Model{
ID: strings.TrimSpace(id),
Description: description,
ContextWindow: contextWindow,
ToolCall: toolCall,
Reasoning: reasoning,
InputModalities: inputModalities,
OutputModalities: outputModalities,
InputCost: inputCost,
OutputCost: outputCost,
Tags: cleanStrings(model.Tags),
Source: source,
ID: strings.TrimSpace(id),
Description: description,
ContextWindow: contextWindow,
ToolCall: toolCall,
Reasoning: reasoning,
ReasoningEfforts: reasoningEfforts,
DefaultReasoningEffort: strings.TrimSpace(model.DefaultReasoning),
ServiceTiers: serviceTiers,
DefaultServiceTier: normalizeServiceTier(model.DefaultServiceTier),
InputModalities: inputModalities,
OutputModalities: outputModalities,
InputCost: inputCost,
OutputCost: outputCost,
Tags: cleanStrings(model.Tags),
Source: source,
}
}
func normalizedServiceTiers(tiers []string, legacy []string) []string {
values := append(append([]string{}, tiers...), legacy...)
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, value := range values {
value = normalizeServiceTier(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
result = append(result, value)
}
return result
}
func normalizeServiceTier(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "fast" {
return "priority"
}
return value
}
func (model remoteModel) supportedReasoningEfforts() []string {
efforts := append([]string{}, model.ReasoningEfforts...)
var info remoteReasoningInfo
if err := json.Unmarshal(bytes.TrimSpace(model.ReasoningRaw), &info); err == nil {
efforts = append(efforts, info.Supported...)
}
return cleanStrings(efforts)
}
func (model remoteModel) supportsReasoning() bool {
raw := bytes.TrimSpace(model.ReasoningRaw)
if len(raw) == 0 || string(raw) == "null" {
+180 -61
View File
@@ -19,20 +19,27 @@ import (
"github.com/Gitlawb/zero/internal/redaction"
)
const anthropicVersion = "2023-06-01"
const (
anthropicVersion = "2023-06-01"
chatGPTModelsProtocolVersion = "0.146.0"
)
type Model struct {
ID string
Description string
ContextWindow int
ToolCall bool
Reasoning bool
InputModalities []string
OutputModalities []string
InputCost float64
OutputCost float64
Tags []string
Source string
ID string
Description string
ContextWindow int
ToolCall bool
Reasoning bool
ReasoningEfforts []string
DefaultReasoningEffort string
ServiceTiers []string
DefaultServiceTier string
InputModalities []string
OutputModalities []string
InputCost float64
OutputCost float64
Tags []string
Source string
}
type Options struct {
@@ -43,6 +50,7 @@ type Options struct {
OAuthResolver providerio.TokenResolver
CodexAccountResolver openai.CodexAccountResolver
UserAgent string
ClientVersion string
}
func DiscoverCatalog(ctx context.Context, provider providercatalog.Descriptor, profile config.ProviderProfile, options Options) ([]Model, error) {
@@ -51,6 +59,7 @@ func DiscoverCatalog(ctx context.Context, provider providercatalog.Descriptor, p
// without credentials so the picker stays current before a key is entered.
canProbeProvider := modelDiscoveryAllowed(profile) && (!provider.RequiresAuth ||
discoveryHasCredential(profile) ||
options.OAuthResolver != nil ||
publicLiveCatalogProvider(provider, profile))
if canProbeProvider {
liveModels, liveErr := Discover(ctx, profile, options)
@@ -204,6 +213,18 @@ func discoverOpenAIModels(ctx context.Context, profile config.ProviderProfile, o
}
var configure func(*http.Request)
if providercatalog.NormalizeID(profile.CatalogID) == "chatgpt" {
parsed, parseErr := url.Parse(endpoint)
if parseErr != nil {
return nil, fmt.Errorf("parse ChatGPT models endpoint: %w", parseErr)
}
clientVersion := strings.TrimSpace(options.ClientVersion)
if clientVersion == "" {
clientVersion = chatGPTModelsProtocolVersion
}
query := parsed.Query()
query.Set("client_version", clientVersion)
parsed.RawQuery = query.Encode()
endpoint = parsed.String()
configure = func(request *http.Request) {
account := ""
if options.CodexAccountResolver != nil {
@@ -326,24 +347,39 @@ func anthropicModelsEndpoint(baseURL string) (string, error) {
return parsed.String(), nil
}
type modelsResponseItem struct {
ID string `json:"id"`
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
Name string `json:"name"`
Description string `json:"description"`
Visibility string `json:"visibility"`
ContextWindow int `json:"context_window"`
ContextWindowAlt int `json:"contextWindow"`
ContextLength int `json:"context_length"`
MaxContextLength int `json:"max_context_length"`
Free bool `json:"free"`
IsFree bool `json:"is_free"`
ToolCall bool `json:"tool_call"`
ToolCallCamel bool `json:"toolCall"`
Tools bool `json:"tools"`
Reasoning bool `json:"reasoning"`
ReasoningEfforts []string `json:"reasoning_efforts"`
DefaultReasoning string `json:"default_reasoning_level"`
SupportedReasoning []struct {
Effort string `json:"effort"`
} `json:"supported_reasoning_levels"`
ServiceTiers []struct {
ID string `json:"id"`
} `json:"service_tiers"`
AdditionalSpeedTiers []string `json:"additional_speed_tiers"`
DefaultServiceTier string `json:"default_service_tier"`
SupportedParameters []string `json:"supported_parameters"`
}
type modelsResponse struct {
Data []struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
Name string `json:"name"`
Description string `json:"description"`
ContextWindow int `json:"context_window"`
ContextWindowAlt int `json:"contextWindow"`
ContextLength int `json:"context_length"`
MaxContextLength int `json:"max_context_length"`
Free bool `json:"free"`
IsFree bool `json:"is_free"`
ToolCall bool `json:"tool_call"`
ToolCallCamel bool `json:"toolCall"`
Tools bool `json:"tools"`
Reasoning bool `json:"reasoning"`
SupportedParameters []string `json:"supported_parameters"`
} `json:"data"`
Data []modelsResponseItem `json:"data"`
Models []modelsResponseItem `json:"models"`
}
func parseModelsResponse(body []byte) ([]Model, error) {
@@ -352,12 +388,21 @@ func parseModelsResponse(body []byte) ([]Model, error) {
return nil, fmt.Errorf("decode models response: %w", err)
}
seen := map[string]bool{}
models := make([]Model, 0, len(payload.Data))
for _, item := range payload.Data {
id := strings.TrimSpace(item.ID)
items := payload.Data
if len(items) == 0 {
items = payload.Models
}
models := make([]Model, 0, len(items))
for _, item := range items {
id := firstNonEmptyDiscovery(item.ID, item.Slug)
if id == "" || seen[id] {
continue
}
// The ChatGPT catalog includes hidden/internal entries alongside picker
// models. Generic OpenAI-compatible responses omit visibility entirely.
if visibility := strings.ToLower(strings.TrimSpace(item.Visibility)); visibility != "" && visibility != "list" {
continue
}
seen[id] = true
description := firstNonEmptyDiscovery(
item.DisplayName,
@@ -381,13 +426,19 @@ func parseModelsResponse(body []byte) ([]Model, error) {
discoveryContainsFold(item.SupportedParameters, "reasoning") ||
discoveryContainsFold(item.SupportedParameters, "reasoning_effort") ||
discoveryContainsFold(item.SupportedParameters, "include_reasoning")
efforts := reasoningEfforts(item)
serviceTiers := modelServiceTiers(item)
models = append(models, Model{
ID: id,
Description: description,
ContextWindow: contextWindow,
ToolCall: toolCall,
Reasoning: reasoning,
Source: "live",
ID: id,
Description: description,
ContextWindow: contextWindow,
ToolCall: toolCall,
Reasoning: reasoning || len(efforts) > 0,
ReasoningEfforts: efforts,
DefaultReasoningEffort: strings.TrimSpace(item.DefaultReasoning),
ServiceTiers: serviceTiers,
DefaultServiceTier: normalizeServiceTier(item.DefaultServiceTier),
Source: "live",
})
}
sort.SliceStable(models, func(i, j int) bool {
@@ -399,6 +450,50 @@ func parseModelsResponse(body []byte) ([]Model, error) {
return models, nil
}
func modelServiceTiers(item modelsResponseItem) []string {
values := append([]string{}, item.AdditionalSpeedTiers...)
for _, tier := range item.ServiceTiers {
values = append(values, tier.ID)
}
seen := map[string]bool{}
result := make([]string, 0, len(values))
for _, value := range values {
value = normalizeServiceTier(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
result = append(result, value)
}
return result
}
func normalizeServiceTier(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "fast" {
return "priority"
}
return value
}
func reasoningEfforts(item modelsResponseItem) []string {
efforts := append([]string{}, item.ReasoningEfforts...)
for _, level := range item.SupportedReasoning {
efforts = append(efforts, level.Effort)
}
seen := map[string]bool{}
result := make([]string, 0, len(efforts))
for _, effort := range efforts {
effort = strings.ToLower(strings.TrimSpace(effort))
if effort == "" || seen[effort] {
continue
}
seen[effort] = true
result = append(result, effort)
}
return result
}
func fetchCatalogModels(ctx context.Context, provider providercatalog.Descriptor, options Options) ([]Model, error) {
models, err := providermodelcatalog.FetchRemote(ctx, provider, providermodelcatalog.FetchOptions{
HTTPClient: options.HTTPClient,
@@ -416,17 +511,21 @@ func modelsFromCatalog(models []providermodelcatalog.Model) []Model {
result := make([]Model, 0, len(models))
for _, model := range models {
result = append(result, Model{
ID: model.ID,
Description: model.Description,
ContextWindow: model.ContextWindow,
ToolCall: model.ToolCall,
Reasoning: model.Reasoning,
InputModalities: append([]string{}, model.InputModalities...),
OutputModalities: append([]string{}, model.OutputModalities...),
InputCost: model.InputCost,
OutputCost: model.OutputCost,
Tags: append([]string{}, model.Tags...),
Source: model.Source,
ID: model.ID,
Description: model.Description,
ContextWindow: model.ContextWindow,
ToolCall: model.ToolCall,
Reasoning: model.Reasoning,
ReasoningEfforts: append([]string{}, model.ReasoningEfforts...),
DefaultReasoningEffort: model.DefaultReasoningEffort,
ServiceTiers: append([]string{}, model.ServiceTiers...),
DefaultServiceTier: model.DefaultServiceTier,
InputModalities: append([]string{}, model.InputModalities...),
OutputModalities: append([]string{}, model.OutputModalities...),
InputCost: model.InputCost,
OutputCost: model.OutputCost,
Tags: append([]string{}, model.Tags...),
Source: model.Source,
})
}
return result
@@ -440,7 +539,8 @@ func mergeLiveModels(provider providercatalog.Descriptor, liveModels []Model, ca
hasCatalog := len(byID) > 0
// Aggregators publish the live list as the source of truth. Keep live-only
// ids even when a remote catalog also loaded, instead of intersecting.
preferLive := providermodelcatalog.PublicLiveCatalog(provider.ID)
preferLive := providermodelcatalog.PublicLiveCatalog(provider.ID) ||
providercatalog.NormalizeID(provider.ID) == "chatgpt"
result := make([]Model, 0, len(liveModels))
for _, live := range liveModels {
if catalog, ok := byID[live.ID]; ok {
@@ -458,6 +558,21 @@ func mergeLiveModels(provider providercatalog.Descriptor, liveModels []Model, ca
if strings.TrimSpace(catalog.Description) == "" && strings.TrimSpace(live.Description) != "" {
catalog.Description = live.Description
}
if len(live.ReasoningEfforts) > 0 {
catalog.ReasoningEfforts = append([]string{}, live.ReasoningEfforts...)
}
if live.DefaultReasoningEffort != "" {
catalog.DefaultReasoningEffort = live.DefaultReasoningEffort
}
if live.Reasoning || len(live.ReasoningEfforts) > 0 || live.DefaultReasoningEffort != "" {
catalog.Reasoning = true
}
if len(live.ServiceTiers) > 0 {
catalog.ServiceTiers = append([]string{}, live.ServiceTiers...)
}
if live.DefaultServiceTier != "" {
catalog.DefaultServiceTier = live.DefaultServiceTier
}
catalog.Source = firstDiscoverySource(catalog.Source, "live")
result = append(result, catalog)
continue
@@ -526,17 +641,21 @@ func liveModelAllowedWithoutCatalog(provider providercatalog.Descriptor, id stri
func catalogModelFromDiscovery(model Model) providermodelcatalog.Model {
return providermodelcatalog.Model{
ID: model.ID,
Description: model.Description,
ContextWindow: model.ContextWindow,
ToolCall: model.ToolCall,
Reasoning: model.Reasoning,
InputModalities: append([]string{}, model.InputModalities...),
OutputModalities: append([]string{}, model.OutputModalities...),
InputCost: model.InputCost,
OutputCost: model.OutputCost,
Tags: append([]string{}, model.Tags...),
Source: model.Source,
ID: model.ID,
Description: model.Description,
ContextWindow: model.ContextWindow,
ToolCall: model.ToolCall,
Reasoning: model.Reasoning,
ReasoningEfforts: append([]string{}, model.ReasoningEfforts...),
DefaultReasoningEffort: model.DefaultReasoningEffort,
ServiceTiers: append([]string{}, model.ServiceTiers...),
DefaultServiceTier: model.DefaultServiceTier,
InputModalities: append([]string{}, model.InputModalities...),
OutputModalities: append([]string{}, model.OutputModalities...),
InputCost: model.InputCost,
OutputCost: model.OutputCost,
Tags: append([]string{}, model.Tags...),
Source: model.Source,
}
}
@@ -80,6 +80,90 @@ func TestParseModelsResponseCapturesContextAndFree(t *testing.T) {
}
}
func TestParseModelsResponseSupportsChatGPTCatalog(t *testing.T) {
models, err := parseModelsResponse([]byte(`{
"models": [
{"slug":"gpt-5.6-sol","display_name":"GPT-5.6 Sol","description":"Frontier coding model","visibility":"list","context_window":400000,"default_reasoning_level":"high","supported_reasoning_levels":[{"effort":"low"},{"effort":"medium"},{"effort":"high"},{"effort":"xhigh"}],"service_tiers":[{"id":"priority"}],"default_service_tier":"standard"},
{"slug":"gpt-5.6-terra","display_name":"GPT-5.6 Terra","visibility":"list"},
{"slug":"internal-router","display_name":"Internal","visibility":"hide"},
{"slug":"server-only","display_name":"Server only","visibility":"none"}
]
}`))
if err != nil {
t.Fatalf("parseModelsResponse: %v", err)
}
if got, want := modelIDs(models), []string{"gpt-5.6-sol", "gpt-5.6-terra"}; strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("models = %#v, want visible ChatGPT models %#v", got, want)
}
if models[0].Description != "GPT-5.6 Sol" || models[0].ContextWindow != 400000 {
t.Fatalf("ChatGPT metadata = %#v", models[0])
}
if got := strings.Join(models[0].ReasoningEfforts, ","); got != "low,medium,high,xhigh" {
t.Fatalf("reasoning efforts = %q", got)
}
if models[0].DefaultReasoningEffort != "high" || !models[0].Reasoning {
t.Fatalf("reasoning metadata = %#v", models[0])
}
if got := strings.Join(models[0].ServiceTiers, ","); got != "priority" || models[0].DefaultServiceTier != "standard" {
t.Fatalf("service tier metadata = %#v", models[0])
}
}
func TestParseModelsResponseNormalizesLegacyFastTier(t *testing.T) {
models, err := parseModelsResponse([]byte(`{"data":[{"id":"gpt-test","additional_speed_tiers":["fast","priority"]}]}`))
if err != nil {
t.Fatalf("parseModelsResponse: %v", err)
}
if got := strings.Join(models[0].ServiceTiers, ","); got != "priority" {
t.Fatalf("service tiers = %q, want priority", got)
}
}
func TestMergeChatGPTModelsKeepsLiveOnlyEntries(t *testing.T) {
models := mergeLiveModels(
providercatalog.Descriptor{ID: "chatgpt"},
[]Model{
{ID: "gpt-5.5", ReasoningEfforts: []string{"low", "high"}, ServiceTiers: []string{"priority"}},
{ID: "gpt-5.6-sol", ReasoningEfforts: []string{"low", "ultra"}},
},
[]Model{{ID: "gpt-5.5", Description: "fallback"}},
)
if got, want := strings.Join(modelIDs(models), ","), "gpt-5.5,gpt-5.6-sol"; got != want {
t.Fatalf("models = %q, want %q", got, want)
}
if got := strings.Join(models[0].ReasoningEfforts, ","); got != "low,high" {
t.Fatalf("merged reasoning efforts = %q", got)
}
if got := strings.Join(models[0].ServiceTiers, ","); got != "priority" {
t.Fatalf("merged service tiers = %q", got)
}
}
func TestMergeLiveModelsUsesLiveDefaultsAndReasoningCapability(t *testing.T) {
models := mergeLiveModels(
providercatalog.Descriptor{ID: "chatgpt"},
[]Model{{
ID: "gpt-live",
Reasoning: true,
DefaultReasoningEffort: "high",
DefaultServiceTier: "priority",
}},
[]Model{{
ID: "gpt-live",
Reasoning: false,
DefaultReasoningEffort: "low",
DefaultServiceTier: "standard",
}},
)
if len(models) != 1 {
t.Fatalf("merged models = %#v, want one model", models)
}
got := models[0]
if !got.Reasoning || got.DefaultReasoningEffort != "high" || got.DefaultServiceTier != "priority" {
t.Fatalf("merged live metadata = %#v", got)
}
}
func TestDiscoverCatalogOpenGatewayUsesLiveListWithoutKey(t *testing.T) {
// Catalog and live endpoints return distinct payloads so the merge must keep
// live-only ids that are absent from the remote catalog response.
@@ -206,6 +290,9 @@ func TestDiscoverChatGPTModelsUsesOAuthAndCodexHeaders(t *testing.T) {
if got := r.URL.Path; got != "/backend-api/codex/models" {
t.Errorf("path = %q, want Codex models endpoint", got)
}
if got := r.URL.Query().Get("client_version"); got != chatGPTModelsProtocolVersion {
t.Errorf("client_version = %q, want %q", got, chatGPTModelsProtocolVersion)
}
wantToken := "Bearer old-token"
wantAccount := "old-account"
if requests == 2 {
+56
View File
@@ -0,0 +1,56 @@
package providers
import (
"context"
"errors"
"net/http"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providers/providerio"
)
// OAuthLoginForProfile resolves the OAuth login used by profile once and
// returns both its bearer resolver and credential-store key. Keeping the key
// alongside the resolver lets auxiliary requests derive account-scoped headers
// from the same login that supplies the bearer.
func OAuthLoginForProfile(profile config.ProviderProfile) (providerio.TokenResolver, string) {
candidates := profile.OAuthLoginCandidates()
if len(candidates) == 0 {
return nil, ""
}
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
return nil, ""
}
_, key, ok := oauth.FirstStored(store, candidates)
if !ok {
return nil, ""
}
manager, err := oauth.NewManager(oauth.ManagerOptions{
Store: store,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
AllowPresets: true,
})
if err != nil {
return nil, ""
}
resolver := func(ctx context.Context, forceRefresh bool) (string, string, bool, error) {
var token string
var resolveErr error
if forceRefresh {
token, resolveErr = manager.Handle401(ctx, key)
} else {
token, resolveErr = manager.GetFresh(ctx, key)
}
if errors.Is(resolveErr, oauth.ErrNoToken) {
return "", "", false, nil
}
if resolveErr != nil {
return "", "", false, resolveErr
}
return "Authorization", "Bearer " + token, true, nil
}
return resolver, key
}
+68
View File
@@ -0,0 +1,68 @@
package providers
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
)
func TestOAuthLoginForProfileBindsBearerAndAccountToSameLogin(t *testing.T) {
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
t.Setenv("ZERO_OAUTH_STORAGE", "file")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
if err != nil {
t.Fatalf("oauth store: %v", err)
}
key := oauth.ProviderKey("chatgpt")
if err := store.Save(key, oauth.Token{
AccessToken: "subscription-token",
Account: "account-42",
ExpiresAt: time.Now().Add(time.Hour),
}); err != nil {
t.Fatalf("save OAuth token: %v", err)
}
resolver, loginKey := OAuthLoginForProfile(config.ProviderProfile{Name: "codex", CatalogID: "chatgpt"})
if resolver == nil || loginKey != key {
t.Fatalf("OAuth login = (%v, %q), want resolver bound to %q", resolver != nil, loginKey, key)
}
header, value, ok, err := resolver(context.Background(), false)
if err != nil || !ok || header != "Authorization" || value != "Bearer subscription-token" {
t.Fatalf("bearer resolution = (%q, %q, %v, %v)", header, value, ok, err)
}
account, ok, err := CodexAccountResolverForLogin(loginKey)(context.Background())
if err != nil || !ok || account != "account-42" {
t.Fatalf("account resolution = (%q, %v, %v)", account, ok, err)
}
}
func TestOAuthLoginForProfileReturnsNoResolverWithoutUsableLogin(t *testing.T) {
t.Setenv("ZERO_OAUTH_STORAGE", "file")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", filepath.Join(t.TempDir(), "oauth-tokens.json"))
for _, test := range []struct {
name string
profile config.ProviderProfile
}{
{
name: "profile with its own credential has no OAuth candidates",
profile: config.ProviderProfile{Name: "openai", CatalogID: "openai", APIKey: "sk-configured"},
},
{
name: "candidate has no stored login",
profile: config.ProviderProfile{Name: "chatgpt", CatalogID: "chatgpt"},
},
} {
t.Run(test.name, func(t *testing.T) {
resolver, key := OAuthLoginForProfile(test.profile)
if resolver != nil || key != "" {
t.Fatalf("OAuthLoginForProfile() = (%v, %q), want (nil, empty)", resolver != nil, key)
}
})
}
}
@@ -80,6 +80,7 @@ type responsesRequest struct {
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
Tools []responsesTool `json:"tools,omitempty"`
Reasoning *responsesReasoning `json:"reasoning,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
}
// responsesReasoning carries the reasoning controls for the Responses API. The
@@ -265,6 +266,9 @@ func (p *CodexProvider) buildResponsesRequest(request zeroruntime.CompletionRequ
// long thinking phase shows live progress instead of looking hung.
req.Reasoning = &responsesReasoning{Effort: effort, Summary: "auto"}
}
if tier := openAIServiceTier(request.ServiceTier); tier != "" {
req.ServiceTier = tier
}
return req, nil
}
+43
View File
@@ -492,6 +492,49 @@ func TestCodexProviderForwardsReasoningEffort(t *testing.T) {
}
}
func TestCodexProviderNormalizesServiceTier(t *testing.T) {
for _, test := range []struct {
name string
input string
want string
}{
{name: "priority", input: "priority", want: "priority"},
{name: "flex", input: "flex", want: "flex"},
{name: "unsupported omitted", input: "standard", want: ""},
} {
t.Run(test.name, func(t *testing.T) {
var rec codexRequest
srv := newCodexTestServer(t, &rec)
defer srv.Close()
provider, err := NewCodexProvider(CodexOptions{
Options: Options{APIKey: "sk-test", BaseURL: srv.URL, Model: "gpt-5-codex"},
AccountID: "acc-x",
})
if err != nil {
t.Fatalf("NewCodexProvider: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
ServiceTier: test.input,
})
if err != nil {
t.Fatalf("StreamCompletion: %v", err)
}
drainCodexEvents(t, stream)
if test.want == "" {
if _, ok := rec.body["service_tier"]; ok {
t.Fatalf("body.service_tier = %#v, want omitted", rec.body["service_tier"])
}
return
}
if got := rec.body["service_tier"]; got != test.want {
t.Fatalf("body.service_tier = %#v, want %q", got, test.want)
}
})
}
}
func TestCodexProviderStreamsReasoningSummaryDeltas(t *testing.T) {
// reasoning_summary_text deltas must surface as StreamEventReasoning (live
// "thinking"), in order, alongside the normal text output. Without this a long
+13 -1
View File
@@ -467,6 +467,9 @@ func (provider *Provider) openAIRequest(request zeroruntime.CompletionRequest) c
if effort := openAIReasoningEffort(request.ReasoningEffort); effort != "" {
mapped.ReasoningEffort = effort
}
if tier := openAIServiceTier(request.ServiceTier); tier != "" {
mapped.ServiceTier = tier
}
// prompt_cache_key is a documented OpenAI parameter for server-side prefix
// cache routing. Official OpenAI accepts it; many openai-compatible
// gateways (NVIDIA NIM, strict local proxies) reject unknown fields with a
@@ -505,7 +508,16 @@ func promptCacheKeyDisabled() bool {
// is dropped rather than risking a 400 on an unrecognized enum.
func openAIReasoningEffort(requested string) string {
switch strings.ToLower(strings.TrimSpace(requested)) {
case "minimal", "low", "medium", "high":
case "minimal", "low", "medium", "high", "xhigh", "max", "ultra":
return strings.ToLower(strings.TrimSpace(requested))
default:
return ""
}
}
func openAIServiceTier(requested string) string {
switch strings.ToLower(strings.TrimSpace(requested)) {
case "priority", "flex":
return strings.ToLower(strings.TrimSpace(requested))
default:
return ""
@@ -860,6 +860,51 @@ func TestStreamCompletionSendsReasoningEffort(t *testing.T) {
}
}
func TestStreamCompletionNormalizesServiceTier(t *testing.T) {
for _, test := range []struct {
name string
input string
want string
}{
{name: "priority", input: "priority", want: "priority"},
{name: "flex with surrounding whitespace", input: " FlEx ", want: "flex"},
{name: "unsupported omitted", input: "standard", want: ""},
} {
t.Run(test.name, func(t *testing.T) {
var gotBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("decode request body: %v", err)
}
writeSSE(w, `[DONE]`)
}))
defer server.Close()
provider, err := New(Options{BaseURL: server.URL + "/", Model: "gpt-test"})
if err != nil {
t.Fatalf("New: %v", err)
}
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hi"}},
ServiceTier: test.input,
})
if err != nil {
t.Fatalf("StreamCompletion: %v", err)
}
drain(stream)
if test.want == "" {
if _, ok := gotBody["service_tier"]; ok {
t.Fatalf("service_tier = %#v, want omitted", gotBody["service_tier"])
}
return
}
if got := gotBody["service_tier"]; got != test.want {
t.Fatalf("service_tier = %#v, want %q", got, test.want)
}
})
}
}
func TestStreamCompletionOmitsReasoningEffortWhenUnsetOrInvalid(t *testing.T) {
for _, effort := range []string{"", "none", "bogus"} {
var gotBody map[string]any
@@ -0,0 +1,34 @@
package openai
import "testing"
func TestOpenAIReasoningEffortPreservesKnownProviderTiers(t *testing.T) {
for _, effort := range []string{"minimal", "low", "medium", "high", "xhigh", "max", "ultra"} {
if got := openAIReasoningEffort(effort); got != effort {
t.Fatalf("openAIReasoningEffort(%q) = %q", effort, got)
}
}
if got := openAIReasoningEffort("unknown"); got != "" {
t.Fatalf("unknown effort = %q, want omitted", got)
}
}
func TestOpenAIServiceTierNormalizesKnownValues(t *testing.T) {
for _, test := range []struct {
input string
want string
}{
{input: "priority", want: "priority"},
{input: "flex", want: "flex"},
{input: " PRIORITY ", want: "priority"},
{input: "FlEx", want: "flex"},
{input: "", want: ""},
{input: "standard", want: ""},
} {
t.Run(test.input, func(t *testing.T) {
if got := openAIServiceTier(test.input); got != test.want {
t.Fatalf("openAIServiceTier(%q) = %q, want %q", test.input, got, test.want)
}
})
}
}
+4 -2
View File
@@ -153,8 +153,8 @@ func (s *turnSession) observePrefix(ctx context.Context, request zeroruntime.Com
// computeFingerprint digests the wire-affecting request parameters: the
// session's model and max-tokens (fixed at construction — CompletionRequest
// carries no model field), the normalized reasoning effort as it would appear
// on the wire, the prompt-cache key (constant within a session, and part of
// the request the provider serializes), and an order-preserving digest of the
// on the wire, the normalized service tier, the prompt-cache key (constant
// within a session, and part of the request the provider serializes), and an order-preserving digest of the
// advertised tools (request serialization preserves tool order, so a reorder
// changes the wire bytes and counts as drift). Messages are excluded — they
// grow every turn; this fingerprints request parameters, not conversation
@@ -175,6 +175,8 @@ func (s *turnSession) computeFingerprint(request zeroruntime.CompletionRequest)
builder.WriteByte('|')
builder.WriteString(openAIReasoningEffort(request.ReasoningEffort))
builder.WriteByte('|')
builder.WriteString(openAIServiceTier(request.ServiceTier))
builder.WriteByte('|')
builder.WriteString(request.PromptCacheKey)
builder.WriteByte('|')
builder.WriteString(wireToolsDigest(request.Tools))
+8 -1
View File
@@ -231,11 +231,18 @@ func TestTurnSessionFingerprintDriftOnToolsAndEffort(t *testing.T) {
// Unrecognized efforts normalize to omitted — same as empty.
droppedEffort := session.computeFingerprint(zeroruntime.CompletionRequest{
Tools: []zeroruntime.ToolDefinition{{Name: "read_file", Parameters: map[string]any{"type": "object"}}},
ReasoningEffort: "xhigh",
ReasoningEffort: "bogus",
})
if base != droppedEffort {
t.Fatal("an effort the wire would omit must fingerprint like no effort")
}
changedTier := session.computeFingerprint(zeroruntime.CompletionRequest{
Tools: []zeroruntime.ToolDefinition{{Name: "read_file", Parameters: map[string]any{"type": "object"}}},
ServiceTier: "priority",
})
if base == changedTier {
t.Fatal("fingerprint did not drift on a changed service tier")
}
}
func TestTurnSessionFingerprintDriftOnToolDescription(t *testing.T) {
+1
View File
@@ -6,6 +6,7 @@ type chatCompletionRequest struct {
Tools []toolDefinition `json:"tools,omitempty"`
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
ReasoningEffort string `json:"reasoning_effort,omitempty"`
ServiceTier string `json:"service_tier,omitempty"`
Stream bool `json:"stream"`
StreamOptions *streamOptions `json:"stream_options,omitempty"`
// PromptCacheKey asks the backend to route the request to a replica that
+1 -1
View File
@@ -105,7 +105,7 @@ Review.`)
_, err = ParseMarkdown(`---
name: reviewer
description: Reviews code
reasoningEffort: ULTRA
reasoningEffort: IMPOSSIBLE
---
Review.`)
if err == nil || !strings.Contains(err.Error(), "unknown reasoning effort") {
+2
View File
@@ -456,6 +456,7 @@ func (m model) handleModelCommand(args string) (model, string) {
// Keep sub-agent child processes on the same provider we just switched to.
config.SetActiveProviderEnv(nextProfile.Name)
m.modelName = target.modelID
m.serviceTier = ""
// Record the outgoing pair too, not just the destination: otherwise the
// model a session started on (never itself the target of a recordRecentModel
// call) would silently drop out of "Recent" the moment you switch away from
@@ -559,6 +560,7 @@ func (m model) switchProviderModel(providerName, modelID string) (model, string,
m.providerProfile = target
m.providerName = target.Name
m.modelName = target.Model
m.serviceTier = ""
// An active profile's effort fill is per-model: re-derive it for the
// destination, exactly like handleModelCommand does. No generic
// unsupported-drop here: cross-provider targets are often custom models
+9 -1
View File
@@ -33,6 +33,7 @@ const (
commandCompact
commandRewind
commandEffort
commandFast
commandStyle
commandTheme
commandTranscript
@@ -271,11 +272,18 @@ var commandDefinitions = []commandDefinition{
},
{
name: "/effort",
usage: "/effort [list|low|medium|high|auto]",
usage: "/effort [list|level|auto]",
group: commandGroupModel,
description: "Show or set reasoning effort for supported models.",
kind: commandEffort,
},
{
name: "/fast",
usage: "/fast",
group: commandGroupModel,
description: "Toggle fast mode for supported ChatGPT subscription models.",
kind: commandFast,
},
{
name: "/style",
usage: "/style [list|balanced|concise|explanatory|review]",
+2 -1
View File
@@ -47,7 +47,8 @@ func TestFormatCommandHelpLinesGroupsCommandsByStableOrder(t *testing.T) {
"model:",
" /provider [add|status] - Manage providers: activate, add, edit, delete.",
" /model [list|id] - Show or switch the active model.",
" /effort [list|low|medium|high|auto] - Show or set reasoning effort for supported models.",
" /effort [list|level|auto] - Show or set reasoning effort for supported models.",
" /fast - Toggle fast mode for supported ChatGPT subscription models.",
"session:",
" /plan [status|on|off] - Show plan status, or enter/exit read-only planning mode.",
"runtime:",
+97
View File
@@ -0,0 +1,97 @@
package tui
import (
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
)
func TestFastCommandTogglesOnlyAdvertisedTier(t *testing.T) {
m := model{
modelName: "gpt-fast",
providerName: "ChatGPT",
providerProfile: config.ProviderProfile{CatalogID: "chatgpt"},
modelPickerLiveByProvider: map[string][]providermodeldiscovery.Model{
"chatgpt": {{ID: "gpt-fast", ServiceTiers: []string{"priority"}}},
},
}
m, _ = m.handleFastCommand("")
if got := m.activeServiceTier(); got != "priority" {
t.Fatalf("active service tier = %q, want priority", got)
}
m, _ = m.handleFastCommand("")
if got := m.activeServiceTier(); got != "" {
t.Fatalf("active service tier = %q after second toggle, want off", got)
}
}
func TestFastCommandRejectsUnsupportedModel(t *testing.T) {
m := model{
modelName: "gpt-standard",
providerProfile: config.ProviderProfile{CatalogID: "chatgpt"},
modelPickerLiveByProvider: map[string][]providermodeldiscovery.Model{
"chatgpt": {{ID: "gpt-standard"}},
},
serviceTier: "priority",
}
next, text := m.handleFastCommand("")
if next.activeServiceTier() != "" || next.serviceTier != "" {
t.Fatalf("unsupported model retained fast tier: %#v", next.serviceTier)
}
if !strings.Contains(text, "ChatGPT subscription") {
t.Fatalf("unsupported response = %q", text)
}
}
func TestFastCommandRejectsMeteredPriorityTier(t *testing.T) {
m := model{
modelName: "gpt-metered",
providerProfile: config.ProviderProfile{CatalogID: "openai"},
modelPickerLiveByProvider: map[string][]providermodeldiscovery.Model{
"openai": {{ID: "gpt-metered", ServiceTiers: []string{"priority"}}},
},
serviceTier: "priority",
}
next, text := m.handleFastCommand("")
if got := next.activeServiceTier(); got != "" {
t.Fatalf("metered profile forwarded service tier %q", got)
}
if next.serviceTier != "" {
t.Fatalf("metered profile retained service tier %q", next.serviceTier)
}
if !strings.Contains(text, "ChatGPT subscription") {
t.Fatalf("metered response = %q", text)
}
}
func TestFastCommandDoesNotChangeDuringRun(t *testing.T) {
m := model{
pending: true,
modelName: "gpt-fast",
providerProfile: config.ProviderProfile{CatalogID: "chatgpt"},
modelPickerLiveByProvider: map[string][]providermodeldiscovery.Model{
"chatgpt": {{ID: "gpt-fast", ServiceTiers: []string{"priority"}}},
},
}
next, text := m.handleFastCommand("")
if next.serviceTier != "" || !strings.Contains(text, "Finish or stop") {
t.Fatalf("pending fast mutation = tier %q text %q", next.serviceTier, text)
}
}
func TestFastCommandDoesNotInterpretArguments(t *testing.T) {
m := model{
modelName: "gpt-fast",
providerProfile: config.ProviderProfile{CatalogID: "chatgpt"},
modelPickerLiveByProvider: map[string][]providermodeldiscovery.Model{
"chatgpt": {{ID: "gpt-fast", ServiceTiers: []string{"priority"}}},
},
}
next, text := m.handleFastCommand("status")
if next.serviceTier != "" || !strings.Contains(text, "Use /fast to toggle") {
t.Fatalf("argument changed fast state: tier %q text %q", next.serviceTier, text)
}
}
+7
View File
@@ -141,6 +141,7 @@ type model struct {
permissionModeBeforePlan agent.PermissionMode
selfCorrectTests bool
reasoningEffort modelregistry.ReasoningEffort
serviceTier string
// Active execution profile (set by /profile; applies to the NEXT run).
// The displaced/applied pairs let a switch or /profile balanced restore
// exactly what the profile replaced while leaving later manual overrides
@@ -4840,6 +4841,11 @@ func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
m, text = m.handleEffortCommand(command.text)
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
return m, nil
case commandFast:
text := ""
m, text = m.handleFastCommand(command.text)
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: text})
return m, nil
case commandStyle:
text := ""
m, text = m.handleStyleCommand(command.text)
@@ -5408,6 +5414,7 @@ func (m model) runAgentWithOptions(runID int, runCtx context.Context, prompt str
options.ProviderName = m.providerName
options.Model = m.modelName
options.ReasoningEffort = string(m.reasoningEffort)
options.ServiceTier = m.activeServiceTier()
options.ResponseStyle = m.responseStyle
options.Cwd = m.cwd
options.Images = images
+1
View File
@@ -198,6 +198,7 @@ func TestParseCommand(t *testing.T) {
{input: "/plan off", kind: commandPlan, text: "off"},
{input: "/compact", kind: commandCompact},
{input: "/effort high", kind: commandEffort, text: "high"},
{input: "/fast", kind: commandFast},
{input: "/style concise", kind: commandStyle, text: "concise"},
{input: "/debug-mode", kind: commandDebug},
{input: "hello zero", kind: commandPrompt, text: "hello zero"},
+12 -1
View File
@@ -14,6 +14,7 @@ import (
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providermodelcatalog"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/providers"
)
// pickerKind identifies which command a picker selection feeds back into.
@@ -407,8 +408,9 @@ func (m model) modelPickerProviderDiscoveryCmd(descriptor providercatalog.Descri
needOAuth := key == "" && descriptor.OAuth && !descriptor.OAuthMintsKey
discover := m.discoverProviderModels
if discover == nil {
discoveryOptions := m.modelPickerDiscoveryOptions(authed)
discover = func(ctx context.Context, p config.ProviderProfile) ([]providermodeldiscovery.Model, error) {
return providermodeldiscovery.DiscoverCatalog(ctx, descriptor, p, providermodeldiscovery.Options{})
return providermodeldiscovery.DiscoverCatalog(ctx, descriptor, p, discoveryOptions)
}
}
providerID := descriptor.ID
@@ -426,6 +428,15 @@ func (m model) modelPickerProviderDiscoveryCmd(descriptor providercatalog.Descri
}
}
func (m model) modelPickerDiscoveryOptions(profile config.ProviderProfile) providermodeldiscovery.Options {
oauthResolver, loginKey := providers.OAuthLoginForProfile(profile)
return providermodeldiscovery.Options{
OAuthResolver: oauthResolver,
CodexAccountResolver: providers.CodexAccountResolverForLogin(loginKey),
UserAgent: m.userAgent,
}
}
func (m model) modelPickerIsLoading() bool {
return m.picker != nil && m.picker.kind == pickerModel && m.modelPickerLoading
}
+71
View File
@@ -10,6 +10,7 @@ import (
"reflect"
"strings"
"testing"
"time"
tea "charm.land/bubbletea/v2"
@@ -52,6 +53,40 @@ func TestModelPickerDetectsOllamaCloudFromBaseURL(t *testing.T) {
}
}
func TestModelPickerChatGPTDiscoveryUsesMatchingOAuthAccount(t *testing.T) {
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
t.Setenv("ZERO_OAUTH_STORAGE", "file")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
if err != nil {
t.Fatalf("oauth store: %v", err)
}
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{
AccessToken: "live-token",
Account: "workspace-77",
ExpiresAt: time.Now().Add(time.Hour),
}); err != nil {
t.Fatalf("save OAuth token: %v", err)
}
m := model{userAgent: "zero/test"}
options := m.modelPickerDiscoveryOptions(config.ProviderProfile{Name: "codex", CatalogID: "chatgpt"})
if options.OAuthResolver == nil || options.CodexAccountResolver == nil {
t.Fatal("ChatGPT model discovery must carry both bearer and account resolvers")
}
header, bearer, ok, err := options.OAuthResolver(context.Background(), false)
if err != nil || !ok || header != "Authorization" || bearer != "Bearer live-token" {
t.Fatalf("discovery bearer = (%q, %q, %v, %v)", header, bearer, ok, err)
}
account, ok, err := options.CodexAccountResolver(context.Background())
if err != nil || !ok || account != "workspace-77" {
t.Fatalf("discovery account = (%q, %v, %v)", account, ok, err)
}
if options.UserAgent != "zero/test" {
t.Fatalf("discovery user agent = %q", options.UserAgent)
}
}
func TestModelPickerRefreshesLiveModelsForActiveProvider(t *testing.T) {
var captured config.ProviderProfile
m := newModel(context.Background(), Options{
@@ -1109,6 +1144,42 @@ func TestEffortPickerOpensForModelWithoutEffortControls(t *testing.T) {
}
}
func TestEffortPickerPrefersLiveProviderReasoningLevels(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "future-reasoner"})
m.providerProfile = config.ProviderProfile{CatalogID: "test-provider"}
m.modelPickerLiveByProvider = map[string][]providermodeldiscovery.Model{
"test-provider": {{
ID: "future-reasoner",
Reasoning: true,
ReasoningEfforts: []string{"low", "high", "xhigh", "max", "ultra", "unknown", "high"},
}},
}
got := m.availableReasoningEfforts()
want := []modelregistry.ReasoningEffort{
modelregistry.ReasoningEffortLow,
modelregistry.ReasoningEffortHigh,
modelregistry.ReasoningEffortXHigh,
modelregistry.ReasoningEffortMax,
modelregistry.ReasoningEffortUltra,
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("reasoning efforts = %#v, want %#v", got, want)
}
}
func TestEffortPickerDoesNotFallbackWhenLiveModelAdvertisesNoSelectableEfforts(t *testing.T) {
m := newModel(context.Background(), Options{ModelName: "gpt-5.5"})
m.providerProfile = config.ProviderProfile{CatalogID: "test-provider"}
m.modelPickerLiveByProvider = map[string][]providermodeldiscovery.Model{
"test-provider": {{ID: "gpt-5.5", ReasoningEfforts: []string{"none"}}},
}
if got := m.availableReasoningEfforts(); len(got) != 0 {
t.Fatalf("live model with only none exposed efforts %#v, want none", got)
}
}
func TestEffortPickerAutoSelectionKeepsEffortUnset(t *testing.T) {
// Picking "auto" on a model without effort controls clears any stale
// preference and emits the success status text (handleEffortCommand("auto")).
+119
View File
@@ -13,6 +13,8 @@ import (
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/execprofile"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/usage"
"github.com/Gitlawb/zero/internal/zeroruntime"
@@ -84,6 +86,96 @@ func (m model) handleEffortCommand(args string) (model, string) {
return m, m.effortStatusCard(string(requested), "Reasoning effort preference is stored for this TUI session.")
}
func (m model) handleFastCommand(args string) (model, string) {
if strings.TrimSpace(args) != "" {
return m, m.fastStatusText("Use /fast to toggle fast mode for supported ChatGPT subscription models.")
}
if m.pending {
return m, m.fastStatusText("Finish or stop the current run before changing fast mode.")
}
if !m.fastModeSupported() {
m.serviceTier = ""
return m, m.fastStatusText("Fast mode is available only for ChatGPT subscription models that advertise it.")
}
if m.activeServiceTier() == "priority" {
m.serviceTier = ""
} else {
m.serviceTier = "priority"
}
return m, m.fastStatusText("Fast mode applies to the next request for this ChatGPT subscription model.")
}
func (m model) fastStatusText(detail string) string {
state := "off"
if m.activeServiceTier() == "priority" {
state = "on"
}
fields := []commandField{
{Key: "fast mode", Value: state},
{Key: "model", Value: displayValue(m.modelName, "none")},
}
if detail == "" {
detail = "Use /fast to toggle fast mode for supported ChatGPT subscription models."
}
return renderCommandCardTranscript(commandCard{
Title: "Fast",
Summary: []string{"fast mode: " + state},
Sections: []commandCardSection{{
Title: "State",
Fields: fields,
Lines: []string{detail},
}},
})
}
func (m model) activeServiceTier() string {
if m.serviceTier == "priority" && m.fastModeSupported() {
return "priority"
}
return ""
}
func (m model) fastModeSupported() bool {
// Priority is a ChatGPT subscription capability. Never forward it for
// metered provider profiles, even if a third-party catalog advertises it.
if providercatalog.NormalizeID(m.providerProfile.CatalogID) != "chatgpt" {
return false
}
for _, model := range m.discoveredModelsForActiveProvider() {
if !strings.EqualFold(strings.TrimSpace(model.ID), strings.TrimSpace(m.modelName)) {
continue
}
for _, tier := range model.ServiceTiers {
if normalizeTUIServiceTier(tier) == "priority" {
return true
}
}
return false
}
return false
}
func (m model) discoveredModelsForActiveProvider() []providermodeldiscovery.Model {
for _, key := range []string{m.providerProfile.CatalogID, m.providerProfile.Name, m.providerName} {
key = strings.TrimSpace(key)
if key == "" {
continue
}
if models := m.modelPickerLiveByProvider[key]; len(models) > 0 {
return models
}
}
return nil
}
func normalizeTUIServiceTier(tier string) string {
tier = strings.ToLower(strings.TrimSpace(tier))
if tier == "fast" {
return "priority"
}
return tier
}
// effortStatusCard renders the small inline confirmation card shown after a
// /effort <value> mutation (set, auto, unknown, unsupported). The body is a
// lime-bordered card so the transition from "picker open" -> "card collapsed"
@@ -236,6 +328,9 @@ func (m model) availableReasoningEfforts() []modelregistry.ReasoningEffort {
if strings.TrimSpace(m.modelName) == "" {
return nil
}
if efforts, found := m.discoveredReasoningEfforts(); found {
return efforts
}
registry, err := modelregistry.DefaultRegistry()
if err != nil {
return nil
@@ -243,6 +338,30 @@ func (m model) availableReasoningEfforts() []modelregistry.ReasoningEffort {
return registry.ReasoningEfforts(m.modelName)
}
func (m model) discoveredReasoningEfforts() ([]modelregistry.ReasoningEffort, bool) {
return reasoningEffortsFromModels(m.discoveredModelsForActiveProvider(), m.modelName)
}
func reasoningEffortsFromModels(models []providermodeldiscovery.Model, modelName string) ([]modelregistry.ReasoningEffort, bool) {
for _, discovered := range models {
if !strings.EqualFold(strings.TrimSpace(discovered.ID), strings.TrimSpace(modelName)) {
continue
}
seen := map[modelregistry.ReasoningEffort]bool{}
efforts := make([]modelregistry.ReasoningEffort, 0, len(discovered.ReasoningEfforts))
for _, raw := range discovered.ReasoningEfforts {
effort := modelregistry.ReasoningEffort(strings.ToLower(strings.TrimSpace(raw)))
if effort == modelregistry.ReasoningEffortNone || !modelregistry.ValidReasoningEffort(effort) || seen[effort] {
continue
}
seen[effort] = true
efforts = append(efforts, effort)
}
return efforts, true
}
return nil, false
}
func (m model) effortDisplay() string {
if m.reasoningEffort == "" {
return "auto"
+4 -1
View File
@@ -187,7 +187,7 @@ func (m model) composerDividerLine(width int) string {
}
// statusLine renders the bottom readout as ` │ `-separated groups: the run-state
// chip (permission mode + effort) on the left, a flexible gap, then the
// chip (permission mode + effort/fast tier) on the left, a flexible gap, then the
// context-fill gauge and token/cost usage on the right. The provider lives in the
// title bar and is NOT duplicated here. Groups drop with the width tier.
func (m model) statusLine(width int) string {
@@ -225,6 +225,9 @@ func (m model) statusLine(width int) string {
if m.reasoningEffort != "" {
left += zeroTheme.muted.Render(" · ") + zeroTheme.accent.Render(string(m.reasoningEffort))
}
if m.activeServiceTier() == "priority" {
left += zeroTheme.muted.Render(" · ") + zeroTheme.accent.Render("fast")
}
if m.exitConfirmActive {
left = prefix + btwChip + zeroTheme.amber.Render("●") + " " + zeroTheme.amber.Render(ctrlCExitConfirmText)
} else if m.cancelConfirmActive {
+2
View File
@@ -40,6 +40,7 @@ func TestDefaultTurnSessionStreamForwardsVerbatim(t *testing.T) {
request := CompletionRequest{
Messages: []Message{{Role: MessageRoleUser, Content: "hi"}},
ReasoningEffort: "high",
ServiceTier: "priority",
PromptCacheKey: "session-123",
}
stream, err := session.Stream(context.Background(), request)
@@ -60,6 +61,7 @@ func TestDefaultTurnSessionStreamForwardsVerbatim(t *testing.T) {
recorded := provider.requests[0]
if recorded.PromptCacheKey != request.PromptCacheKey ||
recorded.ReasoningEffort != request.ReasoningEffort ||
recorded.ServiceTier != request.ServiceTier ||
len(recorded.Messages) != 1 || recorded.Messages[0].Content != "hi" {
t.Fatalf("request not forwarded verbatim: %#v", recorded)
}
+4
View File
@@ -205,6 +205,10 @@ type CompletionRequest struct {
// Anthropic/Gemini thinking budgets) and ignores it for models that do not
// support reasoning. Empty means "let the provider decide".
ReasoningEffort string
// ServiceTier selects an optional provider-advertised request tier. Empty
// means the provider default; "priority" is the wire value used by fast mode.
// Callers must only set a tier advertised for the active provider/model.
ServiceTier string
// PromptCacheKey, when non-empty, is an opaque stable identifier for the
// conversation (the session ID). Providers with server-side prefix-cache
// routing forward it — OpenAI `prompt_cache_key` — so consecutive requests