From 3881d1c28f6e7e24bb2b4c1440bbe7f025b674af Mon Sep 17 00:00:00 2001 From: Ed Zynda Date: Fri, 24 Apr 2026 15:13:38 +0300 Subject: [PATCH] fix(models): auto-register new OpenAI models for Responses API routing Fantasy's hardcoded responsesModelIDs list gates whether a model uses the Responses API or Chat Completions code path. When a new model (e.g. gpt-5.5) is added via `kit update-models` but fantasy hasn't been updated yet, the type mismatch between *ResponsesProviderOptions and *ProviderOptions causes a crash. - Add isResponsesAPIModel()/isResponsesReasoningModel() helpers that supplement fantasy's checks with prefix-based heuristics for modern OpenAI model families (gpt-4.1+, gpt-5+, o-series, codex, chatgpt) - Add RegisterResponsesModels() using go:linkname to append missing model IDs from our database into fantasy's internal slices at init time and after ReloadGlobalRegistry() - Replace all direct openai.IsResponsesModel/IsResponsesReasoningModel calls in providers.go with the new helpers - Merge embedded + cached model databases instead of cache-only fallback - Bump fantasy v0.19.0 -> v0.20.0 to match existing import usage - Document the technique and model-family update process in AGENTS.md --- AGENTS.md | 14 +++ go.mod | 2 +- go.sum | 12 +-- internal/models/providers.go | 60 ++++++++++- internal/models/registry.go | 38 +++++-- internal/models/responses_models.go | 58 +++++++++++ internal/models/responses_models_test.go | 123 +++++++++++++++++++++++ 7 files changed, 290 insertions(+), 17 deletions(-) create mode 100644 internal/models/responses_models.go create mode 100644 internal/models/responses_models_test.go diff --git a/AGENTS.md b/AGENTS.md index b60e3b68..fa5e2e7a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,20 @@ func OldName() { return NewName() } - **Context function fields**: The `Context` struct uses function fields (`Print func(string)`, `SetWidget func(WidgetConfig)`) wired by closures in `cmd/root.go` - **Package-level vars in extensions**: Yaegi supports package-level variables captured in closures — this is how extensions maintain state across event callbacks +### OpenAI Responses API Model Registration +Fantasy's OpenAI provider routes models through either the **Responses API** or **Chat Completions** based on a hardcoded `responsesModelIDs` list. When OpenAI releases a new model (e.g. `gpt-5.5`) and it's added to our database via `kit update-models`, fantasy may not know about it yet, causing a type-mismatch crash (`*ResponsesProviderOptions` vs `*ProviderOptions`). + +**How we handle it** (`internal/models/responses_models.go`): +- `isResponsesAPIModel()` / `isResponsesReasoningModel()` — supplement fantasy's checks with prefix-based heuristics (`gpt-4.1+`, `gpt-5+`, `o1/o3/o4`, `codex`, `chatgpt-`) +- `RegisterResponsesModels()` — uses `//go:linkname` to append new model IDs from our database into fantasy's unexported `responsesModelIDs` / `responsesReasoningModelIDs` slices at init time and after `ReloadGlobalRegistry()` +- All call sites in `providers.go` use our helpers (`isResponsesAPIModel`, `isResponsesReasoningModel`) instead of `openai.IsResponsesModel` / `openai.IsResponsesReasoningModel` directly + +**To add a brand-new OpenAI model family:** +1. If the model ID starts with an existing prefix in `isResponsesAPIModel()`, it works automatically +2. If it's a new prefix (e.g. `o5-*`), add it to the prefix lists in both `isResponsesAPIModel()` and (if reasoning) `isResponsesReasoningModel()` in `internal/models/providers.go` +3. Run `kit update-models` to pull the model metadata — `RegisterResponsesModels()` handles the rest +4. Tests: `internal/models/responses_models_test.go` + ### Unicode in Widget Text - Widget content renders through `lipgloss.Style.Render()` which preserves ANSI escape codes - Use rune-based width calculations (`len([]rune(s))`) not byte length (`len(s)`) when aligning box-drawing characters or multi-byte symbols diff --git a/go.mod b/go.mod index f84d8afe..257f582f 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.26.2 require ( charm.land/bubbles/v2 v2.1.0 charm.land/bubbletea/v2 v2.0.6 - charm.land/fantasy v0.19.0 + charm.land/fantasy v0.20.0 charm.land/huh/v2 v2.0.3 charm.land/lipgloss/v2 v2.0.3 github.com/alecthomas/chroma/v2 v2.23.1 diff --git a/go.sum b/go.sum index a4cf61a2..20635821 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g= charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY= charm.land/bubbletea/v2 v2.0.6 h1:UHN/91OyuhaOFGSrBXQ/hMZD8IO1Uc4BvHlgHXL2WJo= charm.land/bubbletea/v2 v2.0.6/go.mod h1:MH/D8ZLlN3op37vQvijKuU29g3rqTp+aQapURFonF9g= -charm.land/fantasy v0.19.0 h1:fnNXkIJ/xcIW3sdVtWxjtQGpWWe8pDGhBCWSHkgbrd0= -charm.land/fantasy v0.19.0/go.mod h1:V9cCIUMZB9g3Bq40aKEY8xBNzDd48EdfHp2OMS0uzWs= +charm.land/fantasy v0.20.0 h1:puadUHRbcyo10o2HpzTamX5+Mrz+0/xj9K4XWLCGbIw= +charm.land/fantasy v0.20.0/go.mod h1:GYYvvDAS3u/Wpb5hX0VxCJPhQCaffHNNeBRtGw04IBI= charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= @@ -314,10 +314,10 @@ google.golang.org/api v0.276.0 h1:nVArUtfLEihtW+b0DdcqRGK1xoEm2+ltAihyztq7MKY= google.golang.org/api v0.276.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw= google.golang.org/genai v1.54.0 h1:ZQCa70WMTJDI11FdqWCzGvZ5PanpcpfoO6jl/lrSnGU= google.golang.org/genai v1.54.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= -google.golang.org/genproto v0.0.0-20260406210006-6f92a3bedf2d h1:N1Ec54vZnIPd7MnxRiYLW+oY4fDR4BOS/LrssdD9+ek= -google.golang.org/genproto v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:c2hJ1grtnH0xUiEKGDGkjGNTJ1Hy2LrblyKOHF0sqRM= -google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d h1:/aDRtSZJjyLQzm75d+a1wOJaqyKBMvIAfeQmoa3ORiI= -google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:etfGUgejTiadZAUaEP14NP97xi1RGeawqkjDARA/UOs= +google.golang.org/genproto v0.0.0-20260414002931-afd174a4e478 h1:aLsVTW0lZ8+IY5u/ERjZSCvAmhuR7slKzyha3YikDNA= +google.golang.org/genproto v0.0.0-20260414002931-afd174a4e478/go.mod h1:YJAzKjfHIUHb9T+bfu8L7mthAp7VVXQBUs1PLdBWS7M= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec= +google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc= google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg= google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= diff --git a/internal/models/providers.go b/internal/models/providers.go index b4ba21fc..4d478589 100644 --- a/internal/models/providers.go +++ b/internal/models/providers.go @@ -309,7 +309,7 @@ func CreateProvider(ctx context.Context, config *ProviderConfig) (*ProviderResul // For OpenAI Responses API models, we skip merging entirely because // ResponsesProviderOptions and ProviderOptions are incompatible types. skipMerge := false - if provider == "openai" && openai.IsResponsesModel(modelName) { + if provider == "openai" && isResponsesAPIModel(modelName) { skipMerge = true } if !skipMerge { @@ -549,17 +549,69 @@ func clearConflictingAnthropicSamplingParams(config *ProviderConfig) { } } +// isResponsesAPIModel returns true when the model ID should use the OpenAI +// Responses API. It first consults fantasy's built-in list (which may lag +// behind new model releases) and falls back to a heuristic based on the +// model ID prefix. All modern OpenAI models (gpt-4.1+, gpt-4.5+, gpt-5+, +// o-series, codex, chatgpt) use the Responses API. +func isResponsesAPIModel(modelName string) bool { + if openai.IsResponsesModel(modelName) { + return true + } + // Heuristic: modern OpenAI model families that use the Responses API. + // This catches newly released models (e.g. gpt-5.5) before fantasy + // adds them to its hardcoded list. + for _, prefix := range []string{ + "gpt-4.1", "gpt-4.5", "gpt-5", + "o1", "o3", "o4", + "codex", + "chatgpt-", + } { + if strings.HasPrefix(modelName, prefix) { + return true + } + } + return false +} + +// isResponsesReasoningModel returns true when the model ID should be treated +// as an OpenAI Responses API reasoning model. Like isResponsesAPIModel, it +// supplements fantasy's built-in list with a heuristic for new models. +func isResponsesReasoningModel(modelName string) bool { + if openai.IsResponsesReasoningModel(modelName) { + return true + } + // Heuristic: if it's a responses-API model, check model metadata. + // Reasoning models in the gpt-5+ and o-series families have + // reasoning=true in models.dev. + if !isResponsesAPIModel(modelName) { + return false + } + registry := GetGlobalRegistry() + modelInfo := registry.LookupModel("openai", modelName) + if modelInfo != nil && modelInfo.Reasoning { + return true + } + // For unknown models in reasoning families, assume reasoning. + for _, prefix := range []string{"o1", "o3", "o4", "gpt-5", "codex"} { + if strings.HasPrefix(modelName, prefix) { + return true + } + } + return false +} + // buildOpenAIProviderOptions returns fantasy.ProviderOptions configured for // OpenAI Responses API models. For reasoning models it sets reasoning_summary // to "auto", includes encrypted reasoning content, and maps the ThinkingLevel // to an OpenAI ReasoningEffort. For non-responses or non-reasoning models the // returned map is nil (no extra options needed). func buildOpenAIProviderOptions(config *ProviderConfig, modelName string) fantasy.ProviderOptions { - if !openai.IsResponsesModel(modelName) { + if !isResponsesAPIModel(modelName) { return nil } - if openai.IsResponsesReasoningModel(modelName) { + if isResponsesReasoningModel(modelName) { reasoningSummary := "auto" opts := &openai.ResponsesProviderOptions{ ReasoningSummary: &reasoningSummary, @@ -905,7 +957,7 @@ func buildCodexProviderOptions(config *ProviderConfig, modelName string) fantasy opts.Instructions = &config.SystemPrompt } - if openai.IsResponsesReasoningModel(modelName) { + if isResponsesReasoningModel(modelName) { opts.ReasoningEffort = thinkingLevelToReasoningEffort(config.ThinkingLevel) } diff --git a/internal/models/registry.go b/internal/models/registry.go index e9eb51fc..cb431b1d 100644 --- a/internal/models/registry.go +++ b/internal/models/registry.go @@ -4,6 +4,7 @@ import ( _ "embed" "encoding/json" "fmt" + "maps" "os" "strings" @@ -111,13 +112,30 @@ func NewModelsRegistry() *ModelsRegistry { } // buildFromModelsDB converts models.dev provider data into our internal format. -// It tries the on-disk cache first and falls back to the embedded database. +// It starts from the compile-time embedded database and merges on-disk cached +// data from `kit update-models` on top. Cached provider metadata replaces +// embedded metadata, and model entries are merged with cached models taking +// precedence. This means newly synced models are available while embedded +// models that haven't been synced yet are still reachable. func buildFromModelsDB() map[string]ProviderInfo { - // Try cached data first (from `kit update-models`) - dbProviders, _ := LoadCachedProviders() - if len(dbProviders) == 0 { - // Fall back to compile-time embedded data - dbProviders = loadEmbeddedProviders() + // Start with compile-time embedded data as the base. + dbProviders := loadEmbeddedProviders() + if dbProviders == nil { + dbProviders = make(ModelsDBProviders) + } + + // Merge on-disk cached data on top (cached takes precedence). + if cached, _ := LoadCachedProviders(); len(cached) > 0 { + for providerID, cp := range cached { + if existing, ok := dbProviders[providerID]; ok { + // Merge models: embedded base + cached overrides. + mergedModels := make(map[string]modelsDBModel, len(existing.Models)+len(cp.Models)) + maps.Copy(mergedModels, existing.Models) + maps.Copy(mergedModels, cp.Models) + cp.Models = mergedModels + } + dbProviders[providerID] = cp + } } providers := make(map[string]ProviderInfo, len(dbProviders)) @@ -463,6 +481,13 @@ func (r *ModelsRegistry) ValidateModelString(modelString string) error { // Global registry instance var globalRegistry = NewModelsRegistry() +func init() { + // Ensure fantasy's Responses API model lists include any new models + // from the model database that were released after the fantasy + // dependency was pinned. + RegisterResponsesModels() +} + // GetGlobalRegistry returns the global models registry instance. func GetGlobalRegistry() *ModelsRegistry { return globalRegistry @@ -472,4 +497,5 @@ func GetGlobalRegistry() *ModelsRegistry { // data sources (cache → embedded). Call after updating the cache. func ReloadGlobalRegistry() { globalRegistry = NewModelsRegistry() + RegisterResponsesModels() } diff --git a/internal/models/responses_models.go b/internal/models/responses_models.go new file mode 100644 index 00000000..60dddafe --- /dev/null +++ b/internal/models/responses_models.go @@ -0,0 +1,58 @@ +package models + +import ( + _ "unsafe" // Required for go:linkname. +) + +// responsesModelIDs and responsesReasoningModelIDs are the unexported slices +// in charm.land/fantasy/providers/openai that gate whether a model uses the +// Responses API code path vs Chat Completions. When a brand-new model is +// released (e.g. gpt-5.5) and models.dev is updated via `kit update-models`, +// Kit recognises the model but fantasy's hardcoded list does not. That causes +// a type-mismatch crash: Kit builds *ResponsesProviderOptions (correct for +// the Responses endpoint) but fantasy routes through Chat Completions and +// rejects the type. +// +// RegisterResponsesModels appends model IDs that are missing from fantasy's +// lists so the provider routes through the correct code path. It is called +// once during provider creation after loading the model database. + +//go:linkname fantasyResponsesModelIDs charm.land/fantasy/providers/openai.responsesModelIDs +var fantasyResponsesModelIDs []string + +//go:linkname fantasyResponsesReasoningModelIDs charm.land/fantasy/providers/openai.responsesReasoningModelIDs +var fantasyResponsesReasoningModelIDs []string + +// RegisterResponsesModels ensures every OpenAI model known to our model +// database that should use the Responses API is present in fantasy's +// internal lists. This is a no-op for models already registered. +func RegisterResponsesModels() { + registry := GetGlobalRegistry() + providerInfo := registry.GetProviderInfo("openai") + if providerInfo == nil { + return + } + + existing := make(map[string]bool, len(fantasyResponsesModelIDs)) + for _, id := range fantasyResponsesModelIDs { + existing[id] = true + } + existingReasoning := make(map[string]bool, len(fantasyResponsesReasoningModelIDs)) + for _, id := range fantasyResponsesReasoningModelIDs { + existingReasoning[id] = true + } + + for modelID, modelInfo := range providerInfo.Models { + if !isResponsesAPIModel(modelID) { + continue + } + if !existing[modelID] { + fantasyResponsesModelIDs = append(fantasyResponsesModelIDs, modelID) + existing[modelID] = true + } + if modelInfo.Reasoning && !existingReasoning[modelID] { + fantasyResponsesReasoningModelIDs = append(fantasyResponsesReasoningModelIDs, modelID) + existingReasoning[modelID] = true + } + } +} diff --git a/internal/models/responses_models_test.go b/internal/models/responses_models_test.go new file mode 100644 index 00000000..49d967d2 --- /dev/null +++ b/internal/models/responses_models_test.go @@ -0,0 +1,123 @@ +package models + +import ( + "testing" + + "charm.land/fantasy/providers/openai" +) + +func TestIsResponsesAPIModel(t *testing.T) { + tests := []struct { + modelID string + expected bool + }{ + // Already in fantasy's list — always true + {"gpt-5", true}, + {"gpt-4.1", true}, + {"o3", true}, + {"o4-mini", true}, + {"codex-mini-latest", true}, + + // NOT in fantasy's list but matches our heuristic + {"gpt-5.5", true}, + {"gpt-5.6-turbo", true}, + {"gpt-4.1-ultra", true}, + {"o3-jumbo", true}, + {"o4-mega", true}, + + // Should NOT match + {"gpt-3.5-turbo", true}, // actually IS in fantasy's responses list (legacy Chat Completions compat) + {"llama-3", false}, + {"claude-opus-4-6", false}, + {"gemini-2.5-pro", false}, + {"random-model", false}, + } + + for _, tt := range tests { + t.Run(tt.modelID, func(t *testing.T) { + got := isResponsesAPIModel(tt.modelID) + if got != tt.expected { + t.Errorf("isResponsesAPIModel(%q) = %v, want %v", tt.modelID, got, tt.expected) + } + }) + } +} + +func TestIsResponsesReasoningModel(t *testing.T) { + tests := []struct { + modelID string + expected bool + }{ + // In fantasy's reasoning list + {"gpt-5", true}, + {"o3", true}, + {"o4-mini", true}, + + // NOT in fantasy's list but matches reasoning heuristic (gpt-5 prefix) + {"gpt-5.5", true}, + {"gpt-5.6-turbo", true}, + + // Responses API but NOT reasoning + {"gpt-4.1", false}, + {"gpt-4.1-mini", false}, + + // Not OpenAI at all + {"claude-opus-4-6", false}, + } + + for _, tt := range tests { + t.Run(tt.modelID, func(t *testing.T) { + got := isResponsesReasoningModel(tt.modelID) + if got != tt.expected { + t.Errorf("isResponsesReasoningModel(%q) = %v, want %v", tt.modelID, got, tt.expected) + } + }) + } +} + +func TestRegisterResponsesModels(t *testing.T) { + // After RegisterResponsesModels() (called in init()), + // any model matching our heuristic that's in the model database + // should be queryable via openai.IsResponsesModel. + + // Models in the embedded database that are also in fantasy's list + // should remain accessible. + if !openai.IsResponsesModel("gpt-5") { + t.Error("gpt-5 should be a responses model after registration") + } + + // The registration should not break existing models. + if openai.IsResponsesModel("random-nonexistent-model") { + t.Error("random model should NOT be a responses model") + } +} + +func TestBuildOpenAIProviderOptions_NewModel(t *testing.T) { + // A model like gpt-5.5 that isn't in fantasy's hardcoded list + // but matches our heuristic should get ResponsesProviderOptions. + config := &ProviderConfig{ + ModelString: "openai/gpt-5.5", + } + opts := buildOpenAIProviderOptions(config, "gpt-5.5") + if opts == nil { + t.Fatal("buildOpenAIProviderOptions should return non-nil for gpt-5.5") + } + v, ok := opts[openai.Name] + if !ok { + t.Fatal("should have openai key in provider options") + } + if _, ok := v.(*openai.ResponsesProviderOptions); !ok { + t.Errorf("expected *ResponsesProviderOptions, got %T", v) + } +} + +func TestBuildOpenAIProviderOptions_NonResponsesModel(t *testing.T) { + // A model that doesn't match any heuristic should get nil. + config := &ProviderConfig{ + ModelString: "openai/some-old-model", + } + opts := buildOpenAIProviderOptions(config, "some-old-model") + if opts != nil { + t.Errorf("buildOpenAIProviderOptions should return nil for unknown model, got %v", opts) + } +}