Skip to content
This repository was archived by the owner on Sep 7, 2026. It is now read-only.
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
286 changes: 284 additions & 2 deletions src/__tests__/api-clients.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { ConversationManager, HttpClient, Workspace } from '../index';
import { BashClient, ServerClient, SkillsClient } from '../clients';
import {
Agent,
ConversationManager,
HttpClient,
HttpError,
RemoteConversation,
RemoteWorkspace,
Workspace,
} from '../index';
import { BashClient, ProfilesClient, ServerClient, SkillsClient } from '../clients';

const originalFetch = global.fetch;

Expand All @@ -14,8 +22,11 @@ describe('Auxiliary API clients', () => {

expect(manager.server).toBeInstanceOf(ServerClient);
expect(manager.skills).toBeInstanceOf(SkillsClient);
expect(manager.profiles).toBeInstanceOf(ProfilesClient);
expect(manager.server.host).toBe('http://example.com');
expect(manager.server.apiKey).toBe('secret');
expect(manager.profiles.host).toBe('http://example.com');
expect(manager.profiles.apiKey).toBe('secret');
});

it('Workspace exposes bash namespace', () => {
Expand Down Expand Up @@ -97,6 +108,277 @@ describe('Auxiliary API clients', () => {
);
});

it('ProfilesClient.listProfiles GETs the profiles endpoint', async () => {
global.fetch = jest.fn().mockResolvedValue(
new Response(
JSON.stringify({
profiles: [{ name: 'default', model: 'gpt-4o', api_key_set: true }],
}),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
) as typeof fetch;

const client = new ProfilesClient({ host: 'http://example.com' });
const result = await client.listProfiles();

expect(result.profiles).toHaveLength(1);
expect(result.profiles[0].name).toBe('default');
expect(global.fetch).toHaveBeenCalledWith(
'http://example.com/api/profiles',
expect.objectContaining({ method: 'GET' })
);
});

it('ProfilesClient.getProfile sends X-Expose-Secrets header when requested', async () => {
global.fetch = jest.fn().mockResolvedValue(
new Response(
JSON.stringify({
name: 'default',
config: { model: 'gpt-4o', api_key: 'sk-x' },
api_key_set: true,
}),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
) as typeof fetch;

const client = new ProfilesClient({ host: 'http://example.com' });
const result = await client.getProfile('default', { exposeSecrets: 'plaintext' });

expect(result.name).toBe('default');
expect(result.api_key_set).toBe(true);
const fetchMock = global.fetch as jest.Mock;
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('http://example.com/api/profiles/default');
expect(init.method).toBe('GET');
expect(init.headers['X-Expose-Secrets']).toBe('plaintext');
});

it('ProfilesClient.getProfile omits X-Expose-Secrets header by default', async () => {
global.fetch = jest
.fn()
.mockResolvedValue(
new Response(
JSON.stringify({ name: 'default', config: { model: 'gpt-4o' }, api_key_set: false }),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
) as typeof fetch;

const client = new ProfilesClient({ host: 'http://example.com' });
await client.getProfile('default');

const fetchMock = global.fetch as jest.Mock;
const [, init] = fetchMock.mock.calls[0];
expect(init.headers['X-Expose-Secrets']).toBeUndefined();
});

it('ProfilesClient.getProfile percent-encodes the profile name', async () => {
global.fetch = jest.fn().mockResolvedValue(
new Response(JSON.stringify({ name: 'my profile', config: {}, api_key_set: false }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
) as typeof fetch;

const client = new ProfilesClient({ host: 'http://example.com' });
await client.getProfile('my profile');

expect(global.fetch).toHaveBeenCalledWith(
'http://example.com/api/profiles/my%20profile',
expect.objectContaining({ method: 'GET' })
);
});

it('ProfilesClient.saveProfile POSTs the profile body', async () => {
global.fetch = jest.fn().mockResolvedValue(
new Response(JSON.stringify({ name: 'default', message: "Profile 'default' saved" }), {
status: 201,
headers: { 'content-type': 'application/json' },
})
) as typeof fetch;

const client = new ProfilesClient({ host: 'http://example.com' });
const result = await client.saveProfile('default', {
llm: { model: 'gpt-4o', api_key: 'sk-secret' },
include_secrets: true,
});

expect(result.name).toBe('default');
expect(global.fetch).toHaveBeenCalledWith(
'http://example.com/api/profiles/default',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
llm: { model: 'gpt-4o', api_key: 'sk-secret' },
include_secrets: true,
}),
})
);
});

it('ProfilesClient.deleteProfile DELETEs the profile endpoint', async () => {
global.fetch = jest.fn().mockResolvedValue(
new Response(JSON.stringify({ name: 'default', message: "Profile 'default' deleted" }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
) as typeof fetch;

const client = new ProfilesClient({ host: 'http://example.com' });
const result = await client.deleteProfile('default');

expect(result.name).toBe('default');
expect(global.fetch).toHaveBeenCalledWith(
'http://example.com/api/profiles/default',
expect.objectContaining({ method: 'DELETE' })
);
});

it('ProfilesClient.renameProfile POSTs to the rename endpoint', async () => {
global.fetch = jest.fn().mockResolvedValue(
new Response(JSON.stringify({ name: 'new', message: "Profile 'old' renamed to 'new'" }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
) as typeof fetch;

const client = new ProfilesClient({ host: 'http://example.com' });
const result = await client.renameProfile('old', 'new');

expect(result.name).toBe('new');
expect(global.fetch).toHaveBeenCalledWith(
'http://example.com/api/profiles/old/rename',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ new_name: 'new' }),
})
);
});

it('ProfilesClient.getProfile sends X-Expose-Secrets: encrypted', async () => {
global.fetch = jest.fn().mockResolvedValue(
new Response(
JSON.stringify({
name: 'default',
config: { model: 'gpt-4o', api_key: 'gAAAAA-encrypted-blob' },
api_key_set: true,
}),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
) as typeof fetch;

const client = new ProfilesClient({ host: 'http://example.com' });
const result = await client.getProfile('default', { exposeSecrets: 'encrypted' });

expect(result.config.api_key).toBe('gAAAAA-encrypted-blob');
const fetchMock = global.fetch as jest.Mock;
const [, init] = fetchMock.mock.calls[0];
expect(init.headers['X-Expose-Secrets']).toBe('encrypted');
});

it('ProfilesClient.getProfile surfaces HttpError on 404', async () => {
global.fetch = jest.fn(
async () =>
new Response(JSON.stringify({ detail: "Profile 'missing' not found" }), {
status: 404,
statusText: 'Not Found',
headers: { 'content-type': 'application/json' },
})
) as typeof fetch;

const client = new ProfilesClient({ host: 'http://example.com' });
const error = await client.getProfile('missing').catch((e: unknown) => e);
expect(error).toBeInstanceOf(HttpError);
expect((error as HttpError).status).toBe(404);
expect((error as HttpError).response).toEqual({ detail: "Profile 'missing' not found" });
});

it('ProfilesClient.saveProfile omits include_secrets when not provided', async () => {
global.fetch = jest.fn().mockResolvedValue(
new Response(JSON.stringify({ name: 'default', message: "Profile 'default' saved" }), {
status: 201,
headers: { 'content-type': 'application/json' },
})
) as typeof fetch;

const client = new ProfilesClient({ host: 'http://example.com' });
await client.saveProfile('default', { llm: { model: 'gpt-4o' } });

const fetchMock = global.fetch as jest.Mock;
const [, init] = fetchMock.mock.calls[0];
const body = JSON.parse(init.body as string);
expect(body).toEqual({ llm: { model: 'gpt-4o' } });
expect(body).not.toHaveProperty('include_secrets');
});

it('ProfilesClient.renameProfile percent-encodes the source name', async () => {
global.fetch = jest.fn().mockResolvedValue(
new Response(JSON.stringify({ name: 'fresh', message: 'renamed' }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
) as typeof fetch;

const client = new ProfilesClient({ host: 'http://example.com' });
await client.renameProfile('my profile', 'fresh');

expect(global.fetch).toHaveBeenCalledWith(
'http://example.com/api/profiles/my%20profile/rename',
expect.objectContaining({ method: 'POST' })
);
});

it('ProfilesClient.activateProfile POSTs to the activate endpoint', async () => {
global.fetch = jest.fn().mockResolvedValue(
new Response(
JSON.stringify({
name: 'default',
message: "Profile 'default' activated and applied to current settings",
llm_applied: true,
}),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
) as typeof fetch;

const client = new ProfilesClient({ host: 'http://example.com' });
const result = await client.activateProfile('my profile');

expect(result.name).toBe('default');
expect(result.llm_applied).toBe(true);
expect(global.fetch).toHaveBeenCalledWith(
'http://example.com/api/profiles/my%20profile/activate',
expect.objectContaining({
method: 'POST',
body: '{}',
})
);
});

it('RemoteConversation.switchLlm POSTs the llm to the switch_llm endpoint', async () => {
global.fetch = jest.fn().mockResolvedValue(
new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
) as typeof fetch;

const agent = new Agent({ llm: { model: 'gpt-4o', api_key: 'k' } });
const workspace = new RemoteWorkspace({ host: 'http://example.com', workingDir: '/tmp' });
const conversation = new RemoteConversation(agent, workspace, {
conversationId: 'conv-123',
});

const llm = { model: 'gpt-4o-mini', api_key: 'sk-new' };
await conversation.switchLlm(llm);

expect(global.fetch).toHaveBeenCalledWith(
'http://example.com/api/conversations/conv-123/switch_llm',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ llm }),
})
);
});

it('HttpClient can parse blob responses when requested', async () => {
global.fetch = jest.fn().mockResolvedValue(
new Response(new Blob(['zip-data']), {
Expand Down
93 changes: 93 additions & 0 deletions src/client/profiles-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { HttpClient } from './http-client';
import {
ActivateProfileResponse,
ExposeSecretsMode,
ProfileDetailResponse,
ProfileListResponse,
ProfileMutationResponse,
SaveProfileRequest,
} from '../models/api';

export interface ProfilesClientOptions {
host: string;
apiKey?: string;
timeout?: number;
}

export interface GetProfileOptions {
/**
* Controls server-side secret exposure via the ``X-Expose-Secrets`` header:
* - ``encrypted``: cipher-encrypted values (safe for frontend round-trip)
* - ``plaintext``: raw secret values (backend clients only)
* - omitted: ``api_key`` is nulled, with ``api_key_set`` indicator
*/
exposeSecrets?: ExposeSecretsMode;
}

export class ProfilesClient {
public readonly host: string;
public readonly apiKey?: string;
private readonly client: HttpClient;

constructor(options: ProfilesClientOptions) {
this.host = options.host.replace(/\/$/, '');
this.apiKey = options.apiKey;
this.client = new HttpClient({
baseUrl: this.host,
apiKey: this.apiKey,
timeout: options.timeout || 60000,
});
}

async listProfiles(): Promise<ProfileListResponse> {
const response = await this.client.get<ProfileListResponse>('/api/profiles');
return response.data;
}

async getProfile(name: string, options: GetProfileOptions = {}): Promise<ProfileDetailResponse> {
const headers: Record<string, string> = {};
if (options.exposeSecrets) {
headers['X-Expose-Secrets'] = options.exposeSecrets;
}
const response = await this.client.get<ProfileDetailResponse>(
`/api/profiles/${encodeURIComponent(name)}`,
{ headers }
);
return response.data;
}

async saveProfile(name: string, request: SaveProfileRequest): Promise<ProfileMutationResponse> {
const response = await this.client.post<ProfileMutationResponse>(
`/api/profiles/${encodeURIComponent(name)}`,
request
);
return response.data;
}

async deleteProfile(name: string): Promise<ProfileMutationResponse> {
const response = await this.client.delete<ProfileMutationResponse>(
`/api/profiles/${encodeURIComponent(name)}`
);
return response.data;
}

async renameProfile(name: string, newName: string): Promise<ProfileMutationResponse> {
const response = await this.client.post<ProfileMutationResponse>(
`/api/profiles/${encodeURIComponent(name)}/rename`,
{ new_name: newName }
);
return response.data;
}

async activateProfile(name: string): Promise<ActivateProfileResponse> {
const response = await this.client.post<ActivateProfileResponse>(
`/api/profiles/${encodeURIComponent(name)}/activate`,
{}
);
return response.data;
}

close(): void {
this.client.close();
}
}
Loading
Loading