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
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://github.com/requestyai/cli/blob/main/LICENSE)

> Point Claude Code, Codex, OpenCode, Pi and Hermes at [Requesty](https://requesty.ai) from one terminal app.
> Point Claude Code, Codex, OpenCode, Pi, Hermes and DeepSeek Harness at [Requesty](https://requesty.ai) from one terminal app.

`requesty` finds the AI coding harnesses installed on your machine and rewrites their own
configuration so every request goes through the Requesty gateway. You get 300+ models behind a
Expand Down Expand Up @@ -68,7 +68,7 @@ Harnesses found on your `PATH` are listed first. The ones that are not installed
cannot be configured. The footer always shows the keys available on the current screen.

```text
harnesses on this machine 0 of 5 routing through requesty
harnesses on this machine 0 of 6 routing through requesty

HARNESS CONFIG STATUS
────────────────────────────────────────────────────────────────────────────
Expand All @@ -77,6 +77,7 @@ cannot be configured. The footer always shows the keys available on the current
[ ] OpenCode /home/you/.config/opencode/opencode.jsoninactive
[ ] Pi /home/you/.pi/agent/models.json inactive
[ ] Hermes /home/you/.hermes/config.yaml inactive
[ ] DeepSeek Harness /home/you/.dsh/settings.yaml inactive

╭──────────────────────────────────────────────────────────────────────────╮
│ Claude Code /home/you/.claude/settings.json │
Expand All @@ -95,8 +96,8 @@ the files, the row flips to `[✓] active`, and the header count goes up.

**Restart the harness**

Harnesses read their configuration at startup. Restart `claude`, `codex`, `opencode`, `pi` or
`hermes` and it is talking to Requesty.
Harnesses read their configuration at startup. Restart `claude`, `codex`, `opencode`, `pi`,
`hermes` or `dsh` and it is talking to Requesty.

## Supported harnesses

Expand All @@ -107,6 +108,7 @@ Harnesses read their configuration at startup. Restart `claude`, `codex`, `openc
| [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` |
| [DeepSeek Harness](https://docs.requesty.ai/integrations/deepseek-harness) | `dsh` on `PATH` | `$DSH_HOME/settings.yaml`, `$DSH_HOME/.credentials.yaml` (`~/.dsh` by default) | A `requesty` provider on `.../v1` under `llm-pi-ai`, the selected model as `agent-default-model`, and the key stored as `REQUESTY_API_KEY` |

Pi and Hermes are configured against the native Anthropic Messages format, which is what lets
Requesty apply [automatic prompt caching](https://docs.requesty.ai/features/auto-caching) to
Expand All @@ -115,6 +117,11 @@ 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.

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
Settings, Models, Requesty, either with `Fetch available models` or with `Add model`. In merge mode
a later run of the CLI keeps those entries and only adds the model you selected if it is missing.

## Usage

Above the harness list, the CLI shows spend, requests and tokens for the last 30 days for the key
Expand Down
321 changes: 321 additions & 0 deletions internal/harnesses/deepseek.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,321 @@
package harnesses

import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"

"github.com/requestyai/cli/internal/config"
"gopkg.in/yaml.v3"
)

const (
deepseekProvider = "requesty"

// deepseekAPI is the OpenAI Chat Completions format, which the harness
// speaks against a base URL that keeps the /v1 suffix.
deepseekAPI = "openai-completions"

// deepseekCredentialRef names the credential in the harness credentials
// file. The settings file references it instead of holding the key.
deepseekCredentialRef = "REQUESTY_API_KEY"

deepseekContextWindow = 200000
deepseekMaxTokens = 8192
)

type deepseekSettings struct {
DefaultModel deepseekDefaultModel `yaml:"agent-default-model"`
PiAI deepseekPiAI `yaml:"llm-pi-ai"`
}

type deepseekDefaultModel struct {
Provider string `yaml:"provider"`
Model string `yaml:"model"`
}

type deepseekPiAI struct {
Providers map[string]deepseekProviderConfig `yaml:"providers"`
}

type deepseekProviderConfig struct {
DisplayName string `yaml:"displayName"`
APIKeyEnv string `yaml:"apiKeyEnv"`
API string `yaml:"api"`
BaseURL string `yaml:"baseURL"`
DefaultContextWindow int `yaml:"defaultContextWindow"`
DefaultMaxTokens int `yaml:"defaultMaxTokens"`
Headers map[string]string `yaml:"headers,omitempty"`
Models []deepseekModelConfig `yaml:"models"`
}

type deepseekModelConfig struct {
ID string `yaml:"id"`
}

// deepseekRawSettings reads the model entries without dropping fields the
// harness writes for them, such as display names and input capacities.
type deepseekRawSettings struct {
PiAI struct {
Providers map[string]struct {
Models []map[string]any `yaml:"models"`
} `yaml:"providers"`
} `yaml:"llm-pi-ai"`
}

type DeepSeekHarness struct {
config config.Config
configDir string
}

// DefaultConfigDirDeepSeek is the DeepSeek Harness home, which holds its
// settings and credentials. The harness reads DSH_HOME first, so an explicit
// home wins over the default one.
func DefaultConfigDirDeepSeek() (string, error) {
if home := os.Getenv("DSH_HOME"); home != "" {
return home, nil
}

return configDirInHome(".dsh")
}

func NewDeepSeekHarness(config config.Config, configDir string) *DeepSeekHarness {
return &DeepSeekHarness{
config: config,
configDir: configDir,
}
}

func (d *DeepSeekHarness) Name() string {
return "DeepSeek Harness"
}

func (d *DeepSeekHarness) Description() []string {
return []string{
"takes a backup of settings.yaml and .credentials.yaml",
"writes settings.yaml and .credentials.yaml to route through Requesty",
}
}

func (d *DeepSeekHarness) Status() (Status, error) {
status := Status{}

if _, err := exec.LookPath("dsh"); err == nil {
status.Executable = true
} else if !errors.Is(err, exec.ErrNotFound) {
return status, fmt.Errorf("failed to find executable: %w", err)
}

settingsPath := d.settingsPath()
credentialsPath := d.credentialsPath()
status.Files = append(status.Files, settingsPath, credentialsPath)

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

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

if !settingsExists || !credentialsExists {
status.Configured = false
return status, nil
}

settingsBytes, err := os.ReadFile(settingsPath)
if err != nil {
return status, fmt.Errorf("failed to read file: %w", err)
}

var settings deepseekSettings
if err := yaml.Unmarshal(settingsBytes, &settings); err != nil {
return status, fmt.Errorf("failed to unmarshal: %w", err)
}

credentialsBytes, err := os.ReadFile(credentialsPath)
if err != nil {
return status, fmt.Errorf("failed to read credentials file: %w", err)
}

credentials := make(map[string]string)
if err := yaml.Unmarshal(credentialsBytes, &credentials); err != nil {
return status, fmt.Errorf("failed to unmarshal credentials: %w", err)
}

status.Configured = settings.PiAI.Providers[deepseekProvider].BaseURL == d.routerBaseURL() &&
settings.DefaultModel.Provider == deepseekProvider &&
credentials[deepseekCredentialRef] != ""

return status, nil
}

func (d *DeepSeekHarness) Configure(opts ConfigureOptions) error {
if opts.Overwrite {
return d.configureOverwrite(opts)
}

return d.configureMerge(opts)
}

func (d *DeepSeekHarness) configureMerge(opts ConfigureOptions) error {
settingsPath := d.settingsPath()

models, err := d.mergedModels(settingsPath, opts.Model)
if err != nil {
return err
}

settings, err := mergeOrCreateYAMLConfigFile(settingsPath, map[string]any{
"agent-default-model": map[string]any{
"provider": deepseekProvider,
"model": opts.Model,
},
"llm-pi-ai": map[string]any{
"providers": map[string]any{
deepseekProvider: map[string]any{
"displayName": "Requesty",
"apiKeyEnv": deepseekCredentialRef,
"api": deepseekAPI,
"baseURL": d.routerBaseURL(),
"defaultContextWindow": deepseekContextWindow,
"defaultMaxTokens": deepseekMaxTokens,
"headers": map[string]any{
"HTTP-Referer": "https://requesty.ai",
"X-Title": "DeepSeek Harness",
},
"models": models,
},
},
},
})
if err != nil {
return fmt.Errorf("failed to merge settings file: %w", err)
}

if err := backupAndWriteConfigFileAsYAML(settingsPath, &settings); err != nil {
return fmt.Errorf("failed to write settings file: %w", err)
}

if err := d.writeCredentials(); err != nil {
return err
}

return nil
}

func (d *DeepSeekHarness) configureOverwrite(opts ConfigureOptions) error {
settings := deepseekSettings{
DefaultModel: deepseekDefaultModel{
Provider: deepseekProvider,
Model: opts.Model,
},
PiAI: deepseekPiAI{
Providers: map[string]deepseekProviderConfig{
deepseekProvider: {
DisplayName: "Requesty",
APIKeyEnv: deepseekCredentialRef,
API: deepseekAPI,
BaseURL: d.routerBaseURL(),
DefaultContextWindow: deepseekContextWindow,
DefaultMaxTokens: deepseekMaxTokens,
Headers: map[string]string{
"HTTP-Referer": "https://requesty.ai",
"X-Title": "DeepSeek Harness",
},
Models: []deepseekModelConfig{
{ID: opts.Model},
},
},
},
},
}

if err := backupAndWriteConfigFileAsYAML(d.settingsPath(), &settings); err != nil {
return fmt.Errorf("failed to write settings file: %w", err)
}

if err := d.writeCredentials(); err != nil {
return err
}

return nil
}

// mergedModels keeps the model entries already listed for the Requesty
// provider, including any the user added through the harness itself, and adds
// the selected model when it is missing. The harness needs an explicit catalog
// because a custom provider ships none, so the list cannot be dropped, and a
// later run must not discard models the user picked in the harness.
func (d *DeepSeekHarness) mergedModels(settingsPath, model string) ([]any, error) {
settingsBytes, err := os.ReadFile(settingsPath)
if errors.Is(err, os.ErrNotExist) {
return []any{map[string]any{"id": model}}, nil
} else if err != nil {
return nil, fmt.Errorf("failed to read settings file: %w", err)
}

var settings deepseekRawSettings
if err := yaml.Unmarshal(settingsBytes, &settings); err != nil {
return nil, fmt.Errorf("failed to unmarshal settings file: %w", err)
}

models := make([]any, 0, len(settings.PiAI.Providers[deepseekProvider].Models)+1)
selected := false

for _, entry := range settings.PiAI.Providers[deepseekProvider].Models {
id, ok := entry["id"].(string)
if !ok || id == "" {
continue
}

if id == model {
selected = true
}

models = append(models, entry)
}

if !selected {
models = append(models, map[string]any{"id": model})
}

return models, nil
}

// writeCredentials stores the API key in the harness credentials file, which
// the settings file reaches through apiKeyEnv. Other stored credentials are
// preserved.
func (d *DeepSeekHarness) writeCredentials() error {
credentialsPath := d.credentialsPath()

credentials, err := mergeOrCreateYAMLConfigFile(credentialsPath, map[string]any{
deepseekCredentialRef: d.config.APIKey,
})
if err != nil {
return fmt.Errorf("failed to merge credentials file: %w", err)
}

if err := backupAndWriteConfigFileAsYAML(credentialsPath, &credentials); err != nil {
return fmt.Errorf("failed to write credentials file: %w", err)
}

return nil
}

// routerBaseURL keeps the /v1 suffix the OpenAI Chat Completions format needs.
func (d *DeepSeekHarness) routerBaseURL() string {
return fmt.Sprintf("%s/v1", d.config.RouterBaseURL)
}

func (d *DeepSeekHarness) settingsPath() string {
return filepath.Join(d.configDir, "settings.yaml")
}

func (d *DeepSeekHarness) credentialsPath() string {
return filepath.Join(d.configDir, ".credentials.yaml")
}
Loading
Loading