diff --git a/API.md b/API.md index 51cb81ba..4dff4490 100644 --- a/API.md +++ b/API.md @@ -213,7 +213,12 @@ fresh reset event. - `GET /api/models` — available models. - `GET /api/tools` — registered tool definitions. - `GET/POST/PUT/DELETE /api/custom-models[/]` — custom model CRUD. -- `POST /api/custom-models-test` — test a custom model config. + Custom models accept an optional `user_agent` string. When set, Shelley uses + it as the outbound `User-Agent` for that model; when empty, Shelley sends its + normal `Shelley/` value. +- `POST /api/custom-models-test` — test a custom model config. The request also + accepts `user_agent`, so client-restricted providers can be tested before the + model is saved. - `GET/POST/PUT/DELETE /api/notification-channels[/]`, `GET /api/notification-channel-types` — notification CRUD. diff --git a/db/generated/models.go b/db/generated/models.go index 55305dd4..779d3bea 100644 --- a/db/generated/models.go +++ b/db/generated/models.go @@ -64,6 +64,7 @@ type Model struct { UpdatedAt time.Time `json:"updated_at"` ReasoningEffort string `json:"reasoning_effort"` ImageSupport string `json:"image_support"` + UserAgent string `json:"user_agent"` ReasoningSupport string `json:"reasoning_support"` ReasoningMap string `json:"reasoning_map"` } diff --git a/db/generated/models.sql.go b/db/generated/models.sql.go index 714b8c83..fc9f50d3 100644 --- a/db/generated/models.sql.go +++ b/db/generated/models.sql.go @@ -10,9 +10,9 @@ import ( ) const createModel = `-- name: CreateModel :one -INSERT INTO models (model_id, display_name, provider_type, endpoint, api_key, model_name, max_tokens, tags, reasoning_effort, image_support, reasoning_support, reasoning_map) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) -RETURNING model_id, display_name, provider_type, endpoint, api_key, model_name, max_tokens, tags, created_at, updated_at, reasoning_effort, image_support, reasoning_support, reasoning_map +INSERT INTO models (model_id, display_name, provider_type, endpoint, api_key, model_name, max_tokens, tags, reasoning_effort, image_support, reasoning_support, reasoning_map, user_agent) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +RETURNING model_id, display_name, provider_type, endpoint, api_key, model_name, max_tokens, tags, created_at, updated_at, reasoning_effort, image_support, user_agent, reasoning_support, reasoning_map ` type CreateModelParams struct { @@ -28,6 +28,7 @@ type CreateModelParams struct { ImageSupport string `json:"image_support"` ReasoningSupport string `json:"reasoning_support"` ReasoningMap string `json:"reasoning_map"` + UserAgent string `json:"user_agent"` } func (q *Queries) CreateModel(ctx context.Context, arg CreateModelParams) (Model, error) { @@ -44,6 +45,7 @@ func (q *Queries) CreateModel(ctx context.Context, arg CreateModelParams) (Model arg.ImageSupport, arg.ReasoningSupport, arg.ReasoningMap, + arg.UserAgent, ) var i Model err := row.Scan( @@ -59,6 +61,7 @@ func (q *Queries) CreateModel(ctx context.Context, arg CreateModelParams) (Model &i.UpdatedAt, &i.ReasoningEffort, &i.ImageSupport, + &i.UserAgent, &i.ReasoningSupport, &i.ReasoningMap, ) @@ -75,7 +78,7 @@ func (q *Queries) DeleteModel(ctx context.Context, modelID string) error { } const getModel = `-- name: GetModel :one -SELECT model_id, display_name, provider_type, endpoint, api_key, model_name, max_tokens, tags, created_at, updated_at, reasoning_effort, image_support, reasoning_support, reasoning_map FROM models WHERE model_id = ? +SELECT model_id, display_name, provider_type, endpoint, api_key, model_name, max_tokens, tags, created_at, updated_at, reasoning_effort, image_support, user_agent, reasoning_support, reasoning_map FROM models WHERE model_id = ? ` func (q *Queries) GetModel(ctx context.Context, modelID string) (Model, error) { @@ -94,6 +97,7 @@ func (q *Queries) GetModel(ctx context.Context, modelID string) (Model, error) { &i.UpdatedAt, &i.ReasoningEffort, &i.ImageSupport, + &i.UserAgent, &i.ReasoningSupport, &i.ReasoningMap, ) @@ -101,7 +105,7 @@ func (q *Queries) GetModel(ctx context.Context, modelID string) (Model, error) { } const getModels = `-- name: GetModels :many -SELECT model_id, display_name, provider_type, endpoint, api_key, model_name, max_tokens, tags, created_at, updated_at, reasoning_effort, image_support, reasoning_support, reasoning_map FROM models ORDER BY created_at ASC +SELECT model_id, display_name, provider_type, endpoint, api_key, model_name, max_tokens, tags, created_at, updated_at, reasoning_effort, image_support, user_agent, reasoning_support, reasoning_map FROM models ORDER BY created_at ASC ` func (q *Queries) GetModels(ctx context.Context) ([]Model, error) { @@ -126,6 +130,7 @@ func (q *Queries) GetModels(ctx context.Context) ([]Model, error) { &i.UpdatedAt, &i.ReasoningEffort, &i.ImageSupport, + &i.UserAgent, &i.ReasoningSupport, &i.ReasoningMap, ); err != nil { @@ -155,9 +160,10 @@ SET display_name = ?, image_support = ?, reasoning_support = ?, reasoning_map = ?, + user_agent = ?, updated_at = CURRENT_TIMESTAMP WHERE model_id = ? -RETURNING model_id, display_name, provider_type, endpoint, api_key, model_name, max_tokens, tags, created_at, updated_at, reasoning_effort, image_support, reasoning_support, reasoning_map +RETURNING model_id, display_name, provider_type, endpoint, api_key, model_name, max_tokens, tags, created_at, updated_at, reasoning_effort, image_support, user_agent, reasoning_support, reasoning_map ` type UpdateModelParams struct { @@ -172,6 +178,7 @@ type UpdateModelParams struct { ImageSupport string `json:"image_support"` ReasoningSupport string `json:"reasoning_support"` ReasoningMap string `json:"reasoning_map"` + UserAgent string `json:"user_agent"` ModelID string `json:"model_id"` } @@ -188,6 +195,7 @@ func (q *Queries) UpdateModel(ctx context.Context, arg UpdateModelParams) (Model arg.ImageSupport, arg.ReasoningSupport, arg.ReasoningMap, + arg.UserAgent, arg.ModelID, ) var i Model @@ -204,6 +212,7 @@ func (q *Queries) UpdateModel(ctx context.Context, arg UpdateModelParams) (Model &i.UpdatedAt, &i.ReasoningEffort, &i.ImageSupport, + &i.UserAgent, &i.ReasoningSupport, &i.ReasoningMap, ) diff --git a/db/query/models.sql b/db/query/models.sql index f4672483..dbeea329 100644 --- a/db/query/models.sql +++ b/db/query/models.sql @@ -5,8 +5,8 @@ SELECT * FROM models ORDER BY created_at ASC; SELECT * FROM models WHERE model_id = ?; -- name: CreateModel :one -INSERT INTO models (model_id, display_name, provider_type, endpoint, api_key, model_name, max_tokens, tags, reasoning_effort, image_support, reasoning_support, reasoning_map) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +INSERT INTO models (model_id, display_name, provider_type, endpoint, api_key, model_name, max_tokens, tags, reasoning_effort, image_support, reasoning_support, reasoning_map, user_agent) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *; -- name: UpdateModel :one @@ -22,6 +22,7 @@ SET display_name = ?, image_support = ?, reasoning_support = ?, reasoning_map = ?, + user_agent = ?, updated_at = CURRENT_TIMESTAMP WHERE model_id = ? RETURNING *; diff --git a/db/schema/033-add-model-user-agent.sql b/db/schema/033-add-model-user-agent.sql new file mode 100644 index 00000000..ca3e94c2 --- /dev/null +++ b/db/schema/033-add-model-user-agent.sql @@ -0,0 +1 @@ +ALTER TABLE models ADD COLUMN user_agent TEXT NOT NULL DEFAULT ''; diff --git a/llm/llmhttp/llmhttp.go b/llm/llmhttp/llmhttp.go index 3f73cb0c..88031e9c 100644 --- a/llm/llmhttp/llmhttp.go +++ b/llm/llmhttp/llmhttp.go @@ -25,6 +25,7 @@ const ( modelIDKey providerKey requestTraceKey + userAgentKey ) // shelleyRequestIDHeader is the header Shelley sets on every LLM request with a @@ -178,6 +179,20 @@ func ProviderFromContext(ctx context.Context) string { return "" } +// WithUserAgent returns a context that overrides Shelley's default User-Agent. +// An empty value preserves the default. +func WithUserAgent(ctx context.Context, userAgent string) context.Context { + return context.WithValue(ctx, userAgentKey, userAgent) +} + +// UserAgentFromContext returns the configured User-Agent override, if any. +func UserAgentFromContext(ctx context.Context) string { + if v := ctx.Value(userAgentKey); v != nil { + return v.(string) + } + return "" +} + // ErrIdleTimeout is returned (wrapped) when a response stream makes no // progress — no bytes received — for longer than the configured idle timeout. // Callers can test for it with errors.Is. It is deliberately distinct from @@ -208,11 +223,14 @@ func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { // Clone the request to avoid modifying the original req = req.Clone(req.Context()) - // Add User-Agent with Shelley version - info := version.GetInfo() - userAgent := "Shelley" - if info.Commit != "" { - userAgent += "/" + info.Commit[:min(8, len(info.Commit))] + // Add the per-model override or Shelley's default User-Agent. + userAgent := UserAgentFromContext(req.Context()) + if userAgent == "" { + info := version.GetInfo() + userAgent = "Shelley" + if info.Commit != "" { + userAgent += "/" + info.Commit[:min(8, len(info.Commit))] + } } req.Header.Set("User-Agent", userAgent) diff --git a/llm/llmhttp/llmhttp_test.go b/llm/llmhttp/llmhttp_test.go index 9bd8b0ca..6b117fa3 100644 --- a/llm/llmhttp/llmhttp_test.go +++ b/llm/llmhttp/llmhttp_test.go @@ -32,6 +32,12 @@ func TestContextFunctions(t *testing.T) { t.Errorf("ProviderFromContext() = %q, want %q", got, "anthropic") } + // Test User-Agent override + ctx = WithUserAgent(ctx, "custom-agent/1.0") + if got := UserAgentFromContext(ctx); got != "custom-agent/1.0" { + t.Errorf("UserAgentFromContext() = %q, want %q", got, "custom-agent/1.0") + } + // Test empty context emptyCtx := context.Background() if got := ConversationIDFromContext(emptyCtx); got != "" { @@ -43,6 +49,9 @@ func TestContextFunctions(t *testing.T) { if got := ProviderFromContext(emptyCtx); got != "" { t.Errorf("ProviderFromContext(empty) = %q, want empty", got) } + if got := UserAgentFromContext(emptyCtx); got != "" { + t.Errorf("UserAgentFromContext(empty) = %q, want empty", got) + } } func TestTransportAddsHeaders(t *testing.T) { @@ -83,6 +92,29 @@ func TestTransportAddsHeaders(t *testing.T) { } } +func TestTransportUsesUserAgentOverride(t *testing.T) { + var got string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.Header.Get("User-Agent") + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + ctx := WithUserAgent(context.Background(), "codex_cli_rs/0.144.0") + req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil) + if err != nil { + t.Fatal(err) + } + resp, err := NewClient(nil).Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if got != "codex_cli_rs/0.144.0" { + t.Fatalf("User-Agent = %q, want override", got) + } +} + func TestTransportAddsSessionAffinityForFireworks(t *testing.T) { // Create a test server that echoes request headers var receivedHeaders http.Header diff --git a/models/models.go b/models/models.go index 423bcf88..cdafb603 100644 --- a/models/models.go +++ b/models/models.go @@ -437,6 +437,7 @@ type serviceEntry struct { source string displayName string tags string + userAgent string baseURL string apiType APIType } @@ -448,47 +449,55 @@ type ConfigInfo interface { // loggingService wraps an llm.Service with request/usage logging. type loggingService struct { - service llm.Service - logger *slog.Logger - modelID string - provider Provider + service llm.Service + logger *slog.Logger + modelID string + provider Provider + userAgent string } func (l *loggingService) Do(ctx context.Context, request *llm.Request) (*llm.Response, error) { start := time.Now() ctx = llmhttp.WithModelID(ctx, l.modelID) ctx = llmhttp.WithProvider(ctx, string(l.provider)) + if l.userAgent != "" { + ctx = llmhttp.WithUserAgent(ctx, l.userAgent) + } response, err := l.service.Do(ctx, request) durationSeconds := time.Since(start).Seconds() if err != nil { - logAttrs := []any{"model", l.modelID, "duration_seconds", durationSeconds} - if configProvider, ok := l.service.(ConfigInfo); ok { - for k, v := range configProvider.ConfigDetails() { - logAttrs = append(logAttrs, k, v) + if l.logger != nil { + logAttrs := []any{"model", l.modelID, "duration_seconds", durationSeconds} + if configProvider, ok := l.service.(ConfigInfo); ok { + for k, v := range configProvider.ConfigDetails() { + logAttrs = append(logAttrs, k, v) + } } + logAttrs = append(logAttrs, "error", err) + l.logger.Error("LLM request failed", logAttrs...) } - logAttrs = append(logAttrs, "error", err) - l.logger.Error("LLM request failed", logAttrs...) return response, err } - logAttrs := []any{"model", l.modelID, "duration_seconds", durationSeconds} - if !response.Usage.IsZero() { - logAttrs = append( - logAttrs, - "input_tokens", response.Usage.InputTokens, - "output_tokens", response.Usage.OutputTokens, - "cost_usd", response.Usage.CostUSD, - ) - if response.Usage.CacheCreationInputTokens > 0 { - logAttrs = append(logAttrs, "cache_creation_input_tokens", response.Usage.CacheCreationInputTokens) - } - if response.Usage.CacheReadInputTokens > 0 { - logAttrs = append(logAttrs, "cache_read_input_tokens", response.Usage.CacheReadInputTokens) + if l.logger != nil { + logAttrs := []any{"model", l.modelID, "duration_seconds", durationSeconds} + if !response.Usage.IsZero() { + logAttrs = append( + logAttrs, + "input_tokens", response.Usage.InputTokens, + "output_tokens", response.Usage.OutputTokens, + "cost_usd", response.Usage.CostUSD, + ) + if response.Usage.CacheCreationInputTokens > 0 { + logAttrs = append(logAttrs, "cache_creation_input_tokens", response.Usage.CacheCreationInputTokens) + } + if response.Usage.CacheReadInputTokens > 0 { + logAttrs = append(logAttrs, "cache_read_input_tokens", response.Usage.CacheReadInputTokens) + } } + l.logger.Info("LLM request completed", logAttrs...) } - l.logger.Info("LLM request completed", logAttrs...) return response, err } @@ -608,6 +617,7 @@ func (m *Manager) loadCustomModelsLocked(dbModels []generated.Model) { source: SourceCustomLabel, displayName: model.DisplayName, tags: model.Tags, + userAgent: model.UserAgent, } m.modelOrder = append(m.modelOrder, model.ModelID) } @@ -660,15 +670,13 @@ func (m *Manager) GetService(modelID string) (llm.Service, error) { if !ok { return nil, fmt.Errorf("unsupported model: %s", modelID) } - if m.logger != nil { - return &loggingService{ - service: entry.service, - logger: m.logger, - modelID: entry.modelID, - provider: entry.provider, - }, nil - } - return entry.service, nil + return &loggingService{ + service: entry.service, + logger: m.logger, + modelID: entry.modelID, + provider: entry.provider, + userAgent: entry.userAgent, + }, nil } func (m *Manager) GetAvailableModels() []string { diff --git a/models/models_test.go b/models/models_test.go index 3d0067c7..6aa29b85 100644 --- a/models/models_test.go +++ b/models/models_test.go @@ -13,6 +13,7 @@ import ( "shelley.exe.dev/db" "shelley.exe.dev/db/generated" "shelley.exe.dev/llm" + "shelley.exe.dev/llm/llmhttp" "shelley.exe.dev/loop" ) @@ -197,6 +198,42 @@ func (m *mockLLMService) MaxImageDimension() int { func (m *mockLLMService) MaxImageBytes() int { return 5 * 1024 * 1024 } func (m *mockLLMService) UseSimplifiedPatch() bool { return m.useSimplifiedPatch } +type contextCapturingService struct { + mockLLMService + userAgent string +} + +func (s *contextCapturingService) Do(ctx context.Context, request *llm.Request) (*llm.Response, error) { + s.userAgent = llmhttp.UserAgentFromContext(ctx) + return s.mockLLMService.Do(ctx, request) +} + +func TestManagerAppliesUserAgentWithoutLogger(t *testing.T) { + inner := &contextCapturingService{} + mgr, err := NewManager(&Config{Models: []Built{{ + ID: "custom-ua", + Provider: ProviderOpenAI, + Source: "test", + Service: inner, + }}}) + if err != nil { + t.Fatal(err) + } + mgr.services["custom-ua"] = serviceEntry{ + service: inner, provider: ProviderOpenAI, modelID: "custom-ua", userAgent: "codex_cli_rs/0.144.0", + } + svc, err := mgr.GetService("custom-ua") + if err != nil { + t.Fatal(err) + } + if _, err := svc.Do(context.Background(), &llm.Request{}); err != nil { + t.Fatal(err) + } + if inner.userAgent != "codex_cli_rs/0.144.0" { + t.Fatalf("User-Agent override = %q", inner.userAgent) + } +} + func TestManagerGetService(t *testing.T) { mgr, err := NewManager(&Config{Models: []Built{predictableBuilt()}}) if err != nil { diff --git a/server/custom_models.go b/server/custom_models.go index 0d300d47..e330984b 100644 --- a/server/custom_models.go +++ b/server/custom_models.go @@ -12,6 +12,7 @@ import ( "shelley.exe.dev/llm" "shelley.exe.dev/llm/ant" "shelley.exe.dev/llm/gem" + "shelley.exe.dev/llm/llmhttp" "shelley.exe.dev/llm/oai" "shelley.exe.dev/models" ) @@ -27,6 +28,7 @@ type ModelAPI struct { MaxTokens int64 `json:"max_tokens"` Tags string `json:"tags"` // Comma-separated tags (e.g., "slug" for slug generation) ReasoningEffort string `json:"reasoning_effort,omitempty"` + UserAgent string `json:"user_agent"` // ImageSupport is one of "auto", "yes", or "no". "auto" is resolved // automatically from the model's endpoint and name. ImageSupport string `json:"image_support"` @@ -48,6 +50,7 @@ type CreateModelRequest struct { MaxTokens int64 `json:"max_tokens"` Tags string `json:"tags"` // Comma-separated tags ReasoningEffort string `json:"reasoning_effort,omitempty"` + UserAgent string `json:"user_agent"` ImageSupport string `json:"image_support"` // "auto"|"yes"|"no"; empty = "auto" ReasoningSupport string `json:"reasoning_support"` // "auto"|"yes"|"no"; empty = "auto" ReasoningMap string `json:"reasoning_map"` // JSON map of Shelley level to provider-supported level @@ -63,6 +66,7 @@ type UpdateModelRequest struct { MaxTokens int64 `json:"max_tokens"` Tags string `json:"tags"` // Comma-separated tags ReasoningEffort *string `json:"reasoning_effort,omitempty"` + UserAgent *string `json:"user_agent"` // Nil preserves existing; empty clears override ImageSupport string `json:"image_support"` // "auto"|"yes"|"no"; empty preserves existing ReasoningSupport string `json:"reasoning_support"` // "auto"|"yes"|"no"; empty preserves existing ReasoningMap string `json:"reasoning_map"` @@ -115,6 +119,7 @@ type TestModelRequest struct { ReasoningSupport string `json:"reasoning_support"` ReasoningMap string `json:"reasoning_map"` ReasoningEffort *string `json:"reasoning_effort,omitempty"` + UserAgent string `json:"user_agent"` } func toModelAPI(m generated.Model) ModelAPI { @@ -128,6 +133,7 @@ func toModelAPI(m generated.Model) ModelAPI { MaxTokens: m.MaxTokens, Tags: m.Tags, ReasoningEffort: m.ReasoningEffort, + UserAgent: m.UserAgent, ImageSupport: m.ImageSupport, ReasoningSupport: m.ReasoningSupport, ReasoningMap: m.ReasoningMap, @@ -222,6 +228,7 @@ func (s *Server) handleCreateModel(w http.ResponseWriter, r *http.Request) { ImageSupport: imageSupport, ReasoningSupport: reasoningSupport, ReasoningMap: req.ReasoningMap, + UserAgent: strings.TrimSpace(req.UserAgent), }) if err != nil { http.Error(w, fmt.Sprintf("Failed to create model: %v", err), http.StatusInternalServerError) @@ -338,6 +345,10 @@ func (s *Server) handleUpdateModel(w http.ResponseWriter, r *http.Request, model if req.ReasoningEffort != nil { reasoningEffort = *req.ReasoningEffort } + userAgent := existing.UserAgent + if req.UserAgent != nil { + userAgent = strings.TrimSpace(*req.UserAgent) + } model, err := s.db.UpdateModel(r.Context(), generated.UpdateModelParams{ DisplayName: req.DisplayName, @@ -351,6 +362,7 @@ func (s *Server) handleUpdateModel(w http.ResponseWriter, r *http.Request, model ImageSupport: imageSupport, ReasoningSupport: reasoningSupport, ReasoningMap: req.ReasoningMap, + UserAgent: userAgent, ModelID: modelID, }) if err != nil { @@ -429,6 +441,7 @@ func (s *Server) handleDuplicateModel(w http.ResponseWriter, r *http.Request, mo ImageSupport: source.ImageSupport, ReasoningSupport: source.ReasoningSupport, ReasoningMap: source.ReasoningMap, + UserAgent: source.UserAgent, }) if err != nil { http.Error(w, fmt.Sprintf("Failed to duplicate model: %v", err), http.StatusInternalServerError) @@ -472,6 +485,9 @@ func (s *Server) handleTestModel(w http.ResponseWriter, r *http.Request) { if req.ReasoningEffort == nil { req.ReasoningEffort = &model.ReasoningEffort } + if req.UserAgent == "" { + req.UserAgent = model.UserAgent + } } if req.ProviderType == "" || req.Endpoint == "" || req.APIKey == "" || req.ModelName == "" { @@ -484,6 +500,9 @@ func (s *Server) handleTestModel(w http.ResponseWriter, r *http.Request) { reasoningEffort = *req.ReasoningEffort } + // Test with the same shared transport used at runtime. + testClient := llmhttp.NewClient(nil) + // Create the appropriate service based on provider type var service llm.Service switch req.ProviderType { @@ -493,6 +512,7 @@ func (s *Server) handleTestModel(w http.ResponseWriter, r *http.Request) { URL: req.Endpoint, Model: req.ModelName, ThinkingLevel: llm.ThinkingLevelMedium, + HTTPC: testClient, } case "openai": service = &oai.Service{ @@ -509,6 +529,8 @@ func (s *Server) handleTestModel(w http.ResponseWriter, r *http.Request) { UseSimplifiedPatch: false, SupportsImages: true, }, + HTTPC: testClient, + ProviderName: "openai", } case "gemini": service = &gem.Service{ @@ -516,6 +538,7 @@ func (s *Server) handleTestModel(w http.ResponseWriter, r *http.Request) { URL: req.Endpoint, Model: req.ModelName, ReasoningEffort: reasoningEffort, + HTTPC: testClient, } case "openai-responses": service = &oai.ResponsesService{ @@ -534,6 +557,7 @@ func (s *Server) handleTestModel(w http.ResponseWriter, r *http.Request) { // medium is the default when no explicit override is given. ThinkingLevel: llm.ThinkingLevelMedium, ReasoningEffort: reasoningEffort, + HTTPC: testClient, } default: http.Error(w, "Invalid provider_type", http.StatusBadRequest) @@ -552,6 +576,9 @@ func (s *Server) handleTestModel(w http.ResponseWriter, r *http.Request) { // Send a simple test request ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() + if req.UserAgent != "" { + ctx = llmhttp.WithUserAgent(ctx, strings.TrimSpace(req.UserAgent)) + } request := &llm.Request{ Messages: []llm.Message{ diff --git a/server/custom_models_test.go b/server/custom_models_test.go index 73dfd0a1..1310efc4 100644 --- a/server/custom_models_test.go +++ b/server/custom_models_test.go @@ -139,3 +139,45 @@ func TestCustomModelTestEndpoint(t *testing.T) { t.Error("Got empty response error despite having a valid API key") } } +func TestCustomModelUserAgentCRUD(t *testing.T) { + h := NewTestHarness(t) + + body := `{"display_name":"UA model","provider_type":"openai-responses","endpoint":"https://example.com/v1","api_key":"secret","model_name":"test-model","max_tokens":4096,"user_agent":" custom-agent/1.0 "}` + req := httptest.NewRequest(http.MethodPost, "/api/custom-models", bytes.NewBufferString(body)) + w := httptest.NewRecorder() + h.server.handleCreateModel(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("create status = %d: %s", w.Code, w.Body.String()) + } + var created ModelAPI + if err := json.NewDecoder(w.Body).Decode(&created); err != nil { + t.Fatal(err) + } + if created.UserAgent != "custom-agent/1.0" { + t.Fatalf("created User-Agent = %q", created.UserAgent) + } + + empty := "" + update := UpdateModelRequest{ + DisplayName: created.DisplayName, ProviderType: created.ProviderType, + Endpoint: created.Endpoint, ModelName: created.ModelName, MaxTokens: created.MaxTokens, + UserAgent: &empty, + } + updateBody, err := json.Marshal(update) + if err != nil { + t.Fatal(err) + } + req = httptest.NewRequest(http.MethodPut, "/api/custom-models/"+created.ModelID, bytes.NewReader(updateBody)) + w = httptest.NewRecorder() + h.server.handleUpdateModel(w, req, created.ModelID) + if w.Code != http.StatusOK { + t.Fatalf("update status = %d: %s", w.Code, w.Body.String()) + } + var updated ModelAPI + if err := json.NewDecoder(w.Body).Decode(&updated); err != nil { + t.Fatal(err) + } + if updated.UserAgent != "" { + t.Fatalf("cleared User-Agent = %q", updated.UserAgent) + } +} diff --git a/ui/src/services/api.ts b/ui/src/services/api.ts index c479c297..7e5ee8b1 100644 --- a/ui/src/services/api.ts +++ b/ui/src/services/api.ts @@ -718,6 +718,7 @@ export interface CustomModel { max_tokens: number; tags: string; // Comma-separated tags (e.g., "slug" for slug generation) reasoning_effort: string; // Legacy provider-verbatim default + user_agent: string; // Optional outbound User-Agent override reasoning_support: "auto" | "yes" | "no"; reasoning_map: string; supports_reasoning: boolean; @@ -734,6 +735,7 @@ export interface CreateCustomModelRequest { max_tokens: number; tags: string; // Comma-separated tags reasoning_effort: string; // Legacy provider-verbatim default + user_agent: string; // Optional outbound User-Agent override reasoning_support: "auto" | "yes" | "no"; reasoning_map: string; image_support: "auto" | "yes" | "no"; @@ -746,6 +748,7 @@ export interface TestCustomModelRequest { api_key: string; model_name: string; reasoning_effort?: string; + user_agent?: string; reasoning_support?: "auto" | "yes" | "no"; reasoning_map?: string; } diff --git a/ui/src/vue/components/ModelFormModal.vue b/ui/src/vue/components/ModelFormModal.vue index 0bc3edc1..5f23b815 100644 --- a/ui/src/vue/components/ModelFormModal.vue +++ b/ui/src/vue/components/ModelFormModal.vue @@ -108,6 +108,21 @@ /> + +
+ + +
+ Optional. Overrides the User-Agent only for this custom model. +
+
+
@@ -348,6 +363,7 @@ watch( max_tokens: m.max_tokens, tags: m.tags, reasoning_effort: m.reasoning_effort || "", + user_agent: m.user_agent || "", reasoning_support: m.reasoning_support || "auto", reasoning_map: parseReasoningMap(m.reasoning_map), image_support: m.image_support ?? "auto", @@ -394,6 +410,7 @@ async function handleTest() { api_key: form.api_key, model_name: form.model_name, reasoning_effort: form.reasoning_effort, + user_agent: form.user_agent, reasoning_support: form.reasoning_support, reasoning_map: serializeReasoningMap(), }; @@ -424,6 +441,7 @@ async function handleSave() { max_tokens: form.max_tokens, tags: form.tags, reasoning_effort: form.reasoning_effort, + user_agent: form.user_agent, reasoning_support: form.reasoning_support, reasoning_map: serializeReasoningMap(), image_support: form.image_support, diff --git a/ui/src/vue/components/customModelConstants.ts b/ui/src/vue/components/customModelConstants.ts index 505dd53d..08b8f987 100644 --- a/ui/src/vue/components/customModelConstants.ts +++ b/ui/src/vue/components/customModelConstants.ts @@ -71,6 +71,7 @@ export interface FormData { max_tokens: number; tags: string; reasoning_effort: string; + user_agent: string; reasoning_support: "auto" | "yes" | "no"; reasoning_map: ReasoningMap; image_support: "auto" | "yes" | "no"; @@ -86,6 +87,7 @@ export const emptyForm: FormData = { max_tokens: 200000, tags: "", reasoning_effort: "", + user_agent: "", reasoning_support: "auto", reasoning_map: { ...DEFAULT_REASONING_MAP }, image_support: "auto",