Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Array-Based Configuration for LLM Providers with Target Flag #74

Open
kardolus opened this issue Oct 2, 2024 · 11 comments
Open

Array-Based Configuration for LLM Providers with Target Flag #74

kardolus opened this issue Oct 2, 2024 · 11 comments

Comments

@kardolus
Copy link
Owner

kardolus commented Oct 2, 2024

Problem

Currently, the configuration for the ChatGPT CLI uses a singular configuration format for LLMs and models. This works well if you're using only one LLM at a time, but it becomes cumbersome when switching between different providers (OpenAI, Perplexity, Llama, etc.) or models.

For example, the current configuration looks like this:

name: openai
api_key: 
model: gpt-4o
max_tokens: 4096
context_window: 8192
...

When switching between providers or models, users have to either edit the configuration file or rely on environment variables. This makes switching between multiple setups more complicated and less efficient.

Proposed Solution

  1. Introduce Array-Based Configuration for LLMs:
    Convert the current configuration from a single setup to an array-based format where multiple configurations for different LLMs and models can be stored.

    Example:

    providers:
      - name: openai
        api_key: 
        model: gpt-4o
        max_tokens: 4096
        context_window: 8192
        ...
      - name: llama
        api_key: 
        model: llama-2-13b-chat
        max_tokens: 4096
        context_window: 8192
        ...
      - name: perplexity
        api_key: 
        model: llama-3.1-sonar
        max_tokens: 4096
        context_window: 8192
        ...
  2. Add a --target Flag to Dynamically Select a Configuration:
    Add a --target flag that allows users to select which configuration (provider and model) to use for a specific command.

    Example:

    chatgpt --target openai "Who is Max Verstappen?"
    chatgpt --target llama "Tell me a joke"
    chatgpt --target perplexity "Summarize this article"

    This way, users can quickly switch between configurations without having to edit the config.yaml file or rely on environment variables.

Benefits

  • Ease of Use: With array-based configurations and the --target flag, users can easily switch between LLM providers and models.
  • Cleaner Configuration: Avoids the need for multiple environment variables or manual configuration file edits for each LLM.
  • Better Flexibility: Supports different LLM providers (OpenAI, Llama, Perplexity, etc.) and models without requiring reconfiguration.
@yitsushi
Copy link

yitsushi commented Feb 8, 2025

I had a free hour and looked around in the code and tried to sketch out a PoC for that with compatibility in mind.

  1. keep everything on global level too as "default value" for all targets if not defined.
  2. define a target top level that defined the default target (if no flag or env).
  3. define a targets map (map[string]...). As a map and not a slice because that way it's guaranteed one target is defined only and can use the same name field for multiple targets. For example if someone wants to define the same openai name (for env vars and everything, like api key), but different model, max tokens, role, etc.

However, I don't think it's that trivial with the current architecture. Viper can handle conf + flag + env with priorities (overrides), but with the introduction of an extra target list layer, after viper is done with parsing the way it does their job, there's no way to tell if given value was provided by config (root value), env variable, or cli flag.

Ideally the priority would be:

  1. Flag.
  2. Environment variable.
  3. Defined in target config.
  4. Defined in default config (not in targets).

So for example for model with pseudo code

model = defaultModel // if not defined anywhere

if flag.model:
  model = flag.model
else if env.model:
  model = env.model
else if config.targets[target].model:
  model = config.targets[target].model
else if in config.model:
  model = config.model

The problem with this approach, by the time viper is done with parsing, we can't tell where the value of viper.GetString("model") comes from (flag, env, root conf), so even if we get viper.GetString("target") and check if a value is defined under targets[target] (after viper.UnmarshalKey("targets", &conf.Targets)), we don't know if we have to overwrite a value in Config or not because the value may come from a flag and that should take precedent.

I didn't use viper for a long time (probably 4 years ago last time), but I don't think there is a way to inject this logic in there in a sane way.

If we drop the top level configuration it's still tricky to get this working with viper for the array as it tries to map env and flag for a single value, but if there are 2 entries in that list, it has to map --model or MODEL for all of them. Maybe it can do that, but then we lost top level default configuration which is very useful at least to define a default target, but it would be annoying to update all values all the time for things like role, max_tokens, and similar generic values (and it would be good to be able to say "use max 1000 token on that target, but 2000 on everything else".


note: I didn't pick up this as "oh I want to do this", because I have very limited free time these days (I would if I have more time). I just tried to play around and check the code to determine the complexity of this issue and decide if I can do it in a few hours on a weekend, but I don't think I can, it's definitely more than a few hours 😆

@yitsushi
Copy link

yitsushi commented Feb 8, 2025

I tried to think about an alternative solution. This feature can be introduced with less work if it's not a single configuration file.

.
├── config.ollama-ds-r1.yaml
├── config.gpt-4o.yaml
├── config.yaml -> config.ollama-ds-r1.yaml
└── history

Effectively just swapping the configuration file. It's not as elegant as having a single structure configuration file, but can handle multiple providers still with an easy switch and a user can set a default with a config file or symlink given config.

A very ugly monkey patch to do that:

diff --git a/cmd/chatgpt/main.go b/cmd/chatgpt/main.go
index 80e9210..5e56157 100644
--- a/cmd/chatgpt/main.go
+++ b/cmd/chatgpt/main.go
@@ -389,12 +389,19 @@ func initConfig(rootCmd *cobra.Command) (config.Config, error) {
 	// Set default name for environment variables if no config is loaded yet.
 	viper.SetDefault("name", "openai")
 
+	_ = rootCmd.ParseFlags(os.Args)
+
+	configName := "config"
+	if target := rootCmd.Flag("target").Value.String(); target != "" {
+		configName += "." + target
+	}
+
 	// Read only the `name` field from the config to determine the environment prefix.
 	configHome, err := internal.GetConfigHome()
 	if err != nil {
 		return config.Config{}, err
 	}
-	viper.SetConfigName("config")
+	viper.SetConfigName(configName)
 	viper.SetConfigType("yaml")
 	viper.AddConfigPath(configHome)
 
@@ -607,6 +614,7 @@ func setCustomHelp(rootCmd *cobra.Command) {
 		printFlagWithPadding("-c, --config", "Display the configuration")
 		printFlagWithPadding("-v, --version", "Display the version information")
 		printFlagWithPadding("-l, --list-models", "List available models")
+		printFlagWithPadding("-t, --target", "Set target")
 		printFlagWithPadding("--list-threads", "List available threads")
 		printFlagWithPadding("--delete-thread", "Delete the specified thread (supports wildcards)")
 		printFlagWithPadding("--clear-history", "Clear the history of the current thread")
@@ -660,6 +668,7 @@ func setupFlags(rootCmd *cobra.Command) {
 	rootCmd.PersistentFlags().StringVar(&threadName, "delete-thread", "", "Delete the specified thread")
 	rootCmd.PersistentFlags().BoolVar(&showHistory, "show-history", false, "Show the human-readable conversation history")
 	rootCmd.PersistentFlags().StringVar(&shell, "set-completions", "", "Generate autocompletion script for your current shell")
+	rootCmd.PersistentFlags().StringP("target", "t", "", "Target to use")
 }
 
 func setupConfigFlags(rootCmd *cobra.Command, meta ConfigMetadata) {

I can clean it up and file a PR for this approach if you prefer that and you don't have time to do that. It can solve some of the pain points this issues is trying to resolve, but it's not as elegant as having a nice and clean configuration file.

A background why I perused the "targets" path and not "providers" path in both cases:
I play a lot and compare different models and providers or even different models within the same provider, now I have a script to swap config files before moving to the next "test subject" (I realized, technically I do this now but with an external script and not a target flag 🤦‍♀). It's easier to pre-configure everything and just swap target than writing a script that "knows" when to change which flags. For example testing same provider and same model but with different role, or some models I want to limit to lower max_tokens. So I can have a list of targets like:

  • fancymodel as an architect using A provider
  • fancymodel as an quality tester using A provider
  • fancymodelv2 as an architect using A provider
  • fanciermodel as an architect using B provider

@kardolus
Copy link
Owner Author

kardolus commented Feb 9, 2025

@yitsushi first of all, thank you so much!!

I didn't consider these viper limitations. Let me play with viper real quick to see if I can get an array based config to work. If not, I think your patch would make a lot of sense. Just have different configuration files and pick one with a target flag. That solves the problem as well. Great idea!

@kardolus kardolus moved this from Todo to In Progress in LLM Kanban Feb 9, 2025
@kardolus
Copy link
Owner Author

I haven't had much time just yet, but perhaps something like this would work?

type Provider struct {
    Name          string `mapstructure:"name"`
    APIKey        string `mapstructure:"api_key"`
    Model         string `mapstructure:"model"`
    MaxTokens     int    `mapstructure:"max_tokens"`
    ContextWindow int    `mapstructure:"context_window"`
}

type Config struct {
    Providers []Provider `mapstructure:"providers"`
}

And do the parsing like this:

providers := viper.Get("providers").([]interface{})
for _, provider := range providers {
    p := provider.(map[string]interface{})
    name := p["name"].(string)
    model := p["model"].(string)
}

Will report back once I have some time to implement this.

@yitsushi
Copy link

yitsushi commented Feb 10, 2025

viper.Unmarshal() can be used for that, the issue the comfort of viper with env handling.

package main

import (
	"bytes"
	"fmt"
	"os"

	"github.com/spf13/viper"
)

type Provider struct {
	Name          string `mapstructure:"name"`
	APIKey        string `mapstructure:"api_key"`
	Model         string `mapstructure:"model"`
	MaxTokens     int    `mapstructure:"max_tokens"`
	ContextWindow int    `mapstructure:"context_window"`
}

type Config struct {
	Providers []Provider `mapstructure:"providers"`
}

const exampleConfig = `---
providers:
  - name: openai
    api_key: 123
    model: gpt-4o
  - name: openai
    api_key: 123
    model: gpt-4o-mod
`

func main() {
	config := Config{}

	viper.SetConfigType("yaml")
	viper.SetEnvPrefix("example")
	viper.BindEnv("max_tokens")

	viper.ReadConfig(bytes.NewBuffer([]byte(exampleConfig)))
	viper.ReadInConfig()

	if err := viper.UnmarshalExact(&config); err != nil {
		fmt.Fprintf(os.Stderr, "[UnmarshalExact] error: %v\n", err)
		os.Exit(1)
	}

	if err := viper.Unmarshal(&config); err != nil {
		fmt.Fprintf(os.Stderr, "[Unmarshal] error: %v\n", err)
		os.Exit(1)
	}

	fmt.Fprintf(os.Stdout, "%#v\n", config)

	for name, provider := range config.Providers {
		fmt.Fprintf(os.Stdout, "%d: %#v\n", name, provider)
	}
}

In this case, max_tokens has env bound to it, but viper tries to map it to a top level (with viper.UnmarshalExact tried to force to fail if unknown key):

fsh ❯ EXAMPLE_MAX_TOKENS=321 go run .
[UnmarshalExact] error: 1 error(s) decoding:

* '' has invalid keys: max_tokens
exit status 1

It's the same issue with map:

package main

import (
	"bytes"
	"fmt"
	"os"

	"github.com/spf13/viper"
)

type Provider struct {
	Name          string `mapstructure:"name"`
	APIKey        string `mapstructure:"api_key"`
	Model         string `mapstructure:"model"`
	MaxTokens     int    `mapstructure:"max_tokens"`
	ContextWindow int    `mapstructure:"context_window"`
}

type Config struct {
	Providers map[string]Provider `mapstructure:"providers"`
}

const exampleConfig = `---
providers:
  option-one:
    name: openai
    api_key: 123
    model: gpt-4o
  option-two:
    name: openai
    api_key: 123
    model: gpt-4o-mod
`

func main() {
	config := Config{}

	viper.SetConfigType("yaml")
	viper.SetEnvPrefix("example")
	viper.BindEnv("max_tokens")

	viper.ReadConfig(bytes.NewBuffer([]byte(exampleConfig)))
	viper.ReadInConfig()

	if err := viper.UnmarshalExact(&config); err != nil {
		fmt.Fprintf(os.Stderr, "[UnmarshalExact] error: %v\n", err)
		os.Exit(1)
	}

	if err := viper.Unmarshal(&config); err != nil {
		fmt.Fprintf(os.Stderr, "[Unmarshal] error: %v\n", err)
		os.Exit(1)
	}

	fmt.Fprintf(os.Stdout, "%#v\n", config)

	for name, provider := range config.Providers {
		fmt.Fprintf(os.Stdout, "%s: %#v\n", name, provider)
	}
}
fsh ❯ go run .
main.Config{Providers:map[string]main.Provider{"option-one":main.Provider{Name:"openai", APIKey:"123", Model:"gpt-4o", MaxTokens:0, ContextWindow:0}, "option-two":main.Provider{Name:"openai", APIKey:"123", Model:"gpt-4o-mod", MaxTokens:0, ContextWindow:0}}}
option-one: main.Provider{Name:"openai", APIKey:"123", Model:"gpt-4o", MaxTokens:0, ContextWindow:0}
option-two: main.Provider{Name:"openai", APIKey:"123", Model:"gpt-4o-mod", MaxTokens:0, ContextWindow:0}

fsh ❯ EXAMPLE_MAX_TOKENS=321 go run .
[UnmarshalExact] error: 1 error(s) decoding:

* '' has invalid keys: max_tokens
exit status 1

Default value can be handled with extra top level fields, but that doesn't handle provider level ENV variables, or even cli flags:

package main

import (
	"bytes"
	"fmt"
	"os"

	"github.com/spf13/viper"
)

type Provider struct {
	Name          string `mapstructure:"name"`
	APIKey        string `mapstructure:"api_key"`
	Model         string `mapstructure:"model"`
	MaxTokens     int    `mapstructure:"max_tokens"`
	ContextWindow int    `mapstructure:"context_window"`
}

type Config struct {
	MaxTokens int                 `mapstructure:"max_tokens"`
	Providers map[string]Provider `mapstructure:"providers"`
}

const exampleConfig = `---
max_tokens: 100

providers:
  option-one:
    name: openai
    api_key: 123
    model: gpt-4o
  option-two:
    name: openai
    api_key: 123
    model: gpt-4o-mod
  option-three:
    name: openai
    api_key: 123
    model: gpt-4o-mod
    max_tokens: 1000
`

func main() {
	config := Config{}

	viper.SetConfigType("yaml")
	viper.SetEnvPrefix("example")
	viper.BindEnv("max_tokens")

	viper.ReadConfig(bytes.NewBuffer([]byte(exampleConfig)))
	viper.ReadInConfig()

	if err := viper.UnmarshalExact(&config); err != nil {
		fmt.Fprintf(os.Stderr, "[UnmarshalExact] error: %v\n", err)
		os.Exit(1)
	}

	if err := viper.Unmarshal(&config); err != nil {
		fmt.Fprintf(os.Stderr, "[Unmarshal] error: %v\n", err)
		os.Exit(1)
	}

	// Set global max token if local is not defined.
	for name, provider := range config.Providers {
		current := config.Providers[name]

		if provider.MaxTokens <= 0 {
			current.MaxTokens = config.MaxTokens
		}

		config.Providers[name] = current
	}

	fmt.Fprintf(os.Stdout, "%#v\n", config)

	for name, provider := range config.Providers {
		fmt.Fprintf(os.Stdout, "%s: %#v\n", name, provider)
	}
}
fsh ❯ go run .
main.Config{MaxTokens:100, Providers:map[string]main.Provider{"option-one":main.Provider{Name:"openai", APIKey:"123", Model:"gpt-4o", MaxTokens:100, ContextWindow:0}, "option-three":main.Provider{Name:"openai", APIKey:"123", Model:"gpt-4o-mod", MaxTokens:1000, ContextWindow:0}, "option-two":main.Provider{Name:"openai", APIKey:"123", Model:"gpt-4o-mod", MaxTokens:100, ContextWindow:0}}}
option-one: main.Provider{Name:"openai", APIKey:"123", Model:"gpt-4o", MaxTokens:100, ContextWindow:0}
option-two: main.Provider{Name:"openai", APIKey:"123", Model:"gpt-4o-mod", MaxTokens:100, ContextWindow:0}
option-three: main.Provider{Name:"openai", APIKey:"123", Model:"gpt-4o-mod", MaxTokens:1000, ContextWindow:0}

# This will change the default value only
fsh ❯ EXAMPLE_MAX_TOKENS=321 go run .
main.Config{MaxTokens:321, Providers:map[string]main.Provider{"option-one":main.Provider{Name:"openai", APIKey:"123", Model:"gpt-4o", MaxTokens:321, ContextWindow:0}, "option-three":main.Provider{Name:"openai", APIKey:"123", Model:"gpt-4o-mod", MaxTokens:1000, ContextWindow:0}, "option-two":main.Provider{Name:"openai", APIKey:"123", Model:"gpt-4o-mod", MaxTokens:321, ContextWindow:0}}}
option-two: main.Provider{Name:"openai", APIKey:"123", Model:"gpt-4o-mod", MaxTokens:321, ContextWindow:0}
option-three: main.Provider{Name:"openai", APIKey:"123", Model:"gpt-4o-mod", MaxTokens:1000, ContextWindow:0}
option-one: main.Provider{Name:"openai", APIKey:"123", Model:"gpt-4o", MaxTokens:321, ContextWindow:0}

Even if that's solved with a custom Decoder for viper for the env parser, flags are still a question.


(an hour later 😆 )

After spent some time on it, if the logic is gets a cut and it has two parts:

  • Parse Config File
  • Parse Flags and Env

Then a workflow can be applied like:

  1. Read Config
  2. (if there is an option to set global) Update Provider values from Global
  3. Read Env/Flags
  4. Pick Provider (from Flag, Config top-level, first in the list, 'default' named, etc.)
  5. If Viper says Flag/Env for given key has value, update Provider value.
  6. ???
  7. Profit

That's a lot of work, but probably works, and doesn't really break anything (we there is a default empty provider if providers is empty and just fill in with default values/top-level values). Is it ugly? Yes. Is it smelly? Yes.

A PoC Go code:

package main

import (
	"bytes"
	"fmt"
	"os"
	"strings"

	"github.com/spf13/pflag"
	"github.com/spf13/viper"
)

type Provider struct {
	Name          string `mapstructure:"name"`
	APIKey        string `mapstructure:"api_key"`
	Model         string `mapstructure:"model"`
	MaxTokens     int    `mapstructure:"max_tokens"`
	ContextWindow int    `mapstructure:"context_window"`
}

type Config struct {
	MaxTokens int                 `mapstructure:"max_tokens"`
	Provider  string              `mapstructure:"provider"`
	Providers map[string]Provider `mapstructure:"providers"`
}

const exampleConfig = `---
max_tokens: 100
provider: option-one

providers:
  option-one:
    name: openai
    api_key: 123
    model: gpt-4o
  option-two:
    name: openai
    api_key: 123
    model: gpt-4o-mod
  option-three:
    name: openai
    api_key: 123
    model: gpt-4o-mod
    max_tokens: 1000
`

func main() {
	config := Config{}

	pflag.Int("max-tokens", 0, "max tokens")
	pflag.String("provider", "", "provider to use")
	pflag.Parse()

	// Config file.
	fConfig := viper.New()
	fConfig.SetConfigType("yaml")
	fConfig.ReadConfig(bytes.NewBuffer([]byte(exampleConfig)))

	// Runtime Config (env, flag).
	rConfig := viper.New()
	rConfig.AutomaticEnv()
	rConfig.SetEnvPrefix("example")
	rConfig.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
	rConfig.BindPFlags(pflag.CommandLine)

	if err := fConfig.UnmarshalExact(&config); err != nil {
		fmt.Fprintf(os.Stderr, "[UnmarshalExact] error: %v\n", err)
		os.Exit(1)
	}

	// Set global max token if local is not defined.
	for name, provider := range config.Providers {
		current := config.Providers[name]

		if provider.MaxTokens <= 0 {
			current.MaxTokens = config.MaxTokens
		}

		config.Providers[name] = current
	}

	if value := rConfig.GetString("provider"); value != "" {
		config.Provider = value
	}

	provider, ok := config.Providers[config.Provider]
	if !ok {
		fmt.Fprintf(os.Stderr, "provider %s not found\n", config.Provider)
		os.Exit(1)
	}

	// Fill in
	if value := rConfig.GetInt("max-tokens"); value > 0 {
		provider.MaxTokens = value
	}

	fmt.Fprintf(os.Stdout, "%#v\n", provider)
}

Output:

fsh ❯ go run .
main.Provider{Name:"openai", APIKey:"123", Model:"gpt-4o", MaxTokens:100, ContextWindow:0}

fsh ❯ EXAMPLE_MAX_TOKENS=321 go run .
main.Provider{Name:"openai", APIKey:"123", Model:"gpt-4o", MaxTokens:321, ContextWindow:0}

fsh ❯ EXAMPLE_MAX_TOKENS=321 go run . --max-tokens 999
main.Provider{Name:"openai", APIKey:"123", Model:"gpt-4o", MaxTokens:999, ContextWindow:0}

fsh ❯ go run . --provider option-three
main.Provider{Name:"openai", APIKey:"123", Model:"gpt-4o-mod", MaxTokens:1000, ContextWindow:0}

fsh ❯ EXAMPLE_MAX_TOKENS=321 go run . --provider option-three
main.Provider{Name:"openai", APIKey:"123", Model:"gpt-4o-mod", MaxTokens:321, ContextWindow:0}

fsh ❯ EXAMPLE_MAX_TOKENS=321 go run . --max-tokens 999 --provider option-three
main.Provider{Name:"openai", APIKey:"123", Model:"gpt-4o-mod", MaxTokens:999, ContextWindow:0}

(I know I went with the map again, but it should work the same way for an array too, instead of a map lookup, it's a loop, it was simpler now to keep the code smaller)

Edit:
Sorry, I'm just dumping these here. That's the most I can do now.

@kardolus
Copy link
Owner Author

Oh my, this is great! (sorry I am on vacation and not online as much as usual).

It's does smell though, you're totally right. I still really like your initial solution with the formatted filenames, ie: config.gpt-4o.yaml and a target flag that can select a specific configuration file. The default would be to fall back to config.yaml. Unfortunately not as flexible as the solution in your latest message.

Do you think the name could be unique? Or what would be a good argument against that?

const exampleConfig = `---
max_tokens: 100
provider: openai-gpt4o

providers:
    - name: openai-gpt4o
       api_key: 123
       model: gpt-4o
    - name: openai
       api_key: 123
       model: gpt-4o-mod
      max_tokens: 1000
    - name: openai-4o-mod
       api_key: 123
       model: gpt-4o-mod
       max_tokens: 1000

I'm sorry in case I missed your explanation.

@kardolus
Copy link
Owner Author

kardolus commented Feb 14, 2025

Ugh, what a headache to keep it backwards compatible as well :/. I'd probably end up writing a migration "script" instead.

@kardolus
Copy link
Owner Author

I pushed this super hack: https://github.com/kardolus/chatgpt-cli/tree/providers

Basically just made it so it compiles. Totally doesn't work though, but this could be a good starting point to figure out if we can get this to work in the first place (by updating main.go). If we can get it to work e2e I can do all the dirty work (ie. update all the tests etc).

@yitsushi
Copy link

Do you think the name could be unique? Or what would be a good argument against that?

The main benefit of a map instead of a slice:

  1. Easier lookup
  2. Name is not used to look up the ENV for token ({NAME}_API_KEY) if not defined.

I think that's it.

The reason the second one can be handy:
We don't have to change that logic. It can still look up based on name is required.

But really, that's it. If it's a slice (array), there has to be a loop to find the right target configuration, and if name is used for that, the user is forced to define what's the ENV to use for that provider, as no one wants to define environment variables like OPENAI_API_KEY, OPENAI_HIGH_MAX_TOKEN_API_KEY, OPENAI_LIMITED_API_KEY, OPENAI_SOMETHING_RANDOM_API_KEY with the same content.

For the examples:
With my examples I tried with slice as that's how you proposed, and then checked how it would behave with map, and kept the map as it reduced the side of the code (no need that extra for loop to find the right item in the list). Apart from that slight difference (loop vs single lookup), it's the same logic:

providerConfig, ok := conf.Providers[providerName]
if !ok {
  // ... not found error
}

// vs
var providerConfig ProviderConfig
for _, provider := range conf.Providers {
  if provider.Name == providerName {
    providerConfig = conf.Providers
    break
  }
}
if providerConfig.Name == "" {
  // ... not found error
}

After that, in both cases:

  • Apply values from flags/env (override value)
  • Apply global or default values if something is empty

So it's really the same.

@kardolus
Copy link
Owner Author

Ah I see I see. Thanks!

@kardolus
Copy link
Owner Author

kardolus commented Feb 20, 2025

Oh my, this is so tricky. I am leaning towards your initial solution which is simply using filenames with clever extensions. It seems user friendly enough.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Status: In Progress
Development

No branches or pull requests

2 participants