File size: 4,463 Bytes
3d700dd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
import type {
  ActivateProfileResponse,
  ProfileDetailResponse,
  ProfileListResponse,
  ProfileMutationResponse,
  SaveProfileRequest,
} from "@openhands/typescript-client";
import { getActiveBackend } from "../backend-registry/active-store";
import type { Backend } from "../backend-registry/types";
import { callCloudProxy } from "./proxy";

/**
 * Cloud LLM-profile service.
 *
 * Profile CRUD is routed to the org-scoped endpoints
 * `/api/organizations/{orgId}/profiles`, which enforce `EDIT_ORG_SETTINGS`
 * server-side — so a member's mutation is rejected with 403 even on a direct
 * API call, not just hidden in the UI. When the active cloud backend has no org
 * bound (legacy API keys), we fall back to the ungated per-user settings route
 * `/api/v1/settings/profiles`, where there is no org role to enforce against.
 *
 * The two routes share shapes except `get` (org returns `llm`, settings returns
 * `config`) and `activate` (org returns `llm`, settings returns `model`); both
 * are normalized to the SDK profile types below. Neither route exposes secrets.
 */

const SETTINGS_PROFILES_PATH = "/api/v1/settings/profiles";

/**
 * Resolve the backend + base path for the active cloud backend's profiles:
 * the org-gated route when an org is bound, else the per-user settings route.
 */
function cloudProfilesTarget(): { backend: Backend; base: string } {
  const { backend, orgId } = getActiveBackend();
  if (backend.kind !== "cloud") {
    throw new Error("Cloud profiles call requires a cloud backend.");
  }
  return {
    backend,
    base: orgId
      ? `/api/organizations/${encodeURIComponent(orgId)}/profiles`
      : SETTINGS_PROFILES_PATH,
  };
}

export async function fetchCloudProfiles(): Promise<ProfileListResponse> {
  const { backend, base } = cloudProfilesTarget();
  return callCloudProxy<ProfileListResponse>({
    backend,
    method: "GET",
    path: base,
  });
}

export async function fetchCloudProfile(
  name: string,
): Promise<ProfileDetailResponse> {
  const { backend, base } = cloudProfilesTarget();
  // Org returns `{ name, llm }`; settings returns `{ name, config, api_key_set }`.
  // Normalize to the SDK detail shape. Neither route exposes the key, and the
  // GUI never reads the detail's `api_key_set` (only list-item `api_key_set`).
  const result = await callCloudProxy<{
    name: string;
    config?: Record<string, unknown>;
    llm?: Record<string, unknown>;
    api_key_set?: boolean;
  }>({
    backend,
    method: "GET",
    path: `${base}/${encodeURIComponent(name)}`,
  });
  return {
    name: result.name,
    config: result.config ?? result.llm ?? {},
    api_key_set: result.api_key_set ?? false,
  };
}

export async function saveCloudProfile(
  name: string,
  request: SaveProfileRequest,
): Promise<ProfileMutationResponse> {
  const { backend, base } = cloudProfilesTarget();
  return callCloudProxy<ProfileMutationResponse>({
    backend,
    method: "POST",
    path: `${base}/${encodeURIComponent(name)}`,
    body: request,
  });
}

export async function deleteCloudProfile(
  name: string,
): Promise<ProfileMutationResponse> {
  const { backend, base } = cloudProfilesTarget();
  return callCloudProxy<ProfileMutationResponse>({
    backend,
    method: "DELETE",
    path: `${base}/${encodeURIComponent(name)}`,
  });
}

export async function renameCloudProfile(
  name: string,
  newName: string,
): Promise<ProfileMutationResponse> {
  const { backend, base } = cloudProfilesTarget();
  return callCloudProxy<ProfileMutationResponse>({
    backend,
    method: "POST",
    path: `${base}/${encodeURIComponent(name)}/rename`,
    body: { new_name: newName },
  });
}

export async function activateCloudProfile(
  name: string,
): Promise<ActivateProfileResponse> {
  const { backend, base } = cloudProfilesTarget();
  // Org returns `{ name, message, llm }`; settings returns `{ name, message,
  // model }`. The SDK type carries `llm_applied`; derive it from whichever the
  // route provided (consumers only read name/message — the hook just
  // invalidates caches).
  const result = await callCloudProxy<{
    name: string;
    message: string;
    model?: string | null;
    llm?: Record<string, unknown> | null;
  }>({
    backend,
    method: "POST",
    path: `${base}/${encodeURIComponent(name)}/activate`,
    body: {},
  });
  return {
    name: result.name,
    message: result.message,
    llm_applied: result.model != null || result.llm != null,
  };
}