Skip to content
Open
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
8 changes: 6 additions & 2 deletions internal/app/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ func (u *UseCases) profileContext1M(profileID string) bool {
// config shapes read off one observed version with no documented reasoning
// field, and inventing keys in files those apps own risks corrupting state
// they manage (see WriteZCode).
func writeManagedAgentConfig(ctx context.Context, writer configWriter.Writer, agentID string, agent catalog.Agent, path, providerID, providerName, baseURL, apiKey, model, reasoningEffort string, context1M bool) error {
func writeManagedAgentConfig(ctx context.Context, writer configWriter.Writer, agentID string, agent catalog.Agent, path, providerID, providerName, baseURL, apiKey, model, reasoningEffort string, context1M bool, selectedProtocol ...string) error {
switch agent.ConfigAdapter {
case "codex":
return writer.WriteCodex(ctx, path, providerName, baseURL, apiKey, model, reasoningEffort)
Expand Down Expand Up @@ -275,7 +275,11 @@ func writeManagedAgentConfig(ctx context.Context, writer configWriter.Writer, ag
if providerID == "deepseek" {
return writer.WriteDSHOfficial(ctx, path, apiKey, model, reasoningEffort)
}
return writer.WriteDSH(ctx, path, providerName, baseURL, apiKey, model)
protocolID := provider.ProtocolOpenAI
if len(selectedProtocol) > 0 && selectedProtocol[0] != "" {
protocolID = selectedProtocol[0]
}
return writer.WriteDSHProtocol(ctx, path, providerName, baseURL, apiKey, model, protocolID)
case "hermes":
return writer.WriteHermes(ctx, path, baseURL, apiKey, model)
case "kimi-code":
Expand Down
46 changes: 44 additions & 2 deletions internal/app/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,15 @@ func (u *UseCases) probeInstallProtocols(ctx context.Context, options InstallAge
}
protocols := make(map[string]bool)
for _, agentID := range autoAgents {
protocols[provider.ProtocolForAdapter(manifest.Agents[agentID].ConfigAdapter)] = true
agent := manifest.Agents[agentID]
protocols[provider.ProtocolForAdapter(agent.ConfigAdapter)] = true
// DSH's current pi-ai adapter supports both OpenAI wire protocols.
// Probe both because newer reasoning models (for example gpt-5.6-sol)
// may reject Chat Completions while accepting Responses.
if agent.ConfigAdapter == "dsh" {
protocols[provider.ProtocolResponses] = true
protocols[provider.ProtocolOpenAI] = true
}
}
ordered := make([]string, 0, len(protocols))
for protocolID := range protocols {
Expand All @@ -247,6 +255,30 @@ func (u *UseCases) probeInstallProtocols(ctx context.Context, options InstallAge
return nil, err
}
u.sharpenInstallModelDiagnosis(ctx, probes, options)
// A DSH install can use either protocol. Keep the preferred successful
// result (Responses first) and discard a failed alternative so one
// unsupported wire format does not fail an otherwise valid installation.
if slices.Contains(autoAgents, "dsh") {
nonDSHNeedsOpenAI := false
nonDSHNeedsResponses := false
for _, agentID := range autoAgents {
agent := manifest.Agents[agentID]
if agent.ConfigAdapter == "dsh" {
continue
}
switch provider.ProtocolForAdapter(agent.ConfigAdapter) {
case provider.ProtocolOpenAI:
nonDSHNeedsOpenAI = true
case provider.ProtocolResponses:
nonDSHNeedsResponses = true
}
}
if responses, ok := probes[provider.ProtocolResponses]; ok && responses.OK && !nonDSHNeedsOpenAI {
delete(probes, provider.ProtocolOpenAI)
} else if chat, ok := probes[provider.ProtocolOpenAI]; ok && chat.OK && !nonDSHNeedsResponses {
delete(probes, provider.ProtocolResponses)
}
}
return probes, nil
}

Expand Down Expand Up @@ -402,6 +434,11 @@ func (r *installRun) configure(ctx context.Context, agentID string, agent catalo
if r.options.Configure {
r.emitPhase(agentID, "configuring")
protocolID := provider.ProtocolForAdapter(agent.ConfigAdapter)
if agent.ConfigAdapter == "dsh" {
if verdict, ok := r.probes[provider.ProtocolResponses]; ok && verdict.OK {
protocolID = provider.ProtocolResponses
}
}
if verdict, found := r.probes[protocolID]; found && !verdict.OK {
code := pointerString(verdict.ErrorCode)
if code == "" {
Expand All @@ -424,7 +461,7 @@ func (r *installRun) configure(ctx context.Context, agentID string, agent catalo
// launched with is the only carrier.
reasoningEffort := r.core.profileReasoningEffort(r.options.ProfileID)
context1M := r.core.profileContext1M(r.options.ProfileID)
if err := writeManagedAgentConfig(ctx, writer, agentID, agent, configPathValue, dshRouteProviderID(target, r.options.APIBaseURL), r.providerName, configBase, r.options.APIKey, r.options.Model, reasoningEffort, context1M); err != nil {
if err := writeManagedAgentConfig(ctx, writer, agentID, agent, configPathValue, dshRouteProviderID(target, r.options.APIBaseURL), r.providerName, configBase, r.options.APIKey, r.options.Model, reasoningEffort, context1M, protocolID); err != nil {
return err
}
if _, err := r.core.profiles.WriteAgentBinding(ctx, agentID, profileStore.BindingWriteRequest{
Expand Down Expand Up @@ -579,6 +616,11 @@ func (r *installRun) finish(ctx context.Context, baseURL string) InstallAgentsRe
for _, agentID := range r.options.Agents {
if agent, ok := r.manifest.Agents[agentID]; ok && agent.ConfigMode == "auto" {
profileProtocol = provider.ProtocolForAdapter(agent.ConfigAdapter)
if agent.ConfigAdapter == "dsh" {
if verdict, ok := r.probes[provider.ProtocolResponses]; ok && verdict.OK {
profileProtocol = provider.ProtocolResponses
}
}
break
}
}
Expand Down
7 changes: 7 additions & 0 deletions internal/app/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,13 @@ func (u *UseCases) probeProtocols(ctx context.Context, protocols []string, apiKe
}(protocolID)
}
group.Wait()
// Callers may probe alternative protocols (notably DSH, which supports both
// Chat Completions and Responses). A transport failure for one alternative
// must not discard a successful result for another; only fail when every
// requested probe failed before producing a verdict.
if len(results) > 0 {
return results, nil
}
for _, protocolID := range protocols {
if err := errorsByProtocol[protocolID]; err != nil {
return results, err
Expand Down
58 changes: 52 additions & 6 deletions internal/config/write.go
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,14 @@ func (w Writer) WriteOpenClaw(ctx context.Context, path, providerName, baseURL,
// The endpoint goes in with OpenAIBaseURL's /v1 rather than bare, because the
// adapter appends only the operation path to whatever it is given.
func (w Writer) WriteDSH(ctx context.Context, path, providerName, baseURL, apiKey, model string) error {
return w.WriteDSHProtocol(ctx, path, providerName, baseURL, apiKey, model, provider.ProtocolOpenAI)
}

// WriteDSHProtocol writes a hand-declared pi-ai route using the protocol
// accepted by the selected upstream model. DSH supports both OpenAI Chat
// Completions and Responses; the install flow probes and passes the one that
// actually works.
func (w Writer) WriteDSHProtocol(ctx context.Context, path, providerName, baseURL, apiKey, model, protocolID string) error {
// The credential lands first: a route pointing at a provider dsh cannot
// authenticate is worse than an unreferenced key.
if err := w.writeDSHCredential(ctx, filepath.Join(filepath.Dir(path), ".credentials.yaml"), dshCredentialReference, apiKey); err != nil {
Expand All @@ -530,10 +538,14 @@ func (w Writer) WriteDSH(ctx context.Context, path, providerName, baseURL, apiKe
return configError("Existing llm-pi-ai providers must contain an object: %s", path)
}
route := &yaml.Node{Kind: yaml.MappingNode}
apiName := "openai-completions"
if protocolID == provider.ProtocolResponses {
apiName = "openai-responses"
}
for _, item := range []struct{ key, value string }{
{"displayName", providerName},
{"apiKeyEnv", dshCredentialReference},
{"api", "openai-completions"},
{"api", apiName},
{"baseURL", provider.OpenAIBaseURL(baseURL)},
} {
yamlSet(route, item.key, item.value)
Expand Down Expand Up @@ -636,16 +648,41 @@ func (w Writer) WriteDSHOfficial(ctx context.Context, path, apiKey, model, reaso
// the given reference, keeping every other credential the user stored from
// dsh's Models page.
//
// The document is a strict credential-to-value mapping: dsh rejects a non-string
// value, an empty string, or a key that is not a POSIX identifier, and fails loud
// rather than skipping the entry. So this writes one identifier and nothing else
// -- no wrapper level, no version field.
// The document is dsh's strict version-1 credential store: references live
// under refs and must contain non-empty strings keyed by POSIX identifiers.
// Legacy flat files are migrated on the first write so the current Web app can
// load them without losing existing credentials.
func (w Writer) writeDSHCredential(ctx context.Context, path, reference, apiKey string) error {
root, err := yamlDocument(path, "DeepSeek Harness credentials")
if err != nil {
return err
}
yamlSet(root.Content[0], reference, apiKey)
// dsh 0.1.5 and newer use the versioned credential document. Older
// releases accepted a flat map, so migrate that shape in memory while
// preserving every existing reference before writing the new document.
version := yamlLookup(root.Content[0], "version")
refs := yamlChild(root.Content[0], "refs")
if version == nil && refs == nil {
legacy := append([]*yaml.Node(nil), root.Content[0].Content...)
root.Content[0].Content = nil
root.Content[0].Content = append(root.Content[0].Content,
&yaml.Node{Kind: yaml.ScalarNode, Value: "version"},
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!int", Value: "1"})
refs = &yaml.Node{Kind: yaml.MappingNode}
root.Content[0].Content = append(root.Content[0].Content,
&yaml.Node{Kind: yaml.ScalarNode, Value: "refs"}, refs)
for index := 0; index+1 < len(legacy); index += 2 {
refs.Content = append(refs.Content, legacy[index], legacy[index+1])
}
} else {
if version == nil || version.Value != "1" {
return configError("DeepSeek Harness credentials must use version: 1: %s", path)
}
if refs == nil || refs.Kind != yaml.MappingNode {
return configError("DeepSeek Harness credentials refs must be an object: %s", path)
}
}
yamlSet(refs, reference, apiKey)
data, err := yaml.Marshal(root)
if err != nil {
return configError("Cannot encode YAML credentials %s: %v", path, err)
Expand Down Expand Up @@ -895,6 +932,15 @@ func yamlChild(parent *yaml.Node, key string) *yaml.Node {
return nil
}

func yamlLookup(parent *yaml.Node, key string) *yaml.Node {
for index := 0; index+1 < len(parent.Content); index += 2 {
if parent.Content[index].Value == key {
return parent.Content[index+1]
}
}
return nil
}

// yamlDelete removes one key and its value from a mapping; an absent key is a
// no-op.
func yamlDelete(parent *yaml.Node, key string) {
Expand Down
40 changes: 33 additions & 7 deletions internal/config/write_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"
"testing"

"github.com/MaimoryLab/BootAgent/internal/provider"
"github.com/MaimoryLab/BootAgent/internal/securefs"
"gopkg.in/yaml.v3"
)
Expand Down Expand Up @@ -855,20 +856,26 @@ func dshSettings(t *testing.T, path string) map[string]map[string]any {
return parsed.PiAI.Providers
}

// dshCredentials returns the credential document as the strict mapping dsh
// requires it to be: any other shape fails on dsh's side rather than being
// skipped, so the test asserts the shape too.
// dshCredentials returns the refs from the versioned credential document dsh
// requires. Legacy flat files are accepted as input by the migration test
// path, but writes must publish version: 1 with a refs mapping.
func dshCredentials(t *testing.T, path string) map[string]string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
parsed := map[string]string{}
if err := yaml.Unmarshal(data, &parsed); err != nil {
t.Fatalf("credentials are not a string mapping: %v\n%s", err, data)
var document struct {
Version int `yaml:"version"`
Refs map[string]string `yaml:"refs"`
}
if err := yaml.Unmarshal(data, &document); err != nil {
t.Fatalf("credentials are not a versioned document: %v\n%s", err, data)
}
return parsed
if document.Version != 1 {
t.Fatalf("credential version = %d, want 1\n%s", document.Version, data)
}
return document.Refs
}

// Both files BootAgent writes for dsh are the user's, shared with dsh's own
Expand Down Expand Up @@ -1018,6 +1025,25 @@ func TestWriteDSHIsIdempotent(t *testing.T) {
}
}

func TestWriteDSHProtocolUsesResponsesForModelsThatRequireIt(t *testing.T) {
home := t.TempDir()
path := filepath.Join(home, ".dsh", "settings.yaml")
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
writer := testWriter(t, home, "linux")
if err := writer.WriteDSHProtocol(context.Background(), path, "OpenAI", "https://api.example", "sk-x", "gpt-5.6-sol", provider.ProtocolResponses); err != nil {
t.Fatal(err)
}
route := dshSettings(t, path)["bootagent"]
if route["api"] != "openai-responses" {
t.Fatalf("route api = %v, want openai-responses", route["api"])
}
if route["baseURL"] != "https://api.example/v1" {
t.Fatalf("route baseURL = %v, want /v1", route["baseURL"])
}
}

// The route is BootAgent's own, so redefining it must not leave a field from the
// activation before it behind -- a stale compat block or a narrower model list
// would keep serving under a route the user believes was just repointed.
Expand Down
16 changes: 13 additions & 3 deletions internal/desktopapp/dsh.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,18 +180,28 @@ func installDSH(ctx context.Context, options Options) (ActionResult, error) {
if err := downloadDSH(ctx, options, url, name); err != nil {
return ActionResult{}, err
}
mount := filepath.Dir(name) + "/mount"
if err := os.MkdirAll(mount, 0o700); err != nil {
mount, err := os.MkdirTemp(filepath.Dir(name), "dsh-mount-")
if err != nil {
return ActionResult{}, err
}
defer os.RemoveAll(mount)
mounted := false
defer func() {
if mounted {
// Cleanup must run even when the install context is cancelled. The
// image is read-only, so detaching it is safe and prevents a leaked
// volume from blocking later installs or temporary-directory removal.
_, _ = run(options, context.Background(), []string{"/usr/bin/hdiutil", "detach", mount}, installTimeout)
}
_ = os.RemoveAll(mount)
}()
result, err := run(options, ctx, []string{"/usr/bin/hdiutil", "attach", name, "-nobrowse", "-readonly", "-mountpoint", mount}, installTimeout)
if err != nil {
return ActionResult{}, fmt.Errorf("mount %s installer: %w", DSHDesktopName, err)
}
if result.ExitCode != 0 {
return ActionResult{}, commandFailure("mount "+DSHDesktopName+" installer", result)
}
mounted = true
app := filepath.Join(mount, "DSH Desktop.app")
if _, err := os.Stat(app); err != nil {
return ActionResult{}, errors.New("DSH Desktop.app not found in installer")
Expand Down
Loading
Loading