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
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ Harnesses read their configuration at startup. Restart `claude`, `codex`, `openc
| Harness | Detected by | Files written | What Requesty sets |
| --- | --- | --- | --- |
| [Claude Code](https://docs.requesty.ai/integrations/claude-code) | `claude` on `PATH` | `~/.claude/settings.json` | `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN` and `ANTHROPIC_MODEL` in the `env` block |
| [Codex](https://docs.requesty.ai/integrations/openai-codex) | `codex` on `PATH` | `~/.codex/config.toml`, `~/.codex/auth.json` | A `requesty` model provider on `.../v1`, the selected model, and API key auth |
| [Codex](https://docs.requesty.ai/integrations/openai-codex) | `codex` on `PATH` | `~/.codex/config.toml` | A `requesty` model provider on `.../v1`, the selected model, and command-backed auth through `requesty auth token` |
| [OpenCode](https://docs.requesty.ai/integrations/opencode) | `opencode` on `PATH` | `~/.config/opencode/opencode.json` | A `requesty` provider on `.../v1` plus the model as `requesty/<model>` |
| [Pi](https://docs.requesty.ai/integrations/pi) | `pi` on `PATH` | `~/.pi/agent/models.json` | A `requesty` provider using the native Anthropic Messages API |
| [Hermes](https://docs.requesty.ai/integrations/hermes) | `hermes` on `PATH` | `~/.hermes/config.yaml` | A `requesty` entry in `custom_providers` and `model.default` |
Expand Down Expand Up @@ -154,9 +154,10 @@ mv ~/.claude/settings.json.requesty.bak ~/.claude/settings.json

## Keys

Your Requesty API key is written into `~/.requesty/config.json` and into each harness config the
CLI configures, because that is how those harnesses authenticate. All of these files are written
so that only your user can read them.
Your Requesty API key is written into `~/.requesty/config.json`. Codex retrieves it when needed
through `requesty auth token`; other harnesses may also store it in their own config because that
is how they authenticate. All files containing the key are written so that only your user can
read them.

Treat those files as secrets and do not commit them. Keys can be rotated or revoked at any time
on the [API keys page](https://app.requesty.ai/api-keys).
Expand Down
34 changes: 34 additions & 0 deletions cmd/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package cmd

import (
"fmt"

"github.com/spf13/cobra"
)

func newAuthCommand(env environment) *cobra.Command {
auth := &cobra.Command{
Use: "auth",
Short: "Credential helpers for integrations",
Args: cobra.NoArgs,
Hidden: true,
}

auth.AddCommand(&cobra.Command{
Use: "token",
Short: "Print the configured Requesty API key",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if env.config.APIKey == "" {
return fmt.Errorf("no Requesty API key configured")
}

if _, err := fmt.Fprintln(cmd.OutOrStdout(), env.config.APIKey); err != nil {
return fmt.Errorf("failed to print API key: %w", err)
}
return nil
},
})

return auth
}
1 change: 1 addition & 0 deletions cmd/requesty.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ func newRootCommand(env environment) *cobra.Command {
}

root.AddCommand(
newAuthCommand(env),
newAPIKeysCommand(env),
newGroupsCommand(env),
)
Expand Down
40 changes: 40 additions & 0 deletions cmd/requesty_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package cmd

import (
"bytes"
"testing"

"github.com/requestyai/cli/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestAuthTokenPrintsAPIKey(t *testing.T) {
var output bytes.Buffer
command := newRootCommand(environment{
config: config.Config{APIKey: "my-api-key"},
})
command.SetOut(&output)
command.SetArgs([]string{"auth", "token"})

require.NoError(t, command.Execute())
assert.Equal(t, "my-api-key\n", output.String())
}

func TestAuthTokenRejectsMissingAPIKey(t *testing.T) {
command := newRootCommand(environment{})
command.SetArgs([]string{"auth", "token"})

err := command.Execute()

require.EqualError(t, err, "no Requesty API key configured")
}

func TestAuthCommandIsHidden(t *testing.T) {
command := newRootCommand(environment{})

auth, _, err := command.Find([]string{"auth"})

require.NoError(t, err)
assert.True(t, auth.Hidden)
}
58 changes: 18 additions & 40 deletions internal/harnesses/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,12 @@ type codexProvider struct {
Name string `toml:"name"`
BaseURL string `toml:"base_url"`
HTTPHeaders map[string]string `toml:"http_headers"`
Auth codexProviderAuth `toml:"auth"`
}

type codexAuth struct {
AuthMode string `json:"auth_mode"`
OpenAIAPIKey string `json:"OPENAI_API_KEY"`
type codexProviderAuth struct {
Command string `toml:"command"`
Args []string `toml:"args"`
}

type CodexHarness struct {
Expand All @@ -60,8 +61,8 @@ func (c *CodexHarness) Name() string {

func (c *CodexHarness) Description() []string {
return []string{
"takes a backup of current config.toml and auth.json",
"writes a config.toml and auth.json to route through Requesty",
"takes a backup of current config.toml",
"writes a config.toml to route through Requesty",
}
}

Expand All @@ -75,20 +76,14 @@ func (c *CodexHarness) Status() (Status, error) {
}

configPath := c.configPath()
authPath := c.authPath()
status.Files = append(status.Files, configPath, authPath)
status.Files = append(status.Files, configPath)

configExists, err := pathExists(configPath)
if err != nil {
return status, fmt.Errorf("failed to check file exists: %w", err)
}

authExists, err := pathExists(authPath)
if err != nil {
return status, fmt.Errorf("failed to check file exists: %w", err)
}

if !configExists || !authExists {
if !configExists {
status.Configured = false
return status, nil
}
Expand All @@ -105,7 +100,8 @@ func (c *CodexHarness) Status() (Status, error) {
return status, fmt.Errorf("failed to unmarshal: %w", err)
}

if config.ModelProvider == codexModelProvider {
provider, providerExists := config.ModelProviders[codexModelProvider]
if config.ModelProvider == codexModelProvider && providerExists && provider.Auth.Command != "" {
status.Configured = true
} else {
status.Configured = false
Expand Down Expand Up @@ -139,29 +135,20 @@ func (c *CodexHarness) configureMerge(opts ConfigureOptions) error {
"http_headers": map[string]any{
"X-Title": "OpenAI Codex",
},
"auth": map[string]any{
"command": "requesty",
"args": []string{"auth", "token"},
},
},
},
})
if err != nil {
return fmt.Errorf("failed to merge config file: %w", err)
}

authPath := c.authPath()

auth, err := mergeOrCreateJSONConfigFile(authPath, map[string]any{
"auth_mode": "apikey",
"OPENAI_API_KEY": c.config.APIKey,
})
if err != nil {
return fmt.Errorf("failed to merge auth file: %w", err)
}

if err := backupAndWriteConfigFileAsTOML(configPath, &config); err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
if err := backupAndWriteConfigFileAsJSON(authPath, &auth); err != nil {
return fmt.Errorf("failed to write auth file: %w", err)
}

return nil
}
Expand All @@ -177,6 +164,10 @@ func (c *CodexHarness) configureOverwrite(opts ConfigureOptions) error {
HTTPHeaders: map[string]string{
"X-Title": "OpenAI Codex",
},
Auth: codexProviderAuth{
Command: "requesty",
Args: []string{"auth", "token"},
},
},
},
ModelReasoningEffort: "high",
Expand All @@ -189,22 +180,9 @@ func (c *CodexHarness) configureOverwrite(opts ConfigureOptions) error {
return fmt.Errorf("failed to write config file: %w", err)
}

auth := codexAuth{
AuthMode: "apikey",
OpenAIAPIKey: c.config.APIKey,
}

if err := backupAndWriteConfigFileAsJSON(c.authPath(), &auth); err != nil {
return fmt.Errorf("failed to write auth file: %w", err)
}

return nil
}

func (c *CodexHarness) configPath() string {
return filepath.Join(c.configDir, "config.toml")
}

func (c *CodexHarness) authPath() string {
return filepath.Join(c.configDir, "auth.json")
}
38 changes: 29 additions & 9 deletions internal/harnesses/codex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,13 @@ func TestCodexIntegrationRoundTrip(t *testing.T) {
// Simulate a machine with Codex installed but not integrated.
configDir := t.TempDir()
configPath := filepath.Join(configDir, "config.toml")
authPath := filepath.Join(configDir, "auth.json")
require.NoError(t, os.WriteFile(configPath, []byte("model = \"gpt-5.5\"\n"), 0o600))
require.NoError(t, os.WriteFile(authPath, []byte(`{"auth_mode": "chatgpt"}`), 0o600))

harness := NewCodexHarness(config, configDir)

status, err := harness.Status()
require.NoError(t, err)
assert.Contains(t, status.Files, configPath)
assert.Contains(t, status.Files, authPath)
assert.Equal(t, false, status.Configured)

// Configure the machine.
Expand All @@ -51,7 +48,6 @@ func TestCodexHarnessConfigureCreatesMissingConfig(t *testing.T) {
}
configDir := t.TempDir()
configPath := filepath.Join(configDir, "config.toml")
authPath := filepath.Join(configDir, "auth.json")
harness := NewCodexHarness(config, configDir)

require.NoError(t, harness.Configure(ConfigureOptions{
Expand All @@ -65,13 +61,37 @@ func TestCodexHarnessConfigureCreatesMissingConfig(t *testing.T) {
assert.Equal(t, "openai-responses/gpt-5.5", parsedConfig.Model)
assert.Equal(t, codexModelProvider, parsedConfig.ModelProvider)
assert.Equal(t, "https://router.requesty.ai/v1", parsedConfig.ModelProviders[codexModelProvider].BaseURL)
assert.Equal(t, codexProviderAuth{
Command: "requesty",
Args: []string{"auth", "token"},
}, parsedConfig.ModelProviders[codexModelProvider].Auth)
_, err = os.Stat(filepath.Join(configDir, "auth.json"))
assert.ErrorIs(t, err, os.ErrNotExist)
}

func TestCodexHarnessConfigureOverwriteUsesProviderAuth(t *testing.T) {
cfg := config.Config{
RouterBaseURL: "https://router.requesty.ai",
APIKey: "my-api-key",
}
configDir := t.TempDir()
harness := NewCodexHarness(cfg, configDir)

auth, err := os.ReadFile(authPath)
require.NoError(t, harness.Configure(ConfigureOptions{
Model: "openai-responses/gpt-5.5",
Overwrite: true,
}))

configBytes, err := os.ReadFile(filepath.Join(configDir, "config.toml"))
require.NoError(t, err)
assert.JSONEq(t, `{
"auth_mode": "apikey",
"OPENAI_API_KEY": "my-api-key"
}`, string(auth))
var parsedConfig codexConfig
require.NoError(t, toml.Unmarshal(configBytes, &parsedConfig))
assert.Equal(t, codexProviderAuth{
Command: "requesty",
Args: []string{"auth", "token"},
}, parsedConfig.ModelProviders[codexModelProvider].Auth)
_, err = os.Stat(filepath.Join(configDir, "auth.json"))
assert.ErrorIs(t, err, os.ErrNotExist)
}

func TestCodexHarnessDefaultConfigDir(t *testing.T) {
Expand Down
12 changes: 12 additions & 0 deletions internal/harnesses/merge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ custom_provider_setting = "keep-me"
[model_providers.requesty.http_headers]
Existing = "keep-me"

[model_providers.requesty.auth]
custom_auth_setting = "keep-me"

[projects."/existing/project"]
trust_level = "untrusted"

Expand All @@ -78,6 +81,10 @@ custom_project_setting = "keep-me"
"http_headers": map[string]any{
"X-Title": "OpenAI Codex",
},
"auth": map[string]any{
"command": "requesty",
"args": []string{"auth", "token"},
},
},
},
})
Expand All @@ -98,6 +105,11 @@ custom_project_setting = "keep-me"
"Existing": "keep-me",
"X-Title": "OpenAI Codex",
},
"auth": map[string]any{
"custom_auth_setting": "keep-me",
"command": "requesty",
"args": []string{"auth", "token"},
},
},
},
"projects": map[string]any{
Expand Down
Loading