diff --git a/internal/aiparse/parse.go b/internal/aiparse/parse.go index a1e3524..2a70ad3 100644 --- a/internal/aiparse/parse.go +++ b/internal/aiparse/parse.go @@ -394,35 +394,74 @@ func normalizeSecretToken(secret string) string { // ParseCoreJSON parses the LLM's strict core JSON object. func ParseCoreJSON(text string) (config.CoreConfig, error) { - dec := json.NewDecoder(strings.NewReader(strings.TrimSpace(text))) + dec := json.NewDecoder(strings.NewReader(stripOuterMarkdownFence(text))) dec.DisallowUnknownFields() - var raw map[string]string + var raw coreJSONFields if err := dec.Decode(&raw); err != nil { return config.CoreConfig{}, fmt.Errorf("--ai parse response was not strict core JSON") } if err := dec.Decode(&struct{}{}); err != io.EOF { return config.CoreConfig{}, fmt.Errorf("--ai parse response had trailing data") } - var core config.CoreConfig - for key, value := range raw { - switch key { - case "base_url": - core.BaseURL = value - case "api_key": - core.APIKey = value - case "model": - core.Model = value - case "small_fast_model": - core.SmallFastModel = value - case "provider": - core.Provider = value - default: - return config.CoreConfig{}, fmt.Errorf("--ai parse response contained unsupported field %q", key) - } + core := config.CoreConfig{ + BaseURL: string(raw.BaseURL), + APIKey: string(raw.APIKey), + Model: string(raw.Model), + SmallFastModel: string(raw.SmallFastModel), + Provider: string(raw.Provider), + } + if !coreHasAnyField(core) { + return config.CoreConfig{}, fmt.Errorf("--ai parse response contained no profile fields") } return core, nil } +func stripOuterMarkdownFence(text string) string { + trimmed := strings.TrimSpace(text) + if !strings.HasPrefix(trimmed, "```") { + return trimmed + } + + lines := strings.Split(trimmed, "\n") + if len(lines) < 2 { + return trimmed + } + + opener := strings.TrimSpace(lines[0]) + lang := strings.TrimSpace(strings.TrimPrefix(opener, "```")) + if lang != "" && !strings.EqualFold(lang, "json") { + return trimmed + } + + if strings.TrimSpace(lines[len(lines)-1]) != "```" { + return trimmed + } + + return strings.TrimSpace(strings.Join(lines[1:len(lines)-1], "\n")) +} + +type coreJSONFields struct { + BaseURL strictJSONString `json:"base_url,omitempty"` + APIKey strictJSONString `json:"api_key,omitempty"` + Model strictJSONString `json:"model,omitempty"` + SmallFastModel strictJSONString `json:"small_fast_model,omitempty"` + Provider strictJSONString `json:"provider,omitempty"` +} + +type strictJSONString string + +func (s *strictJSONString) UnmarshalJSON(data []byte) error { + if bytes.Equal(bytes.TrimSpace(data), []byte("null")) { + return fmt.Errorf("null string field") + } + var value string + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *s = strictJSONString(value) + return nil +} + func coreHasAnyField(core config.CoreConfig) bool { return core.BaseURL != "" || core.APIKey != "" || diff --git a/internal/aiparse/parse_test.go b/internal/aiparse/parse_test.go index 22ef6d4..226fee3 100644 --- a/internal/aiparse/parse_test.go +++ b/internal/aiparse/parse_test.go @@ -202,6 +202,91 @@ func TestParseCoreJSONStrictSchema(t *testing.T) { } } +func TestParseCoreJSONAcceptsMarkdownJSONFenceLiveFixture(t *testing.T) { + text := "```json\n" + + "{\n" + + " \"base_url\": \"https://api.anthropic.com\",\n" + + " \"api_key\": \"{{CLAUDECM_SECRET_1}}\",\n" + + " \"model\": \"claude-3-5-sonnet-20241022\",\n" + + " \"small_fast_model\": \"claude-3-5-haiku-20241022\",\n" + + " \"provider\": \"anthropic\"\n" + + "}\n" + + "```" + core, err := ParseCoreJSON(text) + if err != nil { + t.Fatalf("ParseCoreJSON: %v", err) + } + if core.BaseURL != "https://api.anthropic.com" || + core.APIKey != "{{CLAUDECM_SECRET_1}}" || + core.Model != "claude-3-5-sonnet-20241022" || + core.SmallFastModel != "claude-3-5-haiku-20241022" || + core.Provider != "anthropic" { + t.Fatalf("Core = %#v", core) + } +} + +func TestParseCoreJSONAcceptsBareMarkdownFence(t *testing.T) { + text := "```\n" + + "{\n" + + " \"base_url\": \"https://api.example.com\",\n" + + " \"model\": \"claude-test\",\n" + + " \"provider\": \"anthropic\"\n" + + "}\n" + + "```" + core, err := ParseCoreJSON(text) + if err != nil { + t.Fatalf("ParseCoreJSON: %v", err) + } + if core.BaseURL != "https://api.example.com" || + core.Model != "claude-test" || + core.Provider != "anthropic" { + t.Fatalf("Core = %#v", core) + } +} + +func TestParseCoreJSONStillRefusesNonConformingPayloads(t *testing.T) { + tests := []struct { + name string + text string + }{ + { + name: "unknown field", + text: `{"base_url":"https://api.example.com","extra":"nope"}`, + }, + { + name: "nested object", + text: `{"base_url":"https://api.example.com","model":{"no":"no"}}`, + }, + { + name: "array value", + text: `{"base_url":"https://api.example.com","model":["no"]}`, + }, + { + name: "null value", + text: `{"base_url":"https://api.example.com","model":null}`, + }, + { + name: "trailing data", + text: `{"base_url":"https://api.example.com"} {"model":"claude"}`, + }, + { + name: "empty object", + text: `{}`, + }, + { + name: "fenced unknown field", + text: "```json\n{\"base_url\":\"https://api.example.com\",\"extra\":\"nope\"}\n```", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := ParseCoreJSON(tt.text); err == nil { + t.Fatalf("ParseCoreJSON accepted %s", tt.name) + } + }) + } +} + type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) Do(req *http.Request) (*http.Response, error) {