Skip to content
Merged
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
129 changes: 87 additions & 42 deletions internal/harnesses/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

Expand Down Expand Up @@ -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())
Comment thread
lavicosa marked this conversation as resolved.
Resolved
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 {
Expand All @@ -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 {
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand All @@ -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) {
Expand Down Expand Up @@ -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")
}
102 changes: 101 additions & 1 deletion internal/harnesses/claude_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
})
}
}
Loading
Loading