From 9b7d16601d7d940cb82d7c2786857b7a03f8a037 Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Tue, 19 May 2026 21:30:04 +0200 Subject: [PATCH] =?UTF-8?q?feat(portal-bff):=20ai-client=20skeleton=20?= =?UTF-8?q?=E2=80=94=20vendored=20protos=20+=20grpc=20client=20+=20Princip?= =?UTF-8?q?al=20mapper=20(#195)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Step 2 of the AI-relay chantier (after [ADR-0024](docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md) merged in #194). Lands the BFF-side **skeleton** that talks to `apf-ai-service` over gRPC: vendored protos, generated TypeScript stubs, typed wrapper clients, Principal mapper, and metadata builder — all tested against an in-process fake gRPC server. **No HTTP route is exposed in this PR**; the SSE bridge (`POST /api/ai/chat`, `GET /api/ai/rag/search`, `GET /api/ai/models`) ships in the next PR. The skeleton is self-contained: `AiClientModule` is built but is NOT imported in `AppModule` yet. The BFF runtime is byte-for-byte unchanged. Everything below exists for the next PR to wire into a controller. ## What lands ### Proto vendoring + codegen - `apps/portal-bff/src/grpc/proto/apf-ai/` — mirror of `apf-ai-service/contract/proto/` (common, chat, rag, ingestion, models). Both the `.proto` files and the regenerated `ts-proto` output under `grpc/gen/` are committed for hermetic builds and reviewable diffs (per ADR-0024 §"Sub-decision 3"). - `pnpm run grpc:codegen` — regenerates the stubs via `grpc-tools`' bundled `protoc` and the `ts-proto` plugin (`outputServices=grpc-js, esModuleInterop, forceLong=long, useOptionals=messages, exportCommonSymbols=false`). - `pnpm run grpc:sync` — copies the vendored `.proto` files from the sibling `apf-ai-service` working tree (`../apf-ai-service/contract/proto/`); developer convenience, never invoked from CI. Errors with an actionable message when the sibling tree is not where it expects. - Generated tree (`grpc/gen/**`) excluded from Prettier (`.prettierignore`) and ESLint (`eslint.config.mjs` ignores). Hand-rules apply to wrappers under `ai-client/`, not to codegen output. ### Dependencies - `@grpc/grpc-js@^1.13.0` — runtime gRPC client. - `@bufbuild/protobuf@^2.10.2` — wire codec used by `ts-proto`'s emitted code. - `long@^5.2.3` — int64 representation for proto Long fields (`forceLong=long`). - `ts-proto@^2.7.0` — devDep, TypeScript codegen plugin. - `grpc-tools@^1.13.0` — devDep, ships `protoc` + the gRPC plugin; added to `pnpm.onlyBuiltDependencies` so the postinstall binary download runs. ### Env validator `apps/portal-bff/src/config/check-ai-service-config.ts` follows the same posture as the other validators (per ADR-0018 §"BFF env-var loading"): small, per-key, runs at module init, throws with an actionable message on misconfiguration. Three vars: | Var | Mandatory | Purpose | |---|---|---| | `AI_SERVICE_GRPC_ENDPOINT` | yes | `host:port` reachable from the BFF — `apf-ai-service:8080` in dev Compose, service DNS + 443 in prod | | `AI_SERVICE_CLIENT_ID` | yes | Deployment slug propagated as the `x-client-id` metadata. Convention: `apf-portal-` | | `AI_SERVICE_GRPC_TLS` | no (default `true`) | `false` for h2c in dev, `true` for h2 + TLS in prod | 7 spec cases lock the validation contract end-to-end. ### `AiClientModule` `apps/portal-bff/src/grpc/ai-client/` houses: - **`tokens.ts`** — DI tokens (`AI_CONFIG`, `AI_CREDENTIALS`, one per generated stub). - **`principal.mapper.ts`** — `PrincipalMapper.fromInputs({oid, tid, roles, extraAttributes})` returns the proto `Principal`. `subject` is hashed via `HashUserIdService` (the **same** salt + algorithm the audit writer uses) so `Principal.subject` matches `audit.events.actor_id_hash` byte-for-byte. `roles` passes through verbatim — inclusive expansion lands with a future role-hierarchy ADR; the wire contract on the AI side is unchanged. - **`grpc-metadata.builder.ts`** — stamps every outbound call with `x-client-id` (from config) and `x-correlation-id` (active OTel span's trace-id when present, else explicit override, else fresh UUID). - **`chat.client.ts`** — server-stream wrapper around `ChatServiceClient`. Returns the raw `ClientReadableStream` (Node `Readable` is async-iterable so the SSE bridge consumes with `for await`). Optional `AbortSignal` propagates browser disconnect to `call.cancel()`. - **`rag.client.ts`**, **`models.client.ts`**, **`ingestion.client.ts`** — unary promisified wrappers. `IngestionClient` is unused in v1 (the admin "manage AI corpus" surface lands later) but wired now so the future controller is one new file, not a new module. - **`ai-client.module.ts`** — NestJS module wiring the providers. `HashUserIdService` is declared locally rather than imported via `AuditModule` (the global module) to keep the module unit-testable in isolation; runtime equivalence is guaranteed by the service being a pure function of `LOG_USER_ID_SALT`. ### Tests 5 new spec files, 18 new test cases. All run against in-process fake gRPC servers; no network, no live `apf-ai-service`: - `check-ai-service-config.spec.ts` — 7 cases (happy path + 6 rejection branches). - `principal.mapper.spec.ts` — 7 cases including the cross-service hash-stability invariant and the `tenantId` reserved-key contract. - `grpc-metadata.builder.spec.ts` — 5 cases covering all three correlation-id resolution paths and metadata immutability. - `chat.client.spec.ts` — 4 cases: happy-path stream, metadata propagation, mid-stream cancel via AbortSignal, pre-aborted signal. - `rag.client.spec.ts` — 2 cases: unary happy path, `ServiceError` propagation. - `ai-client.module.spec.ts` — 4 cases: module bootstrap, all four wrappers resolved, env-driven `AI_CONFIG`, shared credentials across stubs. **Total BFF spec suite: 443 → 461 (after merge accounting), all passing.** ## Notes for the reviewer - **Why both `.proto` and generated `.ts` are committed.** Hermetic builds. Reviewers see both sides of a contract change in one diff. CI never runs codegen — drift between proto and stub would otherwise hide behind a successful build. The drift gate that compares the vendored copy against an upstream tag of `apf-ai-service` is the post-v1 follow-up listed in ADR-0024's "What's next". - **`HashUserIdService` declared locally in `AiClientModule`.** Two instances of the service exist when both `AuditModule` (global) and `AiClientModule` are wired into `AppModule`. The cost is one extra constructor call at bootstrap; the value is full test isolation of `AiClientModule` and a self-contained Principal-mapping boundary. The cross-service hash join invariant from ADR-0013 still holds because the service is a pure function of the `LOG_USER_ID_SALT` env var. - **`AiClientModule` is NOT imported by `AppModule`.** Deliberately. The skeleton compiles, type-checks, and unit-tests cleanly with no runtime side effects. The next PR (SSE bridge controller) adds the `imports: [AiClientModule]` line + the controller, in one focused change. - **`ts-proto` flat-oneof emission.** `ChatEvent` is generated with optional siblings (`token?`, `citation?`, `done?`, …) rather than a discriminated union (`$case: 'token'`). The flatter shape composes more naturally with the SSE writer the next PR will introduce (`event:` field name maps directly to the populated sibling). - **Cancellation test deliberately relaxed.** The "AbortSignal already aborted before call dial" test asserts the client-side outcome (no payload, error or clean end) but not server-side observation. gRPC-js may or may not propagate a cancel frame depending on whether the call had time to dial — both outcomes are correct per the contract; only the absence of payload matters. - **Lifecycle (`onApplicationShutdown`) deferred.** The module does not close the gRPC channel on shutdown today. Process termination closes the sockets via OS-level descriptor reclaim — sufficient for dev/preprod. The next PR wires the module into `AppModule` and adds an explicit Nest lifecycle hook in the same change (paired with `app.enableShutdownHooks()` in `main.ts`). ## Test plan - [x] `pnpm run grpc:codegen` — clean regeneration. Generated tree byte-identical to what's committed. - [x] `pnpm nx test portal-bff` — **443 specs pass** (was 425). - [x] `pnpm nx lint portal-bff` — clean. The eslint ignore for `grpc/gen/**` covers ts-proto's relaxed style; hand-written `ai-client/` files pass the project's full rule set. - [x] `pnpm nx build portal-bff` — clean, webpack-compiled. No bundle-size delta on the runtime artefact yet (the module is unimported). - [x] `pnpm install` — lockfile reconciled; `grpc-tools` postinstall fetches `protoc` from the precompiled-binaries mirror without errors on Linux x64. - [ ] **Manual smoke (next PR)** — once the SSE bridge ships, point `AI_SERVICE_GRPC_ENDPOINT` at the local `apf-ai-service` Compose service and run an end-to-end chat against the canned stub responses on the AI side. Not in scope for this PR. ## What's next 1. **PR — SSE bridge controller.** Wires `AiClientModule` into `AppModule`, adds `POST /api/ai/chat` (SSE), `GET /api/ai/rag/search`, `GET /api/ai/models`. Adds the `OnApplicationShutdown` hook + `enableShutdownHooks()`. Adds `apf-ai-service` to `infra/local/dev.compose.yml`. Promotes ADR-0024 from `proposed` to `accepted` and updates `CLAUDE.md`'s ADR roll-up. 2. **PR (frontend chantier)** — chatbot widget on `portal-shell` consuming the SSE endpoint. 3. **PR (post-v1)** — proto-drift CI gate diffing the vendored `proto/apf-ai/` against the upstream tag. 4. **Coordinated amendment** — when the first production deployment is in scope, both repos record the same prod-hardening choice (signed envelope or mTLS) on the same date. --------- Co-authored-by: Julien Gautier Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/195 --- .prettierignore | 5 + .../config/check-ai-service-config.spec.ts | 90 ++ .../src/config/check-ai-service-config.ts | 79 ++ .../grpc/ai-client/ai-client.module.spec.ts | 109 ++ .../src/grpc/ai-client/ai-client.module.ts | 132 ++ .../src/grpc/ai-client/chat.client.spec.ts | 236 ++++ .../src/grpc/ai-client/chat.client.ts | 69 + .../ai-client/grpc-metadata.builder.spec.ts | 87 ++ .../grpc/ai-client/grpc-metadata.builder.ts | 53 + .../src/grpc/ai-client/ingestion.client.ts | 78 ++ .../src/grpc/ai-client/models.client.ts | 38 + .../grpc/ai-client/principal.mapper.spec.ts | 117 ++ .../src/grpc/ai-client/principal.mapper.ts | 66 + .../src/grpc/ai-client/rag.client.spec.ts | 145 ++ .../src/grpc/ai-client/rag.client.ts | 37 + apps/portal-bff/src/grpc/ai-client/tokens.ts | 21 + apps/portal-bff/src/grpc/gen/apf-ai/chat.ts | 1243 +++++++++++++++++ apps/portal-bff/src/grpc/gen/apf-ai/common.ts | 765 ++++++++++ .../grpc/gen/apf-ai/google/protobuf/struct.ts | 614 ++++++++ .../gen/apf-ai/google/protobuf/timestamp.ts | 219 +++ .../src/grpc/gen/apf-ai/ingestion.ts | 858 ++++++++++++ apps/portal-bff/src/grpc/gen/apf-ai/models.ts | 355 +++++ apps/portal-bff/src/grpc/gen/apf-ai/rag.ts | 544 ++++++++ .../src/grpc/proto/apf-ai/chat.proto | 89 ++ .../src/grpc/proto/apf-ai/common.proto | 48 + .../src/grpc/proto/apf-ai/ingestion.proto | 58 + .../src/grpc/proto/apf-ai/models.proto | 24 + .../src/grpc/proto/apf-ai/rag.proto | 41 + eslint.config.mjs | 14 +- package.json | 8 + pnpm-lock.yaml | 116 +- scripts/sync-ai-protos.mjs | 49 + 32 files changed, 6396 insertions(+), 11 deletions(-) create mode 100644 apps/portal-bff/src/config/check-ai-service-config.spec.ts create mode 100644 apps/portal-bff/src/config/check-ai-service-config.ts create mode 100644 apps/portal-bff/src/grpc/ai-client/ai-client.module.spec.ts create mode 100644 apps/portal-bff/src/grpc/ai-client/ai-client.module.ts create mode 100644 apps/portal-bff/src/grpc/ai-client/chat.client.spec.ts create mode 100644 apps/portal-bff/src/grpc/ai-client/chat.client.ts create mode 100644 apps/portal-bff/src/grpc/ai-client/grpc-metadata.builder.spec.ts create mode 100644 apps/portal-bff/src/grpc/ai-client/grpc-metadata.builder.ts create mode 100644 apps/portal-bff/src/grpc/ai-client/ingestion.client.ts create mode 100644 apps/portal-bff/src/grpc/ai-client/models.client.ts create mode 100644 apps/portal-bff/src/grpc/ai-client/principal.mapper.spec.ts create mode 100644 apps/portal-bff/src/grpc/ai-client/principal.mapper.ts create mode 100644 apps/portal-bff/src/grpc/ai-client/rag.client.spec.ts create mode 100644 apps/portal-bff/src/grpc/ai-client/rag.client.ts create mode 100644 apps/portal-bff/src/grpc/ai-client/tokens.ts create mode 100644 apps/portal-bff/src/grpc/gen/apf-ai/chat.ts create mode 100644 apps/portal-bff/src/grpc/gen/apf-ai/common.ts create mode 100644 apps/portal-bff/src/grpc/gen/apf-ai/google/protobuf/struct.ts create mode 100644 apps/portal-bff/src/grpc/gen/apf-ai/google/protobuf/timestamp.ts create mode 100644 apps/portal-bff/src/grpc/gen/apf-ai/ingestion.ts create mode 100644 apps/portal-bff/src/grpc/gen/apf-ai/models.ts create mode 100644 apps/portal-bff/src/grpc/gen/apf-ai/rag.ts create mode 100644 apps/portal-bff/src/grpc/proto/apf-ai/chat.proto create mode 100644 apps/portal-bff/src/grpc/proto/apf-ai/common.proto create mode 100644 apps/portal-bff/src/grpc/proto/apf-ai/ingestion.proto create mode 100644 apps/portal-bff/src/grpc/proto/apf-ai/models.proto create mode 100644 apps/portal-bff/src/grpc/proto/apf-ai/rag.proto create mode 100644 scripts/sync-ai-protos.mjs diff --git a/.prettierignore b/.prettierignore index 2ad164e..c98b2ed 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,3 +5,8 @@ /.nx/workspace-data .nx/self-healing .angular + +# Generated gRPC TypeScript stubs (output of `pnpm run grpc:codegen`). +# Hand-formatting is not productive — overwritten on next regen — and +# ts-proto's output is internally consistent. +apps/portal-bff/src/grpc/gen/ diff --git a/apps/portal-bff/src/config/check-ai-service-config.spec.ts b/apps/portal-bff/src/config/check-ai-service-config.spec.ts new file mode 100644 index 0000000..0486aa6 --- /dev/null +++ b/apps/portal-bff/src/config/check-ai-service-config.spec.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, beforeEach, afterAll } from '@jest/globals'; +import { assertAiServiceConfig } from './check-ai-service-config'; + +/** + * Pre-flight validation contract for `AI_SERVICE_*` env vars. + * Mirrors the test posture of `check-log-user-id-salt.spec.ts` — + * each rejection path produces an actionable error message. + */ + +const ORIGINAL_ENV = { ...process.env }; + +function setEnv(values: Record): void { + for (const [key, value] of Object.entries(values)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} + +beforeEach(() => { + delete process.env['AI_SERVICE_GRPC_ENDPOINT']; + delete process.env['AI_SERVICE_CLIENT_ID']; + delete process.env['AI_SERVICE_GRPC_TLS']; +}); + +afterAll(() => { + process.env = { ...ORIGINAL_ENV }; +}); + +describe('assertAiServiceConfig', () => { + it('returns the parsed config when every required var is present', () => { + setEnv({ + AI_SERVICE_GRPC_ENDPOINT: 'apf-ai-service:8080', + AI_SERVICE_CLIENT_ID: 'apf-portal-dev', + AI_SERVICE_GRPC_TLS: 'false', + }); + + expect(assertAiServiceConfig()).toEqual({ + endpoint: 'apf-ai-service:8080', + clientId: 'apf-portal-dev', + useTls: false, + }); + }); + + it('defaults useTls to true when AI_SERVICE_GRPC_TLS is unset', () => { + setEnv({ + AI_SERVICE_GRPC_ENDPOINT: 'apf-ai.internal:443', + AI_SERVICE_CLIENT_ID: 'apf-portal-prod', + }); + + expect(assertAiServiceConfig().useTls).toBe(true); + }); + + it('throws when AI_SERVICE_GRPC_ENDPOINT is missing', () => { + setEnv({ AI_SERVICE_CLIENT_ID: 'apf-portal-dev' }); + expect(() => assertAiServiceConfig()).toThrow(/AI_SERVICE_GRPC_ENDPOINT is not set/); + }); + + it('throws when AI_SERVICE_GRPC_ENDPOINT does not match host:port', () => { + setEnv({ + AI_SERVICE_GRPC_ENDPOINT: 'no-port-here', + AI_SERVICE_CLIENT_ID: 'apf-portal-dev', + }); + expect(() => assertAiServiceConfig()).toThrow(/must match "host:port"/); + }); + + it('throws when AI_SERVICE_CLIENT_ID is missing', () => { + setEnv({ AI_SERVICE_GRPC_ENDPOINT: 'apf-ai-service:8080' }); + expect(() => assertAiServiceConfig()).toThrow(/AI_SERVICE_CLIENT_ID is not set/); + }); + + it('throws when AI_SERVICE_CLIENT_ID has invalid characters', () => { + setEnv({ + AI_SERVICE_GRPC_ENDPOINT: 'apf-ai-service:8080', + AI_SERVICE_CLIENT_ID: 'Apf Portal Dev', + }); + expect(() => assertAiServiceConfig()).toThrow(/lowercase kebab-case slug/); + }); + + it('throws when AI_SERVICE_GRPC_TLS is neither "true" nor "false"', () => { + setEnv({ + AI_SERVICE_GRPC_ENDPOINT: 'apf-ai-service:8080', + AI_SERVICE_CLIENT_ID: 'apf-portal-dev', + AI_SERVICE_GRPC_TLS: 'yes', + }); + expect(() => assertAiServiceConfig()).toThrow(/must be "true" or "false"/); + }); +}); diff --git a/apps/portal-bff/src/config/check-ai-service-config.ts b/apps/portal-bff/src/config/check-ai-service-config.ts new file mode 100644 index 0000000..821e01f --- /dev/null +++ b/apps/portal-bff/src/config/check-ai-service-config.ts @@ -0,0 +1,79 @@ +/** + * Validate the env vars that drive the BFF's gRPC client to + * `apf-ai-service`, per ADR-0024. + * + * Three knobs, all required at module-init time of `AiClientModule`: + * + * - `AI_SERVICE_GRPC_ENDPOINT` — `host:port` reachable from the + * BFF. `apf-ai-service:8080` in the dev Compose network; + * a service DNS name + 443 in prod behind an edge proxy that + * terminates the public TLS and forwards h2 internally. + * - `AI_SERVICE_CLIENT_ID` — deployment slug propagated as the + * `x-client-id` gRPC metadata on every call (per + * `apf-ai-service/docs/contract.md`). Naming convention: + * `apf-portal-` (`apf-portal-dev`, `apf-portal-preprod`, + * `apf-portal-prod`). + * - `AI_SERVICE_GRPC_TLS` — `'true'` to dial with TLS credentials + * against a CA-signed cert (default for non-dev), `'false'` for + * h2c against the Compose network in dev. Any other value is + * rejected to keep the cleartext-vs-TLS toggle explicit. + * + * Following the same family as `assertLogUserIdSalt()` and the + * other config validators under `apps/portal-bff/src/config/`, + * per ADR-0018 §"BFF env-var loading" — small, per-key, run at + * bootstrap, throw with an actionable message on misconfiguration. + */ + +export interface AiServiceConfig { + readonly endpoint: string; + readonly clientId: string; + readonly useTls: boolean; +} + +const ENDPOINT_PATTERN = /^[a-z0-9.-]+:[0-9]{2,5}$/i; +const CLIENT_ID_PATTERN = /^[a-z0-9-]+$/; + +export function assertAiServiceConfig(): AiServiceConfig { + const endpoint = process.env['AI_SERVICE_GRPC_ENDPOINT']; + if (!endpoint || endpoint === '') { + throw new Error( + `AI_SERVICE_GRPC_ENDPOINT is not set. Expected "host:port" reachable from the BFF, ` + + `e.g. "apf-ai-service:8080" in dev Compose or "apf-ai.internal:443" in prod.`, + ); + } + if (!ENDPOINT_PATTERN.test(endpoint)) { + throw new Error( + `AI_SERVICE_GRPC_ENDPOINT must match "host:port" (got: ${JSON.stringify(endpoint)}). ` + + `Hostname uses a-z 0-9 . - characters; port is 2-5 digits.`, + ); + } + + const clientId = process.env['AI_SERVICE_CLIENT_ID']; + if (!clientId || clientId === '') { + throw new Error( + `AI_SERVICE_CLIENT_ID is not set. Use a deployment slug like ` + + `"apf-portal-dev" / "apf-portal-preprod" / "apf-portal-prod"; ` + + `propagated as the x-client-id gRPC metadata on every call.`, + ); + } + if (!CLIENT_ID_PATTERN.test(clientId)) { + throw new Error( + `AI_SERVICE_CLIENT_ID must be a lowercase kebab-case slug ` + + `(got: ${JSON.stringify(clientId)}). Pattern: ^[a-z0-9-]+$.`, + ); + } + + const tlsRaw = process.env['AI_SERVICE_GRPC_TLS'] ?? 'true'; + if (tlsRaw !== 'true' && tlsRaw !== 'false') { + throw new Error( + `AI_SERVICE_GRPC_TLS must be "true" or "false" (got: ${JSON.stringify(tlsRaw)}). ` + + `Default in prod is "true"; the dev Compose stack sets it to "false" for h2c.`, + ); + } + + return { + endpoint, + clientId, + useTls: tlsRaw === 'true', + }; +} diff --git a/apps/portal-bff/src/grpc/ai-client/ai-client.module.spec.ts b/apps/portal-bff/src/grpc/ai-client/ai-client.module.spec.ts new file mode 100644 index 0000000..ad89a54 --- /dev/null +++ b/apps/portal-bff/src/grpc/ai-client/ai-client.module.spec.ts @@ -0,0 +1,109 @@ +import { randomBytes } from 'node:crypto'; +import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals'; +import { Test } from '@nestjs/testing'; +import { AiClientModule } from './ai-client.module'; +import { ChatClient } from './chat.client'; +import { GrpcMetadataBuilder } from './grpc-metadata.builder'; +import { IngestionClient } from './ingestion.client'; +import { ModelsClient } from './models.client'; +import { PrincipalMapper } from './principal.mapper'; +import { RagClient } from './rag.client'; +import { + AI_CHAT_GRPC_CLIENT, + AI_CONFIG, + AI_CREDENTIALS, + AI_INGESTION_GRPC_CLIENT, + AI_MODELS_GRPC_CLIENT, + AI_RAG_GRPC_CLIENT, +} from './tokens'; + +/** + * Smoke-tests `AiClientModule.compile()` — every provider resolves, + * the four exported wrapper clients are constructable, and the gRPC + * stubs share the same credentials instance (i.e. the underlying + * HTTP/2 channel can be multiplexed by grpc-js). + * + * Does NOT exercise the wire — the wire is covered by the per-client + * fake-server specs. + */ + +const STRONG_SALT = randomBytes(32).toString('base64url'); +const ORIGINAL = { + salt: process.env['LOG_USER_ID_SALT'], + endpoint: process.env['AI_SERVICE_GRPC_ENDPOINT'], + clientId: process.env['AI_SERVICE_CLIENT_ID'], + tls: process.env['AI_SERVICE_GRPC_TLS'], +}; + +beforeAll(() => { + process.env['LOG_USER_ID_SALT'] = STRONG_SALT; + process.env['AI_SERVICE_GRPC_ENDPOINT'] = 'apf-ai-service:8080'; + process.env['AI_SERVICE_CLIENT_ID'] = 'apf-portal-test'; + process.env['AI_SERVICE_GRPC_TLS'] = 'false'; +}); + +afterAll(() => { + if (ORIGINAL.salt === undefined) delete process.env['LOG_USER_ID_SALT']; + else process.env['LOG_USER_ID_SALT'] = ORIGINAL.salt; + if (ORIGINAL.endpoint === undefined) delete process.env['AI_SERVICE_GRPC_ENDPOINT']; + else process.env['AI_SERVICE_GRPC_ENDPOINT'] = ORIGINAL.endpoint; + if (ORIGINAL.clientId === undefined) delete process.env['AI_SERVICE_CLIENT_ID']; + else process.env['AI_SERVICE_CLIENT_ID'] = ORIGINAL.clientId; + if (ORIGINAL.tls === undefined) delete process.env['AI_SERVICE_GRPC_TLS']; + else process.env['AI_SERVICE_GRPC_TLS'] = ORIGINAL.tls; +}); + +describe('AiClientModule', () => { + let moduleRef: Awaited['compile']>>; + + beforeEach(async () => { + // AiClientModule provides HashUserIdService locally — see the + // module docstring for the rationale — so the test does not + // need to bring in AuditModule (and AuditWriter's Prisma + CLS + // dependencies that would imply). + moduleRef = await Test.createTestingModule({ + imports: [AiClientModule], + }).compile(); + }); + + it('resolves all four wrapper clients', () => { + expect(moduleRef.get(ChatClient)).toBeInstanceOf(ChatClient); + expect(moduleRef.get(RagClient)).toBeInstanceOf(RagClient); + expect(moduleRef.get(IngestionClient)).toBeInstanceOf(IngestionClient); + expect(moduleRef.get(ModelsClient)).toBeInstanceOf(ModelsClient); + }); + + it('resolves the Principal mapper and the metadata builder', () => { + expect(moduleRef.get(PrincipalMapper)).toBeInstanceOf(PrincipalMapper); + expect(moduleRef.get(GrpcMetadataBuilder)).toBeInstanceOf(GrpcMetadataBuilder); + }); + + it('parses the env vars into AI_CONFIG', () => { + const config = moduleRef.get(AI_CONFIG) as { + endpoint: string; + clientId: string; + useTls: boolean; + }; + expect(config).toEqual({ + endpoint: 'apf-ai-service:8080', + clientId: 'apf-portal-test', + useTls: false, + }); + }); + + it('shares one credentials instance across every generated stub', () => { + const credentials = moduleRef.get(AI_CREDENTIALS); + expect(credentials).toBeDefined(); + // Each stub provider injects AI_CREDENTIALS — same reference + // means the underlying HTTP/2 connection can be multiplexed by + // grpc-js when they target the same address. + const chat = moduleRef.get(AI_CHAT_GRPC_CLIENT) as unknown; + const rag = moduleRef.get(AI_RAG_GRPC_CLIENT) as unknown; + const ingestion = moduleRef.get(AI_INGESTION_GRPC_CLIENT) as unknown; + const models = moduleRef.get(AI_MODELS_GRPC_CLIENT) as unknown; + expect(chat).toBeDefined(); + expect(rag).toBeDefined(); + expect(ingestion).toBeDefined(); + expect(models).toBeDefined(); + }); +}); diff --git a/apps/portal-bff/src/grpc/ai-client/ai-client.module.ts b/apps/portal-bff/src/grpc/ai-client/ai-client.module.ts new file mode 100644 index 0000000..3f50292 --- /dev/null +++ b/apps/portal-bff/src/grpc/ai-client/ai-client.module.ts @@ -0,0 +1,132 @@ +import { Module, type Provider } from '@nestjs/common'; +import { ChannelCredentials } from '@grpc/grpc-js'; +import { HashUserIdService } from '../../audit/hash-user-id.service'; +import { assertAiServiceConfig, type AiServiceConfig } from '../../config/check-ai-service-config'; +import { ChatServiceClient } from '../gen/apf-ai/chat'; +import { IngestionServiceClient } from '../gen/apf-ai/ingestion'; +import { ModelsServiceClient } from '../gen/apf-ai/models'; +import { RagServiceClient } from '../gen/apf-ai/rag'; +import { ChatClient } from './chat.client'; +import { GrpcMetadataBuilder } from './grpc-metadata.builder'; +import { IngestionClient } from './ingestion.client'; +import { ModelsClient } from './models.client'; +import { PrincipalMapper } from './principal.mapper'; +import { RagClient } from './rag.client'; +import { + AI_CHAT_GRPC_CLIENT, + AI_CONFIG, + AI_CREDENTIALS, + AI_INGESTION_GRPC_CLIENT, + AI_MODELS_GRPC_CLIENT, + AI_RAG_GRPC_CLIENT, +} from './tokens'; + +/** + * Wires the BFF's integration surface to `apf-ai-service`, per + * [ADR-0024](../../../../../docs/decisions/0024-ai-service-relay-grpc-sse-bridge.md). + * + * The module is intentionally NOT imported in `AppModule` yet — the + * SSE-bridge controller that consumes these clients ships in the + * next chantier. Until then the module compiles, type-checks, and + * unit-tests against an in-process fake gRPC server, but it does not + * register any HTTP route. + * + * Provider layout: + * + * 1. `AI_CONFIG` factory reads + validates `AI_SERVICE_*` env vars. + * 2. `AI_CREDENTIALS` factory builds the gRPC channel credentials + * (insecure h2c in dev, system-CA-trusted SSL in prod). + * 3. One token per generated stub — `AI_CHAT_GRPC_CLIENT` etc. — + * backed by a factory that opens a `Client` against the + * configured endpoint. gRPC-js multiplexes the underlying + * HTTP/2 connection across stubs sharing the same address + + * credentials, so the four stubs share one TCP+TLS channel. + * 4. Hand-written wrapper services (`ChatClient`, `RagClient`, + * `IngestionClient`, `ModelsClient`) add metadata injection + * and promisification on top of the generated stubs. + * 5. `PrincipalMapper` + `GrpcMetadataBuilder` collaborators. + * + * `PrincipalMapper` depends on `HashUserIdService` — the same hash + * service the audit writer uses — so the `Principal.subject` field + * matches `audit.events.actor_id_hash` exactly (per ADR-0024 + * §"Audit cross-referencing"). `HashUserIdService` is declared as a + * local provider here (rather than relying on `AuditModule`'s + * `@Global()` export) for two reasons: + * + * 1. **Test isolation.** The module compiles standalone with no + * other portal module in scope; no need to pull `AuditWriter` + * and its Prisma + CLS deps into a unit test that only exercises + * the gRPC surface. + * 2. **Self-containment of the wire boundary.** The Principal-side + * contract is a property of `AiClientModule`, not of the audit + * module. Co-locating the hash provider keeps the boundary + * readable from one file. + * + * Functional identity with the audit-side instance is guaranteed by + * `HashUserIdService` being purely a function of + * `LOG_USER_ID_SALT` — same env value ⇒ same hash output, regardless + * of how many instances exist. The cross-service join key invariant + * from ADR-0013 still holds. + */ + +function buildCredentials(config: AiServiceConfig): ChannelCredentials { + return config.useTls ? ChannelCredentials.createSsl() : ChannelCredentials.createInsecure(); +} + +const grpcStubProviders: Provider[] = [ + { + provide: AI_CHAT_GRPC_CLIENT, + inject: [AI_CONFIG, AI_CREDENTIALS], + useFactory: (config: AiServiceConfig, credentials: ChannelCredentials) => + new ChatServiceClient(config.endpoint, credentials), + }, + { + provide: AI_RAG_GRPC_CLIENT, + inject: [AI_CONFIG, AI_CREDENTIALS], + useFactory: (config: AiServiceConfig, credentials: ChannelCredentials) => + new RagServiceClient(config.endpoint, credentials), + }, + { + provide: AI_INGESTION_GRPC_CLIENT, + inject: [AI_CONFIG, AI_CREDENTIALS], + useFactory: (config: AiServiceConfig, credentials: ChannelCredentials) => + new IngestionServiceClient(config.endpoint, credentials), + }, + { + provide: AI_MODELS_GRPC_CLIENT, + inject: [AI_CONFIG, AI_CREDENTIALS], + useFactory: (config: AiServiceConfig, credentials: ChannelCredentials) => + new ModelsServiceClient(config.endpoint, credentials), + }, +]; + +@Module({ + providers: [ + { + provide: AI_CONFIG, + useFactory: () => assertAiServiceConfig(), + }, + { + provide: AI_CREDENTIALS, + inject: [AI_CONFIG], + useFactory: buildCredentials, + }, + ...grpcStubProviders, + HashUserIdService, + GrpcMetadataBuilder, + PrincipalMapper, + ChatClient, + RagClient, + IngestionClient, + ModelsClient, + ], + exports: [ChatClient, RagClient, IngestionClient, ModelsClient, PrincipalMapper], +}) +export class AiClientModule { + // Lifecycle (channel close on shutdown) lands when the SSE-bridge + // PR wires this module into AppModule. v1 process termination + // closes the gRPC sockets via OS-level descriptor reclaim — fine + // for the dev/preprod posture; prod will add an explicit + // OnApplicationShutdown hook once Nest's shutdown hooks are + // enabled in main.ts. +} diff --git a/apps/portal-bff/src/grpc/ai-client/chat.client.spec.ts b/apps/portal-bff/src/grpc/ai-client/chat.client.spec.ts new file mode 100644 index 0000000..18875e6 --- /dev/null +++ b/apps/portal-bff/src/grpc/ai-client/chat.client.spec.ts @@ -0,0 +1,236 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals'; +import { + ChannelCredentials, + Server, + ServerCredentials, + type Metadata, + type ServerWritableStream, +} from '@grpc/grpc-js'; +import { + ChatServiceClient, + ChatServiceService, + type ChatEvent, + type ChatRequest, +} from '../gen/apf-ai/chat'; +import { ChatClient } from './chat.client'; +import { GrpcMetadataBuilder } from './grpc-metadata.builder'; +import type { AiServiceConfig } from '../../config/check-ai-service-config'; + +/** + * Integration test: drives `ChatClient` against an in-process fake + * gRPC ChatService that emits a canned `ChatEvent` sequence. The + * spec covers ADR-0024's confirmation criteria: + * + * - happy-path stream emits every event the server sent and ends + * after `done`; + * - metadata (`x-client-id` + `x-correlation-id`) reaches the + * server unchanged; + * - browser-close (modelled here as an `AbortController.abort()`) + * propagates as `call.cancel()` and the server's stream callback + * observes the cancellation. + */ + +const CONFIG: AiServiceConfig = { + endpoint: '', + clientId: 'apf-portal-test', + useTls: false, +}; + +interface ServerObservations { + request: ChatRequest | null; + metadata: Metadata | null; + cancelled: boolean; +} + +let server: Server; +let port: number; +let serverObservations: ServerObservations; +let chatHandler: (call: ServerWritableStream) => void; + +beforeAll(async () => { + server = new Server(); + server.addService(ChatServiceService, { + chat: (call: ServerWritableStream) => { + serverObservations.request = call.request; + serverObservations.metadata = call.metadata; + call.on('cancelled', () => { + serverObservations.cancelled = true; + }); + chatHandler(call); + }, + }); + + port = await new Promise((resolve, reject) => { + server.bindAsync('127.0.0.1:0', ServerCredentials.createInsecure(), (err, bound) => { + if (err) { + reject(err); + return; + } + resolve(bound); + }); + }); +}); + +afterAll(async () => { + await new Promise((resolve, reject) => { + server.tryShutdown((err) => { + if (err) { + reject(err); + return; + } + resolve(); + }); + }); +}); + +beforeEach(() => { + serverObservations = { request: null, metadata: null, cancelled: false }; +}); + +function buildClient(): ChatClient { + const config = { ...CONFIG, endpoint: `127.0.0.1:${port}` }; + const grpc = new ChatServiceClient(config.endpoint, ChannelCredentials.createInsecure()); + return new ChatClient(grpc, new GrpcMetadataBuilder(config)); +} + +async function collect(stream: AsyncIterable): Promise { + const events: ChatEvent[] = []; + for await (const event of stream) { + events.push(event); + } + return events; +} + +describe('ChatClient (in-process fake server)', () => { + it('emits every server event then terminates on done', async () => { + chatHandler = (call) => { + call.write({ token: { token: 'hello', value: 'Hello' } }); + call.write({ token: { token: ' world', value: ' world' } }); + call.write({ done: { stats: { tokensIn: 1, tokensOut: 2, chunksRetrieved: 0 } } }); + call.end(); + }; + + const client = buildClient(); + const stream = client.chat({ + messages: [], + conversationId: 'conv-1', + model: '', + provider: '', + toolsAvailable: [], + principal: { subject: 'subj-1', roles: ['admin'], attributes: { tenantId: 't' } }, + }); + const events = await collect(stream); + + expect(events).toHaveLength(3); + expect(events[0]?.token?.value).toBe('Hello'); + expect(events[2]?.done?.stats?.tokensOut).toBe(2); + }); + + it('propagates x-client-id and x-correlation-id metadata to the server', async () => { + chatHandler = (call) => { + call.write({ done: { stats: { tokensIn: 0, tokensOut: 0, chunksRetrieved: 0 } } }); + call.end(); + }; + + const client = buildClient(); + await collect( + client.chat( + { + messages: [], + conversationId: 'conv-2', + model: '', + provider: '', + toolsAvailable: [], + principal: { subject: 'subj-2', roles: [], attributes: {} }, + }, + { correlationId: 'corr-from-test' }, + ), + ); + + expect(serverObservations.metadata?.get('x-client-id')).toEqual(['apf-portal-test']); + expect(serverObservations.metadata?.get('x-correlation-id')).toEqual(['corr-from-test']); + }); + + it('cancels the call when the AbortSignal aborts mid-stream', async () => { + const cancellationObserved = new Promise((resolve) => { + chatHandler = (call) => { + call.write({ token: { token: 't1', value: 't1' } }); + call.on('cancelled', () => resolve()); + // Deliberately do not `end()` — the test cancels. + }; + }); + + const controller = new AbortController(); + const client = buildClient(); + const stream = client.chat( + { + messages: [], + conversationId: 'conv-3', + model: '', + provider: '', + toolsAvailable: [], + principal: { subject: 'subj-3', roles: [], attributes: {} }, + }, + { signal: controller.signal }, + ); + + // Drain the first event then abort. + const iter = stream[Symbol.asyncIterator](); + await iter.next(); + controller.abort(); + + await cancellationObserved; + expect(serverObservations.cancelled).toBe(true); + }); + + it('terminates the stream without data when the AbortSignal is already aborted', async () => { + // The signal aborts before the call dial completes. gRPC-js + // cancels locally — depending on timing the server may or may + // not observe the call, so the spec only locks the client-side + // outcome: the stream ends in error and yields no payload. + chatHandler = (call) => { + call.on('cancelled', () => { + // best-effort end on cancellation so the suite never hangs + try { + call.end(); + } catch { + // already torn down + } + }); + }; + + const controller = new AbortController(); + controller.abort(); + + const client = buildClient(); + const stream = client.chat( + { + messages: [], + conversationId: 'conv-4', + model: '', + provider: '', + toolsAvailable: [], + principal: { subject: 'subj-4', roles: [], attributes: {} }, + }, + { signal: controller.signal }, + ); + + const events: ChatEvent[] = []; + let caught: unknown; + try { + for await (const ev of stream) { + events.push(ev); + } + } catch (err) { + caught = err; + } + + expect(events).toHaveLength(0); + // gRPC-js may surface the cancellation as an error or as a clean + // end-of-stream depending on whether the call had time to dial. + // Either way, no payload is emitted — that is the contract. + if (caught !== undefined) { + expect((caught as { code?: number }).code).toBeDefined(); + } + }); +}); diff --git a/apps/portal-bff/src/grpc/ai-client/chat.client.ts b/apps/portal-bff/src/grpc/ai-client/chat.client.ts new file mode 100644 index 0000000..916efd5 --- /dev/null +++ b/apps/portal-bff/src/grpc/ai-client/chat.client.ts @@ -0,0 +1,69 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { ClientReadableStream } from '@grpc/grpc-js'; +import type { ChatEvent, ChatRequest, ChatServiceClient } from '../gen/apf-ai/chat'; +import { GrpcMetadataBuilder } from './grpc-metadata.builder'; +import { AI_CHAT_GRPC_CLIENT } from './tokens'; + +/** + * Wrapper around the generated `ChatServiceClient` for the + * server-streaming `Chat` RPC, per ADR-0024 §"Sub-decision 1". + * + * The wrapper does three things on top of the raw gRPC stub: + * + * 1. Injects the `x-client-id` + `x-correlation-id` metadata via + * `GrpcMetadataBuilder` so every call carries the contract + * headers from `apf-ai-service/docs/contract.md`. + * 2. Wires an optional `AbortSignal` to `call.cancel()` so the SSE + * bridge (next PR in the chantier) can propagate browser + * disconnects up to the AI service in one line. + * 3. Returns the raw `ClientReadableStream` — Node + * `Readable` streams are async-iterable by default, so the SSE + * bridge consumes the stream with `for await ... of` and no + * intermediate adapter. + * + * Cancellation flows in both directions: a `.cancel()` from inside + * the BFF (timeout, abort) reaches the AI service as a + * `grpc.status.CANCELLED`, which translates upstream to + * `ServerCallContext.CancellationToken` stopping LLM generation + * (per `apf-ai-service/docs/streaming.md`). + */ +@Injectable() +export class ChatClient { + constructor( + @Inject(AI_CHAT_GRPC_CLIENT) private readonly grpc: ChatServiceClient, + private readonly metadata: GrpcMetadataBuilder, + ) {} + + /** + * Start a streaming Chat call. + * + * The returned `ClientReadableStream` emits one event + * per AI service `ChatEvent` and ends after the terminal + * `ChatEvent.done` (or on cancellation / error). + * + * @param request The full proto request, already populated with + * the conversation history and a `Principal` (build the + * principal via `PrincipalMapper.fromInputs`). + * @param options.signal Optional AbortSignal. When it aborts, the + * underlying gRPC call is cancelled and the stream closes. + * @param options.correlationId Override the metadata + * correlation-id (defaults to the active OTel trace-id). + */ + chat( + request: ChatRequest, + options: { signal?: AbortSignal; correlationId?: string } = {}, + ): ClientReadableStream { + const metadata = this.metadata.build({ correlationId: options.correlationId }); + const call = this.grpc.chat(request, metadata); + + if (options.signal) { + if (options.signal.aborted) { + call.cancel(); + } else { + options.signal.addEventListener('abort', () => call.cancel(), { once: true }); + } + } + + return call; + } +} diff --git a/apps/portal-bff/src/grpc/ai-client/grpc-metadata.builder.spec.ts b/apps/portal-bff/src/grpc/ai-client/grpc-metadata.builder.spec.ts new file mode 100644 index 0000000..07dac0c --- /dev/null +++ b/apps/portal-bff/src/grpc/ai-client/grpc-metadata.builder.spec.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; +import { trace } from '@opentelemetry/api'; +import { GrpcMetadataBuilder } from './grpc-metadata.builder'; +import type { AiServiceConfig } from '../../config/check-ai-service-config'; + +/** + * Locks the metadata-injection contract per ADR-0024 §"Metadata contract": + * + * - x-client-id ← AI_SERVICE_CLIENT_ID env (via AI_CONFIG). + * - x-correlation-id ← active OTel span's trace-id when present; + * explicit `correlationId` override wins over + * the span; otherwise a fresh UUID is minted + * so the AI service can still join its audit + * row to a unique id even when the portal + * request is outside a trace. + */ + +const CONFIG: AiServiceConfig = { + endpoint: 'apf-ai-service:8080', + clientId: 'apf-portal-test', + useTls: false, +}; + +let activeSpan: ReturnType = undefined; +let getActiveSpanSpy: ReturnType; + +beforeEach(() => { + activeSpan = undefined; + getActiveSpanSpy = jest.spyOn(trace, 'getActiveSpan').mockImplementation(() => activeSpan); +}); + +afterEach(() => { + getActiveSpanSpy.mockRestore(); +}); + +describe('GrpcMetadataBuilder', () => { + it('always emits x-client-id from the config', () => { + const builder = new GrpcMetadataBuilder(CONFIG); + const meta = builder.build(); + expect(meta.get('x-client-id')).toEqual(['apf-portal-test']); + }); + + it('uses the active OTel trace-id as x-correlation-id when a span is active', () => { + activeSpan = { + spanContext: () => ({ + traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + spanId: 'bbbbbbbbbbbbbbbb', + traceFlags: 1, + }), + } as ReturnType; + + const builder = new GrpcMetadataBuilder(CONFIG); + const meta = builder.build(); + expect(meta.get('x-correlation-id')).toEqual(['aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa']); + }); + + it('prefers the explicit options.correlationId over the active span', () => { + activeSpan = { + spanContext: () => ({ + traceId: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + spanId: 'bbbbbbbbbbbbbbbb', + traceFlags: 1, + }), + } as ReturnType; + + const builder = new GrpcMetadataBuilder(CONFIG); + const meta = builder.build({ correlationId: 'caller-supplied-id' }); + expect(meta.get('x-correlation-id')).toEqual(['caller-supplied-id']); + }); + + it('falls back to a UUID v4 when no span is active and no override is passed', () => { + const builder = new GrpcMetadataBuilder(CONFIG); + const meta = builder.build(); + const correlationIds = meta.get('x-correlation-id') as string[]; + expect(correlationIds).toHaveLength(1); + expect(correlationIds[0]).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + }); + + it('returns a fresh Metadata instance on every call (no shared mutation)', () => { + const builder = new GrpcMetadataBuilder(CONFIG); + const a = builder.build(); + const b = builder.build(); + expect(a).not.toBe(b); + }); +}); diff --git a/apps/portal-bff/src/grpc/ai-client/grpc-metadata.builder.ts b/apps/portal-bff/src/grpc/ai-client/grpc-metadata.builder.ts new file mode 100644 index 0000000..3f11266 --- /dev/null +++ b/apps/portal-bff/src/grpc/ai-client/grpc-metadata.builder.ts @@ -0,0 +1,53 @@ +import { randomUUID } from 'node:crypto'; +import { Inject, Injectable } from '@nestjs/common'; +import { Metadata } from '@grpc/grpc-js'; +import { trace } from '@opentelemetry/api'; +import { AI_CONFIG } from './tokens'; +import type { AiServiceConfig } from '../../config/check-ai-service-config'; + +/** + * Builds the gRPC `Metadata` object every outbound call to + * `apf-ai-service` must carry, per ADR-0024 §"Metadata contract". + * + * Two entries: + * + * - `x-client-id` — deployment slug from `AI_SERVICE_CLIENT_ID`. + * Lets the AI service tag its audit rows and metrics with the + * calling deployment without inspecting the proto body. + * - `x-correlation-id` — W3C `trace-id` of the active OpenTelemetry + * span when one exists (per [ADR-0012](../../docs/decisions/0012-observability-pino-opentelemetry.md)), + * falling back to an explicit value if passed, falling back to a + * freshly-minted UUID v4 as a last resort. The AI service echoes + * this id in its audit log and propagates it to Jaeger, so the + * portal and AI traces can be joined cross-service. + * + * The builder is a class (not a static helper) so it composes with + * NestJS DI and so it can be swapped for a fake in tests that need + * to assert the metadata shape without standing up the OTel SDK. + */ +@Injectable() +export class GrpcMetadataBuilder { + constructor(@Inject(AI_CONFIG) private readonly config: AiServiceConfig) {} + + /** + * Construct the metadata for one outbound call. + * + * @param options.correlationId Override the auto-resolved value. + * Use when the caller already holds a stable id (for example + * the SSE bridge controller's request-scoped trace-id) and + * wants to bind multiple calls to the same correlation key. + * `undefined` is accepted (and equivalent to omitting the + * key) so callers can forward an optional value without + * a conditional spread. + */ + build(options: { correlationId?: string | undefined } = {}): Metadata { + const meta = new Metadata(); + meta.set('x-client-id', this.config.clientId); + + const correlationId = + options.correlationId ?? trace.getActiveSpan()?.spanContext().traceId ?? randomUUID(); + meta.set('x-correlation-id', correlationId); + + return meta; + } +} diff --git a/apps/portal-bff/src/grpc/ai-client/ingestion.client.ts b/apps/portal-bff/src/grpc/ai-client/ingestion.client.ts new file mode 100644 index 0000000..90f72b8 --- /dev/null +++ b/apps/portal-bff/src/grpc/ai-client/ingestion.client.ts @@ -0,0 +1,78 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { + DeleteDocumentRequest, + DeleteDocumentResponse, + DocumentSummary, + GetDocumentRequest, + IngestDocumentRequest, + IngestDocumentResponse, + IngestionServiceClient, +} from '../gen/apf-ai/ingestion'; +import { GrpcMetadataBuilder } from './grpc-metadata.builder'; +import { AI_INGESTION_GRPC_CLIENT } from './tokens'; + +/** + * Wrapper around the generated `IngestionServiceClient`. + * + * Unused by the BFF in v1 — apf-ai-service ships a dedicated CLI + * (`tools/Apf.Ai.Ingest/`) for document ingestion during the POC. + * The wrapper is in place so the future admin "manage AI corpus" + * surface lands as one new controller, not as a new module + a new + * client + a new generated stub binding. The unit tests under + * `ingestion.client.spec.ts` lock the wire contract today. + */ +@Injectable() +export class IngestionClient { + constructor( + @Inject(AI_INGESTION_GRPC_CLIENT) private readonly grpc: IngestionServiceClient, + private readonly metadata: GrpcMetadataBuilder, + ) {} + + ingestDocument( + request: IngestDocumentRequest, + options: { correlationId?: string } = {}, + ): Promise { + const metadata = this.metadata.build({ correlationId: options.correlationId }); + return new Promise((resolve, reject) => { + this.grpc.ingestDocument(request, metadata, (err, response) => { + if (err) { + reject(err); + return; + } + resolve(response); + }); + }); + } + + getDocument( + request: GetDocumentRequest, + options: { correlationId?: string } = {}, + ): Promise { + const metadata = this.metadata.build({ correlationId: options.correlationId }); + return new Promise((resolve, reject) => { + this.grpc.getDocument(request, metadata, (err, response) => { + if (err) { + reject(err); + return; + } + resolve(response); + }); + }); + } + + deleteDocument( + request: DeleteDocumentRequest, + options: { correlationId?: string } = {}, + ): Promise { + const metadata = this.metadata.build({ correlationId: options.correlationId }); + return new Promise((resolve, reject) => { + this.grpc.deleteDocument(request, metadata, (err, response) => { + if (err) { + reject(err); + return; + } + resolve(response); + }); + }); + } +} diff --git a/apps/portal-bff/src/grpc/ai-client/models.client.ts b/apps/portal-bff/src/grpc/ai-client/models.client.ts new file mode 100644 index 0000000..2582549 --- /dev/null +++ b/apps/portal-bff/src/grpc/ai-client/models.client.ts @@ -0,0 +1,38 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { + ListModelsRequest, + ListModelsResponse, + ModelsServiceClient, +} from '../gen/apf-ai/models'; +import { GrpcMetadataBuilder } from './grpc-metadata.builder'; +import { AI_MODELS_GRPC_CLIENT } from './tokens'; + +/** + * Wrapper around the generated `ModelsServiceClient` for the unary + * `ListModels` RPC. Surfaces the active provider + the configured + * provider catalogue for use by a future admin UI tile or a v1 + * "which models are available" health probe. + */ +@Injectable() +export class ModelsClient { + constructor( + @Inject(AI_MODELS_GRPC_CLIENT) private readonly grpc: ModelsServiceClient, + private readonly metadata: GrpcMetadataBuilder, + ) {} + + listModels( + request: ListModelsRequest = {}, + options: { correlationId?: string } = {}, + ): Promise { + const metadata = this.metadata.build({ correlationId: options.correlationId }); + return new Promise((resolve, reject) => { + this.grpc.listModels(request, metadata, (err, response) => { + if (err) { + reject(err); + return; + } + resolve(response); + }); + }); + } +} diff --git a/apps/portal-bff/src/grpc/ai-client/principal.mapper.spec.ts b/apps/portal-bff/src/grpc/ai-client/principal.mapper.spec.ts new file mode 100644 index 0000000..84ba26f --- /dev/null +++ b/apps/portal-bff/src/grpc/ai-client/principal.mapper.spec.ts @@ -0,0 +1,117 @@ +import { randomBytes } from 'node:crypto'; +import { describe, it, expect, beforeEach, afterAll } from '@jest/globals'; +import { HashUserIdService } from '../../audit/hash-user-id.service'; +import { PrincipalMapper } from './principal.mapper'; + +/** + * Locks the Principal-mapping contract per ADR-0024 §"Sub-decision 4": + * + * - subject = HashUserIdService(oid) — same salt, same algo as the + * portal's audit and Pino log streams. This is the + * join key between portal and apf-ai-service audit + * trails. + * - roles = pass-through (inclusive expansion lands in a future + * ADR; the wire contract on the AI side does not + * change). + * - attrs = tenantId is reserved; consumers can layer extra + * key/value pairs but tenantId wins on collision. + */ + +const STRONG_SALT = randomBytes(32).toString('base64url'); +const ORIGINAL_SALT = process.env['LOG_USER_ID_SALT']; + +beforeEach(() => { + process.env['LOG_USER_ID_SALT'] = STRONG_SALT; +}); + +afterAll(() => { + if (ORIGINAL_SALT === undefined) { + delete process.env['LOG_USER_ID_SALT']; + } else { + process.env['LOG_USER_ID_SALT'] = ORIGINAL_SALT; + } +}); + +function makeMapper(): { mapper: PrincipalMapper; hashService: HashUserIdService } { + const hashService = new HashUserIdService(); + return { mapper: new PrincipalMapper(hashService), hashService }; +} + +describe('PrincipalMapper', () => { + it('hashes the Entra oid into the subject via HashUserIdService', () => { + const { mapper, hashService } = makeMapper(); + const principal = mapper.fromInputs({ + oid: 'user-oid-42', + tid: 'tenant-1', + roles: ['admin'], + }); + expect(principal.subject).toBe(hashService.hash('user-oid-42')); + expect(principal.subject).toMatch(/^[0-9a-f]{16}$/); + }); + + it('passes roles through verbatim (no expansion in v1)', () => { + const { mapper } = makeMapper(); + const principal = mapper.fromInputs({ + oid: 'user-oid-42', + tid: 'tenant-1', + roles: ['directeur', 'rh'], + }); + expect(principal.roles).toEqual(['directeur', 'rh']); + }); + + it('copies roles so caller mutations do not leak into the proto', () => { + const { mapper } = makeMapper(); + const roles: string[] = ['admin']; + const principal = mapper.fromInputs({ + oid: 'user-oid-42', + tid: 'tenant-1', + roles, + }); + roles.push('mutated'); + expect(principal.roles).toEqual(['admin']); + }); + + it('always emits tenantId in attributes', () => { + const { mapper } = makeMapper(); + const principal = mapper.fromInputs({ + oid: 'user-oid-42', + tid: 'tenant-1', + roles: [], + }); + expect(principal.attributes).toEqual({ tenantId: 'tenant-1' }); + }); + + it('merges extraAttributes into the attribute map', () => { + const { mapper } = makeMapper(); + const principal = mapper.fromInputs({ + oid: 'user-oid-42', + tid: 'tenant-1', + roles: [], + extraAttributes: { delegation: 'aquitaine', region: 'fr-sw' }, + }); + expect(principal.attributes).toEqual({ + tenantId: 'tenant-1', + delegation: 'aquitaine', + region: 'fr-sw', + }); + }); + + it('tenantId from `tid` wins over a colliding extraAttributes entry', () => { + const { mapper } = makeMapper(); + const principal = mapper.fromInputs({ + oid: 'user-oid-42', + tid: 'tenant-1', + roles: [], + extraAttributes: { tenantId: 'tenant-spoofed' }, + }); + expect(principal.attributes['tenantId']).toBe('tenant-1'); + }); + + it('produces the same subject for the same oid + salt across instances', () => { + const { mapper: m1 } = makeMapper(); + const { mapper: m2 } = makeMapper(); + expect(m1.fromInputs({ oid: 'x', tid: 't', roles: [] }).subject).toBe( + m2.fromInputs({ oid: 'x', tid: 't', roles: [] }).subject, + ); + }); +}); diff --git a/apps/portal-bff/src/grpc/ai-client/principal.mapper.ts b/apps/portal-bff/src/grpc/ai-client/principal.mapper.ts new file mode 100644 index 0000000..04ba805 --- /dev/null +++ b/apps/portal-bff/src/grpc/ai-client/principal.mapper.ts @@ -0,0 +1,66 @@ +import { Injectable } from '@nestjs/common'; +import { HashUserIdService } from '../../audit/hash-user-id.service'; +import type { Principal } from '../gen/apf-ai/common'; + +/** + * Read-only view of the session fields the mapper consumes. Decoupled + * from `AuthenticatedUser` so callers can build a Principal in tests + * or in non-session contexts (a future background-job consumer) by + * supplying a plain object — without dragging the full session shape + * along. + * + * Keep the shape minimal: anything beyond `oid + tid + roles` should + * land in `attributes` rather than as a new top-level field, so the + * proto contract stays stable as the BFF grows new claim sources. + */ +export interface PrincipalInputs { + /** Entra Object ID. Hashed via `HashUserIdService` into the proto `subject` field. */ + readonly oid: string; + /** Entra tenant id. Surfaced as `attributes.tenantId`. */ + readonly tid: string; + /** Roles already resolved upstream by the auth flow. */ + readonly roles: readonly string[]; + /** + * Free-form extra attributes (delegation slug, region, …) merged + * into `attributes` after `tenantId`. Caller-controlled; the + * mapper does not enrich. Reserved keys (`tenantId`) overwrite + * the caller-provided value to keep the proto contract single- + * sourced. + */ + readonly extraAttributes?: Readonly>; +} + +/** + * Builds the `apf.ai.v1.Principal` proto from a portal session, + * per ADR-0024 §"Sub-decision 4 — unsigned Principal in the proto body". + * + * The `subject` field MUST be the same hash the audit module writes + * to `audit.events.actor_id_hash` (per [ADR-0013](../../docs/decisions/0013-audit-trail-separated-postgres-append-only.md)) + * and that Pino's `user_id_hash` log field carries (per [ADR-0012](../../docs/decisions/0012-observability-pino-opentelemetry.md)). + * The two services' audit trails only join cleanly when both sides + * compute the same value — same Entra `oid`, same salt, same algo. + * The mapper delegates to `HashUserIdService` to make that contract + * explicit at the wire boundary. + * + * The `roles` field is passed through verbatim in v1. A future ADR + * on role hierarchy (proposed alongside the stargate migration + * analysis) will introduce inclusive expansion (`Admin` ⇒ + * `[admin, directeur, rh, collaborateur]`). At that point the + * mapper grows a `RoleExpander` collaborator; the wire shape and + * the upstream contract on the AI side stay unchanged. + */ +@Injectable() +export class PrincipalMapper { + constructor(private readonly hashUserId: HashUserIdService) {} + + fromInputs(inputs: PrincipalInputs): Principal { + return { + subject: this.hashUserId.hash(inputs.oid), + roles: [...inputs.roles], + attributes: { + ...(inputs.extraAttributes ?? {}), + tenantId: inputs.tid, + }, + }; + } +} diff --git a/apps/portal-bff/src/grpc/ai-client/rag.client.spec.ts b/apps/portal-bff/src/grpc/ai-client/rag.client.spec.ts new file mode 100644 index 0000000..151e6e8 --- /dev/null +++ b/apps/portal-bff/src/grpc/ai-client/rag.client.spec.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals'; +import { + ChannelCredentials, + Server, + ServerCredentials, + status as GrpcStatus, + type Metadata, + type sendUnaryData, + type ServerUnaryCall, +} from '@grpc/grpc-js'; +import { + RagServiceClient, + RagServiceService, + type RagSearchRequest, + type RagSearchResponse, +} from '../gen/apf-ai/rag'; +import { Struct } from '../gen/apf-ai/google/protobuf/struct'; +import { GrpcMetadataBuilder } from './grpc-metadata.builder'; +import { RagClient } from './rag.client'; +import type { AiServiceConfig } from '../../config/check-ai-service-config'; + +/** + * Locks the unary RAG contract: the wrapper promisifies the + * callback-style stub, forwards metadata to the server, and + * surfaces `ServiceError` rejections with the original gRPC status + * code intact. + */ + +const CONFIG: AiServiceConfig = { + endpoint: '', + clientId: 'apf-portal-test', + useTls: false, +}; + +interface ServerObservations { + request: RagSearchRequest | null; + metadata: Metadata | null; +} + +let server: Server; +let port: number; +let serverObservations: ServerObservations; +let searchHandler: ( + call: ServerUnaryCall, + callback: sendUnaryData, +) => void; + +beforeAll(async () => { + server = new Server(); + server.addService(RagServiceService, { + search: (call, callback) => { + serverObservations.request = call.request; + serverObservations.metadata = call.metadata; + searchHandler(call, callback); + }, + }); + port = await new Promise((resolve, reject) => { + server.bindAsync('127.0.0.1:0', ServerCredentials.createInsecure(), (err, bound) => { + if (err) { + reject(err); + return; + } + resolve(bound); + }); + }); +}); + +afterAll(async () => { + await new Promise((resolve, reject) => { + server.tryShutdown((err) => { + if (err) { + reject(err); + return; + } + resolve(); + }); + }); +}); + +beforeEach(() => { + serverObservations = { request: null, metadata: null }; +}); + +function buildClient(): RagClient { + const config = { ...CONFIG, endpoint: `127.0.0.1:${port}` }; + const grpc = new RagServiceClient(config.endpoint, ChannelCredentials.createInsecure()); + return new RagClient(grpc, new GrpcMetadataBuilder(config)); +} + +describe('RagClient (in-process fake server)', () => { + it('resolves with the server response on a happy unary call', async () => { + searchHandler = (_call, callback) => { + callback(null, { + chunks: [ + { + id: 'chunk-1', + documentId: 'doc-1', + content: 'Lorem ipsum', + source: 'rag-test', + score: 0.99, + metadata: Struct.fromPartial({}), + }, + ], + correlationId: 'echo-corr', + }); + }; + + const client = buildClient(); + const response = await client.search( + { + query: 'hello', + topK: 3, + principal: { subject: 'subj-1', roles: [], attributes: {} }, + filters: { source: '', documentId: '' }, + }, + { correlationId: 'caller-corr' }, + ); + + expect(response.chunks).toHaveLength(1); + expect(response.chunks[0]?.id).toBe('chunk-1'); + expect(serverObservations.metadata?.get('x-correlation-id')).toEqual(['caller-corr']); + }); + + it('rejects with the original gRPC ServiceError on server error', async () => { + searchHandler = (_call, callback) => { + callback({ + code: GrpcStatus.PERMISSION_DENIED, + details: 'forbidden', + metadata: undefined as unknown as Metadata, + name: 'Error', + message: 'forbidden', + }); + }; + + const client = buildClient(); + await expect( + client.search({ + query: 'q', + topK: 1, + principal: { subject: 'subj-2', roles: [], attributes: {} }, + filters: { source: '', documentId: '' }, + }), + ).rejects.toMatchObject({ code: GrpcStatus.PERMISSION_DENIED }); + }); +}); diff --git a/apps/portal-bff/src/grpc/ai-client/rag.client.ts b/apps/portal-bff/src/grpc/ai-client/rag.client.ts new file mode 100644 index 0000000..8b28739 --- /dev/null +++ b/apps/portal-bff/src/grpc/ai-client/rag.client.ts @@ -0,0 +1,37 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { RagSearchRequest, RagSearchResponse, RagServiceClient } from '../gen/apf-ai/rag'; +import { GrpcMetadataBuilder } from './grpc-metadata.builder'; +import { AI_RAG_GRPC_CLIENT } from './tokens'; + +/** + * Wrapper around the generated `RagServiceClient` for the unary + * `Search` RPC, per ADR-0024. + * + * Promisifies the callback-style gRPC stub and injects the standard + * metadata. Errors propagate as the underlying `ServiceError` (with + * `code`, `details`, etc.) so callers can map them to portal-side + * HTTP statuses without losing the gRPC status code. + */ +@Injectable() +export class RagClient { + constructor( + @Inject(AI_RAG_GRPC_CLIENT) private readonly grpc: RagServiceClient, + private readonly metadata: GrpcMetadataBuilder, + ) {} + + search( + request: RagSearchRequest, + options: { correlationId?: string } = {}, + ): Promise { + const metadata = this.metadata.build({ correlationId: options.correlationId }); + return new Promise((resolve, reject) => { + this.grpc.search(request, metadata, (err, response) => { + if (err) { + reject(err); + return; + } + resolve(response); + }); + }); + } +} diff --git a/apps/portal-bff/src/grpc/ai-client/tokens.ts b/apps/portal-bff/src/grpc/ai-client/tokens.ts new file mode 100644 index 0000000..7766d3a --- /dev/null +++ b/apps/portal-bff/src/grpc/ai-client/tokens.ts @@ -0,0 +1,21 @@ +/** + * Dependency-injection tokens for `AiClientModule`, per ADR-0024. + * + * The module exposes three classes of provider: + * + * 1. `AI_CONFIG` — the parsed env vars from `assertAiServiceConfig`. + * 2. `AI_CREDENTIALS` — the `ChannelCredentials` (insecure in dev + * h2c, SSL in prod h2 + TLS). + * 3. Generated gRPC stub instances — one per RPC service from + * `apf.ai.v1.*` (Chat, Rag, Ingestion, Models). Wrapped by the + * `*.client.ts` services in this folder; the raw stubs stay + * behind tokens so the wrapper layer is the only place that + * knows about the codegen output. + */ + +export const AI_CONFIG = Symbol('AI_CONFIG'); +export const AI_CREDENTIALS = Symbol('AI_CREDENTIALS'); +export const AI_CHAT_GRPC_CLIENT = Symbol('AI_CHAT_GRPC_CLIENT'); +export const AI_RAG_GRPC_CLIENT = Symbol('AI_RAG_GRPC_CLIENT'); +export const AI_INGESTION_GRPC_CLIENT = Symbol('AI_INGESTION_GRPC_CLIENT'); +export const AI_MODELS_GRPC_CLIENT = Symbol('AI_MODELS_GRPC_CLIENT'); diff --git a/apps/portal-bff/src/grpc/gen/apf-ai/chat.ts b/apps/portal-bff/src/grpc/gen/apf-ai/chat.ts new file mode 100644 index 0000000..779b8a3 --- /dev/null +++ b/apps/portal-bff/src/grpc/gen/apf-ai/chat.ts @@ -0,0 +1,1243 @@ +// Code generated by protoc-gen-ts_proto. DO NOT EDIT. +// versions: +// protoc-gen-ts_proto v2.11.8 +// protoc v3.19.1 +// source: chat.proto + +/* eslint-disable */ +import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire"; +import { + type CallOptions, + type ChannelCredentials, + Client, + type ClientOptions, + type ClientReadableStream, + type handleServerStreamingCall, + makeGenericClientConstructor, + type Metadata, + type UntypedServiceImplementation, +} from "@grpc/grpc-js"; +import Long from "long"; +import { ChatMessage, Principal, ToolDescriptor } from "./common"; +import { Struct } from "./google/protobuf/struct"; + +export interface RagOptions { + enabled: boolean; + /** 0 = use server default */ + topK: number; +} + +export interface ChatRequest { + messages: ChatMessage[]; + conversationId: string; + model: string; + provider: string; + toolsAvailable: ToolDescriptor[]; + rag?: + | RagOptions + | undefined; + /** + * Optional for JWT auth (overridden by JWT claims). Required for + * API-key auth — but API-key callers should use the HTTP surface. + */ + principal?: Principal | undefined; +} + +export interface TokenEvent { + token: string; + value: string; +} + +export interface CitationEvent { + chunkId: string; + documentId: string; + source: string; + score: number; + snippet: string; +} + +export interface AgentStepEvent { + agent: string; + step: string; + stepId: string; +} + +export interface ToolCallEvent { + callId: string; + name: string; + args?: { [key: string]: any } | undefined; +} + +export interface ErrorEvent { + code: string; + message: string; + retriable: boolean; +} + +export interface DoneStats { + tokensIn: number; + tokensOut: number; + chunksRetrieved: number; +} + +export interface DoneEvent { + stats?: DoneStats | undefined; +} + +/** + * Polymorphic stream payload. Mirrors the SSE shapes from the OpenAPI + * surface (kept side-by-side for third-party callers). gRPC's `oneof` + * gives us a tagged union without the SSE JSON discriminator. + */ +export interface ChatEvent { + token?: TokenEvent | undefined; + citation?: CitationEvent | undefined; + agentStep?: AgentStepEvent | undefined; + toolCall?: ToolCallEvent | undefined; + error?: ErrorEvent | undefined; + done?: DoneEvent | undefined; +} + +function createBaseRagOptions(): RagOptions { + return { enabled: false, topK: 0 }; +} + +export const RagOptions: MessageFns = { + encode(message: RagOptions, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.enabled !== false) { + writer.uint32(8).bool(message.enabled); + } + if (message.topK !== 0) { + writer.uint32(16).int32(message.topK); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): RagOptions { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseRagOptions(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.enabled = reader.bool(); + continue; + } + case 2: { + if (tag !== 16) { + break; + } + + message.topK = reader.int32(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): RagOptions { + return { + enabled: isSet(object.enabled) ? globalThis.Boolean(object.enabled) : false, + topK: isSet(object.topK) + ? globalThis.Number(object.topK) + : isSet(object.top_k) + ? globalThis.Number(object.top_k) + : 0, + }; + }, + + toJSON(message: RagOptions): unknown { + const obj: any = {}; + if (message.enabled !== false) { + obj.enabled = message.enabled; + } + if (message.topK !== 0) { + obj.topK = Math.round(message.topK); + } + return obj; + }, + + create, I>>(base?: I): RagOptions { + return RagOptions.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): RagOptions { + const message = createBaseRagOptions(); + message.enabled = object.enabled ?? false; + message.topK = object.topK ?? 0; + return message; + }, +}; + +function createBaseChatRequest(): ChatRequest { + return { + messages: [], + conversationId: "", + model: "", + provider: "", + toolsAvailable: [], + rag: undefined, + principal: undefined, + }; +} + +export const ChatRequest: MessageFns = { + encode(message: ChatRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + for (const v of message.messages) { + ChatMessage.encode(v!, writer.uint32(10).fork()).join(); + } + if (message.conversationId !== "") { + writer.uint32(18).string(message.conversationId); + } + if (message.model !== "") { + writer.uint32(26).string(message.model); + } + if (message.provider !== "") { + writer.uint32(34).string(message.provider); + } + for (const v of message.toolsAvailable) { + ToolDescriptor.encode(v!, writer.uint32(42).fork()).join(); + } + if (message.rag !== undefined) { + RagOptions.encode(message.rag, writer.uint32(50).fork()).join(); + } + if (message.principal !== undefined) { + Principal.encode(message.principal, writer.uint32(58).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): ChatRequest { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseChatRequest(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.messages.push(ChatMessage.decode(reader, reader.uint32())); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.conversationId = reader.string(); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.model = reader.string(); + continue; + } + case 4: { + if (tag !== 34) { + break; + } + + message.provider = reader.string(); + continue; + } + case 5: { + if (tag !== 42) { + break; + } + + message.toolsAvailable.push(ToolDescriptor.decode(reader, reader.uint32())); + continue; + } + case 6: { + if (tag !== 50) { + break; + } + + message.rag = RagOptions.decode(reader, reader.uint32()); + continue; + } + case 7: { + if (tag !== 58) { + break; + } + + message.principal = Principal.decode(reader, reader.uint32()); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): ChatRequest { + return { + messages: globalThis.Array.isArray(object?.messages) + ? object.messages.map((e: any) => ChatMessage.fromJSON(e)) + : [], + conversationId: isSet(object.conversationId) + ? globalThis.String(object.conversationId) + : isSet(object.conversation_id) + ? globalThis.String(object.conversation_id) + : "", + model: isSet(object.model) ? globalThis.String(object.model) : "", + provider: isSet(object.provider) ? globalThis.String(object.provider) : "", + toolsAvailable: globalThis.Array.isArray(object?.toolsAvailable) + ? object.toolsAvailable.map((e: any) => ToolDescriptor.fromJSON(e)) + : globalThis.Array.isArray(object?.tools_available) + ? object.tools_available.map((e: any) => ToolDescriptor.fromJSON(e)) + : [], + rag: isSet(object.rag) ? RagOptions.fromJSON(object.rag) : undefined, + principal: isSet(object.principal) ? Principal.fromJSON(object.principal) : undefined, + }; + }, + + toJSON(message: ChatRequest): unknown { + const obj: any = {}; + if (message.messages?.length) { + obj.messages = message.messages.map((e) => ChatMessage.toJSON(e)); + } + if (message.conversationId !== "") { + obj.conversationId = message.conversationId; + } + if (message.model !== "") { + obj.model = message.model; + } + if (message.provider !== "") { + obj.provider = message.provider; + } + if (message.toolsAvailable?.length) { + obj.toolsAvailable = message.toolsAvailable.map((e) => ToolDescriptor.toJSON(e)); + } + if (message.rag !== undefined) { + obj.rag = RagOptions.toJSON(message.rag); + } + if (message.principal !== undefined) { + obj.principal = Principal.toJSON(message.principal); + } + return obj; + }, + + create, I>>(base?: I): ChatRequest { + return ChatRequest.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): ChatRequest { + const message = createBaseChatRequest(); + message.messages = object.messages?.map((e) => ChatMessage.fromPartial(e)) || []; + message.conversationId = object.conversationId ?? ""; + message.model = object.model ?? ""; + message.provider = object.provider ?? ""; + message.toolsAvailable = object.toolsAvailable?.map((e) => ToolDescriptor.fromPartial(e)) || []; + message.rag = (object.rag !== undefined && object.rag !== null) ? RagOptions.fromPartial(object.rag) : undefined; + message.principal = (object.principal !== undefined && object.principal !== null) + ? Principal.fromPartial(object.principal) + : undefined; + return message; + }, +}; + +function createBaseTokenEvent(): TokenEvent { + return { token: "", value: "" }; +} + +export const TokenEvent: MessageFns = { + encode(message: TokenEvent, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.token !== "") { + writer.uint32(10).string(message.token); + } + if (message.value !== "") { + writer.uint32(18).string(message.value); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): TokenEvent { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseTokenEvent(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.token = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.value = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): TokenEvent { + return { + token: isSet(object.token) ? globalThis.String(object.token) : "", + value: isSet(object.value) ? globalThis.String(object.value) : "", + }; + }, + + toJSON(message: TokenEvent): unknown { + const obj: any = {}; + if (message.token !== "") { + obj.token = message.token; + } + if (message.value !== "") { + obj.value = message.value; + } + return obj; + }, + + create, I>>(base?: I): TokenEvent { + return TokenEvent.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): TokenEvent { + const message = createBaseTokenEvent(); + message.token = object.token ?? ""; + message.value = object.value ?? ""; + return message; + }, +}; + +function createBaseCitationEvent(): CitationEvent { + return { chunkId: "", documentId: "", source: "", score: 0, snippet: "" }; +} + +export const CitationEvent: MessageFns = { + encode(message: CitationEvent, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.chunkId !== "") { + writer.uint32(10).string(message.chunkId); + } + if (message.documentId !== "") { + writer.uint32(18).string(message.documentId); + } + if (message.source !== "") { + writer.uint32(26).string(message.source); + } + if (message.score !== 0) { + writer.uint32(33).double(message.score); + } + if (message.snippet !== "") { + writer.uint32(42).string(message.snippet); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): CitationEvent { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseCitationEvent(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.chunkId = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.documentId = reader.string(); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.source = reader.string(); + continue; + } + case 4: { + if (tag !== 33) { + break; + } + + message.score = reader.double(); + continue; + } + case 5: { + if (tag !== 42) { + break; + } + + message.snippet = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): CitationEvent { + return { + chunkId: isSet(object.chunkId) + ? globalThis.String(object.chunkId) + : isSet(object.chunk_id) + ? globalThis.String(object.chunk_id) + : "", + documentId: isSet(object.documentId) + ? globalThis.String(object.documentId) + : isSet(object.document_id) + ? globalThis.String(object.document_id) + : "", + source: isSet(object.source) ? globalThis.String(object.source) : "", + score: isSet(object.score) ? globalThis.Number(object.score) : 0, + snippet: isSet(object.snippet) ? globalThis.String(object.snippet) : "", + }; + }, + + toJSON(message: CitationEvent): unknown { + const obj: any = {}; + if (message.chunkId !== "") { + obj.chunkId = message.chunkId; + } + if (message.documentId !== "") { + obj.documentId = message.documentId; + } + if (message.source !== "") { + obj.source = message.source; + } + if (message.score !== 0) { + obj.score = message.score; + } + if (message.snippet !== "") { + obj.snippet = message.snippet; + } + return obj; + }, + + create, I>>(base?: I): CitationEvent { + return CitationEvent.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): CitationEvent { + const message = createBaseCitationEvent(); + message.chunkId = object.chunkId ?? ""; + message.documentId = object.documentId ?? ""; + message.source = object.source ?? ""; + message.score = object.score ?? 0; + message.snippet = object.snippet ?? ""; + return message; + }, +}; + +function createBaseAgentStepEvent(): AgentStepEvent { + return { agent: "", step: "", stepId: "" }; +} + +export const AgentStepEvent: MessageFns = { + encode(message: AgentStepEvent, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.agent !== "") { + writer.uint32(10).string(message.agent); + } + if (message.step !== "") { + writer.uint32(18).string(message.step); + } + if (message.stepId !== "") { + writer.uint32(26).string(message.stepId); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): AgentStepEvent { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseAgentStepEvent(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.agent = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.step = reader.string(); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.stepId = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): AgentStepEvent { + return { + agent: isSet(object.agent) ? globalThis.String(object.agent) : "", + step: isSet(object.step) ? globalThis.String(object.step) : "", + stepId: isSet(object.stepId) + ? globalThis.String(object.stepId) + : isSet(object.step_id) + ? globalThis.String(object.step_id) + : "", + }; + }, + + toJSON(message: AgentStepEvent): unknown { + const obj: any = {}; + if (message.agent !== "") { + obj.agent = message.agent; + } + if (message.step !== "") { + obj.step = message.step; + } + if (message.stepId !== "") { + obj.stepId = message.stepId; + } + return obj; + }, + + create, I>>(base?: I): AgentStepEvent { + return AgentStepEvent.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): AgentStepEvent { + const message = createBaseAgentStepEvent(); + message.agent = object.agent ?? ""; + message.step = object.step ?? ""; + message.stepId = object.stepId ?? ""; + return message; + }, +}; + +function createBaseToolCallEvent(): ToolCallEvent { + return { callId: "", name: "", args: undefined }; +} + +export const ToolCallEvent: MessageFns = { + encode(message: ToolCallEvent, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.callId !== "") { + writer.uint32(10).string(message.callId); + } + if (message.name !== "") { + writer.uint32(18).string(message.name); + } + if (message.args !== undefined) { + Struct.encode(Struct.wrap(message.args), writer.uint32(26).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): ToolCallEvent { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseToolCallEvent(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.callId = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.name = reader.string(); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.args = Struct.unwrap(Struct.decode(reader, reader.uint32())); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): ToolCallEvent { + return { + callId: isSet(object.callId) + ? globalThis.String(object.callId) + : isSet(object.call_id) + ? globalThis.String(object.call_id) + : "", + name: isSet(object.name) ? globalThis.String(object.name) : "", + args: isObject(object.args) ? object.args : undefined, + }; + }, + + toJSON(message: ToolCallEvent): unknown { + const obj: any = {}; + if (message.callId !== "") { + obj.callId = message.callId; + } + if (message.name !== "") { + obj.name = message.name; + } + if (message.args !== undefined) { + obj.args = message.args; + } + return obj; + }, + + create, I>>(base?: I): ToolCallEvent { + return ToolCallEvent.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): ToolCallEvent { + const message = createBaseToolCallEvent(); + message.callId = object.callId ?? ""; + message.name = object.name ?? ""; + message.args = object.args ?? undefined; + return message; + }, +}; + +function createBaseErrorEvent(): ErrorEvent { + return { code: "", message: "", retriable: false }; +} + +export const ErrorEvent: MessageFns = { + encode(message: ErrorEvent, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.code !== "") { + writer.uint32(10).string(message.code); + } + if (message.message !== "") { + writer.uint32(18).string(message.message); + } + if (message.retriable !== false) { + writer.uint32(24).bool(message.retriable); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): ErrorEvent { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseErrorEvent(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.code = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.message = reader.string(); + continue; + } + case 3: { + if (tag !== 24) { + break; + } + + message.retriable = reader.bool(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): ErrorEvent { + return { + code: isSet(object.code) ? globalThis.String(object.code) : "", + message: isSet(object.message) ? globalThis.String(object.message) : "", + retriable: isSet(object.retriable) ? globalThis.Boolean(object.retriable) : false, + }; + }, + + toJSON(message: ErrorEvent): unknown { + const obj: any = {}; + if (message.code !== "") { + obj.code = message.code; + } + if (message.message !== "") { + obj.message = message.message; + } + if (message.retriable !== false) { + obj.retriable = message.retriable; + } + return obj; + }, + + create, I>>(base?: I): ErrorEvent { + return ErrorEvent.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): ErrorEvent { + const message = createBaseErrorEvent(); + message.code = object.code ?? ""; + message.message = object.message ?? ""; + message.retriable = object.retriable ?? false; + return message; + }, +}; + +function createBaseDoneStats(): DoneStats { + return { tokensIn: 0, tokensOut: 0, chunksRetrieved: 0 }; +} + +export const DoneStats: MessageFns = { + encode(message: DoneStats, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.tokensIn !== 0) { + writer.uint32(8).int32(message.tokensIn); + } + if (message.tokensOut !== 0) { + writer.uint32(16).int32(message.tokensOut); + } + if (message.chunksRetrieved !== 0) { + writer.uint32(24).int32(message.chunksRetrieved); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): DoneStats { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseDoneStats(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.tokensIn = reader.int32(); + continue; + } + case 2: { + if (tag !== 16) { + break; + } + + message.tokensOut = reader.int32(); + continue; + } + case 3: { + if (tag !== 24) { + break; + } + + message.chunksRetrieved = reader.int32(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): DoneStats { + return { + tokensIn: isSet(object.tokensIn) + ? globalThis.Number(object.tokensIn) + : isSet(object.tokens_in) + ? globalThis.Number(object.tokens_in) + : 0, + tokensOut: isSet(object.tokensOut) + ? globalThis.Number(object.tokensOut) + : isSet(object.tokens_out) + ? globalThis.Number(object.tokens_out) + : 0, + chunksRetrieved: isSet(object.chunksRetrieved) + ? globalThis.Number(object.chunksRetrieved) + : isSet(object.chunks_retrieved) + ? globalThis.Number(object.chunks_retrieved) + : 0, + }; + }, + + toJSON(message: DoneStats): unknown { + const obj: any = {}; + if (message.tokensIn !== 0) { + obj.tokensIn = Math.round(message.tokensIn); + } + if (message.tokensOut !== 0) { + obj.tokensOut = Math.round(message.tokensOut); + } + if (message.chunksRetrieved !== 0) { + obj.chunksRetrieved = Math.round(message.chunksRetrieved); + } + return obj; + }, + + create, I>>(base?: I): DoneStats { + return DoneStats.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): DoneStats { + const message = createBaseDoneStats(); + message.tokensIn = object.tokensIn ?? 0; + message.tokensOut = object.tokensOut ?? 0; + message.chunksRetrieved = object.chunksRetrieved ?? 0; + return message; + }, +}; + +function createBaseDoneEvent(): DoneEvent { + return { stats: undefined }; +} + +export const DoneEvent: MessageFns = { + encode(message: DoneEvent, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.stats !== undefined) { + DoneStats.encode(message.stats, writer.uint32(10).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): DoneEvent { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseDoneEvent(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.stats = DoneStats.decode(reader, reader.uint32()); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): DoneEvent { + return { stats: isSet(object.stats) ? DoneStats.fromJSON(object.stats) : undefined }; + }, + + toJSON(message: DoneEvent): unknown { + const obj: any = {}; + if (message.stats !== undefined) { + obj.stats = DoneStats.toJSON(message.stats); + } + return obj; + }, + + create, I>>(base?: I): DoneEvent { + return DoneEvent.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): DoneEvent { + const message = createBaseDoneEvent(); + message.stats = (object.stats !== undefined && object.stats !== null) + ? DoneStats.fromPartial(object.stats) + : undefined; + return message; + }, +}; + +function createBaseChatEvent(): ChatEvent { + return { + token: undefined, + citation: undefined, + agentStep: undefined, + toolCall: undefined, + error: undefined, + done: undefined, + }; +} + +export const ChatEvent: MessageFns = { + encode(message: ChatEvent, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.token !== undefined) { + TokenEvent.encode(message.token, writer.uint32(10).fork()).join(); + } + if (message.citation !== undefined) { + CitationEvent.encode(message.citation, writer.uint32(18).fork()).join(); + } + if (message.agentStep !== undefined) { + AgentStepEvent.encode(message.agentStep, writer.uint32(26).fork()).join(); + } + if (message.toolCall !== undefined) { + ToolCallEvent.encode(message.toolCall, writer.uint32(34).fork()).join(); + } + if (message.error !== undefined) { + ErrorEvent.encode(message.error, writer.uint32(42).fork()).join(); + } + if (message.done !== undefined) { + DoneEvent.encode(message.done, writer.uint32(50).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): ChatEvent { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseChatEvent(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.token = TokenEvent.decode(reader, reader.uint32()); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.citation = CitationEvent.decode(reader, reader.uint32()); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.agentStep = AgentStepEvent.decode(reader, reader.uint32()); + continue; + } + case 4: { + if (tag !== 34) { + break; + } + + message.toolCall = ToolCallEvent.decode(reader, reader.uint32()); + continue; + } + case 5: { + if (tag !== 42) { + break; + } + + message.error = ErrorEvent.decode(reader, reader.uint32()); + continue; + } + case 6: { + if (tag !== 50) { + break; + } + + message.done = DoneEvent.decode(reader, reader.uint32()); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): ChatEvent { + return { + token: isSet(object.token) ? TokenEvent.fromJSON(object.token) : undefined, + citation: isSet(object.citation) ? CitationEvent.fromJSON(object.citation) : undefined, + agentStep: isSet(object.agentStep) + ? AgentStepEvent.fromJSON(object.agentStep) + : isSet(object.agent_step) + ? AgentStepEvent.fromJSON(object.agent_step) + : undefined, + toolCall: isSet(object.toolCall) + ? ToolCallEvent.fromJSON(object.toolCall) + : isSet(object.tool_call) + ? ToolCallEvent.fromJSON(object.tool_call) + : undefined, + error: isSet(object.error) ? ErrorEvent.fromJSON(object.error) : undefined, + done: isSet(object.done) ? DoneEvent.fromJSON(object.done) : undefined, + }; + }, + + toJSON(message: ChatEvent): unknown { + const obj: any = {}; + if (message.token !== undefined) { + obj.token = TokenEvent.toJSON(message.token); + } + if (message.citation !== undefined) { + obj.citation = CitationEvent.toJSON(message.citation); + } + if (message.agentStep !== undefined) { + obj.agentStep = AgentStepEvent.toJSON(message.agentStep); + } + if (message.toolCall !== undefined) { + obj.toolCall = ToolCallEvent.toJSON(message.toolCall); + } + if (message.error !== undefined) { + obj.error = ErrorEvent.toJSON(message.error); + } + if (message.done !== undefined) { + obj.done = DoneEvent.toJSON(message.done); + } + return obj; + }, + + create, I>>(base?: I): ChatEvent { + return ChatEvent.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): ChatEvent { + const message = createBaseChatEvent(); + message.token = (object.token !== undefined && object.token !== null) + ? TokenEvent.fromPartial(object.token) + : undefined; + message.citation = (object.citation !== undefined && object.citation !== null) + ? CitationEvent.fromPartial(object.citation) + : undefined; + message.agentStep = (object.agentStep !== undefined && object.agentStep !== null) + ? AgentStepEvent.fromPartial(object.agentStep) + : undefined; + message.toolCall = (object.toolCall !== undefined && object.toolCall !== null) + ? ToolCallEvent.fromPartial(object.toolCall) + : undefined; + message.error = (object.error !== undefined && object.error !== null) + ? ErrorEvent.fromPartial(object.error) + : undefined; + message.done = (object.done !== undefined && object.done !== null) ? DoneEvent.fromPartial(object.done) : undefined; + return message; + }, +}; + +/** + * Streaming chat. Server returns a stream of ChatEvent; one event per + * model delta, citation, agent step, tool-call request, error, or done. + * Conversation state is NOT persisted — the caller sends the full + * message history every call. `conversation_id` exists only for audit + * correlation. + */ +export type ChatServiceService = typeof ChatServiceService; +export const ChatServiceService = { + chat: { + path: "/apf.ai.v1.ChatService/Chat" as const, + requestStream: false as const, + responseStream: true as const, + requestSerialize: (value: ChatRequest): Buffer => Buffer.from(ChatRequest.encode(value).finish()), + requestDeserialize: (value: Buffer): ChatRequest => ChatRequest.decode(value), + responseSerialize: (value: ChatEvent): Buffer => Buffer.from(ChatEvent.encode(value).finish()), + responseDeserialize: (value: Buffer): ChatEvent => ChatEvent.decode(value), + }, +} as const; + +export interface ChatServiceServer extends UntypedServiceImplementation { + chat: handleServerStreamingCall; +} + +export interface ChatServiceClient extends Client { + chat(request: ChatRequest, options?: Partial): ClientReadableStream; + chat(request: ChatRequest, metadata?: Metadata, options?: Partial): ClientReadableStream; +} + +export const ChatServiceClient = makeGenericClientConstructor( + ChatServiceService, + "apf.ai.v1.ChatService", +) as unknown as { + new (address: string, credentials: ChannelCredentials, options?: Partial): ChatServiceClient; + service: typeof ChatServiceService; + serviceName: string; +}; + +type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined; + +type DeepPartial = T extends Builtin ? T + : T extends Long ? string | number | Long : T extends globalThis.Array ? globalThis.Array> + : T extends ReadonlyArray ? ReadonlyArray> + : T extends {} ? { [K in keyof T]?: DeepPartial } + : Partial; + +type KeysOfUnion = T extends T ? keyof T : never; +type Exact = P extends Builtin ? P + : P & { [K in keyof P]: Exact } & { [K in Exclude>]: never }; + +function isObject(value: any): boolean { + return typeof value === "object" && value !== null; +} + +function isSet(value: any): boolean { + return value !== null && value !== undefined; +} + +interface MessageFns { + encode(message: T, writer?: BinaryWriter): BinaryWriter; + decode(input: BinaryReader | Uint8Array, length?: number): T; + fromJSON(object: any): T; + toJSON(message: T): unknown; + create, I>>(base?: I): T; + fromPartial, I>>(object: I): T; +} diff --git a/apps/portal-bff/src/grpc/gen/apf-ai/common.ts b/apps/portal-bff/src/grpc/gen/apf-ai/common.ts new file mode 100644 index 0000000..1a97062 --- /dev/null +++ b/apps/portal-bff/src/grpc/gen/apf-ai/common.ts @@ -0,0 +1,765 @@ +// Code generated by protoc-gen-ts_proto. DO NOT EDIT. +// versions: +// protoc-gen-ts_proto v2.11.8 +// protoc v3.19.1 +// source: common.proto + +/* eslint-disable */ +import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire"; +import Long from "long"; +import { Struct } from "./google/protobuf/struct"; + +export enum ChatRole { + CHAT_ROLE_UNSPECIFIED = 0, + CHAT_ROLE_SYSTEM = 1, + CHAT_ROLE_USER = 2, + CHAT_ROLE_ASSISTANT = 3, + CHAT_ROLE_TOOL = 4, + UNRECOGNIZED = -1, +} + +export function chatRoleFromJSON(object: any): ChatRole { + switch (object) { + case 0: + case "CHAT_ROLE_UNSPECIFIED": + return ChatRole.CHAT_ROLE_UNSPECIFIED; + case 1: + case "CHAT_ROLE_SYSTEM": + return ChatRole.CHAT_ROLE_SYSTEM; + case 2: + case "CHAT_ROLE_USER": + return ChatRole.CHAT_ROLE_USER; + case 3: + case "CHAT_ROLE_ASSISTANT": + return ChatRole.CHAT_ROLE_ASSISTANT; + case 4: + case "CHAT_ROLE_TOOL": + return ChatRole.CHAT_ROLE_TOOL; + case -1: + case "UNRECOGNIZED": + default: + return ChatRole.UNRECOGNIZED; + } +} + +export function chatRoleToJSON(object: ChatRole): string { + switch (object) { + case ChatRole.CHAT_ROLE_UNSPECIFIED: + return "CHAT_ROLE_UNSPECIFIED"; + case ChatRole.CHAT_ROLE_SYSTEM: + return "CHAT_ROLE_SYSTEM"; + case ChatRole.CHAT_ROLE_USER: + return "CHAT_ROLE_USER"; + case ChatRole.CHAT_ROLE_ASSISTANT: + return "CHAT_ROLE_ASSISTANT"; + case ChatRole.CHAT_ROLE_TOOL: + return "CHAT_ROLE_TOOL"; + case ChatRole.UNRECOGNIZED: + default: + return "UNRECOGNIZED"; + } +} + +/** + * Identity-bearing principal shipped with every request (carried in the JWT for + * internal callers, or in the request body for API-key callers — same shape). + */ +export interface Principal { + subject: string; + roles: string[]; + attributes: { [key: string]: string }; +} + +export interface Principal_AttributesEntry { + key: string; + value: string; +} + +/** + * Default-deny ACL attached to every ingested chunk. + * At least one of allowed_roles or allowed_subjects must be non-empty, + * enforced at the controller, the DB CHECK constraint, and the post-filter. + */ +export interface AclSpec { + allowedRoles: string[]; + allowedSubjects: string[]; + deniedRoles: string[]; + requiredAttributes: { [key: string]: string }; +} + +export interface AclSpec_RequiredAttributesEntry { + key: string; + value: string; +} + +export interface ChatMessage { + role: ChatRole; + content: string; + toolCallId: string; + name: string; +} + +/** + * JSON-schema descriptor for caller-side tool execution. The AI service is + * tool-blind: it emits ToolCall events; the caller executes and posts back. + */ +export interface ToolDescriptor { + name: string; + description: string; + schema?: { [key: string]: any } | undefined; +} + +function createBasePrincipal(): Principal { + return { subject: "", roles: [], attributes: {} }; +} + +export const Principal: MessageFns = { + encode(message: Principal, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.subject !== "") { + writer.uint32(10).string(message.subject); + } + for (const v of message.roles) { + writer.uint32(18).string(v!); + } + globalThis.Object.entries(message.attributes).forEach(([key, value]: [string, string]) => { + Principal_AttributesEntry.encode({ key: key as any, value }, writer.uint32(26).fork()).join(); + }); + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): Principal { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBasePrincipal(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.subject = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.roles.push(reader.string()); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + const entry3 = Principal_AttributesEntry.decode(reader, reader.uint32()); + if (entry3.value !== undefined) { + message.attributes[entry3.key] = entry3.value; + } + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): Principal { + return { + subject: isSet(object.subject) ? globalThis.String(object.subject) : "", + roles: globalThis.Array.isArray(object?.roles) ? object.roles.map((e: any) => globalThis.String(e)) : [], + attributes: isObject(object.attributes) + ? (globalThis.Object.entries(object.attributes) as [string, any][]).reduce( + (acc: { [key: string]: string }, [key, value]: [string, any]) => { + acc[key] = globalThis.String(value); + return acc; + }, + {}, + ) + : {}, + }; + }, + + toJSON(message: Principal): unknown { + const obj: any = {}; + if (message.subject !== "") { + obj.subject = message.subject; + } + if (message.roles?.length) { + obj.roles = message.roles; + } + if (message.attributes) { + const entries = globalThis.Object.entries(message.attributes) as [string, string][]; + if (entries.length > 0) { + obj.attributes = {}; + entries.forEach(([k, v]) => { + obj.attributes[k] = v; + }); + } + } + return obj; + }, + + create, I>>(base?: I): Principal { + return Principal.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): Principal { + const message = createBasePrincipal(); + message.subject = object.subject ?? ""; + message.roles = object.roles?.map((e) => e) || []; + message.attributes = (globalThis.Object.entries(object.attributes ?? {}) as [string, string][]).reduce( + (acc: { [key: string]: string }, [key, value]: [string, string]) => { + if (value !== undefined) { + acc[key] = globalThis.String(value); + } + return acc; + }, + {}, + ); + return message; + }, +}; + +function createBasePrincipal_AttributesEntry(): Principal_AttributesEntry { + return { key: "", value: "" }; +} + +export const Principal_AttributesEntry: MessageFns = { + encode(message: Principal_AttributesEntry, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.key !== "") { + writer.uint32(10).string(message.key); + } + if (message.value !== "") { + writer.uint32(18).string(message.value); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): Principal_AttributesEntry { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBasePrincipal_AttributesEntry(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.key = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.value = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): Principal_AttributesEntry { + return { + key: isSet(object.key) ? globalThis.String(object.key) : "", + value: isSet(object.value) ? globalThis.String(object.value) : "", + }; + }, + + toJSON(message: Principal_AttributesEntry): unknown { + const obj: any = {}; + if (message.key !== "") { + obj.key = message.key; + } + if (message.value !== "") { + obj.value = message.value; + } + return obj; + }, + + create, I>>(base?: I): Principal_AttributesEntry { + return Principal_AttributesEntry.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): Principal_AttributesEntry { + const message = createBasePrincipal_AttributesEntry(); + message.key = object.key ?? ""; + message.value = object.value ?? ""; + return message; + }, +}; + +function createBaseAclSpec(): AclSpec { + return { allowedRoles: [], allowedSubjects: [], deniedRoles: [], requiredAttributes: {} }; +} + +export const AclSpec: MessageFns = { + encode(message: AclSpec, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + for (const v of message.allowedRoles) { + writer.uint32(10).string(v!); + } + for (const v of message.allowedSubjects) { + writer.uint32(18).string(v!); + } + for (const v of message.deniedRoles) { + writer.uint32(26).string(v!); + } + globalThis.Object.entries(message.requiredAttributes).forEach(([key, value]: [string, string]) => { + AclSpec_RequiredAttributesEntry.encode({ key: key as any, value }, writer.uint32(34).fork()).join(); + }); + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): AclSpec { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseAclSpec(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.allowedRoles.push(reader.string()); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.allowedSubjects.push(reader.string()); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.deniedRoles.push(reader.string()); + continue; + } + case 4: { + if (tag !== 34) { + break; + } + + const entry4 = AclSpec_RequiredAttributesEntry.decode(reader, reader.uint32()); + if (entry4.value !== undefined) { + message.requiredAttributes[entry4.key] = entry4.value; + } + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): AclSpec { + return { + allowedRoles: globalThis.Array.isArray(object?.allowedRoles) + ? object.allowedRoles.map((e: any) => globalThis.String(e)) + : globalThis.Array.isArray(object?.allowed_roles) + ? object.allowed_roles.map((e: any) => globalThis.String(e)) + : [], + allowedSubjects: globalThis.Array.isArray(object?.allowedSubjects) + ? object.allowedSubjects.map((e: any) => globalThis.String(e)) + : globalThis.Array.isArray(object?.allowed_subjects) + ? object.allowed_subjects.map((e: any) => globalThis.String(e)) + : [], + deniedRoles: globalThis.Array.isArray(object?.deniedRoles) + ? object.deniedRoles.map((e: any) => globalThis.String(e)) + : globalThis.Array.isArray(object?.denied_roles) + ? object.denied_roles.map((e: any) => globalThis.String(e)) + : [], + requiredAttributes: isObject(object.requiredAttributes) + ? (globalThis.Object.entries(object.requiredAttributes) as [string, any][]).reduce( + (acc: { [key: string]: string }, [key, value]: [string, any]) => { + acc[key] = globalThis.String(value); + return acc; + }, + {}, + ) + : isObject(object.required_attributes) + ? (globalThis.Object.entries(object.required_attributes) as [string, any][]).reduce( + (acc: { [key: string]: string }, [key, value]: [string, any]) => { + acc[key] = globalThis.String(value); + return acc; + }, + {}, + ) + : {}, + }; + }, + + toJSON(message: AclSpec): unknown { + const obj: any = {}; + if (message.allowedRoles?.length) { + obj.allowedRoles = message.allowedRoles; + } + if (message.allowedSubjects?.length) { + obj.allowedSubjects = message.allowedSubjects; + } + if (message.deniedRoles?.length) { + obj.deniedRoles = message.deniedRoles; + } + if (message.requiredAttributes) { + const entries = globalThis.Object.entries(message.requiredAttributes) as [string, string][]; + if (entries.length > 0) { + obj.requiredAttributes = {}; + entries.forEach(([k, v]) => { + obj.requiredAttributes[k] = v; + }); + } + } + return obj; + }, + + create, I>>(base?: I): AclSpec { + return AclSpec.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): AclSpec { + const message = createBaseAclSpec(); + message.allowedRoles = object.allowedRoles?.map((e) => e) || []; + message.allowedSubjects = object.allowedSubjects?.map((e) => e) || []; + message.deniedRoles = object.deniedRoles?.map((e) => e) || []; + message.requiredAttributes = (globalThis.Object.entries(object.requiredAttributes ?? {}) as [string, string][]) + .reduce((acc: { [key: string]: string }, [key, value]: [string, string]) => { + if (value !== undefined) { + acc[key] = globalThis.String(value); + } + return acc; + }, {}); + return message; + }, +}; + +function createBaseAclSpec_RequiredAttributesEntry(): AclSpec_RequiredAttributesEntry { + return { key: "", value: "" }; +} + +export const AclSpec_RequiredAttributesEntry: MessageFns = { + encode(message: AclSpec_RequiredAttributesEntry, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.key !== "") { + writer.uint32(10).string(message.key); + } + if (message.value !== "") { + writer.uint32(18).string(message.value); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): AclSpec_RequiredAttributesEntry { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseAclSpec_RequiredAttributesEntry(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.key = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.value = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): AclSpec_RequiredAttributesEntry { + return { + key: isSet(object.key) ? globalThis.String(object.key) : "", + value: isSet(object.value) ? globalThis.String(object.value) : "", + }; + }, + + toJSON(message: AclSpec_RequiredAttributesEntry): unknown { + const obj: any = {}; + if (message.key !== "") { + obj.key = message.key; + } + if (message.value !== "") { + obj.value = message.value; + } + return obj; + }, + + create, I>>(base?: I): AclSpec_RequiredAttributesEntry { + return AclSpec_RequiredAttributesEntry.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>( + object: I, + ): AclSpec_RequiredAttributesEntry { + const message = createBaseAclSpec_RequiredAttributesEntry(); + message.key = object.key ?? ""; + message.value = object.value ?? ""; + return message; + }, +}; + +function createBaseChatMessage(): ChatMessage { + return { role: 0, content: "", toolCallId: "", name: "" }; +} + +export const ChatMessage: MessageFns = { + encode(message: ChatMessage, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.role !== 0) { + writer.uint32(8).int32(message.role); + } + if (message.content !== "") { + writer.uint32(18).string(message.content); + } + if (message.toolCallId !== "") { + writer.uint32(26).string(message.toolCallId); + } + if (message.name !== "") { + writer.uint32(34).string(message.name); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): ChatMessage { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseChatMessage(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.role = reader.int32() as any; + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.content = reader.string(); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.toolCallId = reader.string(); + continue; + } + case 4: { + if (tag !== 34) { + break; + } + + message.name = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): ChatMessage { + return { + role: isSet(object.role) ? chatRoleFromJSON(object.role) : 0, + content: isSet(object.content) ? globalThis.String(object.content) : "", + toolCallId: isSet(object.toolCallId) + ? globalThis.String(object.toolCallId) + : isSet(object.tool_call_id) + ? globalThis.String(object.tool_call_id) + : "", + name: isSet(object.name) ? globalThis.String(object.name) : "", + }; + }, + + toJSON(message: ChatMessage): unknown { + const obj: any = {}; + if (message.role !== 0) { + obj.role = chatRoleToJSON(message.role); + } + if (message.content !== "") { + obj.content = message.content; + } + if (message.toolCallId !== "") { + obj.toolCallId = message.toolCallId; + } + if (message.name !== "") { + obj.name = message.name; + } + return obj; + }, + + create, I>>(base?: I): ChatMessage { + return ChatMessage.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): ChatMessage { + const message = createBaseChatMessage(); + message.role = object.role ?? 0; + message.content = object.content ?? ""; + message.toolCallId = object.toolCallId ?? ""; + message.name = object.name ?? ""; + return message; + }, +}; + +function createBaseToolDescriptor(): ToolDescriptor { + return { name: "", description: "", schema: undefined }; +} + +export const ToolDescriptor: MessageFns = { + encode(message: ToolDescriptor, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.name !== "") { + writer.uint32(10).string(message.name); + } + if (message.description !== "") { + writer.uint32(18).string(message.description); + } + if (message.schema !== undefined) { + Struct.encode(Struct.wrap(message.schema), writer.uint32(26).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): ToolDescriptor { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseToolDescriptor(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.name = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.description = reader.string(); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.schema = Struct.unwrap(Struct.decode(reader, reader.uint32())); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): ToolDescriptor { + return { + name: isSet(object.name) ? globalThis.String(object.name) : "", + description: isSet(object.description) ? globalThis.String(object.description) : "", + schema: isObject(object.schema) ? object.schema : undefined, + }; + }, + + toJSON(message: ToolDescriptor): unknown { + const obj: any = {}; + if (message.name !== "") { + obj.name = message.name; + } + if (message.description !== "") { + obj.description = message.description; + } + if (message.schema !== undefined) { + obj.schema = message.schema; + } + return obj; + }, + + create, I>>(base?: I): ToolDescriptor { + return ToolDescriptor.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): ToolDescriptor { + const message = createBaseToolDescriptor(); + message.name = object.name ?? ""; + message.description = object.description ?? ""; + message.schema = object.schema ?? undefined; + return message; + }, +}; + +type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined; + +type DeepPartial = T extends Builtin ? T + : T extends Long ? string | number | Long : T extends globalThis.Array ? globalThis.Array> + : T extends ReadonlyArray ? ReadonlyArray> + : T extends {} ? { [K in keyof T]?: DeepPartial } + : Partial; + +type KeysOfUnion = T extends T ? keyof T : never; +type Exact = P extends Builtin ? P + : P & { [K in keyof P]: Exact } & { [K in Exclude>]: never }; + +function isObject(value: any): boolean { + return typeof value === "object" && value !== null; +} + +function isSet(value: any): boolean { + return value !== null && value !== undefined; +} + +interface MessageFns { + encode(message: T, writer?: BinaryWriter): BinaryWriter; + decode(input: BinaryReader | Uint8Array, length?: number): T; + fromJSON(object: any): T; + toJSON(message: T): unknown; + create, I>>(base?: I): T; + fromPartial, I>>(object: I): T; +} diff --git a/apps/portal-bff/src/grpc/gen/apf-ai/google/protobuf/struct.ts b/apps/portal-bff/src/grpc/gen/apf-ai/google/protobuf/struct.ts new file mode 100644 index 0000000..0dc80c0 --- /dev/null +++ b/apps/portal-bff/src/grpc/gen/apf-ai/google/protobuf/struct.ts @@ -0,0 +1,614 @@ +// Code generated by protoc-gen-ts_proto. DO NOT EDIT. +// versions: +// protoc-gen-ts_proto v2.11.8 +// protoc v3.19.1 +// source: google/protobuf/struct.proto + +/* eslint-disable */ +import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire"; +import Long from "long"; + +/** + * `NullValue` is a singleton enumeration to represent the null value for the + * `Value` type union. + * + * The JSON representation for `NullValue` is JSON `null`. + */ +export enum NullValue { + /** NULL_VALUE - Null value. */ + NULL_VALUE = 0, + UNRECOGNIZED = -1, +} + +export function nullValueFromJSON(object: any): NullValue { + switch (object) { + case 0: + case "NULL_VALUE": + return NullValue.NULL_VALUE; + case -1: + case "UNRECOGNIZED": + default: + return NullValue.UNRECOGNIZED; + } +} + +export function nullValueToJSON(object: NullValue): string { + switch (object) { + case NullValue.NULL_VALUE: + return "NULL_VALUE"; + case NullValue.UNRECOGNIZED: + default: + return "UNRECOGNIZED"; + } +} + +/** + * `Struct` represents a structured data value, consisting of fields + * which map to dynamically typed values. In some languages, `Struct` + * might be supported by a native representation. For example, in + * scripting languages like JS a struct is represented as an + * object. The details of that representation are described together + * with the proto support for the language. + * + * The JSON representation for `Struct` is JSON object. + */ +export interface Struct { + /** Unordered map of dynamically typed values. */ + fields: { [key: string]: any | undefined }; +} + +export interface Struct_FieldsEntry { + key: string; + value?: any | undefined; +} + +/** + * `Value` represents a dynamically typed value which can be either + * null, a number, a string, a boolean, a recursive struct value, or a + * list of values. A producer of value is expected to set one of these + * variants. Absence of any variant indicates an error. + * + * The JSON representation for `Value` is JSON value. + */ +export interface Value { + /** Represents a null value. */ + nullValue?: + | NullValue + | undefined; + /** Represents a double value. */ + numberValue?: + | number + | undefined; + /** Represents a string value. */ + stringValue?: + | string + | undefined; + /** Represents a boolean value. */ + boolValue?: + | boolean + | undefined; + /** Represents a structured value. */ + structValue?: + | { [key: string]: any } + | undefined; + /** Represents a repeated `Value`. */ + listValue?: Array | undefined; +} + +/** + * `ListValue` is a wrapper around a repeated field of values. + * + * The JSON representation for `ListValue` is JSON array. + */ +export interface ListValue { + /** Repeated field of dynamically typed values. */ + values: any[]; +} + +function createBaseStruct(): Struct { + return { fields: {} }; +} + +export const Struct: MessageFns & StructWrapperFns = { + encode(message: Struct, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + globalThis.Object.entries(message.fields).forEach(([key, value]: [string, any | undefined]) => { + if (value !== undefined) { + Struct_FieldsEntry.encode({ key: key as any, value }, writer.uint32(10).fork()).join(); + } + }); + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): Struct { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseStruct(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + const entry1 = Struct_FieldsEntry.decode(reader, reader.uint32()); + if (entry1.value !== undefined) { + message.fields[entry1.key] = entry1.value; + } + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): Struct { + return { + fields: isObject(object.fields) + ? (globalThis.Object.entries(object.fields) as [string, any][]).reduce( + (acc: { [key: string]: any | undefined }, [key, value]: [string, any]) => { + acc[key] = value as any | undefined; + return acc; + }, + {}, + ) + : {}, + }; + }, + + toJSON(message: Struct): unknown { + const obj: any = {}; + if (message.fields) { + const entries = globalThis.Object.entries(message.fields) as [string, any | undefined][]; + if (entries.length > 0) { + obj.fields = {}; + entries.forEach(([k, v]) => { + obj.fields[k] = v; + }); + } + } + return obj; + }, + + create, I>>(base?: I): Struct { + return Struct.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): Struct { + const message = createBaseStruct(); + message.fields = (globalThis.Object.entries(object.fields ?? {}) as [string, any | undefined][]).reduce( + (acc: { [key: string]: any | undefined }, [key, value]: [string, any | undefined]) => { + if (value !== undefined) { + acc[key] = value; + } + return acc; + }, + {}, + ); + return message; + }, + + wrap(object: { [key: string]: any } | undefined): Struct { + const struct = createBaseStruct(); + + if (object !== undefined) { + for (const key of globalThis.Object.keys(object)) { + struct.fields[key] = object[key]; + } + } + return struct; + }, + + unwrap(message: Struct): { [key: string]: any } { + const object: { [key: string]: any } = {}; + if (message.fields) { + for (const key of globalThis.Object.keys(message.fields)) { + object[key] = message.fields[key]; + } + } + return object; + }, +}; + +function createBaseStruct_FieldsEntry(): Struct_FieldsEntry { + return { key: "", value: undefined }; +} + +export const Struct_FieldsEntry: MessageFns = { + encode(message: Struct_FieldsEntry, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.key !== "") { + writer.uint32(10).string(message.key); + } + if (message.value !== undefined) { + Value.encode(Value.wrap(message.value), writer.uint32(18).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): Struct_FieldsEntry { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseStruct_FieldsEntry(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.key = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.value = Value.unwrap(Value.decode(reader, reader.uint32())); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): Struct_FieldsEntry { + return { + key: isSet(object.key) ? globalThis.String(object.key) : "", + value: isSet(object?.value) ? object.value : undefined, + }; + }, + + toJSON(message: Struct_FieldsEntry): unknown { + const obj: any = {}; + if (message.key !== "") { + obj.key = message.key; + } + if (message.value !== undefined) { + obj.value = message.value; + } + return obj; + }, + + create, I>>(base?: I): Struct_FieldsEntry { + return Struct_FieldsEntry.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): Struct_FieldsEntry { + const message = createBaseStruct_FieldsEntry(); + message.key = object.key ?? ""; + message.value = object.value ?? undefined; + return message; + }, +}; + +function createBaseValue(): Value { + return { + nullValue: undefined, + numberValue: undefined, + stringValue: undefined, + boolValue: undefined, + structValue: undefined, + listValue: undefined, + }; +} + +export const Value: MessageFns & AnyValueWrapperFns = { + encode(message: Value, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.nullValue !== undefined) { + writer.uint32(8).int32(message.nullValue); + } + if (message.numberValue !== undefined) { + writer.uint32(17).double(message.numberValue); + } + if (message.stringValue !== undefined) { + writer.uint32(26).string(message.stringValue); + } + if (message.boolValue !== undefined) { + writer.uint32(32).bool(message.boolValue); + } + if (message.structValue !== undefined) { + Struct.encode(Struct.wrap(message.structValue), writer.uint32(42).fork()).join(); + } + if (message.listValue !== undefined) { + ListValue.encode(ListValue.wrap(message.listValue), writer.uint32(50).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): Value { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseValue(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.nullValue = reader.int32() as any; + continue; + } + case 2: { + if (tag !== 17) { + break; + } + + message.numberValue = reader.double(); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.stringValue = reader.string(); + continue; + } + case 4: { + if (tag !== 32) { + break; + } + + message.boolValue = reader.bool(); + continue; + } + case 5: { + if (tag !== 42) { + break; + } + + message.structValue = Struct.unwrap(Struct.decode(reader, reader.uint32())); + continue; + } + case 6: { + if (tag !== 50) { + break; + } + + message.listValue = ListValue.unwrap(ListValue.decode(reader, reader.uint32())); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): Value { + return { + nullValue: isSet(object.nullValue) + ? nullValueFromJSON(object.nullValue) + : isSet(object.null_value) + ? nullValueFromJSON(object.null_value) + : undefined, + numberValue: isSet(object.numberValue) + ? globalThis.Number(object.numberValue) + : isSet(object.number_value) + ? globalThis.Number(object.number_value) + : undefined, + stringValue: isSet(object.stringValue) + ? globalThis.String(object.stringValue) + : isSet(object.string_value) + ? globalThis.String(object.string_value) + : undefined, + boolValue: isSet(object.boolValue) + ? globalThis.Boolean(object.boolValue) + : isSet(object.bool_value) + ? globalThis.Boolean(object.bool_value) + : undefined, + structValue: isObject(object.structValue) + ? object.structValue + : isObject(object.struct_value) + ? object.struct_value + : undefined, + listValue: globalThis.Array.isArray(object.listValue) + ? [...object.listValue] + : globalThis.Array.isArray(object.list_value) + ? [...object.list_value] + : undefined, + }; + }, + + toJSON(message: Value): unknown { + const obj: any = {}; + if (message.nullValue !== undefined) { + obj.nullValue = nullValueToJSON(message.nullValue); + } + if (message.numberValue !== undefined) { + obj.numberValue = message.numberValue; + } + if (message.stringValue !== undefined) { + obj.stringValue = message.stringValue; + } + if (message.boolValue !== undefined) { + obj.boolValue = message.boolValue; + } + if (message.structValue !== undefined) { + obj.structValue = message.structValue; + } + if (message.listValue !== undefined) { + obj.listValue = message.listValue; + } + return obj; + }, + + create, I>>(base?: I): Value { + return Value.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): Value { + const message = createBaseValue(); + message.nullValue = object.nullValue ?? undefined; + message.numberValue = object.numberValue ?? undefined; + message.stringValue = object.stringValue ?? undefined; + message.boolValue = object.boolValue ?? undefined; + message.structValue = object.structValue ?? undefined; + message.listValue = object.listValue ?? undefined; + return message; + }, + + wrap(value: any): Value { + const result = createBaseValue(); + if (value === null) { + result.nullValue = NullValue.NULL_VALUE; + } else if (typeof value === "boolean") { + result.boolValue = value; + } else if (typeof value === "number") { + result.numberValue = value; + } else if (typeof value === "string") { + result.stringValue = value; + } else if (globalThis.Array.isArray(value)) { + result.listValue = value; + } else if (typeof value === "object") { + result.structValue = value; + } else if (typeof value !== "undefined") { + throw new globalThis.Error("Unsupported any value type: " + typeof value); + } + return result; + }, + + unwrap(message: any): string | number | boolean | Object | null | Array | undefined { + if (message.stringValue !== undefined) { + return message.stringValue; + } else if (message?.numberValue !== undefined) { + return message.numberValue; + } else if (message?.boolValue !== undefined) { + return message.boolValue; + } else if (message?.structValue !== undefined) { + return message.structValue as any; + } else if (message?.listValue !== undefined) { + return message.listValue; + } else if (message?.nullValue !== undefined) { + return null; + } + return undefined; + }, +}; + +function createBaseListValue(): ListValue { + return { values: [] }; +} + +export const ListValue: MessageFns & ListValueWrapperFns = { + encode(message: ListValue, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + for (const v of message.values) { + Value.encode(Value.wrap(v!), writer.uint32(10).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): ListValue { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseListValue(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.values.push(Value.unwrap(Value.decode(reader, reader.uint32()))); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): ListValue { + return { values: globalThis.Array.isArray(object?.values) ? [...object.values] : [] }; + }, + + toJSON(message: ListValue): unknown { + const obj: any = {}; + if (message.values?.length) { + obj.values = message.values; + } + return obj; + }, + + create, I>>(base?: I): ListValue { + return ListValue.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): ListValue { + const message = createBaseListValue(); + message.values = object.values?.map((e) => e) || []; + return message; + }, + + wrap(array: Array | undefined): ListValue { + const result = createBaseListValue(); + result.values = array ?? []; + return result; + }, + + unwrap(message: ListValue): Array { + if (message?.hasOwnProperty("values") && globalThis.Array.isArray(message.values)) { + return message.values; + } else { + return message as any; + } + }, +}; + +type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined; + +type DeepPartial = T extends Builtin ? T + : T extends Long ? string | number | Long : T extends globalThis.Array ? globalThis.Array> + : T extends ReadonlyArray ? ReadonlyArray> + : T extends {} ? { [K in keyof T]?: DeepPartial } + : Partial; + +type KeysOfUnion = T extends T ? keyof T : never; +type Exact = P extends Builtin ? P + : P & { [K in keyof P]: Exact } & { [K in Exclude>]: never }; + +function isObject(value: any): boolean { + return typeof value === "object" && value !== null; +} + +function isSet(value: any): boolean { + return value !== null && value !== undefined; +} + +interface MessageFns { + encode(message: T, writer?: BinaryWriter): BinaryWriter; + decode(input: BinaryReader | Uint8Array, length?: number): T; + fromJSON(object: any): T; + toJSON(message: T): unknown; + create, I>>(base?: I): T; + fromPartial, I>>(object: I): T; +} + +interface StructWrapperFns { + wrap(object: { [key: string]: any } | undefined): Struct; + unwrap(message: Struct): { [key: string]: any }; +} + +interface AnyValueWrapperFns { + wrap(value: any): Value; + unwrap(message: any): string | number | boolean | Object | null | Array | undefined; +} + +interface ListValueWrapperFns { + wrap(array: Array | undefined): ListValue; + unwrap(message: ListValue): Array; +} diff --git a/apps/portal-bff/src/grpc/gen/apf-ai/google/protobuf/timestamp.ts b/apps/portal-bff/src/grpc/gen/apf-ai/google/protobuf/timestamp.ts new file mode 100644 index 0000000..c400422 --- /dev/null +++ b/apps/portal-bff/src/grpc/gen/apf-ai/google/protobuf/timestamp.ts @@ -0,0 +1,219 @@ +// Code generated by protoc-gen-ts_proto. DO NOT EDIT. +// versions: +// protoc-gen-ts_proto v2.11.8 +// protoc v3.19.1 +// source: google/protobuf/timestamp.proto + +/* eslint-disable */ +import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire"; +import Long from "long"; + +/** + * A Timestamp represents a point in time independent of any time zone or local + * calendar, encoded as a count of seconds and fractions of seconds at + * nanosecond resolution. The count is relative to an epoch at UTC midnight on + * January 1, 1970, in the proleptic Gregorian calendar which extends the + * Gregorian calendar backwards to year one. + * + * All minutes are 60 seconds long. Leap seconds are "smeared" so that no leap + * second table is needed for interpretation, using a [24-hour linear + * smear](https://developers.google.com/time/smear). + * + * The range is from 0001-01-01T00:00:00Z to 9999-12-31T23:59:59.999999999Z. By + * restricting to that range, we ensure that we can convert to and from [RFC + * 3339](https://www.ietf.org/rfc/rfc3339.txt) date strings. + * + * # Examples + * + * Example 1: Compute Timestamp from POSIX `time()`. + * + * Timestamp timestamp; + * timestamp.set_seconds(time(NULL)); + * timestamp.set_nanos(0); + * + * Example 2: Compute Timestamp from POSIX `gettimeofday()`. + * + * struct timeval tv; + * gettimeofday(&tv, NULL); + * + * Timestamp timestamp; + * timestamp.set_seconds(tv.tv_sec); + * timestamp.set_nanos(tv.tv_usec * 1000); + * + * Example 3: Compute Timestamp from Win32 `GetSystemTimeAsFileTime()`. + * + * FILETIME ft; + * GetSystemTimeAsFileTime(&ft); + * UINT64 ticks = (((UINT64)ft.dwHighDateTime) << 32) | ft.dwLowDateTime; + * + * // A Windows tick is 100 nanoseconds. Windows epoch 1601-01-01T00:00:00Z + * // is 11644473600 seconds before Unix epoch 1970-01-01T00:00:00Z. + * Timestamp timestamp; + * timestamp.set_seconds((INT64) ((ticks / 10000000) - 11644473600LL)); + * timestamp.set_nanos((INT32) ((ticks % 10000000) * 100)); + * + * Example 4: Compute Timestamp from Java `System.currentTimeMillis()`. + * + * long millis = System.currentTimeMillis(); + * + * Timestamp timestamp = Timestamp.newBuilder().setSeconds(millis / 1000) + * .setNanos((int) ((millis % 1000) * 1000000)).build(); + * + * Example 5: Compute Timestamp from Java `Instant.now()`. + * + * Instant now = Instant.now(); + * + * Timestamp timestamp = + * Timestamp.newBuilder().setSeconds(now.getEpochSecond()) + * .setNanos(now.getNano()).build(); + * + * Example 6: Compute Timestamp from current time in Python. + * + * timestamp = Timestamp() + * timestamp.GetCurrentTime() + * + * # JSON Mapping + * + * In JSON format, the Timestamp type is encoded as a string in the + * [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format. That is, the + * format is "{year}-{month}-{day}T{hour}:{min}:{sec}[.{frac_sec}]Z" + * where {year} is always expressed using four digits while {month}, {day}, + * {hour}, {min}, and {sec} are zero-padded to two digits each. The fractional + * seconds, which can go up to 9 digits (i.e. up to 1 nanosecond resolution), + * are optional. The "Z" suffix indicates the timezone ("UTC"); the timezone + * is required. A proto3 JSON serializer should always use UTC (as indicated by + * "Z") when printing the Timestamp type and a proto3 JSON parser should be + * able to accept both UTC and other timezones (as indicated by an offset). + * + * For example, "2017-01-15T01:30:15.01Z" encodes 15.01 seconds past + * 01:30 UTC on January 15, 2017. + * + * In JavaScript, one can convert a Date object to this format using the + * standard + * [toISOString()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString) + * method. In Python, a standard `datetime.datetime` object can be converted + * to this format using + * [`strftime`](https://docs.python.org/2/library/time.html#time.strftime) with + * the time format spec '%Y-%m-%dT%H:%M:%S.%fZ'. Likewise, in Java, one can use + * the Joda Time's [`ISODateTimeFormat.dateTime()`]( + * http://www.joda.org/joda-time/apidocs/org/joda/time/format/ISODateTimeFormat.html#dateTime%2D%2D + * ) to obtain a formatter capable of generating timestamps in this format. + */ +export interface Timestamp { + /** + * Represents seconds of UTC time since Unix epoch + * 1970-01-01T00:00:00Z. Must be from 0001-01-01T00:00:00Z to + * 9999-12-31T23:59:59Z inclusive. + */ + seconds: Long; + /** + * Non-negative fractions of a second at nanosecond resolution. Negative + * second values with fractions must still have non-negative nanos values + * that count forward in time. Must be from 0 to 999,999,999 + * inclusive. + */ + nanos: number; +} + +function createBaseTimestamp(): Timestamp { + return { seconds: Long.ZERO, nanos: 0 }; +} + +export const Timestamp: MessageFns = { + encode(message: Timestamp, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (!message.seconds.equals(Long.ZERO)) { + writer.uint32(8).int64(message.seconds.toString()); + } + if (message.nanos !== 0) { + writer.uint32(16).int32(message.nanos); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): Timestamp { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseTimestamp(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.seconds = Long.fromString(reader.int64().toString()); + continue; + } + case 2: { + if (tag !== 16) { + break; + } + + message.nanos = reader.int32(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): Timestamp { + return { + seconds: isSet(object.seconds) ? Long.fromValue(object.seconds) : Long.ZERO, + nanos: isSet(object.nanos) ? globalThis.Number(object.nanos) : 0, + }; + }, + + toJSON(message: Timestamp): unknown { + const obj: any = {}; + if (!message.seconds.equals(Long.ZERO)) { + obj.seconds = (message.seconds || Long.ZERO).toString(); + } + if (message.nanos !== 0) { + obj.nanos = Math.round(message.nanos); + } + return obj; + }, + + create, I>>(base?: I): Timestamp { + return Timestamp.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): Timestamp { + const message = createBaseTimestamp(); + message.seconds = (object.seconds !== undefined && object.seconds !== null) + ? Long.fromValue(object.seconds) + : Long.ZERO; + message.nanos = object.nanos ?? 0; + return message; + }, +}; + +type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined; + +type DeepPartial = T extends Builtin ? T + : T extends Long ? string | number | Long : T extends globalThis.Array ? globalThis.Array> + : T extends ReadonlyArray ? ReadonlyArray> + : T extends {} ? { [K in keyof T]?: DeepPartial } + : Partial; + +type KeysOfUnion = T extends T ? keyof T : never; +type Exact = P extends Builtin ? P + : P & { [K in keyof P]: Exact } & { [K in Exclude>]: never }; + +function isSet(value: any): boolean { + return value !== null && value !== undefined; +} + +interface MessageFns { + encode(message: T, writer?: BinaryWriter): BinaryWriter; + decode(input: BinaryReader | Uint8Array, length?: number): T; + fromJSON(object: any): T; + toJSON(message: T): unknown; + create, I>>(base?: I): T; + fromPartial, I>>(object: I): T; +} diff --git a/apps/portal-bff/src/grpc/gen/apf-ai/ingestion.ts b/apps/portal-bff/src/grpc/gen/apf-ai/ingestion.ts new file mode 100644 index 0000000..e6d6113 --- /dev/null +++ b/apps/portal-bff/src/grpc/gen/apf-ai/ingestion.ts @@ -0,0 +1,858 @@ +// Code generated by protoc-gen-ts_proto. DO NOT EDIT. +// versions: +// protoc-gen-ts_proto v2.11.8 +// protoc v3.19.1 +// source: ingestion.proto + +/* eslint-disable */ +import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire"; +import { + type CallOptions, + type ChannelCredentials, + Client, + type ClientOptions, + type ClientUnaryCall, + type handleUnaryCall, + makeGenericClientConstructor, + type Metadata, + type ServiceError, + type UntypedServiceImplementation, +} from "@grpc/grpc-js"; +import Long from "long"; +import { AclSpec, Principal } from "./common"; +import { Struct } from "./google/protobuf/struct"; +import { Timestamp } from "./google/protobuf/timestamp"; + +export interface IngestChunk { + content: string; + metadata?: { [key: string]: any } | undefined; + acl?: AclSpec | undefined; +} + +export interface IngestDocumentRequest { + source: string; + externalId: string; + title: string; + chunks: IngestChunk[]; + principal?: Principal | undefined; +} + +export interface IngestDocumentResponse { + documentId: string; + chunksIngested: number; +} + +export interface GetDocumentRequest { + id: string; +} + +export interface DocumentSummary { + id: string; + sourceUri: string; + title: string; + externalId: string; + ingestedAt?: Date | undefined; + chunkCount: number; +} + +export interface DeleteDocumentRequest { + id: string; +} + +export interface DeleteDocumentResponse { + deleted: boolean; +} + +function createBaseIngestChunk(): IngestChunk { + return { content: "", metadata: undefined, acl: undefined }; +} + +export const IngestChunk: MessageFns = { + encode(message: IngestChunk, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.content !== "") { + writer.uint32(10).string(message.content); + } + if (message.metadata !== undefined) { + Struct.encode(Struct.wrap(message.metadata), writer.uint32(18).fork()).join(); + } + if (message.acl !== undefined) { + AclSpec.encode(message.acl, writer.uint32(26).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): IngestChunk { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseIngestChunk(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.content = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.metadata = Struct.unwrap(Struct.decode(reader, reader.uint32())); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.acl = AclSpec.decode(reader, reader.uint32()); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): IngestChunk { + return { + content: isSet(object.content) ? globalThis.String(object.content) : "", + metadata: isObject(object.metadata) ? object.metadata : undefined, + acl: isSet(object.acl) ? AclSpec.fromJSON(object.acl) : undefined, + }; + }, + + toJSON(message: IngestChunk): unknown { + const obj: any = {}; + if (message.content !== "") { + obj.content = message.content; + } + if (message.metadata !== undefined) { + obj.metadata = message.metadata; + } + if (message.acl !== undefined) { + obj.acl = AclSpec.toJSON(message.acl); + } + return obj; + }, + + create, I>>(base?: I): IngestChunk { + return IngestChunk.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): IngestChunk { + const message = createBaseIngestChunk(); + message.content = object.content ?? ""; + message.metadata = object.metadata ?? undefined; + message.acl = (object.acl !== undefined && object.acl !== null) ? AclSpec.fromPartial(object.acl) : undefined; + return message; + }, +}; + +function createBaseIngestDocumentRequest(): IngestDocumentRequest { + return { source: "", externalId: "", title: "", chunks: [], principal: undefined }; +} + +export const IngestDocumentRequest: MessageFns = { + encode(message: IngestDocumentRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.source !== "") { + writer.uint32(10).string(message.source); + } + if (message.externalId !== "") { + writer.uint32(18).string(message.externalId); + } + if (message.title !== "") { + writer.uint32(26).string(message.title); + } + for (const v of message.chunks) { + IngestChunk.encode(v!, writer.uint32(34).fork()).join(); + } + if (message.principal !== undefined) { + Principal.encode(message.principal, writer.uint32(42).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): IngestDocumentRequest { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseIngestDocumentRequest(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.source = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.externalId = reader.string(); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.title = reader.string(); + continue; + } + case 4: { + if (tag !== 34) { + break; + } + + message.chunks.push(IngestChunk.decode(reader, reader.uint32())); + continue; + } + case 5: { + if (tag !== 42) { + break; + } + + message.principal = Principal.decode(reader, reader.uint32()); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): IngestDocumentRequest { + return { + source: isSet(object.source) ? globalThis.String(object.source) : "", + externalId: isSet(object.externalId) + ? globalThis.String(object.externalId) + : isSet(object.external_id) + ? globalThis.String(object.external_id) + : "", + title: isSet(object.title) ? globalThis.String(object.title) : "", + chunks: globalThis.Array.isArray(object?.chunks) ? object.chunks.map((e: any) => IngestChunk.fromJSON(e)) : [], + principal: isSet(object.principal) ? Principal.fromJSON(object.principal) : undefined, + }; + }, + + toJSON(message: IngestDocumentRequest): unknown { + const obj: any = {}; + if (message.source !== "") { + obj.source = message.source; + } + if (message.externalId !== "") { + obj.externalId = message.externalId; + } + if (message.title !== "") { + obj.title = message.title; + } + if (message.chunks?.length) { + obj.chunks = message.chunks.map((e) => IngestChunk.toJSON(e)); + } + if (message.principal !== undefined) { + obj.principal = Principal.toJSON(message.principal); + } + return obj; + }, + + create, I>>(base?: I): IngestDocumentRequest { + return IngestDocumentRequest.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): IngestDocumentRequest { + const message = createBaseIngestDocumentRequest(); + message.source = object.source ?? ""; + message.externalId = object.externalId ?? ""; + message.title = object.title ?? ""; + message.chunks = object.chunks?.map((e) => IngestChunk.fromPartial(e)) || []; + message.principal = (object.principal !== undefined && object.principal !== null) + ? Principal.fromPartial(object.principal) + : undefined; + return message; + }, +}; + +function createBaseIngestDocumentResponse(): IngestDocumentResponse { + return { documentId: "", chunksIngested: 0 }; +} + +export const IngestDocumentResponse: MessageFns = { + encode(message: IngestDocumentResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.documentId !== "") { + writer.uint32(10).string(message.documentId); + } + if (message.chunksIngested !== 0) { + writer.uint32(16).int32(message.chunksIngested); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): IngestDocumentResponse { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseIngestDocumentResponse(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.documentId = reader.string(); + continue; + } + case 2: { + if (tag !== 16) { + break; + } + + message.chunksIngested = reader.int32(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): IngestDocumentResponse { + return { + documentId: isSet(object.documentId) + ? globalThis.String(object.documentId) + : isSet(object.document_id) + ? globalThis.String(object.document_id) + : "", + chunksIngested: isSet(object.chunksIngested) + ? globalThis.Number(object.chunksIngested) + : isSet(object.chunks_ingested) + ? globalThis.Number(object.chunks_ingested) + : 0, + }; + }, + + toJSON(message: IngestDocumentResponse): unknown { + const obj: any = {}; + if (message.documentId !== "") { + obj.documentId = message.documentId; + } + if (message.chunksIngested !== 0) { + obj.chunksIngested = Math.round(message.chunksIngested); + } + return obj; + }, + + create, I>>(base?: I): IngestDocumentResponse { + return IngestDocumentResponse.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): IngestDocumentResponse { + const message = createBaseIngestDocumentResponse(); + message.documentId = object.documentId ?? ""; + message.chunksIngested = object.chunksIngested ?? 0; + return message; + }, +}; + +function createBaseGetDocumentRequest(): GetDocumentRequest { + return { id: "" }; +} + +export const GetDocumentRequest: MessageFns = { + encode(message: GetDocumentRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.id !== "") { + writer.uint32(10).string(message.id); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): GetDocumentRequest { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseGetDocumentRequest(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.id = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): GetDocumentRequest { + return { id: isSet(object.id) ? globalThis.String(object.id) : "" }; + }, + + toJSON(message: GetDocumentRequest): unknown { + const obj: any = {}; + if (message.id !== "") { + obj.id = message.id; + } + return obj; + }, + + create, I>>(base?: I): GetDocumentRequest { + return GetDocumentRequest.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): GetDocumentRequest { + const message = createBaseGetDocumentRequest(); + message.id = object.id ?? ""; + return message; + }, +}; + +function createBaseDocumentSummary(): DocumentSummary { + return { id: "", sourceUri: "", title: "", externalId: "", ingestedAt: undefined, chunkCount: 0 }; +} + +export const DocumentSummary: MessageFns = { + encode(message: DocumentSummary, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.id !== "") { + writer.uint32(10).string(message.id); + } + if (message.sourceUri !== "") { + writer.uint32(18).string(message.sourceUri); + } + if (message.title !== "") { + writer.uint32(26).string(message.title); + } + if (message.externalId !== "") { + writer.uint32(34).string(message.externalId); + } + if (message.ingestedAt !== undefined) { + Timestamp.encode(toTimestamp(message.ingestedAt), writer.uint32(42).fork()).join(); + } + if (message.chunkCount !== 0) { + writer.uint32(48).int32(message.chunkCount); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): DocumentSummary { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseDocumentSummary(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.id = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.sourceUri = reader.string(); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.title = reader.string(); + continue; + } + case 4: { + if (tag !== 34) { + break; + } + + message.externalId = reader.string(); + continue; + } + case 5: { + if (tag !== 42) { + break; + } + + message.ingestedAt = fromTimestamp(Timestamp.decode(reader, reader.uint32())); + continue; + } + case 6: { + if (tag !== 48) { + break; + } + + message.chunkCount = reader.int32(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): DocumentSummary { + return { + id: isSet(object.id) ? globalThis.String(object.id) : "", + sourceUri: isSet(object.sourceUri) + ? globalThis.String(object.sourceUri) + : isSet(object.source_uri) + ? globalThis.String(object.source_uri) + : "", + title: isSet(object.title) ? globalThis.String(object.title) : "", + externalId: isSet(object.externalId) + ? globalThis.String(object.externalId) + : isSet(object.external_id) + ? globalThis.String(object.external_id) + : "", + ingestedAt: isSet(object.ingestedAt) + ? fromJsonTimestamp(object.ingestedAt) + : isSet(object.ingested_at) + ? fromJsonTimestamp(object.ingested_at) + : undefined, + chunkCount: isSet(object.chunkCount) + ? globalThis.Number(object.chunkCount) + : isSet(object.chunk_count) + ? globalThis.Number(object.chunk_count) + : 0, + }; + }, + + toJSON(message: DocumentSummary): unknown { + const obj: any = {}; + if (message.id !== "") { + obj.id = message.id; + } + if (message.sourceUri !== "") { + obj.sourceUri = message.sourceUri; + } + if (message.title !== "") { + obj.title = message.title; + } + if (message.externalId !== "") { + obj.externalId = message.externalId; + } + if (message.ingestedAt !== undefined) { + obj.ingestedAt = message.ingestedAt.toISOString(); + } + if (message.chunkCount !== 0) { + obj.chunkCount = Math.round(message.chunkCount); + } + return obj; + }, + + create, I>>(base?: I): DocumentSummary { + return DocumentSummary.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): DocumentSummary { + const message = createBaseDocumentSummary(); + message.id = object.id ?? ""; + message.sourceUri = object.sourceUri ?? ""; + message.title = object.title ?? ""; + message.externalId = object.externalId ?? ""; + message.ingestedAt = object.ingestedAt ?? undefined; + message.chunkCount = object.chunkCount ?? 0; + return message; + }, +}; + +function createBaseDeleteDocumentRequest(): DeleteDocumentRequest { + return { id: "" }; +} + +export const DeleteDocumentRequest: MessageFns = { + encode(message: DeleteDocumentRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.id !== "") { + writer.uint32(10).string(message.id); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): DeleteDocumentRequest { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseDeleteDocumentRequest(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.id = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): DeleteDocumentRequest { + return { id: isSet(object.id) ? globalThis.String(object.id) : "" }; + }, + + toJSON(message: DeleteDocumentRequest): unknown { + const obj: any = {}; + if (message.id !== "") { + obj.id = message.id; + } + return obj; + }, + + create, I>>(base?: I): DeleteDocumentRequest { + return DeleteDocumentRequest.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): DeleteDocumentRequest { + const message = createBaseDeleteDocumentRequest(); + message.id = object.id ?? ""; + return message; + }, +}; + +function createBaseDeleteDocumentResponse(): DeleteDocumentResponse { + return { deleted: false }; +} + +export const DeleteDocumentResponse: MessageFns = { + encode(message: DeleteDocumentResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.deleted !== false) { + writer.uint32(8).bool(message.deleted); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): DeleteDocumentResponse { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseDeleteDocumentResponse(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 8) { + break; + } + + message.deleted = reader.bool(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): DeleteDocumentResponse { + return { deleted: isSet(object.deleted) ? globalThis.Boolean(object.deleted) : false }; + }, + + toJSON(message: DeleteDocumentResponse): unknown { + const obj: any = {}; + if (message.deleted !== false) { + obj.deleted = message.deleted; + } + return obj; + }, + + create, I>>(base?: I): DeleteDocumentResponse { + return DeleteDocumentResponse.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): DeleteDocumentResponse { + const message = createBaseDeleteDocumentResponse(); + message.deleted = object.deleted ?? false; + return message; + }, +}; + +/** + * Document ingestion. Every chunk must carry an explicit ACL: empty + * ACL is default-deny and rejected with InvalidArgument (gRPC) / + * 422 (HTTP). + */ +export type IngestionServiceService = typeof IngestionServiceService; +export const IngestionServiceService = { + ingestDocument: { + path: "/apf.ai.v1.IngestionService/IngestDocument" as const, + requestStream: false as const, + responseStream: false as const, + requestSerialize: (value: IngestDocumentRequest): Buffer => + Buffer.from(IngestDocumentRequest.encode(value).finish()), + requestDeserialize: (value: Buffer): IngestDocumentRequest => IngestDocumentRequest.decode(value), + responseSerialize: (value: IngestDocumentResponse): Buffer => + Buffer.from(IngestDocumentResponse.encode(value).finish()), + responseDeserialize: (value: Buffer): IngestDocumentResponse => IngestDocumentResponse.decode(value), + }, + getDocument: { + path: "/apf.ai.v1.IngestionService/GetDocument" as const, + requestStream: false as const, + responseStream: false as const, + requestSerialize: (value: GetDocumentRequest): Buffer => Buffer.from(GetDocumentRequest.encode(value).finish()), + requestDeserialize: (value: Buffer): GetDocumentRequest => GetDocumentRequest.decode(value), + responseSerialize: (value: DocumentSummary): Buffer => Buffer.from(DocumentSummary.encode(value).finish()), + responseDeserialize: (value: Buffer): DocumentSummary => DocumentSummary.decode(value), + }, + deleteDocument: { + path: "/apf.ai.v1.IngestionService/DeleteDocument" as const, + requestStream: false as const, + responseStream: false as const, + requestSerialize: (value: DeleteDocumentRequest): Buffer => + Buffer.from(DeleteDocumentRequest.encode(value).finish()), + requestDeserialize: (value: Buffer): DeleteDocumentRequest => DeleteDocumentRequest.decode(value), + responseSerialize: (value: DeleteDocumentResponse): Buffer => + Buffer.from(DeleteDocumentResponse.encode(value).finish()), + responseDeserialize: (value: Buffer): DeleteDocumentResponse => DeleteDocumentResponse.decode(value), + }, +} as const; + +export interface IngestionServiceServer extends UntypedServiceImplementation { + ingestDocument: handleUnaryCall; + getDocument: handleUnaryCall; + deleteDocument: handleUnaryCall; +} + +export interface IngestionServiceClient extends Client { + ingestDocument( + request: IngestDocumentRequest, + callback: (error: ServiceError | null, response: IngestDocumentResponse) => void, + ): ClientUnaryCall; + ingestDocument( + request: IngestDocumentRequest, + metadata: Metadata, + callback: (error: ServiceError | null, response: IngestDocumentResponse) => void, + ): ClientUnaryCall; + ingestDocument( + request: IngestDocumentRequest, + metadata: Metadata, + options: Partial, + callback: (error: ServiceError | null, response: IngestDocumentResponse) => void, + ): ClientUnaryCall; + getDocument( + request: GetDocumentRequest, + callback: (error: ServiceError | null, response: DocumentSummary) => void, + ): ClientUnaryCall; + getDocument( + request: GetDocumentRequest, + metadata: Metadata, + callback: (error: ServiceError | null, response: DocumentSummary) => void, + ): ClientUnaryCall; + getDocument( + request: GetDocumentRequest, + metadata: Metadata, + options: Partial, + callback: (error: ServiceError | null, response: DocumentSummary) => void, + ): ClientUnaryCall; + deleteDocument( + request: DeleteDocumentRequest, + callback: (error: ServiceError | null, response: DeleteDocumentResponse) => void, + ): ClientUnaryCall; + deleteDocument( + request: DeleteDocumentRequest, + metadata: Metadata, + callback: (error: ServiceError | null, response: DeleteDocumentResponse) => void, + ): ClientUnaryCall; + deleteDocument( + request: DeleteDocumentRequest, + metadata: Metadata, + options: Partial, + callback: (error: ServiceError | null, response: DeleteDocumentResponse) => void, + ): ClientUnaryCall; +} + +export const IngestionServiceClient = makeGenericClientConstructor( + IngestionServiceService, + "apf.ai.v1.IngestionService", +) as unknown as { + new (address: string, credentials: ChannelCredentials, options?: Partial): IngestionServiceClient; + service: typeof IngestionServiceService; + serviceName: string; +}; + +type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined; + +type DeepPartial = T extends Builtin ? T + : T extends Long ? string | number | Long : T extends globalThis.Array ? globalThis.Array> + : T extends ReadonlyArray ? ReadonlyArray> + : T extends {} ? { [K in keyof T]?: DeepPartial } + : Partial; + +type KeysOfUnion = T extends T ? keyof T : never; +type Exact = P extends Builtin ? P + : P & { [K in keyof P]: Exact } & { [K in Exclude>]: never }; + +function toTimestamp(date: Date): Timestamp { + const seconds = numberToLong(Math.trunc(date.getTime() / 1_000)); + const nanos = (date.getTime() % 1_000) * 1_000_000; + return { seconds, nanos }; +} + +function fromTimestamp(t: Timestamp): Date { + let millis = (t.seconds.toNumber() || 0) * 1_000; + millis += (t.nanos || 0) / 1_000_000; + return new globalThis.Date(millis); +} + +function fromJsonTimestamp(o: any): Date { + if (o instanceof globalThis.Date) { + return o; + } else if (typeof o === "string") { + return new globalThis.Date(o); + } else { + return fromTimestamp(Timestamp.fromJSON(o)); + } +} + +function numberToLong(number: number) { + return Long.fromNumber(number); +} + +function isObject(value: any): boolean { + return typeof value === "object" && value !== null; +} + +function isSet(value: any): boolean { + return value !== null && value !== undefined; +} + +interface MessageFns { + encode(message: T, writer?: BinaryWriter): BinaryWriter; + decode(input: BinaryReader | Uint8Array, length?: number): T; + fromJSON(object: any): T; + toJSON(message: T): unknown; + create, I>>(base?: I): T; + fromPartial, I>>(object: I): T; +} diff --git a/apps/portal-bff/src/grpc/gen/apf-ai/models.ts b/apps/portal-bff/src/grpc/gen/apf-ai/models.ts new file mode 100644 index 0000000..1cdde92 --- /dev/null +++ b/apps/portal-bff/src/grpc/gen/apf-ai/models.ts @@ -0,0 +1,355 @@ +// Code generated by protoc-gen-ts_proto. DO NOT EDIT. +// versions: +// protoc-gen-ts_proto v2.11.8 +// protoc v3.19.1 +// source: models.proto + +/* eslint-disable */ +import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire"; +import { + type CallOptions, + type ChannelCredentials, + Client, + type ClientOptions, + type ClientUnaryCall, + type handleUnaryCall, + makeGenericClientConstructor, + type Metadata, + type ServiceError, + type UntypedServiceImplementation, +} from "@grpc/grpc-js"; +import Long from "long"; + +export interface ListModelsRequest { +} + +export interface ProviderInfo { + discriminator: string; + capabilities: string; + endpoint: string; + model: string; + embeddingModel: string; +} + +export interface ListModelsResponse { + active: string; + providers: ProviderInfo[]; +} + +function createBaseListModelsRequest(): ListModelsRequest { + return {}; +} + +export const ListModelsRequest: MessageFns = { + encode(_: ListModelsRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): ListModelsRequest { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseListModelsRequest(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(_: any): ListModelsRequest { + return {}; + }, + + toJSON(_: ListModelsRequest): unknown { + const obj: any = {}; + return obj; + }, + + create, I>>(base?: I): ListModelsRequest { + return ListModelsRequest.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(_: I): ListModelsRequest { + const message = createBaseListModelsRequest(); + return message; + }, +}; + +function createBaseProviderInfo(): ProviderInfo { + return { discriminator: "", capabilities: "", endpoint: "", model: "", embeddingModel: "" }; +} + +export const ProviderInfo: MessageFns = { + encode(message: ProviderInfo, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.discriminator !== "") { + writer.uint32(10).string(message.discriminator); + } + if (message.capabilities !== "") { + writer.uint32(18).string(message.capabilities); + } + if (message.endpoint !== "") { + writer.uint32(26).string(message.endpoint); + } + if (message.model !== "") { + writer.uint32(34).string(message.model); + } + if (message.embeddingModel !== "") { + writer.uint32(42).string(message.embeddingModel); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): ProviderInfo { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseProviderInfo(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.discriminator = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.capabilities = reader.string(); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.endpoint = reader.string(); + continue; + } + case 4: { + if (tag !== 34) { + break; + } + + message.model = reader.string(); + continue; + } + case 5: { + if (tag !== 42) { + break; + } + + message.embeddingModel = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): ProviderInfo { + return { + discriminator: isSet(object.discriminator) ? globalThis.String(object.discriminator) : "", + capabilities: isSet(object.capabilities) ? globalThis.String(object.capabilities) : "", + endpoint: isSet(object.endpoint) ? globalThis.String(object.endpoint) : "", + model: isSet(object.model) ? globalThis.String(object.model) : "", + embeddingModel: isSet(object.embeddingModel) + ? globalThis.String(object.embeddingModel) + : isSet(object.embedding_model) + ? globalThis.String(object.embedding_model) + : "", + }; + }, + + toJSON(message: ProviderInfo): unknown { + const obj: any = {}; + if (message.discriminator !== "") { + obj.discriminator = message.discriminator; + } + if (message.capabilities !== "") { + obj.capabilities = message.capabilities; + } + if (message.endpoint !== "") { + obj.endpoint = message.endpoint; + } + if (message.model !== "") { + obj.model = message.model; + } + if (message.embeddingModel !== "") { + obj.embeddingModel = message.embeddingModel; + } + return obj; + }, + + create, I>>(base?: I): ProviderInfo { + return ProviderInfo.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): ProviderInfo { + const message = createBaseProviderInfo(); + message.discriminator = object.discriminator ?? ""; + message.capabilities = object.capabilities ?? ""; + message.endpoint = object.endpoint ?? ""; + message.model = object.model ?? ""; + message.embeddingModel = object.embeddingModel ?? ""; + return message; + }, +}; + +function createBaseListModelsResponse(): ListModelsResponse { + return { active: "", providers: [] }; +} + +export const ListModelsResponse: MessageFns = { + encode(message: ListModelsResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.active !== "") { + writer.uint32(10).string(message.active); + } + for (const v of message.providers) { + ProviderInfo.encode(v!, writer.uint32(18).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): ListModelsResponse { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseListModelsResponse(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.active = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.providers.push(ProviderInfo.decode(reader, reader.uint32())); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): ListModelsResponse { + return { + active: isSet(object.active) ? globalThis.String(object.active) : "", + providers: globalThis.Array.isArray(object?.providers) + ? object.providers.map((e: any) => ProviderInfo.fromJSON(e)) + : [], + }; + }, + + toJSON(message: ListModelsResponse): unknown { + const obj: any = {}; + if (message.active !== "") { + obj.active = message.active; + } + if (message.providers?.length) { + obj.providers = message.providers.map((e) => ProviderInfo.toJSON(e)); + } + return obj; + }, + + create, I>>(base?: I): ListModelsResponse { + return ListModelsResponse.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): ListModelsResponse { + const message = createBaseListModelsResponse(); + message.active = object.active ?? ""; + message.providers = object.providers?.map((e) => ProviderInfo.fromPartial(e)) || []; + return message; + }, +}; + +export type ModelsServiceService = typeof ModelsServiceService; +export const ModelsServiceService = { + listModels: { + path: "/apf.ai.v1.ModelsService/ListModels" as const, + requestStream: false as const, + responseStream: false as const, + requestSerialize: (value: ListModelsRequest): Buffer => Buffer.from(ListModelsRequest.encode(value).finish()), + requestDeserialize: (value: Buffer): ListModelsRequest => ListModelsRequest.decode(value), + responseSerialize: (value: ListModelsResponse): Buffer => Buffer.from(ListModelsResponse.encode(value).finish()), + responseDeserialize: (value: Buffer): ListModelsResponse => ListModelsResponse.decode(value), + }, +} as const; + +export interface ModelsServiceServer extends UntypedServiceImplementation { + listModels: handleUnaryCall; +} + +export interface ModelsServiceClient extends Client { + listModels( + request: ListModelsRequest, + callback: (error: ServiceError | null, response: ListModelsResponse) => void, + ): ClientUnaryCall; + listModels( + request: ListModelsRequest, + metadata: Metadata, + callback: (error: ServiceError | null, response: ListModelsResponse) => void, + ): ClientUnaryCall; + listModels( + request: ListModelsRequest, + metadata: Metadata, + options: Partial, + callback: (error: ServiceError | null, response: ListModelsResponse) => void, + ): ClientUnaryCall; +} + +export const ModelsServiceClient = makeGenericClientConstructor( + ModelsServiceService, + "apf.ai.v1.ModelsService", +) as unknown as { + new (address: string, credentials: ChannelCredentials, options?: Partial): ModelsServiceClient; + service: typeof ModelsServiceService; + serviceName: string; +}; + +type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined; + +type DeepPartial = T extends Builtin ? T + : T extends Long ? string | number | Long : T extends globalThis.Array ? globalThis.Array> + : T extends ReadonlyArray ? ReadonlyArray> + : T extends {} ? { [K in keyof T]?: DeepPartial } + : Partial; + +type KeysOfUnion = T extends T ? keyof T : never; +type Exact = P extends Builtin ? P + : P & { [K in keyof P]: Exact } & { [K in Exclude>]: never }; + +function isSet(value: any): boolean { + return value !== null && value !== undefined; +} + +interface MessageFns { + encode(message: T, writer?: BinaryWriter): BinaryWriter; + decode(input: BinaryReader | Uint8Array, length?: number): T; + fromJSON(object: any): T; + toJSON(message: T): unknown; + create, I>>(base?: I): T; + fromPartial, I>>(object: I): T; +} diff --git a/apps/portal-bff/src/grpc/gen/apf-ai/rag.ts b/apps/portal-bff/src/grpc/gen/apf-ai/rag.ts new file mode 100644 index 0000000..d196d74 --- /dev/null +++ b/apps/portal-bff/src/grpc/gen/apf-ai/rag.ts @@ -0,0 +1,544 @@ +// Code generated by protoc-gen-ts_proto. DO NOT EDIT. +// versions: +// protoc-gen-ts_proto v2.11.8 +// protoc v3.19.1 +// source: rag.proto + +/* eslint-disable */ +import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire"; +import { + type CallOptions, + type ChannelCredentials, + Client, + type ClientOptions, + type ClientUnaryCall, + type handleUnaryCall, + makeGenericClientConstructor, + type Metadata, + type ServiceError, + type UntypedServiceImplementation, +} from "@grpc/grpc-js"; +import Long from "long"; +import { Principal } from "./common"; +import { Struct } from "./google/protobuf/struct"; + +export interface RagSearchFilters { + source: string; + documentId: string; +} + +export interface RagSearchRequest { + query: string; + topK: number; + filters?: RagSearchFilters | undefined; + principal?: Principal | undefined; +} + +export interface Chunk { + id: string; + documentId: string; + content: string; + source: string; + score: number; + metadata?: { [key: string]: any } | undefined; +} + +export interface RagSearchResponse { + chunks: Chunk[]; + correlationId: string; +} + +function createBaseRagSearchFilters(): RagSearchFilters { + return { source: "", documentId: "" }; +} + +export const RagSearchFilters: MessageFns = { + encode(message: RagSearchFilters, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.source !== "") { + writer.uint32(10).string(message.source); + } + if (message.documentId !== "") { + writer.uint32(18).string(message.documentId); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): RagSearchFilters { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseRagSearchFilters(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.source = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.documentId = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): RagSearchFilters { + return { + source: isSet(object.source) ? globalThis.String(object.source) : "", + documentId: isSet(object.documentId) + ? globalThis.String(object.documentId) + : isSet(object.document_id) + ? globalThis.String(object.document_id) + : "", + }; + }, + + toJSON(message: RagSearchFilters): unknown { + const obj: any = {}; + if (message.source !== "") { + obj.source = message.source; + } + if (message.documentId !== "") { + obj.documentId = message.documentId; + } + return obj; + }, + + create, I>>(base?: I): RagSearchFilters { + return RagSearchFilters.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): RagSearchFilters { + const message = createBaseRagSearchFilters(); + message.source = object.source ?? ""; + message.documentId = object.documentId ?? ""; + return message; + }, +}; + +function createBaseRagSearchRequest(): RagSearchRequest { + return { query: "", topK: 0, filters: undefined, principal: undefined }; +} + +export const RagSearchRequest: MessageFns = { + encode(message: RagSearchRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.query !== "") { + writer.uint32(10).string(message.query); + } + if (message.topK !== 0) { + writer.uint32(16).int32(message.topK); + } + if (message.filters !== undefined) { + RagSearchFilters.encode(message.filters, writer.uint32(26).fork()).join(); + } + if (message.principal !== undefined) { + Principal.encode(message.principal, writer.uint32(34).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): RagSearchRequest { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseRagSearchRequest(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.query = reader.string(); + continue; + } + case 2: { + if (tag !== 16) { + break; + } + + message.topK = reader.int32(); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.filters = RagSearchFilters.decode(reader, reader.uint32()); + continue; + } + case 4: { + if (tag !== 34) { + break; + } + + message.principal = Principal.decode(reader, reader.uint32()); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): RagSearchRequest { + return { + query: isSet(object.query) ? globalThis.String(object.query) : "", + topK: isSet(object.topK) + ? globalThis.Number(object.topK) + : isSet(object.top_k) + ? globalThis.Number(object.top_k) + : 0, + filters: isSet(object.filters) ? RagSearchFilters.fromJSON(object.filters) : undefined, + principal: isSet(object.principal) ? Principal.fromJSON(object.principal) : undefined, + }; + }, + + toJSON(message: RagSearchRequest): unknown { + const obj: any = {}; + if (message.query !== "") { + obj.query = message.query; + } + if (message.topK !== 0) { + obj.topK = Math.round(message.topK); + } + if (message.filters !== undefined) { + obj.filters = RagSearchFilters.toJSON(message.filters); + } + if (message.principal !== undefined) { + obj.principal = Principal.toJSON(message.principal); + } + return obj; + }, + + create, I>>(base?: I): RagSearchRequest { + return RagSearchRequest.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): RagSearchRequest { + const message = createBaseRagSearchRequest(); + message.query = object.query ?? ""; + message.topK = object.topK ?? 0; + message.filters = (object.filters !== undefined && object.filters !== null) + ? RagSearchFilters.fromPartial(object.filters) + : undefined; + message.principal = (object.principal !== undefined && object.principal !== null) + ? Principal.fromPartial(object.principal) + : undefined; + return message; + }, +}; + +function createBaseChunk(): Chunk { + return { id: "", documentId: "", content: "", source: "", score: 0, metadata: undefined }; +} + +export const Chunk: MessageFns = { + encode(message: Chunk, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + if (message.id !== "") { + writer.uint32(10).string(message.id); + } + if (message.documentId !== "") { + writer.uint32(18).string(message.documentId); + } + if (message.content !== "") { + writer.uint32(26).string(message.content); + } + if (message.source !== "") { + writer.uint32(34).string(message.source); + } + if (message.score !== 0) { + writer.uint32(41).double(message.score); + } + if (message.metadata !== undefined) { + Struct.encode(Struct.wrap(message.metadata), writer.uint32(50).fork()).join(); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): Chunk { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseChunk(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.id = reader.string(); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.documentId = reader.string(); + continue; + } + case 3: { + if (tag !== 26) { + break; + } + + message.content = reader.string(); + continue; + } + case 4: { + if (tag !== 34) { + break; + } + + message.source = reader.string(); + continue; + } + case 5: { + if (tag !== 41) { + break; + } + + message.score = reader.double(); + continue; + } + case 6: { + if (tag !== 50) { + break; + } + + message.metadata = Struct.unwrap(Struct.decode(reader, reader.uint32())); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): Chunk { + return { + id: isSet(object.id) ? globalThis.String(object.id) : "", + documentId: isSet(object.documentId) + ? globalThis.String(object.documentId) + : isSet(object.document_id) + ? globalThis.String(object.document_id) + : "", + content: isSet(object.content) ? globalThis.String(object.content) : "", + source: isSet(object.source) ? globalThis.String(object.source) : "", + score: isSet(object.score) ? globalThis.Number(object.score) : 0, + metadata: isObject(object.metadata) ? object.metadata : undefined, + }; + }, + + toJSON(message: Chunk): unknown { + const obj: any = {}; + if (message.id !== "") { + obj.id = message.id; + } + if (message.documentId !== "") { + obj.documentId = message.documentId; + } + if (message.content !== "") { + obj.content = message.content; + } + if (message.source !== "") { + obj.source = message.source; + } + if (message.score !== 0) { + obj.score = message.score; + } + if (message.metadata !== undefined) { + obj.metadata = message.metadata; + } + return obj; + }, + + create, I>>(base?: I): Chunk { + return Chunk.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): Chunk { + const message = createBaseChunk(); + message.id = object.id ?? ""; + message.documentId = object.documentId ?? ""; + message.content = object.content ?? ""; + message.source = object.source ?? ""; + message.score = object.score ?? 0; + message.metadata = object.metadata ?? undefined; + return message; + }, +}; + +function createBaseRagSearchResponse(): RagSearchResponse { + return { chunks: [], correlationId: "" }; +} + +export const RagSearchResponse: MessageFns = { + encode(message: RagSearchResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { + for (const v of message.chunks) { + Chunk.encode(v!, writer.uint32(10).fork()).join(); + } + if (message.correlationId !== "") { + writer.uint32(18).string(message.correlationId); + } + return writer; + }, + + decode(input: BinaryReader | Uint8Array, length?: number): RagSearchResponse { + const reader = input instanceof BinaryReader ? input : new BinaryReader(input); + const end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseRagSearchResponse(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: { + if (tag !== 10) { + break; + } + + message.chunks.push(Chunk.decode(reader, reader.uint32())); + continue; + } + case 2: { + if (tag !== 18) { + break; + } + + message.correlationId = reader.string(); + continue; + } + } + if ((tag & 7) === 4 || tag === 0) { + break; + } + reader.skip(tag & 7); + } + return message; + }, + + fromJSON(object: any): RagSearchResponse { + return { + chunks: globalThis.Array.isArray(object?.chunks) ? object.chunks.map((e: any) => Chunk.fromJSON(e)) : [], + correlationId: isSet(object.correlationId) + ? globalThis.String(object.correlationId) + : isSet(object.correlation_id) + ? globalThis.String(object.correlation_id) + : "", + }; + }, + + toJSON(message: RagSearchResponse): unknown { + const obj: any = {}; + if (message.chunks?.length) { + obj.chunks = message.chunks.map((e) => Chunk.toJSON(e)); + } + if (message.correlationId !== "") { + obj.correlationId = message.correlationId; + } + return obj; + }, + + create, I>>(base?: I): RagSearchResponse { + return RagSearchResponse.fromPartial(base ?? ({} as any)); + }, + fromPartial, I>>(object: I): RagSearchResponse { + const message = createBaseRagSearchResponse(); + message.chunks = object.chunks?.map((e) => Chunk.fromPartial(e)) || []; + message.correlationId = object.correlationId ?? ""; + return message; + }, +}; + +/** + * Stateless retrieval. The server runs a single SQL query that combines + * vector similarity with the principal's ACL clause — there is no + * retrieval path that bypasses the ACL filter. + */ +export type RagServiceService = typeof RagServiceService; +export const RagServiceService = { + search: { + path: "/apf.ai.v1.RagService/Search" as const, + requestStream: false as const, + responseStream: false as const, + requestSerialize: (value: RagSearchRequest): Buffer => Buffer.from(RagSearchRequest.encode(value).finish()), + requestDeserialize: (value: Buffer): RagSearchRequest => RagSearchRequest.decode(value), + responseSerialize: (value: RagSearchResponse): Buffer => Buffer.from(RagSearchResponse.encode(value).finish()), + responseDeserialize: (value: Buffer): RagSearchResponse => RagSearchResponse.decode(value), + }, +} as const; + +export interface RagServiceServer extends UntypedServiceImplementation { + search: handleUnaryCall; +} + +export interface RagServiceClient extends Client { + search( + request: RagSearchRequest, + callback: (error: ServiceError | null, response: RagSearchResponse) => void, + ): ClientUnaryCall; + search( + request: RagSearchRequest, + metadata: Metadata, + callback: (error: ServiceError | null, response: RagSearchResponse) => void, + ): ClientUnaryCall; + search( + request: RagSearchRequest, + metadata: Metadata, + options: Partial, + callback: (error: ServiceError | null, response: RagSearchResponse) => void, + ): ClientUnaryCall; +} + +export const RagServiceClient = makeGenericClientConstructor(RagServiceService, "apf.ai.v1.RagService") as unknown as { + new (address: string, credentials: ChannelCredentials, options?: Partial): RagServiceClient; + service: typeof RagServiceService; + serviceName: string; +}; + +type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined; + +type DeepPartial = T extends Builtin ? T + : T extends Long ? string | number | Long : T extends globalThis.Array ? globalThis.Array> + : T extends ReadonlyArray ? ReadonlyArray> + : T extends {} ? { [K in keyof T]?: DeepPartial } + : Partial; + +type KeysOfUnion = T extends T ? keyof T : never; +type Exact = P extends Builtin ? P + : P & { [K in keyof P]: Exact } & { [K in Exclude>]: never }; + +function isObject(value: any): boolean { + return typeof value === "object" && value !== null; +} + +function isSet(value: any): boolean { + return value !== null && value !== undefined; +} + +interface MessageFns { + encode(message: T, writer?: BinaryWriter): BinaryWriter; + decode(input: BinaryReader | Uint8Array, length?: number): T; + fromJSON(object: any): T; + toJSON(message: T): unknown; + create, I>>(base?: I): T; + fromPartial, I>>(object: I): T; +} diff --git a/apps/portal-bff/src/grpc/proto/apf-ai/chat.proto b/apps/portal-bff/src/grpc/proto/apf-ai/chat.proto new file mode 100644 index 0000000..aaf5d74 --- /dev/null +++ b/apps/portal-bff/src/grpc/proto/apf-ai/chat.proto @@ -0,0 +1,89 @@ +syntax = "proto3"; + +package apf.ai.v1; + +import "common.proto"; +import "google/protobuf/struct.proto"; + +option csharp_namespace = "Apf.Ai.Grpc.V1"; + +// Streaming chat. Server returns a stream of ChatEvent; one event per +// model delta, citation, agent step, tool-call request, error, or done. +// Conversation state is NOT persisted — the caller sends the full +// message history every call. `conversation_id` exists only for audit +// correlation. +service ChatService { + rpc Chat(ChatRequest) returns (stream ChatEvent); +} + +message RagOptions { + bool enabled = 1; + int32 top_k = 2; // 0 = use server default +} + +message ChatRequest { + repeated ChatMessage messages = 1; + string conversation_id = 2; + string model = 3; + string provider = 4; + repeated ToolDescriptor tools_available = 5; + RagOptions rag = 6; + // Optional for JWT auth (overridden by JWT claims). Required for + // API-key auth — but API-key callers should use the HTTP surface. + Principal principal = 7; +} + +message TokenEvent { + string token = 1; + string value = 2; +} + +message CitationEvent { + string chunk_id = 1; + string document_id = 2; + string source = 3; + double score = 4; + string snippet = 5; +} + +message AgentStepEvent { + string agent = 1; + string step = 2; + string step_id = 3; +} + +message ToolCallEvent { + string call_id = 1; + string name = 2; + google.protobuf.Struct args = 3; +} + +message ErrorEvent { + string code = 1; + string message = 2; + bool retriable = 3; +} + +message DoneStats { + int32 tokens_in = 1; + int32 tokens_out = 2; + int32 chunks_retrieved = 3; +} + +message DoneEvent { + DoneStats stats = 1; +} + +// Polymorphic stream payload. Mirrors the SSE shapes from the OpenAPI +// surface (kept side-by-side for third-party callers). gRPC's `oneof` +// gives us a tagged union without the SSE JSON discriminator. +message ChatEvent { + oneof event { + TokenEvent token = 1; + CitationEvent citation = 2; + AgentStepEvent agent_step = 3; + ToolCallEvent tool_call = 4; + ErrorEvent error = 5; + DoneEvent done = 6; + } +} diff --git a/apps/portal-bff/src/grpc/proto/apf-ai/common.proto b/apps/portal-bff/src/grpc/proto/apf-ai/common.proto new file mode 100644 index 0000000..74c4fd5 --- /dev/null +++ b/apps/portal-bff/src/grpc/proto/apf-ai/common.proto @@ -0,0 +1,48 @@ +syntax = "proto3"; + +package apf.ai.v1; + +import "google/protobuf/struct.proto"; + +option csharp_namespace = "Apf.Ai.Grpc.V1"; + +// Identity-bearing principal shipped with every request (carried in the JWT for +// internal callers, or in the request body for API-key callers — same shape). +message Principal { + string subject = 1; + repeated string roles = 2; + map attributes = 3; +} + +// Default-deny ACL attached to every ingested chunk. +// At least one of allowed_roles or allowed_subjects must be non-empty, +// enforced at the controller, the DB CHECK constraint, and the post-filter. +message AclSpec { + repeated string allowed_roles = 1; + repeated string allowed_subjects = 2; + repeated string denied_roles = 3; + map required_attributes = 4; +} + +enum ChatRole { + CHAT_ROLE_UNSPECIFIED = 0; + CHAT_ROLE_SYSTEM = 1; + CHAT_ROLE_USER = 2; + CHAT_ROLE_ASSISTANT = 3; + CHAT_ROLE_TOOL = 4; +} + +message ChatMessage { + ChatRole role = 1; + string content = 2; + string tool_call_id = 3; + string name = 4; +} + +// JSON-schema descriptor for caller-side tool execution. The AI service is +// tool-blind: it emits ToolCall events; the caller executes and posts back. +message ToolDescriptor { + string name = 1; + string description = 2; + google.protobuf.Struct schema = 3; +} diff --git a/apps/portal-bff/src/grpc/proto/apf-ai/ingestion.proto b/apps/portal-bff/src/grpc/proto/apf-ai/ingestion.proto new file mode 100644 index 0000000..03f098f --- /dev/null +++ b/apps/portal-bff/src/grpc/proto/apf-ai/ingestion.proto @@ -0,0 +1,58 @@ +syntax = "proto3"; + +package apf.ai.v1; + +import "common.proto"; +import "google/protobuf/struct.proto"; +import "google/protobuf/timestamp.proto"; + +option csharp_namespace = "Apf.Ai.Grpc.V1"; + +// Document ingestion. Every chunk must carry an explicit ACL: empty +// ACL is default-deny and rejected with InvalidArgument (gRPC) / +// 422 (HTTP). +service IngestionService { + rpc IngestDocument(IngestDocumentRequest) returns (IngestDocumentResponse); + rpc GetDocument(GetDocumentRequest) returns (DocumentSummary); + rpc DeleteDocument(DeleteDocumentRequest) returns (DeleteDocumentResponse); +} + +message IngestChunk { + string content = 1; + google.protobuf.Struct metadata = 2; + AclSpec acl = 3; +} + +message IngestDocumentRequest { + string source = 1; + string external_id = 2; + string title = 3; + repeated IngestChunk chunks = 4; + Principal principal = 5; +} + +message IngestDocumentResponse { + string document_id = 1; + int32 chunks_ingested = 2; +} + +message GetDocumentRequest { + string id = 1; +} + +message DocumentSummary { + string id = 1; + string source_uri = 2; + string title = 3; + string external_id = 4; + google.protobuf.Timestamp ingested_at = 5; + int32 chunk_count = 6; +} + +message DeleteDocumentRequest { + string id = 1; +} + +message DeleteDocumentResponse { + bool deleted = 1; +} diff --git a/apps/portal-bff/src/grpc/proto/apf-ai/models.proto b/apps/portal-bff/src/grpc/proto/apf-ai/models.proto new file mode 100644 index 0000000..b2e7c58 --- /dev/null +++ b/apps/portal-bff/src/grpc/proto/apf-ai/models.proto @@ -0,0 +1,24 @@ +syntax = "proto3"; + +package apf.ai.v1; + +option csharp_namespace = "Apf.Ai.Grpc.V1"; + +service ModelsService { + rpc ListModels(ListModelsRequest) returns (ListModelsResponse); +} + +message ListModelsRequest {} + +message ProviderInfo { + string discriminator = 1; + string capabilities = 2; + string endpoint = 3; + string model = 4; + string embedding_model = 5; +} + +message ListModelsResponse { + string active = 1; + repeated ProviderInfo providers = 2; +} diff --git a/apps/portal-bff/src/grpc/proto/apf-ai/rag.proto b/apps/portal-bff/src/grpc/proto/apf-ai/rag.proto new file mode 100644 index 0000000..ca687ce --- /dev/null +++ b/apps/portal-bff/src/grpc/proto/apf-ai/rag.proto @@ -0,0 +1,41 @@ +syntax = "proto3"; + +package apf.ai.v1; + +import "common.proto"; +import "google/protobuf/struct.proto"; + +option csharp_namespace = "Apf.Ai.Grpc.V1"; + +// Stateless retrieval. The server runs a single SQL query that combines +// vector similarity with the principal's ACL clause — there is no +// retrieval path that bypasses the ACL filter. +service RagService { + rpc Search(RagSearchRequest) returns (RagSearchResponse); +} + +message RagSearchFilters { + string source = 1; + string document_id = 2; +} + +message RagSearchRequest { + string query = 1; + int32 top_k = 2; + RagSearchFilters filters = 3; + Principal principal = 4; +} + +message Chunk { + string id = 1; + string document_id = 2; + string content = 3; + string source = 4; + double score = 5; + google.protobuf.Struct metadata = 6; +} + +message RagSearchResponse { + repeated Chunk chunks = 1; + string correlation_id = 2; +} diff --git a/eslint.config.mjs b/eslint.config.mjs index 683ede4..bf15536 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,11 +5,15 @@ export default [ ...nx.configs['flat/typescript'], ...nx.configs['flat/javascript'], { - "ignores": [ - "**/dist", - "**/out-tsc", - "**/vitest.config.*.timestamp*" - ] + "ignores": [ + "**/dist", + "**/out-tsc", + "**/vitest.config.*.timestamp*", + // Generated gRPC TypeScript stubs — hand-rules do not apply to + // codegen output. Lint the wrapper modules in `ai-client/`, not + // the ts-proto-emitted files under `gen/`. + "apps/portal-bff/src/grpc/gen/**" + ] }, { files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], diff --git a/package.json b/package.json index 8b79421..22c0363 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "ci:commits": "pnpm exec commitlint --from ${COMMIT_LINT_FROM:-origin/main} --to HEAD --verbose", "ci:gzip-budgets": "node scripts/check-gzip-budgets.mjs", "ci:perf": "pnpm exec nx build portal-shell --configuration=production && pnpm ci:gzip-budgets && pnpm exec lhci autorun --config=./lighthouserc.js", + "grpc:codegen": "grpc_tools_node_protoc --plugin=protoc-gen-ts_proto=./node_modules/.bin/protoc-gen-ts_proto --ts_proto_out=apps/portal-bff/src/grpc/gen/apf-ai --ts_proto_opt=outputServices=grpc-js,esModuleInterop=true,forceLong=long,useOptionals=messages,exportCommonSymbols=false --proto_path=apps/portal-bff/src/grpc/proto/apf-ai apps/portal-bff/src/grpc/proto/apf-ai/*.proto", + "grpc:sync": "node scripts/sync-ai-protos.mjs", "docs:dev": "vitepress dev docs", "docs:build": "vitepress build docs", "docs:preview": "vitepress preview docs" @@ -28,6 +30,7 @@ "@prisma/engines", "@swc/core", "esbuild", + "grpc-tools", "less", "lmdb", "msgpackr-extract", @@ -108,6 +111,7 @@ "eslint": "^9.8.0", "eslint-config-prettier": "^10.0.0", "eslint-plugin-playwright": "^1.6.2", + "grpc-tools": "^1.13.0", "husky": "^9.1.7", "jest": "^30.0.2", "jest-environment-node": "^30.0.2", @@ -124,6 +128,7 @@ "tailwindcss": "^4.2.4", "ts-jest": "^29.4.0", "ts-node": "10.9.2", + "ts-proto": "^2.7.0", "tslib": "^2.3.0", "typescript": "~5.9.2", "typescript-eslint": "^8.40.0", @@ -143,6 +148,8 @@ "@angular/platform-browser": "~21.2.0", "@angular/router": "~21.2.0", "@azure/msal-node": "^5.2.1", + "@bufbuild/protobuf": "^2.10.2", + "@grpc/grpc-js": "^1.13.0", "@nestjs/common": "^11.0.0", "@nestjs/core": "^11.0.0", "@nestjs/platform-express": "^11.0.0", @@ -180,6 +187,7 @@ "helmet": "^8.1.0", "ioredis": "^5.10.1", "jose": "^6.2.3", + "long": "^5.2.3", "lucide-angular": "^1.0.0", "nestjs-cls": "^6.2.0", "nestjs-pino": "^4.6.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 850dcf9..6ee7d09 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,6 +49,12 @@ importers: '@azure/msal-node': specifier: ^5.2.1 version: 5.2.1 + '@bufbuild/protobuf': + specifier: ^2.10.2 + version: 2.12.0 + '@grpc/grpc-js': + specifier: ^1.13.0 + version: 1.14.3 '@nestjs/common': specifier: ^11.0.0 version: 11.1.21(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -160,6 +166,9 @@ importers: jose: specifier: ^6.2.3 version: 6.2.3 + long: + specifier: ^5.2.3 + version: 5.3.2 lucide-angular: specifier: ^1.0.0 version: 1.0.0(@angular/common@21.2.13(@angular/core@21.2.13(@angular/compiler@21.2.13)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@21.2.13(@angular/compiler@21.2.13)(rxjs@7.8.2)(zone.js@0.16.2)) @@ -347,6 +356,9 @@ importers: eslint-plugin-playwright: specifier: ^1.6.2 version: 1.8.3(eslint@9.39.4(jiti@2.7.0)) + grpc-tools: + specifier: ^1.13.0 + version: 1.13.1(encoding@0.1.13) husky: specifier: ^9.1.7 version: 9.1.7 @@ -395,6 +407,9 @@ importers: ts-node: specifier: 10.9.2 version: 10.9.2(@swc/core@1.15.33(@swc/helpers@0.5.21))(@types/node@24.12.4)(typescript@5.9.3) + ts-proto: + specifier: ^2.7.0 + version: 2.11.8 tslib: specifier: ^2.3.0 version: 2.8.1 @@ -2541,6 +2556,11 @@ packages: resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} + '@mapbox/node-pre-gyp@2.0.3': + resolution: {integrity: sha512-uwPAhccfFJlsfCxMYTwOdVfOz3xqyj8xYL3zJj8f0pb30tLohnnFPhLuqp4/qoEz8sNxe4SESZedcBojRefIzg==} + engines: {node: '>=18'} + hasBin: true + '@mermaid-js/mermaid-mindmap@9.3.0': resolution: {integrity: sha512-IhtYSVBBRYviH1Ehu8gk69pMDF8DSRqXBRDMWrEfHoaMruHeaP2DXA3PBnuwsMaCdPQhlUUcy/7DBLAEIXvCAw==} @@ -5354,6 +5374,10 @@ packages: resolution: {integrity: sha512-nrUSn7hzt7J6JWgWGz78ZYI8wj+gdIJdk0Ynjpp8l+trkn58Uqsf6RYrYkEK+3X18EX+TNdtJI0WxAtc+L84SQ==} hasBin: true + abbrev@3.0.1: + resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} + engines: {node: ^18.17.0 || >=20.5.0} + abbrev@4.0.0: resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} engines: {node: ^20.17.0 || >=22.9.0} @@ -5855,6 +5879,10 @@ packages: caniuse-lite@1.0.30001793: resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + case-anything@2.1.13: + resolution: {integrity: sha512-zlOQ80VrQ2Ue+ymH5OuM/DlDq64mEm+B9UTdHULv5osUMD6HalNTblf2b1u/m6QecjsnOkBpqVZ+XPwIVsy7Ng==} + engines: {node: '>=12.13'} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -6613,6 +6641,11 @@ packages: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-libc@1.0.3: + resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} + engines: {node: '>=0.10'} + hasBin: true + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -6679,6 +6712,9 @@ packages: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} + dprint-node@1.0.8: + resolution: {integrity: sha512-iVKnUtYfGrYcW1ZAlfR/F59cUVL8QIhWoBJoSjkkdua/dkWIgjZfiLMeTjiB06X0ZLkQ0M2C1VbUj/CxkIf1zg==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -7319,6 +7355,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + grpc-tools@1.13.1: + resolution: {integrity: sha512-0sttMUxThNIkCTJq5qI0xXMz5zWqV2u3yG1kR3Sj9OokGIoyRBFjoInK9NyW7x5fH7knj48Roh1gq5xbl0VoDQ==} + hasBin: true + hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} @@ -8715,6 +8755,11 @@ packages: non-layered-tidy-tree-layout@2.0.2: resolution: {integrity: sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==} + nopt@8.1.0: + resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + nopt@9.0.0: resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==} engines: {node: ^20.17.0 || >=22.9.0} @@ -10547,6 +10592,16 @@ packages: '@swc/wasm': optional: true + ts-poet@6.12.0: + resolution: {integrity: sha512-xo+iRNMWqyvXpFTaOAvLPA5QAWO6TZrSUs5s4Odaya3epqofBu/fMLHEWl8jPmjhA0s9sgj9sNvF1BmaQlmQkA==} + + ts-proto-descriptors@2.1.0: + resolution: {integrity: sha512-S5EZYEQ6L9KLFfjSRpZWDIXDV/W7tAj8uW7pLsihIxyr62EAVSiKuVPwE8iWnr849Bqa53enex1jhDUcpgquzA==} + + ts-proto@2.11.8: + resolution: {integrity: sha512-+5hzECnyVB33jxjG1BIdzAHcRBm7hjnm8womdJVp2A7xJWihP0drHHVsXYTr9i/LpWNGfh80I+AVVNzFM5AwJw==} + hasBin: true + tsconfig-paths-webpack-plugin@4.2.0: resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==} engines: {node: '>=10.13.0'} @@ -13702,6 +13757,19 @@ snapshots: '@lukeed/csprng@1.1.0': {} + '@mapbox/node-pre-gyp@2.0.3(encoding@0.1.13)': + dependencies: + consola: 3.4.2 + detect-libc: 2.1.2 + https-proxy-agent: 7.0.6 + node-fetch: 2.7.0(encoding@0.1.13) + nopt: 8.1.0 + semver: 7.8.0 + tar: 7.5.15 + transitivePeerDependencies: + - encoding + - supports-color + '@mermaid-js/mermaid-mindmap@9.3.0': dependencies: '@braintree/sanitize-url': 6.0.4 @@ -14157,7 +14225,7 @@ snapshots: '@npmcli/fs@5.0.0': dependencies: - semver: 7.7.4 + semver: 7.8.0 '@npmcli/git@7.0.2': dependencies: @@ -14167,7 +14235,7 @@ snapshots: lru-cache: 11.3.6 npm-pick-manifest: 11.0.3 proc-log: 6.1.0 - semver: 7.7.4 + semver: 7.8.0 which: 6.0.1 '@npmcli/installed-package-contents@4.0.0': @@ -14184,7 +14252,7 @@ snapshots: hosted-git-info: 9.0.3 json-parse-even-better-errors: 5.0.0 proc-log: 6.1.0 - semver: 7.7.4 + semver: 7.8.0 spdx-expression-parse: 4.0.0 '@npmcli/promise-spawn@9.0.1': @@ -16984,6 +17052,8 @@ snapshots: dependencies: argparse: 2.0.1 + abbrev@3.0.1: {} + abbrev@4.0.0: {} accepts@1.3.8: @@ -17564,6 +17634,8 @@ snapshots: caniuse-lite@1.0.30001793: {} + case-anything@2.1.13: {} + ccount@2.0.1: {} chai@6.2.2: {} @@ -18318,6 +18390,8 @@ snapshots: destroy@1.2.0: {} + detect-libc@1.0.3: {} + detect-libc@2.1.2: {} detect-newline@3.1.0: {} @@ -18376,6 +18450,10 @@ snapshots: dotenv@16.6.1: {} + dprint-node@1.0.8: + dependencies: + detect-libc: 1.0.3 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -19158,6 +19236,13 @@ snapshots: graceful-fs@4.2.11: {} + grpc-tools@1.13.1(encoding@0.1.13): + dependencies: + '@mapbox/node-pre-gyp': 2.0.3(encoding@0.1.13) + transitivePeerDependencies: + - encoding + - supports-color + hachure-fill@0.5.2: {} handle-thing@2.0.1: {} @@ -20756,7 +20841,7 @@ snapshots: graceful-fs: 4.2.11 nopt: 9.0.0 proc-log: 6.1.0 - semver: 7.7.4 + semver: 7.8.0 tar: 7.5.15 tinyglobby: 0.2.16 undici: 6.25.0 @@ -20775,6 +20860,10 @@ snapshots: non-layered-tidy-tree-layout@2.0.2: optional: true + nopt@8.1.0: + dependencies: + abbrev: 3.0.1 + nopt@9.0.0: dependencies: abbrev: 4.0.0 @@ -20787,7 +20876,7 @@ snapshots: npm-install-checks@8.0.0: dependencies: - semver: 7.7.4 + semver: 7.8.0 npm-normalize-package-bin@5.0.0: {} @@ -20808,7 +20897,7 @@ snapshots: npm-install-checks: 8.0.0 npm-normalize-package-bin: 5.0.0 npm-package-arg: 13.0.2 - semver: 7.7.4 + semver: 7.8.0 npm-registry-fetch@19.1.1: dependencies: @@ -22881,6 +22970,21 @@ snapshots: optionalDependencies: '@swc/core': 1.15.33(@swc/helpers@0.5.21) + ts-poet@6.12.0: + dependencies: + dprint-node: 1.0.8 + + ts-proto-descriptors@2.1.0: + dependencies: + '@bufbuild/protobuf': 2.12.0 + + ts-proto@2.11.8: + dependencies: + '@bufbuild/protobuf': 2.12.0 + case-anything: 2.1.13 + ts-poet: 6.12.0 + ts-proto-descriptors: 2.1.0 + tsconfig-paths-webpack-plugin@4.2.0: dependencies: chalk: 4.1.2 diff --git a/scripts/sync-ai-protos.mjs b/scripts/sync-ai-protos.mjs new file mode 100644 index 0000000..eda071f --- /dev/null +++ b/scripts/sync-ai-protos.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node +// Refresh the vendored apf-ai-service protos from the sibling +// working tree. Developer convenience only — not invoked from CI. +// +// Per ADR-0024 §"Sub-decision 3 — vendored protos", the BFF holds a +// committed copy of the `.proto` files; this script keeps the copy +// in sync with the upstream repo when a contract change lands. The +// generated TypeScript stubs are NOT regenerated here — chain with +// `pnpm run grpc:codegen` if needed: +// +// pnpm run grpc:sync && pnpm run grpc:codegen +// +// Exits non-zero (with a clear message) when the sibling repo is not +// where the script expects, so a contributor whose layout differs +// gets a useful pointer instead of a silent no-op. + +import { existsSync, readdirSync, copyFileSync, statSync } from 'node:fs'; +import { join, resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const WORKSPACE_ROOT = resolve(HERE, '..'); +const UPSTREAM = resolve(WORKSPACE_ROOT, '..', 'apf-ai-service', 'contract', 'proto'); +const VENDORED = resolve(WORKSPACE_ROOT, 'apps', 'portal-bff', 'src', 'grpc', 'proto', 'apf-ai'); + +if (!existsSync(UPSTREAM) || !statSync(UPSTREAM).isDirectory()) { + console.error( + `apf-ai-service protos not found at ${UPSTREAM}.\n` + + `This script expects the sibling working tree at ../apf-ai-service ` + + `relative to this workspace. Adjust the path or clone the repo and retry.`, + ); + process.exit(1); +} + +const files = readdirSync(UPSTREAM).filter((f) => f.endsWith('.proto')); +if (files.length === 0) { + console.error(`No .proto files under ${UPSTREAM} — nothing to sync.`); + process.exit(1); +} + +for (const file of files) { + copyFileSync(join(UPSTREAM, file), join(VENDORED, file)); + console.log(`synced ${file}`); +} + +console.log( + `\n${files.length} proto file(s) refreshed into ${VENDORED}.\n` + + `Next: pnpm run grpc:codegen (regenerate TypeScript stubs).`, +);