refactor(models): remove responses API model registration hack

Fantasy v0.21.0 natively includes gpt-5.5 and other newer models in
its responsesModelIDs/responsesReasoningModelIDs lists, making our
workaround unnecessary.

- Delete responses_models.go (go:linkname hack + RegisterResponsesModels)
- Delete responses_models_test.go
- Replace isResponsesAPIModel/isResponsesReasoningModel heuristics with
  direct openai.IsResponsesModel/openai.IsResponsesReasoningModel calls
- Remove RegisterResponsesModels calls from registry init/reload
- Remove hack documentation from AGENTS.md
- Update all deps (fantasy v0.21.0, smithy-go, ultraviolet, etc.)
This commit is contained in:
Ed Zynda
2026-04-27 09:42:52 +03:00
parent 3881d1c28f
commit e830bf87ca
7 changed files with 34 additions and 289 deletions
+4 -56
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" && isResponsesAPIModel(modelName) {
if provider == "openai" && openai.IsResponsesModel(modelName) {
skipMerge = true
}
if !skipMerge {
@@ -549,69 +549,17 @@ 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 !isResponsesAPIModel(modelName) {
if !openai.IsResponsesModel(modelName) {
return nil
}
if isResponsesReasoningModel(modelName) {
if openai.IsResponsesReasoningModel(modelName) {
reasoningSummary := "auto"
opts := &openai.ResponsesProviderOptions{
ReasoningSummary: &reasoningSummary,
@@ -957,7 +905,7 @@ func buildCodexProviderOptions(config *ProviderConfig, modelName string) fantasy
opts.Instructions = &config.SystemPrompt
}
if isResponsesReasoningModel(modelName) {
if openai.IsResponsesReasoningModel(modelName) {
opts.ReasoningEffort = thinkingLevelToReasoningEffort(config.ThinkingLevel)
}
-8
View File
@@ -481,13 +481,6 @@ 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
@@ -497,5 +490,4 @@ func GetGlobalRegistry() *ModelsRegistry {
// data sources (cache → embedded). Call after updating the cache.
func ReloadGlobalRegistry() {
globalRegistry = NewModelsRegistry()
RegisterResponsesModels()
}
-58
View File
@@ -1,58 +0,0 @@
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
@@ -1,123 +0,0 @@
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)
}
}