feat(portal-bff): ai-client skeleton — vendored protos + grpc client + Principal mapper
CI / commits (pull_request) Successful in 3m34s
CI / scan (pull_request) Successful in 3m47s
CI / check (pull_request) Successful in 6m15s
CI / a11y (pull_request) Successful in 2m26s
Docs site / build (pull_request) Successful in 1m7s
CI / perf (pull_request) Successful in 4m33s

Lands the BFF-side skeleton for the apf-ai-service relay per ADR-0024,
step 2 of the chantier (skeleton + tests against an in-process fake
gRPC server; no live HTTP endpoint yet — that ships in the SSE-bridge
PR).

What ships:

- contract/proto vendoring at apps/portal-bff/src/grpc/proto/apf-ai/
  (common, chat, rag, ingestion, models). pnpm run grpc:sync refreshes
  from the sibling apf-ai-service tree; both .proto and the ts-proto
  TypeScript stubs under grpc/gen/ are committed for hermetic builds
  and reviewable diffs.
- Codegen via pnpm run grpc:codegen using grpc-tools' bundled protoc
  and ts-proto (outputServices=grpc-js, esModuleInterop, forceLong,
  useOptionals=messages). Generated tree is excluded from Prettier
  and ESLint so hand-rules do not chase codegen output.
- assertAiServiceConfig() pre-flight validator alongside the other
  config validators (AI_SERVICE_GRPC_ENDPOINT, AI_SERVICE_CLIENT_ID,
  AI_SERVICE_GRPC_TLS).
- AiClientModule provides ChatClient / RagClient / IngestionClient /
  ModelsClient wrappers, a GrpcMetadataBuilder that stamps every call
  with x-client-id + x-correlation-id (W3C trace-id from OTel when a
  span is active, explicit override or UUID fallback otherwise), and
  a PrincipalMapper that hashes the Entra oid via HashUserIdService
  so the proto Principal.subject matches audit.events.actor_id_hash
  exactly (ADR-0013 cross-reference contract).
- Specs cover: env validator, principal mapper, metadata builder
  (OTel span / override / fallback paths), chat client streaming +
  cancellation against an in-process fake gRPC ChatService, rag
  client unary happy path + error propagation, and module bootstrap.

AiClientModule is NOT imported in AppModule yet — the SSE-bridge
controller and its live route ship in the next PR.
This commit is contained in:
Julien Gautier
2026-05-19 20:50:04 +02:00
parent 3f3f47317b
commit 428d19f60f
32 changed files with 6396 additions and 11 deletions
@@ -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<string, string | undefined>): 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"/);
});
});
@@ -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-<env>` (`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',
};
}
@@ -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<ReturnType<ReturnType<typeof Test.createTestingModule>['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();
});
});
@@ -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.
}
@@ -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<ChatRequest, ChatEvent>) => void;
beforeAll(async () => {
server = new Server();
server.addService(ChatServiceService, {
chat: (call: ServerWritableStream<ChatRequest, ChatEvent>) => {
serverObservations.request = call.request;
serverObservations.metadata = call.metadata;
call.on('cancelled', () => {
serverObservations.cancelled = true;
});
chatHandler(call);
},
});
port = await new Promise<number>((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<void>((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<ChatEvent>): Promise<ChatEvent[]> {
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<void>((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();
}
});
});
@@ -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<ChatEvent>` — 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<ChatEvent>` 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<ChatEvent> {
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;
}
}
@@ -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<typeof trace.getActiveSpan> = undefined;
let getActiveSpanSpy: ReturnType<typeof jest.spyOn>;
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<typeof trace.getActiveSpan>;
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<typeof trace.getActiveSpan>;
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);
});
});
@@ -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;
}
}
@@ -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<IngestDocumentResponse> {
const metadata = this.metadata.build({ correlationId: options.correlationId });
return new Promise<IngestDocumentResponse>((resolve, reject) => {
this.grpc.ingestDocument(request, metadata, (err, response) => {
if (err) {
reject(err);
return;
}
resolve(response);
});
});
}
getDocument(
request: GetDocumentRequest,
options: { correlationId?: string } = {},
): Promise<DocumentSummary> {
const metadata = this.metadata.build({ correlationId: options.correlationId });
return new Promise<DocumentSummary>((resolve, reject) => {
this.grpc.getDocument(request, metadata, (err, response) => {
if (err) {
reject(err);
return;
}
resolve(response);
});
});
}
deleteDocument(
request: DeleteDocumentRequest,
options: { correlationId?: string } = {},
): Promise<DeleteDocumentResponse> {
const metadata = this.metadata.build({ correlationId: options.correlationId });
return new Promise<DeleteDocumentResponse>((resolve, reject) => {
this.grpc.deleteDocument(request, metadata, (err, response) => {
if (err) {
reject(err);
return;
}
resolve(response);
});
});
}
}
@@ -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<ListModelsResponse> {
const metadata = this.metadata.build({ correlationId: options.correlationId });
return new Promise<ListModelsResponse>((resolve, reject) => {
this.grpc.listModels(request, metadata, (err, response) => {
if (err) {
reject(err);
return;
}
resolve(response);
});
});
}
}
@@ -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,
);
});
});
@@ -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<Record<string, string>>;
}
/**
* 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,
},
};
}
}
@@ -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<RagSearchRequest, RagSearchResponse>,
callback: sendUnaryData<RagSearchResponse>,
) => 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<number>((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<void>((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 });
});
});
@@ -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<RagSearchResponse> {
const metadata = this.metadata.build({ correlationId: options.correlationId });
return new Promise<RagSearchResponse>((resolve, reject) => {
this.grpc.search(request, metadata, (err, response) => {
if (err) {
reject(err);
return;
}
resolve(response);
});
});
}
}
@@ -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');
File diff suppressed because it is too large Load Diff
@@ -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<Principal> = {
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 extends Exact<DeepPartial<Principal>, I>>(base?: I): Principal {
return Principal.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<Principal>, 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<Principal_AttributesEntry> = {
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 extends Exact<DeepPartial<Principal_AttributesEntry>, I>>(base?: I): Principal_AttributesEntry {
return Principal_AttributesEntry.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<Principal_AttributesEntry>, 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<AclSpec> = {
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 extends Exact<DeepPartial<AclSpec>, I>>(base?: I): AclSpec {
return AclSpec.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<AclSpec>, 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<AclSpec_RequiredAttributesEntry> = {
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 extends Exact<DeepPartial<AclSpec_RequiredAttributesEntry>, I>>(base?: I): AclSpec_RequiredAttributesEntry {
return AclSpec_RequiredAttributesEntry.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<AclSpec_RequiredAttributesEntry>, 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<ChatMessage> = {
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 extends Exact<DeepPartial<ChatMessage>, I>>(base?: I): ChatMessage {
return ChatMessage.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<ChatMessage>, 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<ToolDescriptor> = {
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 extends Exact<DeepPartial<ToolDescriptor>, I>>(base?: I): ToolDescriptor {
return ToolDescriptor.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<ToolDescriptor>, 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> = T extends Builtin ? T
: T extends Long ? string | number | Long : T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>;
type KeysOfUnion<T> = T extends T ? keyof T : never;
type Exact<P, I extends P> = P extends Builtin ? P
: P & { [K in keyof P]: Exact<P[K], I[K]> } & { [K in Exclude<keyof I, KeysOfUnion<P>>]: never };
function isObject(value: any): boolean {
return typeof value === "object" && value !== null;
}
function isSet(value: any): boolean {
return value !== null && value !== undefined;
}
interface MessageFns<T> {
encode(message: T, writer?: BinaryWriter): BinaryWriter;
decode(input: BinaryReader | Uint8Array, length?: number): T;
fromJSON(object: any): T;
toJSON(message: T): unknown;
create<I extends Exact<DeepPartial<T>, I>>(base?: I): T;
fromPartial<I extends Exact<DeepPartial<T>, I>>(object: I): T;
}
@@ -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<any> | 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<Struct> & 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 extends Exact<DeepPartial<Struct>, I>>(base?: I): Struct {
return Struct.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<Struct>, 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<Struct_FieldsEntry> = {
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 extends Exact<DeepPartial<Struct_FieldsEntry>, I>>(base?: I): Struct_FieldsEntry {
return Struct_FieldsEntry.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<Struct_FieldsEntry>, 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<Value> & 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 extends Exact<DeepPartial<Value>, I>>(base?: I): Value {
return Value.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<Value>, 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<any> | 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<ListValue> & 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 extends Exact<DeepPartial<ListValue>, I>>(base?: I): ListValue {
return ListValue.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<ListValue>, I>>(object: I): ListValue {
const message = createBaseListValue();
message.values = object.values?.map((e) => e) || [];
return message;
},
wrap(array: Array<any> | undefined): ListValue {
const result = createBaseListValue();
result.values = array ?? [];
return result;
},
unwrap(message: ListValue): Array<any> {
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> = T extends Builtin ? T
: T extends Long ? string | number | Long : T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>;
type KeysOfUnion<T> = T extends T ? keyof T : never;
type Exact<P, I extends P> = P extends Builtin ? P
: P & { [K in keyof P]: Exact<P[K], I[K]> } & { [K in Exclude<keyof I, KeysOfUnion<P>>]: never };
function isObject(value: any): boolean {
return typeof value === "object" && value !== null;
}
function isSet(value: any): boolean {
return value !== null && value !== undefined;
}
interface MessageFns<T> {
encode(message: T, writer?: BinaryWriter): BinaryWriter;
decode(input: BinaryReader | Uint8Array, length?: number): T;
fromJSON(object: any): T;
toJSON(message: T): unknown;
create<I extends Exact<DeepPartial<T>, I>>(base?: I): T;
fromPartial<I extends Exact<DeepPartial<T>, 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<any> | undefined;
}
interface ListValueWrapperFns {
wrap(array: Array<any> | undefined): ListValue;
unwrap(message: ListValue): Array<any>;
}
@@ -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<Timestamp> = {
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 extends Exact<DeepPartial<Timestamp>, I>>(base?: I): Timestamp {
return Timestamp.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<Timestamp>, 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> = T extends Builtin ? T
: T extends Long ? string | number | Long : T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>;
type KeysOfUnion<T> = T extends T ? keyof T : never;
type Exact<P, I extends P> = P extends Builtin ? P
: P & { [K in keyof P]: Exact<P[K], I[K]> } & { [K in Exclude<keyof I, KeysOfUnion<P>>]: never };
function isSet(value: any): boolean {
return value !== null && value !== undefined;
}
interface MessageFns<T> {
encode(message: T, writer?: BinaryWriter): BinaryWriter;
decode(input: BinaryReader | Uint8Array, length?: number): T;
fromJSON(object: any): T;
toJSON(message: T): unknown;
create<I extends Exact<DeepPartial<T>, I>>(base?: I): T;
fromPartial<I extends Exact<DeepPartial<T>, I>>(object: I): T;
}
@@ -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<IngestChunk> = {
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 extends Exact<DeepPartial<IngestChunk>, I>>(base?: I): IngestChunk {
return IngestChunk.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<IngestChunk>, 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<IngestDocumentRequest> = {
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 extends Exact<DeepPartial<IngestDocumentRequest>, I>>(base?: I): IngestDocumentRequest {
return IngestDocumentRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<IngestDocumentRequest>, 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<IngestDocumentResponse> = {
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 extends Exact<DeepPartial<IngestDocumentResponse>, I>>(base?: I): IngestDocumentResponse {
return IngestDocumentResponse.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<IngestDocumentResponse>, 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<GetDocumentRequest> = {
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 extends Exact<DeepPartial<GetDocumentRequest>, I>>(base?: I): GetDocumentRequest {
return GetDocumentRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<GetDocumentRequest>, 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<DocumentSummary> = {
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 extends Exact<DeepPartial<DocumentSummary>, I>>(base?: I): DocumentSummary {
return DocumentSummary.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<DocumentSummary>, 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<DeleteDocumentRequest> = {
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 extends Exact<DeepPartial<DeleteDocumentRequest>, I>>(base?: I): DeleteDocumentRequest {
return DeleteDocumentRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<DeleteDocumentRequest>, I>>(object: I): DeleteDocumentRequest {
const message = createBaseDeleteDocumentRequest();
message.id = object.id ?? "";
return message;
},
};
function createBaseDeleteDocumentResponse(): DeleteDocumentResponse {
return { deleted: false };
}
export const DeleteDocumentResponse: MessageFns<DeleteDocumentResponse> = {
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 extends Exact<DeepPartial<DeleteDocumentResponse>, I>>(base?: I): DeleteDocumentResponse {
return DeleteDocumentResponse.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<DeleteDocumentResponse>, 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<IngestDocumentRequest, IngestDocumentResponse>;
getDocument: handleUnaryCall<GetDocumentRequest, DocumentSummary>;
deleteDocument: handleUnaryCall<DeleteDocumentRequest, DeleteDocumentResponse>;
}
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<CallOptions>,
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<CallOptions>,
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<CallOptions>,
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<ClientOptions>): IngestionServiceClient;
service: typeof IngestionServiceService;
serviceName: string;
};
type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
type DeepPartial<T> = T extends Builtin ? T
: T extends Long ? string | number | Long : T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>;
type KeysOfUnion<T> = T extends T ? keyof T : never;
type Exact<P, I extends P> = P extends Builtin ? P
: P & { [K in keyof P]: Exact<P[K], I[K]> } & { [K in Exclude<keyof I, KeysOfUnion<P>>]: 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<T> {
encode(message: T, writer?: BinaryWriter): BinaryWriter;
decode(input: BinaryReader | Uint8Array, length?: number): T;
fromJSON(object: any): T;
toJSON(message: T): unknown;
create<I extends Exact<DeepPartial<T>, I>>(base?: I): T;
fromPartial<I extends Exact<DeepPartial<T>, I>>(object: I): T;
}
@@ -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<ListModelsRequest> = {
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 extends Exact<DeepPartial<ListModelsRequest>, I>>(base?: I): ListModelsRequest {
return ListModelsRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<ListModelsRequest>, I>>(_: I): ListModelsRequest {
const message = createBaseListModelsRequest();
return message;
},
};
function createBaseProviderInfo(): ProviderInfo {
return { discriminator: "", capabilities: "", endpoint: "", model: "", embeddingModel: "" };
}
export const ProviderInfo: MessageFns<ProviderInfo> = {
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 extends Exact<DeepPartial<ProviderInfo>, I>>(base?: I): ProviderInfo {
return ProviderInfo.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<ProviderInfo>, 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<ListModelsResponse> = {
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 extends Exact<DeepPartial<ListModelsResponse>, I>>(base?: I): ListModelsResponse {
return ListModelsResponse.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<ListModelsResponse>, 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<ListModelsRequest, ListModelsResponse>;
}
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<CallOptions>,
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<ClientOptions>): ModelsServiceClient;
service: typeof ModelsServiceService;
serviceName: string;
};
type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
type DeepPartial<T> = T extends Builtin ? T
: T extends Long ? string | number | Long : T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>;
type KeysOfUnion<T> = T extends T ? keyof T : never;
type Exact<P, I extends P> = P extends Builtin ? P
: P & { [K in keyof P]: Exact<P[K], I[K]> } & { [K in Exclude<keyof I, KeysOfUnion<P>>]: never };
function isSet(value: any): boolean {
return value !== null && value !== undefined;
}
interface MessageFns<T> {
encode(message: T, writer?: BinaryWriter): BinaryWriter;
decode(input: BinaryReader | Uint8Array, length?: number): T;
fromJSON(object: any): T;
toJSON(message: T): unknown;
create<I extends Exact<DeepPartial<T>, I>>(base?: I): T;
fromPartial<I extends Exact<DeepPartial<T>, I>>(object: I): T;
}
+544
View File
@@ -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<RagSearchFilters> = {
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 extends Exact<DeepPartial<RagSearchFilters>, I>>(base?: I): RagSearchFilters {
return RagSearchFilters.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<RagSearchFilters>, 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<RagSearchRequest> = {
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 extends Exact<DeepPartial<RagSearchRequest>, I>>(base?: I): RagSearchRequest {
return RagSearchRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<RagSearchRequest>, 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<Chunk> = {
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 extends Exact<DeepPartial<Chunk>, I>>(base?: I): Chunk {
return Chunk.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<Chunk>, 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<RagSearchResponse> = {
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 extends Exact<DeepPartial<RagSearchResponse>, I>>(base?: I): RagSearchResponse {
return RagSearchResponse.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<RagSearchResponse>, 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<RagSearchRequest, RagSearchResponse>;
}
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<CallOptions>,
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<ClientOptions>): RagServiceClient;
service: typeof RagServiceService;
serviceName: string;
};
type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
type DeepPartial<T> = T extends Builtin ? T
: T extends Long ? string | number | Long : T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
: Partial<T>;
type KeysOfUnion<T> = T extends T ? keyof T : never;
type Exact<P, I extends P> = P extends Builtin ? P
: P & { [K in keyof P]: Exact<P[K], I[K]> } & { [K in Exclude<keyof I, KeysOfUnion<P>>]: never };
function isObject(value: any): boolean {
return typeof value === "object" && value !== null;
}
function isSet(value: any): boolean {
return value !== null && value !== undefined;
}
interface MessageFns<T> {
encode(message: T, writer?: BinaryWriter): BinaryWriter;
decode(input: BinaryReader | Uint8Array, length?: number): T;
fromJSON(object: any): T;
toJSON(message: T): unknown;
create<I extends Exact<DeepPartial<T>, I>>(base?: I): T;
fromPartial<I extends Exact<DeepPartial<T>, I>>(object: I): T;
}
@@ -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;
}
}
@@ -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<string, string> 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<string, string> 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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}