fix: address gadfly review — filename sanitization, truncation guard, shared plumbing
CI / Tidy (pull_request) Successful in 10m2s
CI / Build & Test (pull_request) Successful in 10m24s

- Transcribe: sanitize the caller-supplied multipart filename (CR/LF
  would inject Content-Disposition headers; upload metadata is
  untrusted), always send the required model/response_format fields,
  parse MIME parameters before extension matching, and give audio/opus
  its own .opus extension.
- doRaw: a response larger than maxResponseBytes is now an error, not a
  silent truncation.
- Shared plumbing: requireBaseURL() + newRequest() helpers replace the
  7x-duplicated guard/error string and the triplicated request
  building across doJSON/doRaw/Health.
- Health: non-2xx now returns *llm.APIError (package convention,
  programmatically distinguishable from transport failure) instead of
  a one-off unexported error type.
- Speak: reject negative Speed; speechMIME no longer accepts video/*
  Content-Types.
- image.go: Generate/Edit share one sdWire validate+map helper.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AXQxVhXBw8PwFAtsVrXSmj
This commit is contained in:
2026-07-11 23:46:23 -04:00
co-authored by Claude Fable 5
parent 434d721b99
commit 9c0ac1d60b
4 changed files with 155 additions and 128 deletions
+37 -16
View File
@@ -9,9 +9,11 @@
// package adds beyond a bare OpenAI-compat endpoint is the "tailored" surface:
//
// - llama-swap management endpoints exposed as concrete methods — ListModels
// (GET /v1/models), Running (GET /running), Unload (POST /api/models/unload)
// — which have no place on the canonical llm.Provider interface;
// - image generation via the imagegen interface (see image.go); and
// (GET /v1/models), Running (GET /running), Unload (POST /api/models/unload),
// ListVoices (GET /v1/audio/voices), Health (GET /health) — which have no
// place on the canonical llm.Provider interface;
// - image generation + editing via the imagegen interfaces (see image.go);
// - speech synthesis + transcription via the audio interfaces (see audio.go); and
// - swap-aware defaults: the HTTP client carries NO timeout, because the
// first request to an unloaded model blocks while llama-swap spawns the
// upstream (its healthCheckTimeout is at least 15s). Bound a call with a
@@ -100,8 +102,8 @@ func (p *Provider) BaseURL() string { return p.baseURL }
// endpoint, delegating to provider/openai. The id is passed through verbatim
// and selects which upstream llama-swap loads.
func (p *Provider) Model(id string, opts ...llm.ModelOption) (llm.Model, error) {
if p.baseURL == "" {
return nil, fmt.Errorf("llama-swap provider %q: no base URL configured (set one via WithBaseURL or an LLM_* env DSN)", p.name)
if err := p.requireBaseURL(); err != nil {
return nil, err
}
return p.chatProvider().Model(id, opts...)
}
@@ -178,7 +180,32 @@ func (p *Provider) Unload(ctx context.Context, model string) error {
return p.doJSON(ctx, http.MethodPost, path, "", nil, nil)
}
// --- shared HTTP helper for management + image endpoints ---
// --- shared HTTP helpers for management + image + audio endpoints ---
// requireBaseURL is the shared guard for every entry point: construction
// never fails (see New), so a missing base URL surfaces here, at use time.
func (p *Provider) requireBaseURL() error {
if p.baseURL == "" {
return fmt.Errorf("llama-swap provider %q: no base URL configured (set one via WithBaseURL or an LLM_* env DSN)", p.name)
}
return nil
}
// newRequest builds an authenticated request relative to baseURL.
// contentType is applied only when a body is present.
func (p *Provider) newRequest(ctx context.Context, method, path, contentType string, body io.Reader) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, method, p.baseURL+path, body)
if err != nil {
return nil, fmt.Errorf("llama-swap: build request: %w", err)
}
if body != nil && contentType != "" {
req.Header.Set("Content-Type", contentType)
}
if p.token != "" {
req.Header.Set("Authorization", "Bearer "+p.token)
}
return req, nil
}
// doJSON performs a request to a llama-swap endpoint relative to baseURL,
// optionally encoding body and decoding into out (either may be nil). model
@@ -186,8 +213,8 @@ func (p *Provider) Unload(ctx context.Context, model string) error {
// model-specific). Transport failures are wrapped raw so llm.Classify still
// sees the underlying net error; non-2xx responses become *llm.APIError.
func (p *Provider) doJSON(ctx context.Context, method, path, model string, body, out any) error {
if p.baseURL == "" {
return fmt.Errorf("llama-swap provider %q: no base URL configured (set one via WithBaseURL or an LLM_* env DSN)", p.name)
if err := p.requireBaseURL(); err != nil {
return err
}
var rdr io.Reader
if body != nil {
@@ -197,15 +224,9 @@ func (p *Provider) doJSON(ctx context.Context, method, path, model string, body,
}
rdr = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, p.baseURL+path, rdr)
req, err := p.newRequest(ctx, method, path, "application/json", rdr)
if err != nil {
return fmt.Errorf("llama-swap: build request: %w", err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if p.token != "" {
req.Header.Set("Authorization", "Bearer "+p.token)
return err
}
resp, err := p.client.Do(req)
if err != nil {