mirror of
https://github.com/danielmiessler/Fabric.git
synced 2026-01-10 23:08:06 -05:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ae20a8ccd | ||
|
|
0fbc86be17 | ||
|
|
5b1a4ab306 | ||
|
|
817e75853e |
10
CHANGELOG.md
10
CHANGELOG.md
@@ -1,5 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## v1.4.332 (2025-11-24)
|
||||
|
||||
### PR [#1843](https://github.com/danielmiessler/Fabric/pull/1843) by [ksylvan](https://github.com/ksylvan): Implement case-insensitive vendor and model name matching
|
||||
|
||||
- Fix: implement case-insensitive vendor and model name matching across the application
|
||||
- Add case-insensitive vendor lookup in VendorsManager
|
||||
- Implement model name normalization in GetChatter method
|
||||
- Add FilterByVendor method with case-insensitive matching
|
||||
- Add FindModelNameCaseInsensitive helper for model queries
|
||||
|
||||
## v1.4.331 (2025-11-22)
|
||||
|
||||
### PR [#1839](https://github.com/danielmiessler/Fabric/pull/1839) by [ksylvan](https://github.com/ksylvan): Add GitHub Models Provider and Refactor Fetching Fallback Logic
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
package main
|
||||
|
||||
var version = "v1.4.331"
|
||||
var version = "v1.4.332"
|
||||
|
||||
Binary file not shown.
@@ -39,6 +39,11 @@ func handleListingCommands(currentFlags *Flags, fabricDb *fsdb.Db, registry *cor
|
||||
if models, err = registry.VendorManager.GetModels(); err != nil {
|
||||
return true, err
|
||||
}
|
||||
|
||||
if currentFlags.Vendor != "" {
|
||||
models = models.FilterByVendor(currentFlags.Vendor)
|
||||
}
|
||||
|
||||
if currentFlags.ShellCompleteOutput {
|
||||
models.Print(true)
|
||||
} else {
|
||||
|
||||
@@ -17,8 +17,9 @@ func handleTranscription(flags *Flags, registry *core.PluginRegistry) (message s
|
||||
if vendorName == "" {
|
||||
vendorName = "OpenAI"
|
||||
}
|
||||
vendor, ok := registry.VendorManager.VendorsByName[vendorName]
|
||||
if !ok {
|
||||
|
||||
vendor := registry.VendorManager.FindByName(vendorName)
|
||||
if vendor == nil {
|
||||
return "", fmt.Errorf("%s", fmt.Sprintf(i18n.T("vendor_not_configured"), vendorName))
|
||||
}
|
||||
tr, ok := vendor.(transcriber)
|
||||
|
||||
@@ -32,11 +32,9 @@ type Chatter struct {
|
||||
|
||||
// Send processes a chat request and applies file changes for create_coding_feature pattern
|
||||
func (o *Chatter) Send(request *domain.ChatRequest, opts *domain.ChatOptions) (session *fsdb.Session, err error) {
|
||||
modelToUse := opts.Model
|
||||
if modelToUse == "" {
|
||||
modelToUse = o.model
|
||||
}
|
||||
if o.vendor.NeedsRawMode(modelToUse) {
|
||||
// Use o.model (normalized) for NeedsRawMode check instead of opts.Model
|
||||
// This ensures case-insensitive model names work correctly (e.g., "GPT-5" → "gpt-5")
|
||||
if o.vendor.NeedsRawMode(o.model) {
|
||||
opts.Raw = true
|
||||
}
|
||||
if session, err = o.BuildSession(request, opts.Raw); err != nil {
|
||||
@@ -57,6 +55,10 @@ func (o *Chatter) Send(request *domain.ChatRequest, opts *domain.ChatOptions) (s
|
||||
|
||||
if opts.Model == "" {
|
||||
opts.Model = o.model
|
||||
} else {
|
||||
// Ensure opts.Model uses the normalized name from o.model if they refer to the same model
|
||||
// This handles cases where user provides "GPT-5" but we've normalized it to "gpt-5"
|
||||
opts.Model = o.model
|
||||
}
|
||||
|
||||
if opts.ModelContextLength == 0 {
|
||||
|
||||
@@ -222,9 +222,8 @@ func (o *PluginRegistry) Setup() (err error) {
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := o.VendorManager.VendorsByName[plugin.GetName()]; !ok {
|
||||
var vendor ai.Vendor
|
||||
if vendor, ok = plugin.(ai.Vendor); ok {
|
||||
if o.VendorManager.FindByName(plugin.GetName()) == nil {
|
||||
if vendor, ok := plugin.(ai.Vendor); ok {
|
||||
o.VendorManager.AddVendors(vendor)
|
||||
}
|
||||
}
|
||||
@@ -330,11 +329,22 @@ func (o *PluginRegistry) GetChatter(model string, modelContextLength int, vendor
|
||||
if models, err = vendorManager.GetModels(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Normalize model name to match actual available model (case-insensitive)
|
||||
// This must be done BEFORE checking vendor availability
|
||||
actualModelName := models.FindModelNameCaseInsensitive(model)
|
||||
if actualModelName != "" {
|
||||
model = actualModelName // Use normalized name for all subsequent checks
|
||||
}
|
||||
|
||||
if vendorName != "" {
|
||||
// ensure vendor exists and provides model
|
||||
ret.vendor = vendorManager.FindByName(vendorName)
|
||||
availableVendors := models.FindGroupsByItem(model)
|
||||
if ret.vendor == nil || !lo.Contains(availableVendors, vendorName) {
|
||||
vendorAvailable := lo.ContainsBy(availableVendors, func(name string) bool {
|
||||
return strings.EqualFold(name, vendorName)
|
||||
})
|
||||
if ret.vendor == nil || !vendorAvailable {
|
||||
err = fmt.Errorf("model %s not available for vendor %s", model, vendorName)
|
||||
return
|
||||
}
|
||||
@@ -345,6 +355,7 @@ func (o *PluginRegistry) GetChatter(model string, modelContextLength int, vendor
|
||||
}
|
||||
ret.vendor = vendorManager.FindByName(models.FindGroupsByItemFirst(model))
|
||||
}
|
||||
|
||||
ret.model = model
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,35 @@ type VendorsModels struct {
|
||||
*util.GroupsItemsSelectorString
|
||||
}
|
||||
|
||||
// FilterByVendor returns a new VendorsModels containing only the specified vendor's models.
|
||||
// Vendor matching is case-insensitive (e.g., "OpenAI", "openai", and "OPENAI" all match).
|
||||
// If the vendor is not found, an empty VendorsModels is returned.
|
||||
func (o *VendorsModels) FilterByVendor(vendor string) *VendorsModels {
|
||||
filtered := NewVendorsModels()
|
||||
for _, groupItems := range o.GroupsItems {
|
||||
if strings.EqualFold(groupItems.Group, vendor) {
|
||||
filtered.AddGroupItems(groupItems.Group, groupItems.Items...)
|
||||
break
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// FindModelNameCaseInsensitive returns the actual model name from available models,
|
||||
// matching case-insensitively. Returns empty string if not found.
|
||||
// For example, if the available models contain "gpt-4o" and user queries "GPT-4O",
|
||||
// this returns "gpt-4o" (the actual model name that should be sent to the API).
|
||||
func (o *VendorsModels) FindModelNameCaseInsensitive(modelQuery string) string {
|
||||
for _, groupItems := range o.GroupsItems {
|
||||
for _, item := range groupItems.Items {
|
||||
if strings.EqualFold(item, modelQuery) {
|
||||
return item
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// PrintWithVendor prints models including their vendor on each line.
|
||||
// When shellCompleteList is true, output is suitable for shell completion.
|
||||
// Default vendor and model are highlighted with an asterisk.
|
||||
|
||||
@@ -19,19 +19,19 @@ func TestNewVendorsModels(t *testing.T) {
|
||||
|
||||
func TestFindVendorsByModelFirst(t *testing.T) {
|
||||
vendors := NewVendorsModels()
|
||||
vendors.AddGroupItems("vendor1", []string{"model1", "model2"}...)
|
||||
vendors.AddGroupItems("Vendor1", []string{"Model1", "model2"}...)
|
||||
vendor := vendors.FindGroupsByItemFirst("model1")
|
||||
if vendor != "vendor1" {
|
||||
t.Fatalf("FindVendorsByModelFirst() = %v, want %v", vendor, "vendor1")
|
||||
if vendor != "Vendor1" {
|
||||
t.Fatalf("FindVendorsByModelFirst() = %v, want %v", vendor, "Vendor1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindVendorsByModel(t *testing.T) {
|
||||
vendors := NewVendorsModels()
|
||||
vendors.AddGroupItems("vendor1", []string{"model1", "model2"}...)
|
||||
foundVendors := vendors.FindGroupsByItem("model1")
|
||||
if len(foundVendors) != 1 || foundVendors[0] != "vendor1" {
|
||||
t.Fatalf("FindVendorsByModel() = %v, want %v", foundVendors, []string{"vendor1"})
|
||||
vendors.AddGroupItems("Vendor1", []string{"Model1", "model2"}...)
|
||||
foundVendors := vendors.FindGroupsByItem("MODEL1")
|
||||
if len(foundVendors) != 1 || foundVendors[0] != "Vendor1" {
|
||||
t.Fatalf("FindVendorsByModel() = %v, want %v", foundVendors, []string{"Vendor1"})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,3 +54,51 @@ func TestPrintWithVendorMarksDefault(t *testing.T) {
|
||||
t.Fatalf("default model not marked: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterByVendorCaseInsensitive(t *testing.T) {
|
||||
vendors := NewVendorsModels()
|
||||
vendors.AddGroupItems("vendor1", []string{"model1"}...)
|
||||
vendors.AddGroupItems("vendor2", []string{"model2"}...)
|
||||
|
||||
filtered := vendors.FilterByVendor("VENDOR2")
|
||||
|
||||
if len(filtered.GroupsItems) != 1 {
|
||||
t.Fatalf("expected 1 vendor group, got %d", len(filtered.GroupsItems))
|
||||
}
|
||||
|
||||
if filtered.GroupsItems[0].Group != "vendor2" {
|
||||
t.Fatalf("expected vendor2, got %s", filtered.GroupsItems[0].Group)
|
||||
}
|
||||
|
||||
if len(filtered.GroupsItems[0].Items) != 1 || filtered.GroupsItems[0].Items[0] != "model2" {
|
||||
t.Fatalf("unexpected models for vendor2: %v", filtered.GroupsItems[0].Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindModelNameCaseInsensitive(t *testing.T) {
|
||||
vendors := NewVendorsModels()
|
||||
vendors.AddGroupItems("OpenAI", []string{"gpt-4o", "gpt-5"}...)
|
||||
vendors.AddGroupItems("Anthropic", []string{"claude-3-opus"}...)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
expectedModel string
|
||||
}{
|
||||
{"exact match lowercase", "gpt-4o", "gpt-4o"},
|
||||
{"uppercase query", "GPT-4O", "gpt-4o"},
|
||||
{"mixed case query", "GpT-5", "gpt-5"},
|
||||
{"exact match with hyphens", "claude-3-opus", "claude-3-opus"},
|
||||
{"uppercase with hyphens", "CLAUDE-3-OPUS", "claude-3-opus"},
|
||||
{"non-existent model", "gpt-999", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := vendors.FindModelNameCaseInsensitive(tt.query)
|
||||
if result != tt.expectedModel {
|
||||
t.Errorf("FindModelNameCaseInsensitive(%q) = %q, want %q", tt.query, result, tt.expectedModel)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,9 +25,12 @@ type VendorsManager struct {
|
||||
Models *VendorsModels
|
||||
}
|
||||
|
||||
// AddVendors registers one or more vendors with the manager.
|
||||
// Vendors are stored with lowercase keys to enable case-insensitive lookup.
|
||||
func (o *VendorsManager) AddVendors(vendors ...Vendor) {
|
||||
for _, vendor := range vendors {
|
||||
o.VendorsByName[vendor.GetName()] = vendor
|
||||
name := strings.ToLower(vendor.GetName())
|
||||
o.VendorsByName[name] = vendor
|
||||
o.Vendors = append(o.Vendors, vendor)
|
||||
}
|
||||
}
|
||||
@@ -63,8 +66,10 @@ func (o *VendorsManager) HasVendors() bool {
|
||||
return len(o.Vendors) > 0
|
||||
}
|
||||
|
||||
// FindByName returns a vendor by name. Lookup is case-insensitive.
|
||||
// For example, "OpenAI", "openai", and "OPENAI" all match the same vendor.
|
||||
func (o *VendorsManager) FindByName(name string) Vendor {
|
||||
return o.VendorsByName[name]
|
||||
return o.VendorsByName[strings.ToLower(name)]
|
||||
}
|
||||
|
||||
func (o *VendorsManager) readModels() (err error) {
|
||||
@@ -143,9 +148,9 @@ func (o *VendorsManager) SetupVendor(vendorName string, configuredVendors map[st
|
||||
func (o *VendorsManager) setupVendorTo(vendor Vendor, configuredVendors map[string]Vendor) {
|
||||
if vendorErr := vendor.Setup(); vendorErr == nil {
|
||||
fmt.Printf("[%v] configured\n", vendor.GetName())
|
||||
configuredVendors[vendor.GetName()] = vendor
|
||||
configuredVendors[strings.ToLower(vendor.GetName())] = vendor
|
||||
} else {
|
||||
delete(configuredVendors, vendor.GetName())
|
||||
delete(configuredVendors, strings.ToLower(vendor.GetName()))
|
||||
fmt.Printf("[%v] skipped\n", vendor.GetName())
|
||||
}
|
||||
}
|
||||
|
||||
66
internal/plugins/ai/vendors_test.go
Normal file
66
internal/plugins/ai/vendors_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/danielmiessler/fabric/internal/chat"
|
||||
"github.com/danielmiessler/fabric/internal/domain"
|
||||
)
|
||||
|
||||
type stubVendor struct {
|
||||
name string
|
||||
}
|
||||
|
||||
func (v *stubVendor) GetName() string { return v.name }
|
||||
func (v *stubVendor) GetSetupDescription() string { return "" }
|
||||
func (v *stubVendor) IsConfigured() bool { return true }
|
||||
func (v *stubVendor) Configure() error { return nil }
|
||||
func (v *stubVendor) Setup() error { return nil }
|
||||
func (v *stubVendor) SetupFillEnvFileContent(*bytes.Buffer) {}
|
||||
func (v *stubVendor) ListModels() ([]string, error) { return nil, nil }
|
||||
func (v *stubVendor) SendStream([]*chat.ChatCompletionMessage, *domain.ChatOptions, chan string) error {
|
||||
return nil
|
||||
}
|
||||
func (v *stubVendor) Send(context.Context, []*chat.ChatCompletionMessage, *domain.ChatOptions) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (v *stubVendor) NeedsRawMode(string) bool { return false }
|
||||
|
||||
func TestVendorsManagerFindByNameCaseInsensitive(t *testing.T) {
|
||||
manager := NewVendorsManager()
|
||||
vendor := &stubVendor{name: "OpenAI"}
|
||||
|
||||
manager.AddVendors(vendor)
|
||||
|
||||
if got := manager.FindByName("openai"); got != vendor {
|
||||
t.Fatalf("FindByName lowercase = %v, want %v", got, vendor)
|
||||
}
|
||||
|
||||
if got := manager.FindByName("OPENAI"); got != vendor {
|
||||
t.Fatalf("FindByName uppercase = %v, want %v", got, vendor)
|
||||
}
|
||||
|
||||
if got := manager.FindByName("OpenAI"); got != vendor {
|
||||
t.Fatalf("FindByName mixed case = %v, want %v", got, vendor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVendorsManagerSetupVendorToCaseInsensitive(t *testing.T) {
|
||||
manager := NewVendorsManager()
|
||||
vendor := &stubVendor{name: "OpenAI"}
|
||||
|
||||
configured := map[string]Vendor{}
|
||||
manager.setupVendorTo(vendor, configured)
|
||||
|
||||
// Verify vendor is stored with lowercase key
|
||||
if _, ok := configured["openai"]; !ok {
|
||||
t.Fatalf("setupVendorTo should store vendor using lowercase key")
|
||||
}
|
||||
|
||||
// Verify original case key is not used
|
||||
if _, ok := configured["OpenAI"]; ok {
|
||||
t.Fatalf("setupVendorTo should not store vendor using original case key")
|
||||
}
|
||||
}
|
||||
@@ -133,7 +133,7 @@ func (o *GroupsItemsSelector[I]) Print(shellCompleteList bool) {
|
||||
|
||||
func (o *GroupsItemsSelector[I]) HasGroup(group string) (ret bool) {
|
||||
for _, groupItems := range o.GroupsItems {
|
||||
if ret = groupItems.Group == group; ret {
|
||||
if ret = strings.EqualFold(groupItems.Group, group); ret {
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -146,7 +146,7 @@ func (o *GroupsItemsSelector[I]) FindGroupsByItemFirst(item I) (ret string) {
|
||||
for _, groupItems := range o.GroupsItems {
|
||||
if groupItems.ContainsItemBy(func(groupItem I) bool {
|
||||
groupItemKey := o.GetItemKey(groupItem)
|
||||
return groupItemKey == itemKey
|
||||
return strings.EqualFold(groupItemKey, itemKey)
|
||||
}) {
|
||||
ret = groupItems.Group
|
||||
break
|
||||
@@ -161,7 +161,7 @@ func (o *GroupsItemsSelector[I]) FindGroupsByItem(item I) (groups []string) {
|
||||
for _, groupItems := range o.GroupsItems {
|
||||
if groupItems.ContainsItemBy(func(groupItem I) bool {
|
||||
groupItemKey := o.GetItemKey(groupItem)
|
||||
return groupItemKey == itemKey
|
||||
return strings.EqualFold(groupItemKey, itemKey)
|
||||
}) {
|
||||
groups = append(groups, groupItems.Group)
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
"1.4.331"
|
||||
"1.4.332"
|
||||
|
||||
Reference in New Issue
Block a user