mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user