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
123 changes: 123 additions & 0 deletions src/__tests__/api-clients.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
HooksClient,
isAgentServerVersionError,
MCPClient,
MetaProfilesClient,
ProfilesClient,
SecurityClient,
ServerClient,
Expand All @@ -46,10 +47,13 @@ describe('Auxiliary API clients', () => {
expect(manager.server).toBeInstanceOf(ServerClient);
expect(manager.skills).toBeInstanceOf(SkillsClient);
expect(manager.profiles).toBeInstanceOf(ProfilesClient);
expect(manager.metaProfiles).toBeInstanceOf(MetaProfilesClient);
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');
expect(manager.metaProfiles.host).toBe('http://example.com');
expect(manager.metaProfiles.apiKey).toBe('secret');
expect(manager.files).toBeInstanceOf(FileClient);
expect(manager.workspaces).toBeInstanceOf(WorkspacesClient);
expect(manager.security).toBeInstanceOf(SecurityClient);
Expand Down Expand Up @@ -661,6 +665,125 @@ describe('Auxiliary API clients', () => {
);
});

it('MetaProfilesClient.listMetaProfiles GETs the meta-profiles endpoint', async () => {
global.fetch = jest.fn().mockResolvedValue(
new Response(
JSON.stringify({
meta_profiles: [
{
name: 'balanced',
classifier_model: 'classifier',
default_model: 'default',
num_classes: 2,
},
],
active_meta_profile: 'balanced',
}),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
) as typeof fetch;

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

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

it('MetaProfilesClient.getMetaProfile percent-encodes the name', async () => {
global.fetch = jest.fn().mockResolvedValue(
new Response(
JSON.stringify({
name: 'my profile',
config: {
classifier_model: 'classifier',
default_model: 'default',
classes: [{ description: 'UI', model: 'fast' }],
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
) as typeof fetch;

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

expect(result.name).toBe('my profile');
expect(result.config.classes).toHaveLength(1);
expect(global.fetch).toHaveBeenCalledWith(
'http://example.com/api/meta-profiles/my%20profile',
expect.objectContaining({ method: 'GET' })
);
});

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

const client = new MetaProfilesClient({ host: 'http://example.com' });
const config = {
classifier_model: 'classifier',
default_model: 'default',
classes: [{ description: 'tests', model: 'slow' }],
};
const result = await client.saveMetaProfile('balanced', config);

expect(result.name).toBe('balanced');
expect(global.fetch).toHaveBeenCalledWith(
'http://example.com/api/meta-profiles/balanced',
expect.objectContaining({ method: 'POST', body: JSON.stringify(config) })
);
});

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

const client = new MetaProfilesClient({ host: 'http://example.com' });
const result = await client.deleteMetaProfile('balanced');

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

it('MetaProfilesClient.activateMetaProfile POSTs to the activate endpoint', async () => {
global.fetch = jest
.fn()
.mockResolvedValue(
new Response(
JSON.stringify({ name: 'balanced', message: "Meta-profile 'balanced' activated" }),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
) as typeof fetch;

const client = new MetaProfilesClient({ host: 'http://example.com' });
const result = await client.activateMetaProfile('balanced');

expect(result.name).toBe('balanced');
expect(global.fetch).toHaveBeenCalledWith(
'http://example.com/api/meta-profiles/balanced/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 }), {
Expand Down
77 changes: 77 additions & 0 deletions src/client/meta-profiles-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { HttpClient } from './http-client';
import {
ActivateMetaProfileResponse,
MetaProfile,
MetaProfileDetailResponse,
MetaProfileListResponse,
MetaProfileMutationResponse,
} from '../models/api';

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

/**
* Client for the agent-server ``/api/meta-profiles`` endpoints.
*
* A meta-profile is a model-routing configuration consumed by the
* ``classify_and_switch_llm`` tool. Unlike LLM profiles, meta-profiles hold no
* secrets — they are plain JSON documents — so there is no secret-exposure
* handling here.
*/
export class MetaProfilesClient {
public readonly host: string;
public readonly apiKey?: string;
private readonly client: HttpClient;

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

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

async getMetaProfile(name: string): Promise<MetaProfileDetailResponse> {
const response = await this.client.get<MetaProfileDetailResponse>(
`/api/meta-profiles/${encodeURIComponent(name)}`
);
return response.data;
}

async saveMetaProfile(name: string, config: MetaProfile): Promise<MetaProfileMutationResponse> {
const response = await this.client.post<MetaProfileMutationResponse>(
`/api/meta-profiles/${encodeURIComponent(name)}`,
config
);
return response.data;
}

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

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

close(): void {
this.client.close();
}
}
2 changes: 2 additions & 0 deletions src/clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export { HooksClient } from './client/hooks-client';
export { LLMMetadataClient } from './client/llm-client';
export { MCPClient } from './client/mcp-client';
export { ProfilesClient } from './client/profiles-client';
export { MetaProfilesClient } from './client/meta-profiles-client';
export { SettingsClient } from './client/settings-client';
export { SkillsClient } from './client/skills-client';
export { ToolClient } from './client/tool-client';
Expand Down Expand Up @@ -41,6 +42,7 @@ export type { HooksClientOptions } from './client/hooks-client';
export type { LLMMetadataClientOptions } from './client/llm-client';
export type { MCPClientOptions } from './client/mcp-client';
export type { ProfilesClientOptions, GetProfileOptions } from './client/profiles-client';
export type { MetaProfilesClientOptions } from './client/meta-profiles-client';
export type {
SettingsClientOptions,
ExposeSecretsMode,
Expand Down
4 changes: 4 additions & 0 deletions src/conversation/conversation-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { HooksClient } from '../client/hooks-client';
import { LLMMetadataClient } from '../client/llm-client';
import { MCPClient } from '../client/mcp-client';
import { ProfilesClient } from '../client/profiles-client';
import { MetaProfilesClient } from '../client/meta-profiles-client';
import { ServerClient } from '../client/server-client';
import { SecurityClient } from '../client/security-client';
import { SessionClient } from '../client/session-client';
Expand Down Expand Up @@ -84,6 +85,7 @@ export class ConversationManager {
public readonly server: ServerClient;
public readonly llm: LLMMetadataClient;
public readonly profiles: ProfilesClient;
public readonly metaProfiles: MetaProfilesClient;
public readonly settings: SettingsClient;
public readonly skills: SkillsClient;
public readonly tools: ToolClient;
Expand Down Expand Up @@ -118,6 +120,7 @@ export class ConversationManager {
this.server = new ServerClient(clientOptions);
this.llm = new LLMMetadataClient(clientOptions);
this.profiles = new ProfilesClient(clientOptions);
this.metaProfiles = new MetaProfilesClient(clientOptions);
this.settings = new SettingsClient(clientOptions);
this.skills = new SkillsClient(clientOptions);
this.tools = new ToolClient(clientOptions);
Expand Down Expand Up @@ -404,6 +407,7 @@ export class ConversationManager {
this.server.close();
this.llm.close();
this.profiles.close();
this.metaProfiles.close();
this.settings.close();
this.skills.close();
this.tools.close();
Expand Down
7 changes: 7 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,13 @@ export type {
ActivateProfileResponse,
SaveProfileRequest,
RenameProfileRequest,
MetaProfileClass,
MetaProfile,
MetaProfileInfo,
MetaProfileListResponse,
MetaProfileDetailResponse,
MetaProfileMutationResponse,
ActivateMetaProfileResponse,
ExposeSecretsMode,
SettingsValue,
SettingsApiResponse,
Expand Down
49 changes: 49 additions & 0 deletions src/models/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,55 @@ export interface RenameProfileRequest {
new_name: string;
}

/**
* Meta-profiles: declarative model-routing configurations consumed by the
* ``classify_and_switch_llm`` tool (agent-server ``/api/meta-profiles``).
*
* Every model reference (``classifier_model``, ``default_model`` and each
* class's ``model``) is the name of a saved LLM profile, not a raw model
* string.
*/
export interface MetaProfileClass {
description: string;
/** Name of the saved LLM profile to switch to for this class. */
model: string;
}

export interface MetaProfile {
/** Name of the saved LLM profile used to classify the task. */
classifier_model: string;
/** Name of the saved LLM profile to use when no class matches. */
default_model: string;
classes: MetaProfileClass[];
}

export interface MetaProfileInfo {
name: string;
classifier_model: string | null;
default_model: string | null;
num_classes: number;
}

export interface MetaProfileListResponse {
meta_profiles: MetaProfileInfo[];
active_meta_profile: string | null;
}

export interface MetaProfileDetailResponse {
name: string;
config: MetaProfile;
}

export interface MetaProfileMutationResponse {
name: string;
message: string;
}

export interface ActivateMetaProfileResponse {
name: string;
message: string;
}

export type ExposeSecretsMode = 'encrypted' | 'plaintext';

export type SettingsValue = unknown;
Expand Down
Loading