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
This commit is contained in:
Ed Zynda
2026-04-24 15:13:38 +03:00
parent 53f6682bd0
commit 3881d1c28f
7 changed files with 290 additions and 17 deletions
+14
View File
@@ -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
+1 -1
View File
@@ -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
+6 -6
View File
@@ -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=
+56 -4
View File
@@ -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)
}
+32 -6
View File
@@ -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()
}
+58
View File
@@ -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
}
}
}
+123
View File
@@ -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)
}
}