Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/web-provider-routes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": minor
---

Point the web provider calls at routes that exist. Adding a provider now writes through `POST /config`, refreshing reads `GET /providers/{id}`, and a new `DELETE /providers/{provider_id}` route removes a provider together with the model aliases that referenced it.
40 changes: 27 additions & 13 deletions apps/pythinker-web/src/api/daemon/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1106,17 +1106,14 @@ export class DaemonPythinkerWebApi implements PythinkerWebApi {

// -------------------------------------------------------------------------
// Models + Providers
// PRESUMED — not in current daemon docs; isolated here, swap when backend defines them.
// -------------------------------------------------------------------------

async listModels(): Promise<AppModel[]> {
// PRESUMED endpoint: GET /v1/models → { items: WireModel[] }
const data = await this.http.get<{ items: WireModel[] }>('/models');
return data.items.map(toAppModel);
}

async listProviders(): Promise<AppProvider[]> {
// PRESUMED endpoint: GET /v1/providers → { items: WireProvider[] }
const data = await this.http.get<{ items: WireProvider[] }>('/providers');
return data.items.map(toAppProvider);
}
Expand All @@ -1127,29 +1124,46 @@ export class DaemonPythinkerWebApi implements PythinkerWebApi {
baseUrl?: string;
defaultModel?: string;
}): Promise<AppProvider> {
// PRESUMED endpoint: POST /v1/providers → WireProvider
const body: Record<string, unknown> = { type: input.type };
if (input.apiKey !== undefined) body['api_key'] = input.apiKey;
if (input.baseUrl !== undefined) body['base_url'] = input.baseUrl;
if (input.defaultModel !== undefined) body['default_model'] = input.defaultModel;
const data = await this.http.post<WireProvider>('/providers', body);
const providerId = input.type.replaceAll('_', '-');
const modelId = input.defaultModel ?? providerId;
const modelAlias = `${providerId}/${modelId}`.replaceAll('_', '-');
await this.http.post('/config', {
providers: {
[providerId]: {
type: input.type,
api_key: input.apiKey,
base_url: input.baseUrl,
default_model: input.defaultModel,
},
},
models: {
[modelAlias]: {
provider: providerId,
model: modelId,
max_context_size: 262_144,
},
},
default_model: modelAlias,
});
const data = await this.http.get<WireProvider>(
`/providers/${encodeURIComponent(providerId)}`,
);
return toAppProvider(data);
}

async deleteProvider(id: string): Promise<{ deleted: true }> {
// PRESUMED endpoint: DELETE /v1/providers/{id} → { deleted: true }
return this.http.delete<{ deleted: true }>(`/providers/${encodeURIComponent(id)}`);
}

async refreshProvider(id: string): Promise<AppProvider> {
// PRESUMED endpoint: POST /v1/providers/{id}:refresh → WireProvider
const data = await this.http.post<WireProvider>(
`/providers/${encodeURIComponent(id)}:refresh`,
const data = await this.http.get<WireProvider>(
`/providers/${encodeURIComponent(id)}`,
);
return toAppProvider(data);
}

async refreshOAuthProviderModels(): Promise<ProviderRefreshResult> {
// No server route or core RPC currently backs this presumed endpoint.
const data = await this.http.post<WireProviderRefreshResult>('/providers:refresh_oauth');
return {
changed: data.changed.map((item) => ({
Expand Down
83 changes: 83 additions & 0 deletions apps/pythinker-web/test/daemon-contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,86 @@ describe('dynamic workflow daemon contracts', () => {
expect(dynamicClient.dynamicWorkflowMode.value).toBe(false);
});
});

describe('provider daemon contracts', () => {
it('adds a provider through one config patch and reads it back', async () => {
const provider = {
id: 'openai-responses',
type: 'openai_responses',
base_url: 'https://api.example.test/v1',
default_model: 'gpt_5-mini',
has_api_key: true,
status: 'connected',
models: ['openai-responses/gpt-5-mini'],
};
const fetchMock = vi.fn()
.mockResolvedValueOnce(okEnvelope({}))
.mockResolvedValueOnce(okEnvelope(provider));
vi.stubGlobal('fetch', fetchMock);

await expect(api().addProvider({
type: 'openai_responses',
apiKey: 'sk-test',
baseUrl: 'https://api.example.test/v1',
defaultModel: 'gpt_5-mini',
})).resolves.toMatchObject({ id: 'openai-responses', defaultModel: 'gpt_5-mini' });

expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0]![0]).toBe('http://example.test:58627/api/v1/config');
expect(fetchMock.mock.calls[0]![1]).toMatchObject({ method: 'POST' });
expect(JSON.parse((fetchMock.mock.calls[0]![1] as RequestInit).body as string)).toEqual({
providers: {
'openai-responses': {
type: 'openai_responses',
api_key: 'sk-test',
base_url: 'https://api.example.test/v1',
default_model: 'gpt_5-mini',
},
},
models: {
'openai-responses/gpt-5-mini': {
provider: 'openai-responses',
model: 'gpt_5-mini',
max_context_size: 262_144,
},
},
default_model: 'openai-responses/gpt-5-mini',
});
expect(fetchMock.mock.calls[1]![0]).toBe(
'http://example.test:58627/api/v1/providers/openai-responses',
);
expect(fetchMock.mock.calls[1]![1]).toMatchObject({ method: 'GET' });
});

it('deletes a provider through the provider resource route', async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(okEnvelope({ deleted: true }));
vi.stubGlobal('fetch', fetchMock);

await expect(api().deleteProvider('openai/custom')).resolves.toEqual({ deleted: true });

expect(fetchMock.mock.calls[0]![0]).toBe(
'http://example.test:58627/api/v1/providers/openai%2Fcustom',
);
expect(fetchMock.mock.calls[0]![1]).toMatchObject({ method: 'DELETE' });
expect((fetchMock.mock.calls[0]![1] as RequestInit).body).toBeUndefined();
});

it('refreshes a provider by reading the provider resource', async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(okEnvelope({
id: 'openai',
type: 'openai',
has_api_key: true,
status: 'connected',
models: ['openai/gpt-5'],
}));
vi.stubGlobal('fetch', fetchMock);

await expect(api().refreshProvider('openai')).resolves.toMatchObject({ id: 'openai' });

expect(fetchMock.mock.calls[0]![0]).toBe(
'http://example.test:58627/api/v1/providers/openai',
);
expect(fetchMock.mock.calls[0]![1]).toMatchObject({ method: 'GET' });
expect((fetchMock.mock.calls[0]![1] as RequestInit).body).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface IModelCatalogService {
listModels(): Promise<readonly ModelCatalogItem[]>;
listProviders(): Promise<readonly ProviderCatalogItem[]>;
getProvider(providerId: string): Promise<ProviderCatalogItem>;
removeProvider(providerId: string): Promise<void>;
setDefaultModel(modelId: string): Promise<SetDefaultModelResponse>;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ export class ModelCatalogService
return this._provider(config, providerId, provider);
}

async removeProvider(providerId: string): Promise<void> {
const config = await this._readConfig();
if (config.providers?.[providerId] === undefined) {
throw new ProviderNotFoundError(providerId);
}
await this.core.rpc.removePythinkerProvider({ providerId });
}

async setDefaultModel(modelId: string): Promise<SetDefaultModelResponse> {
const config = await this._readConfig();
const alias = config.models?.[modelId];
Expand Down
57 changes: 57 additions & 0 deletions packages/agent-core/test/services/config-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it, vi } from 'vitest';

import type { CoreRPC, PythinkerConfig } from '../../src';
import {
ConfigService,
type ICoreProcessService,
type IEventService,
} from '../../src/services';

describe('ConfigService', () => {
it('converts underscores but preserves hyphens in nested record keys', async () => {
const setPythinkerConfig = vi.fn(async (patch: unknown) => patch as PythinkerConfig);
const core = {
rpc: { setPythinkerConfig } as unknown as CoreRPC,
} as ICoreProcessService;
const eventService = { publish: vi.fn() } as unknown as IEventService;
const service = new ConfigService(core, eventService);

await service.set({
providers: {
provider_with_underscore: { type: 'openai' },
'provider-with-hyphen': { type: 'openai' },
},
models: {
model_with_underscore: {
provider: 'provider_with_underscore',
model: 'model_with_underscore',
max_context_size: 1000,
},
'model-with-hyphen': {
provider: 'provider-with-hyphen',
model: 'model-with-hyphen',
max_context_size: 1000,
},
},
});

expect(setPythinkerConfig).toHaveBeenCalledWith({
providers: {
providerWithUnderscore: { type: 'openai' },
'provider-with-hyphen': { type: 'openai' },
},
models: {
modelWithUnderscore: {
provider: 'provider_with_underscore',
model: 'model_with_underscore',
maxContextSize: 1000,
},
'model-with-hyphen': {
provider: 'provider-with-hyphen',
model: 'model-with-hyphen',
maxContextSize: 1000,
},
},
});
});
});
13 changes: 13 additions & 0 deletions packages/agent-core/test/services/model-catalog-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,4 +252,17 @@ describe('ModelCatalogService', () => {
);
});

it('removes an existing provider through core RPC', async () => {
const configRef = { current: catalogConfig() };
const { core, removeCalls } = makeCore(configRef);
const svc = new ModelCatalogService(core);

await expect(svc.removeProvider('pythinker')).resolves.toBeUndefined();
expect(removeCalls).toEqual(['pythinker']);
await expect(svc.removeProvider('missing')).rejects.toBeInstanceOf(
ProviderNotFoundError,
);
expect(removeCalls).toEqual(['pythinker']);
});

});
44 changes: 44 additions & 0 deletions packages/server/src/routes/modelCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ interface ModelCatalogRouteHost {
reply: { send(payload: unknown): unknown },
) => Promise<void> | void,
): unknown;
delete(
path: string,
options: { preHandler: unknown[]; schema?: Record<string, unknown> },
handler: (
req: { id: string; params: unknown },
reply: { send(payload: unknown): unknown },
) => Promise<void> | void,
): unknown;
}

const providerIdParamSchema = z.object({
Expand All @@ -40,6 +48,10 @@ const modelActionTailParamSchema = z.object({
tail: z.string().min(1),
});

const deleteProviderResponseSchema = z.object({
deleted: z.literal(true),
});

export function registerModelCatalogRoutes(
app: ModelCatalogRouteHost,
ix: IInstantiationService,
Expand Down Expand Up @@ -161,6 +173,38 @@ export function registerModelCatalogRoutes(
getProviderRoute.options,
getProviderRoute.handler as Parameters<ModelCatalogRouteHost['get']>[2],
);

const deleteProviderRoute = defineRoute(
{
method: 'DELETE',
path: '/providers/{provider_id}',
params: providerIdParamSchema,
success: { data: deleteProviderResponseSchema },
errors: {
[ErrorCode.VALIDATION_FAILED]: {},
[ErrorCode.PROVIDER_NOT_FOUND]: {},
},
description: 'Delete a configured provider and its model aliases',
tags: ['providers'],
operationId: 'deleteProvider',
},
async (req, reply) => {
try {
const { provider_id } = req.params;
await ix.invokeFunction((a) =>
a.get(IModelCatalogService).removeProvider(provider_id),
);
reply.send(okEnvelope({ deleted: true as const }, req.id));
} catch (error) {
sendMappedError(reply, req.id, error);
}
},
);
app.delete(
deleteProviderRoute.path,
deleteProviderRoute.options,
deleteProviderRoute.handler as Parameters<ModelCatalogRouteHost['delete']>[2],
);
}

function sendMappedError(
Expand Down
Loading
Loading