diff --git a/apps/backend-agent-controller/src/migrations/1766800000000_CreateClientAgentOpenAiApiKeysTable.ts b/apps/backend-agent-controller/src/migrations/1766800000000_CreateClientAgentOpenAiApiKeysTable.ts new file mode 100644 index 00000000..608b3c66 --- /dev/null +++ b/apps/backend-agent-controller/src/migrations/1766800000000_CreateClientAgentOpenAiApiKeysTable.ts @@ -0,0 +1,114 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey, TableIndex, TableUnique } from 'typeorm'; + +/** + * Per-agent OpenAI-compatible API keys for agent-controller `/api/openai` routes. + */ +export class CreateClientAgentOpenAiApiKeysTable1766800000000 implements MigrationInterface { + name = 'CreateClientAgentOpenAiApiKeysTable1766800000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'client_agent_openai_api_keys', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'uuid_generate_v4()', + }, + { + name: 'client_id', + type: 'uuid', + isNullable: false, + }, + { + name: 'agent_id', + type: 'uuid', + isNullable: false, + }, + { + name: 'api_key_encrypted', + type: 'text', + isNullable: false, + }, + { + name: 'api_key_hash', + type: 'varchar', + length: '64', + isNullable: false, + }, + { + name: 'created_at', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + isNullable: false, + }, + { + name: 'updated_at', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + isNullable: false, + }, + ], + }), + true, + ); + + await queryRunner.createUniqueConstraint( + 'client_agent_openai_api_keys', + new TableUnique({ + name: 'uq_openai_client_agent', + columnNames: ['client_id', 'agent_id'], + }), + ); + + await queryRunner.createUniqueConstraint( + 'client_agent_openai_api_keys', + new TableUnique({ + name: 'uq_openai_api_key_hash', + columnNames: ['api_key_hash'], + }), + ); + + await queryRunner.createForeignKey( + 'client_agent_openai_api_keys', + new TableForeignKey({ + columnNames: ['client_id'], + referencedColumnNames: ['id'], + referencedTableName: 'clients', + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }), + ); + + await queryRunner.createIndex( + 'client_agent_openai_api_keys', + new TableIndex({ + name: 'IDX_openai_keys_client_id', + columnNames: ['client_id'], + }), + ); + await queryRunner.createIndex( + 'client_agent_openai_api_keys', + new TableIndex({ + name: 'IDX_openai_keys_agent_id', + columnNames: ['agent_id'], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropIndex('client_agent_openai_api_keys', 'IDX_openai_keys_agent_id'); + await queryRunner.dropIndex('client_agent_openai_api_keys', 'IDX_openai_keys_client_id'); + const table = await queryRunner.getTable('client_agent_openai_api_keys'); + const fk = table?.foreignKeys.find((f) => f.columnNames.indexOf('client_id') !== -1); + if (fk) { + await queryRunner.dropForeignKey('client_agent_openai_api_keys', fk); + } + await queryRunner.dropUniqueConstraint('client_agent_openai_api_keys', 'uq_openai_api_key_hash'); + await queryRunner.dropUniqueConstraint('client_agent_openai_api_keys', 'uq_openai_client_agent'); + await queryRunner.dropTable('client_agent_openai_api_keys'); + } +} diff --git a/apps/backend-agent-controller/src/typeorm.config.ts b/apps/backend-agent-controller/src/typeorm.config.ts index 3c8072bb..615f79e6 100644 --- a/apps/backend-agent-controller/src/typeorm.config.ts +++ b/apps/backend-agent-controller/src/typeorm.config.ts @@ -1,4 +1,5 @@ import { + ClientAgentOpenAiApiKeyEntity, ProvisioningReferenceEntity, StatisticsAgentEntity, StatisticsChatFilterDropEntity, @@ -35,6 +36,7 @@ export const typeormConfig: DataSourceOptions = { entities: [ ClientEntity, ClientAgentCredentialEntity, + ClientAgentOpenAiApiKeyEntity, ClientUserEntity, ProvisioningReferenceEntity, UserEntity, diff --git a/docs/agenstra/api-reference/README.md b/docs/agenstra/api-reference/README.md index d46acdaa..e5a329b9 100644 --- a/docs/agenstra/api-reference/README.md +++ b/docs/agenstra/api-reference/README.md @@ -17,6 +17,7 @@ The Agent Controller HTTP API provides: - Client management (CRUD operations) - Proxied agent operations (create, update, delete agents) +- **OpenAI-compatible inference** under `/openai/v1/*` (per-agent API keys; SSE streaming where supported) - Proxied file operations (read, write, create, delete files) - Proxied version control operations (git status, branches, commit, push, pull, rebase) - Server provisioning (Hetzner Cloud, DigitalOcean) diff --git a/docs/agenstra/applications/backend-agent-controller.md b/docs/agenstra/applications/backend-agent-controller.md index f7f11f86..07700ae1 100644 --- a/docs/agenstra/applications/backend-agent-controller.md +++ b/docs/agenstra/applications/backend-agent-controller.md @@ -34,7 +34,7 @@ The application integrates the `@forepath/framework-backend-feature-agent-contro ## API Endpoints -All HTTP endpoints are prefixed with `/api` and protected by Keycloak authentication (or API key authentication if `STATIC_API_KEY` is set). +Most HTTP endpoints are prefixed with `/api` and protected by Keycloak authentication (or API key authentication if `STATIC_API_KEY` is set). **Exception:** **`/api/openai/*`** uses **per-agent OpenAI-compatible keys** (`agenstra_oai_...`) only—platform JWT/static API key is not used there. ### Client Management @@ -56,9 +56,21 @@ In api-key mode, users do not play a role; these endpoints are not applicable. - `GET /api/clients/:id/agents` - List all agents for a client - `GET /api/clients/:id/agents/:agentId` - Get a single agent by UUID -- `POST /api/clients/:id/agents` - Create a new agent for a client (returns auto-generated password, saves credentials) +- `POST /api/clients/:id/agents` - Create a new agent for a client (returns auto-generated password, saves credentials, and a one-time **OpenAI API key** for `/api/openai`) - `POST /api/clients/:id/agents/:agentId` - Update an existing agent - `DELETE /api/clients/:id/agents/:agentId` - Delete an agent (also deletes stored credentials) +- `POST /api/clients/:id/agents/:agentId/openai-api-key/rotate` - Rotate the per-agent OpenAI key (plaintext returned once) + +### OpenAI-compatible API (external tools) + +Routes under **`/api/openai/v1/*`** mirror a small OpenAI HTTP surface so external clients can call agents without the platform auth model. + +- **Auth:** `Authorization: Bearer ` (or `ApiKey `). The key uniquely identifies the agent; the JSON **`model`** field is the model id for that agent. +- **Endpoints:** `GET /v1/models`, `POST /v1/chat/completions`, `POST /v1/completions`, `POST /v1/responses`. When `stream: true`, responses use **SSE** (`text/event-stream`). +- **Storage:** Keys are encrypted at rest; **`ENCRYPTION_KEY`** must be set for agent-controller (enforced in production). +- **Not implemented:** embeddings, audio, images, realtime WS, files, batches, fine-tuning, full Responses tool/multimodal parity. + +See the Agent Controller OpenAPI spec and `libs/domains/framework/backend/feature-agent-controller/docs/openai-sequence.mmd`. ### Proxied File Operations diff --git a/libs/domains/framework/backend/feature-agent-controller/README.md b/libs/domains/framework/backend/feature-agent-controller/README.md index 18ed9eb9..e338df00 100644 --- a/libs/domains/framework/backend/feature-agent-controller/README.md +++ b/libs/domains/framework/backend/feature-agent-controller/README.md @@ -35,10 +35,12 @@ The library follows Domain-Driven Design (DDD) principles with clear separation - **Entities**: - `ClientEntity` - Domain model representing a client (remote agent-manager service) - `ClientAgentCredentialEntity` - Stores credentials for agents created via proxied requests + - `ClientAgentOpenAiApiKeyEntity` - Per-agent OpenAI-compatible API keys (encrypted at rest, SHA-256 hash for lookup) - `ClientUserEntity` - Many-to-many relationship between users and clients with per-client roles - **Repositories**: - `ClientsRepository` - Data access layer for client operations - `ClientAgentCredentialsRepository` - Data access layer for agent credentials + - `ClientAgentOpenAiApiKeysRepository` - Data access layer for per-agent OpenAI API keys - `ClientUsersRepository` - Data access layer for client-user relationships - **Services**: - `ClientsService` - Business logic orchestration for clients with permission checks @@ -47,13 +49,16 @@ The library follows Domain-Driven Design (DDD) principles with clear separation - `ClientAgentFileSystemProxyService` - Proxies file system operations to remote agent-manager services - `ClientAgentEnvironmentVariablesProxyService` - Proxies environment variable operations to remote agent-manager services - `ClientAgentCredentialsService` - Manages stored agent credentials + - `ClientAgentOpenAiApiKeysService` - Issues and rotates per-agent OpenAI keys; resolves key → agent + - `OpenAiAgentWsProxyService` - Bridges OpenAI HTTP requests to remote agent-manager `chat` / `chatEvent` over Socket.IO - `KeycloakTokenService` - Handles Keycloak OAuth2 Client Credentials flow with token caching - **DTOs**: Data transfer objects for API boundaries - `CreateClientDto` - Input validation for creating clients - `UpdateClientDto` - Input validation for updating clients - `ClientResponseDto` - Safe API responses (excludes sensitive data, includes proxied config with agent types from remote agent-manager) - `CreateClientResponseDto` - Response when creating client (includes API key if applicable) -- **Controllers**: `ClientsController` - HTTP endpoints for client and proxied agent management (protected by Keycloak) +- **Controllers**: `ClientsController` - HTTP endpoints for client and proxied agent management (protected by Keycloak); `OpenAiV1Controller` - OpenAI-shaped routes under `/openai` (per-agent key auth, `@Public()`) +- **Guards**: `OpenAiApiKeyGuard` - Validates `Authorization: Bearer ` (or `ApiKey`) and attaches agent context - **Gateways**: `ClientsGateway` - WebSocket gateway for forwarding events to remote agent-manager WebSocket endpoints - **Modules**: `ClientsModule` - NestJS module wiring all dependencies @@ -77,6 +82,7 @@ All diagrams are available in the [`docs/`](./docs/) directory: - **[WebSocket Forwarding Diagram](./docs/sequence-ws-forward.mmd)** - Sequence diagram for WebSocket connection, client context setup, event forwarding, and auto-login - **[Chat prompt enhancement](./docs/sequence-chat-enhancement.mmd)** - Sequence for `enhanceChat` / `chatEnhanceResult` (magic-wand flow; statistics only, no `agent_messages`) - **[Lifecycle Diagram](./docs/lifecycle.mmd)** - End-to-end sequence diagram showing the complete lifecycle from client creation through proxied agent operations to WebSocket event forwarding +- **[OpenAI HTTP bridge](./docs/openai-sequence.mmd)** - Sequence for `/api/openai/v1/*` → DB key lookup → agent-manager `/agents` `chat` / SSE streaming These diagrams provide comprehensive visual documentation of: @@ -182,7 +188,7 @@ When listing clients (`GET /api/clients`), only clients the user has access to a ## API Endpoints -All HTTP endpoints require authentication (except `/api/health` and public auth endpoints). The authentication method depends on `AUTHENTICATION_METHOD`; see [Authentication](#authentication) above. +All HTTP endpoints require authentication (except `/api/health`, public auth endpoints, and **`/api/openai/*`**, which use **per-agent OpenAI keys** only). The authentication method depends on `AUTHENTICATION_METHOD`; see [Authentication](#authentication) above. **Note**: All endpoints that access a specific client (`/api/clients/:id/*`) now check permissions. Users without access will receive a `403 Forbidden` response. @@ -201,9 +207,23 @@ Base URL: `/api` - `GET /api/clients/:id/agents` - List all agents for a client (supports `limit` and `offset` query parameters) - `GET /api/clients/:id/agents/:agentId` - Get a single agent by UUID - `GET /api/clients/:id/agents/:agentId/models` - List models for an agent (proxied; same client access rules as get agent) -- `POST /api/clients/:id/agents` - Create a new agent for a client (returns auto-generated password, saves credentials) +- `POST /api/clients/:id/agents` - Create a new agent for a client (returns auto-generated password, saves credentials, and a one-time **OpenAI API key** for `/api/openai`) - `POST /api/clients/:id/agents/:agentId` - Update an existing agent - `DELETE /api/clients/:id/agents/:agentId` - Delete an agent (also deletes stored credentials) +- `POST /api/clients/:id/agents/:agentId/openai-api-key/rotate` - Rotate the per-agent OpenAI key (returns new plaintext once; requires normal platform auth) + +### OpenAI-compatible HTTP (`/api/openai`) + +For external tools and SDKs that expect an OpenAI-style API. These routes are **`@Public()`** with respect to platform JWT/static API key: you must send **`Authorization: Bearer `** (or `ApiKey `). The key selects **one agent** (and its client); the request `model` field is the model id for that agent. + +- `GET /api/openai/v1/models` - List models (proxied from agent-manager for that agent) +- `POST /api/openai/v1/chat/completions` - Chat completions; `stream: true` returns **SSE** (`text/event-stream`) +- `POST /api/openai/v1/completions` - Legacy completions (string/array prompt) +- `POST /api/openai/v1/responses` - Minimal Responses API subset (text-oriented) + +**Encryption:** Per-agent keys are stored encrypted (AES-256-GCM). Set **`ENCRYPTION_KEY`** in the agent-controller environment (required in production); see the app `README` and `main.ts` bootstrap checks. + +**Not implemented** on this surface: embeddings, audio, images, realtime WebSocket, files, batches, fine-tuning, and full Responses tool/multimodal parity. **Note**: Agent creation requests are proxied to the remote agent-manager service. SSH repository configuration (including `GIT_PRIVATE_KEY`) must be configured on the agent-manager instance via environment variables, not through the API request. See the [agent-manager documentation](../feature-agent-manager/README.md) for details on SSH repository setup. diff --git a/libs/domains/framework/backend/feature-agent-controller/docs/openai-sequence.mmd b/libs/domains/framework/backend/feature-agent-controller/docs/openai-sequence.mmd new file mode 100644 index 00000000..7a89a5b1 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/docs/openai-sequence.mmd @@ -0,0 +1,15 @@ +sequenceDiagram + participant ExtTool as ExternalTool(OpenAI_SDK) + participant AgentCtrl as AgentController(/api/openai) + participant DB as AgentControllerDB + participant ClientAM as ClientAgentManager(SocketIO_/agents) + + ExtTool->>AgentCtrl: POST /v1/chat/completions (Bearer agent_openai_key) + AgentCtrl->>DB: Lookup api_key_hash -> (clientId, agentId) + AgentCtrl->>ClientAM: Connect + Authorization (client API key / JWT) + AgentCtrl->>ClientAM: login(agentId, password_from_DB) + AgentCtrl->>ClientAM: chat(message, model, responseMode) + ClientAM-->>AgentCtrl: chatEvent assistantDelta (stream) + AgentCtrl-->>ExtTool: SSE chunks (delta) + ClientAM-->>AgentCtrl: chatEvent assistantMessage (final) + AgentCtrl-->>ExtTool: final SSE + [DONE] diff --git a/libs/domains/framework/backend/feature-agent-controller/spec/asyncapi.yaml b/libs/domains/framework/backend/feature-agent-controller/spec/asyncapi.yaml index c8e2d0ec..2c2d93d1 100644 --- a/libs/domains/framework/backend/feature-agent-controller/spec/asyncapi.yaml +++ b/libs/domains/framework/backend/feature-agent-controller/spec/asyncapi.yaml @@ -7,6 +7,12 @@ info: All connections require authentication via the `Authorization` header in the handshake (same as HTTP API: `Bearer ` or `Bearer ` / `ApiKey `). Unauthenticated connections are rejected with connect_error "Unauthorized". + + **OpenAI-compatible HTTP (not this AsyncAPI):** Agent Controller also exposes `/api/openai/v1/*` + for external tools. Those routes use a **per-agent** bearer secret (`agenstra_oai_...`), not platform JWT. + Streaming there is **HTTP Server-Sent Events (SSE)**, not WebSocket. Internally, the controller bridges + to the remote agent-manager `/agents` namespace (`chat` / `chatEvent` assistant deltas and final messages); + see the Agent Controller OpenAPI spec and `docs/openai-sequence.mmd`. The `setClient` operation enforces per-client authorization: only users with access to the requested client (global admin, client creator, or client_users entry) can set that client context. Unauthorized setClient attempts emit an `error` event with message diff --git a/libs/domains/framework/backend/feature-agent-controller/spec/openapi.yaml b/libs/domains/framework/backend/feature-agent-controller/spec/openapi.yaml index a304f719..c823c18c 100644 --- a/libs/domains/framework/backend/feature-agent-controller/spec/openapi.yaml +++ b/libs/domains/framework/backend/feature-agent-controller/spec/openapi.yaml @@ -2,7 +2,10 @@ openapi: 3.1.0 info: title: Agent Controller API version: 1.0.0 - description: HTTP API for managing clients and proxied agent operations + description: | + HTTP API for managing clients and proxied agent operations. + OpenAI-compatible inference is exposed under `/openai/v1/*` using per-agent API keys + (see operation security); platform JWT/static API key auth does not apply to those routes. contact: name: ForePath email: hi@forepath.io @@ -772,13 +775,48 @@ paths: $ref: '#/components/schemas/CreateAgentDto' responses: '201': - description: Created agent with generated password + description: Created agent with generated password and OpenAI-compatible API key content: application/json: schema: - $ref: '#/components/schemas/CreateAgentResponseDto' + $ref: '#/components/schemas/CreateClientAgentResponseDto' '403': description: User does not have access to this client + /clients/{id}/agents/{agentId}/openai-api-key/rotate: + post: + summary: Rotate per-agent OpenAI API key + operationId: rotateOpenAiApiKey + security: + - bearerAuth: [] + description: | + Issues a new OpenAI-compatible API key for `/api/openai`. The plaintext key is returned once; + store it securely. Old keys stop working immediately. + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + description: Client UUID + - in: path + name: agentId + required: true + schema: + type: string + format: uuid + description: Agent UUID + responses: + '200': + description: New key (plaintext, single response) + content: + application/json: + schema: + $ref: '#/components/schemas/RotateOpenAiApiKeyResponseDto' + '403': + description: User does not have access to this client + '404': + description: Agent or key record not found /clients/{id}/agents/{agentId}: get: summary: Get agent by id for a client @@ -2982,12 +3020,118 @@ paths: $ref: '#/components/schemas/StatisticsEntityEventListDto' '403': description: Access denied to requested client + /openai/v1/models: + get: + summary: List models (OpenAI-compatible) + operationId: openAiListModels + description: | + Lists models available to the agent associated with the API key. Keys are provider model ids. + **Not implemented elsewhere under `/openai`:** embeddings, audio, images, realtime, files, batches, fine-tuning. + security: + - openAiAgentApiKey: [] + responses: + '200': + description: OpenAI-style model list + content: + application/json: + schema: + $ref: '#/components/schemas/OpenAiModelsListResponse' + '401': + description: Missing or invalid per-agent OpenAI API key + /openai/v1/chat/completions: + post: + summary: Chat completions (OpenAI-compatible) + operationId: openAiChatCompletions + description: | + Maps to the agent-manager WebSocket `chat` flow. `messages` must use string `content` only. + When `stream` is true, the response is `text/event-stream` (SSE) with `chat.completion.chunk` JSON lines. + security: + - openAiAgentApiKey: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OpenAiChatCompletionsRequest' + responses: + '200': + description: Non-stream JSON or SSE stream + content: + application/json: + schema: + $ref: '#/components/schemas/OpenAiChatCompletionResponse' + text/event-stream: + schema: + type: string + description: 'SSE data lines; stream ends with a terminal line like data plus [DONE] (OpenAI-style).' + '401': + description: Missing or invalid per-agent OpenAI API key + '400': + description: Invalid body or agent error + /openai/v1/completions: + post: + summary: Text completions (OpenAI-compatible) + operationId: openAiCompletions + description: Legacy completions; `prompt` is string or array of strings (concatenated). + security: + - openAiAgentApiKey: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OpenAiCompletionsRequest' + responses: + '200': + description: Non-stream JSON or SSE stream + content: + application/json: + schema: + $ref: '#/components/schemas/OpenAiTextCompletionResponse' + text/event-stream: + schema: + type: string + '401': + description: Missing or invalid per-agent OpenAI API key + /openai/v1/responses: + post: + summary: Responses API (minimal subset) + operationId: openAiResponses + description: | + Minimal compatibility with OpenAI Responses. `input` is string or structured list (best-effort text extraction). + Full tool-calling and multimodal parity are not implemented. + security: + - openAiAgentApiKey: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/OpenAiResponsesRequest' + responses: + '200': + description: Non-stream JSON or SSE stream + content: + application/json: + schema: + $ref: '#/components/schemas/OpenAiResponseObject' + text/event-stream: + schema: + type: string + '401': + description: Missing or invalid per-agent OpenAI API key components: securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: JWT + openAiAgentApiKey: + type: http + scheme: bearer + description: | + Per-agent OpenAI-compatible secret (`agenstra_oai_...`) issued on agent creation or via rotate endpoint. + Use `Authorization: Bearer `. This is not the platform JWT or client static API key. schemas: AuthenticationType: type: string @@ -3326,6 +3470,99 @@ components: properties: password: type: string + CreateClientAgentResponseDto: + allOf: + - $ref: '#/components/schemas/CreateAgentResponseDto' + - type: object + properties: + openAiApiKey: + type: string + description: Per-agent key for `/api/openai/*`; returned once when the agent is created. + RotateOpenAiApiKeyResponseDto: + type: object + required: [openAiApiKey] + properties: + openAiApiKey: + type: string + OpenAiChatMessage: + type: object + required: [role, content] + properties: + role: + type: string + content: + type: string + OpenAiChatCompletionsRequest: + type: object + required: [model, messages] + properties: + model: + type: string + messages: + type: array + items: + $ref: '#/components/schemas/OpenAiChatMessage' + stream: + type: boolean + OpenAiCompletionsRequest: + type: object + required: [model, prompt] + properties: + model: + type: string + prompt: + oneOf: + - type: string + - type: array + items: + type: string + stream: + type: boolean + OpenAiResponsesRequest: + type: object + required: [model, input] + properties: + model: + type: string + input: + oneOf: + - type: string + - type: array + items: + type: object + additionalProperties: true + description: String or list (best-effort text extraction server-side) + stream: + type: boolean + OpenAiModelsListResponse: + type: object + properties: + object: + type: string + enum: [list] + data: + type: array + items: + type: object + properties: + id: + type: string + object: + type: string + enum: [model] + created: + type: integer + owned_by: + type: string + OpenAiChatCompletionResponse: + type: object + description: Subset of OpenAI chat.completion object + OpenAiTextCompletionResponse: + type: object + description: Subset of OpenAI text_completion object + OpenAiResponseObject: + type: object + description: Minimal OpenAI response object AgentTypeInfo: type: object required: [type, displayName] diff --git a/libs/domains/framework/backend/feature-agent-controller/src/index.ts b/libs/domains/framework/backend/feature-agent-controller/src/index.ts index b18fa03f..14e1d790 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/index.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/index.ts @@ -67,6 +67,7 @@ export * from './lib/dto/client-response.dto'; export * from './lib/dto/create-client-response.dto'; export * from './lib/dto/create-client.dto'; export * from './lib/dto/update-client.dto'; +export * from './lib/entities/client-agent-openai-api-key.entity'; export * from './lib/entities/provisioning-reference.entity'; export * from './lib/entities/ticket-activity.entity'; export * from './lib/entities/ticket-body-generation-session.entity'; diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/auth/openai-agent-context.decorator.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/auth/openai-agent-context.decorator.ts new file mode 100644 index 00000000..a846228e --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/auth/openai-agent-context.decorator.ts @@ -0,0 +1,12 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { Request } from 'express'; +import { OPENAI_AGENT_CONTEXT_KEY, OpenAiAgentContext } from './openai-agent.types'; + +export const OpenAiAgentCtx = createParamDecorator((_data: unknown, ctx: ExecutionContext): OpenAiAgentContext => { + const request = ctx.switchToHttp().getRequest(); + const value = request[OPENAI_AGENT_CONTEXT_KEY]; + if (!value) { + throw new Error('OpenAiAgentContext missing; ensure OpenAiApiKeyGuard runs before this handler'); + } + return value; +}); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/auth/openai-agent.types.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/auth/openai-agent.types.ts new file mode 100644 index 00000000..3ce6ea4c --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/auth/openai-agent.types.ts @@ -0,0 +1,6 @@ +export const OPENAI_AGENT_CONTEXT_KEY = 'openaiAgentContext'; + +export interface OpenAiAgentContext { + clientId: string; + agentId: string; +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/auth/openai-api-key.guard.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/auth/openai-api-key.guard.ts new file mode 100644 index 00000000..3fb622c1 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/auth/openai-api-key.guard.ts @@ -0,0 +1,43 @@ +import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; +import { Request } from 'express'; +import { ClientAgentOpenAiApiKeysService } from '../services/client-agent-openai-api-keys.service'; +import { OPENAI_AGENT_CONTEXT_KEY, OpenAiAgentContext } from './openai-agent.types'; + +function extractBearerToken(authHeader: string | undefined): string | null { + if (!authHeader || typeof authHeader !== 'string') { + return null; + } + const parts = authHeader.trim().split(/\s+/); + if (parts.length !== 2) { + return null; + } + const [scheme, token] = parts; + const s = scheme.toLowerCase(); + if (s !== 'bearer' && s !== 'apikey') { + return null; + } + return token.trim() || null; +} + +/** + * Authenticates `/api/openai` using per-agent OpenAI-style keys (Bearer or ApiKey scheme). + */ +@Injectable() +export class OpenAiApiKeyGuard implements CanActivate { + constructor(private readonly openAiApiKeysService: ClientAgentOpenAiApiKeysService) {} + + async canActivate(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest(); + const token = extractBearerToken(req.headers.authorization); + if (!token) { + throw new UnauthorizedException('Missing or invalid Authorization header'); + } + const resolved = await this.openAiApiKeysService.resolveClientAndAgentByRawKey(token); + if (!resolved) { + throw new UnauthorizedException('Invalid API key'); + } + const ctx: OpenAiAgentContext = { clientId: resolved.clientId, agentId: resolved.agentId }; + (req as Request & { [OPENAI_AGENT_CONTEXT_KEY]?: OpenAiAgentContext })[OPENAI_AGENT_CONTEXT_KEY] = ctx; + return true; + } +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients.controller.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients.controller.spec.ts index 78735cef..4ec7df20 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients.controller.spec.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients.controller.spec.ts @@ -35,6 +35,7 @@ import { ProvisioningProvider } from '../providers/provisioning-provider.interfa import { ClientsRepository } from '../repositories/clients.repository'; import { ClientAgentEnvironmentVariablesProxyService } from '../services/client-agent-environment-variables-proxy.service'; import { ClientAgentFileSystemProxyService } from '../services/client-agent-file-system-proxy.service'; +import { ClientAgentOpenAiApiKeysService } from '../services/client-agent-openai-api-keys.service'; import { ClientAgentProxyService } from '../services/client-agent-proxy.service'; import { ClientsService } from '../services/clients.service'; import { ProvisioningService } from '../services/provisioning.service'; @@ -51,6 +52,7 @@ describe('ClientsController', () => { let clientUsersService: jest.Mocked; let clientsRepository: jest.Mocked; let clientUsersRepository: jest.Mocked; + let clientAgentOpenAiApiKeysService: jest.Mocked; const mockClientResponse: ClientResponseDto = { id: 'test-uuid', @@ -154,6 +156,10 @@ describe('ClientsController', () => { findUserClientAccess: jest.fn(), }; + const mockClientAgentOpenAiApiKeysService = { + rotateKey: jest.fn(), + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [ClientsController], @@ -194,6 +200,10 @@ describe('ClientsController', () => { provide: ClientUsersRepository, useValue: mockClientUsersRepository, }, + { + provide: ClientAgentOpenAiApiKeysService, + useValue: mockClientAgentOpenAiApiKeysService, + }, ], }).compile(); @@ -207,6 +217,7 @@ describe('ClientsController', () => { clientUsersService = module.get(ClientUsersService); clientsRepository = module.get(ClientsRepository); clientUsersRepository = module.get(ClientUsersRepository); + clientAgentOpenAiApiKeysService = module.get(ClientAgentOpenAiApiKeysService); }); afterEach(() => { @@ -384,6 +395,20 @@ describe('ClientsController', () => { }); }); + describe('rotateOpenAiApiKey', () => { + it('should rotate key and return plaintext once', async () => { + const mockReq = { apiKeyAuthenticated: true } as any; + clientsRepository.findById.mockResolvedValue({ id: 'client-uuid', userId: null } as any); + clientUsersRepository.findUserClientAccess.mockResolvedValue(null); + clientAgentOpenAiApiKeysService.rotateKey.mockResolvedValue('new-openai-key'); + + const result = await controller.rotateOpenAiApiKey('client-uuid', 'agent-uuid', mockReq); + + expect(result).toEqual({ openAiApiKey: 'new-openai-key' }); + expect(clientAgentOpenAiApiKeysService.rotateKey).toHaveBeenCalledWith('client-uuid', 'agent-uuid'); + }); + }); + describe('createClientAgent', () => { it('should create new agent for a client', async () => { const createDto: CreateAgentDto = { diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts index 11ba4227..eb5e6467 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts @@ -2,7 +2,6 @@ import { AgentModelsResponseDto, AgentResponseDto, CreateAgentDto, - CreateAgentResponseDto, CreateEnvironmentVariableDto, CreateFileDto, EnvironmentVariableResponseDto, @@ -50,9 +49,11 @@ import { ProvisionedServerResponseDto } from '../dto/provisioned-server-response import { UpdateClientDto } from '../dto/update-client.dto'; import { ProvisioningProviderFactory } from '../providers/provisioning-provider.factory'; import { ClientsRepository } from '../repositories/clients.repository'; +import { RotateOpenAiApiKeyResponseDto } from '../dto/rotate-openai-api-key-response.dto'; import { ClientAgentEnvironmentVariablesProxyService } from '../services/client-agent-environment-variables-proxy.service'; import { ClientAgentFileSystemProxyService } from '../services/client-agent-file-system-proxy.service'; -import { ClientAgentProxyService } from '../services/client-agent-proxy.service'; +import { ClientAgentOpenAiApiKeysService } from '../services/client-agent-openai-api-keys.service'; +import { ClientAgentProxyService, CreateClientAgentResultDto } from '../services/client-agent-proxy.service'; import { ClientsService } from '../services/clients.service'; import { ProvisioningService } from '../services/provisioning.service'; @@ -72,6 +73,7 @@ export class ClientsController { private readonly clientUsersService: ClientUsersService, private readonly clientsRepository: ClientsRepository, private readonly clientUsersRepository: ClientUsersRepository, + private readonly clientAgentOpenAiApiKeysService: ClientAgentOpenAiApiKeysService, ) {} /** @@ -240,12 +242,27 @@ export class ClientsController { @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, @Body() createAgentDto: CreateAgentDto, @Req() req?: RequestWithUser, - ): Promise { + ): Promise { await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, id, req); const userInfo = getUserFromRequest(req || ({} as RequestWithUser)); return await this.clientAgentProxyService.createClientAgent(id, createAgentDto, userInfo.userId); } + /** + * Rotate the OpenAI-compatible API key for an agent (plaintext returned once). + */ + @Post(':id/agents/:agentId/openai-api-key/rotate') + @HttpCode(HttpStatus.OK) + async rotateOpenAiApiKey( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, + @Req() req?: RequestWithUser, + ): Promise { + await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, id, req); + const openAiApiKey = await this.clientAgentOpenAiApiKeysService.rotateKey(id, agentId); + return { openAiApiKey }; + } + /** * Delete an agent for a specific client by agent ID. * Only accessible if the user has access to the client. diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/openai/openai-v1.controller.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/openai/openai-v1.controller.ts new file mode 100644 index 00000000..d18aec1e --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/controllers/openai/openai-v1.controller.ts @@ -0,0 +1,279 @@ +import { Body, Controller, Get, Post, Res, UseGuards } from '@nestjs/common'; +import { SkipThrottle } from '@nestjs/throttler'; +import { Public } from '@forepath/identity/backend'; +import type { Response } from 'express'; +import { OpenAiAgentCtx } from '../../auth/openai-agent-context.decorator'; +import type { OpenAiAgentContext } from '../../auth/openai-agent.types'; +import { OpenAiApiKeyGuard } from '../../auth/openai-api-key.guard'; +import { + parseChatCompletionsBody, + parseCompletionsBody, + parseResponsesBody, +} from '../../dto/openai/openai-body.validation'; +import { + formatChatMessagesForAgent, + normalizeOpenAiPrompt, + normalizeResponsesInput, +} from '../../services/openai/openai-prompt.utils'; +import { + buildChatCompletion, + buildChatCompletionChunk, + buildModelsList, + buildResponsesObject, + buildResponsesStreamChunk, + buildTextCompletion, + buildTextCompletionChunk, + newOpenAiId, + openAiUnixTimestamp, +} from '../../services/openai/openai-response-shapes'; +import { OpenAiAgentWsProxyService } from '../../services/openai/openai-agent-ws-proxy.service'; +import { ClientAgentProxyService } from '../../services/client-agent-proxy.service'; + +/** + * OpenAI API v1-compatible surface. Authenticated via per-agent key (`Authorization: Bearer `). + * Platform JWT / static API key auth is bypassed via `@Public()`; {@link OpenAiApiKeyGuard} enforces agent keys. + */ +@Public() +@SkipThrottle() +@Controller('openai') +@UseGuards(OpenAiApiKeyGuard) +export class OpenAiV1Controller { + constructor( + private readonly clientAgentProxyService: ClientAgentProxyService, + private readonly openAiAgentWsProxy: OpenAiAgentWsProxyService, + ) {} + + @Get('v1/models') + async listModels(@OpenAiAgentCtx() ctx: OpenAiAgentContext): Promise> { + const modelsMap = await this.clientAgentProxyService.listClientAgentModels(ctx.clientId, ctx.agentId); + const modelIds = Object.keys(modelsMap); + if (modelIds.length === 0) { + return buildModelsList({ modelIds: ['default'] }); + } + return buildModelsList({ modelIds }); + } + + @Post('v1/chat/completions') + async chatCompletions( + @OpenAiAgentCtx() ctx: OpenAiAgentContext, + @Body() body: unknown, + @Res() res: Response, + ): Promise { + const parsed = parseChatCompletionsBody(body); + const userText = formatChatMessagesForAgent(parsed.messages); + const correlationId = OpenAiAgentWsProxyService.newCorrelationId(); + + if (parsed.stream) { + res.status(200); + res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + const id = newOpenAiId('chatcmpl'); + const created = openAiUnixTimestamp(); + try { + res.write( + `data: ${JSON.stringify( + buildChatCompletionChunk({ + id, + created, + model: parsed.model, + delta: { role: 'assistant' }, + }), + )}\n\n`, + ); + for await (const delta of this.openAiAgentWsProxy.completeStream({ + clientId: ctx.clientId, + agentId: ctx.agentId, + userText, + model: parsed.model, + correlationId, + })) { + if (delta) { + res.write( + `data: ${JSON.stringify( + buildChatCompletionChunk({ + id, + created, + model: parsed.model, + delta: { content: delta }, + }), + )}\n\n`, + ); + } + } + res.write( + `data: ${JSON.stringify( + buildChatCompletionChunk({ + id, + created, + model: parsed.model, + delta: {}, + finishReason: 'stop', + }), + )}\n\n`, + ); + res.write('data: [DONE]\n\n'); + } catch (e) { + const err = e as { message?: string }; + res.write(`data: ${JSON.stringify({ error: { message: err.message || 'stream_error' } })}\n\n`); + } + res.end(); + return; + } + + const text = await this.openAiAgentWsProxy.completeNonStream({ + clientId: ctx.clientId, + agentId: ctx.agentId, + userText, + model: parsed.model, + correlationId, + }); + const id = newOpenAiId('chatcmpl'); + res.status(200).json( + buildChatCompletion({ + id, + created: openAiUnixTimestamp(), + model: parsed.model, + content: text, + }), + ); + } + + @Post('v1/completions') + async completions( + @OpenAiAgentCtx() ctx: OpenAiAgentContext, + @Body() body: unknown, + @Res() res: Response, + ): Promise { + const parsed = parseCompletionsBody(body); + const prompt = normalizeOpenAiPrompt(parsed.prompt); + const correlationId = OpenAiAgentWsProxyService.newCorrelationId(); + + if (parsed.stream) { + res.status(200); + res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + const id = newOpenAiId('cmpl'); + const created = openAiUnixTimestamp(); + try { + for await (const delta of this.openAiAgentWsProxy.completeStream({ + clientId: ctx.clientId, + agentId: ctx.agentId, + userText: prompt, + model: parsed.model, + correlationId, + })) { + if (delta) { + res.write( + `data: ${JSON.stringify( + buildTextCompletionChunk({ + id, + created, + model: parsed.model, + text: delta, + }), + )}\n\n`, + ); + } + } + res.write( + `data: ${JSON.stringify( + buildTextCompletionChunk({ + id, + created, + model: parsed.model, + text: '', + finishReason: 'stop', + }), + )}\n\n`, + ); + res.write('data: [DONE]\n\n'); + } catch (e) { + const err = e as { message?: string }; + res.write(`data: ${JSON.stringify({ error: { message: err.message || 'stream_error' } })}\n\n`); + } + res.end(); + return; + } + + const text = await this.openAiAgentWsProxy.completeNonStream({ + clientId: ctx.clientId, + agentId: ctx.agentId, + userText: prompt, + model: parsed.model, + correlationId, + }); + const id = newOpenAiId('cmpl'); + res.status(200).json( + buildTextCompletion({ + id, + created: openAiUnixTimestamp(), + model: parsed.model, + text, + }), + ); + } + + @Post('v1/responses') + async responses( + @OpenAiAgentCtx() ctx: OpenAiAgentContext, + @Body() body: unknown, + @Res() res: Response, + ): Promise { + const parsed = parseResponsesBody(body); + const userText = normalizeResponsesInput(parsed.input); + const correlationId = OpenAiAgentWsProxyService.newCorrelationId(); + const respId = newOpenAiId('resp'); + + if (parsed.stream) { + res.status(200); + res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + try { + for await (const delta of this.openAiAgentWsProxy.completeStream({ + clientId: ctx.clientId, + agentId: ctx.agentId, + userText, + model: parsed.model, + correlationId, + })) { + if (delta) { + res.write( + `data: ${JSON.stringify( + buildResponsesStreamChunk({ + id: respId, + model: parsed.model, + delta, + }), + )}\n\n`, + ); + } + } + res.write('data: [DONE]\n\n'); + } catch (e) { + const err = e as { message?: string }; + res.write(`data: ${JSON.stringify({ error: { message: err.message || 'stream_error' } })}\n\n`); + } + res.end(); + return; + } + + const text = await this.openAiAgentWsProxy.completeNonStream({ + clientId: ctx.clientId, + agentId: ctx.agentId, + userText, + model: parsed.model, + correlationId, + }); + res.status(200).json( + buildResponsesObject({ + id: respId, + created: openAiUnixTimestamp(), + model: parsed.model, + text, + }), + ); + } +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/openai/openai-body.validation.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/openai/openai-body.validation.spec.ts new file mode 100644 index 00000000..b58745b3 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/openai/openai-body.validation.spec.ts @@ -0,0 +1,31 @@ +import { BadRequestException } from '@nestjs/common'; +import { parseChatCompletionsBody, parseCompletionsBody, parseResponsesBody } from './openai-body.validation'; + +describe('openai-body.validation', () => { + it('parseChatCompletionsBody parses valid body', () => { + const r = parseChatCompletionsBody({ + model: 'm1', + messages: [{ role: 'user', content: 'hi' }], + stream: true, + }); + expect(r.model).toBe('m1'); + expect(r.messages).toHaveLength(1); + expect(r.stream).toBe(true); + }); + + it('parseChatCompletionsBody rejects invalid', () => { + expect(() => parseChatCompletionsBody({})).toThrow(BadRequestException); + }); + + it('parseCompletionsBody normalizes prompt', () => { + const r = parseCompletionsBody({ model: 'm', prompt: 'x' }); + expect(r.model).toBe('m'); + expect(r.prompt).toBe('x'); + }); + + it('parseResponsesBody requires input', () => { + expect(() => parseResponsesBody({ model: 'm' })).toThrow(BadRequestException); + const r = parseResponsesBody({ model: 'm', input: 'hello' }); + expect(r.input).toBe('hello'); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/openai/openai-body.validation.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/openai/openai-body.validation.ts new file mode 100644 index 00000000..3f1e6e7a --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/openai/openai-body.validation.ts @@ -0,0 +1,75 @@ +import { BadRequestException } from '@nestjs/common'; + +export interface ParsedChatMessage { + role: string; + content: string; +} + +export function parseChatCompletionsBody(body: unknown): { + model: string; + messages: ParsedChatMessage[]; + stream: boolean; +} { + if (!body || typeof body !== 'object') { + throw new BadRequestException('Invalid JSON body'); + } + const o = body as Record; + if (typeof o.model !== 'string' || !o.model.trim()) { + throw new BadRequestException('model is required'); + } + if (!Array.isArray(o.messages) || o.messages.length === 0) { + throw new BadRequestException('messages must be a non-empty array'); + } + const messages: ParsedChatMessage[] = []; + for (const m of o.messages) { + if (!m || typeof m !== 'object') { + throw new BadRequestException('Each message must be an object'); + } + const msg = m as Record; + if (typeof msg.role !== 'string' || typeof msg.content !== 'string') { + throw new BadRequestException('Each message must have string role and string content'); + } + messages.push({ role: msg.role, content: msg.content }); + } + return { + model: o.model.trim(), + messages, + stream: o.stream === true, + }; +} + +export function parseCompletionsBody(body: unknown): { model: string; stream: boolean; prompt: unknown } { + if (!body || typeof body !== 'object') { + throw new BadRequestException('Invalid JSON body'); + } + const o = body as Record; + if (typeof o.model !== 'string' || !o.model.trim()) { + throw new BadRequestException('model is required'); + } + if (o.prompt === undefined) { + throw new BadRequestException('prompt is required'); + } + return { + model: o.model.trim(), + stream: o.stream === true, + prompt: o.prompt, + }; +} + +export function parseResponsesBody(body: unknown): { model: string; stream: boolean; input: unknown } { + if (!body || typeof body !== 'object') { + throw new BadRequestException('Invalid JSON body'); + } + const o = body as Record; + if (typeof o.model !== 'string' || !o.model.trim()) { + throw new BadRequestException('model is required'); + } + if (o.input === undefined) { + throw new BadRequestException('input is required'); + } + return { + model: o.model.trim(), + stream: o.stream === true, + input: o.input, + }; +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/rotate-openai-api-key-response.dto.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/rotate-openai-api-key-response.dto.ts new file mode 100644 index 00000000..a1ffcc4d --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/dto/rotate-openai-api-key-response.dto.ts @@ -0,0 +1,6 @@ +/** + * Returned once when rotating the per-agent OpenAI API key. + */ +export class RotateOpenAiApiKeyResponseDto { + openAiApiKey!: string; +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/client-agent-openai-api-key.entity.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/client-agent-openai-api-key.entity.ts new file mode 100644 index 00000000..852b9ef8 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/entities/client-agent-openai-api-key.entity.ts @@ -0,0 +1,38 @@ +import { createAes256GcmTransformer } from '@forepath/shared/backend'; +import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, Unique, UpdateDateColumn } from 'typeorm'; + +/** + * Stores per-agent OpenAI-compatible API keys for `/api/openai` access. + * Plain keys are never stored; encrypted value uses AES-256-GCM via ENCRYPTION_KEY. + * Lookup uses SHA-256 hex hash of the raw key. + */ +@Entity('client_agent_openai_api_keys') +@Unique('uq_openai_client_agent', ['clientId', 'agentId']) +@Unique('uq_openai_api_key_hash', ['apiKeyHash']) +export class ClientAgentOpenAiApiKeyEntity { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ type: 'uuid', name: 'client_id' }) + clientId!: string; + + @Column({ type: 'uuid', name: 'agent_id' }) + agentId!: string; + + @Column({ + type: 'text', + name: 'api_key_encrypted', + transformer: createAes256GcmTransformer(), + }) + apiKeyEncrypted!: string; + + /** SHA-256 hex digest of the raw API key (for O(1) lookup). */ + @Column({ type: 'varchar', length: 64, name: 'api_key_hash' }) + apiKeyHash!: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.spec.ts index 4b23da61..5553342b 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.spec.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.spec.ts @@ -14,6 +14,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { KEYCLOAK_CONNECT_OPTIONS, KEYCLOAK_INSTANCE } from 'nest-keycloak-connect'; import { ClientsController } from '../controllers/clients.controller'; +import { ClientAgentOpenAiApiKeyEntity } from '../entities/client-agent-openai-api-key.entity'; import { ProvisioningReferenceEntity } from '../entities/provisioning-reference.entity'; import { StatisticsAgentEntity } from '../entities/statistics-agent.entity'; import { StatisticsChatFilterDropEntity } from '../entities/statistics-chat-filter-drop.entity'; @@ -91,6 +92,8 @@ describe('ClientsModule', () => { .useValue(mockRepository) .overrideProvider(getRepositoryToken(ClientAgentCredentialEntity)) .useValue(mockRepository) + .overrideProvider(getRepositoryToken(ClientAgentOpenAiApiKeyEntity)) + .useValue(mockRepository) .overrideProvider(getRepositoryToken(ProvisioningReferenceEntity)) .useValue(mockRepository) .overrideProvider(getRepositoryToken(ClientUserEntity)) diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.ts index 8ac34052..3c37b4a2 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/modules/clients.module.ts @@ -16,12 +16,15 @@ import { import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { KeycloakConnectModule } from 'nest-keycloak-connect'; +import { OpenAiApiKeyGuard } from '../auth/openai-api-key.guard'; import { ClientStatisticsController } from '../controllers/client-statistics.controller'; import { ClientsDeploymentsController } from '../controllers/clients-deployments.controller'; import { ClientsVcsController } from '../controllers/clients-vcs.controller'; import { ClientsController } from '../controllers/clients.controller'; +import { OpenAiV1Controller } from '../controllers/openai/openai-v1.controller'; import { StatisticsController } from '../controllers/statistics.controller'; import { TicketsController } from '../controllers/tickets.controller'; +import { ClientAgentOpenAiApiKeyEntity } from '../entities/client-agent-openai-api-key.entity'; import { ProvisioningReferenceEntity } from '../entities/provisioning-reference.entity'; import { TicketActivityEntity } from '../entities/ticket-activity.entity'; import { TicketBodyGenerationSessionEntity } from '../entities/ticket-body-generation-session.entity'; @@ -31,12 +34,15 @@ import { ClientsGateway } from '../gateways/clients.gateway'; import { DigitalOceanProvider } from '../providers/digital-ocean.provider'; import { HetznerProvider } from '../providers/hetzner.provider'; import { ProvisioningProviderFactory } from '../providers/provisioning-provider.factory'; +import { ClientAgentOpenAiApiKeysRepository } from '../repositories/client-agent-openai-api-keys.repository'; import { ClientsRepository } from '../repositories/clients.repository'; import { ProvisioningReferencesRepository } from '../repositories/provisioning-references.repository'; import { ClientAgentDeploymentsProxyService } from '../services/client-agent-deployments-proxy.service'; import { ClientAgentEnvironmentVariablesProxyService } from '../services/client-agent-environment-variables-proxy.service'; import { ClientAgentFileSystemProxyService } from '../services/client-agent-file-system-proxy.service'; +import { ClientAgentOpenAiApiKeysService } from '../services/client-agent-openai-api-keys.service'; import { ClientAgentProxyService } from '../services/client-agent-proxy.service'; +import { OpenAiAgentWsProxyService } from '../services/openai/openai-agent-ws-proxy.service'; import { ClientAgentVcsProxyService } from '../services/client-agent-vcs-proxy.service'; import { ClientsService } from '../services/clients.service'; import { TicketsService } from '../services/tickets.service'; @@ -55,6 +61,7 @@ const authMethod = getAuthenticationMethod(); TypeOrmModule.forFeature([ ClientEntity, ClientAgentCredentialEntity, + ClientAgentOpenAiApiKeyEntity, ProvisioningReferenceEntity, ClientUserEntity, UserEntity, @@ -74,6 +81,7 @@ const authMethod = getAuthenticationMethod(); ClientStatisticsController, StatisticsController, TicketsController, + OpenAiV1Controller, ], providers: [ ClientsService, @@ -83,7 +91,11 @@ const authMethod = getAuthenticationMethod(); ClientUsersService, UsersRepository, KeycloakTokenService, + ClientAgentOpenAiApiKeysRepository, + ClientAgentOpenAiApiKeysService, ClientAgentProxyService, + OpenAiAgentWsProxyService, + OpenAiApiKeyGuard, ClientAgentFileSystemProxyService, ClientAgentVcsProxyService, ClientAgentDeploymentsProxyService, diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/repositories/client-agent-openai-api-keys.repository.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/repositories/client-agent-openai-api-keys.repository.ts new file mode 100644 index 00000000..e777da1f --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/repositories/client-agent-openai-api-keys.repository.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { ClientAgentOpenAiApiKeyEntity } from '../entities/client-agent-openai-api-key.entity'; + +@Injectable() +export class ClientAgentOpenAiApiKeysRepository { + constructor( + @InjectRepository(ClientAgentOpenAiApiKeyEntity) + private readonly repository: Repository, + ) {} + + async findByApiKeyHash(apiKeyHash: string): Promise { + return await this.repository.findOne({ where: { apiKeyHash } }); + } + + async findByClientAndAgent(clientId: string, agentId: string): Promise { + return await this.repository.findOne({ where: { clientId, agentId } }); + } + + async save(entity: ClientAgentOpenAiApiKeyEntity): Promise { + return await this.repository.save(entity); + } + + async create( + dto: Pick, + ): Promise { + const entity = this.repository.create(dto); + return await this.repository.save(entity); + } + + async deleteByClientAndAgent(clientId: string, agentId: string): Promise { + const existing = await this.findByClientAndAgent(clientId, agentId); + if (existing) { + await this.repository.remove(existing); + } + } +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-openai-api-keys.service.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-openai-api-keys.service.ts new file mode 100644 index 00000000..bf012951 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-openai-api-keys.service.ts @@ -0,0 +1,85 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { ClientAgentOpenAiApiKeysRepository } from '../repositories/client-agent-openai-api-keys.repository'; +import { generateOpenAiStyleApiKey, hashOpenAiApiKey } from '../utils/openai-api-key-hash'; + +/** + * Issues and resolves per-agent OpenAI-compatible API keys stored encrypted-at-rest. + */ +@Injectable() +export class ClientAgentOpenAiApiKeysService { + private readonly logger = new Logger(ClientAgentOpenAiApiKeysService.name); + + constructor(private readonly repository: ClientAgentOpenAiApiKeysRepository) {} + + /** + * Create a new key for the agent and return the plaintext once (caller must persist securely). + */ + async issueKeyForNewAgent(clientId: string, agentId: string): Promise { + const existing = await this.repository.findByClientAndAgent(clientId, agentId); + if (existing) { + this.logger.warn(`OpenAI API key already exists for client ${clientId} agent ${agentId}; skipping issue`); + return ''; + } + return await this.createAndPersistKey(clientId, agentId); + } + + /** + * Rotate key: removes old row semantics by updating hash + encrypted value in place. + */ + async rotateKey(clientId: string, agentId: string): Promise { + const existing = await this.repository.findByClientAndAgent(clientId, agentId); + const plain = generateOpenAiStyleApiKey(); + const apiKeyHash = hashOpenAiApiKey(plain); + if (existing) { + existing.apiKeyEncrypted = plain; + existing.apiKeyHash = apiKeyHash; + await this.repository.save(existing); + } else { + await this.repository.create({ clientId, agentId, apiKeyEncrypted: plain, apiKeyHash }); + } + return plain; + } + + private async createAndPersistKey(clientId: string, agentId: string): Promise { + const plain = generateOpenAiStyleApiKey(); + const apiKeyHash = hashOpenAiApiKey(plain); + await this.repository.create({ clientId, agentId, apiKeyEncrypted: plain, apiKeyHash }); + return plain; + } + + async resolveClientAndAgentByRawKey(rawKey: string): Promise<{ clientId: string; agentId: string } | null> { + const trimmed = rawKey?.trim(); + if (!trimmed) { + return null; + } + const apiKeyHash = hashOpenAiApiKey(trimmed); + const row = await this.repository.findByApiKeyHash(apiKeyHash); + if (!row) { + return null; + } + return { clientId: row.clientId, agentId: row.agentId }; + } + + /** + * Ensure a key exists (for agents created before this feature). Returns plaintext only when newly created. + */ + async ensureKeyExists(clientId: string, agentId: string): Promise<{ created: boolean; plainKey?: string }> { + const existing = await this.repository.findByClientAndAgent(clientId, agentId); + if (existing) { + return { created: false }; + } + const plain = await this.createAndPersistKey(clientId, agentId); + return { created: true, plainKey: plain }; + } + + async assertKeyRowExists(clientId: string, agentId: string): Promise { + const row = await this.repository.findByClientAndAgent(clientId, agentId); + if (!row) { + throw new NotFoundException('No OpenAI API key configured for this agent; create or rotate a key first.'); + } + } + + async deleteForAgent(clientId: string, agentId: string): Promise { + await this.repository.deleteByClientAndAgent(clientId, agentId); + } +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-proxy.service.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-proxy.service.spec.ts index 50102417..87263a53 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-proxy.service.spec.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-proxy.service.spec.ts @@ -11,6 +11,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AuthenticationType, ClientAgentCredentialsService, ClientEntity } from '@forepath/identity/backend'; import axios, { AxiosError } from 'axios'; import { ClientsRepository } from '../repositories/clients.repository'; +import { ClientAgentOpenAiApiKeysService } from './client-agent-openai-api-keys.service'; import { ClientAgentProxyService } from './client-agent-proxy.service'; import { ClientsService } from './clients.service'; import { StatisticsService } from './statistics.service'; @@ -24,6 +25,7 @@ describe('ClientAgentProxyService', () => { let clientsService: jest.Mocked; let clientsRepository: jest.Mocked; let credentialsService: jest.Mocked; + let openAiKeysService: jest.Mocked; const mockClientEntity: ClientEntity = { id: 'client-uuid', @@ -77,6 +79,11 @@ describe('ClientAgentProxyService', () => { deleteCredentials: jest.fn(), }; + const mockOpenAiKeysService = { + issueKeyForNewAgent: jest.fn().mockResolvedValue('agenstra_oai_testkey'), + deleteForAgent: jest.fn().mockResolvedValue(undefined), + }; + const mockStatisticsService = { recordEntityCreated: jest.fn().mockResolvedValue(undefined), recordEntityUpdated: jest.fn().mockResolvedValue(undefined), @@ -99,6 +106,10 @@ describe('ClientAgentProxyService', () => { provide: ClientAgentCredentialsService, useValue: mockCredentialsService, }, + { + provide: ClientAgentOpenAiApiKeysService, + useValue: mockOpenAiKeysService, + }, { provide: StatisticsService, useValue: mockStatisticsService, @@ -110,6 +121,7 @@ describe('ClientAgentProxyService', () => { clientsService = module.get(ClientsService); clientsRepository = module.get(ClientsRepository); credentialsService = module.get(ClientAgentCredentialsService); + openAiKeysService = module.get(ClientAgentOpenAiApiKeysService); // Reset mocks jest.clearAllMocks(); @@ -294,7 +306,11 @@ describe('ClientAgentProxyService', () => { const result = await service.createClientAgent('client-uuid', createDto); - expect(result).toEqual(mockCreateAgentResponse); + expect(result).toEqual({ + ...mockCreateAgentResponse, + openAiApiKey: 'agenstra_oai_testkey', + }); + expect(openAiKeysService.issueKeyForNewAgent).toHaveBeenCalledWith('client-uuid', mockCreateAgentResponse.id); expect(mockedAxios.request).toHaveBeenCalledWith( expect.objectContaining({ method: 'POST', @@ -362,6 +378,7 @@ describe('ClientAgentProxyService', () => { deleteCredentials: jest.Mock; }; expect(credentialsService.deleteCredentials).toHaveBeenCalledWith('client-uuid', 'agent-uuid'); + expect(openAiKeysService.deleteForAgent).toHaveBeenCalledWith('client-uuid', 'agent-uuid'); }); }); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-proxy.service.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-proxy.service.ts index 05909fd3..479f414d 100644 --- a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-proxy.service.ts +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/client-agent-proxy.service.ts @@ -12,9 +12,12 @@ import { AuthenticationType, ClientAgentCredentialsService } from '@forepath/ide import { StatisticsEntityType } from '../entities/statistics-entity-event.entity'; import axios, { AxiosError, AxiosRequestConfig } from 'axios'; import { ClientsRepository } from '../repositories/clients.repository'; +import { ClientAgentOpenAiApiKeysService } from './client-agent-openai-api-keys.service'; import { ClientsService } from './clients.service'; import { StatisticsService } from './statistics.service'; +export type CreateClientAgentResultDto = CreateAgentResponseDto & { openAiApiKey?: string }; + /** * Service for proxying agent management requests to client endpoints. * Handles authentication (API key or Keycloak JWT) and forwards requests to the client's agent-manager service. @@ -28,6 +31,7 @@ export class ClientAgentProxyService { private readonly clientsService: ClientsService, private readonly clientsRepository: ClientsRepository, private readonly clientAgentCredentialsService: ClientAgentCredentialsService, + private readonly clientAgentOpenAiApiKeysService: ClientAgentOpenAiApiKeysService, private readonly statisticsService: StatisticsService, ) {} @@ -214,7 +218,7 @@ export class ClientAgentProxyService { clientId: string, createAgentDto: CreateAgentDto, userId?: string, - ): Promise { + ): Promise { const result = await this.makeRequest(clientId, { method: 'POST', data: createAgentDto, @@ -223,7 +227,12 @@ export class ClientAgentProxyService { if (result?.id && result?.password) { await this.clientAgentCredentialsService.saveCredentials(clientId, result.id, result.password); } + let openAiApiKey: string | undefined; if (result?.id) { + const issued = await this.clientAgentOpenAiApiKeysService.issueKeyForNewAgent(clientId, result.id); + if (issued) { + openAiApiKey = issued; + } this.statisticsService .recordEntityCreated( StatisticsEntityType.AGENT, @@ -239,7 +248,7 @@ export class ClientAgentProxyService { ) .catch(() => undefined); } - return result; + return openAiApiKey ? { ...result, openAiApiKey } : result; } /** @@ -290,6 +299,7 @@ export class ClientAgentProxyService { }); // Cleanup stored credentials for this client/agent pair await this.clientAgentCredentialsService.deleteCredentials(clientId, agentId); + await this.clientAgentOpenAiApiKeysService.deleteForAgent(clientId, agentId); } /** diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/openai/openai-agent-ws-proxy.service.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/openai/openai-agent-ws-proxy.service.ts new file mode 100644 index 00000000..c5f5b210 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/openai/openai-agent-ws-proxy.service.ts @@ -0,0 +1,362 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import { randomUUID } from 'crypto'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { AuthenticationType, ClientAgentCredentialsRepository } from '@forepath/identity/backend'; +import type { Socket } from 'socket.io-client'; +import { ClientsRepository } from '../../repositories/clients.repository'; +import { ClientsService } from '../clients.service'; +import { agentResponseToPlainText, parseAgentChatMessage, parseChatEventEnvelope } from './openai-socket-payload.utils'; + +const REMOTE_CONNECT_TIMEOUT_MS = 15000; +const LOGIN_TIMEOUT_MS = 10000; +const IDLE_RESOLVE_MS = 750; + +@Injectable() +export class OpenAiAgentWsProxyService { + private readonly logger = new Logger(OpenAiAgentWsProxyService.name); + + constructor( + private readonly clientsRepository: ClientsRepository, + private readonly clientsService: ClientsService, + private readonly clientAgentCredentialsRepository: ClientAgentCredentialsRepository, + ) {} + + private async getAuthHeader(clientId: string): Promise { + const clientEntity = await this.clientsRepository.findByIdOrThrow(clientId); + if (clientEntity.authenticationType === AuthenticationType.API_KEY) { + if (!clientEntity.apiKey) { + throw new BadRequestException('API key is not configured for this client'); + } + return `Bearer ${clientEntity.apiKey}`; + } + if (clientEntity.authenticationType === AuthenticationType.KEYCLOAK) { + const token = await this.clientsService.getAccessToken(clientId); + return `Bearer ${token}`; + } + throw new BadRequestException(`Unsupported authentication type: ${clientEntity.authenticationType}`); + } + + private buildAgentsWsUrl(endpoint: string, overridePort?: number): string { + const url = new URL(endpoint); + const effectivePort = (overridePort && String(overridePort)) || process.env.CLIENTS_REMOTE_WS_PORT || '8080'; + const protocol = url.protocol === 'https:' ? 'https' : 'http'; + const host = url.hostname; + return `${protocol}://${host}:${effectivePort}/agents`; + } + + private async connectRemote(clientId: string): Promise<{ remote: Socket; cleanup: () => void }> { + const client = await this.clientsRepository.findByIdOrThrow(clientId); + const authHeader = await this.getAuthHeader(clientId); + const remoteUrl = this.buildAgentsWsUrl(client.endpoint, client.agentWsPort); + const { io } = require('socket.io-client'); + const remote: Socket = io(remoteUrl, { + transports: ['websocket'], + extraHeaders: { Authorization: authHeader }, + rejectUnauthorized: false, + reconnection: false, + }); + + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new BadRequestException('Timed out connecting to agent-manager WebSocket')); + }, REMOTE_CONNECT_TIMEOUT_MS); + remote.once('connect', () => { + clearTimeout(timer); + resolve(); + }); + remote.once('connect_error', (err: Error) => { + clearTimeout(timer); + reject(new BadRequestException(`Remote WebSocket connection failed: ${err.message}`)); + }); + }); + + const cleanup = (): void => { + try { + remote.removeAllListeners(); + remote.disconnect(); + } catch { + // ignore + } + }; + + return { remote, cleanup }; + } + + private async loginRemote(remote: Socket, agentId: string, password: string): Promise { + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new BadRequestException('Timed out during agent WebSocket login')); + }, LOGIN_TIMEOUT_MS); + const onOk = (): void => { + clearTimeout(timer); + remote.off('loginError', onErr); + resolve(); + }; + const onErr = (payload: unknown): void => { + clearTimeout(timer); + remote.off('loginSuccess', onOk); + const msg = + payload && typeof payload === 'object' && 'error' in (payload as object) + ? String((payload as { error?: { message?: string } }).error?.message || 'Login failed') + : 'Login failed'; + reject(new BadRequestException(msg)); + }; + remote.once('loginSuccess', onOk); + remote.once('loginError', onErr); + remote.emit('login', { agentId, password }); + }); + } + + /** + * Non-streaming: aggregates agent output for the given correlation id, then resolves after idle gap. + */ + async completeNonStream(params: { + clientId: string; + agentId: string; + userText: string; + model?: string; + correlationId: string; + }): Promise { + const creds = await this.clientAgentCredentialsRepository.findByClientAndAgent(params.clientId, params.agentId); + if (!creds?.password) { + throw new BadRequestException('No stored agent credentials for this agent; OpenAI bridge unavailable'); + } + + const { remote, cleanup } = await this.connectRemote(params.clientId); + try { + await this.loginRemote(remote, params.agentId, creds.password); + + const result = await new Promise((resolve, reject) => { + const globalTimer = setTimeout( + () => { + teardown(); + reject(new BadRequestException('OpenAI bridge request timed out')); + }, + parseInt(process.env.REQUEST_TIMEOUT || '600000', 10), + ); + + let aggregated = ''; + let idleTimer: NodeJS.Timeout | undefined; + + const teardown = (): void => { + clearTimeout(globalTimer); + if (idleTimer) { + clearTimeout(idleTimer); + } + remote.off('chatMessage', onChatMessage); + remote.off('chatEvent', onChatEvent); + remote.off('error', onSocketError); + }; + + const scheduleIdleResolve = (): void => { + if (idleTimer) { + clearTimeout(idleTimer); + } + idleTimer = setTimeout(() => { + teardown(); + const out = aggregated.trim(); + if (!out) { + reject(new BadRequestException('Empty response from agent')); + } else { + resolve(out); + } + }, IDLE_RESOLVE_MS); + }; + + const onChatEvent = (...args: unknown[]): void => { + const env = parseChatEventEnvelope(args[0]); + if (!env || env.correlationId !== params.correlationId) { + return; + } + if (env.kind === 'error' && env.payload?.message) { + teardown(); + reject(new BadRequestException(String(env.payload.message))); + return; + } + if (env.kind === 'assistantDelta' && typeof env.payload?.delta === 'string') { + aggregated += env.payload.delta; + scheduleIdleResolve(); + } + if (env.kind === 'assistantMessage' && typeof env.payload?.text === 'string') { + aggregated = env.payload.text; + scheduleIdleResolve(); + } + }; + + const onChatMessage = (...args: unknown[]): void => { + const msg = parseAgentChatMessage(args[0]); + if (msg?.from !== 'agent') { + return; + } + const piece = msg.text ?? agentResponseToPlainText(msg.response); + if (piece) { + aggregated = aggregated ? `${aggregated}\n\n${piece}` : piece; + scheduleIdleResolve(); + } + }; + + const onSocketError = (err: unknown): void => { + teardown(); + const message = err instanceof Error ? err.message : 'WebSocket error'; + reject(new BadRequestException(message)); + }; + + remote.on('chatEvent', onChatEvent); + remote.on('chatMessage', onChatMessage); + remote.on('error', onSocketError); + + remote.emit('chat', { + message: params.userText, + model: params.model, + correlationId: params.correlationId, + responseMode: 'single', + }); + }); + + return result; + } catch (e) { + this.logger.warn(`OpenAI WS proxy (non-stream) failed: ${(e as Error).message}`); + throw e; + } finally { + cleanup(); + } + } + + /** + * Streaming: yields text deltas from assistantDelta events; completes after idle following last delta or final chatMessage. + */ + async *completeStream(params: { + clientId: string; + agentId: string; + userText: string; + model?: string; + correlationId: string; + }): AsyncGenerator { + const creds = await this.clientAgentCredentialsRepository.findByClientAndAgent(params.clientId, params.agentId); + if (!creds?.password) { + throw new BadRequestException('No stored agent credentials for this agent; OpenAI bridge unavailable'); + } + + const { remote, cleanup } = await this.connectRemote(params.clientId); + const queue: string[] = []; + let done = false; + let error: Error | undefined; + let notify: (() => void) | undefined; + + const waitChunk = async (): Promise => + new Promise((resolve) => { + if (queue.length > 0) { + resolve(queue.shift() ?? null); + return; + } + if (done) { + resolve(null); + return; + } + notify = (): void => { + notify = undefined; + resolve(queue.shift() ?? null); + }; + }); + + try { + await this.loginRemote(remote, params.agentId, creds.password); + + let idleTimer: NodeJS.Timeout | undefined; + const finish = (): void => { + if (idleTimer) { + clearTimeout(idleTimer); + } + done = true; + notify?.(); + }; + + const bumpIdle = (): void => { + if (idleTimer) { + clearTimeout(idleTimer); + } + idleTimer = setTimeout(() => { + finish(); + }, IDLE_RESOLVE_MS); + }; + + const onChatEvent = (...args: unknown[]): void => { + const env = parseChatEventEnvelope(args[0]); + if (!env || env.correlationId !== params.correlationId) { + return; + } + if (env.kind === 'error' && env.payload?.message) { + error = new BadRequestException(String(env.payload.message)); + finish(); + return; + } + if (env.kind === 'assistantDelta' && typeof env.payload?.delta === 'string') { + queue.push(env.payload.delta); + notify?.(); + bumpIdle(); + } + if (env.kind === 'assistantMessage' && typeof env.payload?.text === 'string') { + queue.push(env.payload.text); + notify?.(); + bumpIdle(); + } + }; + + const onChatMessage = (...args: unknown[]): void => { + const msg = parseAgentChatMessage(args[0]); + if (msg?.from !== 'agent') { + return; + } + const piece = msg.text ?? agentResponseToPlainText(msg.response); + if (piece) { + queue.push(piece); + notify?.(); + bumpIdle(); + } + }; + + remote.on('chatEvent', onChatEvent); + remote.on('chatMessage', onChatMessage); + + const hardTimeout = setTimeout( + () => { + error = new BadRequestException('OpenAI bridge stream timed out'); + finish(); + }, + parseInt(process.env.REQUEST_TIMEOUT || '600000', 10), + ); + + remote.emit('chat', { + message: params.userText, + model: params.model, + correlationId: params.correlationId, + responseMode: 'stream', + }); + + bumpIdle(); + + while (!done || queue.length > 0) { + if (error) { + throw error; + } + const next = await waitChunk(); + if (next) { + yield next; + } else if (done) { + break; + } + } + + clearTimeout(hardTimeout); + if (error) { + throw error; + } + } finally { + cleanup(); + } + } + + static newCorrelationId(): string { + return randomUUID(); + } +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/openai/openai-prompt.utils.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/openai/openai-prompt.utils.ts new file mode 100644 index 00000000..73520d33 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/openai/openai-prompt.utils.ts @@ -0,0 +1,70 @@ +import { BadRequestException } from '@nestjs/common'; + +export function formatChatMessagesForAgent(messages: Array<{ role: string; content: string }>): string { + return messages + .map((m) => { + const role = (m.role || 'user').toUpperCase(); + return `${role}:\n${m.content}`; + }) + .join('\n\n'); +} + +export function normalizeOpenAiPrompt(prompt: unknown): string { + if (typeof prompt === 'string') { + return prompt; + } + if (Array.isArray(prompt)) { + const parts = prompt.filter((p): p is string => typeof p === 'string'); + if (parts.length !== prompt.length) { + throw new BadRequestException('prompt array must contain only strings'); + } + return parts.join(''); + } + throw new BadRequestException('prompt must be a string or array of strings'); +} + +/** + * Best-effort extraction of text from OpenAI Responses API `input` (string or list of content items). + */ +export function normalizeResponsesInput(input: unknown): string { + if (typeof input === 'string') { + return input; + } + if (!Array.isArray(input)) { + throw new BadRequestException('input must be a string or array'); + } + const chunks: string[] = []; + for (const item of input) { + if (typeof item === 'string') { + chunks.push(item); + continue; + } + if (item && typeof item === 'object') { + const o = item as Record; + if (typeof o.text === 'string') { + chunks.push(o.text); + continue; + } + if (typeof o.content === 'string') { + chunks.push(o.content); + continue; + } + if (Array.isArray(o.content)) { + for (const part of o.content) { + if (typeof part === 'string') { + chunks.push(part); + } else if (part && typeof part === 'object') { + const p = part as Record; + if (typeof p.text === 'string') { + chunks.push(p.text); + } + } + } + } + } + } + if (chunks.length === 0) { + throw new BadRequestException('Could not extract text from input'); + } + return chunks.join('\n'); +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/openai/openai-response-shapes.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/openai/openai-response-shapes.ts new file mode 100644 index 00000000..be911490 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/openai/openai-response-shapes.ts @@ -0,0 +1,157 @@ +import { randomUUID } from 'crypto'; + +export function openAiUnixTimestamp(): number { + return Math.floor(Date.now() / 1000); +} + +export function newOpenAiId(prefix: string): string { + return `${prefix}_${randomUUID().replace(/-/g, '').slice(0, 24)}`; +} + +export function buildChatCompletionChunk(params: { + id: string; + created: number; + model: string; + delta: Record; + finishReason?: string | null; +}): Record { + return { + id: params.id, + object: 'chat.completion.chunk', + created: params.created, + model: params.model, + choices: [ + { + index: 0, + delta: params.delta, + finish_reason: params.finishReason ?? null, + }, + ], + }; +} + +export function buildChatCompletion(params: { + id: string; + created: number; + model: string; + content: string; +}): Record { + return { + id: params.id, + object: 'chat.completion', + created: params.created, + model: params.model, + choices: [ + { + index: 0, + message: { role: 'assistant', content: params.content, refusal: null }, + finish_reason: 'stop', + logprobs: null, + }, + ], + usage: { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + }, + }; +} + +export function buildTextCompletion(params: { + id: string; + created: number; + model: string; + text: string; +}): Record { + return { + id: params.id, + object: 'text_completion', + created: params.created, + model: params.model, + choices: [ + { + text: params.text, + index: 0, + finish_reason: 'stop', + logprobs: null, + }, + ], + usage: { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + }, + }; +} + +export function buildTextCompletionChunk(params: { + id: string; + created: number; + model: string; + text: string; + finishReason?: string | null; +}): Record { + return { + id: params.id, + object: 'text_completion', + created: params.created, + model: params.model, + choices: [ + { + text: params.text, + index: 0, + finish_reason: params.finishReason ?? null, + logprobs: null, + }, + ], + }; +} + +export function buildModelsList(params: { modelIds: string[] }): Record { + const created = openAiUnixTimestamp(); + return { + object: 'list', + data: params.modelIds.map((id) => ({ + id, + object: 'model', + created, + owned_by: 'agenstra-agent', + })), + }; +} + +export function buildResponsesObject(params: { + id: string; + created: number; + model: string; + text: string; +}): Record { + return { + id: params.id, + object: 'response', + created_at: params.created, + model: params.model, + status: 'completed', + output_text: params.text, + output: [ + { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: params.text }], + }, + ], + }; +} + +export function buildResponsesStreamChunk(params: { + id: string; + model: string; + delta: string; +}): Record { + return { + type: 'response.output_text.delta', + response_id: params.id, + model: params.model, + delta: params.delta, + }; +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/services/openai/openai-socket-payload.utils.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/openai/openai-socket-payload.utils.ts new file mode 100644 index 00000000..2bb6d003 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/services/openai/openai-socket-payload.utils.ts @@ -0,0 +1,68 @@ +/** + * Unwraps agent-manager Socket.IO payloads shaped as `{ success, data }` or raw. + */ +export function unwrapSuccessData>(raw: unknown): T | null { + if (!raw || typeof raw !== 'object') { + return null; + } + const o = raw as Record; + if (o.success === true && o.data && typeof o.data === 'object') { + return o.data as T; + } + return raw as T; +} + +export function parseChatEventEnvelope(raw: unknown): { + correlationId?: string; + kind?: string; + payload?: Record; +} | null { + const data = unwrapSuccessData>(raw); + if (!data) { + return null; + } + const correlationId = typeof data.correlationId === 'string' ? data.correlationId : undefined; + const kind = typeof data.kind === 'string' ? data.kind : undefined; + const payload = + data.payload && typeof data.payload === 'object' ? (data.payload as Record) : undefined; + return { correlationId, kind, payload }; +} + +export function parseAgentChatMessage(raw: unknown): { + from?: string; + response?: unknown; + text?: string; +} | null { + const data = unwrapSuccessData>(raw); + if (!data) { + return null; + } + const from = typeof data.from === 'string' ? data.from : undefined; + const text = typeof data.text === 'string' ? data.text : undefined; + const response = data.response; + return { from, response, text }; +} + +export function agentResponseToPlainText(response: unknown): string { + if (response === null || response === undefined) { + return ''; + } + if (typeof response === 'string') { + return response; + } + if (typeof response === 'object') { + const o = response as Record; + if (typeof o.result === 'string') { + return o.result; + } + if (typeof o.text === 'string') { + return o.text; + } + try { + return JSON.stringify(response); + } catch { + return String(response); + } + } + return String(response); +} diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/openai-api-key-hash.spec.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/openai-api-key-hash.spec.ts new file mode 100644 index 00000000..1e431a02 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/openai-api-key-hash.spec.ts @@ -0,0 +1,16 @@ +import { generateOpenAiStyleApiKey, hashOpenAiApiKey } from './openai-api-key-hash'; + +describe('openai-api-key-hash', () => { + it('hashOpenAiApiKey should be stable for same input', () => { + const k = 'test-key'; + expect(hashOpenAiApiKey(k)).toBe(hashOpenAiApiKey(k)); + expect(hashOpenAiApiKey(k)).toHaveLength(64); + }); + + it('generateOpenAiStyleApiKey should produce unique values', () => { + const a = generateOpenAiStyleApiKey(); + const b = generateOpenAiStyleApiKey(); + expect(a).not.toEqual(b); + expect(a.startsWith('agenstra_oai_')).toBe(true); + }); +}); diff --git a/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/openai-api-key-hash.ts b/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/openai-api-key-hash.ts new file mode 100644 index 00000000..31833361 --- /dev/null +++ b/libs/domains/framework/backend/feature-agent-controller/src/lib/utils/openai-api-key-hash.ts @@ -0,0 +1,17 @@ +import { createHash, randomBytes } from 'crypto'; + +const KEY_PREFIX = 'agenstra_oai_'; + +/** + * Generate a new opaque OpenAI-style API key (prefix + random base64url). + */ +export function generateOpenAiStyleApiKey(): string { + return `${KEY_PREFIX}${randomBytes(32).toString('base64url')}`; +} + +/** + * SHA-256 hex digest of the UTF-8 key string. + */ +export function hashOpenAiApiKey(rawKey: string): string { + return createHash('sha256').update(rawKey, 'utf8').digest('hex'); +}