Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 57 additions & 18 deletions internal/aiparse/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
io.Copy(io.Discard, resp.Body)

Check failure on line 107 in internal/aiparse/parse.go

View workflow job for this annotation

GitHub Actions / Lint

Error return value of `io.Copy` is not checked (errcheck)
return config.CoreConfig{}, fmt.Errorf("--ai parse request failed with HTTP status %d", resp.StatusCode)
}

Expand Down Expand Up @@ -394,35 +394,74 @@

// 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 != "" ||
Expand Down
85 changes: 85 additions & 0 deletions internal/aiparse/parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading