fix: address gadfly review — filename sanitization, truncation guard, shared plumbing
- 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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user