diff --git a/src/__tests__/api-clients.test.ts b/src/__tests__/api-clients.test.ts index 48fd126..ab220ba 100644 --- a/src/__tests__/api-clients.test.ts +++ b/src/__tests__/api-clients.test.ts @@ -21,6 +21,7 @@ import { HooksClient, isAgentServerVersionError, MCPClient, + MetaProfilesClient, ProfilesClient, SecurityClient, ServerClient, @@ -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); @@ -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 }), { diff --git a/src/client/meta-profiles-client.ts b/src/client/meta-profiles-client.ts new file mode 100644 index 0000000..0c7ce71 --- /dev/null +++ b/src/client/meta-profiles-client.ts @@ -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 { + const response = await this.client.get('/api/meta-profiles'); + return response.data; + } + + async getMetaProfile(name: string): Promise { + const response = await this.client.get( + `/api/meta-profiles/${encodeURIComponent(name)}` + ); + return response.data; + } + + async saveMetaProfile(name: string, config: MetaProfile): Promise { + const response = await this.client.post( + `/api/meta-profiles/${encodeURIComponent(name)}`, + config + ); + return response.data; + } + + async deleteMetaProfile(name: string): Promise { + const response = await this.client.delete( + `/api/meta-profiles/${encodeURIComponent(name)}` + ); + return response.data; + } + + async activateMetaProfile(name: string): Promise { + const response = await this.client.post( + `/api/meta-profiles/${encodeURIComponent(name)}/activate`, + {} + ); + return response.data; + } + + close(): void { + this.client.close(); + } +} diff --git a/src/clients.ts b/src/clients.ts index bbb6e67..16f1ced 100644 --- a/src/clients.ts +++ b/src/clients.ts @@ -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'; @@ -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, diff --git a/src/conversation/conversation-manager.ts b/src/conversation/conversation-manager.ts index 6d40871..e4e007c 100644 --- a/src/conversation/conversation-manager.ts +++ b/src/conversation/conversation-manager.ts @@ -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'; @@ -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; @@ -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); @@ -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(); diff --git a/src/index.ts b/src/index.ts index 4e36679..af1a685 100644 --- a/src/index.ts +++ b/src/index.ts @@ -298,6 +298,13 @@ export type { ActivateProfileResponse, SaveProfileRequest, RenameProfileRequest, + MetaProfileClass, + MetaProfile, + MetaProfileInfo, + MetaProfileListResponse, + MetaProfileDetailResponse, + MetaProfileMutationResponse, + ActivateMetaProfileResponse, ExposeSecretsMode, SettingsValue, SettingsApiResponse, diff --git a/src/models/api.ts b/src/models/api.ts index 08f0d60..83f69f8 100644 --- a/src/models/api.ts +++ b/src/models/api.ts @@ -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;