Skip to content
Closed
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
7 changes: 6 additions & 1 deletion API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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[/<id>]` — 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/<commit>` 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[/<id>]`,
`GET /api/notification-channel-types` — notification CRUD.

Expand Down
1 change: 1 addition & 0 deletions db/generated/models.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 15 additions & 6 deletions db/generated/models.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions db/query/models.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,6 +22,7 @@ SET display_name = ?,
image_support = ?,
reasoning_support = ?,
reasoning_map = ?,
user_agent = ?,
updated_at = CURRENT_TIMESTAMP
WHERE model_id = ?
RETURNING *;
Expand Down
1 change: 1 addition & 0 deletions db/schema/033-add-model-user-agent.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE models ADD COLUMN user_agent TEXT NOT NULL DEFAULT '';
28 changes: 23 additions & 5 deletions llm/llmhttp/llmhttp.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const (
modelIDKey
providerKey
requestTraceKey
userAgentKey
)

// shelleyRequestIDHeader is the header Shelley sets on every LLM request with a
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
32 changes: 32 additions & 0 deletions llm/llmhttp/llmhttp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "" {
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
74 changes: 41 additions & 33 deletions models/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,7 @@ type serviceEntry struct {
source string
displayName string
tags string
userAgent string
baseURL string
apiType APIType
}
Expand All @@ -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
}

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down
Loading