fix(config): accept singular api_key in v2 model_list JSON

ModelConfig only declared APIKeys (json:"api_keys"). The singular
"api_key" field used in v2 configs was silently dropped by the JSON
decoder, leaving APIKeys empty and causing startup to fail with:

  failed to create provider: api_key or api_base is required for
  HTTP-based protocol "openrouter"

Add ModelConfig.UnmarshalJSON (alias trick to avoid recursion) that
reads an optional "api_key" field and prepends it to APIKeys. Guarded
by five regression tests in model_list_sushi30_test.go covering literal
keys, env:// refs, plural-only, both fields, and a full model-list array.

Note: SecureString.IsZero() always returns true outside the yaml
library (by design), so the presence check uses String() != "" instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
github-actions[bot] 2026-04-11 09:08:15 +02:00
parent 8eec398c9f
commit 09a9a091f4
2 changed files with 138 additions and 0 deletions

View file

@ -678,6 +678,27 @@ type ModelConfig struct {
isVirtual bool
}
// UnmarshalJSON accepts both "api_key" (singular SecureString) and "api_keys"
// (plural SecureStrings). When "api_key" is present it is prepended to
// APIKeys so that ModelConfig.APIKey() returns the resolved value.
func (c *ModelConfig) UnmarshalJSON(data []byte) error {
// Use a local alias to avoid infinite recursion through this method.
type modelConfigAlias ModelConfig
type wire struct {
modelConfigAlias
APIKey *SecureString `json:"api_key,omitempty"`
}
var w wire
if err := json.Unmarshal(data, &w); err != nil {
return err
}
*c = ModelConfig(w.modelConfigAlias)
if w.APIKey != nil && w.APIKey.String() != "" {
c.APIKeys = append(SecureStrings{w.APIKey}, c.APIKeys...)
}
return nil
}
// APIKey returns the first API key from apiKeys
func (c *ModelConfig) APIKey() string {
if len(c.APIKeys) > 0 {

View file

@ -0,0 +1,117 @@
// model_list_sushi30_test.go — sushi30 fork regression tests for ModelConfig
// JSON unmarshalling of the singular "api_key" field.
//
// Background: ModelConfig only declares APIKeys (plural, json:"api_keys").
// The singular "api_key" field used in v2 configs was silently dropped by the
// standard JSON unmarshaller, leaving APIKeys empty and causing:
// failed to create provider: api_key or api_base is required for HTTP-based protocol "openrouter"
//
// Fix: ModelConfig.UnmarshalJSON in pkg/config/config.go now accepts both
// "api_key" (singular) and "api_keys" (plural) and merges them into APIKeys.
// These tests guard against that regression being re-introduced by a future
// upstream merge or rebase.
package config
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestModelConfig_SingularAPIKey_JSON verifies that a literal api_key value in
// a model_list entry is unmarshalled into APIKey().
func TestModelConfig_SingularAPIKey_JSON(t *testing.T) {
data := `{
"model_name": "test-model",
"model": "openrouter/test/model",
"api_key": "sk-literal-key"
}`
var cfg ModelConfig
require.NoError(t, json.Unmarshal([]byte(data), &cfg))
assert.Equal(t, "sk-literal-key", cfg.APIKey(),
"singular api_key must be available via APIKey()")
}
// TestModelConfig_SingularAPIKey_EnvRef verifies that an env:// reference in
// the singular api_key field is resolved to the environment variable value.
func TestModelConfig_SingularAPIKey_EnvRef(t *testing.T) {
t.Setenv("PICOCLAW_TEST_MODEL_KEY", "sk-from-env")
data := `{
"model_name": "test-model",
"model": "openrouter/test/model",
"api_key": "env://PICOCLAW_TEST_MODEL_KEY"
}`
var cfg ModelConfig
require.NoError(t, json.Unmarshal([]byte(data), &cfg))
assert.Equal(t, "sk-from-env", cfg.APIKey(),
"env:// api_key must resolve to the environment variable value")
}
// TestModelConfig_PluralAPIKeys_StillWork verifies that the existing api_keys
// (plural) field continues to work after the UnmarshalJSON change.
func TestModelConfig_PluralAPIKeys_StillWork(t *testing.T) {
data := `{
"model_name": "test-model",
"model": "openrouter/test/model",
"api_keys": ["sk-a", "sk-b"]
}`
var cfg ModelConfig
require.NoError(t, json.Unmarshal([]byte(data), &cfg))
assert.Equal(t, "sk-a", cfg.APIKey(),
"first entry of api_keys must be returned by APIKey()")
assert.Len(t, cfg.APIKeys, 2)
}
// TestModelConfig_BothAPIKeyFields_SingularWins verifies that when both
// api_key and api_keys are present, the singular api_key ends up at index 0.
func TestModelConfig_BothAPIKeyFields_SingularWins(t *testing.T) {
data := `{
"model_name": "test-model",
"model": "openrouter/test/model",
"api_key": "sk-primary",
"api_keys": ["sk-fallback"]
}`
var cfg ModelConfig
require.NoError(t, json.Unmarshal([]byte(data), &cfg))
assert.Equal(t, "sk-primary", cfg.APIKey(),
"singular api_key must take slot 0 when both fields are present")
assert.Len(t, cfg.APIKeys, 2,
"api_keys entries must be preserved after singular api_key is prepended")
}
// TestModelList_EnvRef_ResolvedByGateway mimics how a real v2 config.json
// model_list array is loaded and checks that APIKey() returns the resolved
// env var value, not an empty string.
func TestModelList_EnvRef_ResolvedByGateway(t *testing.T) {
t.Setenv("OPENROUTER_API_KEY", "sk-openrouter-test")
data := `[
{
"model_name": "glm-4.5",
"model": "openrouter/z-ai/glm-4.5",
"api_key": "env://OPENROUTER_API_KEY"
},
{
"model_name": "gpt-4o",
"model": "openrouter/openai/gpt-4o",
"api_key": "env://OPENROUTER_API_KEY"
}
]`
var list SecureModelList
require.NoError(t, json.Unmarshal([]byte(data), &list))
require.Len(t, list, 2)
for _, m := range list {
assert.Equal(t, "sk-openrouter-test", m.APIKey(),
"model %q: api_key env:// must resolve, not return empty string", m.ModelName)
}
}