From 895b4c8d46ecba6e8943209b0487fe65981c2baf Mon Sep 17 00:00:00 2001 From: Fayzan Ahmed Date: Mon, 19 Oct 2026 11:23:42 +0000 Subject: [PATCH] Tag harness traffic set up by the CLI with X-Requesty-Client Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 3 +- internal/harnesses/claude.go | 129 ++++++++++++++++++--------- internal/harnesses/claude_test.go | 102 ++++++++++++++++++++- internal/harnesses/codex.go | 59 ++++++------ internal/harnesses/codex_test.go | 16 +++- internal/harnesses/deepseek.go | 14 ++- internal/harnesses/deepseek_test.go | 10 ++- internal/harnesses/harnesses.go | 32 +++++++ internal/harnesses/harnesses_test.go | 35 ++++++++ internal/harnesses/hermes.go | 48 +++++----- internal/harnesses/hermes_test.go | 25 ++++-- internal/harnesses/merge.go | 14 ++- internal/harnesses/merge_test.go | 10 ++- internal/harnesses/opencode.go | 13 +-- internal/harnesses/opencode_test.go | 28 +++++- internal/harnesses/pi.go | 38 +++++--- internal/harnesses/pi/requesty.ts | 15 +++- internal/harnesses/pi_test.go | 23 ++++- 18 files changed, 461 insertions(+), 153 deletions(-) create mode 100644 internal/harnesses/harnesses_test.go diff --git a/README.md b/README.md index 56bec23..12c625b 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,8 @@ Requesty apply [automatic prompt caching](https://docs.requesty.ai/features/auto those harnesses. Where a harness supports custom headers, the CLI also sets an `X-Title` header naming the tool, so -the [Requesty dashboard](https://app.requesty.ai/analytics) can break spend down per harness. +the [Requesty dashboard](https://app.requesty.ai/analytics) can break spend down per harness, and +`X-Requesty-Client` and `X-Requesty-Client-Version` headers marking the traffic as set up by the CLI. DeepSeek Harness needs the models of a custom provider listed in its own settings, so the CLI writes the model you selected. You can add more Requesty models later inside the harness, under diff --git a/internal/harnesses/claude.go b/internal/harnesses/claude.go index 16dc357..e418b52 100644 --- a/internal/harnesses/claude.go +++ b/internal/harnesses/claude.go @@ -4,20 +4,25 @@ import ( "encoding/json" "errors" "fmt" + "maps" "os" "os/exec" "path/filepath" "runtime" + "slices" "strings" "github.com/requestyai/cli/internal/config" ) +const claudeTitle = "Claude Code" + type claudeSettings struct { Env struct { - AnthropicBaseURL string `json:"ANTHROPIC_BASE_URL"` - AnthropicAuthToken string `json:"ANTHROPIC_AUTH_TOKEN"` - AnthropicModel string `json:"ANTHROPIC_MODEL"` + AnthropicBaseURL string `json:"ANTHROPIC_BASE_URL"` + AnthropicAuthToken string `json:"ANTHROPIC_AUTH_TOKEN"` + AnthropicModel string `json:"ANTHROPIC_MODEL"` + AnthropicCustomHeaders string `json:"ANTHROPIC_CUSTOM_HEADERS,omitempty"` } `json:"env"` } @@ -64,38 +69,34 @@ func (c *ClaudeHarness) Status() (Status, error) { return status, fmt.Errorf("failed to find executable: %w", err) } - settingsPath := c.settingsPath() - status.Files = append(status.Files, settingsPath) + status.Files = append(status.Files, c.settingsPath()) - settingsExists, err := pathExists(settingsPath) + settings, err := c.readSettings() if err != nil { - return status, fmt.Errorf("failed to check file exists: %w", err) + return status, err } + status.Configured = settings.Env.AnthropicBaseURL == c.config.RouterBaseURL - if !settingsExists { - status.Configured = false - return status, nil - } + return status, nil +} + +// readSettings is the user's settings.json, or empty settings when there is +// none yet. +func (c *ClaudeHarness) readSettings() (claudeSettings, error) { + var settings claudeSettings - settingsBytes, err := os.ReadFile(settingsPath) + data, err := os.ReadFile(c.settingsPath()) if errors.Is(err, os.ErrNotExist) { - status.Configured = false + return settings, nil } else if err != nil { - return status, fmt.Errorf("failed to read file: %w", err) + return settings, fmt.Errorf("failed to read file: %w", err) } - var settings claudeSettings - if err := json.Unmarshal(settingsBytes, &settings); err != nil { - return status, fmt.Errorf("failed to unmarshal: %w", err) + if err := json.Unmarshal(data, &settings); err != nil { + return settings, fmt.Errorf("failed to unmarshal: %w", err) } - if settings.Env.AnthropicBaseURL == c.config.RouterBaseURL { - status.Configured = true - } else { - status.Configured = false - } - - return status, nil + return settings, nil } func (c *ClaudeHarness) Configure(opts ConfigureOptions) error { @@ -109,11 +110,17 @@ func (c *ClaudeHarness) Configure(opts ConfigureOptions) error { func (c *ClaudeHarness) configureMerge(opts ConfigureOptions) error { settingsPath := c.settingsPath() + existing, err := c.readSettings() + if err != nil { + return fmt.Errorf("failed to read settings file: %w", err) + } + settings, err := mergeOrCreateJSONConfigFile(settingsPath, map[string]any{ "env": map[string]any{ - "ANTHROPIC_BASE_URL": c.config.RouterBaseURL, - "ANTHROPIC_AUTH_TOKEN": c.config.APIKey, - "ANTHROPIC_MODEL": opts.Model, + "ANTHROPIC_BASE_URL": c.config.RouterBaseURL, + "ANTHROPIC_AUTH_TOKEN": c.config.APIKey, + "ANTHROPIC_MODEL": opts.Model, + "ANTHROPIC_CUSTOM_HEADERS": withHeaderLines(existing.Env.AnthropicCustomHeaders, attributionHeaders(claudeTitle)), }, }) if err != nil { @@ -132,6 +139,7 @@ func (c *ClaudeHarness) configureOverwrite(opts ConfigureOptions) error { settings.Env.AnthropicBaseURL = c.config.RouterBaseURL settings.Env.AnthropicAuthToken = c.config.APIKey settings.Env.AnthropicModel = opts.Model + settings.Env.AnthropicCustomHeaders = withHeaderLines("", attributionHeaders(claudeTitle)) if err := backupAndWriteConfigFileAsJSON(c.settingsPath(), &settings); err != nil { return fmt.Errorf("failed to write settings file: %w", err) @@ -185,10 +193,31 @@ func (c *ClaudeHarness) Launch(opts LaunchOptions) error { return fmt.Errorf("`claude` is not on PATH; install Claude Code (https://code.claude.com/docs/en/setup) and try again") } + env := c.launchEnv(opts) + + argv := []string{"claude"} + if !hasAnyFlag(opts.Args, claudeSettingsFlags) { + settings, err := c.inlineSettings() + if err != nil { + return err + } + argv = append(argv, "--settings", settings) + } + argv = append(argv, opts.Args...) + + if err := execProcess(status.ExecutablePath, argv, formatEnvironmentVariables(env)); err != nil { + return fmt.Errorf("failed to launch claude: %w", err) + } + + return nil +} + +func (c *ClaudeHarness) launchEnv(opts LaunchOptions) map[string]string { env := parseEnvironmentVariables(opts.Env) env["ANTHROPIC_BASE_URL"] = c.config.RouterBaseURL env["ANTHROPIC_API_KEY"] = c.config.APIKey env["REQUESTY_API_KEY"] = c.config.APIKey + env["ANTHROPIC_CUSTOM_HEADERS"] = withHeaderLines(env["ANTHROPIC_CUSTOM_HEADERS"], attributionHeaders(claudeTitle)) delete(env, "ANTHROPIC_AUTH_TOKEN") // The fast-mode check calls an Anthropic-only org endpoint that a gateway // cannot answer; skipping it avoids a startup warning. @@ -204,21 +233,7 @@ func (c *ClaudeHarness) Launch(opts LaunchOptions) error { env["ANTHROPIC_SMALL_FAST_MODEL"] = opts.FastModel } - argv := []string{"claude"} - if !hasAnyFlag(opts.Args, claudeSettingsFlags) { - settings, err := c.inlineSettings() - if err != nil { - return err - } - argv = append(argv, "--settings", settings) - } - argv = append(argv, opts.Args...) - - if err := execProcess(status.ExecutablePath, argv, formatEnvironmentVariables(env)); err != nil { - return fmt.Errorf("failed to launch claude: %w", err) - } - - return nil + return env } func (c *ClaudeHarness) inlineSettings() (string, error) { @@ -256,3 +271,33 @@ func hasAnyFlag(args []string, flags []string) bool { return false } + +// withHeaderLines adds headers to existing, a newline-separated list of +// `Name: Value` lines as ANTHROPIC_CUSTOM_HEADERS expects. Earlier lines for +// the same names are replaced; the user's other headers are kept. +func withHeaderLines(existing string, headers map[string]string) string { + replaced := func(name string) bool { + for header := range headers { + if strings.EqualFold(name, header) { + return true + } + } + return false + } + + lines := []string{} + for _, line := range strings.Split(existing, "\n") { + line = strings.TrimSuffix(line, "\r") + name, _, _ := strings.Cut(line, ":") + name = strings.TrimSpace(name) + if name == "" || replaced(name) { + continue + } + lines = append(lines, line) + } + for _, name := range slices.Sorted(maps.Keys(headers)) { + lines = append(lines, name+": "+headers[name]) + } + + return strings.Join(lines, "\n") +} diff --git a/internal/harnesses/claude_test.go b/internal/harnesses/claude_test.go index 7d858f7..c3ae2c6 100644 --- a/internal/harnesses/claude_test.go +++ b/internal/harnesses/claude_test.go @@ -59,7 +59,107 @@ func TestClaudeHarnessConfigureCreatesMissingConfig(t *testing.T) { "env": { "ANTHROPIC_BASE_URL": "https://router.requesty.ai", "ANTHROPIC_AUTH_TOKEN": "my-api-key", - "ANTHROPIC_MODEL": "anthropic/claude-fable-5" + "ANTHROPIC_MODEL": "anthropic/claude-fable-5", + "ANTHROPIC_CUSTOM_HEADERS": "X-Requesty-Client: requesty-cli\nX-Requesty-Client-Version: dev\nX-Title: Claude Code" } }`, string(settings)) } + +func TestClaudeHarnessConfigureKeepsCustomHeaders(t *testing.T) { + config := config.Config{ + RouterBaseURL: "https://router.requesty.ai", + APIKey: "my-api-key", + } + configDir := t.TempDir() + settingsPath := filepath.Join(configDir, "settings.json") + require.NoError(t, os.WriteFile(settingsPath, []byte(`{ + "env": {"ANTHROPIC_CUSTOM_HEADERS": "X-Team: platform\nX-Requesty-Client-Version: 0.0.1\nX-Requesty-Client: requesty-cli"} + }`), 0o600)) + harness := newClaudeHarness(config, configDir) + + require.NoError(t, harness.Configure(ConfigureOptions{ + Model: "anthropic/claude-fable-5", + })) + + settings, err := os.ReadFile(settingsPath) + require.NoError(t, err) + assert.JSONEq(t, `{ + "env": { + "ANTHROPIC_BASE_URL": "https://router.requesty.ai", + "ANTHROPIC_AUTH_TOKEN": "my-api-key", + "ANTHROPIC_MODEL": "anthropic/claude-fable-5", + "ANTHROPIC_CUSTOM_HEADERS": "X-Team: platform\nX-Requesty-Client: requesty-cli\nX-Requesty-Client-Version: dev\nX-Title: Claude Code" + } + }`, string(settings)) +} + +func TestClaudeHarnessConfigureRejectsNonStringCustomHeaders(t *testing.T) { + configDir := t.TempDir() + settingsPath := filepath.Join(configDir, "settings.json") + original := []byte(`{"env": {"ANTHROPIC_CUSTOM_HEADERS": ["X-Team: platform"]}}`) + require.NoError(t, os.WriteFile(settingsPath, original, 0o600)) + harness := newClaudeHarness(config.Config{RouterBaseURL: "https://router.requesty.ai"}, configDir) + + err := harness.Configure(ConfigureOptions{Model: "anthropic/claude-fable-5"}) + + require.Error(t, err) + settings, err := os.ReadFile(settingsPath) + require.NoError(t, err) + assert.Equal(t, original, settings) +} + +func TestClaudeHarnessLaunchEnvAddsAttributionHeaders(t *testing.T) { + harness := newClaudeHarness(config.Config{ + RouterBaseURL: "https://router.requesty.ai", + APIKey: "my-api-key", + }, t.TempDir()) + + env := harness.launchEnv(LaunchOptions{ + Env: []string{"ANTHROPIC_CUSTOM_HEADERS=X-Team: platform"}, + }) + + assert.Equal(t, "X-Team: platform\nX-Requesty-Client: requesty-cli\nX-Requesty-Client-Version: dev\nX-Title: Claude Code", env["ANTHROPIC_CUSTOM_HEADERS"]) +} + +func TestWithHeaderLines(t *testing.T) { + headers := map[string]string{"X-Title": "Tool", "X-Requesty-Client": "requesty-cli"} + const ourLines = "X-Requesty-Client: requesty-cli\nX-Title: Tool" + + tests := []struct { + name string + existing string + want string + }{ + { + name: "empty", + existing: "", + want: ourLines, + }, + { + name: "keeps the user's headers", + existing: "X-Team: platform\nX-Env: prod", + want: "X-Team: platform\nX-Env: prod\n" + ourLines, + }, + { + name: "replaces earlier values of our headers whatever their case", + existing: "x-requesty-client: other\nX-TITLE: Old\nX-Team: platform", + want: "X-Team: platform\n" + ourLines, + }, + { + name: "drops carriage returns and blank lines", + existing: "X-Team: platform\r\n\r\nX-Env: prod\r\n", + want: "X-Team: platform\nX-Env: prod\n" + ourLines, + }, + { + name: "keeps values containing colons", + existing: "X-Callback: https://example.com:8443/hook", + want: "X-Callback: https://example.com:8443/hook\n" + ourLines, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, withHeaderLines(tt.existing, headers)) + }) + } +} diff --git a/internal/harnesses/codex.go b/internal/harnesses/codex.go index d09e986..8820e2c 100644 --- a/internal/harnesses/codex.go +++ b/internal/harnesses/codex.go @@ -3,9 +3,11 @@ package harnesses import ( "errors" "fmt" + "maps" "os" "os/exec" "path/filepath" + "slices" "strconv" "strings" @@ -15,6 +17,7 @@ import ( const ( codexModelProvider = "requesty" + codexTitle = "OpenAI Codex" ) // codexProviderAuthConflicts are provider keys Codex refuses alongside @@ -142,11 +145,9 @@ func (c *CodexHarness) configureMerge(opts ConfigureOptions) error { "personality": "pragmatic", "model_providers": map[string]any{ codexModelProvider: map[string]any{ - "name": "Requesty", - "base_url": fmt.Sprintf("%s/v1", c.config.RouterBaseURL), - "http_headers": map[string]any{ - "X-Title": "OpenAI Codex", - }, + "name": "Requesty", + "base_url": fmt.Sprintf("%s/v1", c.config.RouterBaseURL), + "http_headers": attributionHeaders(codexTitle), "auth": map[string]any{ "command": "requesty", "args": c.authArgs(), @@ -180,11 +181,9 @@ func (c *CodexHarness) configureOverwrite(opts ConfigureOptions) error { ModelProvider: codexModelProvider, ModelProviders: map[string]codexProvider{ codexModelProvider: { - Name: "Requesty", - BaseURL: fmt.Sprintf("%s/v1", c.config.RouterBaseURL), - HTTPHeaders: map[string]string{ - "X-Title": "OpenAI Codex", - }, + Name: "Requesty", + BaseURL: fmt.Sprintf("%s/v1", c.config.RouterBaseURL), + HTTPHeaders: attributionHeaders(codexTitle), Auth: codexProviderAuth{ Command: "requesty", Args: c.authArgs(), @@ -225,21 +224,7 @@ func (c *CodexHarness) Launch(opts LaunchOptions) error { return fmt.Errorf("`codex` is not on PATH; install Codex (https://developers.openai.com/codex/cli) and try again") } - provider := "model_providers." + codexModelProvider - argv := []string{ - "codex", - "-c", "model_provider=" + tomlString(codexModelProvider), - "-c", provider + ".name=" + tomlString("Requesty"), - "-c", provider + ".base_url=" + tomlString(c.config.RouterBaseURL+"/v1"), - "-c", provider + ".http_headers.X-Title=" + tomlString("OpenAI Codex"), - "-c", provider + ".auth.command=" + tomlString(requestyExecutable()), - "-c", provider + ".auth.args=[" + tomlString("auth") + "," + tomlString("token") + "," + tomlString("--profile") + "," + tomlString(c.config.Name) + "]", - "-c", "model_supports_reasoning_summaries=false", - } - if opts.Model != "" { - argv = append(argv, "-m", opts.Model) - } - argv = append(argv, opts.Args...) + argv := c.launchArgs(opts) env := opts.Env if env == nil { @@ -253,6 +238,30 @@ func (c *CodexHarness) Launch(opts LaunchOptions) error { return nil } +func (c *CodexHarness) launchArgs(opts LaunchOptions) []string { + provider := "model_providers." + codexModelProvider + argv := []string{ + "codex", + "-c", "model_provider=" + tomlString(codexModelProvider), + "-c", provider + ".name=" + tomlString("Requesty"), + "-c", provider + ".base_url=" + tomlString(c.config.RouterBaseURL+"/v1"), + } + headers := attributionHeaders(codexTitle) + for _, name := range slices.Sorted(maps.Keys(headers)) { + argv = append(argv, "-c", provider+".http_headers."+name+"="+tomlString(headers[name])) + } + argv = append(argv, + "-c", provider+".auth.command="+tomlString(requestyExecutable()), + "-c", provider+".auth.args=["+tomlString("auth")+","+tomlString("token")+","+tomlString("--profile")+","+tomlString(c.config.Name)+"]", + "-c", "model_supports_reasoning_summaries=false", + ) + if opts.Model != "" { + argv = append(argv, "-m", opts.Model) + } + + return append(argv, opts.Args...) +} + // tomlString quotes s as a TOML string for a Codex -c override. Codex parses // the value side as TOML, so unquoted text with spaces or slashes would be // rejected. Literal strings are preferred because they need no escaping. diff --git a/internal/harnesses/codex_test.go b/internal/harnesses/codex_test.go index f2c3f61..a9c1141 100644 --- a/internal/harnesses/codex_test.go +++ b/internal/harnesses/codex_test.go @@ -136,6 +136,18 @@ func TestTomlStringQuotesForCodexOverrides(t *testing.T) { } } +func TestCodexHarnessLaunchArgsSendClientHeaders(t *testing.T) { + harness := newCodexHarness(config.Config{RouterBaseURL: "https://router.requesty.ai"}, t.TempDir()) + + argv := harness.launchArgs(LaunchOptions{}) + + assert.Subset(t, argv, []string{ + "model_providers.requesty.http_headers.X-Title='OpenAI Codex'", + "model_providers.requesty.http_headers.X-Requesty-Client='requesty-cli'", + "model_providers.requesty.http_headers.X-Requesty-Client-Version='dev'", + }) +} + func TestCodexHarnessConfigureMergeDropsConflictingProviderAuth(t *testing.T) { cfg := config.Config{ Name: "work", @@ -170,7 +182,9 @@ custom_provider_setting = "keep-me" "base_url": "https://router.requesty.ai/v1", "custom_provider_setting": "keep-me", "http_headers": map[string]any{ - "X-Title": "OpenAI Codex", + "X-Title": "OpenAI Codex", + "X-Requesty-Client": "requesty-cli", + "X-Requesty-Client-Version": "dev", }, "auth": map[string]any{ "command": "requesty", diff --git a/internal/harnesses/deepseek.go b/internal/harnesses/deepseek.go index a5cc771..86b2b86 100644 --- a/internal/harnesses/deepseek.go +++ b/internal/harnesses/deepseek.go @@ -24,6 +24,8 @@ const ( deepseekContextWindow = 200000 deepseekMaxTokens = 8192 + + deepseekTitle = "DeepSeek Harness" ) type deepseekSettings struct { @@ -193,11 +195,8 @@ func (d *DeepSeekHarness) configureMerge(opts ConfigureOptions) error { "baseURL": d.routerBaseURL(), "defaultContextWindow": deepseekContextWindow, "defaultMaxTokens": deepseekMaxTokens, - "headers": map[string]any{ - "HTTP-Referer": "https://requesty.ai", - "X-Title": "DeepSeek Harness", - }, - "models": models, + "headers": attributionHeaders(deepseekTitle), + "models": models, }, }, }, @@ -232,10 +231,7 @@ func (d *DeepSeekHarness) configureOverwrite(opts ConfigureOptions) error { BaseURL: d.routerBaseURL(), DefaultContextWindow: deepseekContextWindow, DefaultMaxTokens: deepseekMaxTokens, - Headers: map[string]string{ - "HTTP-Referer": "https://requesty.ai", - "X-Title": "DeepSeek Harness", - }, + Headers: attributionHeaders(deepseekTitle), Models: []deepseekModelConfig{ {ID: opts.Model}, }, diff --git a/internal/harnesses/deepseek_test.go b/internal/harnesses/deepseek_test.go index c46db82..4afd492 100644 --- a/internal/harnesses/deepseek_test.go +++ b/internal/harnesses/deepseek_test.go @@ -71,8 +71,9 @@ llm-deepseek: "defaultContextWindow": 200000, "defaultMaxTokens": 8192, "headers": map[string]any{ - "HTTP-Referer": "https://requesty.ai", - "X-Title": "DeepSeek Harness", + "X-Title": "DeepSeek Harness", + "X-Requesty-Client": "requesty-cli", + "X-Requesty-Client-Version": "dev", }, "models": []any{ map[string]any{"id": "deepseek/deepseek-v4-pro-0813"}, @@ -127,8 +128,9 @@ func TestDeepSeekHarnessConfigureCreatesMissingFiles(t *testing.T) { DefaultContextWindow: 200000, DefaultMaxTokens: 8192, Headers: map[string]string{ - "HTTP-Referer": "https://requesty.ai", - "X-Title": "DeepSeek Harness", + "X-Title": "DeepSeek Harness", + "X-Requesty-Client": "requesty-cli", + "X-Requesty-Client-Version": "dev", }, Models: []deepseekModelConfig{ {ID: "anthropic/claude-fable-5"}, diff --git a/internal/harnesses/harnesses.go b/internal/harnesses/harnesses.go index 4e5cdba..2b86c33 100644 --- a/internal/harnesses/harnesses.go +++ b/internal/harnesses/harnesses.go @@ -1,9 +1,41 @@ package harnesses import ( + "strings" + "github.com/requestyai/cli/internal/config" + "github.com/requestyai/cli/internal/version" ) +// Every request from a harness the CLI set up or launched carries two pieces +// of attribution: which harness it came from, and that the Requesty CLI put +// it there. The router stores both, so the dashboard can break spend down per +// harness and tell CLI traffic apart from harnesses configured by hand. +const ( + // harnessTitleHeader names the harness, in the X-Title convention the + // harnesses themselves use. + harnessTitleHeader = "X-Title" + + requestyClientHeader = "X-Requesty-Client" + requestyClientVersionHeader = "X-Requesty-Client-Version" + requestyClientName = "requesty-cli" +) + +func requestyClientVersion() string { + return strings.TrimPrefix(version.Version, "v") +} + +// attributionHeaders are the headers a harness sends on every request so the +// router can attribute it: title names the harness, and the client headers +// say the Requesty CLI configured or launched it. +func attributionHeaders(title string) map[string]string { + return map[string]string{ + harnessTitleHeader: title, + requestyClientHeader: requestyClientName, + requestyClientVersionHeader: requestyClientVersion(), + } +} + type Status struct { Files []string Executable bool diff --git a/internal/harnesses/harnesses_test.go b/internal/harnesses/harnesses_test.go new file mode 100644 index 0000000..b88aae1 --- /dev/null +++ b/internal/harnesses/harnesses_test.go @@ -0,0 +1,35 @@ +package harnesses + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAttributionHeadersNameHarnessAndClient(t *testing.T) { + assert.Equal(t, map[string]string{ + "X-Title": "Tool", + "X-Requesty-Client": "requesty-cli", + "X-Requesty-Client-Version": "dev", + }, attributionHeaders("Tool")) +} + +func TestMergePatchMergesTypedHeadersIntoExisting(t *testing.T) { + destination := map[string]any{ + "headers": map[string]any{"X-Team": "platform", "X-Title": "stale"}, + } + + require.NoError(t, mergePatch(destination, map[string]any{ + "headers": attributionHeaders("Tool"), + }, "")) + + assert.Equal(t, map[string]any{ + "headers": map[string]any{ + "X-Team": "platform", + "X-Title": "Tool", + "X-Requesty-Client": "requesty-cli", + "X-Requesty-Client-Version": "dev", + }, + }, destination) +} diff --git a/internal/harnesses/hermes.go b/internal/harnesses/hermes.go index 2fb3219..2e23a2d 100644 --- a/internal/harnesses/hermes.go +++ b/internal/harnesses/hermes.go @@ -18,6 +18,11 @@ const ( // hermesAPIMode is the native Anthropic Messages format, which lets Requesty // apply automatic prompt caching to Hermes' large system prompt. hermesAPIMode = "anthropic_messages" + + // hermesTitle and the other attribution headers go on the provider entry + // rather than in model.default_headers, which Hermes ignores in + // anthropic_messages mode. + hermesTitle = "Hermes Agent" ) type hermesConfig struct { @@ -26,16 +31,16 @@ type hermesConfig struct { } type hermesModel struct { - Default string `yaml:"default"` - Provider string `yaml:"provider"` - DefaultHeaders map[string]string `yaml:"default_headers,omitempty"` + Default string `yaml:"default"` + Provider string `yaml:"provider"` } type hermesCustomProvider struct { - Name string `yaml:"name"` - BaseURL string `yaml:"base_url"` - APIKey string `yaml:"api_key"` - APIMode string `yaml:"api_mode"` + Name string `yaml:"name"` + BaseURL string `yaml:"base_url"` + APIKey string `yaml:"api_key"` + APIMode string `yaml:"api_mode"` + ExtraHeaders map[string]string `yaml:"extra_headers,omitempty"` } type HermesHarness struct { @@ -149,6 +154,9 @@ func (h *HermesHarness) Launch(opts LaunchOptions) error { return fmt.Errorf("`hermes` is not on PATH; install Hermes (https://hermes-agent.nousresearch.com/docs/getting-started/quickstart) and try again") } + // Launched runs go without the Requesty client headers: Hermes reads + // headers only from config.yaml, matching custom_providers entries by + // base URL, and has no flag or environment variable to pass them. env := parseEnvironmentVariables(opts.Env) env["CUSTOM_BASE_URL"] = h.config.RouterBaseURL + "/v1" env["REQUESTY_API_KEY"] = h.config.APIKey @@ -243,10 +251,6 @@ func (h *HermesHarness) configureMerge(opts ConfigureOptions) error { "model": map[string]any{ "default": opts.Model, "provider": hermesProvider, - "default_headers": map[string]any{ - "HTTP-Referer": "https://hermes-agent.nousresearch.com", - "X-Origin-Title": "Hermes", - }, }, }) if err != nil { @@ -273,17 +277,14 @@ func (h *HermesHarness) configureOverwrite(opts ConfigureOptions) error { Model: hermesModel{ Default: opts.Model, Provider: hermesProvider, - DefaultHeaders: map[string]string{ - "HTTP-Referer": "https://hermes-agent.nousresearch.com", - "X-Origin-Title": "Hermes", - }, }, CustomProviders: []hermesCustomProvider{ { - Name: hermesProvider, - BaseURL: h.config.RouterBaseURL, - APIKey: h.config.APIKey, - APIMode: hermesAPIMode, + Name: hermesProvider, + BaseURL: h.config.RouterBaseURL, + APIKey: h.config.APIKey, + APIMode: hermesAPIMode, + ExtraHeaders: attributionHeaders(hermesTitle), }, }, } @@ -299,10 +300,11 @@ func (h *HermesHarness) configureOverwrite(opts ConfigureOptions) error { // when the user has not configured Requesty yet. func (h *HermesHarness) upsertCustomProvider(providers []any) []any { provider := map[string]any{ - "name": hermesProvider, - "base_url": h.config.RouterBaseURL, - "api_key": h.config.APIKey, - "api_mode": hermesAPIMode, + "name": hermesProvider, + "base_url": h.config.RouterBaseURL, + "api_key": h.config.APIKey, + "api_mode": hermesAPIMode, + "extra_headers": attributionHeaders(hermesTitle), } for i, existing := range providers { diff --git a/internal/harnesses/hermes_test.go b/internal/harnesses/hermes_test.go index faa08ec..f1ebbfb 100644 --- a/internal/harnesses/hermes_test.go +++ b/internal/harnesses/hermes_test.go @@ -56,10 +56,6 @@ custom_providers: "model": map[string]any{ "default": "anthropic/claude-fable-5", "provider": "requesty", - "default_headers": map[string]any{ - "HTTP-Referer": "https://hermes-agent.nousresearch.com", - "X-Origin-Title": "Hermes", - }, }, "custom_providers": []any{ map[string]any{ @@ -72,6 +68,11 @@ custom_providers: "base_url": "https://router.requesty.ai", "api_key": "my-api-key", "api_mode": "anthropic_messages", + "extra_headers": map[string]any{ + "X-Title": "Hermes Agent", + "X-Requesty-Client": "requesty-cli", + "X-Requesty-Client-Version": "dev", + }, }, }, }, settings) @@ -107,7 +108,12 @@ func TestHermesHarnessUpdatesExistingProvider(t *testing.T) { "base_url": "https://router.eu.requesty.ai", "api_key": "my-new-api-key", "api_mode": "anthropic_messages", - "timeout": 300, + "extra_headers": map[string]any{ + "X-Title": "Hermes Agent", + "X-Requesty-Client": "requesty-cli", + "X-Requesty-Client-Version": "dev", + }, + "timeout": 300, }, }, settings["custom_providers"]) @@ -139,10 +145,6 @@ func TestHermesHarnessConfigureCreatesMissingConfig(t *testing.T) { "model": map[string]any{ "default": "anthropic/claude-fable-5", "provider": "requesty", - "default_headers": map[string]any{ - "HTTP-Referer": "https://hermes-agent.nousresearch.com", - "X-Origin-Title": "Hermes", - }, }, "custom_providers": []any{ map[string]any{ @@ -150,6 +152,11 @@ func TestHermesHarnessConfigureCreatesMissingConfig(t *testing.T) { "base_url": "https://router.requesty.ai", "api_key": "my-api-key", "api_mode": "anthropic_messages", + "extra_headers": map[string]any{ + "X-Title": "Hermes Agent", + "X-Requesty-Client": "requesty-cli", + "X-Requesty-Client-Version": "dev", + }, }, }, }, settings) diff --git a/internal/harnesses/merge.go b/internal/harnesses/merge.go index 88a4450..68cbf99 100644 --- a/internal/harnesses/merge.go +++ b/internal/harnesses/merge.go @@ -115,8 +115,18 @@ func mergeYAMLConfigFileWithOptions(path string, patch map[string]any, options m // It rejects nested patches when the existing value is not an object or table. func mergePatch(destination, patch map[string]any, path string) error { for key, patchValue := range patch { - patchMap, isMap := patchValue.(map[string]any) - if !isMap { + var patchMap map[string]any + switch value := patchValue.(type) { + case map[string]any: + patchMap = value + case map[string]string: + // Header maps are typed; they merge into the user's headers like + // any other object rather than replacing them. + patchMap = make(map[string]any, len(value)) + for name, v := range value { + patchMap[name] = v + } + default: destination[key] = patchValue continue } diff --git a/internal/harnesses/merge_test.go b/internal/harnesses/merge_test.go index d9bcc12..116722f 100644 --- a/internal/harnesses/merge_test.go +++ b/internal/harnesses/merge_test.go @@ -79,7 +79,9 @@ custom_project_setting = "keep-me" codexModelProvider: map[string]any{ "base_url": "https://router.requesty.ai/v1", "http_headers": map[string]any{ - "X-Title": "OpenAI Codex", + "X-Title": "OpenAI Codex", + "X-Requesty-Client": "requesty-cli", + "X-Requesty-Client-Version": "dev", }, "auth": map[string]any{ "command": "requesty", @@ -102,8 +104,10 @@ custom_project_setting = "keep-me" "custom_provider_setting": "keep-me", "base_url": "https://router.requesty.ai/v1", "http_headers": map[string]any{ - "Existing": "keep-me", - "X-Title": "OpenAI Codex", + "Existing": "keep-me", + "X-Title": "OpenAI Codex", + "X-Requesty-Client": "requesty-cli", + "X-Requesty-Client-Version": "dev", }, "auth": map[string]any{ "custom_auth_setting": "keep-me", diff --git a/internal/harnesses/opencode.go b/internal/harnesses/opencode.go index c822ae5..3b2a3eb 100644 --- a/internal/harnesses/opencode.go +++ b/internal/harnesses/opencode.go @@ -16,6 +16,7 @@ import ( const ( openCodeProvider = "requesty" openCodeSchema = "https://opencode.ai/config.json" + openCodeTitle = "OpenCode" ) type openCodeConfig struct { @@ -168,9 +169,7 @@ func (o *OpenCodeHarness) inlineConfig(existing, model string) (string, error) { "options": map[string]any{ "baseURL": o.baseURL(), "apiKey": "{env:REQUESTY_API_KEY}", - "headers": map[string]any{ - "X-Title": "OpenCode", - }, + "headers": attributionHeaders(openCodeTitle), }, } if model != "" { @@ -255,9 +254,7 @@ func (o *OpenCodeHarness) configureMerge(opts ConfigureOptions) error { "options": map[string]any{ "baseURL": o.baseURL(), "apiKey": o.config.APIKey, - "headers": map[string]any{ - "X-Title": "OpenCode", - }, + "headers": attributionHeaders(openCodeTitle), }, }, }, @@ -282,9 +279,7 @@ func (o *OpenCodeHarness) configureOverwrite(opts ConfigureOptions) error { Options: openCodeProviderOptions{ BaseURL: o.baseURL(), APIKey: o.config.APIKey, - Headers: map[string]string{ - "X-Title": "OpenCode", - }, + Headers: attributionHeaders(openCodeTitle), }, }, }, diff --git a/internal/harnesses/opencode_test.go b/internal/harnesses/opencode_test.go index a05cb43..f501f87 100644 --- a/internal/harnesses/opencode_test.go +++ b/internal/harnesses/opencode_test.go @@ -50,13 +50,37 @@ func TestOpenCodeHarnessRoundTrip(t *testing.T) { "options": { "baseURL": "https://router.requesty.ai/v1", "apiKey": "my-api-key", - "headers": {"X-Title": "OpenCode"} + "headers": {"X-Title": "OpenCode", "X-Requesty-Client": "requesty-cli", "X-Requesty-Client-Version": "dev"} } } } }`, string(settings)) } +func TestOpenCodeHarnessInlineConfigKeepsUserHeaders(t *testing.T) { + harness := newOpenCodeHarness(config.Config{RouterBaseURL: "https://router.requesty.ai"}, t.TempDir()) + + content, err := harness.inlineConfig(`{"provider": {"requesty": {"options": {"headers": {"X-Team": "platform"}}}}}`, "") + require.NoError(t, err) + + assert.JSONEq(t, `{ + "provider": { + "requesty": { + "options": { + "baseURL": "https://router.requesty.ai/v1", + "apiKey": "{env:REQUESTY_API_KEY}", + "headers": { + "X-Team": "platform", + "X-Title": "OpenCode", + "X-Requesty-Client": "requesty-cli", + "X-Requesty-Client-Version": "dev" + } + } + } + } + }`, content) +} + func TestOpenCodeHarnessConfigureCreatesMissingConfig(t *testing.T) { config := config.Config{ RouterBaseURL: "https://router.requesty.ai", @@ -80,7 +104,7 @@ func TestOpenCodeHarnessConfigureCreatesMissingConfig(t *testing.T) { "options": { "baseURL": "https://router.requesty.ai/v1", "apiKey": "my-api-key", - "headers": {"X-Title": "OpenCode"} + "headers": {"X-Title": "OpenCode", "X-Requesty-Client": "requesty-cli", "X-Requesty-Client-Version": "dev"} } } } diff --git a/internal/harnesses/pi.go b/internal/harnesses/pi.go index ecd672a..55250dd 100644 --- a/internal/harnesses/pi.go +++ b/internal/harnesses/pi.go @@ -19,6 +19,7 @@ import ( const ( piProvider = "requesty" + piTitle = "Pi" piDefaultThinkingLevel = "medium" // piAPI is the native Anthropic Messages format, which lets Requesty apply @@ -256,13 +257,10 @@ func (p *PiHarness) Launch(opts LaunchOptions) error { return fmt.Errorf("failed to write Pi model catalog: %w", err) } - env := parseEnvironmentVariables(opts.Env) - for _, key := range piNeutralizedEnv { - delete(env, key) + env, err := p.launchEnv(opts, catalogPath) + if err != nil { + return err } - env["REQUESTY_API_KEY"] = p.config.APIKey - env["REQUESTY_BASE_URL"] = p.config.RouterBaseURL - env["REQUESTY_PI_CATALOG"] = catalogPath argv := []string{"pi", "--extension", extensionPath} switch { @@ -399,10 +397,7 @@ func (p *PiHarness) configureMerge(opts ConfigureOptions) error { "baseUrl": p.config.RouterBaseURL, "api": piAPI, "apiKey": p.config.APIKey, - "headers": map[string]any{ - "HTTP-Referer": "https://pi.dev", - "X-Title": "Pi", - }, + "headers": attributionHeaders(piTitle), "models": []any{ piModelConfigFrom(opts), }, @@ -442,10 +437,7 @@ func (p *PiHarness) configureOverwrite(opts ConfigureOptions) error { BaseURL: p.config.RouterBaseURL, API: piAPI, APIKey: p.config.APIKey, - Headers: map[string]string{ - "HTTP-Referer": "https://pi.dev", - "X-Title": "Pi", - }, + Headers: attributionHeaders(piTitle), Models: []piModelConfig{ piModelConfigFrom(opts), }, @@ -486,6 +478,24 @@ func piModelConfigFrom(opts ConfigureOptions) piModelConfig { } } +func (p *PiHarness) launchEnv(opts LaunchOptions, catalogPath string) (map[string]string, error) { + headers, err := json.Marshal(attributionHeaders(piTitle)) + if err != nil { + return nil, fmt.Errorf("failed to encode Pi headers: %w", err) + } + + env := parseEnvironmentVariables(opts.Env) + for _, key := range piNeutralizedEnv { + delete(env, key) + } + env["REQUESTY_API_KEY"] = p.config.APIKey + env["REQUESTY_BASE_URL"] = p.config.RouterBaseURL + env["REQUESTY_PI_CATALOG"] = catalogPath + env["REQUESTY_PI_HEADERS"] = string(headers) + + return env, nil +} + func (p *PiHarness) modelsPath() string { return filepath.Join(p.configDir, "models.json") } diff --git a/internal/harnesses/pi/requesty.ts b/internal/harnesses/pi/requesty.ts index 984003a..5ad2534 100644 --- a/internal/harnesses/pi/requesty.ts +++ b/internal/harnesses/pi/requesty.ts @@ -5,6 +5,7 @@ // REQUESTY_API_KEY the key for this profile // REQUESTY_BASE_URL the router, without a /v1 suffix // REQUESTY_PI_CATALOG a JSON file listing the models the profile can use +// REQUESTY_PI_HEADERS a JSON object of the headers to send on every request import { readFileSync } from "node:fs"; import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-coding-agent"; @@ -29,6 +30,15 @@ const loadCatalog = (path: string | undefined): ProviderModelConfig[] => { return Array.isArray(models) ? (models as ProviderModelConfig[]) : []; }; +// The CLI writes REQUESTY_PI_HEADERS itself, so only the shape is checked. +const loadHeaders = (json: string | undefined): Record => { + const parsed: unknown = json === undefined ? {} : JSON.parse(json); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error("REQUESTY_PI_HEADERS is not a JSON object; start Pi with `requesty pi`."); + } + return parsed as Record; +}; + export default function (pi: ExtensionAPI): void { if (typeof pi.registerProvider !== "function") { throw new Error("Requesty needs pi 0.84.0 or newer; upgrade pi and try again."); @@ -44,10 +54,7 @@ export default function (pi: ExtensionAPI): void { baseUrl: trimmed(process.env.REQUESTY_BASE_URL) ?? DEFAULT_BASE_URL, api: API, apiKey, - headers: { - "HTTP-Referer": "https://pi.dev", - "X-Title": "Pi", - }, + headers: loadHeaders(trimmed(process.env.REQUESTY_PI_HEADERS)), models: loadCatalog(trimmed(process.env.REQUESTY_PI_CATALOG)), }); } diff --git a/internal/harnesses/pi_test.go b/internal/harnesses/pi_test.go index 8a8405b..7465aa4 100644 --- a/internal/harnesses/pi_test.go +++ b/internal/harnesses/pi_test.go @@ -61,8 +61,9 @@ func TestPiHarnessRoundTrip(t *testing.T) { "api": "anthropic-messages", "apiKey": "my-api-key", "headers": { - "HTTP-Referer": "https://pi.dev", - "X-Title": "Pi" + "X-Title": "Pi", + "X-Requesty-Client": "requesty-cli", + "X-Requesty-Client-Version": "dev" }, "models": [ {"id": "anthropic/claude-fable-5", "contextWindow": 1000000, "maxTokens": 64000} @@ -81,6 +82,19 @@ func TestPiHarnessRoundTrip(t *testing.T) { }`, string(settings)) } +func TestPiHarnessLaunchEnvPassesHeaders(t *testing.T) { + harness := newPiHarness(config.Config{RouterBaseURL: "https://router.requesty.ai"}, t.TempDir()) + + env, err := harness.launchEnv(LaunchOptions{Env: []string{}}, "/tmp/catalog.json") + require.NoError(t, err) + + assert.JSONEq(t, `{ + "X-Title": "Pi", + "X-Requesty-Client": "requesty-cli", + "X-Requesty-Client-Version": "dev" + }`, env["REQUESTY_PI_HEADERS"]) +} + func TestPiHarnessConfigureCreatesMissingConfig(t *testing.T) { config := config.Config{ RouterBaseURL: "https://router.requesty.ai", @@ -105,8 +119,9 @@ func TestPiHarnessConfigureCreatesMissingConfig(t *testing.T) { "api": "anthropic-messages", "apiKey": "my-api-key", "headers": { - "HTTP-Referer": "https://pi.dev", - "X-Title": "Pi" + "X-Title": "Pi", + "X-Requesty-Client": "requesty-cli", + "X-Requesty-Client-Version": "dev" }, "models": [ {"id": "anthropic/claude-fable-5", "contextWindow": 200000, "maxTokens": 8192}