Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,12 @@ The proxy forwards the request to Venice unchanged. Non-streaming returns the JS

### E2EE, streaming (`stream: true`)
1. `E2eeService.createSession(model)` — fetches the TEE attestation, **verifies it**, and performs ECDH key exchange (cached for `SESSION_TTL_MS`).
2. `E2eeService.encrypt(messages, session)` — AES-256-GCM encrypts each message; returns `encryptedMessages`, TEE headers, and `venice_parameters: { enable_e2ee: true }`.
3. The proxy POSTs to Venice with `stream: true`.
4. `E2eeService.decryptStream(body, session)` decrypts each SSE chunk; the controller re-wraps the plaintext deltas as standard OpenAI `chat.completion.chunk` events, terminated by `data: [DONE]`.
2. `E2eeService.encrypt(messages, session)` — AES-256-GCM encrypts prompt and tool-result `content`. `tool_calls`, `tool_call_id`, and `name` stay JSON. `content: null` on a tool-only assistant turn stays `null`. Returns `encryptedMessages`, TEE headers, and `venice_parameters: { enable_e2ee: true }`.
3. The proxy POSTs to Venice with `stream: true`. `tools` and `tool_choice` are forwarded as-is.
4. `E2eeService.decryptStream(body, session)` yields decrypted text, unchanged `tool_calls` deltas, and the real `finish_reason`. The controller re-wraps those as OpenAI `chat.completion.chunk` events, terminated by `data: [DONE]`.

### E2EE, non-streaming (`stream: false` or omitted)
Venice **requires** streaming for E2EE. So the proxy still streams upstream and decrypts, but **buffers** the deltas and returns a single OpenAI `chat.completion` object. This is why non-streaming E2EE requests still work for clients that don't speak SSE — the streaming requirement is an upstream constraint we hide.
Venice **requires** streaming for E2EE. So the proxy still streams upstream and decrypts, but **buffers** text and tool-call deltas (assembled by `index`) and returns a single OpenAI `chat.completion` object. A tool-only turn has `content: null` and `finish_reason: "tool_calls"`. This is why non-streaming E2EE requests still work for clients that don't speak SSE — the streaming requirement is an upstream constraint we hide.

---

Expand Down Expand Up @@ -129,10 +129,11 @@ Proxy (client side) Venice CPU TEE (Intel TDX)

Two subtleties worth recording:

- **Per-chunk ephemeral keys.** Every response chunk carries a *fresh* server ephemeral public key, so each chunk requires its own ECDH derivation. The library handles this in `decryptStream`; we just consume plaintext deltas.
- **Per-chunk ephemeral keys.** Every encrypted response chunk carries a *fresh* server ephemeral public key, so each chunk requires its own ECDH derivation. The library handles this in `decryptStream`; we consume decrypted text plus plaintext tool-call deltas.
- **Plaintext passthrough.** Some tokens (e.g. whitespace) may arrive unencrypted; the library handles that and the `[DONE]` sentinel internally.
- **Tool metadata is plaintext on the wire.** Tool names, JSON schemas, call ids, and arguments are not part of the encrypted `content` channel — the enclave chat template reads them as JSON. Prompts and tool-result bodies are ciphertext.

Plaintext exists in only two places: **on this proxy** (briefly, in memory) and **inside the TEE**. It never traverses the network in the clear.
Prompt and tool-result plaintext exists in only two places: **on this proxy** (briefly, in memory) and **inside the TEE**. That text never traverses the network in the clear. Tool-call metadata does.

---

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ Interactive API documentation is served at **[http://localhost:4337/docs](http:/
└─────────┘ plaintext SSE └──────────────────┘ encrypted stream └──────────────┘
```

- **`e2ee-*` models** → the proxy creates a verified TEE session, encrypts your messages, forwards them to Venice with `venice_parameters: { enable_e2ee: true }`, and decrypts the streamed response on the fly.
- **`e2ee-*` models** → the proxy creates a verified TEE session, encrypts prompts and tool-result bodies, forwards them to Venice with `venice_parameters: { enable_e2ee: true }`, and decrypts the streamed response on the fly. Tool names, schemas, call ids, and arguments stay plaintext (the enclave reads them as JSON). A tool call is returned as `delta.tool_calls` with `finish_reason: "tool_calls"`, so an agent can run the tool and send the result back without the history being flattened to text.
- **All other models** → the request is proxied straight through to Venice unchanged.

**Under the hood:** NestJS (TypeScript, strict mode); encryption via the [`@axlabs/venice-e2ee`](https://github.com/AxLabs/venice-e2ee) library (ECDH secp256k1 → HKDF-SHA256 → AES-256-GCM + TEE attestation); Swagger UI at `/docs`.
Expand Down
14 changes: 7 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
"prepublishOnly": "npm run build"
},
"dependencies": {
"@axlabs/venice-e2ee": "^0.2.0",
"@axlabs/venice-e2ee": "git+https://github.com/AxLabs/venice-e2ee.git#7b4e6528f7f870402224bc887de06f0952f7f575",
"@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.0",
Expand Down
60 changes: 48 additions & 12 deletions src/chat/chat.service.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
import { E2eeService } from '../e2ee/e2ee.service';
import { VeniceHttpService } from '../common/venice/venice-http.service';
import type { ChatCompletion, ChatCompletionChunk } from '../common/interfaces/openai.interface';
import type {
AssembledToolCall,
ChatCompletion,
ChatCompletionChunk,
} from '../common/interfaces/openai.interface';
import {
appendToolCallDeltas,
buildChatCompletion,
buildContentChunk,
buildFinalChunk,
buildRoleChunk,
buildToolCallsChunk,
compactToolCalls,
countIncomingMessageChars,
countOutgoingE2eeMessageChars,
generateCompletionId,
Expand Down Expand Up @@ -37,8 +44,8 @@ export class ChatService {
}

/**
* E2EE streaming: synthesize OpenAI SSE chunks from decrypted text deltas.
* E2EE models always stream upstream (a Venice requirement).
* E2EE streaming: synthesize OpenAI SSE chunks from decrypted text deltas
* and plaintext tool-call deltas. E2EE models always stream upstream.
*/
async *streamE2EE(dto: ChatCompletionRequestDto): AsyncGenerator<ChatCompletionChunk> {
this.logger.debug(`E2EE stream: establishing session for "${dto.model}"`);
Expand Down Expand Up @@ -94,43 +101,72 @@ export class ChatService {

let decryptedChunks = 0;
let decryptedChars = 0;
let toolCallDeltas = 0;
let finishReason = 'stop';
yield buildRoleChunk({ id, model: dto.model, created });
for await (const text of this.e2ee.decryptStream(response.body, session)) {
if (text.length > 0) {
decryptedChunks += 1;
decryptedChars += text.length;
yield buildContentChunk({ id, model: dto.model, content: text, created });
for await (const event of this.e2ee.decryptStream(response.body, session)) {
switch (event.kind) {
case 'content':
if (event.content.length > 0) {
decryptedChunks += 1;
decryptedChars += event.content.length;
yield buildContentChunk({ id, model: dto.model, content: event.content, created });
}
break;
case 'tool_calls':
toolCallDeltas += event.tool_calls.length;
yield buildToolCallsChunk({
id,
model: dto.model,
created,
toolCalls: event.tool_calls,
});
break;
case 'finish':
finishReason = event.finish_reason;
break;
default: {
const unexpected: never = event;
throw new Error(`Unhandled E2EE stream event: ${JSON.stringify(unexpected)}`);
}
}
}
yield buildFinalChunk({ id, model: dto.model, created });
yield buildFinalChunk({ id, model: dto.model, created, finishReason });
this.logger.debug(
`E2EE stream: decrypted ${decryptedChunks} chunk(s), ${decryptedChars} char(s) (id=${id})`,
`E2EE stream: decrypted ${decryptedChunks} chunk(s), ${decryptedChars} char(s), ` +
`${toolCallDeltas} tool-call delta(s), finish_reason=${finishReason} (id=${id})`,
);
}

/**
* E2EE non-streaming: Venice requires streaming for E2EE, so we stream + decrypt
* upstream, buffer the deltas, and return a single OpenAI completion object.
* upstream, buffer text and tool-call deltas, and return one completion object.
*/
async completeE2EE(dto: ChatCompletionRequestDto): Promise<ChatCompletion> {
this.logger.debug(`E2EE non-streaming: buffering decrypted stream for "${dto.model}"`);
let content = '';
let id = '';
let finishReason = 'stop';
const toolCalls: AssembledToolCall[] = [];

for await (const chunk of this.streamE2EE(dto)) {
id = chunk.id;
const choice = chunk.choices[0];
if (choice?.delta?.content) content += choice.delta.content;
if (choice?.delta?.tool_calls) appendToolCallDeltas(toolCalls, choice.delta.tool_calls);
if (choice?.finish_reason) finishReason = choice.finish_reason;
}

this.logger.debug(`E2EE non-streaming: assembled ${content.length} char(s) (id=${id})`);
const assembled = compactToolCalls(toolCalls);
this.logger.debug(
`E2EE non-streaming: assembled ${content.length} char(s), ${assembled.length} tool call(s) (id=${id})`,
);
return buildChatCompletion({
id: id || generateCompletionId(),
model: dto.model,
content,
finishReason,
toolCalls: assembled,
});
}

Expand Down
17 changes: 14 additions & 3 deletions src/chat/dto/chat-completion-response.dto.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

class ChatCompletionMessageDto {
@ApiProperty({ example: 'assistant' })
role!: string;

@ApiProperty({ example: 'Hello! How can I help you today?' })
content!: string;
@ApiProperty({
example: 'Hello! How can I help you today?',
nullable: true,
description: 'Null when the assistant message is only a tool call.',
})
content!: string | null;

@ApiPropertyOptional({
description: 'Tool calls assembled from the stream, when the model asked to call tools.',
type: 'array',
items: { type: 'object' },
})
tool_calls?: unknown[];
}

class ChatCompletionChoiceDto {
Expand Down
34 changes: 27 additions & 7 deletions src/chat/dto/chat-message.dto.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsDefined, IsOptional, IsString } from 'class-validator';
import type { ChatMessageContent } from '../../common/interfaces/openai.interface';
import { IsArray, IsOptional, IsString } from 'class-validator';
import type { ChatMessageContent, ToolCallDelta } from '../../common/interfaces/openai.interface';

/**
* A single chat message. Mirrors the OpenAI message object. `content` accepts
* either a plain string or the structured array form; both are normalized to
* text before encryption.
* a string, the structured array form, or null when the assistant turn is only
* a tool call. Tool-result bodies are encrypted; tool names, ids, and
* arguments are forwarded in the clear.
*/
export class ChatMessageDto {
@ApiProperty({
Expand All @@ -17,15 +18,34 @@ export class ChatMessageDto {
role!: string;

@ApiProperty({
description: 'The message content. A string, or an array of content parts.',
description:
'The message content. A string, an array of content parts, or null when an assistant message is only a tool call.',
nullable: true,
oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'object' } }],
example: 'Hello!',
})
@IsDefined()
content!: ChatMessageContent;
@IsOptional()
content?: ChatMessageContent;

@ApiPropertyOptional({ description: 'Optional name of the message author.' })
@IsOptional()
@IsString()
name?: string;

@ApiPropertyOptional({
description: 'Id of the tool call a `tool` message answers.',
})
@IsOptional()
@IsString()
tool_call_id?: string;

@ApiPropertyOptional({
description:
'Tool calls emitted by an assistant message. Names, ids, and arguments stay plaintext.',
type: 'array',
items: { type: 'object' },
})
@IsOptional()
@IsArray()
tool_calls?: ToolCallDelta[];
}
34 changes: 31 additions & 3 deletions src/common/interfaces/openai.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,48 @@ export interface ChatContentPart {
[key: string]: unknown;
}

/** OpenAI message content can be a plain string or an array of typed parts. */
/** OpenAI message content can be a plain string, an array of typed parts, or null. */
export type ChatMessageContent = string | ChatContentPart[] | null;

/**
* A streaming tool-call delta, or the call object stored on an assistant message.
* Names, ids, and arguments stay plaintext; Venice's enclave reads them as JSON.
*/
export interface ToolCallDelta {
index?: number;
id?: string;
type?: string;
function?: {
name?: string;
arguments?: string;
};
}

/** Tool call assembled from streaming deltas for a non-streaming completion. */
export interface AssembledToolCall {
id: string;
type: string;
function: {
name: string;
arguments: string;
};
}

export interface ChatMessage {
role: ChatRole;
content: ChatMessageContent;
content?: ChatMessageContent;
name?: string;
tool_call_id?: string;
tool_calls?: ToolCallDelta[];
[key: string]: unknown;
}

export interface ChatCompletionChoice {
index: number;
message: {
role: 'assistant';
content: string;
content: string | null;
tool_calls?: AssembledToolCall[];
};
finish_reason: string | null;
logprobs: null;
Expand Down Expand Up @@ -55,6 +82,7 @@ export interface ChatCompletionChunkChoice {
delta: {
role?: 'assistant';
content?: string;
tool_calls?: ToolCallDelta[];
};
finish_reason: string | null;
logprobs: null;
Expand Down
Loading
Loading