feat(server): refactor copilot (#14892)

#### PR Dependency Tree


* **PR #14892** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)
This commit is contained in:
DarkSky
2026-05-04 00:36:47 +08:00
committed by GitHub
parent fa8f1a096c
commit d64f368623
239 changed files with 35859 additions and 16777 deletions
@@ -0,0 +1,16 @@
import { Injectable } from '@nestjs/common';
import { promptAttachmentToUrl } from '../providers/utils';
import type { ChatMessage } from '../types';
@Injectable()
export class HistoryAttachmentUrlProjector {
projectMessages(messages: ChatMessage[]): ChatMessage[] {
return messages.map(message => ({
...message,
attachments: message.attachments
?.map(attachment => promptAttachmentToUrl(attachment))
.filter((attachment): attachment is string => !!attachment),
}));
}
}
@@ -0,0 +1,99 @@
import { Injectable } from '@nestjs/common';
import { AiPromptRole } from '@prisma/client';
import type { Conversation, Turn } from '../core';
import { chatMessageFromTurn } from '../core';
import type { ResolvedPrompt } from '../prompt';
import { type ChatHistory } from '../types';
import { HistoryAttachmentUrlProjector } from './history-attachment-url-projector';
import { HistoryPromptPreloadProjector } from './history-prompt-preload-projector';
import {
HistoryVisibilityPolicy,
type ProjectConversationOptions,
} from './history-visibility-policy';
export type CanonicalConversationHistory = {
conversation: Conversation;
turns: Turn[];
prompt: ResolvedPrompt;
tokenCost: number;
};
export type CanonicalConversationMeta = Omit<
CanonicalConversationHistory,
'turns'
>;
@Injectable()
export class CompatHistoryProjector {
constructor(
private readonly visibility: HistoryVisibilityPolicy,
private readonly preloadProjector: HistoryPromptPreloadProjector,
private readonly attachmentUrls: HistoryAttachmentUrlProjector
) {}
private projectSessionBase(
history: CanonicalConversationMeta
): Omit<ChatHistory, 'messages'> {
const { conversation, prompt, tokenCost } = history;
return {
userId: conversation.userId,
sessionId: conversation.id,
workspaceId: conversation.workspaceId,
docId: conversation.docId,
parentSessionId: conversation.parentId,
pinned: conversation.pinned,
title: conversation.title,
action: prompt.action || null,
model: prompt.model,
optionalModels: prompt.optionalModels || [],
promptName: prompt.name,
tokens: tokenCost,
createdAt: conversation.createdAt,
updatedAt: conversation.updatedAt,
};
}
projectSession(
history: CanonicalConversationMeta,
_options: ProjectConversationOptions
): Omit<ChatHistory, 'messages'> | undefined {
return this.projectSessionBase(history);
}
projectHistory(
history: CanonicalConversationHistory,
options: ProjectConversationOptions & {
withMessages: boolean;
withPrompt?: boolean;
}
): ChatHistory | undefined {
if (!this.visibility.shouldExposeHistory(history, options)) return;
const base = this.projectSessionBase(history);
const { turns } = history;
const messages = turns.map(turn => chatMessageFromTurn(turn));
const preload = this.preloadProjector.project(
history,
options.withMessages,
options.withPrompt
);
const projectedMessages = options.withMessages
? preload
.concat(messages)
.filter(
message =>
message.role !== AiPromptRole.user ||
!!message.content.trim() ||
!!message.attachments?.length
)
.map(message => ({ ...message }))
: [];
return {
...base,
messages: this.attachmentUrls.projectMessages(projectedMessages),
};
}
}
@@ -0,0 +1,37 @@
import { Injectable } from '@nestjs/common';
import { AiPromptRole } from '@prisma/client';
import { PromptService } from '../prompt/service';
import type { ChatMessage } from '../types';
import type { CanonicalConversationHistory } from './history-projector';
@Injectable()
export class HistoryPromptPreloadProjector {
constructor(private readonly prompts: PromptService) {}
project(
history: CanonicalConversationHistory,
withMessages: boolean,
withPrompt?: boolean
): ChatMessage[] {
if (!withMessages || !withPrompt) {
return [];
}
const preload = this.prompts
.finish(
history.prompt,
history.turns[0] ? history.turns[0].metadata : {},
history.conversation.id
)
.filter(({ role }) => role !== AiPromptRole.system) as ChatMessage[];
preload.forEach((message, index) => {
message.createdAt = new Date(
history.conversation.createdAt.getTime() - preload.length - index - 1
);
});
return preload;
}
}
@@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import type { CanonicalConversationHistory } from './history-projector';
export type ProjectConversationOptions = {
requestUserId: string | undefined;
action?: boolean;
skipVisibilityFilter?: boolean;
};
@Injectable()
export class HistoryVisibilityPolicy {
shouldExposeHistory(
history: CanonicalConversationHistory,
options: ProjectConversationOptions
): boolean {
if (options.skipVisibilityFilter) {
return true;
}
return !(
(history.conversation.userId === options.requestUserId &&
!!options.action !== !!history.prompt.action) ||
(history.conversation.userId !== options.requestUserId &&
!!history.prompt.action)
);
}
}
@@ -0,0 +1,118 @@
import { randomUUID } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { Cache } from '../../../base';
import type { PromptMessage } from '../providers/types';
const SUBMISSION_TTL = 24 * 60 * 60 * 1000;
type StoredCompatSubmission = {
id: string;
sessionId: string;
content?: string;
attachments?: PromptMessage['attachments'];
params?: Record<string, any>;
createdAt: string;
};
type StoredAcceptedSubmission = {
sessionId: string;
turnId: string;
acceptedAt: string;
};
export type CompatSubmission = Omit<StoredCompatSubmission, 'createdAt'> & {
createdAt: Date;
};
export type AcceptedCompatSubmission = Omit<
StoredAcceptedSubmission,
'acceptedAt'
> & {
acceptedAt: Date;
};
@Injectable()
export class CompatSubmissionStore {
constructor(private readonly cache: Cache) {}
private submissionKey(token: string) {
return `copilot:submission:${token}`;
}
private acceptedKey(token: string) {
return `copilot:submission:${token}:accepted`;
}
private fromStoredSubmission(
submission?: StoredCompatSubmission
): CompatSubmission | undefined {
if (!submission) {
return;
}
return {
...submission,
createdAt: new Date(submission.createdAt),
};
}
private fromStoredAccepted(
accepted?: StoredAcceptedSubmission
): AcceptedCompatSubmission | undefined {
if (!accepted) {
return;
}
return {
...accepted,
acceptedAt: new Date(accepted.acceptedAt),
};
}
async create(
submission: Omit<CompatSubmission, 'id' | 'createdAt'>
): Promise<string> {
const token = randomUUID();
const stored: StoredCompatSubmission = {
...submission,
id: token,
createdAt: new Date().toISOString(),
};
await this.cache.set(this.submissionKey(token), stored, {
ttl: SUBMISSION_TTL,
});
return token;
}
async get(token: string): Promise<CompatSubmission | undefined> {
return this.fromStoredSubmission(
await this.cache.get<StoredCompatSubmission>(this.submissionKey(token))
);
}
async markAccepted(
token: string,
accepted: { sessionId: string; turnId: string }
) {
await this.cache.set<StoredAcceptedSubmission>(
this.acceptedKey(token),
{
...accepted,
acceptedAt: new Date().toISOString(),
},
{ ttl: SUBMISSION_TTL }
);
await this.cache.delete(this.submissionKey(token));
}
async getAccepted(
token: string
): Promise<AcceptedCompatSubmission | undefined> {
return this.fromStoredAccepted(
await this.cache.get<StoredAcceptedSubmission>(this.acceptedKey(token))
);
}
}
@@ -5,7 +5,6 @@ import {
StorageJSONSchema,
StorageProviderConfig,
} from '../../base';
import { CopilotPromptScenario } from './prompt/prompts';
import {
AnthropicOfficialConfig,
AnthropicVertexConfig,
@@ -41,6 +40,7 @@ export const RustRequestMiddlewareValues = [
'normalize_messages',
'clamp_max_tokens',
'tool_schema_rewrite',
'openai_request_compat',
] as const;
export type RustRequestMiddleware =
(typeof RustRequestMiddlewareValues)[number];
@@ -83,7 +83,7 @@ export type CopilotProviderProfile = CopilotProviderProfileCommon &
}[CopilotProviderType];
export type CopilotProviderDefaults = Partial<
Record<Exclude<ModelOutputType, ModelOutputType.Rerank>, string>
Record<Exclude<ModelOutputType, typeof ModelOutputType.Rerank>, string>
> & {
fallback?: string;
};
@@ -212,7 +212,6 @@ declare global {
key: string;
}>;
storage: ConfigItem<StorageProviderConfig>;
scenarios: ConfigItem<CopilotPromptScenario>;
providers: {
profiles: ConfigItem<CopilotProviderProfile[]>;
defaults: ConfigItem<CopilotProviderDefaults>;
@@ -235,23 +234,6 @@ defineModuleConfig('copilot', {
desc: 'Whether to enable the copilot plugin. <br> Document: <a href="https://docs.affine.pro/self-host-affine/administer/ai" target="_blank">https://docs.affine.pro/self-host-affine/administer/ai</a>',
default: false,
},
scenarios: {
desc: 'Use custom models in scenarios and override default settings.',
default: {
override_enabled: false,
scenarios: {
audio_transcribing: 'gemini-2.5-flash',
chat: 'gemini-2.5-flash',
embedding: 'gemini-embedding-001',
image: 'gpt-image-1',
coding: 'claude-sonnet-4-5@20250929',
complex_text_generation: 'gpt-5-mini',
quick_decision_making: 'gpt-5-mini',
quick_text_generation: 'gemini-2.5-flash',
polish_and_summarize: 'gemini-2.5-flash',
},
},
},
'providers.profiles': {
desc: 'The profile list for copilot providers.',
default: [],
@@ -750,14 +750,17 @@ export class CopilotContextResolver {
sniffMime(buffer, mimetype) || mimetype
);
await this.jobs.addFileEmbeddingQueue({
userId: user.id,
workspaceId: session.workspaceId,
contextId: session.id,
blobId: file.blobId,
fileId: file.id,
fileName: file.name,
});
await this.jobs.addFileEmbeddingQueue(
{
userId: user.id,
workspaceId: session.workspaceId,
contextId: session.id,
blobId: file.blobId,
fileId: file.id,
fileName: file.name,
},
{ priority: 0 }
);
return file;
} catch (e: any) {
@@ -1,5 +1,4 @@
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import {
Cache,
@@ -15,7 +14,7 @@ import {
ContextFile,
Models,
} from '../../../models';
import { getEmbeddingClient } from '../embedding/client';
import { CopilotEmbeddingClientService } from '../embedding/client';
import type { EmbeddingClient } from '../embedding/types';
import { ContextSession } from './session';
@@ -27,7 +26,7 @@ export class CopilotContextService implements OnApplicationBootstrap {
private client: EmbeddingClient | undefined;
constructor(
private readonly moduleRef: ModuleRef,
private readonly embeddingClients: CopilotEmbeddingClientService,
private readonly cache: Cache,
private readonly models: Models
) {}
@@ -43,7 +42,7 @@ export class CopilotContextService implements OnApplicationBootstrap {
}
private async setup() {
this.client = await getEmbeddingClient(this.moduleRef);
this.client = await this.embeddingClients.refresh();
}
async onApplicationBootstrap() {
@@ -59,8 +58,8 @@ export class CopilotContextService implements OnApplicationBootstrap {
}
// public this client to allow overriding in tests
get embeddingClient() {
return this.client as EmbeddingClient;
get embeddingClient(): EmbeddingClient | undefined {
return this.client ?? this.embeddingClients.getClient();
}
private async saveConfig(
@@ -175,8 +174,9 @@ export class CopilotContextService implements OnApplicationBootstrap {
signal?: AbortSignal,
threshold: number = 0.5
) {
if (!this.embeddingClient) return [];
const embedding = await this.embeddingClient.getEmbedding(content, signal);
const client = this.embeddingClient;
if (!client) return [];
const embedding = await client.getEmbedding(content, signal);
if (!embedding) return [];
const blobChunks = await this.models.copilotWorkspace.matchBlobEmbedding(
@@ -187,7 +187,7 @@ export class CopilotContextService implements OnApplicationBootstrap {
);
if (!blobChunks.length) return [];
return await this.embeddingClient.reRank(content, blobChunks, topK, signal);
return await client.reRank(content, blobChunks, topK, signal);
}
async matchWorkspaceFiles(
@@ -197,8 +197,9 @@ export class CopilotContextService implements OnApplicationBootstrap {
signal?: AbortSignal,
threshold: number = 0.5
) {
if (!this.embeddingClient) return [];
const embedding = await this.embeddingClient.getEmbedding(content, signal);
const client = this.embeddingClient;
if (!client) return [];
const embedding = await client.getEmbedding(content, signal);
if (!embedding) return [];
const fileChunks = await this.models.copilotWorkspace.matchFileEmbedding(
@@ -209,7 +210,7 @@ export class CopilotContextService implements OnApplicationBootstrap {
);
if (!fileChunks.length) return [];
return await this.embeddingClient.reRank(content, fileChunks, topK, signal);
return await client.reRank(content, fileChunks, topK, signal);
}
async matchWorkspaceDocs(
@@ -219,8 +220,9 @@ export class CopilotContextService implements OnApplicationBootstrap {
signal?: AbortSignal,
threshold: number = 0.5
) {
if (!this.embeddingClient) return [];
const embedding = await this.embeddingClient.getEmbedding(content, signal);
const client = this.embeddingClient;
if (!client) return [];
const embedding = await client.getEmbedding(content, signal);
if (!embedding) return [];
const workspaceChunks =
@@ -232,12 +234,7 @@ export class CopilotContextService implements OnApplicationBootstrap {
);
if (!workspaceChunks.length) return [];
return await this.embeddingClient.reRank(
content,
workspaceChunks,
topK,
signal
);
return await client.reRank(content, workspaceChunks, topK, signal);
}
async matchWorkspaceAll(
@@ -249,8 +246,9 @@ export class CopilotContextService implements OnApplicationBootstrap {
docIds?: string[],
scopedThreshold: number = 0.85
) {
if (!this.embeddingClient) return [];
const embedding = await this.embeddingClient.getEmbedding(content, signal);
const client = this.embeddingClient;
if (!client) return [];
const embedding = await client.getEmbedding(content, signal);
if (!embedding) return [];
const [fileChunks, blobChunks, workspaceChunks, scopedWorkspaceChunks] =
@@ -293,7 +291,7 @@ export class CopilotContextService implements OnApplicationBootstrap {
return [];
}
return await this.embeddingClient.reRank(
return await client.reRank(
content,
[
...fileChunks,
@@ -318,6 +316,18 @@ export class CopilotContextService implements OnApplicationBootstrap {
}));
}
@OnEvent('workspace.doc.embed.finished')
async onDocEmbedFinished({
contextId,
docId,
}: Events['workspace.doc.embed.finished']) {
const context = await this.get(contextId);
await context.saveDocRecord(docId, doc => ({
...(doc as ContextDoc),
status: ContextEmbedStatus.finished,
}));
}
@OnEvent('workspace.file.embed.finished')
async onFileEmbedFinish({
contextId,
@@ -13,22 +13,17 @@ import type { Request, Response } from 'express';
import {
BehaviorSubject,
catchError,
connect,
filter,
finalize,
from,
ignoreElements,
interval,
lastValueFrom,
map,
merge,
mergeMap,
Observable,
reduce,
Subject,
take,
takeUntil,
tap,
} from 'rxjs';
import {
@@ -36,28 +31,18 @@ import {
BlobNotFound,
CallMetric,
Config,
CopilotSessionNotFound,
mapSseError,
metrics,
NoCopilotProviderAvailable,
UnsplashIsNotConfigured,
} from '../../base';
import { ServerFeature, ServerService } from '../../core';
import { CurrentUser, Public } from '../../core/auth';
import { CopilotContextService } from './context/service';
import { CopilotProviderFactory } from './providers/factory';
import type { CopilotProvider } from './providers/provider';
import {
ModelInputType,
ModelOutputType,
type StreamObject,
} from './providers/types';
import { StreamObjectParser } from './providers/utils';
import { ChatSession, ChatSessionService } from './session';
ActionStreamHost,
projectActionEventToChatEvent,
} from './runtime/hosts/action-stream-host';
import { TurnOrchestrator } from './runtime/turn-orchestrator';
import { CopilotStorage } from './storage';
import { ChatMessage, ChatQuerySchema } from './types';
import { getSignal, getTools } from './utils';
import { CopilotWorkflowService, GraphExecutorState } from './workflow';
import { getSignal } from './utils';
export interface ChatEvent {
type: 'event' | 'attachment' | 'message' | 'error' | 'ping';
@@ -74,11 +59,8 @@ export class CopilotController implements BeforeApplicationShutdown {
constructor(
private readonly config: Config,
private readonly server: ServerService,
private readonly chatSession: ChatSessionService,
private readonly context: CopilotContextService,
private readonly provider: CopilotProviderFactory,
private readonly workflow: CopilotWorkflowService,
private readonly orchestrator: TurnOrchestrator,
private readonly actionStreams: ActionStreamHost,
private readonly storage: CopilotStorage
) {}
@@ -92,85 +74,6 @@ export class CopilotController implements BeforeApplicationShutdown {
this.ongoingStreamCount$.complete();
}
private async chooseProvider(
outputType: ModelOutputType,
userId: string,
sessionId: string,
messageId?: string,
modelId?: string
): Promise<{
provider: CopilotProvider;
model: string;
hasAttachment: boolean;
}> {
const [, session] = await Promise.all([
this.chatSession.checkQuota(userId),
this.chatSession.get(sessionId),
]);
if (!session || session.config.userId !== userId) {
throw new CopilotSessionNotFound();
}
const model = await session.resolveModel(
this.server.features.includes(ServerFeature.Payment),
modelId
);
const hasAttachment = messageId
? !!(await session.getMessageById(messageId)).attachments?.length
: false;
const provider = await this.provider.getProvider({
outputType,
modelId: model,
});
if (!provider) {
throw new NoCopilotProviderAvailable({ modelId: model });
}
return { provider, model, hasAttachment };
}
private async appendSessionMessage(
sessionId: string,
messageId?: string,
retry = false
): Promise<[ChatMessage | undefined, ChatSession]> {
const session = await this.chatSession.get(sessionId);
if (!session) {
throw new CopilotSessionNotFound();
}
let latestMessage = undefined;
if (!messageId || retry) {
// revert the latest message generated by the assistant
// if messageId is provided, we will also revert latest user message
await this.chatSession.revertLatestMessage(sessionId, !!messageId);
session.revertLatestMessage(!!messageId);
if (!messageId) {
latestMessage = session.latestUserMessage;
}
}
if (messageId) {
await session.pushByMessageId(messageId);
}
return [latestMessage, session];
}
private parseNumber(value: string | string[] | undefined) {
if (!value) {
return undefined;
}
const num = Number.parseInt(Array.isArray(value) ? value[0] : value, 10);
if (Number.isNaN(num)) {
return undefined;
}
return num;
}
private mergePingStream(
messageId: string,
source$: Observable<ChatEvent>
@@ -184,59 +87,12 @@ export class CopilotController implements BeforeApplicationShutdown {
return merge(source$.pipe(finalize(() => subject$.next(null))), ping$);
}
private async prepareChatSession(
user: CurrentUser,
sessionId: string,
query: Record<string, string | string[]>,
outputType: ModelOutputType
) {
let { messageId, retry, modelId, params } = ChatQuerySchema.parse(query);
private toMessageEvent(messageId: string | undefined, data: string | object) {
return { type: 'message' as const, id: messageId, data };
}
const { provider, model } = await this.chooseProvider(
outputType,
user.id,
sessionId,
messageId,
modelId
);
const [latestMessage, session] = await this.appendSessionMessage(
sessionId,
messageId,
retry
);
const context = await this.context.getBySessionId(sessionId);
const contextParams =
(Array.isArray(context?.files) && context.files.length > 0) ||
(Array.isArray(context?.blobs) && context.blobs.length > 0)
? {
contextFiles: [
...context.files,
...(await context.getBlobMetadata()),
],
}
: {};
const lastParams = latestMessage
? {
...latestMessage.params,
content: latestMessage.content,
attachments: latestMessage.attachments,
}
: {};
const finalMessage = session.finish({
...params,
...lastParams,
...contextParams,
});
return {
provider,
model,
session,
finalMessage,
};
private toAttachmentEvent(messageId: string | undefined, data: string) {
return { type: 'attachment' as const, id: messageId, data };
}
@Sse('/chat/:sessionId/stream')
@@ -250,19 +106,6 @@ export class CopilotController implements BeforeApplicationShutdown {
const info: any = { sessionId, params: query, throwInStream: false };
try {
const { provider, model, session, finalMessage } =
await this.prepareChatSession(
user,
sessionId,
query,
ModelOutputType.Text
);
info.model = model;
info.finalMessage = finalMessage.filter(m => m.role !== 'system');
metrics.ai.counter('chat_stream_calls').add(1, { model });
this.ongoingStreamCount$.next(this.ongoingStreamCount$.value + 1);
const { signal, onConnectionClosed } = getSignal(req);
let endBeforePromiseResolve = false;
onConnectionClosed(isAborted => {
@@ -271,51 +114,23 @@ export class CopilotController implements BeforeApplicationShutdown {
}
});
const { messageId, reasoning, webSearch, toolsConfig } =
ChatQuerySchema.parse(query);
const prepared = await this.orchestrator.streamText(
user.id,
sessionId,
query,
signal,
() => endBeforePromiseResolve
);
const source$ = from(
provider.streamText({ modelId: model }, finalMessage, {
...session.config.promptConfig,
signal,
user: user.id,
session: session.config.sessionId,
workspace: session.config.workspaceId,
reasoning,
webSearch,
tools: getTools(session.config.promptConfig?.tools, toolsConfig),
})
).pipe(
connect(shared$ =>
merge(
// actual chat event stream
shared$.pipe(
map(data => ({ type: 'message' as const, id: messageId, data }))
),
// save the generated text to the session
shared$.pipe(
reduce((acc, chunk) => acc + chunk, ''),
tap(buffer => {
session.push({
role: 'assistant',
content: endBeforePromiseResolve
? '> Request aborted'
: buffer,
createdAt: new Date(),
});
void session
.save()
.catch(err =>
this.logger.error(
'Failed to save session in sse stream',
err
)
);
}),
ignoreElements()
)
)
),
info.model = prepared.model;
info.finalMessage = prepared.finalMessage.filter(
m => m.role !== 'system'
);
metrics.ai.counter('chat_stream_calls').add(1, { model: prepared.model });
this.ongoingStreamCount$.next(this.ongoingStreamCount$.value + 1);
const source$ = from(prepared.stream).pipe(
map(data => this.toMessageEvent(prepared.messageId, data)),
catchError(e => {
metrics.ai.counter('chat_stream_errors').add(1);
info.throwInStream = true;
@@ -326,7 +141,7 @@ export class CopilotController implements BeforeApplicationShutdown {
})
);
return this.mergePingStream(messageId || '', source$);
return this.mergePingStream(prepared.messageId || '', source$);
} catch (err) {
metrics.ai.counter('chat_stream_errors').add(1, info);
return mapSseError(err, info);
@@ -344,19 +159,6 @@ export class CopilotController implements BeforeApplicationShutdown {
const info: any = { sessionId, params: query, throwInStream: false };
try {
const { provider, model, session, finalMessage } =
await this.prepareChatSession(
user,
sessionId,
query,
ModelOutputType.Object
);
info.model = model;
info.finalMessage = finalMessage.filter(m => m.role !== 'system');
metrics.ai.counter('chat_object_stream_calls').add(1, { model });
this.ongoingStreamCount$.next(this.ongoingStreamCount$.value + 1);
const { signal, onConnectionClosed } = getSignal(req);
let endBeforePromiseResolve = false;
onConnectionClosed(isAborted => {
@@ -365,55 +167,25 @@ export class CopilotController implements BeforeApplicationShutdown {
}
});
const { messageId, reasoning, webSearch, toolsConfig } =
ChatQuerySchema.parse(query);
const prepared = await this.orchestrator.streamObject(
user.id,
sessionId,
query,
signal,
() => endBeforePromiseResolve
);
const source$ = from(
provider.streamObject({ modelId: model }, finalMessage, {
...session.config.promptConfig,
signal,
user: user.id,
session: session.config.sessionId,
workspace: session.config.workspaceId,
reasoning,
webSearch,
tools: getTools(session.config.promptConfig?.tools, toolsConfig),
})
).pipe(
connect(shared$ =>
merge(
// actual chat event stream
shared$.pipe(
map(data => ({ type: 'message' as const, id: messageId, data }))
),
// save the generated text to the session
shared$.pipe(
reduce((acc, chunk) => acc.concat([chunk]), [] as StreamObject[]),
tap(result => {
const parser = new StreamObjectParser();
const streamObjects = parser.mergeTextDelta(result);
const content = parser.mergeContent(streamObjects);
session.push({
role: 'assistant',
content: endBeforePromiseResolve
? '> Request aborted'
: content,
streamObjects: endBeforePromiseResolve ? null : streamObjects,
createdAt: new Date(),
});
void session
.save()
.catch(err =>
this.logger.error(
'Failed to save session in sse stream',
err
)
);
}),
ignoreElements()
)
)
),
info.model = prepared.model;
info.finalMessage = prepared.finalMessage.filter(
m => m.role !== 'system'
);
metrics.ai.counter('chat_object_stream_calls').add(1, {
model: prepared.model,
});
this.ongoingStreamCount$.next(this.ongoingStreamCount$.value + 1);
const source$ = from(prepared.stream).pipe(
map(data => this.toMessageEvent(prepared.messageId, data)),
catchError(e => {
metrics.ai.counter('chat_object_stream_errors').add(1);
info.throwInStream = true;
@@ -424,16 +196,16 @@ export class CopilotController implements BeforeApplicationShutdown {
})
);
return this.mergePingStream(messageId || '', source$);
return this.mergePingStream(prepared.messageId || '', source$);
} catch (err) {
metrics.ai.counter('chat_object_stream_errors').add(1, info);
return mapSseError(err, info);
}
}
@Sse('/chat/:sessionId/workflow')
@CallMetric('ai', 'chat_workflow', { timer: true })
async chatWorkflow(
@Sse('/actions/:sessionId/stream')
@CallMetric('ai', 'action_stream', { timer: true })
async actionStream(
@CurrentUser() user: CurrentUser,
@Req() req: Request,
@Param('sessionId') sessionId: string,
@@ -441,103 +213,26 @@ export class CopilotController implements BeforeApplicationShutdown {
): Promise<Observable<ChatEvent>> {
const info: any = { sessionId, params: query, throwInStream: false };
try {
let { messageId, params } = ChatQuerySchema.parse(query);
const { signal } = getSignal(req);
const [, session] = await this.appendSessionMessage(sessionId, messageId);
info.model = session.model;
metrics.ai.counter('workflow_calls').add(1, { model: session.model });
const latestMessage = session.stashMessages.findLast(
m => m.role === 'user'
const prepared = await this.actionStreams.stream(
user.id,
sessionId,
query,
signal
);
if (latestMessage) {
params = Object.assign({}, params, latestMessage.params, {
content: latestMessage.content,
attachments: latestMessage.attachments,
});
}
info.actionId = prepared.actionId;
info.actionVersion = prepared.actionVersion;
metrics.ai.counter('action_stream_calls').add(1, {
actionId: prepared.actionId,
actionVersion: prepared.actionVersion,
});
this.ongoingStreamCount$.next(this.ongoingStreamCount$.value + 1);
const { signal, onConnectionClosed } = getSignal(req);
let endBeforePromiseResolve = false;
onConnectionClosed(isAborted => {
if (isAborted) {
endBeforePromiseResolve = true;
}
});
const source$ = from(
this.workflow.runGraph(params, session.model, {
...session.config.promptConfig,
signal,
user: user.id,
session: session.config.sessionId,
workspace: session.config.workspaceId,
})
).pipe(
connect(shared$ =>
merge(
// actual chat event stream
shared$.pipe(
map(data => {
switch (data.status) {
case GraphExecutorState.EmitContent:
return {
type: 'message' as const,
id: messageId,
data: data.content,
};
case GraphExecutorState.EmitAttachment:
return {
type: 'attachment' as const,
id: messageId,
data: data.attachment,
};
default:
return {
type: 'event' as const,
id: messageId,
data: {
status: data.status,
id: data.node.id,
type: data.node.config.nodeType,
},
};
}
})
),
// save the generated text to the session
shared$.pipe(
reduce((acc, chunk) => {
if (chunk.status === GraphExecutorState.EmitContent) {
acc += chunk.content;
}
return acc;
}, ''),
tap(content => {
session.push({
role: 'assistant',
content: endBeforePromiseResolve
? '> Request aborted'
: content,
createdAt: new Date(),
});
void session
.save()
.catch(err =>
this.logger.error(
'Failed to save session in sse stream',
err
)
);
}),
ignoreElements()
)
)
),
const source$ = from(prepared.stream).pipe(
map(data => projectActionEventToChatEvent(prepared.messageId, data)),
catchError(e => {
metrics.ai.counter('workflow_errors').add(1, info);
metrics.ai.counter('action_stream_errors').add(1, info);
info.throwInStream = true;
return mapSseError(e, info);
}),
@@ -546,9 +241,9 @@ export class CopilotController implements BeforeApplicationShutdown {
)
);
return this.mergePingStream(messageId || '', source$);
return this.mergePingStream(prepared.messageId || '', source$);
} catch (err) {
metrics.ai.counter('workflow_errors').add(1, info);
metrics.ai.counter('action_stream_errors').add(1, info);
return mapSseError(err, info);
}
}
@@ -563,36 +258,6 @@ export class CopilotController implements BeforeApplicationShutdown {
): Promise<Observable<ChatEvent>> {
const info: any = { sessionId, params: query, throwInStream: false };
try {
let { messageId, params } = ChatQuerySchema.parse(query);
const { provider, model, hasAttachment } = await this.chooseProvider(
ModelOutputType.Image,
user.id,
sessionId,
messageId
);
const [latestMessage, session] = await this.appendSessionMessage(
sessionId,
messageId
);
info.model = model;
metrics.ai.counter('images_stream_calls').add(1, { model });
if (latestMessage) {
params = Object.assign({}, params, latestMessage.params, {
content: latestMessage.content,
attachments: latestMessage.attachments,
});
}
const handleRemoteLink = this.storage.handleRemoteLink.bind(
this.storage,
user.id,
sessionId
);
this.ongoingStreamCount$.next(this.ongoingStreamCount$.value + 1);
const { signal, onConnectionClosed } = getSignal(req);
let endBeforePromiseResolve = false;
onConnectionClosed(isAborted => {
@@ -601,59 +266,22 @@ export class CopilotController implements BeforeApplicationShutdown {
}
});
const source$ = from(
provider.streamImages(
{
modelId: model,
inputTypes: hasAttachment
? [ModelInputType.Image]
: [ModelInputType.Text],
},
session.finish(params),
{
...session.config.promptConfig,
quality: params.quality || undefined,
seed: this.parseNumber(params.seed),
signal,
user: user.id,
session: session.config.sessionId,
workspace: session.config.workspaceId,
}
)
).pipe(
mergeMap(handleRemoteLink),
connect(shared$ =>
merge(
// actual chat event stream
shared$.pipe(
map(attachment => ({
type: 'attachment' as const,
id: messageId,
data: attachment,
}))
),
// save the generated text to the session
shared$.pipe(
reduce((acc, chunk) => acc.concat([chunk]), [] as string[]),
tap(attachments => {
session.push({
role: 'assistant',
content: endBeforePromiseResolve ? '> Request aborted' : '',
attachments: endBeforePromiseResolve ? [] : attachments,
createdAt: new Date(),
});
void session
.save()
.catch(err =>
this.logger.error(
'Failed to save session in sse stream',
err
)
);
}),
ignoreElements()
)
)
const prepared = await this.orchestrator.streamImages(
user.id,
sessionId,
query,
signal,
() => endBeforePromiseResolve
);
info.model = prepared.model;
metrics.ai.counter('images_stream_calls').add(1, {
model: prepared.model,
});
this.ongoingStreamCount$.next(this.ongoingStreamCount$.value + 1);
const source$ = from(prepared.stream).pipe(
map(attachment =>
this.toAttachmentEvent(prepared.messageId, attachment)
),
catchError(e => {
metrics.ai.counter('images_stream_errors').add(1, info);
@@ -665,7 +293,7 @@ export class CopilotController implements BeforeApplicationShutdown {
)
);
return this.mergePingStream(messageId || '', source$);
return this.mergePingStream(prepared.messageId || '', source$);
} catch (err) {
metrics.ai.counter('images_stream_errors').add(1, info);
return mapSseError(err, info);
@@ -0,0 +1,95 @@
import { createHash } from 'node:crypto';
import { BadRequestException, Injectable } from '@nestjs/common';
import {
type FileUpload,
ImageFormatNotSupported,
sniffMime,
} from '../../../base';
import { WorkspacePolicyService } from '../../../core/permission';
import { processImage } from '../../../native';
import { CompatSubmissionStore } from '../compat/submission-store';
import type { PromptMessage } from '../providers/types';
import { ChatSessionService } from '../session';
import { CopilotStorage } from '../storage';
const COPILOT_IMAGE_MAX_EDGE = 1536;
type CreateInboxMessage = {
sessionId: string;
content?: string;
attachments?: string[];
blob?: Promise<FileUpload>;
blobs?: Promise<FileUpload>[];
params?: Record<string, any>;
};
@Injectable()
export class ConversationInboxService {
constructor(
private readonly chatSession: ChatSessionService,
private readonly policy: WorkspacePolicyService,
private readonly storage: CopilotStorage,
private readonly submissions: CompatSubmissionStore
) {}
async createMessage(
userId: string,
options: CreateInboxMessage
): Promise<string> {
const session = await this.chatSession.get(options.sessionId);
if (!session || session.config.userId !== userId) {
throw new BadRequestException('Session not found');
}
const attachments: PromptMessage['attachments'] = options.attachments || [];
const blobs = await Promise.all(
options.blob ? [options.blob] : options.blobs || []
);
if (blobs.length) {
await this.policy.assertCanUploadBlob(userId, session.config.workspaceId);
}
for (const blob of blobs) {
const uploaded = await this.storage.handleUpload(userId, blob);
const detectedMime =
sniffMime(uploaded.buffer, blob.mimetype)?.toLowerCase() ||
blob.mimetype;
let attachmentBuffer = uploaded.buffer;
let attachmentMimeType = detectedMime;
if (detectedMime.startsWith('image/')) {
try {
attachmentBuffer = await processImage(
uploaded.buffer,
COPILOT_IMAGE_MAX_EDGE,
true
);
attachmentMimeType = 'image/webp';
} catch {
throw new ImageFormatNotSupported({ format: detectedMime });
}
}
const filename = createHash('sha256')
.update(attachmentBuffer)
.digest('base64url');
const attachment = await this.storage.put(
userId,
session.config.workspaceId,
filename,
attachmentBuffer
);
attachments.push({ attachment, mimeType: attachmentMimeType });
}
return await this.submissions.create({
sessionId: options.sessionId,
content: options.content,
attachments,
params: options.params,
});
}
}
@@ -0,0 +1,68 @@
import { Injectable } from '@nestjs/common';
import { CopilotQuotaExceeded } from '../../../base';
import { QuotaService } from '../../../core/quota';
import { Models } from '../../../models';
import type { Turn } from '../core';
import type { ResolvedPrompt } from '../prompt';
@Injectable()
export class ConversationPolicy {
constructor(
private readonly models: Models,
private readonly quota: QuotaService
) {}
async getQuota(userId: string) {
const isCopilotUser = await this.models.userFeature.has(
userId,
'unlimited_copilot'
);
let limit: number | undefined;
if (!isCopilotUser) {
const quota = await this.quota.getUserQuota(userId);
limit = quota.copilotActionLimit;
}
const used = await this.models.copilotSession.countUserMessages(userId);
return { limit, used };
}
async checkQuota(userId: string) {
const { limit, used } = await this.getQuota(userId);
if (limit && Number.isFinite(limit) && used >= limit) {
throw new CopilotQuotaExceeded();
}
}
shouldScheduleTitle(prompt: Pick<ResolvedPrompt, 'action'>) {
return !prompt.action;
}
shouldGenerateTitle(input: { title: string | null; turns: Turn[] }) {
if (input.title || !input.turns.length) {
return false;
}
let hasUser = false;
let hasAssistant = false;
for (const turn of input.turns) {
if (turn.role === 'user') {
hasUser = true;
} else if (turn.role === 'assistant') {
hasAssistant = true;
}
if (hasUser && hasAssistant) {
return true;
}
}
return false;
}
buildTitlePromptContent(turns: Turn[]) {
return turns.map(turn => `[${turn.role}]: ${turn.content}`).join('\n');
}
}
@@ -0,0 +1,256 @@
import { Injectable } from '@nestjs/common';
import {
CleanupSessionOptions,
ListSessionOptions,
Models,
UpdateChatSessionOptions,
} from '../../../models';
import {
chatMessageFromTurn,
type Conversation,
type Turn,
turnFromChatMessage,
} from '../core';
import { type ChatMessage, ChatMessageSchema } from '../types';
type SessionRecord = NonNullable<
Awaited<ReturnType<Models['copilotSession']['get']>>
>;
type ConversationSeed = Parameters<
Models['copilotSession']['createWithPrompt']
>[0];
type ForkConversationSeed = Parameters<Models['copilotSession']['fork']>[0];
type ForkTurnsInput = Omit<ForkConversationSeed, 'messages'> & {
turns: Turn[];
};
@Injectable()
export class ConversationStore {
constructor(private readonly models: Models) {}
/**
* Durable-history boundary only.
*
* This store intentionally does not own:
* - quota / model / pin policy
* - title generation
* - prompt preload or rendering
* - compat ChatHistory / SSE projection
*/
private toConversation(session: SessionRecord): Conversation {
return {
id: session.id,
userId: session.userId,
workspaceId: session.workspaceId,
docId: session.docId,
pinned: session.pinned,
parentId: session.parentSessionId,
title: session.title,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
};
}
private toTurns(session: SessionRecord): Turn[] {
return this.toMessages(session.messages).map(message =>
turnFromChatMessage(message, session.id)
);
}
private toMessages(messages: unknown): ChatMessage[] {
const parsed = ChatMessageSchema.array().safeParse(messages ?? []);
if (!parsed.success) return [];
return parsed.data;
}
async create(
seed: ConversationSeed,
reuseLatestChat = false
): Promise<string> {
return await this.models.copilotSession.createWithPrompt(
seed,
reuseLatestChat
);
}
async get(sessionId: string): Promise<
| {
conversation: Conversation;
turns: Turn[];
promptName: string;
tokenCost: number;
}
| undefined
> {
const session = await this.models.copilotSession.get(sessionId);
if (!session) {
return;
}
return {
conversation: this.toConversation(session),
turns: this.toTurns(session),
promptName: session.promptName,
tokenCost: session.tokenCost,
};
}
async getMeta(sessionId: string): Promise<
| {
conversation: Conversation;
promptName: string;
tokenCost: number;
}
| undefined
> {
const session = await this.models.copilotSession.getMeta(sessionId);
if (!session) return;
return {
conversation: {
id: session.id,
userId: session.userId,
workspaceId: session.workspaceId,
docId: session.docId,
pinned: session.pinned,
parentId: session.parentSessionId,
title: session.title,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
},
promptName: session.promptName,
tokenCost: session.tokenCost,
};
}
async list(options: ListSessionOptions) {
const sessions = await this.models.copilotSession.list(options);
return sessions.map(session => ({
conversation: {
id: session.id,
userId: session.userId,
workspaceId: session.workspaceId,
docId: session.docId,
pinned: session.pinned,
parentId: session.parentSessionId,
title: session.title,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
} satisfies Conversation,
turns: this.toMessages(session.messages).map(message =>
turnFromChatMessage(message, session.id)
),
promptName: session.promptName,
tokenCost: session.tokenCost,
}));
}
async listMeta(options: ListSessionOptions) {
const sessions = await this.models.copilotSession.list({
...options,
withMessages: false,
});
return sessions.map(session => ({
conversation: {
id: session.id,
userId: session.userId,
workspaceId: session.workspaceId,
docId: session.docId,
pinned: session.pinned,
parentId: session.parentSessionId,
title: session.title,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
} satisfies Conversation,
promptName: session.promptName,
tokenCost: session.tokenCost,
}));
}
async appendTurns(input: {
sessionId: string;
userId: string;
prompt: { model: string };
turns: Turn[];
}) {
return await this.models.copilotSession.updateMessages({
...input,
messages: input.turns.map(turn => {
const { id: _id, ...message } = chatMessageFromTurn(turn);
return message;
}),
});
}
async appendTurn(input: {
sessionId: string;
userId: string;
prompt: { model: string };
turn: Turn;
compatSubmissionId?: string;
}) {
const message = await this.models.copilotSession.appendMessage({
sessionId: input.sessionId,
userId: input.userId,
prompt: input.prompt,
message: (() => {
const { id: _id, ...message } = chatMessageFromTurn(input.turn);
return { ...message, compatSubmissionId: input.compatSubmissionId };
})(),
});
return turnFromChatMessage(message, input.sessionId);
}
async findTurnByCompatSubmissionId(
sessionId: string,
compatSubmissionId: string
): Promise<Turn | undefined> {
const message =
await this.models.copilotSession.findMessageByCompatSubmissionId(
sessionId,
compatSubmissionId
);
if (!message) return;
return turnFromChatMessage(message, sessionId);
}
async update(options: UpdateChatSessionOptions): Promise<string> {
return await this.models.copilotSession.update(options);
}
async fork(seed: ForkTurnsInput): Promise<string> {
return await this.models.copilotSession.fork({
...seed,
messages: seed.turns.map(turn => {
const { id: _id, ...message } = chatMessageFromTurn(turn);
return message;
}),
});
}
async revertLatestTurn(sessionId: string, removeLatestUserMessage: boolean) {
return await this.models.copilotSession.revertLatestMessage(
sessionId,
removeLatestUserMessage
);
}
async cleanup(options: CleanupSessionOptions): Promise<string[]> {
return await this.models.copilotSession.cleanup(options);
}
async count(options: ListSessionOptions): Promise<number> {
return await this.models.copilotSession.count(options);
}
async unpin(workspaceId: string, userId: string) {
return await this.models.copilotSession.unpin(workspaceId, userId);
}
}
@@ -0,0 +1,108 @@
import type { PromptMessage, StreamObject } from '../providers/types';
import {
streamObjectToToolEvent,
toolEventToStreamObject,
} from '../runtime/contracts/runtime-event-contract';
import type { ChatMessage } from '../types';
import { type ToolEvent, type Turn, TurnSchema } from './types';
const normalizeRenderTrace = (
streamObjects: StreamObject[]
): StreamObject[] => {
return streamObjects.reduce((acc, current) => {
const previous = acc.at(-1);
switch (current.type) {
case 'reasoning':
case 'text-delta': {
if (previous?.type === current.type) {
previous.textDelta += current.textDelta;
} else {
acc.push({ ...current });
}
break;
}
case 'tool-result': {
const index = acc.findIndex(
candidate =>
candidate.type === 'tool-call' &&
candidate.toolCallId === current.toolCallId &&
candidate.toolName === current.toolName
);
if (index !== -1) {
acc[index] = { ...current };
} else {
acc.push({ ...current });
}
break;
}
default: {
acc.push({ ...current });
break;
}
}
return acc;
}, [] as StreamObject[]);
};
const deriveToolEvents = (renderTrace: StreamObject[]): ToolEvent[] =>
renderTrace
.map(streamObjectToToolEvent)
.filter((event): event is ToolEvent => !!event);
export const canonicalizeTurnTrace = (trace: {
renderTrace?: StreamObject[];
toolEvents?: ToolEvent[];
}) => {
const renderTrace =
trace.renderTrace && trace.renderTrace.length
? normalizeRenderTrace(trace.renderTrace)
: trace.toolEvents?.length
? trace.toolEvents.map(toolEventToStreamObject)
: [];
return { renderTrace, toolEvents: deriveToolEvents(renderTrace) };
};
export const turnFromChatMessage = (
message: ChatMessage,
conversationId: string
): Turn => {
const trace = canonicalizeTurnTrace({
renderTrace: message.streamObjects ?? [],
});
return TurnSchema.parse({
id: message.id,
conversationId,
role: message.role,
content: message.content,
attachments: message.attachments ?? [],
renderTrace: trace.renderTrace,
toolEvents: trace.toolEvents,
metadata: message.params ?? {},
createdAt: message.createdAt,
});
};
export const chatMessageFromTurn = (turn: Turn): ChatMessage => {
const { renderTrace } = canonicalizeTurnTrace(turn);
return {
id: turn.id,
role: turn.role,
content: turn.content,
attachments: turn.attachments.length ? turn.attachments : undefined,
params: turn.metadata,
streamObjects: renderTrace.length ? renderTrace : undefined,
createdAt: turn.createdAt,
};
};
export const promptMessageFromTurn = (turn: Turn): PromptMessage => ({
role: turn.role,
content: turn.content,
attachments: turn.attachments.length ? turn.attachments : undefined,
params: Object.keys(turn.metadata).length ? turn.metadata : undefined,
});
@@ -0,0 +1,2 @@
export * from './adapters';
export * from './types';
@@ -0,0 +1,58 @@
import { z } from 'zod';
import { ChatMessageAttachment } from '../providers/types';
import {
StreamObjectSchema,
type ToolEvent,
ToolEventSchema,
} from '../runtime/contracts/runtime-event-contract';
const CanonicalDateSchema = z.coerce.date();
export const ConversationSchema = z
.object({
id: z.string(),
userId: z.string(),
workspaceId: z.string(),
docId: z.string().nullable(),
pinned: z.boolean(),
parentId: z.string().nullable(),
title: z.string().nullable(),
createdAt: CanonicalDateSchema,
updatedAt: CanonicalDateSchema,
})
.strict();
export type Conversation = z.infer<typeof ConversationSchema>;
export const TurnSchema = z
.object({
id: z.string().optional(),
conversationId: z.string(),
role: z.enum(['system', 'assistant', 'user']),
content: z.string(),
attachments: z.array(ChatMessageAttachment).default([]),
renderTrace: z.array(StreamObjectSchema).default([]),
toolEvents: z.array(ToolEventSchema).default([]),
metadata: z.record(z.string(), z.any()).default({}),
createdAt: CanonicalDateSchema,
})
.strict();
export type Turn = z.infer<typeof TurnSchema>;
export const ValidatedStructuredValueSchema = z
.object({
value: z.any(),
schemaHash: z.string(),
schemaValidationVersion: z.string(),
provider: z.string(),
model: z.string(),
})
.strict();
export type ValidatedStructuredValue = z.infer<
typeof ValidatedStructuredValueSchema
>;
export type { ToolEvent };
@@ -25,14 +25,6 @@ export class CopilotCronJobs {
private readonly jobs: JobQueue
) {}
async triggerCleanupTrashedDocEmbeddings() {
await this.jobs.add(
'copilot.workspace.cleanupTrashedDocEmbeddings',
{},
{ jobId: 'daily-copilot-cleanup-trashed-doc-embeddings' }
);
}
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async dailyCleanupJob() {
await this.jobs.add(
@@ -1,41 +1,32 @@
import { Logger } from '@nestjs/common';
import type { ModuleRef } from '@nestjs/core';
import { createHash } from 'node:crypto';
import { Injectable, Logger } from '@nestjs/common';
import { Config, CopilotProviderNotSupported } from '../../../base';
import { CopilotFailedToGenerateEmbedding } from '../../../base/error/errors.gen';
import {
ChunkSimilarity,
Embedding,
EMBEDDING_DIMENSIONS,
} from '../../../models';
import { CopilotProviderFactory } from '../providers/factory';
import type { CopilotProvider } from '../providers/provider';
import {
type CopilotRerankRequest,
type ModelFullConditions,
ModelInputType,
ModelOutputType,
} from '../providers/types';
import { type CopilotRerankRequest } from '../providers/types';
import { CapabilityRuntime } from '../runtime/capability-runtime';
import { TaskPolicy } from '../runtime/task-policy';
import { EmbeddingClient, type ReRankResult } from './types';
const EMBEDDING_MODEL = 'gemini-embedding-001';
const RERANK_MODEL = 'gpt-4o-mini';
class ProductionEmbeddingClient extends EmbeddingClient {
private readonly logger = new Logger(ProductionEmbeddingClient.name);
constructor(
private readonly config: Config,
private readonly providerFactory: CopilotProviderFactory
private readonly taskPolicy: TaskPolicy,
private readonly runtime: CapabilityRuntime
) {
super();
}
override async configured(): Promise<boolean> {
const embedding = await this.providerFactory.getProvider({
modelId: this.getEmbeddingModelId(),
outputType: ModelOutputType.Embedding,
});
const result = Boolean(embedding);
const result = await this.runtime.embeddingConfigured(
this.taskPolicy.resolveEmbeddingModelId()
);
if (!result) {
this.logger.warn(
'Copilot embedding client is not configured properly, please check your configuration.'
@@ -44,42 +35,14 @@ class ProductionEmbeddingClient extends EmbeddingClient {
return result;
}
private async getProvider(
cond: ModelFullConditions
): Promise<CopilotProvider> {
const provider = await this.providerFactory.getProvider(cond);
if (!provider) {
throw new CopilotProviderNotSupported({
provider: 'embedding',
kind: cond.outputType || 'embedding',
});
}
return provider;
}
private getEmbeddingModelId() {
return this.config.copilot?.scenarios?.override_enabled
? this.config.copilot.scenarios.scenarios?.embedding || EMBEDDING_MODEL
: EMBEDDING_MODEL;
}
async getEmbeddings(input: string[]): Promise<Embedding[]> {
const provider = await this.getProvider({
modelId: this.getEmbeddingModelId(),
outputType: ModelOutputType.Embedding,
const modelId = this.taskPolicy.resolveEmbeddingModelId();
const embeddings = await this.runtime.embed(modelId, input, {
dimensions: EMBEDDING_DIMENSIONS,
});
this.logger.verbose(
`Using provider ${provider.type} for embedding: ${input.join(', ')}`
);
const embeddings = await provider.embedding(
{ inputTypes: [ModelInputType.Text] },
input,
{ dimensions: EMBEDDING_DIMENSIONS }
);
if (embeddings.length !== input.length) {
throw new CopilotFailedToGenerateEmbedding({
provider: provider.type,
provider: modelId,
message: `Expected ${input.length} embeddings, got ${embeddings.length}`,
});
}
@@ -108,11 +71,6 @@ class ProductionEmbeddingClient extends EmbeddingClient {
): Promise<ReRankResult> {
if (!embeddings.length) return [];
const provider = await this.getProvider({
modelId: RERANK_MODEL,
outputType: ModelOutputType.Rerank,
});
const rerankRequest: CopilotRerankRequest = {
query,
candidates: embeddings.map((embedding, index) => ({
@@ -121,8 +79,8 @@ class ProductionEmbeddingClient extends EmbeddingClient {
})),
};
const ranks = await provider.rerank(
{ modelId: RERANK_MODEL },
const ranks = await this.runtime.rerank(
this.taskPolicy.resolveRerankModelId(),
rerankRequest,
{ signal }
);
@@ -211,32 +169,40 @@ class ProductionEmbeddingClient extends EmbeddingClient {
}
}
let EMBEDDING_CLIENT: EmbeddingClient | undefined;
export async function getEmbeddingClient(
moduleRef: ModuleRef
): Promise<EmbeddingClient | undefined> {
if (EMBEDDING_CLIENT) {
return EMBEDDING_CLIENT;
@Injectable()
export class CopilotEmbeddingClientService {
private client: EmbeddingClient | undefined;
constructor(
private readonly taskPolicy: TaskPolicy,
private readonly runtime: CapabilityRuntime
) {}
async refresh() {
const client = new ProductionEmbeddingClient(this.taskPolicy, this.runtime);
this.client = (await client.configured()) ? client : undefined;
return this.client;
}
const config = moduleRef.get(Config, { strict: false });
const providerFactory = moduleRef.get(CopilotProviderFactory, {
strict: false,
});
const client = new ProductionEmbeddingClient(config, providerFactory);
if (await client.configured()) {
EMBEDDING_CLIENT = client;
getClient() {
return this.client;
}
return EMBEDDING_CLIENT;
}
export class MockEmbeddingClient extends EmbeddingClient {
private embed(content: string) {
const seed = createHash('sha256').update(content).digest();
return Array.from({ length: EMBEDDING_DIMENSIONS }, (_, index) => {
const byte = seed[index % seed.length];
return byte / 255;
});
}
async getEmbeddings(input: string[]): Promise<Embedding[]> {
return input.map((_, i) => ({
return input.map((content, i) => ({
index: i,
content: input[i],
embedding: Array.from({ length: EMBEDDING_DIMENSIONS }, () =>
Math.random()
),
content,
embedding: this.embed(content),
}));
}
}
@@ -1,4 +1,4 @@
export { getEmbeddingClient, MockEmbeddingClient } from './client';
export { CopilotEmbeddingClientService, MockEmbeddingClient } from './client';
export { CopilotEmbeddingJob } from './job';
export type { Chunk, DocFragment } from './types';
export { EmbeddingClient } from './types';
@@ -1,5 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import {
BlobNotFound,
@@ -18,7 +17,7 @@ import { readAllDocIdsFromWorkspaceSnapshot } from '../../../core/utils/blocksui
import { Models } from '../../../models';
import { CopilotStorage } from '../storage';
import { readStream } from '../utils';
import { getEmbeddingClient } from './client';
import { CopilotEmbeddingClientService } from './client';
import type { Chunk, DocFragment } from './types';
import { EmbeddingClient } from './types';
@@ -32,12 +31,13 @@ export class CopilotEmbeddingJob {
private client: EmbeddingClient | undefined;
constructor(
private readonly moduleRef: ModuleRef,
private readonly embeddingClients: CopilotEmbeddingClientService,
private readonly doc: DocReader,
private readonly event: EventBus,
private readonly models: Models,
private readonly queue: JobQueue,
private readonly storage: CopilotStorage
private readonly storage: CopilotStorage,
private readonly workspaceStorage: WorkspaceBlobStorage
) {}
@OnEvent('config.init')
@@ -54,7 +54,7 @@ export class CopilotEmbeddingJob {
this.supportEmbedding =
await this.models.copilotContext.checkEmbeddingAvailable();
if (this.supportEmbedding) {
this.client = await getEmbeddingClient(this.moduleRef);
this.client = await this.embeddingClients.refresh();
}
}
@@ -64,10 +64,15 @@ export class CopilotEmbeddingJob {
}
@CallMetric('ai', 'addFileEmbeddingQueue')
async addFileEmbeddingQueue(file: Jobs['copilot.embedding.files']) {
async addFileEmbeddingQueue(
file: Jobs['copilot.embedding.files'],
options?: { priority?: number }
) {
if (!this.supportEmbedding) return;
await this.queue.add('copilot.embedding.files', file);
await this.queue.add('copilot.embedding.files', file, {
priority: options?.priority,
});
}
@CallMetric('ai', 'addBlobEmbeddingQueue')
@@ -231,10 +236,7 @@ export class CopilotEmbeddingJob {
blobId: string,
fileName: string
) {
const workspaceStorage = this.moduleRef.get(WorkspaceBlobStorage, {
strict: false,
});
const { body } = await workspaceStorage.get(workspaceId, blobId);
const { body } = await this.workspaceStorage.get(workspaceId, blobId);
if (!body) throw new BlobNotFound({ spaceId: workspaceId, blobId });
const buffer = await readStream(body);
return new File([buffer], fileName);
@@ -445,6 +447,12 @@ export class CopilotEmbeddingJob {
this.logger.debug(
`Doc ${docId} in workspace ${workspaceId} has no content change, skipping embedding.`
);
if (contextId) {
this.event.emit('workspace.doc.embed.finished', {
contextId,
docId,
});
}
return;
}
@@ -487,6 +495,12 @@ export class CopilotEmbeddingJob {
);
}
}
if (contextId) {
this.event.emit('workspace.doc.embed.finished', {
contextId,
docId,
});
}
} catch (error: any) {
if (contextId) {
this.event.emit('workspace.doc.embed.failed', {
@@ -36,6 +36,11 @@ declare global {
docId: string;
};
'workspace.doc.embed.finished': {
contextId: string;
docId: string;
};
'workspace.file.embed.finished': {
contextId: string;
fileId: string;
@@ -7,82 +7,55 @@ import { DocStorageModule } from '../../core/doc';
import { FeatureModule } from '../../core/features';
import { PermissionModule } from '../../core/permission';
import { QuotaModule } from '../../core/quota';
import { StorageModule } from '../../core/storage';
import { WorkspaceModule } from '../../core/workspaces';
import { IndexerModule } from '../indexer';
import {
CopilotContextResolver,
CopilotContextRootResolver,
CopilotContextService,
} from './context';
import { CopilotController } from './controller';
import { CopilotCronJobs } from './cron';
import { CopilotEmbeddingJob } from './embedding';
import { WorkspaceMcpController } from './mcp/controller';
import { WorkspaceMcpProvider } from './mcp/provider';
import { ChatMessageCache } from './message';
import { PromptService } from './prompt';
import { CopilotProviderFactory, CopilotProviders } from './providers';
import {
CopilotResolver,
PromptsManagementResolver,
UserCopilotResolver,
} from './resolver';
import { ChatSessionService } from './session';
import { CopilotStorage } from './storage';
import {
CopilotTranscriptionResolver,
CopilotTranscriptionService,
} from './transcript';
import { CopilotWorkflowExecutors, CopilotWorkflowService } from './workflow';
import {
CopilotWorkspaceEmbeddingConfigResolver,
CopilotWorkspaceEmbeddingResolver,
CopilotWorkspaceService,
} from './workspace';
COPILOT_API_PROVIDERS,
COPILOT_FEATURE_PROVIDERS,
COPILOT_KERNEL_PROVIDERS,
} from './module-providers';
const COPILOT_SHARED_IMPORTS = [
DocStorageModule,
FeatureModule,
QuotaModule,
PermissionModule,
ServerConfigModule,
StorageModule,
WorkspaceModule,
IndexerModule,
];
@Module({
imports: [...COPILOT_SHARED_IMPORTS],
providers: [...COPILOT_KERNEL_PROVIDERS],
exports: [...COPILOT_KERNEL_PROVIDERS],
})
export class CopilotKernelModule {}
@Module({
imports: [...COPILOT_SHARED_IMPORTS, CopilotKernelModule],
providers: [...COPILOT_FEATURE_PROVIDERS],
exports: [...COPILOT_FEATURE_PROVIDERS],
})
export class CopilotFeatureModule {}
@Module({
imports: [
DocStorageModule,
FeatureModule,
QuotaModule,
PermissionModule,
ServerConfigModule,
WorkspaceModule,
IndexerModule,
],
providers: [
// providers
...CopilotProviders,
CopilotProviderFactory,
// services
ChatSessionService,
CopilotResolver,
ChatMessageCache,
PromptService,
CopilotStorage,
// workflow
CopilotWorkflowService,
...CopilotWorkflowExecutors,
// context
CopilotContextResolver,
CopilotContextService,
// jobs
CopilotEmbeddingJob,
CopilotCronJobs,
// transcription
CopilotTranscriptionService,
CopilotTranscriptionResolver,
// workspace embeddings
CopilotWorkspaceService,
CopilotWorkspaceEmbeddingResolver,
CopilotWorkspaceEmbeddingConfigResolver,
// gql resolvers
UserCopilotResolver,
PromptsManagementResolver,
CopilotContextRootResolver,
// mcp
WorkspaceMcpProvider,
...COPILOT_SHARED_IMPORTS,
CopilotKernelModule,
CopilotFeatureModule,
],
providers: [...COPILOT_API_PROVIDERS],
exports: [...COPILOT_API_PROVIDERS],
})
export class CopilotApiModule {}
@Module({
imports: [CopilotKernelModule, CopilotFeatureModule, CopilotApiModule],
controllers: [CopilotController, WorkspaceMcpController],
})
export class CopilotModule {}
@@ -1,27 +0,0 @@
import { randomUUID } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { SessionCache } from '../../base';
import { SubmittedMessage, SubmittedMessageSchema } from './types';
const CHAT_MESSAGE_KEY = 'chat-message';
const CHAT_MESSAGE_TTL = 3600 * 1 * 1000; // 1 hours
@Injectable()
export class ChatMessageCache {
constructor(private readonly cache: SessionCache) {}
async get(id: string): Promise<SubmittedMessage | undefined> {
return await this.cache.get(`${CHAT_MESSAGE_KEY}:${id}`);
}
async set(message: SubmittedMessage): Promise<string> {
const parsedMessage = SubmittedMessageSchema.parse(message);
const id = randomUUID();
await this.cache.set(`${CHAT_MESSAGE_KEY}:${id}`, parsedMessage, {
ttl: CHAT_MESSAGE_TTL,
});
return id;
}
}
@@ -0,0 +1,139 @@
import { HistoryAttachmentUrlProjector } from './compat/history-attachment-url-projector';
import { CompatHistoryProjector } from './compat/history-projector';
import { HistoryPromptPreloadProjector } from './compat/history-prompt-preload-projector';
import { HistoryVisibilityPolicy } from './compat/history-visibility-policy';
import { CompatSubmissionStore } from './compat/submission-store';
import {
CopilotContextResolver,
CopilotContextRootResolver,
CopilotContextService,
} from './context';
import { ConversationInboxService } from './conversation/inbox';
import { ConversationPolicy } from './conversation/policy';
import { ConversationStore } from './conversation/store';
import { CopilotCronJobs } from './cron';
import {
CopilotEmbeddingClientService,
CopilotEmbeddingJob,
} from './embedding';
import { WorkspaceMcpProvider } from './mcp/provider';
import { PromptService } from './prompt';
import {
CopilotProviderFactory,
CopilotProviderLifecycleService,
CopilotProviderRegistryService,
CopilotProviders,
} from './providers';
import { CopilotResolver, UserCopilotResolver } from './resolver';
import { ActionRuntimeBridge } from './runtime/action-runtime-bridge';
import { CapabilityRuntime } from './runtime/capability-runtime';
import { CopilotExecutionMetrics } from './runtime/execution-metrics';
import { ExecutionPlanBuilder } from './runtime/execution-plan';
import { ActionStreamHost } from './runtime/hosts/action-stream-host';
import { AttachmentAdmissionHost } from './runtime/hosts/attachment-admission';
import { AttachmentMaterializer } from './runtime/hosts/attachment-materializer';
import { CapabilityPolicyHost } from './runtime/hosts/capability-policy-host';
import { ConversationHost } from './runtime/hosts/conversation-host';
import { ImageResultHost } from './runtime/hosts/image-result-host';
import { ResponsePostprocessor } from './runtime/hosts/response-postprocessor';
import { ToolExecutorHost } from './runtime/hosts/tool-executor-host';
import { TurnPersistence } from './runtime/hosts/turn-persistence';
import { ModelSelectionPolicy } from './runtime/model-selection-policy';
import { NativeExecutionEngine } from './runtime/native-execution-engine';
import { PromptRuntime } from './runtime/prompt-runtime';
import { TaskPolicy } from './runtime/task-policy';
import { ToolRuntime } from './runtime/tool-runtime';
import { TurnOrchestrator } from './runtime/turn-orchestrator';
import { ChatSessionService } from './session';
import { CopilotStorage } from './storage';
import {
CopilotTranscriptionResolver,
CopilotTranscriptionService,
} from './transcript';
import {
CopilotWorkspaceEmbeddingConfigResolver,
CopilotWorkspaceEmbeddingResolver,
CopilotWorkspaceService,
} from './workspace';
export const COPILOT_PROVIDER_PROVIDERS = [
...CopilotProviders,
CopilotProviderRegistryService,
CopilotProviderFactory,
CopilotProviderLifecycleService,
];
export const COPILOT_RUNTIME_PROVIDERS = [
ChatSessionService,
ConversationStore,
ConversationInboxService,
ConversationPolicy,
HistoryAttachmentUrlProjector,
CompatHistoryProjector,
HistoryPromptPreloadProjector,
CompatSubmissionStore,
HistoryVisibilityPolicy,
CopilotContextService,
CopilotEmbeddingClientService,
PromptService,
ModelSelectionPolicy,
ActionRuntimeBridge,
CopilotExecutionMetrics,
ExecutionPlanBuilder,
PromptRuntime,
CapabilityPolicyHost,
ConversationHost,
CapabilityRuntime,
NativeExecutionEngine,
TaskPolicy,
ToolRuntime,
ToolExecutorHost,
AttachmentMaterializer,
AttachmentAdmissionHost,
ActionStreamHost,
ImageResultHost,
ResponsePostprocessor,
CopilotStorage,
TurnPersistence,
];
export const COPILOT_CONTEXT_PROVIDERS = [CopilotContextResolver];
export const COPILOT_TRANSCRIPT_PROVIDERS = [
CopilotTranscriptionService,
CopilotTranscriptionResolver,
];
export const COPILOT_WORKSPACE_PROVIDERS = [
CopilotWorkspaceService,
CopilotWorkspaceEmbeddingResolver,
CopilotWorkspaceEmbeddingConfigResolver,
];
export const COPILOT_RESOLVER_PROVIDERS = [
CopilotResolver,
UserCopilotResolver,
CopilotContextRootResolver,
];
export const COPILOT_JOB_PROVIDERS = [CopilotEmbeddingJob, CopilotCronJobs];
export const COPILOT_MCP_PROVIDERS = [WorkspaceMcpProvider];
export const COPILOT_KERNEL_PROVIDERS = [
...COPILOT_PROVIDER_PROVIDERS,
...COPILOT_RUNTIME_PROVIDERS,
];
export const COPILOT_FEATURE_PROVIDERS = [
TurnOrchestrator,
...COPILOT_CONTEXT_PROVIDERS,
...COPILOT_TRANSCRIPT_PROVIDERS,
...COPILOT_WORKSPACE_PROVIDERS,
...COPILOT_JOB_PROVIDERS,
];
export const COPILOT_API_PROVIDERS = [
...COPILOT_RESOLVER_PROVIDERS,
...COPILOT_MCP_PROVIDERS,
];
@@ -1,183 +0,0 @@
import { type Tokenizer } from '@affine/server-native';
import { Logger } from '@nestjs/common';
import { AiPrompt } from '@prisma/client';
import Mustache from 'mustache';
import { getTokenEncoder } from '../../../native';
import type {
PromptConfig,
PromptMessage,
PromptParams,
} from '../providers/types';
// disable escaping
Mustache.escape = (text: string) => text;
function extractMustacheParams(template: string) {
const regex = /\{\{\s*([^{}]+)\s*\}\}/g;
const params = [];
let match;
while ((match = regex.exec(template)) !== null) {
params.push(match[1]);
}
return Array.from(new Set(params));
}
export class ChatPrompt {
private readonly logger = new Logger(ChatPrompt.name);
public readonly encoder: Tokenizer | null;
private readonly promptTokenSize: number;
private readonly templateParamKeys: string[] = [];
private readonly templateParams: PromptParams = {};
static createFromPrompt(
options: Omit<
AiPrompt,
'id' | 'createdAt' | 'updatedAt' | 'modified' | 'config'
> & {
messages: PromptMessage[];
config: PromptConfig | undefined;
}
) {
return new ChatPrompt(
options.name,
options.action || undefined,
options.model,
options.optionalModels,
options.config,
options.messages
);
}
constructor(
public readonly name: string,
public readonly action: string | undefined,
public readonly model: string,
public readonly optionalModels: string[],
public readonly config: PromptConfig | undefined,
private readonly messages: PromptMessage[]
) {
this.encoder = getTokenEncoder(model);
this.promptTokenSize = this.encode(messages.map(m => m.content).join(''));
this.templateParamKeys = extractMustacheParams(
messages.map(m => m.content).join('')
);
this.templateParams = messages.reduce(
(acc, m) => Object.assign(acc, m.params),
{} as PromptParams
);
}
/**
* get prompt token size
*/
get tokens() {
return this.promptTokenSize;
}
/**
* get prompt param keys in template
*/
get paramKeys() {
return this.templateParamKeys.slice();
}
/**
* get prompt params
*/
get params() {
return { ...this.templateParams };
}
encode(message: string) {
return this.encoder?.count(message) || 0;
}
private checkParams(params: PromptParams, sessionId?: string) {
const selfParams = this.templateParams;
for (const key of Object.keys(selfParams)) {
const options = selfParams[key];
const income = params[key];
if (
typeof income !== 'string' ||
(Array.isArray(options) && !options.includes(income))
) {
if (sessionId) {
const prefix = income
? `Invalid param value: ${key}=${income}`
: `Missing param value: ${key}`;
this.logger.warn(
`${prefix} in session ${sessionId}, use default options: ${Array.isArray(options) ? options[0] : options}`
);
}
if (Array.isArray(options)) {
// use the first option if income is not in options
params[key] = options[0];
} else {
params[key] = options;
}
}
}
}
private preDefinedParams(params: PromptParams) {
const {
language,
timezone,
docs,
contextFiles: files,
selectedMarkdown,
selectedSnapshot,
html,
currentDocId,
} = params;
return {
'affine::date': new Date().toLocaleDateString(),
'affine::language': language || 'same language as the user query',
'affine::timezone': timezone || 'no preference',
'affine::hasDocsRef': Array.isArray(docs) && docs.length > 0,
'affine::hasFilesRef': Array.isArray(files) && files.length > 0,
'affine::hasSelected': !!selectedMarkdown || !!selectedSnapshot || !!html,
'affine::hasCurrentDoc':
typeof currentDocId === 'string' && currentDocId.trim().length > 0,
};
}
/**
* render prompt messages with params
* @param params record of params, e.g. { name: 'Alice' }
* @returns e.g. [{ role: 'system', content: 'Hello, {{name}}' }] => [{ role: 'system', content: 'Hello, Alice' }]
*/
finish(params: PromptParams, sessionId?: string): PromptMessage[] {
this.checkParams(params, sessionId);
const { attachments: attach, ...restParams } = Object.fromEntries(
Object.entries(params).filter(([k]) => !k.startsWith('affine::'))
);
const paramsAttach = Array.isArray(attach) ? attach : [];
return this.messages.map(
({ attachments: attach, content, params: _, ...rest }) => {
const result: PromptMessage = {
...rest,
params,
content: Mustache.render(
content,
Object.assign({}, restParams, this.preDefinedParams(restParams))
),
};
const attachments = [
...(Array.isArray(attach) ? attach : []),
...paramsAttach,
];
if (attachments.length && rest.role === 'user') {
result.attachments = attachments;
}
return result;
}
);
}
}
@@ -1,3 +1,2 @@
export { ChatPrompt } from './chat-prompt';
export { prompts } from './prompts';
export { PromptService } from './service';
export type { ResolvedPrompt } from './spec';
@@ -0,0 +1,260 @@
import {
llmCollectPromptMetadata,
llmCountPromptTokens,
llmGetBuiltInPromptSpec,
llmListBuiltInPromptSpecs,
llmRenderBuiltInPrompt,
llmRenderBuiltInSessionPrompt,
llmRenderPrompt,
llmRenderSessionPrompt,
type NativeBuiltInPromptRenderRequest as NativeBuiltInPromptRenderContract,
type NativeBuiltInPromptSessionRenderRequest as NativeBuiltInPromptSessionContract,
type NativePromptCountTokensRequest as NativePromptTokenCountContract,
type NativePromptCountTokensResponse as NativePromptTokenCountResult,
type NativePromptMetadataRequest as NativePromptMetadataContract,
type NativePromptMetadataResponse as NativePromptMetadataResult,
type NativePromptRenderRequest as NativePromptRenderContract,
type NativePromptRenderResponse as NativePromptRenderResult,
type NativePromptSessionRenderRequest as NativePromptSessionContract,
type NativePromptSessionRenderResponse as NativePromptSessionResult,
} from '../../../native';
import type { PromptMessage, PromptParams } from '../providers/types';
import { projectPromptMessageForNative } from '../runtime/contracts';
import type { PromptSpec } from './spec';
export type NativePromptRenderRequest = Omit<
NativePromptRenderContract,
'messages' | 'templateParams' | 'renderParams'
> & {
messages: PromptMessage[];
templateParams: PromptParams;
renderParams: PromptParams;
};
export type NativePromptRenderResponse = Omit<
NativePromptRenderResult,
'messages'
> & {
messages: PromptMessage[];
};
export type NativeBuiltInPromptRenderRequest = Omit<
NativeBuiltInPromptRenderContract,
'renderParams'
> & {
renderParams: PromptParams;
};
export type NativePromptCountTokensRequest = Omit<
NativePromptTokenCountContract,
'messages' | 'model'
> & {
model?: string | null;
messages: Pick<PromptMessage, 'content'>[];
};
export type NativePromptCountTokensResponse = NativePromptTokenCountResult;
export type NativePromptMetadataRequest = Omit<
NativePromptMetadataContract,
'messages'
> & {
messages: PromptMessage[];
};
export type NativePromptMetadataResponse = Omit<
NativePromptMetadataResult,
'templateParams'
> & {
templateParams: PromptParams;
};
export type NativePromptSessionRenderRequest = Omit<
NativePromptSessionContract,
'prompt' | 'turns' | 'renderParams'
> & {
prompt: Omit<
NativePromptSessionContract['prompt'],
'templateParams' | 'messages' | 'model'
> & {
model?: string | null;
templateParams: PromptParams;
messages: PromptMessage[];
};
turns: PromptMessage[];
renderParams: PromptParams;
};
export type NativePromptSessionRenderResponse = Omit<
NativePromptSessionResult,
'messages'
> & {
messages: PromptMessage[];
};
export type NativeBuiltInPromptSessionRenderRequest = Omit<
NativeBuiltInPromptSessionContract,
'turns' | 'renderParams'
> & {
turns: PromptMessage[];
renderParams: PromptParams;
};
type NativePromptContractMessage =
NativePromptRenderContract['messages'][number];
function toNativePromptMessage(
message: PromptMessage
): NativePromptContractMessage {
return projectPromptMessageForNative(message).message;
}
function fromNativePromptMessage(
message: NativePromptContractMessage
): PromptMessage {
return {
role: message.role,
content: message.content,
...(message.attachments ? { attachments: message.attachments } : {}),
...(message.params ? { params: message.params } : {}),
...(message.responseFormat
? { responseFormat: message.responseFormat }
: {}),
};
}
export function renderPromptNative(
request: NativePromptRenderRequest
): NativePromptRenderResponse {
const normalizedMessages = request.messages.map(toNativePromptMessage);
const rendered = llmRenderPrompt({
messages: normalizedMessages,
templateParams: request.templateParams,
renderParams: request.renderParams,
});
return {
...rendered,
messages: rendered.messages.map(fromNativePromptMessage),
};
}
export function renderBuiltInPromptNative(
request: NativeBuiltInPromptRenderRequest
): NativePromptRenderResponse {
const rendered = llmRenderBuiltInPrompt({
name: request.name,
renderParams: request.renderParams,
});
return {
...rendered,
messages: rendered.messages.map(fromNativePromptMessage),
};
}
export function renderPromptSessionNative(
request: NativePromptSessionRenderRequest
): NativePromptSessionRenderResponse {
const rendered = llmRenderSessionPrompt({
...request,
prompt: {
...request.prompt,
messages: request.prompt.messages.map(toNativePromptMessage),
model: request.prompt.model ?? undefined,
},
turns: request.turns.map(toNativePromptMessage),
renderParams: request.renderParams,
});
return {
...rendered,
messages: rendered.messages.map(fromNativePromptMessage),
};
}
export function renderBuiltInPromptSessionNative(
request: NativeBuiltInPromptSessionRenderRequest
): NativePromptSessionRenderResponse {
const rendered = llmRenderBuiltInSessionPrompt({
...request,
turns: request.turns.map(toNativePromptMessage),
renderParams: request.renderParams,
});
return {
...rendered,
messages: rendered.messages.map(fromNativePromptMessage),
};
}
export function countPromptTokensNative(
request: NativePromptCountTokensRequest
): NativePromptCountTokensResponse {
return llmCountPromptTokens({
...request,
model: request.model ?? undefined,
});
}
export function collectPromptMetadataNative(
request: NativePromptMetadataRequest
): NativePromptMetadataResponse {
return llmCollectPromptMetadata({
messages: request.messages.map(toNativePromptMessage),
});
}
export function listBuiltInPromptSpecsNative(): PromptSpec[] {
return llmListBuiltInPromptSpecs().map(spec => ({
name: spec.name,
action: spec.action,
model: spec.model,
optionalModels: spec.optionalModels,
config: spec.config,
params: spec.params
? Object.fromEntries(
Object.entries(spec.params).map(([key, value]) => [
key,
{
default: value.default,
enum: value.enumValues,
},
])
)
: undefined,
messages: spec.messages.map(message => ({
role: message.role,
template: message.template,
})),
}));
}
export function getBuiltInPromptSpecNative(name: string): PromptSpec | null {
const spec = llmGetBuiltInPromptSpec(name);
if (!spec) {
return null;
}
return {
name: spec.name,
action: spec.action,
model: spec.model,
optionalModels: spec.optionalModels,
config: spec.config,
params: spec.params
? Object.fromEntries(
Object.entries(spec.params).map(([key, value]) => [
key,
{
default: value.default,
enum: value.enumValues,
},
])
)
: undefined,
messages: spec.messages.map(message => ({
role: message.role,
template: message.template,
})),
};
}
File diff suppressed because it is too large Load Diff
@@ -1,323 +1,110 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import { Prisma, PrismaClient } from '@prisma/client';
import { Injectable, Logger } from '@nestjs/common';
import { Config, OnEvent } from '../../../base';
import type { PromptMessage, PromptParams } from '../providers/types';
import {
PromptConfig,
PromptConfigSchema,
PromptMessage,
PromptMessageSchema,
} from '../providers/types';
import { ChatPrompt } from './chat-prompt';
import {
CopilotPromptScenario,
type Prompt,
prompts,
refreshPrompts,
Scenario,
} from './prompts';
collectPromptMetadataNative,
countPromptTokensNative,
getBuiltInPromptSpecNative,
renderBuiltInPromptNative,
renderBuiltInPromptSessionNative,
renderPromptNative,
renderPromptSessionNative,
} from './native-contract';
import type { Prompt, PromptSpec, ResolvedPrompt } from './spec';
@Injectable()
export class PromptService implements OnApplicationBootstrap {
private readonly logger = new Logger(PromptService.name);
private readonly cache = new Map<string, ChatPrompt>();
private readonly inMemoryPrompts = new Map<string, Prompt>();
constructor(
private readonly config: Config,
private readonly db: PrismaClient
) {}
async onApplicationBootstrap() {
this.resetInMemoryPrompts();
await refreshPrompts(this.db);
export class PromptService {
protected readonly logger = new Logger(PromptService.name);
constructor() {
this.logger.log('Using native built-in prompt catalog.');
}
@OnEvent('config.init')
async onConfigInit() {
await this.setup(this.config.copilot?.scenarios);
}
@OnEvent('config.changed')
async onConfigChanged(event: Events['config.changed']) {
if ('copilot' in event.updates) {
await this.setup(event.updates.copilot?.scenarios);
}
}
protected async setup(scenarios?: CopilotPromptScenario) {
this.ensureInMemoryPrompts();
if (!!scenarios && scenarios.override_enabled && scenarios.scenarios) {
this.logger.log('Updating prompts based on scenarios...');
for (const [scenario, model] of Object.entries(scenarios.scenarios)) {
const promptNames = Scenario[scenario as keyof typeof Scenario] || [];
if (!promptNames.length) continue;
for (const name of promptNames) {
const prompt = prompts.find(p => p.name === name);
if (prompt && model) {
await this.update(
prompt.name,
{ model, modified: true },
{ model: { not: model } }
);
}
}
}
} else {
this.logger.log('No scenarios enabled, using default prompts.');
const prompts = Object.values(Scenario).flat();
for (const prompt of prompts) {
await this.update(prompt, { modified: false });
}
}
}
/**
* list prompt names
* @returns prompt names
*/
async listNames() {
this.ensureInMemoryPrompts();
return Array.from(this.inMemoryPrompts.keys());
}
async list() {
this.ensureInMemoryPrompts();
return Array.from(this.inMemoryPrompts.values())
.map(prompt => ({
name: prompt.name,
action: prompt.action ?? null,
model: prompt.model,
config: prompt.config ? structuredClone(prompt.config) : null,
messages: prompt.messages.map(message => ({
role: message.role,
content: message.content,
params: message.params ?? null,
})),
}))
.sort((a, b) => {
if (a.action === null && b.action !== null) return -1;
if (a.action !== null && b.action === null) return 1;
return (a.action ?? '').localeCompare(b.action ?? '');
});
}
/**
* get prompt messages by prompt name
* @param name prompt name
* @returns prompt messages
*/
async get(name: string): Promise<ChatPrompt | null> {
this.ensureInMemoryPrompts();
// skip cache in dev mode to ensure the latest prompt is always fetched
if (!env.dev) {
const cached = this.cache.get(name);
if (cached) return cached;
async get(name: string): Promise<ResolvedPrompt | null> {
const compatPrompt = this.lookupCompatPrompt(name);
if (compatPrompt) {
return this.describeCompatPrompt(this.clonePrompt(compatPrompt));
}
const prompt = this.inMemoryPrompts.get(name);
if (!prompt) return null;
const builtInPromptSpec = this.lookupBuiltInPromptSpec(name);
if (!builtInPromptSpec) return null;
const messages = PromptMessageSchema.array().safeParse(prompt.messages);
const config = PromptConfigSchema.safeParse(prompt.config);
if (messages.success && config.success) {
const chatPrompt = ChatPrompt.createFromPrompt({
...this.clonePrompt(prompt),
action: prompt.action ?? null,
optionalModels: prompt.optionalModels ?? [],
config: config.data,
messages: messages.data,
});
this.cache.set(name, chatPrompt);
return chatPrompt;
}
return this.describeBuiltInPromptSpec(builtInPromptSpec);
}
finish(
prompt: ResolvedPrompt,
params: PromptParams,
sessionId?: string
): PromptMessage[] {
const rendered =
prompt.source === 'built_in'
? renderBuiltInPromptNative({
name: prompt.name,
renderParams: params,
})
: renderPromptNative({
messages: this.requireCompatMessages(prompt),
templateParams: prompt.params,
renderParams: params,
});
this.logWarnings(rendered.warnings, sessionId);
return rendered.messages;
}
renderSession(
prompt: ResolvedPrompt,
turns: PromptMessage[],
params: PromptParams,
maxTokenSize = prompt.config?.maxTokens || 128 * 1024,
sessionId?: string
): PromptMessage[] {
const rendered =
prompt.source === 'built_in'
? renderBuiltInPromptSessionNative({
name: prompt.name,
turns,
renderParams: params,
maxTokenSize,
})
: renderPromptSessionNative({
prompt: {
action: prompt.action,
model: prompt.model,
promptTokens: this.countCompatPromptTokens(prompt),
templateParams: prompt.params,
messages: this.requireCompatMessages(prompt),
},
turns,
renderParams: params,
maxTokenSize,
});
this.logWarnings(rendered.warnings, sessionId);
return rendered.messages;
}
protected lookupCompatPrompt(_name: string): Prompt | null {
return null;
}
async set(
name: string,
model: string,
messages: PromptMessage[],
config?: PromptConfig | null,
extraConfig?: { optionalModels: string[] }
) {
this.ensureInMemoryPrompts();
const existing = this.inMemoryPrompts.get(name);
const mergedOptionalModels = existing?.optionalModels
? [...existing.optionalModels, ...(extraConfig?.optionalModels ?? [])]
: extraConfig?.optionalModels;
const inMemoryConfig = (!!config && structuredClone(config)) || undefined;
const dbConfig = this.toDbConfig(config);
this.inMemoryPrompts.set(name, {
name,
model,
action: existing?.action,
optionalModels: mergedOptionalModels,
config: inMemoryConfig,
messages: this.cloneMessages(messages),
});
this.cache.delete(name);
try {
return await this.db.aiPrompt
.upsert({
where: { name },
create: {
name,
action: existing?.action,
model,
optionalModels: mergedOptionalModels,
config: dbConfig,
messages: {
create: messages.map((m, idx) => ({
idx,
...m,
attachments: m.attachments || undefined,
params: m.params || undefined,
})),
},
},
update: {
model,
optionalModels: mergedOptionalModels,
config: dbConfig,
updatedAt: new Date(),
messages: {
deleteMany: {},
create: messages.map((m, idx) => ({
idx,
...m,
attachments: m.attachments || undefined,
params: m.params || undefined,
})),
},
},
})
.then(ret => ret.id);
} catch (error) {
this.logger.warn(
`Compat prompt upsert failed for "${name}": ${this.stringifyError(error)}`
);
return -1;
}
protected lookupBuiltInPromptSpec(name: string): PromptSpec | null {
const spec = getBuiltInPromptSpecNative(name);
return spec ? this.clonePromptSpec(spec) : null;
}
@Transactional()
async update(
name: string,
data: {
messages?: PromptMessage[];
model?: string;
modified?: boolean;
config?: PromptConfig | null;
},
where?: Prisma.AiPromptWhereInput
) {
this.ensureInMemoryPrompts();
const { config, messages, model, modified } = data;
const current = this.inMemoryPrompts.get(name);
if (current) {
const next = this.clonePrompt(current);
if (model !== undefined) {
next.model = model;
}
if (config === null) {
next.config = undefined;
} else if (config !== undefined) {
next.config = structuredClone(config);
}
if (messages) {
next.messages = this.cloneMessages(messages);
}
this.inMemoryPrompts.set(name, next);
this.cache.delete(name);
}
try {
const existing = await this.db.aiPrompt
.count({ where: { ...where, name } })
.then(count => count > 0);
if (existing) {
await this.db.aiPrompt.update({
where: { name },
data: {
config: this.toDbConfig(config),
updatedAt: new Date(),
modified,
model,
messages: messages
? {
// cleanup old messages
deleteMany: {},
create: messages.map((m, idx) => ({
idx,
...m,
attachments: m.attachments || undefined,
params: m.params || undefined,
})),
}
: undefined,
},
});
}
} catch (error) {
this.logger.warn(
`Compat prompt update failed for "${name}": ${this.stringifyError(error)}`
);
}
}
async delete(name: string) {
this.inMemoryPrompts.delete(name);
this.cache.delete(name);
try {
const { id } = await this.db.aiPrompt.delete({ where: { name } });
return id;
} catch (error) {
this.logger.warn(
`Compat prompt delete failed for "${name}": ${this.stringifyError(error)}`
);
return -1;
}
}
private resetInMemoryPrompts() {
this.cache.clear();
this.inMemoryPrompts.clear();
for (const prompt of prompts) {
this.inMemoryPrompts.set(prompt.name, this.clonePrompt(prompt));
}
}
private ensureInMemoryPrompts() {
if (!this.inMemoryPrompts.size) {
this.resetInMemoryPrompts();
}
}
private toDbConfig(
config: PromptConfig | null | undefined
): Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput | undefined {
if (config === null) return Prisma.DbNull;
if (config === undefined) return undefined;
return config as Prisma.InputJsonValue;
}
private cloneMessages(messages: PromptMessage[]) {
protected cloneMessages(messages: PromptMessage[]) {
return messages.map(message => ({
...message,
attachments: message.attachments ? [...message.attachments] : undefined,
params: message.params ? structuredClone(message.params) : undefined,
responseFormat: message.responseFormat
? structuredClone(message.responseFormat)
: undefined,
}));
}
private clonePrompt(prompt: Prompt): Prompt {
protected clonePrompt(prompt: Prompt): Prompt {
return {
...prompt,
optionalModels: prompt.optionalModels
@@ -328,7 +115,93 @@ export class PromptService implements OnApplicationBootstrap {
};
}
private stringifyError(error: unknown) {
return error instanceof Error ? error.message : String(error);
protected clonePromptSpec(spec: PromptSpec): PromptSpec {
return {
...spec,
optionalModels: spec.optionalModels
? [...spec.optionalModels]
: undefined,
config: spec.config ? structuredClone(spec.config) : undefined,
params: spec.params ? structuredClone(spec.params) : undefined,
messages: spec.messages.map(message => ({ ...message })),
};
}
private describeBuiltInPromptSpec(spec: PromptSpec): ResolvedPrompt {
const params = this.normalizePromptSpecParams(spec.params);
return {
name: spec.name,
action: spec.action,
model: spec.model,
optionalModels: spec.optionalModels ?? [],
config: spec.config ? structuredClone(spec.config) : undefined,
paramKeys: Object.keys(params),
params,
source: 'built_in',
};
}
private describeCompatPrompt(prompt: Prompt): ResolvedPrompt {
const metadata = collectPromptMetadataNative({ messages: prompt.messages });
return {
name: prompt.name,
action: prompt.action,
model: prompt.model,
optionalModels: prompt.optionalModels ?? [],
config: prompt.config ? structuredClone(prompt.config) : undefined,
paramKeys: metadata.paramKeys,
params: metadata.templateParams,
source: 'compat',
messages: prompt.messages,
};
}
private normalizePromptSpecParams(
params?: PromptSpec['params']
): PromptParams {
if (!params) return {};
return Object.fromEntries(
Object.entries(params).map(([key, value]) => {
if (value.enum?.length) {
const normalized = value.default
? [
value.default,
...value.enum.filter(option => option !== value.default),
]
: [...value.enum];
return [key, normalized];
}
return [key, value.default ?? ''];
})
);
}
private countCompatPromptTokens(prompt: ResolvedPrompt): number {
return countPromptTokensNative({
model: prompt.model,
messages: this.requireCompatMessages(prompt).map(message => ({
content: message.content,
})),
}).tokens;
}
private requireCompatMessages(prompt: ResolvedPrompt): PromptMessage[] {
if (prompt.source === 'compat' && prompt.messages) {
return this.cloneMessages(prompt.messages);
}
throw new Error(`Prompt ${prompt.name} does not expose compat messages`);
}
private logWarnings(warnings: string[], sessionId?: string) {
if (!sessionId) {
return;
}
for (const warning of warnings) {
this.logger.warn(`${warning} in session ${sessionId}`);
}
}
}
@@ -0,0 +1,46 @@
import type {
PromptConfig,
PromptMessage,
PromptParams,
} from '../providers/types';
export type Prompt = {
name: string;
model: string;
optionalModels?: string[];
action?: string;
messages: PromptMessage[];
config?: PromptConfig;
};
export type ResolvedPrompt = {
name: string;
model: string;
optionalModels: string[];
action?: string;
config?: PromptConfig;
paramKeys: string[];
params: PromptParams;
source: 'built_in' | 'compat';
messages?: PromptMessage[];
};
type PromptParamSpec = {
default?: string;
enum?: string[];
};
type PromptSpecMessage = {
role: 'system' | 'assistant' | 'user';
template: string;
};
export type PromptSpec = {
name: string;
action?: string;
model: string;
optionalModels?: string[];
config?: PromptConfig;
params?: Record<string, PromptParamSpec>;
messages: PromptSpecMessage[];
};
@@ -1,24 +1,15 @@
import { CopilotProviderSideError, UserFriendlyError } from '../../../../base';
import {
CopilotProviderSideError,
metrics,
UserFriendlyError,
} from '../../../../base';
import {
llmDispatchStream,
type NativeLlmBackendConfig,
type NativeLlmRequest,
type LlmBackendConfig,
llmResolveRequestIntentOptions,
} from '../../../../native';
import type { NodeTextMiddleware } from '../../config';
import type { CopilotToolSet } from '../../tools';
import { buildNativeRequest, NativeProviderAdapter } from '../native';
import { CopilotProvider } from '../provider';
import type {
CopilotChatOptions,
ModelConditions,
PromptMessage,
StreamObject,
} from '../types';
import { CopilotProviderType, ModelOutputType } from '../types';
import { hasProviderModelBehaviorFlag } from '../provider-model-runtime';
import {
type CopilotProviderExecution,
type ProviderDriverSpec,
} from '../provider-runtime-contract';
import { CopilotProviderType } from '../types';
import {
getGoogleAuth,
getVertexAnthropicBaseUrl,
@@ -26,6 +17,51 @@ import {
} from '../utils';
export abstract class AnthropicProvider<T> extends CopilotProvider<T> {
protected resolveModelBackendKind() {
return this.type === CopilotProviderType.AnthropicVertex
? ('anthropic_vertex' as const)
: ('anthropic' as const);
}
override getDriverSpec(): ProviderDriverSpec {
return {
createBackendConfig: execution => this.createNativeConfig(execution),
mapError: error => this.handleError(error),
chat: {
resolveRequestOptions: async context => {
const requestIntent = await llmResolveRequestIntentOptions({
protocol: context.protocol,
backendConfig: context.backendConfig,
reasoning: {
enabled: context.options.reasoning,
supported: hasProviderModelBehaviorFlag(
context.model,
'reasoning_budget_12000'
),
budgetTokens: hasProviderModelBehaviorFlag(
context.model,
'reasoning_budget_12000'
)
? 12000
: undefined,
},
});
return {
attachmentCapability: this.getAttachCapability(
context.model,
context.outputType
),
reasoning: requestIntent.reasoning,
};
},
},
structured: false,
embedding: false,
rerank: false,
};
}
private handleError(e: any) {
if (e instanceof UserFriendlyError) {
return e;
@@ -37,198 +73,28 @@ export abstract class AnthropicProvider<T> extends CopilotProvider<T> {
});
}
private async createNativeConfig(): Promise<NativeLlmBackendConfig> {
private async createNativeConfig(
execution?: CopilotProviderExecution
): Promise<LlmBackendConfig> {
const config = this.getConfig(execution);
if (this.type === CopilotProviderType.AnthropicVertex) {
const config = this.config as VertexAnthropicProviderConfig;
const auth = await getGoogleAuth(config, 'anthropic');
const vertexConfig = config as VertexAnthropicProviderConfig;
const auth = await getGoogleAuth(vertexConfig, 'anthropic');
const { Authorization: authHeader } = auth.headers();
const token = authHeader.replace(/^Bearer\s+/i, '');
const baseUrl = getVertexAnthropicBaseUrl(config) || auth.baseUrl;
const baseUrl = getVertexAnthropicBaseUrl(vertexConfig) || auth.baseUrl;
return {
base_url: baseUrl || '',
auth_token: token,
request_layer: 'vertex_anthropic',
headers: { Authorization: authHeader },
};
}
const config = this.config as { apiKey: string; baseURL?: string };
const baseUrl = config.baseURL || 'https://api.anthropic.com/v1';
const officialConfig = config as { apiKey: string; baseURL?: string };
const baseUrl = officialConfig.baseURL || 'https://api.anthropic.com/v1';
return {
base_url: baseUrl.replace(/\/v1\/?$/, ''),
auth_token: config.apiKey,
auth_token: officialConfig.apiKey,
};
}
private createAdapter(
backendConfig: NativeLlmBackendConfig,
tools: CopilotToolSet,
nodeTextMiddleware?: NodeTextMiddleware[]
) {
return new NativeProviderAdapter(
(request: NativeLlmRequest, signal?: AbortSignal) =>
llmDispatchStream('anthropic', backendConfig, request, signal),
tools,
this.MAX_STEPS,
{ nodeTextMiddleware }
);
}
private getReasoning(
options: NonNullable<CopilotChatOptions>,
model: string
): Record<string, unknown> | undefined {
if (options.reasoning && this.isReasoningModel(model)) {
return { budget_tokens: 12000, include_thought: true };
}
return undefined;
}
async text(
cond: ModelConditions,
messages: PromptMessage[],
options: CopilotChatOptions = {}
): Promise<string> {
const fullCond = { ...cond, outputType: ModelOutputType.Text };
const normalizedCond = await this.checkParams({
cond: fullCond,
messages,
options,
});
const model = this.selectModel(normalizedCond);
try {
metrics.ai.counter('chat_text_calls').add(1, this.metricLabels(model.id));
const backendConfig = await this.createNativeConfig();
const tools = await this.getTools(options, model.id);
const middleware = this.getActiveProviderMiddleware();
const reasoning = this.getReasoning(options, model.id);
const cap = this.getAttachCapability(model, ModelOutputType.Text);
const { request } = await buildNativeRequest({
model: model.id,
messages,
options,
tools,
attachmentCapability: cap,
reasoning,
middleware,
});
const adapter = this.createAdapter(
backendConfig,
tools,
middleware.node?.text
);
return await adapter.text(request, options.signal, messages);
} catch (e: any) {
metrics.ai
.counter('chat_text_errors')
.add(1, this.metricLabels(model.id));
throw this.handleError(e);
}
}
async *streamText(
cond: ModelConditions,
messages: PromptMessage[],
options: CopilotChatOptions = {}
): AsyncIterable<string> {
const fullCond = { ...cond, outputType: ModelOutputType.Text };
const normalizedCond = await this.checkParams({
cond: fullCond,
messages,
options,
});
const model = this.selectModel(normalizedCond);
try {
metrics.ai
.counter('chat_text_stream_calls')
.add(1, this.metricLabels(model.id));
const backendConfig = await this.createNativeConfig();
const tools = await this.getTools(options, model.id);
const middleware = this.getActiveProviderMiddleware();
const cap = this.getAttachCapability(model, ModelOutputType.Text);
const { request } = await buildNativeRequest({
model: model.id,
messages,
options,
tools,
attachmentCapability: cap,
reasoning: this.getReasoning(options, model.id),
middleware,
});
const adapter = this.createAdapter(
backendConfig,
tools,
middleware.node?.text
);
for await (const chunk of adapter.streamText(
request,
options.signal,
messages
)) {
yield chunk;
}
} catch (e: any) {
metrics.ai
.counter('chat_text_stream_errors')
.add(1, this.metricLabels(model.id));
throw this.handleError(e);
}
}
override async *streamObject(
cond: ModelConditions,
messages: PromptMessage[],
options: CopilotChatOptions = {}
): AsyncIterable<StreamObject> {
const fullCond = { ...cond, outputType: ModelOutputType.Object };
const normalizedCond = await this.checkParams({
cond: fullCond,
messages,
options,
});
const model = this.selectModel(normalizedCond);
try {
metrics.ai
.counter('chat_object_stream_calls')
.add(1, this.metricLabels(model.id));
const backendConfig = await this.createNativeConfig();
const tools = await this.getTools(options, model.id);
const middleware = this.getActiveProviderMiddleware();
const cap = this.getAttachCapability(model, ModelOutputType.Object);
const { request } = await buildNativeRequest({
model: model.id,
messages,
options,
tools,
attachmentCapability: cap,
reasoning: this.getReasoning(options, model.id),
middleware,
});
const adapter = this.createAdapter(
backendConfig,
tools,
middleware.node?.text
);
for await (const chunk of adapter.streamObject(
request,
options.signal,
messages
)) {
yield chunk;
}
} catch (e: any) {
metrics.ai
.counter('chat_object_stream_errors')
.add(1, this.metricLabels(model.id));
throw this.handleError(e);
}
}
private isReasoningModel(model: string) {
// claude 3.5 sonnet doesn't support reasoning config
return model.includes('sonnet') && !model.startsWith('claude-3-5-sonnet');
}
}
@@ -1,7 +1,5 @@
import z from 'zod';
import { IMAGE_ATTACHMENT_CAPABILITY } from '../attachments';
import { CopilotProviderType, ModelInputType, ModelOutputType } from '../types';
import type { CopilotProviderExecution } from '../provider-runtime-contract';
import { CopilotProviderType } from '../types';
import { AnthropicProvider } from './anthropic';
export type AnthropicOfficialConfig = {
@@ -9,74 +7,10 @@ export type AnthropicOfficialConfig = {
baseURL?: string;
};
const ModelListSchema = z.object({
data: z.array(z.object({ id: z.string() })),
});
export class AnthropicOfficialProvider extends AnthropicProvider<AnthropicOfficialConfig> {
override readonly type = CopilotProviderType.Anthropic;
override readonly models = [
{
name: 'Claude Opus 4',
id: 'claude-opus-4-20250514',
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Object],
attachments: IMAGE_ATTACHMENT_CAPABILITY,
},
],
},
{
name: 'Claude Sonnet 4',
id: 'claude-sonnet-4-5-20250929',
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Object],
attachments: IMAGE_ATTACHMENT_CAPABILITY,
},
],
},
{
name: 'Claude Sonnet 4',
id: 'claude-sonnet-4-20250514',
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Object],
attachments: IMAGE_ATTACHMENT_CAPABILITY,
},
],
},
];
override configured(): boolean {
return !!this.config.apiKey;
}
override setup() {
super.setup();
}
override async refreshOnlineModels() {
try {
const baseUrl = this.config.baseURL || 'https://api.anthropic.com/v1';
if (baseUrl && !this.onlineModelList.length) {
const { data } = await fetch(`${baseUrl}/models`, {
headers: {
'x-api-key': this.config.apiKey,
'anthropic-version': '2023-06-01',
'Content-Type': 'application/json',
},
})
.then(r => r.json())
.then(r => ModelListSchema.parse(r));
this.onlineModelList = data.map(model => model.id);
}
} catch (e) {
this.logger.error('Failed to fetch available models', e);
}
override configured(execution?: CopilotProviderExecution): boolean {
return !!this.getConfig(execution).apiKey;
}
}
@@ -1,11 +1,6 @@
import { IMAGE_ATTACHMENT_CAPABILITY } from '../attachments';
import { CopilotProviderType, ModelInputType, ModelOutputType } from '../types';
import {
getGoogleAuth,
getVertexAnthropicBaseUrl,
VertexModelListSchema,
type VertexProviderConfig,
} from '../utils';
import type { CopilotProviderExecution } from '../provider-runtime-contract';
import { CopilotProviderType } from '../types';
import { getVertexAnthropicBaseUrl, type VertexProviderConfig } from '../utils';
import { AnthropicProvider } from './anthropic';
export type AnthropicVertexConfig = VertexProviderConfig;
@@ -13,67 +8,9 @@ export type AnthropicVertexConfig = VertexProviderConfig;
export class AnthropicVertexProvider extends AnthropicProvider<AnthropicVertexConfig> {
override readonly type = CopilotProviderType.AnthropicVertex;
override readonly models = [
{
name: 'Claude Opus 4',
id: 'claude-opus-4@20250514',
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Object],
attachments: IMAGE_ATTACHMENT_CAPABILITY,
},
],
},
{
name: 'Claude Sonnet 4.5',
id: 'claude-sonnet-4-5@20250929',
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Object],
attachments: IMAGE_ATTACHMENT_CAPABILITY,
},
],
},
{
name: 'Claude Sonnet 4',
id: 'claude-sonnet-4@20250514',
capabilities: [
{
input: [ModelInputType.Text, ModelInputType.Image],
output: [ModelOutputType.Text, ModelOutputType.Object],
attachments: IMAGE_ATTACHMENT_CAPABILITY,
},
],
},
];
override configured(): boolean {
if (!this.config.location || !this.config.googleAuthOptions) return false;
return !!this.config.project || !!getVertexAnthropicBaseUrl(this.config);
}
override async refreshOnlineModels() {
try {
const { baseUrl, headers } = await getGoogleAuth(
this.config,
'anthropic'
);
if (baseUrl && !this.onlineModelList.length) {
const { publisherModels } = await fetch(`${baseUrl}/models`, {
headers: headers(),
})
.then(r => r.json())
.then(r => VertexModelListSchema.parse(r));
this.onlineModelList = publisherModels.map(
model =>
model.name.replace('publishers/anthropic/models/', '') +
(model.versionId !== 'default' ? `@${model.versionId}` : '')
);
}
} catch (e) {
this.logger.error('Failed to fetch available models', e);
}
override configured(execution?: CopilotProviderExecution): boolean {
const config = this.getConfig(execution);
if (!config.location || !config.googleAuthOptions) return false;
return !!config.project || !!getVertexAnthropicBaseUrl(config);
}
}
@@ -1,11 +1,8 @@
import type {
ModelAttachmentCapability,
PromptAttachment,
PromptAttachmentKind,
PromptAttachmentSourceKind,
PromptMessage,
} from './types';
import { inferMimeType } from './utils';
export const IMAGE_ATTACHMENT_CAPABILITY: ModelAttachmentCapability = {
kinds: ['image'],
@@ -19,75 +16,6 @@ export const GEMINI_ATTACHMENT_CAPABILITY: ModelAttachmentCapability = {
allowRemoteUrls: true,
};
export type CanonicalPromptAttachment = {
kind: PromptAttachmentKind;
sourceKind: PromptAttachmentSourceKind;
mediaType?: string;
source: Record<string, unknown>;
isRemote: boolean;
};
function parseDataUrl(url: string) {
if (!url.startsWith('data:')) {
return null;
}
const commaIndex = url.indexOf(',');
if (commaIndex === -1) {
return null;
}
const meta = url.slice(5, commaIndex);
const payload = url.slice(commaIndex + 1);
const parts = meta.split(';');
const mediaType = parts[0] || 'text/plain;charset=US-ASCII';
const isBase64 = parts.includes('base64');
return {
mediaType,
data: isBase64
? payload
: Buffer.from(decodeURIComponent(payload), 'utf8').toString('base64'),
};
}
function attachmentTypeFromMediaType(mediaType: string): PromptAttachmentKind {
if (mediaType.startsWith('image/')) {
return 'image';
}
if (mediaType.startsWith('audio/')) {
return 'audio';
}
return 'file';
}
function attachmentKindFromHintOrMediaType(
hint: PromptAttachmentKind | undefined,
mediaType: string | undefined
): PromptAttachmentKind {
if (hint) return hint;
return attachmentTypeFromMediaType(mediaType || '');
}
function toBase64Data(data: string, encoding: 'base64' | 'utf8' = 'base64') {
return encoding === 'base64'
? data
: Buffer.from(data, 'utf8').toString('base64');
}
function appendAttachMetadata(
source: Record<string, unknown>,
attachment: Exclude<PromptAttachment, string> & Record<string, unknown>
) {
if (attachment.fileName) {
source.file_name = attachment.fileName;
}
if (attachment.providerHint) {
source.provider_hint = attachment.providerHint;
}
return source;
}
export function promptAttachmentHasSource(
attachment: PromptAttachment
): boolean {
@@ -110,124 +38,36 @@ export function promptAttachmentHasSource(
}
}
export async function canonicalizePromptAttachment(
export function applyPromptAttachmentMimeTypeHintForNative(
attachment: PromptAttachment,
message: Pick<PromptMessage, 'params'>
): Promise<CanonicalPromptAttachment> {
): PromptAttachment {
const fallbackMimeType =
typeof message.params?.mimetype === 'string'
? message.params.mimetype
: undefined;
if (typeof attachment === 'string') {
const dataUrl = parseDataUrl(attachment);
const mediaType =
fallbackMimeType ??
dataUrl?.mediaType ??
(await inferMimeType(attachment));
const kind = attachmentKindFromHintOrMediaType(undefined, mediaType);
if (dataUrl) {
return {
kind,
sourceKind: 'data',
mediaType,
isRemote: false,
source: {
media_type: mediaType || dataUrl.mediaType,
data: dataUrl.data,
},
};
}
return {
kind,
sourceKind: 'url',
mediaType,
isRemote: /^https?:\/\//.test(attachment),
source: { url: attachment, media_type: mediaType },
};
if (attachment.startsWith('data:')) return attachment;
return fallbackMimeType
? { attachment, mimeType: fallbackMimeType }
: attachment;
}
if ('attachment' in attachment) {
return await canonicalizePromptAttachment(
{
kind: 'url',
url: attachment.attachment,
mimeType: attachment.mimeType,
},
message
);
if (attachment.mimeType || !fallbackMimeType) return attachment;
return { ...attachment, mimeType: fallbackMimeType };
}
if (attachment.kind === 'url') {
const dataUrl = parseDataUrl(attachment.url);
const mediaType =
attachment.mimeType ??
fallbackMimeType ??
dataUrl?.mediaType ??
(await inferMimeType(attachment.url));
const kind = attachmentKindFromHintOrMediaType(
attachment.providerHint?.kind,
mediaType
);
if (dataUrl) {
return {
kind,
sourceKind: 'data',
mediaType,
isRemote: false,
source: appendAttachMetadata(
{ media_type: mediaType || dataUrl.mediaType, data: dataUrl.data },
attachment
),
};
}
if (attachment.kind !== 'url') return attachment;
return {
kind,
sourceKind: 'url',
mediaType,
isRemote: /^https?:\/\//.test(attachment.url),
source: appendAttachMetadata(
{ url: attachment.url, media_type: mediaType },
attachment
),
};
if (
attachment.url.startsWith('data:') ||
attachment.mimeType ||
!fallbackMimeType
) {
return attachment;
}
if (attachment.kind === 'data' || attachment.kind === 'bytes') {
return {
kind: attachmentKindFromHintOrMediaType(
attachment.providerHint?.kind,
attachment.mimeType
),
sourceKind: attachment.kind,
mediaType: attachment.mimeType,
isRemote: false,
source: appendAttachMetadata(
{
media_type: attachment.mimeType,
data: toBase64Data(
attachment.data,
attachment.kind === 'data' ? attachment.encoding : 'base64'
),
},
attachment
),
};
}
return {
kind: attachmentKindFromHintOrMediaType(
attachment.providerHint?.kind,
attachment.mimeType
),
sourceKind: 'file_handle',
mediaType: attachment.mimeType,
isRemote: false,
source: appendAttachMetadata(
{ file_handle: attachment.fileHandle, media_type: attachment.mimeType },
attachment
),
};
return { ...attachment, mimeType: fallbackMimeType };
}
@@ -1,34 +1,12 @@
import {
CopilotProviderSideError,
metrics,
UserFriendlyError,
} from '../../../base';
import {
llmDispatchStream,
llmRerankDispatch,
type NativeLlmBackendConfig,
type NativeLlmRequest,
type NativeLlmRerankRequest,
type NativeLlmRerankResponse,
} from '../../../native';
import type { NodeTextMiddleware } from '../config';
import type { CopilotTool, CopilotToolSet } from '../tools';
import {
buildNativeRequest,
buildNativeRerankRequest,
NativeProviderAdapter,
} from './native';
import { CopilotProviderSideError, UserFriendlyError } from '../../../base';
import { type LlmBackendConfig } from '../../../native';
import type { CopilotTool } from '../tools';
import { CopilotProvider } from './provider';
import type {
CopilotChatOptions,
CopilotChatTools,
CopilotProviderModel,
CopilotRerankRequest,
ModelConditions,
PromptMessage,
StreamObject,
} from './types';
import { CopilotProviderType, ModelInputType, ModelOutputType } from './types';
import {
type CopilotProviderExecution,
type ProviderDriverSpec,
} from './provider-runtime-contract';
import { type CopilotChatTools, CopilotProviderType } from './types';
export type CloudflareWorkersAIConfig = {
apiToken: string;
@@ -36,77 +14,17 @@ export type CloudflareWorkersAIConfig = {
baseURL?: string;
};
function rerankOnlyModel(
id: string,
name: string,
defaultForOutputType = false
): CopilotProviderModel {
return {
name,
id,
capabilities: [
{
input: [ModelInputType.Text],
output: [ModelOutputType.Rerank],
...(defaultForOutputType ? { defaultForOutputType } : {}),
},
],
};
}
function chatAndRerankModel(
id: string,
name: string,
defaultForRerank = false
): CopilotProviderModel {
return {
name,
id,
capabilities: [
{
input: [ModelInputType.Text],
output: [
ModelOutputType.Text,
ModelOutputType.Object,
ModelOutputType.Rerank,
],
...(defaultForRerank ? { defaultForOutputType: true } : {}),
},
],
};
}
export class CloudflareWorkersAIProvider extends CopilotProvider<CloudflareWorkersAIConfig> {
override readonly type = CopilotProviderType.CloudflareWorkersAi;
override readonly models = [
rerankOnlyModel('@cf/baai/bge-reranker-base', 'BGE Reranker Base', true),
chatAndRerankModel('@cf/moonshotai/kimi-k2.5', 'Kimi K2.5'),
chatAndRerankModel(
'@cf/ibm-granite/granite-4.0-h-micro',
'Granite 4.0 H Micro'
),
chatAndRerankModel(
'@cf/aisingapore/gemma-sea-lion-v4-27b-it',
'Gemma Sea Lion V4 27B IT'
),
chatAndRerankModel(
'@cf/nvidia/nemotron-3-120b-a12b',
'Nemotron 3 120B A12B'
),
chatAndRerankModel('@cf/zai-org/glm-4.7-flash', 'GLM 4.7 Flash'),
chatAndRerankModel('@cf/qwen/qwen3-30b-a3b-fp8', 'Qwen3 30B A3B FP8'),
];
override configured(): boolean {
return (
!!this.config.apiToken &&
(!!this.config.accountId || !!this.config.baseURL)
);
protected resolveModelBackendKind() {
return 'cloudflare_workers_ai' as const;
}
override async refreshOnlineModels() {}
override configured(execution?: CopilotProviderExecution): boolean {
const config = this.getConfig(execution);
return !!config.apiToken && (!!config.accountId || !!config.baseURL);
}
override getProviderSpecificTools(
toolName: CopilotChatTools,
_model: string
@@ -128,178 +46,31 @@ export class CloudflareWorkersAIProvider extends CopilotProvider<CloudflareWorke
});
}
private createNativeConfig(): NativeLlmBackendConfig {
private createNativeConfig(
execution?: CopilotProviderExecution
): LlmBackendConfig {
const config = this.getConfig(execution);
return {
base_url: this.resolveBaseUrl(),
auth_token: this.config.apiToken,
request_layer: 'cloudflare_workers_ai',
base_url: this.resolveBaseUrl(execution),
auth_token: config.apiToken,
};
}
private createNativeDispatch(
backendConfig: NativeLlmBackendConfig,
tools: CopilotToolSet,
nodeTextMiddleware?: NodeTextMiddleware[]
) {
return new NativeProviderAdapter(
(request: NativeLlmRequest, signal?: AbortSignal) =>
llmDispatchStream('openai_chat', backendConfig, request, signal),
tools,
this.MAX_STEPS,
{ nodeTextMiddleware }
);
}
private createNativeRerankDispatch(backendConfig: NativeLlmBackendConfig) {
return (
request: NativeLlmRerankRequest
): Promise<NativeLlmRerankResponse> =>
llmRerankDispatch('openai_chat', backendConfig, request);
}
private resolveBaseUrl() {
if (this.config.baseURL) {
return this.config.baseURL.replace(/\/v1\/?$/, '').replace(/\/$/, '');
private resolveBaseUrl(execution?: CopilotProviderExecution) {
const config = this.getConfig(execution);
if (config.baseURL) {
return config.baseURL.replace(/\/v1\/?$/, '').replace(/\/$/, '');
}
const accountId = this.config.accountId ?? '';
const accountId = config.accountId ?? '';
return `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai`;
}
override async text(
cond: ModelConditions,
messages: PromptMessage[],
options: CopilotChatOptions = {}
): Promise<string> {
const normalizedCond = await this.checkParams({
messages,
cond: { ...cond, outputType: ModelOutputType.Text },
options,
});
const model = this.selectModel(normalizedCond);
try {
metrics.ai.counter('chat_text_calls').add(1, this.metricLabels(model.id));
const tools = await this.getTools(options, model.id);
const middleware = this.getActiveProviderMiddleware();
const { request } = await buildNativeRequest({
model: model.id,
messages,
options,
tools,
middleware,
});
return await this.createNativeDispatch(
this.createNativeConfig(),
tools,
middleware.node?.text
).text(request, options.signal, messages);
} catch (e: any) {
metrics.ai
.counter('chat_text_errors')
.add(1, this.metricLabels(model.id));
throw this.handleError(e);
}
}
override async *streamText(
cond: ModelConditions,
messages: PromptMessage[],
options: CopilotChatOptions = {}
): AsyncIterable<string> {
const normalizedCond = await this.checkParams({
messages,
cond: { ...cond, outputType: ModelOutputType.Text },
options,
});
const model = this.selectModel(normalizedCond);
try {
metrics.ai
.counter('chat_text_stream_calls')
.add(1, this.metricLabels(model.id));
const tools = await this.getTools(options, model.id);
const middleware = this.getActiveProviderMiddleware();
const { request } = await buildNativeRequest({
model: model.id,
messages,
options,
tools,
middleware,
});
for await (const chunk of this.createNativeDispatch(
this.createNativeConfig(),
tools,
middleware.node?.text
).streamText(request, options.signal, messages)) {
yield chunk;
}
} catch (e: any) {
metrics.ai
.counter('chat_text_stream_errors')
.add(1, this.metricLabels(model.id));
throw this.handleError(e);
}
}
override async *streamObject(
cond: ModelConditions,
messages: PromptMessage[],
options: CopilotChatOptions = {}
): AsyncIterable<StreamObject> {
const normalizedCond = await this.checkParams({
messages,
cond: { ...cond, outputType: ModelOutputType.Object },
options,
});
const model = this.selectModel(normalizedCond);
try {
metrics.ai
.counter('chat_object_stream_calls')
.add(1, this.metricLabels(model.id));
const tools = await this.getTools(options, model.id);
const middleware = this.getActiveProviderMiddleware();
const { request } = await buildNativeRequest({
model: model.id,
messages,
options,
tools,
middleware,
});
for await (const chunk of this.createNativeDispatch(
this.createNativeConfig(),
tools,
middleware.node?.text
).streamObject(request, options.signal, messages)) {
yield chunk;
}
} catch (e: any) {
metrics.ai
.counter('chat_object_stream_errors')
.add(1, this.metricLabels(model.id));
throw this.handleError(e);
}
}
override async rerank(
cond: ModelConditions,
request: CopilotRerankRequest,
options: CopilotChatOptions = {}
): Promise<number[]> {
const normalizedCond = await this.checkParams({
messages: [],
cond: { ...cond, outputType: ModelOutputType.Rerank },
options,
});
const model = this.selectModel(normalizedCond);
try {
const response = await this.createNativeRerankDispatch(
this.createNativeConfig()
)(buildNativeRerankRequest(model.id, request));
return response.scores;
} catch (e: any) {
throw this.handleError(e);
}
override getDriverSpec(): ProviderDriverSpec {
return {
createBackendConfig: execution => this.createNativeConfig(execution),
mapError: error => this.handleError(error),
structured: false,
embedding: false,
};
}
}
@@ -1,39 +1,76 @@
import { Injectable, Logger } from '@nestjs/common';
import { Config } from '../../../base';
import { ServerFeature, ServerService } from '../../../core';
import type { RequiredStructuredOutputContract } from '../runtime/contracts';
import { getProviderRuntimeHost } from '../runtime/provider-runtime-context';
import type { CopilotProvider } from './provider';
import {
buildProviderRegistry,
type NormalizedCopilotProviderProfile,
resolveModel,
stripProviderPrefix,
} from './provider-registry';
import { CopilotProviderType, ModelFullConditions } from './types';
import type {
CopilotProviderExecution,
PreparedNativeEmbeddingExecution,
PreparedNativeExecution,
PreparedNativeImageExecution,
PreparedNativeRerankExecution,
PreparedNativeStructuredExecution,
} from './provider-runtime-contract';
import { CopilotProviderRegistryService } from './registry-service';
import {
type CopilotChatOptions,
type CopilotEmbeddingOptions,
type CopilotImageOptions,
CopilotProviderType,
type CopilotRerankRequest,
type CopilotStructuredOptions,
ModelFullConditions,
ModelOutputType,
type PromptMessage,
} from './types';
function isAsyncIterable(value: unknown): value is AsyncIterable<unknown> {
return (
value !== null &&
value !== undefined &&
typeof (value as AsyncIterable<unknown>)[Symbol.asyncIterator] ===
'function'
);
}
export type ResolvedCopilotProvider = {
providerId: string;
provider: CopilotProvider;
execution: CopilotProviderExecution;
profile: NormalizedCopilotProviderProfile;
rawModelId?: string;
modelId?: string;
explicitProviderId?: string;
prepared?: PreparedNativeExecution;
preparedStructured?: PreparedNativeStructuredExecution;
preparedEmbedding?: PreparedNativeEmbeddingExecution;
preparedRerank?: PreparedNativeRerankExecution;
preparedImage?: PreparedNativeImageExecution;
};
type RoutePreparationResult = Partial<
Pick<
ResolvedCopilotProvider,
| 'prepared'
| 'preparedStructured'
| 'preparedEmbedding'
| 'preparedRerank'
| 'preparedImage'
| 'modelId'
>
>;
@Injectable()
export class CopilotProviderFactory {
constructor(
private readonly server: ServerService,
private readonly config: Config
private readonly registries: CopilotProviderRegistryService
) {}
private readonly logger = new Logger(CopilotProviderFactory.name);
readonly #providers = new Map<string, CopilotProvider>();
readonly #boundProviders = new Map<string, CopilotProvider>();
readonly #providerIdsByType = new Map<CopilotProviderType, Set<string>>();
private getRegistry() {
return buildProviderRegistry(this.config.copilot.providers);
return this.registries.getRegistry();
}
private getPreferredProviderIds(type?: CopilotProviderType) {
@@ -50,91 +87,235 @@ export class CopilotProviderFactory {
return { ...cond, modelId };
}
private normalizeMethodArgs(providerId: string, args: unknown[]) {
const [first, ...rest] = args;
if (
!first ||
typeof first !== 'object' ||
Array.isArray(first) ||
!('modelId' in first)
) {
return args;
}
private filterPreparedRoutes(routes: Array<ResolvedCopilotProvider | null>) {
return routes.filter(
(route): route is ResolvedCopilotProvider => route !== null
);
}
const cond = first as Record<string, unknown>;
if (typeof cond.modelId !== 'string') return args;
private async prepareResolvedRoutes(
routes: ResolvedCopilotProvider[],
prepare: (
route: ResolvedCopilotProvider
) => Promise<RoutePreparationResult | null | undefined>
) {
const preparedRoutes = await Promise.all(
routes.map(async route => {
const prepared = await prepare(route);
return prepared ? { ...route, ...prepared } : null;
})
);
return this.filterPreparedRoutes(preparedRoutes);
}
async resolveProvider(
cond: ModelFullConditions,
filter: {
prefer?: CopilotProviderType;
} = {}
): Promise<ResolvedCopilotProvider | null> {
return (await this.resolveRoutes(cond, filter))[0] ?? null;
}
async resolveRoutes(
cond: ModelFullConditions,
filter: {
prefer?: CopilotProviderType;
} = {}
): Promise<ResolvedCopilotProvider[]> {
this.logger.debug(
`Resolving copilot provider for output type: ${cond.outputType}`
);
const registry = this.getRegistry();
const modelId = stripProviderPrefix(registry, providerId, cond.modelId);
return [{ ...cond, modelId }, ...rest];
}
const route = resolveModel({
registry,
modelId: cond.modelId,
outputType: cond.outputType,
availableProviderIds: this.#providers.keys(),
preferredProviderIds: this.getPreferredProviderIds(filter.prefer),
});
private wrapAsyncIterable<T>(
provider: CopilotProvider,
providerId: string,
iterable: AsyncIterable<T>
): AsyncIterableIterator<T> {
const iterator = iterable[Symbol.asyncIterator]();
const resolved: ResolvedCopilotProvider[] = [];
for (const providerId of route.candidateProviderIds) {
const provider = this.#providers.get(providerId);
const profile = registry.profiles.get(providerId);
if (!provider || !profile) continue;
return {
next: value =>
provider.runWithProfile(providerId, () => iterator.next(value)),
return: value =>
provider.runWithProfile(providerId, async () => {
if (typeof iterator.return === 'function') {
return iterator.return(value as never);
}
return { done: true, value: value as T };
}),
throw: error =>
provider.runWithProfile(providerId, async () => {
if (typeof iterator.throw === 'function') {
return iterator.throw(error);
}
throw error;
}),
[Symbol.asyncIterator]() {
return this;
},
};
}
const normalizedCond = this.normalizeCond(providerId, cond);
if (
normalizedCond.modelId &&
profile.models?.length &&
!profile.models.includes(normalizedCond.modelId)
) {
continue;
}
private getBoundProvider(providerId: string, provider: CopilotProvider) {
const cached = this.#boundProviders.get(providerId);
if (cached) {
return cached;
const execution = { providerId, profile };
const matched = await provider.match(normalizedCond, execution);
if (!matched) continue;
this.logger.debug(
`Copilot provider candidate found: ${provider.type} (${providerId})`
);
resolved.push({
providerId,
provider,
execution,
profile,
rawModelId: route.rawModelId,
modelId: normalizedCond.modelId,
explicitProviderId: route.explicitProviderId,
});
}
const wrapped = new Proxy(provider, {
get: (target, prop, receiver) => {
if (prop === 'providerId') {
return providerId;
}
return resolved;
}
const value = Reflect.get(target, prop, receiver);
if (typeof value !== 'function') {
return value;
}
async prepareRoutes(
kind: 'text' | 'streamText' | 'streamObject',
cond: ModelFullConditions,
messages: PromptMessage[],
options: CopilotChatOptions = {},
filter: {
prefer?: CopilotProviderType;
} = {}
): Promise<ResolvedCopilotProvider[]> {
const routes = await this.resolveRoutes(cond, filter);
return await this.prepareResolvedRoutes(routes, async route => {
const prepared = await getProviderRuntimeHost(
route.provider
).prepare.chat(
kind,
{ ...cond, modelId: route.modelId },
messages,
options,
route.execution
);
const normalizedPrepared = prepared?.route ? prepared : undefined;
if (!normalizedPrepared) {
return null;
}
return (...args: unknown[]) => {
const normalizedArgs = this.normalizeMethodArgs(providerId, args);
const result = provider.runWithProfile(providerId, () =>
Reflect.apply(value, provider, normalizedArgs)
);
if (isAsyncIterable(result)) {
return this.wrapAsyncIterable(
provider,
providerId,
result as AsyncIterable<unknown>
);
}
return result;
};
},
}) as CopilotProvider;
return {
modelId: normalizedPrepared.route.model,
prepared: normalizedPrepared,
};
});
}
this.#boundProviders.set(providerId, wrapped);
return wrapped;
async prepareStructuredRoutes(
cond: ModelFullConditions,
messages: PromptMessage[],
options: CopilotStructuredOptions = {},
filter: {
prefer?: CopilotProviderType;
} = {},
responseContract?: RequiredStructuredOutputContract
): Promise<ResolvedCopilotProvider[]> {
const routes = await this.resolveRoutes(cond, filter);
return await this.prepareResolvedRoutes(routes, async route => {
const preparedStructured =
(await getProviderRuntimeHost(route.provider).prepare.structured(
{ ...cond, modelId: route.modelId },
messages,
options,
responseContract,
route.execution
)) ?? undefined;
if (!preparedStructured) {
return null;
}
return {
modelId: preparedStructured.route.model,
preparedStructured,
};
});
}
async prepareEmbeddingRoutes(
modelId: string,
input: string | string[],
options: CopilotEmbeddingOptions = {}
): Promise<ResolvedCopilotProvider[]> {
const routes = await this.resolveRoutes({
modelId,
outputType: ModelOutputType.Embedding,
});
return await this.prepareResolvedRoutes(routes, async route => {
const preparedEmbedding =
(await getProviderRuntimeHost(route.provider).prepare.embedding(
{ modelId: route.modelId },
input,
options,
route.execution
)) ?? undefined;
if (!preparedEmbedding) {
return null;
}
return {
modelId: preparedEmbedding.route.model,
preparedEmbedding,
};
});
}
async prepareRerankRoutes(
modelId: string,
request: CopilotRerankRequest,
options: CopilotChatOptions = {}
): Promise<ResolvedCopilotProvider[]> {
const routes = await this.resolveRoutes({
modelId,
outputType: ModelOutputType.Rerank,
});
return await this.prepareResolvedRoutes(routes, async route => {
const preparedRerank =
(await getProviderRuntimeHost(route.provider).prepare.rerank(
{ modelId: route.modelId },
request,
options,
route.execution
)) ?? undefined;
if (!preparedRerank) {
return null;
}
return {
modelId: preparedRerank.route.model,
preparedRerank,
};
});
}
async prepareImageRoutes(
cond: ModelFullConditions,
messages: PromptMessage[],
options: CopilotImageOptions = {},
filter: {
prefer?: CopilotProviderType;
} = {}
): Promise<ResolvedCopilotProvider[]> {
const routes = await this.resolveRoutes(cond, filter);
return await this.prepareResolvedRoutes(routes, async route => {
const preparedImage =
(await getProviderRuntimeHost(route.provider).prepare.image(
{ ...cond, modelId: route.modelId },
messages,
options,
route.execution
)) ?? undefined;
if (!preparedImage) {
return null;
}
return {
modelId: preparedImage.route.model,
preparedImage,
};
});
}
async getProvider(
@@ -143,44 +324,7 @@ export class CopilotProviderFactory {
prefer?: CopilotProviderType;
} = {}
): Promise<CopilotProvider | null> {
this.logger.debug(
`Resolving copilot provider for output type: ${cond.outputType}`
);
const route = resolveModel({
registry: this.getRegistry(),
modelId: cond.modelId,
outputType: cond.outputType,
availableProviderIds: this.#providers.keys(),
preferredProviderIds: this.getPreferredProviderIds(filter.prefer),
});
const registry = this.getRegistry();
for (const providerId of route.candidateProviderIds) {
const provider = this.#providers.get(providerId);
if (!provider) continue;
const profile = registry.profiles.get(providerId);
const normalizedCond = this.normalizeCond(providerId, cond);
if (
normalizedCond.modelId &&
profile?.models?.length &&
!profile.models.includes(normalizedCond.modelId)
) {
continue;
}
const matched = await provider.runWithProfile(providerId, () =>
provider.match(normalizedCond)
);
if (!matched) continue;
this.logger.debug(
`Copilot provider candidate found: ${provider.type} (${providerId})`
);
return this.getBoundProvider(providerId, provider);
}
return null;
return (await this.resolveProvider(cond, filter))?.provider ?? null;
}
async getProviderByModel(
@@ -204,7 +348,6 @@ export class CopilotProviderFactory {
}
this.#providers.set(providerId, provider);
this.#boundProviders.delete(providerId);
const ids = this.#providerIdsByType.get(provider.type) ?? new Set<string>();
ids.add(providerId);
@@ -223,7 +366,6 @@ export class CopilotProviderFactory {
}
this.#providers.delete(providerId);
this.#boundProviders.delete(providerId);
const ids = this.#providerIdsByType.get(provider.type);
ids?.delete(providerId);
@@ -1,228 +1,46 @@
import {
config as falConfig,
stream as falStream,
} from '@fal-ai/serverless-client';
import { Injectable } from '@nestjs/common';
import { z, ZodType } from 'zod';
import {
CopilotPromptInvalid,
CopilotProviderSideError,
metrics,
UserFriendlyError,
} from '../../../base';
import { CopilotProviderSideError, UserFriendlyError } from '../../../base';
import { CopilotProvider } from './provider';
import type {
CopilotChatOptions,
CopilotImageOptions,
ModelConditions,
PromptMessage,
} from './types';
import { CopilotProviderType, ModelInputType, ModelOutputType } from './types';
import { promptAttachmentMimeType, promptAttachmentToUrl } from './utils';
CopilotProviderExecution,
ProviderDriverSpec,
} from './provider-runtime-contract';
import { CopilotProviderType } from './types';
export type FalConfig = {
apiKey: string;
};
const FalImageSchema = z
.object({
url: z.string(),
seed: z.number().nullable().optional(),
content_type: z.string(),
file_name: z.string().nullable().optional(),
file_size: z.number().nullable().optional(),
width: z.number(),
height: z.number(),
})
.optional();
type FalImage = z.infer<typeof FalImageSchema>;
const FalResponseSchema = z.object({
detail: z
.union([
z.array(z.object({ type: z.string(), msg: z.string() })),
z.string(),
])
.optional(),
images: z.array(FalImageSchema).nullable().optional(),
image: FalImageSchema.nullable().optional(),
output: z.string().nullable().optional(),
});
type FalResponse = z.infer<typeof FalResponseSchema>;
const FalStreamOutputSchema = z.object({
type: z.literal('output'),
output: FalResponseSchema,
});
type FalPrompt = {
model_name?: string;
image_url?: string;
prompt?: string;
loras?: { path: string; scale?: number }[];
controlnets?: {
image_url: string;
start_percentage?: number;
end_percentage?: number;
}[];
};
@Injectable()
export class FalProvider extends CopilotProvider<FalConfig> {
override type = CopilotProviderType.FAL;
override readonly models = [
{
id: 'flux-1/schnell',
capabilities: [
{
input: [ModelInputType.Text],
output: [ModelOutputType.Image],
defaultForOutputType: true,
},
],
},
// image to image models
{
id: 'lcm-sd15-i2i',
capabilities: [
{
input: [ModelInputType.Image],
output: [ModelOutputType.Image],
defaultForOutputType: true,
},
],
},
{
id: 'clarity-upscaler',
capabilities: [
{
input: [ModelInputType.Image],
output: [ModelOutputType.Image],
},
],
},
{
id: 'face-to-sticker',
capabilities: [
{
input: [ModelInputType.Image],
output: [ModelOutputType.Image],
},
],
},
{
id: 'imageutils/rembg',
capabilities: [
{
input: [ModelInputType.Image],
output: [ModelOutputType.Image],
},
],
},
{
id: 'workflowutils/teed',
capabilities: [
{
input: [ModelInputType.Image],
output: [ModelOutputType.Image],
},
],
},
{
id: 'lora/image-to-image',
capabilities: [
{
input: [ModelInputType.Image],
output: [ModelOutputType.Image],
},
],
},
];
override configured(): boolean {
return !!this.config.apiKey;
protected resolveModelBackendKind() {
return 'fal' as const;
}
protected override setup() {
super.setup();
falConfig({ credentials: this.config.apiKey });
override configured(execution?: CopilotProviderExecution): boolean {
return !!this.getConfig(execution).apiKey;
}
private extractArray<T>(value: T | T[] | undefined): T[] {
if (Array.isArray(value)) return value;
return value ? [value] : [];
}
private extractPrompt(
message?: PromptMessage,
options: CopilotImageOptions = {}
): FalPrompt {
if (!message) throw new CopilotPromptInvalid('Prompt is empty');
const { content, attachments, params } = message;
// prompt attachments require at least one
if (!content && (!Array.isArray(attachments) || !attachments.length)) {
throw new CopilotPromptInvalid('Prompt or Attachments is empty');
}
if (Array.isArray(attachments) && attachments.length > 1) {
throw new CopilotPromptInvalid('Only one attachment is allowed');
}
const lora = [
...this.extractArray(params?.lora),
...this.extractArray(options.loras),
].filter(
(v): v is { path: string; scale?: number } =>
!!v && typeof v === 'object' && typeof v.path === 'string'
);
const controlnets = this.extractArray(params?.controlnets).filter(
(v): v is { image_url: string } =>
!!v && typeof v === 'object' && typeof v.image_url === 'string'
);
private createNativeConfig(execution?: CopilotProviderExecution) {
return {
model_name: options.modelName || undefined,
image_url: attachments
?.map(v => {
const url = promptAttachmentToUrl(v);
const mediaType = promptAttachmentMimeType(
v,
typeof params?.mimetype === 'string' ? params.mimetype : undefined
);
return url && mediaType?.startsWith('image/') ? url : undefined;
})
.find(v => !!v),
prompt: content.trim(),
loras: lora.length ? lora : undefined,
controlnets: controlnets.length ? controlnets : undefined,
base_url: 'https://fal.run',
auth_token: this.getConfig(execution).apiKey,
};
}
private extractFalError(
resp: FalResponse,
message?: string
): CopilotProviderSideError {
if (Array.isArray(resp.detail) && resp.detail.length) {
const error = resp.detail[0].msg;
return new CopilotProviderSideError({
provider: this.type,
kind: resp.detail[0].type,
message: message ? `${message}: ${error}` : error,
});
} else if (typeof resp.detail === 'string') {
const error = resp.detail;
return new CopilotProviderSideError({
provider: this.type,
kind: resp.detail,
message: message ? `${message}: ${error}` : error,
});
}
return new CopilotProviderSideError({
provider: this.type,
kind: 'unknown',
message: 'No content generated',
});
override getDriverSpec(): ProviderDriverSpec {
return {
createBackendConfig: execution => this.createNativeConfig(execution),
mapError: error => this.handleError(error),
chat: false,
structured: false,
embedding: false,
rerank: false,
image: {},
};
}
private handleError(e: any) {
@@ -238,152 +56,4 @@ export class FalProvider extends CopilotProvider<FalConfig> {
return error;
}
}
private parseSchema<R>(schema: ZodType<R>, data: unknown): R {
const result = schema.safeParse(data);
if (result.success) return result.data;
const errors = JSON.stringify(result.error.errors);
throw new CopilotProviderSideError({
provider: this.type,
kind: 'unexpected_response',
message: `Unexpected fal response: ${errors}`,
});
}
async text(
cond: ModelConditions,
messages: PromptMessage[],
options: CopilotChatOptions = {}
): Promise<string> {
const model = this.selectModel(cond);
try {
metrics.ai.counter('chat_text_calls').add(1, this.metricLabels(model.id));
// by default, image prompt assumes there is only one message
const prompt = this.extractPrompt(messages[messages.length - 1]);
const response = await fetch(`https://fal.run/fal-ai/${model.id}`, {
method: 'POST',
headers: {
Authorization: `key ${this.config.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
...prompt,
sync_mode: true,
enable_safety_checks: false,
}),
signal: options.signal,
});
const data = this.parseSchema(FalResponseSchema, await response.json());
if (!data.output) {
throw this.extractFalError(data, 'Failed to generate text');
}
return data.output;
} catch (e: any) {
metrics.ai
.counter('chat_text_errors')
.add(1, this.metricLabels(model.id));
throw this.handleError(e);
}
}
async *streamText(
cond: ModelConditions,
messages: PromptMessage[],
options: CopilotChatOptions | CopilotImageOptions = {}
): AsyncIterable<string> {
const model = this.selectModel(cond);
try {
metrics.ai
.counter('chat_text_stream_calls')
.add(1, this.metricLabels(model.id));
const result = await this.text(cond, messages, options);
yield result;
} catch (e) {
metrics.ai
.counter('chat_text_stream_errors')
.add(1, this.metricLabels(model.id));
throw e;
}
}
override async *streamImages(
cond: ModelConditions,
messages: PromptMessage[],
options: CopilotImageOptions = {}
): AsyncIterable<string> {
const model = this.selectModel({
...cond,
outputType: ModelOutputType.Image,
});
try {
metrics.ai
.counter('generate_images_stream_calls')
.add(1, this.metricLabels(model.id));
// by default, image prompt assumes there is only one message
const prompt = this.extractPrompt(
messages[messages.length - 1],
options as CopilotImageOptions
);
let data: FalResponse;
if (model.id.startsWith('workflows/')) {
const stream = await falStream(model.id, { input: prompt });
data = this.parseSchema(
FalStreamOutputSchema,
await stream.done()
).output;
} else {
const response = await fetch(`https://fal.run/fal-ai/${model.id}`, {
method: 'POST',
headers: {
Authorization: `key ${this.config.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
...prompt,
sync_mode: true,
seed: (options as CopilotImageOptions)?.seed || 42,
enable_safety_checks: false,
}),
signal: options.signal,
});
data = this.parseSchema(FalResponseSchema, await response.json());
}
if (!data.images?.length && !data.image?.url) {
throw this.extractFalError(data, 'Failed to generate images');
}
if (data.image?.url) {
yield data.image.url;
return;
}
const imageUrls =
data.images
?.filter((image): image is NonNullable<FalImage> => !!image)
.map(image => image.url) || [];
for (const url of imageUrls) {
yield url;
if (options.signal?.aborted) {
break;
}
}
return;
} catch (e) {
metrics.ai
.counter('generate_images_stream_errors')
.add(1, this.metricLabels(model.id));
throw this.handleError(e);
}
}
}
@@ -1,47 +1,34 @@
import { setTimeout as delay } from 'node:timers/promises';
import { Inject } from '@nestjs/common';
import { ZodError } from 'zod';
import {
CopilotProviderSideError,
metrics,
OneMB,
readResponseBufferWithLimit,
safeFetch,
UserFriendlyError,
} from '../../../../base';
import { sniffMime } from '../../../../base/storage/providers/utils';
import {
llmDispatchStream,
llmEmbeddingDispatch,
llmStructuredDispatch,
type NativeLlmBackendConfig,
type NativeLlmEmbeddingRequest,
type NativeLlmRequest,
type NativeLlmStructuredRequest,
isInvalidStructuredOutputError,
type LlmBackendConfig,
llmResolveRequestIntentOptions,
} from '../../../../native';
import type { NodeTextMiddleware } from '../../config';
import type { CopilotToolSet } from '../../tools';
import {
buildNativeEmbeddingRequest,
buildNativeRequest,
buildNativeStructuredRequest,
NativeProviderAdapter,
parseNativeStructuredOutput,
StructuredResponseParseError,
} from '../native';
admittedAttachmentToPromptAttachment,
AttachmentAdmissionHost,
} from '../../runtime/hosts/attachment-admission';
import {
planAdmittedAttachmentMaterialization,
planHostUrlAttachmentMaterialization,
} from '../../runtime/hosts/attachment-materialization-planner';
import { AttachmentMaterializer } from '../../runtime/hosts/attachment-materializer';
import { CopilotProvider } from '../provider';
import type {
CopilotChatOptions,
CopilotEmbeddingOptions,
CopilotImageOptions,
CopilotStructuredOptions,
ModelConditions,
PromptAttachment,
PromptMessage,
StreamObject,
} from '../types';
import { ModelOutputType } from '../types';
import { hasProviderModelBehaviorFlag } from '../provider-model-runtime';
import {
type CopilotProviderExecution,
type ProviderDriverSpec,
} from '../provider-runtime-contract';
import type { PromptAttachment, PromptMessage } from '../types';
import { promptAttachmentMimeType, promptAttachmentToUrl } from '../utils';
export const DEFAULT_DIMENSIONS = 256;
@@ -53,38 +40,20 @@ function normalizeMimeType(mediaType?: string) {
return mediaType?.split(';', 1)[0]?.trim() || 'application/octet-stream';
}
function isYoutubeUrl(url: URL) {
const hostname = url.hostname.toLowerCase();
if (hostname === 'youtu.be') {
return /^\/[\w-]+$/.test(url.pathname);
}
if (hostname !== 'youtube.com' && hostname !== 'www.youtube.com') {
return false;
}
if (url.pathname !== '/watch') {
return false;
}
return !!url.searchParams.get('v');
}
function isGeminiFileUrl(url: URL, baseUrl: string) {
try {
const base = new URL(baseUrl);
const basePath = base.pathname.replace(/\/+$/, '');
return (
url.origin === base.origin &&
url.pathname.startsWith(`${basePath}/files/`)
);
} catch {
return false;
}
}
export abstract class GeminiProvider<T> extends CopilotProvider<T> {
protected abstract createNativeConfig(): Promise<NativeLlmBackendConfig>;
@Inject() protected readonly attachmentMaterializer!: AttachmentMaterializer;
@Inject()
protected readonly attachmentAdmissionHost?: AttachmentAdmissionHost;
protected resolveModelBackendKind() {
return this.type === 'geminiVertex'
? ('gemini_vertex' as const)
: ('gemini_api' as const);
}
protected abstract createNativeConfig(
execution?: CopilotProviderExecution
): Promise<LlmBackendConfig>;
private handleError(e: any) {
if (e instanceof UserFriendlyError) {
@@ -98,158 +67,27 @@ export abstract class GeminiProvider<T> extends CopilotProvider<T> {
}
}
protected createNativeDispatch(backendConfig: NativeLlmBackendConfig) {
return (request: NativeLlmRequest, signal?: AbortSignal) =>
llmDispatchStream('gemini', backendConfig, request, signal);
}
protected createNativeStructuredDispatch(
backendConfig: NativeLlmBackendConfig
) {
return (request: NativeLlmStructuredRequest) =>
llmStructuredDispatch('gemini', backendConfig, request);
}
protected createNativeEmbeddingDispatch(
backendConfig: NativeLlmBackendConfig
) {
return (request: NativeLlmEmbeddingRequest) =>
llmEmbeddingDispatch('gemini', backendConfig, request);
}
protected createNativeAdapter(
backendConfig: NativeLlmBackendConfig,
tools: CopilotToolSet,
nodeTextMiddleware?: NodeTextMiddleware[]
) {
return new NativeProviderAdapter(
this.createNativeDispatch(backendConfig),
tools,
this.MAX_STEPS,
{ nodeTextMiddleware }
private getAttachmentAdmissionHost() {
return (
this.attachmentAdmissionHost ??
new AttachmentAdmissionHost(this.attachmentMaterializer)
);
}
protected async fetchRemoteAttach(url: string, signal?: AbortSignal) {
const parsed = new URL(url);
const response = await safeFetch(
parsed,
{ method: 'GET', signal },
this.buildAttachFetchOptions(parsed)
);
if (!response.ok) {
throw new Error(
`Failed to fetch attachment: ${response.status} ${response.statusText}`
);
}
const buffer = await readResponseBufferWithLimit(
response,
GEMINI_REMOTE_ATTACHMENT_MAX_BYTES
);
const headerMimeType = normalizeMimeType(
response.headers.get('content-type') || ''
);
return {
data: buffer.toString('base64'),
mimeType: normalizeMimeType(sniffMime(buffer, headerMimeType)),
};
}
private buildAttachFetchOptions(url: URL) {
const baseOptions = { timeoutMs: 15_000, maxRedirects: 3 } as const;
if (!env.prod) {
return { ...baseOptions, allowPrivateOrigins: new Set([url.origin]) };
}
const trustedOrigins = new Set<string>();
const protocol = this.AFFiNEConfig.server.https ? 'https:' : 'http:';
const port = this.AFFiNEConfig.server.port;
const isDefaultPort =
(protocol === 'https:' && port === 443) ||
(protocol === 'http:' && port === 80);
const addHostOrigin = (host: string) => {
if (!host) return;
try {
const parsed = new URL(`${protocol}//${host}`);
if (!parsed.port && !isDefaultPort) {
parsed.port = String(port);
}
trustedOrigins.add(parsed.origin);
} catch {
// ignore invalid host config entries
}
};
if (this.AFFiNEConfig.server.externalUrl) {
try {
trustedOrigins.add(
new URL(this.AFFiNEConfig.server.externalUrl).origin
);
} catch {
// ignore invalid external URL
}
}
addHostOrigin(this.AFFiNEConfig.server.host);
for (const host of this.AFFiNEConfig.server.hosts) {
addHostOrigin(host);
}
const hostname = url.hostname.toLowerCase();
const trustedByHost = TRUSTED_ATTACHMENT_HOST_SUFFIXES.some(
suffix => hostname === suffix || hostname.endsWith(`.${suffix}`)
);
if (trustedOrigins.has(url.origin) || trustedByHost) {
return { ...baseOptions, allowPrivateOrigins: new Set([url.origin]) };
}
return baseOptions;
}
private shouldInlineRemoteAttach(url: URL, config: NativeLlmBackendConfig) {
switch (config.request_layer) {
case 'gemini_api':
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
return !(isGeminiFileUrl(url, config.base_url) || isYoutubeUrl(url));
case 'gemini_vertex':
return false;
default:
return false;
}
}
private toInlineAttach(
attachment: PromptAttachment,
mimeType: string,
data: string
): PromptAttachment {
if (typeof attachment === 'string' || !('kind' in attachment)) {
return { kind: 'bytes', data, mimeType };
}
if (attachment.kind !== 'url') {
return attachment;
}
return {
kind: 'bytes',
data,
mimeType,
fileName: attachment.fileName,
providerHint: attachment.providerHint,
};
}
protected async prepareMessages(
messages: PromptMessage[],
backendConfig: NativeLlmBackendConfig,
signal?: AbortSignal
backendConfig: LlmBackendConfig,
options?: {
signal?: AbortSignal;
user?: string;
workspace?: string;
session?: string;
}
): Promise<PromptMessage[]> {
const prepared: PromptMessage[] = [];
for (const message of messages) {
signal?.throwIfAborted();
options?.signal?.throwIfAborted();
if (!Array.isArray(message.attachments) || !message.attachments.length) {
prepared.push(message);
continue;
@@ -258,41 +96,60 @@ export abstract class GeminiProvider<T> extends CopilotProvider<T> {
const attachments: PromptAttachment[] = [];
let changed = false;
for (const attachment of message.attachments) {
signal?.throwIfAborted();
options?.signal?.throwIfAborted();
const rawUrl = promptAttachmentToUrl(attachment);
if (!rawUrl || rawUrl.startsWith('data:')) {
attachments.push(attachment);
continue;
}
let parsed: URL;
try {
parsed = new URL(rawUrl);
new URL(rawUrl);
} catch {
attachments.push(attachment);
continue;
}
if (!this.shouldInlineRemoteAttach(parsed, backendConfig)) {
attachments.push(attachment);
continue;
}
const declaredMimeType = promptAttachmentMimeType(
attachment,
typeof message.params?.mimetype === 'string'
? message.params.mimetype
: undefined
);
const downloaded = await this.fetchRemoteAttach(rawUrl, signal);
attachments.push(
this.toInlineAttach(
attachment,
declaredMimeType
const referencePlan = await planHostUrlAttachmentMaterialization(
'gemini',
backendConfig,
{
attachmentId: rawUrl,
url: rawUrl,
expectedMime: declaredMimeType
? normalizeMimeType(declaredMimeType)
: downloaded.mimeType,
downloaded.data
)
: undefined,
maxSize: GEMINI_REMOTE_ATTACHMENT_MAX_BYTES,
}
);
if (referencePlan.mode === 'remote_reference') {
attachments.push(attachment);
continue;
}
const admitted =
await this.getAttachmentAdmissionHost().admitPromptAttachment(
attachment,
{
userId: options?.user ?? 'provider-runtime',
workspaceId: options?.workspace ?? 'provider-runtime',
sessionId: options?.session,
signal: options?.signal,
maxBytes: referencePlan.request.maxSize,
trustedHostSuffixes: TRUSTED_ATTACHMENT_HOST_SUFFIXES,
}
);
const materialization = planAdmittedAttachmentMaterialization(admitted);
attachments.push(
materialization.mode === 'inline'
? materialization.attachment
: admittedAttachmentToPromptAttachment(admitted)
);
changed = true;
}
@@ -310,291 +167,84 @@ export abstract class GeminiProvider<T> extends CopilotProvider<T> {
await delay(delayMs, undefined, signal ? { signal } : undefined);
}
async text(
cond: ModelConditions,
messages: PromptMessage[],
options: CopilotChatOptions = {}
): Promise<string> {
const fullCond = { ...cond, outputType: ModelOutputType.Text };
const normalizedCond = await this.checkParams({
cond: fullCond,
messages,
options,
});
const model = this.selectModel(normalizedCond);
override getDriverSpec(): ProviderDriverSpec {
return {
createBackendConfig: execution => this.createNativeConfig(execution),
mapError: error => this.handleError(error),
chat: {
prepareMessages: async context =>
await this.prepareMessages(
context.input.messages,
context.backendConfig,
context.options
),
resolveRequestOptions: async context => {
const requestIntent = await llmResolveRequestIntentOptions({
protocol: context.protocol,
backendConfig: context.backendConfig,
reasoning: {
enabled: context.options.reasoning,
supported:
hasProviderModelBehaviorFlag(
context.model,
'reasoning_medium'
) ||
hasProviderModelBehaviorFlag(context.model, 'reasoning_high'),
effort: hasProviderModelBehaviorFlag(
context.model,
'reasoning_high'
)
? 'high'
: 'medium',
includeReasoning:
hasProviderModelBehaviorFlag(
context.model,
'reasoning_medium'
) ||
hasProviderModelBehaviorFlag(context.model, 'reasoning_high'),
},
});
try {
metrics.ai.counter('chat_text_calls').add(1, this.metricLabels(model.id));
const backendConfig = await this.createNativeConfig();
const msg = await this.prepareMessages(
messages,
backendConfig,
options.signal
);
const tools = await this.getTools(options, model.id);
const middleware = this.getActiveProviderMiddleware();
const cap = this.getAttachCapability(model, ModelOutputType.Text);
const { request } = await buildNativeRequest({
model: model.id,
messages: msg,
options,
tools,
attachmentCapability: cap,
reasoning: this.getReasoning(options, model.id),
middleware,
});
const adapter = this.createNativeAdapter(
backendConfig,
tools,
middleware.node?.text
);
return await adapter.text(request, options.signal, messages);
} catch (e: any) {
metrics.ai
.counter('chat_text_errors')
.add(1, this.metricLabels(model.id));
throw this.handleError(e);
}
}
override async structure(
cond: ModelConditions,
messages: PromptMessage[],
options: CopilotStructuredOptions = {}
): Promise<string> {
const fullCond = { ...cond, outputType: ModelOutputType.Structured };
const normalizedCond = await this.checkParams({
cond: fullCond,
messages,
options,
});
const model = this.selectModel(normalizedCond);
try {
metrics.ai.counter('chat_text_calls').add(1, this.metricLabels(model.id));
const backendConfig = await this.createNativeConfig();
const msg = await this.prepareMessages(
messages,
backendConfig,
options.signal
);
const structuredDispatch =
this.createNativeStructuredDispatch(backendConfig);
const middleware = this.getActiveProviderMiddleware();
const cap = this.getAttachCapability(model, ModelOutputType.Structured);
const { request, schema } = await buildNativeStructuredRequest({
model: model.id,
messages: msg,
options,
attachmentCapability: cap,
reasoning: this.getReasoning(options, model.id),
responseSchema: options.schema,
middleware,
});
const maxRetries = Math.max(options.maxRetries ?? 3, 0);
for (let attempt = 0; ; attempt++) {
try {
const response = await structuredDispatch(request);
const parsed = parseNativeStructuredOutput(response);
const validated = schema.parse(parsed);
return JSON.stringify(validated);
} catch (error) {
return {
attachmentCapability: this.getAttachCapability(
context.model,
context.outputType
),
include: requestIntent.include,
reasoning: requestIntent.reasoning,
};
},
},
structured: {
prepareMessages: (inputMessages, backendConfig, structuredOptions) =>
this.prepareMessages(inputMessages, backendConfig, structuredOptions),
shouldRetry: async ({ error, attempt, options: structuredOptions }) => {
const isParsingError =
error instanceof StructuredResponseParseError ||
error instanceof ZodError;
isInvalidStructuredOutputError(error) || error instanceof ZodError;
const retryableError =
isParsingError || !(error instanceof UserFriendlyError);
const maxRetries = Math.max(structuredOptions.maxRetries ?? 3, 0);
if (!retryableError || attempt >= maxRetries) {
throw error;
return false;
}
if (!isParsingError) {
await this.waitForStructuredRetry(
GEMINI_RETRY_INITIAL_DELAY_MS * 2 ** attempt,
options.signal
structuredOptions.signal
);
}
}
}
} catch (e: any) {
metrics.ai
.counter('chat_text_errors')
.add(1, this.metricLabels(model.id));
throw this.handleError(e);
}
}
async *streamText(
cond: ModelConditions,
messages: PromptMessage[],
options: CopilotChatOptions | CopilotImageOptions = {}
): AsyncIterable<string> {
const fullCond = { ...cond, outputType: ModelOutputType.Text };
const normalizedCond = await this.checkParams({
cond: fullCond,
messages,
options,
});
const model = this.selectModel(normalizedCond);
try {
metrics.ai
.counter('chat_text_stream_calls')
.add(1, this.metricLabels(model.id));
const backendConfig = await this.createNativeConfig();
const preparedMessages = await this.prepareMessages(
messages,
backendConfig,
options.signal
);
const tools = await this.getTools(
options as CopilotChatOptions,
model.id
);
const middleware = this.getActiveProviderMiddleware();
const cap = this.getAttachCapability(model, ModelOutputType.Text);
const { request } = await buildNativeRequest({
model: model.id,
messages: preparedMessages,
options: options as CopilotChatOptions,
tools,
attachmentCapability: cap,
reasoning: this.getReasoning(options, model.id),
middleware,
});
const adapter = this.createNativeAdapter(
backendConfig,
tools,
middleware.node?.text
);
for await (const chunk of adapter.streamText(
request,
options.signal,
messages
)) {
yield chunk;
}
} catch (e: any) {
metrics.ai
.counter('chat_text_stream_errors')
.add(1, this.metricLabels(model.id));
throw this.handleError(e);
}
}
override async *streamObject(
cond: ModelConditions,
messages: PromptMessage[],
options: CopilotChatOptions = {}
): AsyncIterable<StreamObject> {
const fullCond = { ...cond, outputType: ModelOutputType.Object };
const normalizedCond = await this.checkParams({
cond: fullCond,
messages,
options,
});
const model = this.selectModel(normalizedCond);
try {
metrics.ai
.counter('chat_object_stream_calls')
.add(1, this.metricLabels(model.id));
const backendConfig = await this.createNativeConfig();
const msg = await this.prepareMessages(
messages,
backendConfig,
options.signal
);
const tools = await this.getTools(options, model.id);
const middleware = this.getActiveProviderMiddleware();
const cap = this.getAttachCapability(model, ModelOutputType.Object);
const { request } = await buildNativeRequest({
model: model.id,
messages: msg,
options,
tools,
attachmentCapability: cap,
reasoning: this.getReasoning(options, model.id),
middleware,
});
const adapter = this.createNativeAdapter(
backendConfig,
tools,
middleware.node?.text
);
for await (const chunk of adapter.streamObject(
request,
options.signal,
messages
)) {
yield chunk;
}
} catch (e: any) {
metrics.ai
.counter('chat_object_stream_errors')
.add(1, this.metricLabels(model.id));
throw this.handleError(e);
}
}
override async embedding(
cond: ModelConditions,
messages: string | string[],
options: CopilotEmbeddingOptions = { dimensions: DEFAULT_DIMENSIONS }
): Promise<number[][]> {
const values = Array.isArray(messages) ? messages : [messages];
const fullCond = { ...cond, outputType: ModelOutputType.Embedding };
const normalizedCond = await this.checkParams({
embeddings: values,
cond: fullCond,
options,
});
const model = this.selectModel(normalizedCond);
try {
metrics.ai
.counter('generate_embedding_calls')
.add(1, this.metricLabels(model.id));
const backendConfig = await this.createNativeConfig();
const response = await this.createNativeEmbeddingDispatch(backendConfig)(
buildNativeEmbeddingRequest({
model: model.id,
inputs: values,
dimensions: options.dimensions || DEFAULT_DIMENSIONS,
taskType: 'RETRIEVAL_DOCUMENT',
})
);
return response.embeddings;
} catch (e: any) {
metrics.ai
.counter('generate_embedding_errors')
.add(1, this.metricLabels(model.id));
throw this.handleError(e);
}
}
protected getReasoning(
options: CopilotChatOptions | CopilotImageOptions,
model: string
): Record<string, unknown> | undefined {
if (
options &&
'reasoning' in options &&
options.reasoning &&
this.isReasoningModel(model)
) {
return this.isGemini3Model(model)
? { include_thoughts: true, thinking_level: 'high' }
: { include_thoughts: true, thinking_budget: 12000 };
}
return undefined;
}
private isGemini3Model(model: string) {
return model.startsWith('gemini-3');
}
private isReasoningModel(model: string) {
return model.startsWith('gemini-2.5') || this.isGemini3Model(model);
return true;
},
},
embedding: {
defaultDimensions: DEFAULT_DIMENSIONS,
taskType: 'RETRIEVAL_DOCUMENT',
},
rerank: false,
image: {
prepareMessages: (inputMessages, backendConfig, imageOptions) =>
this.prepareMessages(inputMessages, backendConfig, imageOptions),
},
};
}
}
@@ -1,8 +1,6 @@
import z from 'zod';
import type { NativeLlmBackendConfig } from '../../../../native';
import { GEMINI_ATTACHMENT_CAPABILITY } from '../attachments';
import { CopilotProviderType, ModelInputType, ModelOutputType } from '../types';
import type { LlmBackendConfig } from '../../../../native';
import type { CopilotProviderExecution } from '../provider-runtime-contract';
import { CopilotProviderType } from '../types';
import { GeminiProvider } from './gemini';
export type GeminiGenerativeConfig = {
@@ -10,142 +8,21 @@ export type GeminiGenerativeConfig = {
baseURL?: string;
};
const ModelListSchema = z.object({
models: z.array(z.object({ name: z.string() })),
});
export class GeminiGenerativeProvider extends GeminiProvider<GeminiGenerativeConfig> {
override readonly type = CopilotProviderType.Gemini;
readonly models = [
{
name: 'Gemini 2.5 Flash',
id: 'gemini-2.5-flash',
capabilities: [
{
input: [
ModelInputType.Text,
ModelInputType.Image,
ModelInputType.Audio,
ModelInputType.File,
],
output: [
ModelOutputType.Text,
ModelOutputType.Object,
ModelOutputType.Structured,
],
attachments: GEMINI_ATTACHMENT_CAPABILITY,
structuredAttachments: GEMINI_ATTACHMENT_CAPABILITY,
},
],
},
{
name: 'Gemini 2.5 Pro',
id: 'gemini-2.5-pro',
capabilities: [
{
input: [
ModelInputType.Text,
ModelInputType.Image,
ModelInputType.Audio,
ModelInputType.File,
],
output: [
ModelOutputType.Text,
ModelOutputType.Object,
ModelOutputType.Structured,
],
attachments: GEMINI_ATTACHMENT_CAPABILITY,
structuredAttachments: GEMINI_ATTACHMENT_CAPABILITY,
},
],
},
{
name: 'Gemini 3.1 Pro Preview',
id: 'gemini-3.1-pro-preview',
capabilities: [
{
input: [
ModelInputType.Text,
ModelInputType.Image,
ModelInputType.Audio,
ModelInputType.File,
],
output: [
ModelOutputType.Text,
ModelOutputType.Object,
ModelOutputType.Structured,
],
attachments: GEMINI_ATTACHMENT_CAPABILITY,
structuredAttachments: GEMINI_ATTACHMENT_CAPABILITY,
},
],
},
{
name: 'Gemini 3.1 Flash Lite Preview',
id: 'gemini-3.1-flash-lite-preview',
capabilities: [
{
input: [
ModelInputType.Text,
ModelInputType.Image,
ModelInputType.Audio,
ModelInputType.File,
],
output: [
ModelOutputType.Text,
ModelOutputType.Object,
ModelOutputType.Structured,
],
attachments: GEMINI_ATTACHMENT_CAPABILITY,
structuredAttachments: GEMINI_ATTACHMENT_CAPABILITY,
},
],
},
{
name: 'Gemini Embedding',
id: 'gemini-embedding-001',
capabilities: [
{
input: [ModelInputType.Text],
output: [ModelOutputType.Embedding],
defaultForOutputType: true,
},
],
},
];
override configured(): boolean {
return !!this.config.apiKey;
override configured(execution?: CopilotProviderExecution): boolean {
return !!this.getConfig(execution).apiKey;
}
override async refreshOnlineModels() {
try {
const baseUrl =
this.config.baseURL ||
'https://generativelanguage.googleapis.com/v1beta';
if (baseUrl && !this.onlineModelList.length) {
const { models } = await fetch(
`${baseUrl}/models?key=${this.config.apiKey}`
)
.then(r => r.json())
.then(r => ModelListSchema.parse(r));
this.onlineModelList = models.map(model =>
model.name.replace('models/', '')
);
}
} catch (e) {
this.logger.error('Failed to fetch available models', e);
}
}
protected override async createNativeConfig(): Promise<NativeLlmBackendConfig> {
protected override async createNativeConfig(
execution?: CopilotProviderExecution
): Promise<LlmBackendConfig> {
const config = this.getConfig(execution);
return {
base_url: (
this.config.baseURL ||
'https://generativelanguage.googleapis.com/v1beta'
config.baseURL || 'https://generativelanguage.googleapis.com/v1beta'
).replace(/\/$/, ''),
auth_token: this.config.apiKey,
request_layer: 'gemini_api',
auth_token: config.apiKey,
};
}
}
@@ -1,149 +1,30 @@
import type { NativeLlmBackendConfig } from '../../../../native';
import { GEMINI_ATTACHMENT_CAPABILITY } from '../attachments';
import { CopilotProviderType, ModelInputType, ModelOutputType } from '../types';
import {
getGoogleAuth,
VertexModelListSchema,
type VertexProviderConfig,
} from '../utils';
import type { LlmBackendConfig } from '../../../../native';
import type { CopilotProviderExecution } from '../provider-runtime-contract';
import { CopilotProviderType } from '../types';
import { getGoogleAuth, type VertexProviderConfig } from '../utils';
import { GeminiProvider } from './gemini';
export type GeminiVertexConfig = VertexProviderConfig;
export class GeminiVertexProvider extends GeminiProvider<GeminiVertexConfig> {
override readonly type = CopilotProviderType.GeminiVertex;
readonly models = [
{
name: 'Gemini 2.5 Flash',
id: 'gemini-2.5-flash',
capabilities: [
{
input: [
ModelInputType.Text,
ModelInputType.Image,
ModelInputType.Audio,
ModelInputType.File,
],
output: [
ModelOutputType.Text,
ModelOutputType.Object,
ModelOutputType.Structured,
],
attachments: GEMINI_ATTACHMENT_CAPABILITY,
structuredAttachments: GEMINI_ATTACHMENT_CAPABILITY,
},
],
},
{
name: 'Gemini 2.5 Pro',
id: 'gemini-2.5-pro',
capabilities: [
{
input: [
ModelInputType.Text,
ModelInputType.Image,
ModelInputType.Audio,
ModelInputType.File,
],
output: [
ModelOutputType.Text,
ModelOutputType.Object,
ModelOutputType.Structured,
],
attachments: GEMINI_ATTACHMENT_CAPABILITY,
structuredAttachments: GEMINI_ATTACHMENT_CAPABILITY,
},
],
},
{
name: 'Gemini 3.1 Pro Preview',
id: 'gemini-3.1-pro-preview',
capabilities: [
{
input: [
ModelInputType.Text,
ModelInputType.Image,
ModelInputType.Audio,
ModelInputType.File,
],
output: [
ModelOutputType.Text,
ModelOutputType.Object,
ModelOutputType.Structured,
],
attachments: GEMINI_ATTACHMENT_CAPABILITY,
structuredAttachments: GEMINI_ATTACHMENT_CAPABILITY,
},
],
},
{
name: 'Gemini 3.1 Flash Lite Preview',
id: 'gemini-3.1-flash-lite-preview',
capabilities: [
{
input: [
ModelInputType.Text,
ModelInputType.Image,
ModelInputType.Audio,
ModelInputType.File,
],
output: [
ModelOutputType.Text,
ModelOutputType.Object,
ModelOutputType.Structured,
],
attachments: GEMINI_ATTACHMENT_CAPABILITY,
structuredAttachments: GEMINI_ATTACHMENT_CAPABILITY,
},
],
},
{
name: 'Gemini Embedding',
id: 'gemini-embedding-001',
capabilities: [
{
input: [ModelInputType.Text],
output: [ModelOutputType.Embedding],
defaultForOutputType: true,
},
],
},
];
override configured(): boolean {
return !!this.config.location && !!this.config.googleAuthOptions;
override configured(execution?: CopilotProviderExecution): boolean {
const config = this.getConfig(execution);
return !!config.location && !!config.googleAuthOptions;
}
protected async resolveVertexAuth(execution?: CopilotProviderExecution) {
return await getGoogleAuth(this.getConfig(execution), 'google');
}
override async refreshOnlineModels() {
try {
const { baseUrl, headers } = await this.resolveVertexAuth();
if (baseUrl && !this.onlineModelList.length) {
const { publisherModels } = await fetch(`${baseUrl}/models`, {
headers: headers(),
})
.then(r => r.json())
.then(r => VertexModelListSchema.parse(r));
this.onlineModelList = publisherModels.map(model =>
model.name.replace('publishers/google/models/', '')
);
}
} catch (e) {
this.logger.error('Failed to fetch available models', e);
}
}
protected async resolveVertexAuth() {
return await getGoogleAuth(this.config, 'google');
}
protected override async createNativeConfig(): Promise<NativeLlmBackendConfig> {
const auth = await this.resolveVertexAuth();
protected override async createNativeConfig(
execution?: CopilotProviderExecution
): Promise<LlmBackendConfig> {
const auth = await this.resolveVertexAuth(execution);
const { Authorization: authHeader } = auth.headers();
return {
base_url: auth.baseUrl || '',
auth_token: authHeader.replace(/^Bearer\s+/i, ''),
request_layer: 'gemini_vertex',
};
}
}
@@ -1,26 +1,3 @@
import {
AnthropicOfficialProvider,
AnthropicVertexProvider,
} from './anthropic';
import { CloudflareWorkersAIProvider } from './cloudflare';
import { FalProvider } from './fal';
import { GeminiGenerativeProvider, GeminiVertexProvider } from './gemini';
import { MorphProvider } from './morph';
import { OpenAIProvider } from './openai';
import { PerplexityProvider } from './perplexity';
export const CopilotProviders = [
OpenAIProvider,
CloudflareWorkersAIProvider,
FalProvider,
GeminiGenerativeProvider,
GeminiVertexProvider,
PerplexityProvider,
AnthropicOfficialProvider,
AnthropicVertexProvider,
MorphProvider,
];
export {
AnthropicOfficialProvider,
AnthropicVertexProvider,
@@ -29,7 +6,10 @@ export { CloudflareWorkersAIProvider } from './cloudflare';
export { CopilotProviderFactory } from './factory';
export { FalProvider } from './fal';
export { GeminiGenerativeProvider, GeminiVertexProvider } from './gemini';
export { CopilotProviderLifecycleService } from './lifecycle-service';
export { OpenAIProvider } from './openai';
export { PerplexityProvider } from './perplexity';
export type { CopilotProvider } from './provider';
export { CopilotProviders } from './provider-tokens';
export { CopilotProviderRegistryService } from './registry-service';
export * from './types';
@@ -0,0 +1,90 @@
import { Injectable, Type } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { OnEvent } from '../../../base';
import { CopilotProviderFactory } from './factory';
import type { CopilotProvider } from './provider';
import type { CopilotProviderExecution } from './provider-runtime-contract';
import { CopilotProviders } from './provider-tokens';
import { CopilotProviderRegistryService } from './registry-service';
@Injectable()
export class CopilotProviderLifecycleService {
private readonly registeredByProvider = new WeakMap<
CopilotProvider,
Set<string>
>();
constructor(
private readonly moduleRef: ModuleRef,
private readonly factory: CopilotProviderFactory,
private readonly registries: CopilotProviderRegistryService
) {}
private getProviders(): CopilotProvider[] {
return CopilotProviders.flatMap(token => {
const provider = this.moduleRef.get(token as Type<CopilotProvider>, {
strict: false,
});
return provider ? [provider] : [];
});
}
private getRegisteredProviderIds(provider: CopilotProvider) {
const current = this.registeredByProvider.get(provider);
if (current) {
return current;
}
const next = new Set<string>();
this.registeredByProvider.set(provider, next);
return next;
}
private async syncProvider(provider: CopilotProvider) {
const registry = this.registries.getRegistry();
const configuredIds = new Set<string>();
for (const providerId of registry.byType.get(provider.type) ?? []) {
const profile = registry.profiles.get(providerId);
if (!profile) {
continue;
}
const execution: CopilotProviderExecution = { providerId, profile };
if (!provider.configured(execution)) {
this.factory.unregister(providerId, provider);
continue;
}
configuredIds.add(providerId);
this.factory.register(providerId, provider);
}
const previous = this.getRegisteredProviderIds(provider);
for (const providerId of previous) {
if (!configuredIds.has(providerId)) {
this.factory.unregister(providerId, provider);
}
}
this.registeredByProvider.set(provider, configuredIds);
}
async syncProviders() {
for (const provider of this.getProviders()) {
await this.syncProvider(provider);
}
}
@OnEvent('config.init')
async onConfigInit() {
await this.syncProviders();
}
@OnEvent('config.changed')
async onConfigChanged(event: Events['config.changed']) {
if ('copilot' in event.updates) {
await this.syncProviders();
}
}
}
@@ -1,479 +0,0 @@
import { z } from 'zod';
import type {
NativeLlmRequest,
NativeLlmStreamEvent,
NativeLlmToolDefinition,
} from '../../../native';
import type {
CopilotTool,
CopilotToolExecuteOptions,
CopilotToolSet,
} from '../tools';
export type NativeDispatchFn = (
request: NativeLlmRequest,
signal?: AbortSignal
) => AsyncIterableIterator<NativeLlmStreamEvent>;
export type NativeToolCall = {
id: string;
name: string;
args: Record<string, unknown>;
rawArgumentsText?: string;
argumentParseError?: string;
thought?: string;
};
type ToolCallState = {
name?: string;
argumentsText: string;
};
type ToolExecutionResult = {
callId: string;
name: string;
args: Record<string, unknown>;
rawArgumentsText?: string;
argumentParseError?: string;
output: unknown;
isError?: boolean;
};
type ParsedToolArguments = {
args: Record<string, unknown>;
rawArgumentsText?: string;
argumentParseError?: string;
};
export class ToolCallAccumulator {
readonly #states = new Map<string, ToolCallState>();
feedDelta(event: Extract<NativeLlmStreamEvent, { type: 'tool_call_delta' }>) {
const state = this.#states.get(event.call_id) ?? {
argumentsText: '',
};
if (event.name) {
state.name = event.name;
}
if (event.arguments_delta) {
state.argumentsText += event.arguments_delta;
}
this.#states.set(event.call_id, state);
}
complete(event: Extract<NativeLlmStreamEvent, { type: 'tool_call' }>) {
const state = this.#states.get(event.call_id);
this.#states.delete(event.call_id);
const parsed =
event.arguments_text !== undefined || event.arguments_error !== undefined
? {
args: event.arguments ?? {},
rawArgumentsText: event.arguments_text ?? state?.argumentsText,
argumentParseError: event.arguments_error,
}
: event.arguments
? this.parseArgs(event.arguments, state?.argumentsText)
: this.parseJson(state?.argumentsText ?? '{}');
return {
id: event.call_id,
name: event.name || state?.name || '',
...parsed,
thought: event.thought,
} satisfies NativeToolCall;
}
drainPending() {
const pending: NativeToolCall[] = [];
for (const [callId, state] of this.#states.entries()) {
if (!state.name) {
continue;
}
pending.push({
id: callId,
name: state.name,
...this.parseJson(state.argumentsText),
});
}
this.#states.clear();
return pending;
}
private parseJson(jsonText: string): ParsedToolArguments {
if (!jsonText.trim()) {
return { args: {} };
}
try {
return this.parseArgs(JSON.parse(jsonText), jsonText);
} catch (error) {
return {
args: {},
rawArgumentsText: jsonText,
argumentParseError:
error instanceof Error
? error.message
: 'Invalid tool arguments JSON',
};
}
}
private parseArgs(
value: unknown,
rawArgumentsText?: string
): ParsedToolArguments {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return {
args: value as Record<string, unknown>,
rawArgumentsText,
};
}
return {
args: {},
rawArgumentsText,
argumentParseError: 'Tool arguments must be a JSON object',
};
}
}
export class ToolSchemaExtractor {
static extract(toolSet: CopilotToolSet): NativeLlmToolDefinition[] {
return Object.entries(toolSet).map(([name, tool]) => {
return {
name,
description: tool.description,
parameters: this.toJsonSchema(tool.inputSchema ?? z.object({})),
};
});
}
static toJsonSchema(schema: unknown): Record<string, unknown> {
if (!(schema instanceof z.ZodType)) {
if (schema && typeof schema === 'object' && !Array.isArray(schema)) {
return schema as Record<string, unknown>;
}
return { type: 'object', properties: {} };
}
if (schema instanceof z.ZodObject) {
const shape = schema.shape;
const properties: Record<string, unknown> = {};
const required: string[] = [];
for (const [key, child] of Object.entries(
shape as Record<string, z.ZodTypeAny>
)) {
properties[key] = this.toJsonSchema(child);
if (!this.isOptional(child)) {
required.push(key);
}
}
return {
type: 'object',
properties,
additionalProperties: false,
...(required.length ? { required } : {}),
};
}
if (schema instanceof z.ZodString) {
return { type: 'string' };
}
if (schema instanceof z.ZodNumber) {
return { type: 'number' };
}
if (schema instanceof z.ZodBoolean) {
return { type: 'boolean' };
}
if (schema instanceof z.ZodArray) {
return { type: 'array', items: this.toJsonSchema(schema.element) };
}
if (schema instanceof z.ZodEnum) {
return { type: 'string', enum: schema.options };
}
if (schema instanceof z.ZodLiteral) {
const literal = schema.value;
if (literal === null) {
return { const: null, type: 'null' };
}
if (typeof literal === 'string') {
return { const: literal, type: 'string' };
}
if (typeof literal === 'number') {
return { const: literal, type: 'number' };
}
if (typeof literal === 'boolean') {
return { const: literal, type: 'boolean' };
}
return { const: literal };
}
if (schema instanceof z.ZodUnion) {
return {
anyOf: schema.options.map((option: z.ZodTypeAny) =>
this.toJsonSchema(option)
),
};
}
if (schema instanceof z.ZodRecord) {
return {
type: 'object',
additionalProperties: this.toJsonSchema(schema.valueSchema),
};
}
if (schema instanceof z.ZodNullable) {
const inner = (schema._def as { innerType?: z.ZodTypeAny }).innerType;
return { anyOf: [this.toJsonSchema(inner), { type: 'null' }] };
}
if (schema instanceof z.ZodOptional || schema instanceof z.ZodDefault) {
return this.toJsonSchema(
(schema._def as { innerType?: z.ZodTypeAny }).innerType
);
}
if (schema instanceof z.ZodEffects) {
return this.toJsonSchema(
(schema._def as { schema?: z.ZodTypeAny }).schema
);
}
return { type: 'object', properties: {} };
}
private static isOptional(schema: z.ZodTypeAny): boolean {
if (schema instanceof z.ZodOptional || schema instanceof z.ZodDefault) {
return true;
}
if (schema instanceof z.ZodNullable) {
return this.isOptional(
(schema._def as { innerType: z.ZodTypeAny }).innerType
);
}
if (schema instanceof z.ZodEffects) {
return this.isOptional((schema._def as { schema: z.ZodTypeAny }).schema);
}
return false;
}
}
export class ToolCallLoop {
constructor(
private readonly dispatch: NativeDispatchFn,
private readonly tools: CopilotToolSet,
private readonly maxSteps = 20
) {}
private normalizeToolExecuteOptions(
signalOrOptions?: AbortSignal | CopilotToolExecuteOptions,
maybeMessages?: CopilotToolExecuteOptions['messages']
): CopilotToolExecuteOptions {
if (
signalOrOptions &&
typeof signalOrOptions === 'object' &&
'aborted' in signalOrOptions
) {
return {
signal: signalOrOptions,
messages: maybeMessages,
};
}
if (!signalOrOptions) {
return maybeMessages ? { messages: maybeMessages } : {};
}
return {
...signalOrOptions,
signal: signalOrOptions.signal,
messages: signalOrOptions.messages ?? maybeMessages,
};
}
async *run(
request: NativeLlmRequest,
signalOrOptions?: AbortSignal | CopilotToolExecuteOptions,
maybeMessages?: CopilotToolExecuteOptions['messages']
): AsyncIterableIterator<NativeLlmStreamEvent> {
const toolExecuteOptions = this.normalizeToolExecuteOptions(
signalOrOptions,
maybeMessages
);
const messages = request.messages.map(message => ({
...message,
content: [...message.content],
}));
for (let step = 0; step < this.maxSteps; step++) {
const toolCalls: NativeToolCall[] = [];
const accumulator = new ToolCallAccumulator();
let finalDone: Extract<NativeLlmStreamEvent, { type: 'done' }> | null =
null;
for await (const event of this.dispatch(
{
...request,
stream: true,
messages,
},
toolExecuteOptions.signal
)) {
switch (event.type) {
case 'tool_call_delta': {
accumulator.feedDelta(event);
break;
}
case 'tool_call': {
toolCalls.push(accumulator.complete(event));
yield event;
break;
}
case 'done': {
finalDone = event;
break;
}
case 'error': {
throw new Error(event.message);
}
default: {
yield event;
break;
}
}
}
toolCalls.push(...accumulator.drainPending());
if (toolCalls.length === 0) {
if (finalDone) {
yield finalDone;
}
break;
}
if (step === this.maxSteps - 1) {
throw new Error('ToolCallLoop max steps reached');
}
const toolResults = await this.executeTools(
toolCalls,
toolExecuteOptions
);
messages.push({
role: 'assistant',
content: toolCalls.map(call => ({
type: 'tool_call',
call_id: call.id,
name: call.name,
arguments: call.args,
arguments_text: call.rawArgumentsText,
arguments_error: call.argumentParseError,
thought: call.thought,
})),
});
for (const result of toolResults) {
messages.push({
role: 'tool',
content: [
{
type: 'tool_result',
call_id: result.callId,
name: result.name,
arguments: result.args,
arguments_text: result.rawArgumentsText,
arguments_error: result.argumentParseError,
output: result.output,
is_error: result.isError,
},
],
});
yield {
type: 'tool_result',
call_id: result.callId,
name: result.name,
arguments: result.args,
arguments_text: result.rawArgumentsText,
arguments_error: result.argumentParseError,
output: result.output,
is_error: result.isError,
};
}
}
}
private async executeTools(
calls: NativeToolCall[],
options: CopilotToolExecuteOptions
) {
return await Promise.all(
calls.map(call => this.executeTool(call, options))
);
}
private async executeTool(
call: NativeToolCall,
options: CopilotToolExecuteOptions
): Promise<ToolExecutionResult> {
const tool = this.tools[call.name] as CopilotTool | undefined;
if (!tool?.execute) {
return {
callId: call.id,
name: call.name,
args: call.args,
rawArgumentsText: call.rawArgumentsText,
argumentParseError: call.argumentParseError,
isError: true,
output: {
message: `Tool not found: ${call.name}`,
},
};
}
if (call.argumentParseError) {
return {
callId: call.id,
name: call.name,
args: call.args,
rawArgumentsText: call.rawArgumentsText,
argumentParseError: call.argumentParseError,
isError: true,
output: {
message: 'Invalid tool arguments JSON',
rawArguments: call.rawArgumentsText,
error: call.argumentParseError,
},
};
}
try {
const output = await tool.execute(call.args, options);
return {
callId: call.id,
name: call.name,
args: call.args,
rawArgumentsText: call.rawArgumentsText,
argumentParseError: call.argumentParseError,
output: output ?? null,
};
} catch (error) {
console.error('Tool execution failed', {
callId: call.id,
toolName: call.name,
error,
});
return {
callId: call.id,
name: call.name,
args: call.args,
rawArgumentsText: call.rawArgumentsText,
argumentParseError: call.argumentParseError,
isError: true,
output: {
message: 'Tool execution failed',
},
};
}
}
}
@@ -1,23 +1,11 @@
import {
CopilotProviderSideError,
metrics,
UserFriendlyError,
} from '../../../base';
import {
llmDispatchStream,
type NativeLlmBackendConfig,
type NativeLlmRequest,
} from '../../../native';
import type { NodeTextMiddleware } from '../config';
import type { CopilotToolSet } from '../tools';
import { buildNativeRequest, NativeProviderAdapter } from './native';
import { CopilotProviderSideError, UserFriendlyError } from '../../../base';
import { type LlmBackendConfig } from '../../../native';
import { CopilotProvider } from './provider';
import type {
CopilotChatOptions,
ModelConditions,
PromptMessage,
} from './types';
import { CopilotProviderType, ModelInputType, ModelOutputType } from './types';
import {
type CopilotProviderExecution,
type ProviderDriverSpec,
} from './provider-runtime-contract';
import { CopilotProviderType, ModelOutputType } from './types';
export const DEFAULT_DIMENSIONS = 256;
@@ -28,42 +16,12 @@ export type MorphConfig = {
export class MorphProvider extends CopilotProvider<MorphConfig> {
readonly type = CopilotProviderType.Morph;
readonly models = [
{
id: 'morph-v2',
capabilities: [
{
input: [ModelInputType.Text],
output: [ModelOutputType.Text],
},
],
},
{
id: 'morph-v3-fast',
capabilities: [
{
input: [ModelInputType.Text],
output: [ModelOutputType.Text],
},
],
},
{
id: 'morph-v3-large',
capabilities: [
{
input: [ModelInputType.Text],
output: [ModelOutputType.Text],
},
],
},
];
override configured(): boolean {
return !!this.config.apiKey;
protected resolveModelBackendKind() {
return 'morph' as const;
}
protected override setup() {
super.setup();
override configured(execution?: CopilotProviderExecution): boolean {
return !!this.getConfig(execution).apiKey;
}
private handleError(e: any) {
@@ -77,106 +35,26 @@ export class MorphProvider extends CopilotProvider<MorphConfig> {
});
}
private createNativeConfig(): NativeLlmBackendConfig {
private createNativeConfig(
execution?: CopilotProviderExecution
): LlmBackendConfig {
return {
base_url: 'https://api.morphllm.com',
auth_token: this.config.apiKey ?? '',
auth_token: this.getConfig(execution).apiKey ?? '',
};
}
private createNativeAdapter(
tools: CopilotToolSet,
nodeTextMiddleware?: NodeTextMiddleware[]
) {
return new NativeProviderAdapter(
(request: NativeLlmRequest, signal?: AbortSignal) =>
llmDispatchStream(
'openai_chat',
this.createNativeConfig(),
request,
signal
),
tools,
this.MAX_STEPS,
{ nodeTextMiddleware }
);
}
async text(
cond: ModelConditions,
messages: PromptMessage[],
options: CopilotChatOptions = {}
): Promise<string> {
const fullCond = { ...cond, outputType: ModelOutputType.Text };
const model = this.selectModel(
await this.checkParams({
messages,
cond: fullCond,
options,
})
);
try {
metrics.ai.counter('chat_text_calls').add(1, this.metricLabels(model.id));
const tools = await this.getTools(options, model.id);
const middleware = this.getActiveProviderMiddleware();
const { request } = await buildNativeRequest({
model: model.id,
messages,
options,
tools,
middleware,
});
const adapter = this.createNativeAdapter(tools, middleware.node?.text);
return await adapter.text(request, options.signal, messages);
} catch (e: any) {
metrics.ai
.counter('chat_text_errors')
.add(1, this.metricLabels(model.id));
throw this.handleError(e);
}
}
async *streamText(
cond: ModelConditions,
messages: PromptMessage[],
options: CopilotChatOptions = {}
): AsyncIterable<string> {
const fullCond = { ...cond, outputType: ModelOutputType.Text };
const model = this.selectModel(
await this.checkParams({
messages,
cond: fullCond,
options,
})
);
try {
metrics.ai
.counter('chat_text_stream_calls')
.add(1, this.metricLabels(model.id));
const tools = await this.getTools(options, model.id);
const middleware = this.getActiveProviderMiddleware();
const { request } = await buildNativeRequest({
model: model.id,
messages,
options,
tools,
middleware,
});
const adapter = this.createNativeAdapter(tools, middleware.node?.text);
for await (const chunk of adapter.streamText(
request,
options.signal,
messages
)) {
yield chunk;
}
} catch (e: any) {
metrics.ai
.counter('chat_text_stream_errors')
.add(1, this.metricLabels(model.id));
throw this.handleError(e);
}
override getDriverSpec(): ProviderDriverSpec {
return {
createBackendConfig: execution => this.createNativeConfig(execution),
mapError: error => this.handleError(error),
chat: {
resolveOutputType: kind =>
kind === 'streamObject' ? null : ModelOutputType.Text,
},
structured: false,
embedding: false,
rerank: false,
};
}
}
@@ -1,692 +0,0 @@
import { ZodType } from 'zod';
import { CopilotPromptInvalid } from '../../../base';
import type {
NativeLlmCoreContent,
NativeLlmCoreMessage,
NativeLlmEmbeddingRequest,
NativeLlmRequest,
NativeLlmRerankRequest,
NativeLlmStreamEvent,
NativeLlmStructuredRequest,
NativeLlmStructuredResponse,
} from '../../../native';
import type { NodeTextMiddleware, ProviderMiddlewareConfig } from '../config';
import type { CopilotToolSet } from '../tools';
import {
canonicalizePromptAttachment,
type CanonicalPromptAttachment,
} from './attachments';
import { NativeDispatchFn, ToolCallLoop, ToolSchemaExtractor } from './loop';
import type {
CopilotChatOptions,
CopilotRerankRequest,
CopilotStructuredOptions,
ModelAttachmentCapability,
PromptMessage,
StreamObject,
} from './types';
import { CitationFootnoteFormatter, TextStreamParser } from './utils';
type BuildNativeRequestOptions = {
model: string;
messages: PromptMessage[];
options?: CopilotChatOptions | CopilotStructuredOptions;
tools?: CopilotToolSet;
withAttachment?: boolean;
attachmentCapability?: ModelAttachmentCapability;
include?: string[];
reasoning?: Record<string, unknown>;
responseSchema?: unknown;
middleware?: ProviderMiddlewareConfig;
};
type BuildNativeRequestResult = {
request: NativeLlmRequest;
schema?: ZodType;
};
type BuildNativeStructuredRequestResult = {
request: NativeLlmStructuredRequest;
schema: ZodType;
};
type ToolCallMeta = {
name: string;
args: Record<string, unknown>;
};
type NormalizedToolResultEvent = Extract<
NativeLlmStreamEvent,
{ type: 'tool_result' }
> & {
name: string;
arguments: Record<string, unknown>;
};
type AttachmentFootnote = {
blobId: string;
fileName: string;
fileType: string;
};
type NativeProviderAdapterOptions = {
nodeTextMiddleware?: NodeTextMiddleware[];
};
function roleToCore(role: PromptMessage['role']) {
switch (role) {
case 'assistant':
return 'assistant';
case 'system':
return 'system';
default:
return 'user';
}
}
function ensureAttachmentSupported(
attachment: CanonicalPromptAttachment,
attachmentCapability?: ModelAttachmentCapability
) {
if (!attachmentCapability) return;
if (!attachmentCapability.kinds.includes(attachment.kind)) {
throw new CopilotPromptInvalid(
`Native path does not support ${attachment.kind} attachments${
attachment.mediaType ? ` (${attachment.mediaType})` : ''
}`
);
}
if (
attachmentCapability.sourceKinds?.length &&
!attachmentCapability.sourceKinds.includes(attachment.sourceKind)
) {
throw new CopilotPromptInvalid(
`Native path does not support ${attachment.sourceKind} attachment sources`
);
}
if (attachment.isRemote && attachmentCapability.allowRemoteUrls === false) {
throw new CopilotPromptInvalid(
'Native path does not support remote attachment urls'
);
}
}
function resolveResponseSchema(
systemMessage: PromptMessage | undefined,
responseSchema?: unknown
): ZodType | undefined {
if (responseSchema instanceof ZodType) {
return responseSchema;
}
if (systemMessage?.responseFormat?.schema instanceof ZodType) {
return systemMessage.responseFormat.schema;
}
return systemMessage?.params?.schema instanceof ZodType
? systemMessage.params.schema
: undefined;
}
function resolveResponseStrict(
systemMessage: PromptMessage | undefined,
options?: CopilotStructuredOptions
) {
return options?.strict ?? systemMessage?.responseFormat?.strict ?? true;
}
export class StructuredResponseParseError extends Error {}
function normalizeStructuredText(text: string) {
const trimmed = text.replaceAll(/^ny\n/g, ' ').trim();
if (trimmed.startsWith('```') || trimmed.endsWith('```')) {
return trimmed
.replace(/```[\w\s-]*\n/g, '')
.replace(/\n```/g, '')
.trim();
}
return trimmed;
}
export function parseNativeStructuredOutput(
response: Pick<NativeLlmStructuredResponse, 'output_text'> & {
output_json?: unknown;
}
) {
if (response.output_json !== undefined) {
return response.output_json;
}
const normalized = normalizeStructuredText(response.output_text);
const candidates = [
() => normalized,
() => {
const objectStart = normalized.indexOf('{');
const objectEnd = normalized.lastIndexOf('}');
return objectStart !== -1 && objectEnd > objectStart
? normalized.slice(objectStart, objectEnd + 1)
: null;
},
() => {
const arrayStart = normalized.indexOf('[');
const arrayEnd = normalized.lastIndexOf(']');
return arrayStart !== -1 && arrayEnd > arrayStart
? normalized.slice(arrayStart, arrayEnd + 1)
: null;
},
];
for (const candidate of candidates) {
try {
const candidateText = candidate();
if (typeof candidateText === 'string') {
return JSON.parse(candidateText);
}
} catch {
continue;
}
}
throw new StructuredResponseParseError(
`Unexpected structured response: ${normalized.slice(0, 200)}`
);
}
export function buildNativeRerankRequest(
model: string,
request: CopilotRerankRequest
): NativeLlmRerankRequest {
return {
model,
query: request.query,
candidates: request.candidates.map(candidate => ({
...(candidate.id ? { id: candidate.id } : {}),
text: candidate.text,
})),
...(request.topK ? { top_n: request.topK } : {}),
};
}
async function toCoreContents(
message: PromptMessage,
withAttachment: boolean,
attachmentCapability?: ModelAttachmentCapability
): Promise<NativeLlmCoreContent[]> {
const contents: NativeLlmCoreContent[] = [];
if (typeof message.content === 'string' && message.content.length) {
contents.push({ type: 'text', text: message.content });
}
if (!withAttachment || !Array.isArray(message.attachments)) return contents;
for (const entry of message.attachments) {
const normalized = await canonicalizePromptAttachment(entry, message);
ensureAttachmentSupported(normalized, attachmentCapability);
contents.push({
type: normalized.kind,
source: normalized.source,
});
}
return contents;
}
export async function buildNativeRequest({
model,
messages,
options = {},
tools = {},
withAttachment = true,
attachmentCapability,
include,
reasoning,
responseSchema,
middleware,
}: BuildNativeRequestOptions): Promise<BuildNativeRequestResult> {
const copiedMessages = messages.map(message => ({
...message,
attachments: message.attachments
? [...message.attachments]
: message.attachments,
}));
const systemMessage =
copiedMessages[0]?.role === 'system' ? copiedMessages.shift() : undefined;
const schema = resolveResponseSchema(systemMessage, responseSchema);
const coreMessages: NativeLlmCoreMessage[] = [];
if (systemMessage?.content?.length) {
coreMessages.push({
role: 'system',
content: [{ type: 'text', text: systemMessage.content }],
});
}
for (const message of copiedMessages) {
if (message.role === 'system') continue;
const content = await toCoreContents(
message,
withAttachment,
attachmentCapability
);
coreMessages.push({ role: roleToCore(message.role), content });
}
return {
request: {
model,
stream: true,
messages: coreMessages,
max_tokens: options.maxTokens ?? undefined,
temperature: options.temperature ?? undefined,
tools: ToolSchemaExtractor.extract(tools),
tool_choice: Object.keys(tools).length ? 'auto' : undefined,
include,
reasoning,
response_schema: schema
? ToolSchemaExtractor.toJsonSchema(schema)
: undefined,
middleware: middleware?.rust
? { request: middleware.rust.request, stream: middleware.rust.stream }
: undefined,
},
schema,
};
}
export async function buildNativeStructuredRequest({
model,
messages,
options = {},
withAttachment = true,
attachmentCapability,
reasoning,
responseSchema,
middleware,
}: Omit<
BuildNativeRequestOptions,
'tools' | 'include'
>): Promise<BuildNativeStructuredRequestResult> {
const copiedMessages = messages.map(message => ({
...message,
attachments: message.attachments
? [...message.attachments]
: message.attachments,
}));
const systemMessage =
copiedMessages[0]?.role === 'system' ? copiedMessages.shift() : undefined;
const schema = resolveResponseSchema(systemMessage, responseSchema);
const strict = resolveResponseStrict(systemMessage, options);
if (!schema) {
throw new CopilotPromptInvalid('Schema is required');
}
const coreMessages: NativeLlmCoreMessage[] = [];
if (systemMessage?.content?.length) {
coreMessages.push({
role: 'system',
content: [{ type: 'text', text: systemMessage.content }],
});
}
for (const message of copiedMessages) {
if (message.role === 'system') continue;
const content = await toCoreContents(
message,
withAttachment,
attachmentCapability
);
coreMessages.push({ role: roleToCore(message.role), content });
}
return {
request: {
model,
messages: coreMessages,
schema: ToolSchemaExtractor.toJsonSchema(schema),
max_tokens: options.maxTokens ?? undefined,
temperature: options.temperature ?? undefined,
reasoning,
strict,
response_mime_type: 'application/json',
middleware: middleware?.rust
? { request: middleware.rust.request }
: undefined,
},
schema,
};
}
export function buildNativeEmbeddingRequest({
model,
inputs,
dimensions,
taskType = 'RETRIEVAL_DOCUMENT',
}: {
model: string;
inputs: string[];
dimensions?: number;
taskType?: string;
}): NativeLlmEmbeddingRequest {
return {
model,
inputs,
dimensions,
task_type: taskType,
};
}
function ensureToolResultMeta(
event: Extract<NativeLlmStreamEvent, { type: 'tool_result' }>,
toolCalls: Map<string, ToolCallMeta>
): NormalizedToolResultEvent | null {
const name = event.name ?? toolCalls.get(event.call_id)?.name;
const args = event.arguments ?? toolCalls.get(event.call_id)?.args;
if (!name || !args) return null;
return { ...event, name, arguments: args };
}
function pickAttachmentFootnote(value: unknown): AttachmentFootnote | null {
if (!value || typeof value !== 'object') {
return null;
}
const record = value as Record<string, unknown>;
const blobId =
typeof record.blobId === 'string'
? record.blobId
: typeof record.blob_id === 'string'
? record.blob_id
: undefined;
const fileName =
typeof record.fileName === 'string'
? record.fileName
: typeof record.name === 'string'
? record.name
: undefined;
const fileType =
typeof record.fileType === 'string'
? record.fileType
: typeof record.mimeType === 'string'
? record.mimeType
: 'application/octet-stream';
if (!blobId || !fileName) {
return null;
}
return { blobId, fileName, fileType };
}
function collectAttachmentFootnotes(
event: NormalizedToolResultEvent
): AttachmentFootnote[] {
if (event.name === 'blob_read') {
const item = pickAttachmentFootnote(event.output);
return item ? [item] : [];
}
if (event.name === 'doc_semantic_search' && Array.isArray(event.output)) {
return event.output
.map(item => pickAttachmentFootnote(item))
.filter((item): item is AttachmentFootnote => item !== null);
}
return [];
}
function formatAttachmentFootnotes(attachments: AttachmentFootnote[]) {
const references = attachments.map((_, index) => `[^${index + 1}]`).join('');
const definitions = attachments
.map((attachment, index) => {
return `[^${index + 1}]: ${JSON.stringify({
type: 'attachment',
blobId: attachment.blobId,
fileName: attachment.fileName,
fileType: attachment.fileType,
})}`;
})
.join('\n');
return `\n\n${references}\n\n${definitions}`;
}
export class NativeProviderAdapter {
readonly #loop: ToolCallLoop;
readonly #enableCallout: boolean;
readonly #enableCitationFootnote: boolean;
constructor(
dispatch: NativeDispatchFn,
tools: CopilotToolSet,
maxSteps = 20,
options: NativeProviderAdapterOptions = {}
) {
this.#loop = new ToolCallLoop(dispatch, tools, maxSteps);
const enabledNodeTextMiddlewares = new Set(
options.nodeTextMiddleware ?? ['citation_footnote', 'callout']
);
this.#enableCallout =
enabledNodeTextMiddlewares.has('callout') ||
enabledNodeTextMiddlewares.has('thinking_format');
this.#enableCitationFootnote =
enabledNodeTextMiddlewares.has('citation_footnote');
}
async text(
request: NativeLlmRequest,
signal?: AbortSignal,
messages?: PromptMessage[]
) {
let output = '';
for await (const chunk of this.streamText(request, signal, messages)) {
output += chunk;
}
return output.trim();
}
async *streamText(
request: NativeLlmRequest,
signal?: AbortSignal,
messages?: PromptMessage[]
): AsyncIterableIterator<string> {
const textParser = this.#enableCallout ? new TextStreamParser() : null;
const citationFormatter = this.#enableCitationFootnote
? new CitationFootnoteFormatter()
: null;
const toolCalls = new Map<string, ToolCallMeta>();
let streamPartId = 0;
for await (const event of this.#loop.run(request, signal, messages)) {
switch (event.type) {
case 'text_delta': {
if (textParser) {
yield textParser.parse({
type: 'text-delta',
id: String(streamPartId++),
text: event.text,
});
} else {
yield event.text;
}
break;
}
case 'reasoning_delta': {
if (textParser) {
yield textParser.parse({
type: 'reasoning-delta',
id: String(streamPartId++),
text: event.text,
});
} else {
yield event.text;
}
break;
}
case 'tool_call': {
const toolCall = {
name: event.name,
args: event.arguments,
};
toolCalls.set(event.call_id, toolCall);
if (textParser) {
yield textParser.parse({
type: 'tool-call',
toolCallId: event.call_id,
toolName: event.name as never,
input: event.arguments,
});
}
break;
}
case 'tool_result': {
const normalized = ensureToolResultMeta(event, toolCalls);
if (!normalized || !textParser) {
break;
}
yield textParser.parse({
type: 'tool-result',
toolCallId: normalized.call_id,
toolName: normalized.name as never,
input: normalized.arguments,
output: normalized.output,
});
break;
}
case 'citation': {
if (citationFormatter) {
citationFormatter.consume({
type: 'citation',
index: event.index,
url: event.url,
});
}
break;
}
case 'done': {
const footnotes = textParser?.end() ?? '';
const citations = citationFormatter?.end() ?? '';
const tails = [citations, footnotes].filter(Boolean).join('\n');
if (tails) {
yield `\n${tails}`;
}
break;
}
case 'error': {
throw new Error(event.message);
}
default:
break;
}
}
}
async *streamObject(
request: NativeLlmRequest,
signal?: AbortSignal,
messages?: PromptMessage[]
): AsyncIterableIterator<StreamObject> {
const toolCalls = new Map<string, ToolCallMeta>();
const citationFormatter = this.#enableCitationFootnote
? new CitationFootnoteFormatter()
: null;
const fallbackAttachmentFootnotes = new Map<string, AttachmentFootnote>();
let hasFootnoteReference = false;
for await (const event of this.#loop.run(request, signal, messages)) {
switch (event.type) {
case 'text_delta': {
if (event.text.includes('[^')) {
hasFootnoteReference = true;
}
yield {
type: 'text-delta',
textDelta: event.text,
};
break;
}
case 'reasoning_delta': {
yield {
type: 'reasoning',
textDelta: event.text,
};
break;
}
case 'tool_call': {
const toolCall = {
name: event.name,
args: event.arguments,
};
toolCalls.set(event.call_id, toolCall);
yield {
type: 'tool-call',
toolCallId: event.call_id,
toolName: event.name,
args: event.arguments,
};
break;
}
case 'tool_result': {
const normalized = ensureToolResultMeta(event, toolCalls);
if (!normalized) {
break;
}
const attachments = collectAttachmentFootnotes(normalized);
attachments.forEach(attachment => {
fallbackAttachmentFootnotes.set(attachment.blobId, attachment);
});
yield {
type: 'tool-result',
toolCallId: normalized.call_id,
toolName: normalized.name,
args: normalized.arguments,
result: normalized.output,
};
break;
}
case 'citation': {
if (citationFormatter) {
citationFormatter.consume({
type: 'citation',
index: event.index,
url: event.url,
});
}
break;
}
case 'done': {
const citations = citationFormatter?.end() ?? '';
if (citations) {
hasFootnoteReference = true;
yield {
type: 'text-delta',
textDelta: `\n${citations}`,
};
}
if (!hasFootnoteReference && fallbackAttachmentFootnotes.size > 0) {
yield {
type: 'text-delta',
textDelta: formatAttachmentFootnotes(
Array.from(fallbackAttachmentFootnotes.values())
),
};
}
break;
}
case 'error': {
throw new Error(event.message);
}
default:
break;
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,21 +1,12 @@
import { CopilotProviderSideError, metrics } from '../../../base';
import {
llmDispatchStream,
type NativeLlmBackendConfig,
type NativeLlmRequest,
} from '../../../native';
import type { NodeTextMiddleware } from '../config';
import type { CopilotToolSet } from '../tools';
import { buildNativeRequest, NativeProviderAdapter } from './native';
import { CopilotProviderSideError } from '../../../base';
import { type LlmBackendConfig } from '../../../native';
import { CopilotProvider } from './provider';
import { hasProviderModelBehaviorFlag } from './provider-model-runtime';
import {
CopilotChatOptions,
CopilotProviderType,
ModelConditions,
ModelInputType,
ModelOutputType,
PromptMessage,
} from './types';
type CopilotProviderExecution,
type ProviderDriverSpec,
} from './provider-runtime-contract';
import { CopilotProviderType, ModelOutputType } from './types';
export type PerplexityConfig = {
apiKey: string;
@@ -25,166 +16,50 @@ export type PerplexityConfig = {
export class PerplexityProvider extends CopilotProvider<PerplexityConfig> {
readonly type = CopilotProviderType.Perplexity;
readonly models = [
{
name: 'Sonar',
id: 'sonar',
capabilities: [
{
input: [ModelInputType.Text],
output: [ModelOutputType.Text],
defaultForOutputType: true,
},
],
},
{
name: 'Sonar Pro',
id: 'sonar-pro',
capabilities: [
{
input: [ModelInputType.Text],
output: [ModelOutputType.Text],
},
],
},
{
name: 'Sonar Reasoning',
id: 'sonar-reasoning',
capabilities: [
{
input: [ModelInputType.Text],
output: [ModelOutputType.Text],
},
],
},
{
name: 'Sonar Reasoning Pro',
id: 'sonar-reasoning-pro',
capabilities: [
{
input: [ModelInputType.Text],
output: [ModelOutputType.Text],
},
],
},
];
override configured(): boolean {
return !!this.config.apiKey;
protected resolveModelBackendKind() {
return 'perplexity' as const;
}
protected override setup() {
super.setup();
override configured(execution?: CopilotProviderExecution): boolean {
return !!this.getConfig(execution).apiKey;
}
private createNativeConfig(): NativeLlmBackendConfig {
const baseUrl = this.config.endpoint || 'https://api.perplexity.ai';
override getDriverSpec(): ProviderDriverSpec {
return {
base_url: baseUrl.replace(/\/v1\/?$/, ''),
auth_token: this.config.apiKey,
createBackendConfig: execution => this.createNativeConfig(execution),
mapError: error => this.handleError(error),
chat: {
resolveOutputType: kind =>
kind === 'streamObject' ? null : ModelOutputType.Text,
withAttachment: false,
resolveRequestOptions: async context => ({
withAttachment: !hasProviderModelBehaviorFlag(
context.model,
'no_attachments'
),
include: hasProviderModelBehaviorFlag(
context.model,
'citations_include'
)
? ['citations']
: undefined,
}),
},
structured: false,
embedding: false,
rerank: false,
};
}
private createNativeAdapter(
tools: CopilotToolSet,
nodeTextMiddleware?: NodeTextMiddleware[]
) {
return new NativeProviderAdapter(
(request: NativeLlmRequest, signal?: AbortSignal) =>
llmDispatchStream(
'openai_chat',
this.createNativeConfig(),
request,
signal
),
tools,
this.MAX_STEPS,
{ nodeTextMiddleware }
);
}
async text(
cond: ModelConditions,
messages: PromptMessage[],
options: CopilotChatOptions = {}
): Promise<string> {
const fullCond = { ...cond, outputType: ModelOutputType.Text };
const normalizedCond = await this.checkParams({
cond: fullCond,
messages,
options,
withAttachment: false,
});
const model = this.selectModel(normalizedCond);
try {
metrics.ai.counter('chat_text_calls').add(1, this.metricLabels(model.id));
const tools = await this.getTools(options, model.id);
const middleware = this.getActiveProviderMiddleware();
const { request } = await buildNativeRequest({
model: model.id,
messages,
options,
tools,
withAttachment: false,
include: ['citations'],
middleware,
});
const adapter = this.createNativeAdapter(tools, middleware.node?.text);
return await adapter.text(request, options.signal, messages);
} catch (e: any) {
metrics.ai
.counter('chat_text_errors')
.add(1, this.metricLabels(model.id));
throw this.handleError(e);
}
}
async *streamText(
cond: ModelConditions,
messages: PromptMessage[],
options: CopilotChatOptions = {}
): AsyncIterable<string> {
const fullCond = { ...cond, outputType: ModelOutputType.Text };
const normalizedCond = await this.checkParams({
cond: fullCond,
messages,
options,
withAttachment: false,
});
const model = this.selectModel(normalizedCond);
try {
metrics.ai
.counter('chat_text_stream_calls')
.add(1, this.metricLabels(model.id));
const tools = await this.getTools(options, model.id);
const middleware = this.getActiveProviderMiddleware();
const { request } = await buildNativeRequest({
model: model.id,
messages,
options,
tools,
withAttachment: false,
include: ['citations'],
middleware,
});
const adapter = this.createNativeAdapter(tools, middleware.node?.text);
for await (const chunk of adapter.streamText(
request,
options.signal,
messages
)) {
yield chunk;
}
} catch (e: any) {
metrics.ai
.counter('chat_text_stream_errors')
.add(1, this.metricLabels(model.id));
throw this.handleError(e);
}
private createNativeConfig(
execution?: CopilotProviderExecution
): LlmBackendConfig {
const config = this.getConfig(execution);
const baseUrl = config.endpoint || 'https://api.perplexity.ai';
return {
base_url: baseUrl.replace(/\/v1\/?$/, ''),
auth_token: config.apiKey,
};
}
private handleError(e: any) {
@@ -1,81 +1,43 @@
import type { ProviderMiddlewareConfig } from '../config';
import { CopilotProviderType } from './types';
const DEFAULT_NODE_TEXT_MIDDLEWARE: NonNullable<
NonNullable<ProviderMiddlewareConfig['node']>['text']
> = ['citation_footnote', 'callout'];
const DEFAULT_MIDDLEWARE_BY_TYPE: Record<
CopilotProviderType,
ProviderMiddlewareConfig
> = {
[CopilotProviderType.OpenAI]: {
rust: {
request: ['normalize_messages'],
stream: ['stream_event_normalize', 'citation_indexing'],
},
node: {
text: ['citation_footnote', 'callout'],
},
node: { text: DEFAULT_NODE_TEXT_MIDDLEWARE },
},
[CopilotProviderType.CloudflareWorkersAi]: {
rust: {
request: ['normalize_messages'],
stream: ['stream_event_normalize', 'citation_indexing'],
},
node: {
text: ['citation_footnote', 'callout'],
},
node: { text: DEFAULT_NODE_TEXT_MIDDLEWARE },
},
[CopilotProviderType.Anthropic]: {
rust: {
request: ['normalize_messages', 'tool_schema_rewrite'],
stream: ['stream_event_normalize', 'citation_indexing'],
},
node: {
text: ['citation_footnote', 'callout'],
},
node: { text: DEFAULT_NODE_TEXT_MIDDLEWARE },
},
[CopilotProviderType.AnthropicVertex]: {
rust: {
request: ['normalize_messages', 'tool_schema_rewrite'],
stream: ['stream_event_normalize', 'citation_indexing'],
},
node: {
text: ['citation_footnote', 'callout'],
},
node: { text: DEFAULT_NODE_TEXT_MIDDLEWARE },
},
[CopilotProviderType.Morph]: {
rust: {
request: ['clamp_max_tokens'],
stream: ['stream_event_normalize', 'citation_indexing'],
},
node: {
text: ['citation_footnote', 'callout'],
},
node: { text: DEFAULT_NODE_TEXT_MIDDLEWARE },
},
[CopilotProviderType.Perplexity]: {
rust: {
request: ['clamp_max_tokens'],
stream: ['stream_event_normalize', 'citation_indexing'],
},
node: {
text: ['citation_footnote', 'callout'],
},
node: { text: DEFAULT_NODE_TEXT_MIDDLEWARE },
},
[CopilotProviderType.Gemini]: {
rust: {
request: ['normalize_messages', 'tool_schema_rewrite'],
stream: ['stream_event_normalize', 'citation_indexing'],
},
node: {
text: ['citation_footnote', 'callout'],
},
node: { text: DEFAULT_NODE_TEXT_MIDDLEWARE },
},
[CopilotProviderType.GeminiVertex]: {
rust: {
request: ['normalize_messages', 'tool_schema_rewrite'],
stream: ['stream_event_normalize', 'citation_indexing'],
},
node: {
text: ['citation_footnote', 'callout'],
},
node: { text: DEFAULT_NODE_TEXT_MIDDLEWARE },
},
[CopilotProviderType.FAL]: {},
};
@@ -91,18 +53,26 @@ function mergeArray<T>(base: T[] | undefined, override: T[] | undefined) {
return unique([...(base ?? []), ...(override ?? [])]);
}
function compactMiddlewareSection<T extends Record<string, unknown>>(
section: T
): T | undefined {
return Object.values(section).some(value => value !== undefined)
? section
: undefined;
}
export function mergeProviderMiddleware(
defaults: ProviderMiddlewareConfig,
override?: ProviderMiddlewareConfig
): ProviderMiddlewareConfig {
return {
rust: {
rust: compactMiddlewareSection({
request: mergeArray(defaults.rust?.request, override?.rust?.request),
stream: mergeArray(defaults.rust?.stream, override?.rust?.stream),
},
node: {
}),
node: compactMiddlewareSection({
text: mergeArray(defaults.node?.text, override?.node?.text),
},
}),
};
}
@@ -0,0 +1,385 @@
import { z } from 'zod';
import { CopilotPromptInvalid } from '../../../base';
import {
type LlmBackendConfig,
llmInferPromptModelConditions,
llmMatchModelCapabilities,
llmMatchModelRegistry,
type LlmProtocol,
llmResolveModelRegistryVariant,
} from '../../../native';
import { applyPromptAttachmentMimeTypeHintForNative } from './attachments';
import {
type CopilotChatOptions,
type CopilotImageOptions,
type CopilotModelBackendKind,
type CopilotProviderModel,
type CopilotProviderType,
type CopilotStructuredOptions,
EmbeddingMessage,
type ModelAttachmentCapability,
type ModelCapability,
type ModelFullConditions,
ModelInputType,
ModelOutputType,
type PromptAttachmentKind,
type PromptAttachmentSourceKind,
type PromptMessage,
PromptMessageSchema,
} from './types';
// Owner: backend host model-selection glue.
// Capability matching and catalog lookup are delegated to native/adapter; this
// file keeps provider prefix/default/prefer behavior and Node prompt checks.
export type ProviderModelRuntimeContext = {
type: CopilotProviderType;
backendKind: CopilotModelBackendKind;
};
export type ResolvedProviderModel = CopilotProviderModel & {
backendKind: CopilotModelBackendKind;
canonicalKey: string;
protocol?: LlmProtocol;
requestLayer?: LlmBackendConfig['request_layer'];
routeOverrides?: Partial<
Record<
ModelOutputType,
{
protocol?: LlmProtocol;
requestLayer?: LlmBackendConfig['request_layer'];
}
>
>;
behaviorFlags?: string[];
};
function unique<T>(values: Iterable<T>) {
return Array.from(new Set(values));
}
function resolveAttachmentCapability(
cap: ModelCapability,
outputType?: ModelOutputType
): ModelAttachmentCapability | undefined {
if (outputType === ModelOutputType.Structured) {
return cap.structuredAttachments ?? cap.attachments;
}
return cap.attachments;
}
function toProviderModel(
variant: NonNullable<
ReturnType<typeof llmResolveModelRegistryVariant>['variant']
>
): ResolvedProviderModel {
return {
id: variant.rawModelId,
name: variant.displayName,
backendKind: variant.backendKind,
canonicalKey: variant.canonicalKey,
protocol: variant.protocol,
requestLayer: variant.requestLayer,
routeOverrides: variant.routeOverrides,
behaviorFlags: variant.behaviorFlags,
capabilities: variant.capabilities.map(capability => ({
input: capability.input as ModelInputType[],
output: capability.output as ModelOutputType[],
attachments: capability.attachments
? {
kinds: capability.attachments.kinds as PromptAttachmentKind[],
sourceKinds: capability.attachments.sourceKinds as
| ModelAttachmentCapability['sourceKinds']
| undefined,
allowRemoteUrls: capability.attachments.allowRemoteUrls,
}
: undefined,
structuredAttachments: capability.structuredAttachments
? {
kinds: capability.structuredAttachments
.kinds as PromptAttachmentKind[],
sourceKinds: capability.structuredAttachments.sourceKinds as
| ModelAttachmentCapability['sourceKinds']
| undefined,
allowRemoteUrls: capability.structuredAttachments.allowRemoteUrls,
}
: undefined,
defaultForOutputType: capability.defaultForOutputType,
})),
};
}
export type ProviderModelSelection = {
kind: 'configured';
model: ResolvedProviderModel;
};
export function resolveProviderModelSelection(
context: ProviderModelRuntimeContext,
cond: ModelFullConditions
): ProviderModelSelection | undefined {
if (cond.modelId) {
const resolved = llmResolveModelRegistryVariant({
backendKind: context.backendKind,
modelId: cond.modelId,
}).variant;
if (!resolved) {
return;
}
const model = toProviderModel(resolved);
const matchedModelId = llmMatchModelCapabilities([model], {
...cond,
modelId: model.id,
});
if (!matchedModelId) {
return;
}
return {
kind: 'configured',
model,
};
}
const resolved = llmMatchModelRegistry({
backendKind: context.backendKind,
cond,
}).variant;
if (!resolved) {
return;
}
return {
kind: 'configured',
model: toProviderModel(resolved),
};
}
function isMultimodal(model: CopilotProviderModel) {
return model.capabilities.some(c =>
[ModelInputType.Image, ModelInputType.Audio, ModelInputType.File].some(t =>
c.input.includes(t)
)
);
}
function handleZodError(ret: z.SafeParseReturnType<any, any>) {
if (ret.success) return;
const issues = ret.error.issues.map(i => {
const path =
'root' +
(i.path.length
? `.${i.path.map(seg => (typeof seg === 'number' ? `[${seg}]` : `.${seg}`)).join('')}`
: '');
return `${i.message}${path}`;
});
throw new CopilotPromptInvalid(issues.join('; '));
}
export async function inferModelConditionsFromMessages(
messages?: PromptMessage[],
withAttachment = true
): Promise<Partial<ModelFullConditions>> {
if (!messages?.length || !withAttachment) return {};
const projectedMessages = messages.map(message => ({
role: message.role,
content: message.content,
...(Array.isArray(message.attachments) && message.attachments.length
? {
attachments: message.attachments.map(attachment =>
applyPromptAttachmentMimeTypeHintForNative(attachment, message)
),
}
: {}),
}));
const inferredCond = llmInferPromptModelConditions(projectedMessages);
return {
...(inferredCond.attachmentKinds?.length
? { attachmentKinds: unique(inferredCond.attachmentKinds) }
: {}),
...(inferredCond.attachmentSourceKinds?.length
? {
attachmentSourceKinds: unique(
inferredCond.attachmentSourceKinds
) as PromptAttachmentSourceKind[],
}
: {}),
...(inferredCond.inputTypes?.length
? { inputTypes: unique(inferredCond.inputTypes) as ModelInputType[] }
: {}),
...(inferredCond.hasRemoteAttachments
? { hasRemoteAttachments: true }
: {}),
};
}
export function mergeModelConditions(
cond: ModelFullConditions,
inferredCond: Partial<ModelFullConditions>
): ModelFullConditions {
return {
...inferredCond,
...cond,
inputTypes: unique([
...(inferredCond.inputTypes ?? []),
...(cond.inputTypes ?? []),
]),
attachmentKinds: unique([
...(inferredCond.attachmentKinds ?? []),
...(cond.attachmentKinds ?? []),
]),
attachmentSourceKinds: unique([
...(inferredCond.attachmentSourceKinds ?? []),
...(cond.attachmentSourceKinds ?? []),
]),
hasRemoteAttachments:
cond.hasRemoteAttachments ?? inferredCond.hasRemoteAttachments,
};
}
export function getAttachCapability(
model: CopilotProviderModel,
outputType: ModelOutputType
): ModelAttachmentCapability | undefined {
const capability =
model.capabilities.find(cap => cap.output.includes(outputType)) ??
model.capabilities[0];
if (!capability) {
return;
}
return resolveAttachmentCapability(capability, outputType);
}
export function matchProviderModel(
context: ProviderModelRuntimeContext,
cond: ModelFullConditions
): boolean {
return !!resolveProviderModelSelection(context, cond);
}
export function resolveProviderModel(
context: ProviderModelRuntimeContext,
modelId: string
): ResolvedProviderModel | undefined {
return resolveProviderModelSelection(context, {
modelId,
})?.model;
}
export function hasProviderModelBehaviorFlag(
model: CopilotProviderModel,
flag: string
) {
const behaviorFlags = (model as ResolvedProviderModel).behaviorFlags;
return Array.isArray(behaviorFlags) && behaviorFlags.includes(flag);
}
export function resolveProviderModelRoute(
model: CopilotProviderModel,
outputType: ModelOutputType
) {
const resolved = model as ResolvedProviderModel;
const override = resolved.routeOverrides?.[outputType];
return {
protocol: override?.protocol ?? resolved.protocol,
requestLayer: override?.requestLayer ?? resolved.requestLayer,
};
}
export function requireProviderModelSelection(
context: ProviderModelRuntimeContext,
cond: ModelFullConditions
): ResolvedProviderModel {
const selection = resolveProviderModelSelection(context, cond);
if (selection) return selection.model;
const { modelId, outputType, inputTypes } = cond;
throw new CopilotPromptInvalid(
modelId
? `Model ${modelId} does not support ${outputType ?? '<any>'} output with ${inputTypes ?? '<any>'} input`
: outputType
? `No model supports ${outputType} output with ${inputTypes ?? '<any>'} input for provider ${context.type}`
: 'Output type is required when modelId is not provided'
);
}
export async function checkProviderParams(
context: ProviderModelRuntimeContext,
{
cond,
messages,
embeddings,
options = {},
withAttachment = true,
}: {
cond: ModelFullConditions;
messages?: PromptMessage[];
embeddings?: string[];
options?:
| CopilotChatOptions
| CopilotStructuredOptions
| CopilotImageOptions;
withAttachment?: boolean;
execution?: unknown;
}
): Promise<ModelFullConditions> {
if (messages) {
const { requireContent = true, requireAttachment = false } = options;
const MessageSchema = z
.array(
PromptMessageSchema.extend({
content: requireContent
? z.string().trim().min(1)
: z.string().optional().nullable(),
})
.passthrough()
.catchall(z.union([z.string(), z.number(), z.date(), z.null()]))
)
.optional();
handleZodError(MessageSchema.safeParse(messages));
const inferredCond = await inferModelConditionsFromMessages(
messages,
withAttachment
);
const mergedCond = mergeModelConditions(cond, inferredCond);
const model = requireProviderModelSelection(context, mergedCond);
const multimodal = isMultimodal(model);
if (
multimodal &&
requireAttachment &&
!messages.some(
message =>
message.role === 'user' &&
Array.isArray(message.attachments) &&
message.attachments.length > 0
)
) {
throw new CopilotPromptInvalid('attachments required in multimodal mode');
}
if (embeddings) {
handleZodError(EmbeddingMessage.safeParse(embeddings));
}
return mergedCond;
}
const inferredCond = await inferModelConditionsFromMessages(
messages,
withAttachment
);
const mergedCond = mergeModelConditions(cond, inferredCond);
if (embeddings) {
handleZodError(EmbeddingMessage.safeParse(embeddings));
}
return mergedCond;
}
@@ -0,0 +1,337 @@
import type {
LlmBackendConfig,
LlmEmbeddingRequest,
LlmProtocol,
LlmRerankRequest,
LlmStructuredRequest,
} from '../../../native';
import {
buildLlmImageRequestFromMessages,
llmEmbeddingDispatch,
llmRerankDispatch,
llmStructuredDispatch,
} from '../../../native';
import type { NodeTextMiddleware, ProviderMiddlewareConfig } from '../config';
import {
buildToolContracts,
projectPromptMessageForNative,
} from '../runtime/contracts';
import { buildNativeRequest } from '../runtime/native-request-runtime';
import type { ToolLoopBackend } from '../runtime/tool/bridge';
import type { NativeProviderAdapter } from '../runtime/tool/native-adapter';
import type { CopilotToolSet } from '../tools';
import type {
CopilotProviderExecution,
PreparedNativeEmbeddingExecution,
PreparedNativeExecution,
PreparedNativeImageExecution,
PreparedNativeRequestOptions,
PreparedNativeRerankExecution,
PreparedNativeStructuredExecution,
} from './provider-runtime-contract';
import type {
CopilotChatOptions,
CopilotImageOptions,
PromptMessage,
} from './types';
export type CreateToolAdapterOptions = {
maxSteps?: number;
nodeTextMiddleware?: NodeTextMiddleware[];
};
export type CreateNativeAdapter = (
backend: ToolLoopBackend,
tools: CopilotToolSet,
nodeTextMiddleware?: NodeTextMiddleware[],
options?: CreateToolAdapterOptions
) => NativeProviderAdapter;
export type CreatePreparedExecutionRuntimeInput = {
resolveProviderId: (execution?: CopilotProviderExecution) => string;
getTools: (
options: CopilotChatOptions,
model: string
) => Promise<CopilotToolSet>;
getActiveProviderMiddleware: (
execution?: CopilotProviderExecution
) => ProviderMiddlewareConfig;
createNativeAdapter: CreateNativeAdapter;
maxSteps: number;
};
export type PreparedExecutionRuntime = ReturnType<
typeof createPreparedExecutionRuntime
>;
export function createPreparedExecutionRuntime(
input: CreatePreparedExecutionRuntimeInput
) {
return {
buildPreparedNativeExecution: async (
prepared: PreparedNativeRequestOptions
) =>
await buildPreparedNativeExecution(
input.resolveProviderId(prepared.execution),
input.getTools,
input.getActiveProviderMiddleware,
input.maxSteps,
prepared
),
createPreparedExecutionAdapter: (prepared: PreparedNativeExecution) =>
createPreparedExecutionAdapter(
input.createNativeAdapter,
input.maxSteps,
prepared
),
buildPreparedNativeStructuredExecution: (
protocol: LlmProtocol,
backendConfig: LlmBackendConfig,
model: string,
request: LlmStructuredRequest,
execution?: CopilotProviderExecution
) =>
buildPreparedNativeStructuredExecution(
input.resolveProviderId(execution),
protocol,
backendConfig,
model,
request
),
buildPreparedNativeEmbeddingExecution: (
protocol: LlmProtocol,
backendConfig: LlmBackendConfig,
model: string,
request: LlmEmbeddingRequest,
execution?: CopilotProviderExecution
) =>
buildPreparedNativeEmbeddingExecution(
input.resolveProviderId(execution),
protocol,
backendConfig,
model,
request
),
buildPreparedNativeRerankExecution: (
protocol: LlmProtocol,
backendConfig: LlmBackendConfig,
model: string,
request: LlmRerankRequest,
execution?: CopilotProviderExecution
) =>
buildPreparedNativeRerankExecution(
input.resolveProviderId(execution),
protocol,
backendConfig,
model,
request
),
buildPreparedNativeImageExecution: (
protocol: LlmProtocol,
backendConfig: LlmBackendConfig,
model: string,
messages: PromptMessage[],
options: CopilotImageOptions = {},
execution?: CopilotProviderExecution
) =>
buildPreparedNativeImageExecution(
input.resolveProviderId(execution),
protocol,
backendConfig,
model,
messages,
options
),
};
}
export function createPreparedExecutionAdapter(
createNativeAdapter: CreateNativeAdapter,
maxSteps: number,
prepared: PreparedNativeExecution
) {
return createNativeAdapter(
{
protocol: prepared.route.protocol,
backendConfig: prepared.route.backendConfig,
},
prepared.tools,
prepared.postprocess?.nodeTextMiddleware,
{
maxSteps,
nodeTextMiddleware: prepared.postprocess?.nodeTextMiddleware,
}
);
}
export function createNativeStructuredDispatch(
backendConfig: LlmBackendConfig,
protocol: LlmProtocol
) {
return (request: LlmStructuredRequest) =>
llmStructuredDispatch(protocol, backendConfig, request);
}
export function createNativeEmbeddingDispatch(
backendConfig: LlmBackendConfig,
protocol: LlmProtocol
) {
return (request: LlmEmbeddingRequest) =>
llmEmbeddingDispatch(protocol, backendConfig, request);
}
export function createNativeRerankDispatch(
backendConfig: LlmBackendConfig,
protocol: LlmProtocol
) {
return (request: LlmRerankRequest) =>
llmRerankDispatch(protocol, backendConfig, request);
}
function buildPreparedRoute(
providerId: string,
protocol: LlmProtocol,
backendConfig: LlmBackendConfig,
model: string
): PreparedNativeExecution['route'] {
return {
providerId,
protocol,
requestLayer: backendConfig.request_layer,
model,
backendConfig,
};
}
export async function buildPreparedNativeExecution(
providerId: string,
getTools: (
options: CopilotChatOptions,
model: string
) => Promise<CopilotToolSet>,
getActiveProviderMiddleware: (
execution?: CopilotProviderExecution
) => ProviderMiddlewareConfig,
maxSteps: number,
{
protocol,
backendConfig,
model,
messages,
options = {},
execution,
withAttachment = true,
attachmentCapability,
include,
reasoning,
tools,
middleware,
}: PreparedNativeRequestOptions
): Promise<PreparedNativeExecution> {
const resolvedTools = tools ?? (await getTools(options, model));
const resolvedMiddleware =
middleware ?? getActiveProviderMiddleware(execution);
const { request } = await buildNativeRequest({
model,
messages,
options,
toolContracts: buildToolContracts(resolvedTools),
withAttachment,
attachmentCapability,
include,
reasoning,
middleware: resolvedMiddleware,
});
return {
route: buildPreparedRoute(providerId, protocol, backendConfig, model),
request,
tools: resolvedTools,
maxSteps,
postprocess: {
nodeTextMiddleware: resolvedMiddleware.node?.text,
},
};
}
type BuildPreparedNativeDispatchExecution = <
TRequest extends
| LlmStructuredRequest
| LlmEmbeddingRequest
| LlmRerankRequest,
>(
providerId: string,
protocol: LlmProtocol,
backendConfig: LlmBackendConfig,
model: string,
request: TRequest
) => {
route: PreparedNativeExecution['route'];
request: TRequest;
};
const buildPreparedNativeDispatchExecution: BuildPreparedNativeDispatchExecution =
(providerId, protocol, backendConfig, model, request) => {
return {
route: buildPreparedRoute(providerId, protocol, backendConfig, model),
request,
};
};
export const buildPreparedNativeStructuredExecution =
buildPreparedNativeDispatchExecution as (
providerId: string,
protocol: LlmProtocol,
backendConfig: LlmBackendConfig,
model: string,
request: LlmStructuredRequest
) => PreparedNativeStructuredExecution;
export const buildPreparedNativeEmbeddingExecution =
buildPreparedNativeDispatchExecution as (
providerId: string,
protocol: LlmProtocol,
backendConfig: LlmBackendConfig,
model: string,
request: LlmEmbeddingRequest
) => PreparedNativeEmbeddingExecution;
export const buildPreparedNativeRerankExecution =
buildPreparedNativeDispatchExecution as (
providerId: string,
protocol: LlmProtocol,
backendConfig: LlmBackendConfig,
model: string,
request: LlmRerankRequest
) => PreparedNativeRerankExecution;
export function buildPreparedNativeImageExecution(
providerId: string,
protocol: LlmProtocol,
backendConfig: LlmBackendConfig,
model: string,
messages: PromptMessage[],
options: CopilotImageOptions = {}
): PreparedNativeImageExecution {
const nativeMessages = messages.map(
message => projectPromptMessageForNative(message).message
);
return {
route: buildPreparedRoute(providerId, protocol, backendConfig, model),
request: buildLlmImageRequestFromMessages({
model,
protocol,
messages: nativeMessages,
options: projectImageRequestOptions(options),
}),
};
}
function projectImageRequestOptions(options: CopilotImageOptions = {}) {
return {
quality: options.quality,
seed: options.seed,
modelName: options.modelName,
loras: options.loras,
};
}
@@ -240,6 +240,16 @@ export function resolveModel({
};
}
if (modelId) {
return {
rawModelId: modelId,
modelId,
candidateProviderIds: registry.order.filter(providerId =>
isAllowed(providerId)
),
};
}
const defaultProviderId =
outputType && outputType !== ModelOutputType.Rerank
? registry.defaults[outputType]
@@ -0,0 +1,456 @@
import type {
LlmBackendConfig,
LlmEmbeddingRequest,
LlmImageRequest,
LlmProtocol,
LlmRequest,
LlmRerankRequest,
LlmStructuredRequest,
} from '../../../native';
import type { NodeTextMiddleware, ProviderMiddlewareConfig } from '../config';
import type { CopilotToolSet } from '../tools';
import {
type ProviderModelRuntimeContext,
resolveProviderModelRoute,
} from './provider-model-runtime';
import type { NormalizedCopilotProviderProfile } from './provider-registry';
import {
CopilotChatOptions,
CopilotImageOptions,
CopilotProviderModel,
CopilotStructuredOptions,
ModelAttachmentCapability,
ModelConditions,
ModelFullConditions,
ModelOutputType,
PromptMessage,
} from './types';
export type NativeExecutionRoute = {
protocol: LlmProtocol;
requestLayer?: LlmBackendConfig['request_layer'];
model: string;
backendConfig: LlmBackendConfig;
};
export type CopilotProviderExecution = {
providerId: string;
profile: NormalizedCopilotProviderProfile;
};
export type PreparedNativeExecution = {
route: NativeExecutionRoute & {
providerId: string;
};
request: LlmRequest;
tools: CopilotToolSet;
maxSteps?: number;
postprocess?: {
nodeTextMiddleware?: NodeTextMiddleware[];
};
};
export type PreparedNativeStructuredExecution = {
route: NativeExecutionRoute & {
providerId: string;
};
request: LlmStructuredRequest;
};
export type PreparedNativeEmbeddingExecution = {
route: NativeExecutionRoute & {
providerId: string;
};
request: LlmEmbeddingRequest;
};
export type PreparedNativeRerankExecution = {
route: NativeExecutionRoute & {
providerId: string;
};
request: LlmRerankRequest;
};
export type PreparedNativeImageExecution = {
route: NativeExecutionRoute & {
providerId: string;
};
request: LlmImageRequest;
};
export type PreparedNativeRequestOptions = {
protocol: LlmProtocol;
backendConfig: LlmBackendConfig;
model: string;
messages: PromptMessage[];
options?: CopilotChatOptions;
execution?: CopilotProviderExecution;
withAttachment?: boolean;
attachmentCapability?: ModelAttachmentCapability;
include?: string[];
reasoning?: Record<string, unknown>;
tools?: CopilotToolSet;
middleware?: ProviderMiddlewareConfig;
};
type ProviderChatDriverPrepareResult = Omit<
PreparedNativeRequestOptions,
'execution' | 'options'
>;
type Awaitable<T> = T | Promise<T>;
type NativeBackendConfigResolver = (
execution?: CopilotProviderExecution
) => Awaitable<LlmBackendConfig>;
export type StructuredProviderDriver = {
createBackendConfig: NativeBackendConfigResolver;
prepareMessages?: (
messages: PromptMessage[],
backendConfig: LlmBackendConfig,
options: NonNullable<CopilotStructuredOptions>
) => Promise<PromptMessage[]>;
shouldRetry?: (context: {
error: unknown;
attempt: number;
options: NonNullable<CopilotStructuredOptions>;
}) => Awaitable<boolean>;
mapError: (error: unknown) => unknown;
};
export type EmbeddingProviderDriver = {
createBackendConfig: NativeBackendConfigResolver;
defaultDimensions?: number;
taskType?: string;
mapError: (error: unknown) => unknown;
};
export type RerankProviderDriver = {
createBackendConfig: NativeBackendConfigResolver;
mapError: (error: unknown) => unknown;
};
export type ImageProviderDriver = {
createBackendConfig: NativeBackendConfigResolver;
prepareMessages?: (
messages: PromptMessage[],
backendConfig: LlmBackendConfig,
options: NonNullable<CopilotImageOptions>
) => Promise<PromptMessage[]>;
mapError: (error: unknown) => unknown;
};
export type ProviderMetricLabels = Record<
string,
string | number | boolean | undefined
>;
export type ProviderExecutionDrivers = {
chat?: ProviderChatDriver;
structured?: StructuredProviderDriver;
embedding?: EmbeddingProviderDriver;
rerank?: RerankProviderDriver;
image?: ImageProviderDriver;
};
export type ProviderDriverSpec = NativeProviderDriverBase & {
chat?: NativeChatDriverOverrides | false;
structured?: NativeStructuredDriverOverrides | false;
embedding?: NativeEmbeddingDriverOverrides | false;
rerank?: NativeRerankDriverOverrides | false;
image?: NativeImageDriverOverrides | false;
};
export type ProviderRuntimeHostSeed = {
model: ProviderModelRuntimeContext;
resolveExecutionDrivers: () => ProviderExecutionDrivers | undefined;
selectModel: NativeChatDriverBase['selectModel'];
checkParams: NativeChatDriverBase['checkParams'];
getAttachCapability: (
model: CopilotProviderModel,
outputType: ModelOutputType
) => ModelAttachmentCapability | undefined;
getActiveProviderMiddleware: (
execution?: CopilotProviderExecution
) => ProviderMiddlewareConfig;
getTools: (
options: CopilotChatOptions,
model: string
) => Promise<CopilotToolSet>;
metricLabels: (
model: string,
labels?: ProviderMetricLabels,
execution?: CopilotProviderExecution
) => ProviderMetricLabels;
};
export type ProviderChatDriverPrepareInput = {
kind: 'text' | 'streamText' | 'streamObject';
cond: ModelConditions;
messages: PromptMessage[];
options: CopilotChatOptions;
execution?: CopilotProviderExecution;
};
export type ProviderChatDriver = {
prepare: (input: {
kind: ProviderChatDriverPrepareInput['kind'];
cond: ProviderChatDriverPrepareInput['cond'];
messages: ProviderChatDriverPrepareInput['messages'];
options: ProviderChatDriverPrepareInput['options'];
execution?: ProviderChatDriverPrepareInput['execution'];
}) => Promise<ProviderChatDriverPrepareResult | null>;
mapError: (error: unknown) => unknown;
};
type NativeProviderDriverBase = Pick<
StructuredProviderDriver,
'createBackendConfig' | 'mapError'
>;
type ChatToolingResult = Pick<
ProviderChatDriverPrepareResult,
'tools' | 'middleware'
>;
type NativeChatDriverBase = NativeProviderDriverBase & {
checkParams: (input: {
cond: ModelFullConditions;
messages?: PromptMessage[];
embeddings?: string[];
options?:
| CopilotChatOptions
| CopilotStructuredOptions
| CopilotImageOptions;
withAttachment?: boolean;
execution?: CopilotProviderExecution;
}) => Promise<ModelFullConditions>;
selectModel: (
cond: ModelFullConditions,
execution?: CopilotProviderExecution
) => CopilotProviderModel;
getTools?: (
options: CopilotChatOptions,
model: string
) => Promise<CopilotToolSet>;
getActiveProviderMiddleware?: (
execution?: CopilotProviderExecution
) => ProviderMiddlewareConfig;
};
type NativeStructuredDriverOverrides = Partial<StructuredProviderDriver>;
type NativeEmbeddingDriverOverrides = Partial<EmbeddingProviderDriver>;
type NativeRerankDriverOverrides = Partial<RerankProviderDriver>;
type NativeImageDriverOverrides = Partial<ImageProviderDriver>;
type NativeChatDriverContext = {
input: ProviderChatDriverPrepareInput;
outputType: ModelOutputType;
normalizedCond: ModelFullConditions;
model: CopilotProviderModel;
backendConfig: LlmBackendConfig;
protocol: LlmProtocol;
messages: PromptMessage[];
options: NonNullable<CopilotChatOptions>;
execution?: CopilotProviderExecution;
};
type NativeChatDriverOverrides = {
resolveOutputType?: (
kind: ProviderChatDriverPrepareInput['kind']
) => ModelOutputType | null;
withAttachment?: boolean;
prepareMessages?: (
context: Omit<NativeChatDriverContext, 'messages'>
) => Awaitable<PromptMessage[]>;
resolveTooling?: (
context: NativeChatDriverContext
) => Awaitable<ChatToolingResult>;
resolveRequestOptions?: (
context: NativeChatDriverContext
) => Awaitable<
Partial<
Pick<
ProviderChatDriverPrepareResult,
'withAttachment' | 'attachmentCapability' | 'include' | 'reasoning'
>
>
>;
};
export function createNativeProviderDriverFactory(
base: NativeProviderDriverBase
) {
return {
structured(
overrides: NativeStructuredDriverOverrides = {}
): StructuredProviderDriver {
return {
createBackendConfig:
overrides.createBackendConfig ?? base.createBackendConfig,
mapError: overrides.mapError ?? base.mapError,
...(overrides.prepareMessages
? { prepareMessages: overrides.prepareMessages }
: {}),
...(overrides.shouldRetry
? { shouldRetry: overrides.shouldRetry }
: {}),
};
},
embedding(
overrides: NativeEmbeddingDriverOverrides = {}
): EmbeddingProviderDriver {
return {
createBackendConfig:
overrides.createBackendConfig ?? base.createBackendConfig,
mapError: overrides.mapError ?? base.mapError,
...(overrides.defaultDimensions !== undefined
? { defaultDimensions: overrides.defaultDimensions }
: {}),
...(overrides.taskType ? { taskType: overrides.taskType } : {}),
};
},
rerank(overrides: NativeRerankDriverOverrides = {}): RerankProviderDriver {
return {
createBackendConfig:
overrides.createBackendConfig ?? base.createBackendConfig,
mapError: overrides.mapError ?? base.mapError,
};
},
image(overrides: NativeImageDriverOverrides = {}): ImageProviderDriver {
return {
createBackendConfig:
overrides.createBackendConfig ?? base.createBackendConfig,
mapError: overrides.mapError ?? base.mapError,
...(overrides.prepareMessages
? { prepareMessages: overrides.prepareMessages }
: {}),
};
},
};
}
function compileProviderChatDriver(
spec: NativeProviderDriverBase & NativeChatDriverOverrides,
base: NativeChatDriverBase
): ProviderChatDriver {
return {
prepare: async (input: ProviderChatDriverPrepareInput) => {
const options: NonNullable<CopilotChatOptions> = input.options ?? {};
const resolvedOutputType = spec.resolveOutputType?.(input.kind);
const outputType =
resolvedOutputType === undefined
? input.kind === 'streamObject'
? ModelOutputType.Object
: ModelOutputType.Text
: resolvedOutputType;
if (!outputType) {
return null;
}
const normalizedCond = await base.checkParams({
messages: input.messages,
cond: {
...input.cond,
outputType,
},
options,
execution: input.execution,
...(spec.withAttachment !== undefined
? { withAttachment: spec.withAttachment }
: {}),
});
const model = base.selectModel(normalizedCond, input.execution);
const backendConfig = await spec.createBackendConfig(input.execution);
const route = resolveProviderModelRoute(model, outputType);
if (!route.protocol) {
throw new Error(`Missing native protocol for model ${model.id}`);
}
const partialContext = {
input,
outputType,
normalizedCond,
model,
backendConfig:
route.requestLayer === backendConfig.request_layer
? backendConfig
: { ...backendConfig, request_layer: route.requestLayer },
protocol: route.protocol,
options,
execution: input.execution,
};
const messages = spec.prepareMessages
? await spec.prepareMessages(partialContext)
: input.messages;
const context = {
...partialContext,
messages,
};
const tooling = spec.resolveTooling
? await spec.resolveTooling(context)
: {
...(base.getTools
? { tools: await base.getTools(options, model.id) }
: {}),
...(base.getActiveProviderMiddleware
? {
middleware: base.getActiveProviderMiddleware(input.execution),
}
: {}),
};
const requestOptions = spec.resolveRequestOptions
? await spec.resolveRequestOptions(context)
: {};
return {
protocol: context.protocol,
backendConfig: context.backendConfig,
model: model.id,
messages,
...(spec.withAttachment === false ? { withAttachment: false } : {}),
...requestOptions,
...tooling,
};
},
mapError: spec.mapError,
};
}
export function createNativeExecutionDriverSpec(
input: ProviderDriverSpec,
runtimeBase: NativeChatDriverBase
): ProviderExecutionDrivers {
const driverBase = {
createBackendConfig: input.createBackendConfig,
mapError: input.mapError,
};
const nativeDrivers = createNativeProviderDriverFactory(driverBase);
return {
...(input.chat !== false
? {
chat: compileProviderChatDriver(
{ ...driverBase, ...input.chat },
runtimeBase
),
}
: {}),
...(input.structured !== false
? {
structured: nativeDrivers.structured(input.structured ?? undefined),
}
: {}),
...(input.embedding !== false
? {
embedding: nativeDrivers.embedding(input.embedding ?? undefined),
}
: {}),
...(input.rerank !== false
? { rerank: nativeDrivers.rerank(input.rerank ?? undefined) }
: {}),
...(input.image !== false
? { image: nativeDrivers.image(input.image ?? undefined) }
: {}),
};
}
@@ -0,0 +1,22 @@
import {
AnthropicOfficialProvider,
AnthropicVertexProvider,
} from './anthropic';
import { CloudflareWorkersAIProvider } from './cloudflare';
import { FalProvider } from './fal';
import { GeminiGenerativeProvider, GeminiVertexProvider } from './gemini';
import { MorphProvider } from './morph';
import { OpenAIProvider } from './openai';
import { PerplexityProvider } from './perplexity';
export const CopilotProviders = [
OpenAIProvider,
CloudflareWorkersAIProvider,
FalProvider,
GeminiGenerativeProvider,
GeminiVertexProvider,
PerplexityProvider,
AnthropicOfficialProvider,
AnthropicVertexProvider,
MorphProvider,
];
@@ -1,377 +1,214 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { z } from 'zod';
import {
Config,
CopilotPromptInvalid,
CopilotProviderNotSupported,
OnEvent,
} from '../../../base';
import { DocReader, DocWriter } from '../../../core/doc';
import { AccessController } from '../../../core/permission';
import { Models } from '../../../models';
import { IndexerService } from '../../indexer';
import type { ProviderMiddlewareConfig } from '../config';
import { CopilotContextService } from '../context/service';
import { PromptService } from '../prompt/service';
import {
buildBlobContentGetter,
buildContentGetter,
buildDocContentGetter,
buildDocCreateHandler,
buildDocKeywordSearchGetter,
buildDocSearchGetter,
buildDocUpdateHandler,
buildDocUpdateMetaHandler,
type CopilotTool,
type CopilotToolSet,
createBlobReadTool,
createCodeArtifactTool,
createConversationSummaryTool,
createDocComposeTool,
createDocCreateTool,
createDocEditTool,
createDocKeywordSearchTool,
createDocReadTool,
createDocSemanticSearchTool,
createDocUpdateMetaTool,
createDocUpdateTool,
createExaCrawlTool,
createExaSearchTool,
createSectionEditTool,
} from '../tools';
import { canonicalizePromptAttachment } from './attachments';
import { CopilotProviderFactory } from './factory';
import { Config } from '../../../base';
import type { NodeTextMiddleware, ProviderMiddlewareConfig } from '../config';
import { ToolExecutorHost } from '../runtime/hosts/tool-executor-host';
import { mapNativeSemanticError } from '../runtime/native-errors';
import type { ToolLoopBackend } from '../runtime/tool/bridge';
import type { CopilotTool, CopilotToolSet } from '../tools';
import { resolveProviderMiddleware } from './provider-middleware';
import { buildProviderRegistry } from './provider-registry';
import {
checkProviderParams,
getAttachCapability as getAttachCapabilityHelper,
matchProviderModel as matchProviderModelHelper,
type ProviderModelRuntimeContext,
requireProviderModelSelection,
resolveProviderModel,
} from './provider-model-runtime';
import {
type CopilotProviderExecution,
createNativeExecutionDriverSpec,
type ProviderDriverSpec,
type ProviderExecutionDrivers,
type ProviderRuntimeHostSeed,
} from './provider-runtime-contract';
import {
type CopilotChatOptions,
CopilotChatTools,
type CopilotEmbeddingOptions,
type CopilotImageOptions,
type CopilotModelBackendKind,
CopilotProviderModel,
CopilotProviderType,
type CopilotRerankRequest,
CopilotStructuredOptions,
EmbeddingMessage,
type CopilotStructuredOptions,
type ModelAttachmentCapability,
ModelCapability,
ModelConditions,
ModelFullConditions,
ModelInputType,
ModelOutputType,
type PromptAttachmentKind,
type PromptAttachmentSourceKind,
type PromptMessage,
PromptMessageSchema,
StreamObject,
} from './types';
const providerProfileContext = new AsyncLocalStorage<string>();
export type {
CopilotProviderExecution,
ProviderDriverSpec,
ProviderExecutionDrivers,
ProviderRuntimeHostSeed,
} from './provider-runtime-contract';
@Injectable()
export abstract class CopilotProvider<C = any> {
protected readonly logger = new Logger(this.constructor.name);
protected readonly MAX_STEPS = 20;
protected onlineModelList: string[] = [];
abstract readonly type: CopilotProviderType;
abstract readonly models: CopilotProviderModel[];
abstract configured(): boolean;
protected abstract resolveModelBackendKind(
execution?: CopilotProviderExecution
): CopilotModelBackendKind;
abstract configured(execution?: CopilotProviderExecution): boolean;
@Inject() protected readonly AFFiNEConfig!: Config;
@Inject() protected readonly factory!: CopilotProviderFactory;
@Inject() protected readonly moduleRef!: ModuleRef;
readonly #registeredProviderIds = new Set<string>();
@Inject() protected readonly toolExecutorHost!: ToolExecutorHost;
runWithProfile<T>(providerId: string, callback: () => T): T {
return providerProfileContext.run(providerId, callback);
get maxSteps() {
return this.MAX_STEPS;
}
protected getActiveProviderId() {
return providerProfileContext.getStore() ?? `${this.type}-default`;
protected resolveModelRuntimeContext(
execution?: CopilotProviderExecution
): ProviderModelRuntimeContext {
return {
type: this.type,
backendKind: this.resolveModelBackendKind(execution),
};
}
protected getActiveProviderMiddleware(): ProviderMiddlewareConfig {
const providerId = this.getActiveProviderId();
const registry = buildProviderRegistry(this.AFFiNEConfig.copilot.providers);
const profile = registry.profiles.get(providerId);
return profile?.middleware ?? resolveProviderMiddleware(this.type);
protected get modelRuntimeContext(): ProviderModelRuntimeContext {
return this.resolveModelRuntimeContext();
}
protected metricLabels(
getDriverSpec(): ProviderDriverSpec | undefined {
return undefined;
}
getExecutionDrivers(): ProviderExecutionDrivers | undefined {
const spec = this.getDriverSpec();
return spec ? this.createDriverSpec(spec) : undefined;
}
protected createDriverSpec(
spec: ProviderDriverSpec
): ProviderExecutionDrivers {
return createNativeExecutionDriverSpec(spec, {
createBackendConfig: spec.createBackendConfig,
mapError: error => {
const mapped = mapNativeSemanticError(error);
return mapped === error ? spec.mapError(error) : mapped;
},
checkParams: input =>
checkProviderParams(
this.resolveModelRuntimeContext(input.execution),
input
),
selectModel: (cond, execution) =>
requireProviderModelSelection(
this.resolveModelRuntimeContext(execution),
cond
),
getTools: this.getTools.bind(this),
getActiveProviderMiddleware: this.getActiveProviderMiddleware.bind(this),
});
}
selectModel(
cond: ModelFullConditions,
execution?: CopilotProviderExecution
): CopilotProviderModel {
return requireProviderModelSelection(
this.resolveModelRuntimeContext(execution),
cond
);
}
checkParams(input: {
cond: ModelFullConditions;
messages?: PromptMessage[];
embeddings?: string[];
options?:
| CopilotChatOptions
| CopilotStructuredOptions
| CopilotImageOptions;
withAttachment?: boolean;
execution?: CopilotProviderExecution;
}) {
return checkProviderParams(
this.resolveModelRuntimeContext(input.execution),
input
);
}
getRuntimeHostSeed(): ProviderRuntimeHostSeed {
return {
model: this.resolveModelRuntimeContext(),
resolveExecutionDrivers: () => this.getExecutionDrivers(),
selectModel: this.selectModel.bind(this),
checkParams: this.checkParams.bind(this),
getAttachCapability: this.getAttachCapability.bind(this),
getActiveProviderMiddleware: this.getActiveProviderMiddleware.bind(this),
getTools: this.getTools.bind(this),
metricLabels: this.metricLabels.bind(this),
};
}
protected getExecutionProfile(execution?: CopilotProviderExecution) {
return execution?.profile?.type === this.type
? execution.profile
: undefined;
}
getActiveProviderMiddleware(
execution?: CopilotProviderExecution
): ProviderMiddlewareConfig {
return (
this.getExecutionProfile(execution)?.middleware ??
resolveProviderMiddleware(this.type)
);
}
metricLabels(
model: string,
labels: Record<string, string | number | boolean | undefined> = {}
labels: Record<string, string | number | boolean | undefined> = {},
execution?: CopilotProviderExecution
) {
const providerId = this.getActiveProviderId();
return { model, providerId, ...labels };
return {
model,
providerId: execution?.providerId ?? `${this.type}-default`,
...labels,
};
}
get config(): C {
const profileId = providerProfileContext.getStore();
if (profileId) {
const profile = this.AFFiNEConfig.copilot.providers.profiles?.find(
profile => profile.id === profileId && profile.type === this.type
);
if (profile) return profile.config as C;
}
protected get config(): C {
return this.AFFiNEConfig.copilot.providers[this.type] as C;
}
@OnEvent('config.init')
async onConfigInit() {
this.setup();
}
@OnEvent('config.changed')
async onConfigChanged(event: Events['config.changed']) {
if ('copilot' in event.updates) {
this.setup();
protected getConfig(execution?: CopilotProviderExecution): C {
const profile = this.getExecutionProfile(execution);
if (profile) {
return profile.config as C;
}
return this.config;
}
protected setup() {
const registry = buildProviderRegistry(this.AFFiNEConfig.copilot.providers);
const providerIds = registry.byType.get(this.type) ?? [];
const nextProviderIds = new Set<string>();
for (const id of providerIds) {
const configured = this.runWithProfile(id, () => this.configured());
if (configured) {
nextProviderIds.add(id);
this.factory.register(id, this);
} else {
this.factory.unregister(id, this);
}
}
for (const providerId of this.#registeredProviderIds) {
if (!nextProviderIds.has(providerId)) {
this.factory.unregister(providerId, this);
}
}
this.#registeredProviderIds.clear();
for (const providerId of nextProviderIds) {
this.#registeredProviderIds.add(providerId);
}
if (env.selfhosted && nextProviderIds.size > 0) {
const [providerId] = Array.from(nextProviderIds);
this.runWithProfile(providerId, () => {
this.refreshOnlineModels().catch(e =>
this.logger.error('Failed to refresh online models', e)
);
});
}
}
async refreshOnlineModels() {}
private unique<T>(values: Iterable<T>) {
return Array.from(new Set(values));
}
private attachmentKindToInputType(
kind: PromptAttachmentKind
): ModelInputType {
switch (kind) {
case 'image':
return ModelInputType.Image;
case 'audio':
return ModelInputType.Audio;
default:
return ModelInputType.File;
}
}
protected async inferModelConditionsFromMessages(
messages?: PromptMessage[],
withAttachment = true
): Promise<Partial<ModelFullConditions>> {
if (!messages?.length || !withAttachment) return {};
const attachmentKinds: PromptAttachmentKind[] = [];
const attachmentSourceKinds: PromptAttachmentSourceKind[] = [];
const inputTypes: ModelInputType[] = [];
let hasRemoteAttachments = false;
for (const message of messages) {
if (!Array.isArray(message.attachments)) continue;
for (const attachment of message.attachments) {
const normalized = await canonicalizePromptAttachment(
attachment,
message
);
attachmentKinds.push(normalized.kind);
inputTypes.push(this.attachmentKindToInputType(normalized.kind));
attachmentSourceKinds.push(normalized.sourceKind);
hasRemoteAttachments = hasRemoteAttachments || normalized.isRemote;
}
}
return {
...(attachmentKinds.length
? { attachmentKinds: this.unique(attachmentKinds) }
: {}),
...(attachmentSourceKinds.length
? { attachmentSourceKinds: this.unique(attachmentSourceKinds) }
: {}),
...(inputTypes.length ? { inputTypes: this.unique(inputTypes) } : {}),
...(hasRemoteAttachments ? { hasRemoteAttachments } : {}),
};
}
private mergeModelConditions(
cond: ModelFullConditions,
inferredCond: Partial<ModelFullConditions>
): ModelFullConditions {
return {
...inferredCond,
...cond,
inputTypes: this.unique([
...(inferredCond.inputTypes ?? []),
...(cond.inputTypes ?? []),
]),
attachmentKinds: this.unique([
...(inferredCond.attachmentKinds ?? []),
...(cond.attachmentKinds ?? []),
]),
attachmentSourceKinds: this.unique([
...(inferredCond.attachmentSourceKinds ?? []),
...(cond.attachmentSourceKinds ?? []),
]),
hasRemoteAttachments:
cond.hasRemoteAttachments ?? inferredCond.hasRemoteAttachments,
};
}
protected getAttachCapability(
getAttachCapability(
model: CopilotProviderModel,
outputType: ModelOutputType
): ModelAttachmentCapability | undefined {
const capability =
model.capabilities.find(cap => cap.output.includes(outputType)) ??
model.capabilities[0];
if (!capability) {
return;
}
return this.resolveAttachmentCapability(capability, outputType);
}
private resolveAttachmentCapability(
cap: ModelCapability,
outputType?: ModelOutputType
): ModelAttachmentCapability | undefined {
if (outputType === ModelOutputType.Structured) {
return cap.structuredAttachments ?? cap.attachments;
}
return cap.attachments;
}
private matchesAttachCapability(
cap: ModelCapability,
cond: ModelFullConditions
) {
const {
attachmentKinds,
attachmentSourceKinds,
hasRemoteAttachments,
outputType,
} = cond;
if (
!attachmentKinds?.length &&
!attachmentSourceKinds?.length &&
!hasRemoteAttachments
) {
return true;
}
const attachmentCapability = this.resolveAttachmentCapability(
cap,
outputType
);
if (!attachmentCapability) {
return !attachmentKinds?.some(
kind => !cap.input.includes(this.attachmentKindToInputType(kind))
);
}
if (
attachmentKinds?.some(kind => !attachmentCapability.kinds.includes(kind))
) {
return false;
}
if (
attachmentSourceKinds?.length &&
attachmentCapability.sourceKinds?.length &&
attachmentSourceKinds.some(
kind => !attachmentCapability.sourceKinds?.includes(kind)
)
) {
return false;
}
if (
hasRemoteAttachments &&
attachmentCapability.allowRemoteUrls === false
) {
return false;
}
return true;
}
private findValidModel(
cond: ModelFullConditions
): CopilotProviderModel | undefined {
const { modelId, outputType, inputTypes } = cond;
const matcher = (cap: ModelCapability) =>
(!outputType || cap.output.includes(outputType)) &&
(!inputTypes?.length ||
inputTypes.every(type => cap.input.includes(type))) &&
this.matchesAttachCapability(cap, cond);
if (modelId) {
const hasOnlineModel = this.onlineModelList.includes(modelId);
const model = this.models.find(
m => m.id === modelId && m.capabilities.some(matcher)
);
if (model) return model;
// allow online model without capabilities check
if (hasOnlineModel) return { id: modelId, capabilities: [] };
return undefined;
}
if (!outputType) return undefined;
return this.models.find(m =>
m.capabilities.some(c => matcher(c) && c.defaultForOutputType)
);
return getAttachCapabilityHelper(model, outputType);
}
// make it async to allow dynamic check available models in some providers
async match(cond: ModelFullConditions = {}): Promise<boolean> {
return this.configured() && !!this.findValidModel(cond);
async match(
cond: ModelFullConditions = {},
execution?: CopilotProviderExecution
): Promise<boolean> {
return (
this.configured(execution) &&
matchProviderModelHelper(this.resolveModelRuntimeContext(execution), cond)
);
}
protected selectModel(cond: ModelFullConditions): CopilotProviderModel {
const model = this.findValidModel(cond);
if (model) return model;
const { modelId, outputType, inputTypes } = cond;
throw new CopilotPromptInvalid(
resolveModel(
modelId: string,
execution?: CopilotProviderExecution
): CopilotProviderModel | undefined {
return resolveProviderModel(
this.resolveModelRuntimeContext(execution),
modelId
? `Model ${modelId} does not support ${outputType ?? '<any>'} output with ${inputTypes ?? '<any>'} input`
: outputType
? `No model supports ${outputType} output with ${inputTypes ?? '<any>'} input for provider ${this.type}`
: 'Output type is required when modelId is not provided'
);
}
@@ -383,302 +220,30 @@ export abstract class CopilotProvider<C = any> {
}
// use for tool use, shared between providers
protected async getTools(
async getTools(
options: CopilotChatOptions,
model: string
): Promise<CopilotToolSet> {
const tools: CopilotToolSet = {};
if (options?.tools?.length) {
this.logger.debug(`getTools: ${JSON.stringify(options.tools)}`);
const ac = this.moduleRef.get(AccessController, { strict: false });
const context = this.moduleRef.get(CopilotContextService, {
strict: false,
});
const docReader = this.moduleRef.get(DocReader, { strict: false });
const docWriter = this.moduleRef.get(DocWriter, { strict: false });
const models = this.moduleRef.get(Models, { strict: false });
const prompt = this.moduleRef.get(PromptService, {
strict: false,
});
for (const tool of options.tools) {
const toolDef = this.getProviderSpecificTools(tool, model);
if (toolDef) {
// allow provider prevent tool creation
if (toolDef[1]) {
tools[toolDef[0]] = toolDef[1];
}
continue;
}
if (
!(env.dev || env.namespaces.canary) &&
['docCreate', 'docUpdate', 'docUpdateMeta'].includes(tool)
) {
continue;
}
switch (tool) {
case 'blobRead': {
const docContext = options.session
? await context.getBySessionId(options.session)
: null;
const getBlobContent = buildBlobContentGetter(ac, docContext);
tools.blob_read = createBlobReadTool(
getBlobContent.bind(null, options)
);
break;
}
case 'codeArtifact': {
tools.code_artifact = createCodeArtifactTool(prompt, this.factory);
break;
}
case 'conversationSummary': {
tools.conversation_summary = createConversationSummaryTool(
options.session,
prompt,
this.factory
);
break;
}
case 'docEdit': {
const getDocContent = buildContentGetter(ac, docReader);
tools.doc_edit = createDocEditTool(
this.factory,
prompt,
getDocContent.bind(null, options)
);
break;
}
case 'docSemanticSearch': {
const docContext = options.session
? await context.getBySessionId(options.session)
: null;
const searchDocs = buildDocSearchGetter(
ac,
context,
docContext,
models
);
tools.doc_semantic_search = createDocSemanticSearchTool(
searchDocs.bind(null, options)
);
break;
}
case 'docKeywordSearch': {
if (this.AFFiNEConfig.indexer.enabled) {
const indexerService = this.moduleRef.get(IndexerService, {
strict: false,
});
const searchDocs = buildDocKeywordSearchGetter(
ac,
indexerService,
models
);
tools.doc_keyword_search = createDocKeywordSearchTool(
searchDocs.bind(null, options)
);
}
break;
}
case 'docRead': {
const getDoc = buildDocContentGetter(ac, docReader, models);
tools.doc_read = createDocReadTool(getDoc.bind(null, options));
break;
}
case 'docCreate': {
const createDoc = buildDocCreateHandler(ac, docWriter);
tools.doc_create = createDocCreateTool(
createDoc.bind(null, options)
);
break;
}
case 'docUpdate': {
const updateDoc = buildDocUpdateHandler(ac, docWriter);
tools.doc_update = createDocUpdateTool(
updateDoc.bind(null, options)
);
break;
}
case 'docUpdateMeta': {
const updateDocMeta = buildDocUpdateMetaHandler(ac, docWriter);
tools.doc_update_meta = createDocUpdateMetaTool(
updateDocMeta.bind(null, options)
);
break;
}
case 'webSearch': {
tools.web_search_exa = createExaSearchTool(this.AFFiNEConfig);
tools.web_crawl_exa = createExaCrawlTool(this.AFFiNEConfig);
break;
}
case 'docCompose': {
tools.doc_compose = createDocComposeTool(prompt, this.factory);
break;
}
case 'sectionEdit': {
tools.section_edit = createSectionEditTool(prompt, this.factory);
break;
}
}
}
return tools;
}
return tools;
}
private handleZodError(ret: z.SafeParseReturnType<any, any>) {
if (ret.success) return;
const issues = ret.error.issues.map(i => {
const path =
'root' +
(i.path.length
? `.${i.path.map(seg => (typeof seg === 'number' ? `[${seg}]` : `.${seg}`)).join('')}`
: '');
return `${i.message}${path}`;
});
throw new CopilotPromptInvalid(issues.join('; '));
}
protected async checkParams({
cond,
messages,
embeddings,
options = {},
withAttachment = true,
}: {
cond: ModelFullConditions;
messages?: PromptMessage[];
embeddings?: string[];
options?: CopilotChatOptions | CopilotStructuredOptions;
withAttachment?: boolean;
}): Promise<ModelFullConditions> {
if (messages) {
const { requireContent = true, requireAttachment = false } = options;
const MessageSchema = z
.array(
PromptMessageSchema.extend({
content: requireContent
? z.string().trim().min(1)
: z.string().optional().nullable(),
})
.passthrough()
.catchall(z.union([z.string(), z.number(), z.date(), z.null()]))
)
.optional();
this.handleZodError(MessageSchema.safeParse(messages));
const inferredCond = await this.inferModelConditionsFromMessages(
messages,
withAttachment
);
const mergedCond = this.mergeModelConditions(cond, inferredCond);
const model = this.selectModel(mergedCond);
const multimodal = model.capabilities.some(c =>
[ModelInputType.Image, ModelInputType.Audio, ModelInputType.File].some(
t => c.input.includes(t)
)
);
if (
multimodal &&
requireAttachment &&
!messages.some(
message =>
message.role === 'user' &&
Array.isArray(message.attachments) &&
message.attachments.length > 0
)
) {
throw new CopilotPromptInvalid(
'attachments required in multimodal mode'
);
}
if (embeddings) {
this.handleZodError(EmbeddingMessage.safeParse(embeddings));
}
return mergedCond;
}
const inferredCond = await this.inferModelConditionsFromMessages(
messages,
withAttachment
this.logger.debug(`getTools: ${JSON.stringify(options?.tools ?? [])}`);
return await this.toolExecutorHost.getTools(
options,
model,
this.getProviderSpecificTools.bind(this)
);
const mergedCond = this.mergeModelConditions(cond, inferredCond);
if (embeddings) {
this.handleZodError(EmbeddingMessage.safeParse(embeddings));
}
return mergedCond;
}
abstract text(
model: ModelConditions,
messages: PromptMessage[],
options?: CopilotChatOptions
): Promise<string>;
abstract streamText(
model: ModelConditions,
messages: PromptMessage[],
options?: CopilotChatOptions
): AsyncIterable<string>;
streamObject(
_model: ModelConditions,
_messages: PromptMessage[],
_options?: CopilotChatOptions
): AsyncIterable<StreamObject> {
throw new CopilotProviderNotSupported({
provider: this.type,
kind: 'object',
});
}
structure(
_cond: ModelConditions,
_messages: PromptMessage[],
_options?: CopilotStructuredOptions
): Promise<string> {
throw new CopilotProviderNotSupported({
provider: this.type,
kind: 'structure',
});
}
streamImages(
_model: ModelConditions,
_messages: PromptMessage[],
_options?: CopilotImageOptions
): AsyncIterable<string> {
throw new CopilotProviderNotSupported({
provider: this.type,
kind: 'image',
});
}
embedding(
_model: ModelConditions,
_text: string | string[],
_options?: CopilotEmbeddingOptions
): Promise<number[][]> {
throw new CopilotProviderNotSupported({
provider: this.type,
kind: 'embedding',
});
}
async rerank(
_model: ModelConditions,
_request: CopilotRerankRequest,
_options?: CopilotChatOptions
): Promise<number[]> {
throw new CopilotProviderNotSupported({
provider: this.type,
kind: 'rerank',
createNativeAdapter(
backend: ToolLoopBackend,
tools: CopilotToolSet,
nodeTextMiddleware?: NodeTextMiddleware[],
options: {
maxSteps?: number;
nodeTextMiddleware?: NodeTextMiddleware[];
} = {}
) {
return this.toolExecutorHost.createNativeAdapter(backend, tools, {
...options,
nodeTextMiddleware: nodeTextMiddleware ?? options.nodeTextMiddleware,
});
}
}
@@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common';
import { Config } from '../../../base';
import {
buildProviderRegistry,
type CopilotProviderRegistry,
type CopilotProvidersConfigInput,
} from './provider-registry';
@Injectable()
export class CopilotProviderRegistryService {
private lastConfig?: CopilotProvidersConfigInput;
private lastRegistry?: CopilotProviderRegistry;
constructor(private readonly config: Config) {}
getRegistry(): CopilotProviderRegistry {
const providerConfig = this.config.copilot.providers;
if (this.lastConfig === providerConfig && this.lastRegistry) {
return this.lastRegistry;
}
const registry = buildProviderRegistry(providerConfig);
this.lastConfig = providerConfig;
this.lastRegistry = registry;
return registry;
}
}
@@ -2,6 +2,23 @@ import { AiPromptRole } from '@prisma/client';
import { z } from 'zod';
import { JSONSchema } from '../../../base';
import type {
CapabilityAttachmentContract,
CapabilityModelCapability,
ModelConditionsContract,
} from '../../../native';
import type { CopilotModelBackendKind } from '../runtime/contracts';
import {
type StreamObject,
StreamObjectSchema,
} from '../runtime/contracts/runtime-event-contract';
// Owner map:
// - provider/profile/config schemas in this file are backend host ingress.
// - prompt/message/attachment Zod schemas validate Node host ingress and
// persistence surfaces before values cross into native prompt DTOs.
// - model condition/capability types are native-generated facades.
// - StreamObject is app-facing projection, not runtime event truth.
// ========== provider ==========
@@ -163,6 +180,8 @@ const PromptAttachmentSchema = z.discriminatedUnion('kind', [
.object({
kind: z.literal('url'),
url: AttachmentUrlSchema,
data: z.string().optional(),
encoding: z.literal('base64').optional(),
mimeType: z.string().optional(),
fileName: z.string().optional(),
providerHint: AttachmentProviderHintSchema.optional(),
@@ -211,34 +230,14 @@ export const ChatMessageAttachment = z.union([
export const PromptResponseFormatSchema = z
.object({
type: z.literal('json_schema'),
schema: z.any(),
responseSchemaJson: z.record(z.unknown()).optional(),
schemaHash: z.string().optional(),
strict: z.boolean().optional(),
})
.strict();
export const StreamObjectSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('text-delta'),
textDelta: z.string(),
}),
z.object({
type: z.literal('reasoning'),
textDelta: z.string(),
}),
z.object({
type: z.literal('tool-call'),
toolCallId: z.string(),
toolName: z.string(),
args: z.record(z.any()),
}),
z.object({
type: z.literal('tool-result'),
toolCallId: z.string(),
toolName: z.string(),
args: z.record(z.any()),
result: z.any(),
}),
]);
.strict()
.refine(value => value.responseSchemaJson !== undefined, {
message: 'responseSchemaJson is required',
});
export const PureMessageSchema = z.object({
content: z.string(),
@@ -253,7 +252,8 @@ export const PromptMessageSchema = PureMessageSchema.extend({
}).strict();
export type PromptMessage = z.infer<typeof PromptMessageSchema>;
export type PromptParams = NonNullable<PromptMessage['params']>;
export type StreamObject = z.infer<typeof StreamObjectSchema>;
export { StreamObjectSchema };
export type { StreamObject };
export type PromptAttachment = z.infer<typeof ChatMessageAttachment>;
export type PromptAttachmentSourceKind = z.infer<
typeof PromptAttachmentSourceKindSchema
@@ -286,7 +286,11 @@ export type CopilotChatTools = NonNullable<
export const CopilotStructuredOptionsSchema =
CopilotProviderOptionsSchema.merge(PromptConfigStrictSchema)
.extend({ schema: z.any().optional(), strict: z.boolean().optional() })
.extend({
responseSchemaJson: z.record(z.unknown()).optional(),
schemaHash: z.string().optional(),
strict: z.boolean().optional(),
})
.optional();
export type CopilotStructuredOptions = z.infer<
@@ -299,6 +303,16 @@ export const CopilotImageOptionsSchema = CopilotProviderOptionsSchema.merge(
.extend({
quality: z.string().optional(),
seed: z.number().optional(),
modelName: z.string().nullable().optional(),
loras: z
.array(
z.object({
path: z.string(),
scale: z.number().nullable().optional(),
})
)
.nullable()
.optional(),
})
.optional();
@@ -306,7 +320,7 @@ export type CopilotImageOptions = z.infer<typeof CopilotImageOptionsSchema>;
export const CopilotEmbeddingOptionsSchema =
CopilotProviderOptionsSchema.extend({
dimensions: z.number(),
dimensions: z.number().optional(),
}).optional();
export type CopilotEmbeddingOptions = z.infer<
@@ -324,35 +338,29 @@ export type CopilotRerankRequest = {
topK?: number;
};
export enum ModelInputType {
Text = 'text',
Image = 'image',
Audio = 'audio',
File = 'file',
}
export const ModelInputType = {
Text: 'text',
Image: 'image',
Audio: 'audio',
File: 'file',
} as const;
export enum ModelOutputType {
Text = 'text',
Object = 'object',
Embedding = 'embedding',
Image = 'image',
Rerank = 'rerank',
Structured = 'structured',
}
export type ModelInputType = CapabilityModelCapability['input'][number];
export interface ModelAttachmentCapability {
kinds: PromptAttachmentKind[];
sourceKinds?: PromptAttachmentSourceKind[];
allowRemoteUrls?: boolean;
}
export const ModelOutputType = {
Text: 'text',
Object: 'object',
Embedding: 'embedding',
Image: 'image',
Rerank: 'rerank',
Structured: 'structured',
} as const;
export interface ModelCapability {
input: ModelInputType[];
output: ModelOutputType[];
attachments?: ModelAttachmentCapability;
structuredAttachments?: ModelAttachmentCapability;
defaultForOutputType?: boolean;
}
export type ModelOutputType = CapabilityModelCapability['output'][number];
export type ModelAttachmentCapability = CapabilityAttachmentContract;
export type ModelCapability = CapabilityModelCapability;
export interface CopilotProviderModel {
id: string;
@@ -360,14 +368,8 @@ export interface CopilotProviderModel {
capabilities: ModelCapability[];
}
export type ModelConditions = {
inputTypes?: ModelInputType[];
attachmentKinds?: PromptAttachmentKind[];
attachmentSourceKinds?: PromptAttachmentSourceKind[];
hasRemoteAttachments?: boolean;
modelId?: string;
};
export type { CopilotModelBackendKind };
export type ModelFullConditions = ModelConditions & {
outputType?: ModelOutputType;
};
export type ModelConditions = Omit<ModelConditionsContract, 'outputType'>;
export type ModelFullConditions = ModelConditionsContract;
@@ -1,5 +1,3 @@
import { createHash } from 'node:crypto';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import {
Args,
@@ -24,37 +22,29 @@ import {
CopilotProviderSideError,
CopilotSessionNotFound,
type FileUpload,
ImageFormatNotSupported,
paginate,
Paginated,
PaginationInput,
RequestMutex,
sniffMime,
Throttle,
TooManyRequest,
UserFriendlyError,
} from '../../base';
import { CurrentUser } from '../../core/auth';
import { Admin } from '../../core/common';
import { DocReader } from '../../core/doc';
import {
AccessController,
DocAction,
WorkspacePolicyService,
} from '../../core/permission';
import { AccessController, DocAction } from '../../core/permission';
import { UserType } from '../../core/user';
import type { ListSessionOptions, UpdateChatSession } from '../../models';
import { processImage } from '../../native';
import { CopilotCronJobs } from './cron';
import { CompatHistoryProjector } from './compat/history-projector';
import { ConversationInboxService } from './conversation/inbox';
import { PromptService } from './prompt/service';
import { CopilotProviderFactory } from './providers/factory';
import type { PromptMessage, StreamObject } from './providers/types';
import { ModelOutputType, type StreamObject } from './providers/types';
import { CapabilityRuntime } from './runtime/capability-runtime';
import { ChatSessionService } from './session';
import { CopilotStorage } from './storage';
import { type ChatHistory, type ChatMessage, SubmittedMessage } from './types';
export const COPILOT_LOCKER = 'copilot';
const COPILOT_IMAGE_MAX_EDGE = 1536;
// ================== Input Types ==================
@@ -382,12 +372,13 @@ export class CopilotResolver {
constructor(
private readonly ac: AccessController,
private readonly mutex: RequestMutex,
private readonly policy: WorkspacePolicyService,
private readonly prompt: PromptService,
private readonly chatSession: ChatSessionService,
private readonly storage: CopilotStorage,
private readonly historyProjector: CompatHistoryProjector,
private readonly inbox: ConversationInboxService,
private readonly docReader: DocReader,
private readonly providerFactory: CopilotProviderFactory
private readonly providerFactory: CopilotProviderFactory,
private readonly runtime: CapabilityRuntime
) {}
@ResolveField(() => CopilotQuotaType, {
@@ -436,33 +427,36 @@ export class CopilotResolver {
if (!prompt) {
throw new NotFoundException('Prompt not found');
}
const convertModels = (ids: string[]) => {
return ids
.map(id => ({ id, name: this.modelNames.get(id) }))
.filter(m => !!m.name) as CopilotModelType[];
const convertModels = async (ids: string[]) => {
const models = await Promise.all(
ids.map(async id => {
const cachedName = this.modelNames.get(id);
if (cachedName) return { id, name: cachedName };
const resolved = await this.providerFactory.resolveProvider({
modelId: id,
outputType: ModelOutputType.Text,
});
const name = resolved?.provider.resolveModel(
resolved.modelId ?? id,
resolved.execution
)?.name;
if (name) {
this.modelNames.set(id, name);
return { id, name };
}
return null;
})
);
return models.filter(model => !!model) as CopilotModelType[];
};
const proModels = prompt.config?.proModels || [];
const missing = new Set(
[...prompt.optionalModels, ...proModels].filter(
id => !this.modelNames.has(id)
)
);
if (missing.size) {
for (const model of missing) {
if (this.modelNames.has(model)) continue;
const provider = await this.providerFactory.getProviderByModel(model);
if (provider?.configured()) {
for (const m of provider.models) {
if (m.name) this.modelNames.set(m.id, m.name);
}
}
}
}
return {
defaultModel: prompt.model,
optionalModels: convertModels(prompt.optionalModels),
proModels: convertModels(proModels),
optionalModels: await convertModels(prompt.optionalModels),
proModels: await convertModels(proModels),
};
}
@@ -476,11 +470,20 @@ export class CopilotResolver {
@Args('sessionId') sessionId: string
): Promise<CopilotSessionType> {
await this.assertPermission(user, copilot);
const session = await this.chatSession.getSessionInfo(sessionId);
if (!session) {
const state = await this.chatSession.getMetaState(sessionId);
if (!state) {
throw new NotFoundException('Session not found');
}
return this.transformToSessionType(session);
const projected = this.historyProjector.projectSession(state, {
requestUserId: user.id,
skipVisibilityFilter: true,
});
if (!projected) {
throw new NotFoundException('Session not found');
}
return this.transformToSessionType(projected);
}
@ResolveField(() => [CopilotSessionType], {
@@ -503,12 +506,19 @@ export class CopilotResolver {
Object.assign({}, copilot, { docId: maybeDocId })
);
const sessions = await this.chatSession.list(
Object.assign({}, options, appendOptions),
false
);
const sessions = (
await this.chatSession.listMetaStates(
Object.assign({}, options, appendOptions)
)
)
.map(state =>
this.historyProjector.projectSession(state, {
requestUserId: user.id,
})
)
.filter((history): history is Omit<ChatHistory, 'messages'> => !!history);
if (appendOptions.docId) {
type Session = ChatHistory & { docId: string };
type Session = Omit<ChatHistory, 'messages'> & { docId: string };
const filtered = sessions.filter((s): s is Session => !!s.docId);
const accessible = await this.ac
.user(user.id)
@@ -537,10 +547,20 @@ export class CopilotResolver {
await this.assertPermission(user, { workspaceId, docId }, 'Doc.Read');
}
const histories = await this.chatSession.list(
Object.assign({}, options, { userId: user.id, workspaceId, docId }),
true
);
const histories = (
await this.chatSession.listStates(
Object.assign({}, options, { userId: user.id, workspaceId, docId })
)
)
.map(state =>
this.historyProjector.projectHistory(state, {
requestUserId: user.id,
withMessages: true,
withPrompt: options?.withPrompt,
action: options?.action,
})
)
.filter((history): history is ChatHistory => !!history);
return histories.map(h => ({
...h,
@@ -574,16 +594,31 @@ export class CopilotResolver {
{ skip: pagination.offset, limit: pagination.first }
);
const totalCount = await this.chatSession.count(finalOptions);
const histories = await this.chatSession.list(
finalOptions,
!!options?.withMessages
);
const histories: ChatHistory[] = options?.withMessages
? (await this.chatSession.listStates(finalOptions))
.map(state =>
this.historyProjector.projectHistory(state, {
requestUserId: user.id,
withMessages: true,
withPrompt: options?.withPrompt,
action: options?.action,
})
)
.filter((history): history is ChatHistory => !!history)
: (await this.chatSession.listMetaStates(finalOptions)).flatMap(state => {
const session = this.historyProjector.projectSession(state, {
requestUserId: user.id,
});
return session
? [{ ...session, messages: [] as ChatHistory['messages'] }]
: [];
});
return paginate(
histories.map(h => ({
...h,
// filter out empty messages
messages: h.messages?.filter(
messages: h.messages.filter(
m => m.content || m.attachments?.length
) as ChatMessageType[],
})),
@@ -639,7 +674,16 @@ export class CopilotResolver {
options: CreateChatSessionInput
): Promise<CopilotHistoriesType> {
const sessionId = await this.createCopilotSessionInternal(user, options);
const session = await this.chatSession.getSessionInfo(sessionId);
const state = await this.chatSession.getState(sessionId);
if (!state) {
throw new NotFoundException('Session not found');
}
const session = this.historyProjector.projectHistory(state, {
requestUserId: user.id,
withMessages: true,
withPrompt: false,
action: !!state.prompt.action,
});
if (!session) {
throw new NotFoundException('Session not found');
}
@@ -768,61 +812,8 @@ export class CopilotResolver {
if (!lock) {
throw new TooManyRequest('Server is busy');
}
const session = await this.chatSession.get(options.sessionId);
if (!session || session.config.userId !== user.id) {
throw new BadRequestException('Session not found');
}
const attachments: PromptMessage['attachments'] = options.attachments || [];
if (options.blob || options.blobs) {
const { workspaceId } = session.config;
const blobs = await Promise.all(
options.blob ? [options.blob] : options.blobs || []
);
delete options.blob;
delete options.blobs;
if (blobs.length) {
await this.policy.assertCanUploadBlob(user.id, workspaceId);
}
for (const blob of blobs) {
const uploaded = await this.storage.handleUpload(user.id, blob);
const detectedMime =
sniffMime(uploaded.buffer, blob.mimetype)?.toLowerCase() ||
blob.mimetype;
let attachmentBuffer = uploaded.buffer;
let attachmentMimeType = detectedMime;
if (detectedMime.startsWith('image/')) {
try {
attachmentBuffer = await processImage(
uploaded.buffer,
COPILOT_IMAGE_MAX_EDGE,
true
);
attachmentMimeType = 'image/webp';
} catch {
throw new ImageFormatNotSupported({ format: detectedMime });
}
}
const filename = createHash('sha256')
.update(attachmentBuffer)
.digest('base64url');
const attachment = await this.storage.put(
user.id,
workspaceId,
filename,
attachmentBuffer
);
attachments.push({ attachment, mimeType: attachmentMimeType });
}
}
try {
return await this.chatSession.createMessage({ ...options, attachments });
return await this.inbox.createMessage(user.id, options);
} catch (e: any) {
throw new CopilotFailedToCreateMessage(e.message);
}
@@ -886,15 +877,16 @@ export class CopilotResolver {
const markdown = docContent.markdown.trim();
// Get LLM provider
const provider =
await this.providerFactory.getProviderByModel('morph-v3-large');
if (!provider) {
const resolved = await this.providerFactory.resolveProvider({
modelId: 'morph-v3-large',
outputType: ModelOutputType.Text,
});
if (!resolved) {
throw new BadRequestException('No LLM provider available');
}
try {
return await provider.text(
return await this.runtime.text(
{ modelId: 'morph-v3-large' },
[
{
@@ -909,7 +901,7 @@ export class CopilotResolver {
throw e;
} else {
throw new CopilotProviderSideError({
provider: provider.type,
provider: resolved.provider.type,
kind: 'unexpected_response',
message: e?.message || 'Unexpected apply response',
});
@@ -917,9 +909,7 @@ export class CopilotResolver {
}
}
private transformToSessionType(
session: Omit<ChatHistory, 'messages'>
): CopilotSessionType {
private transformToSessionType(session: Omit<ChatHistory, 'messages'>) {
return { id: session.sessionId, ...session };
}
}
@@ -944,25 +934,3 @@ export class UserCopilotResolver {
return { workspaceId: workspaceId || null };
}
}
@Admin()
@Resolver(() => String)
export class PromptsManagementResolver {
constructor(private readonly cron: CopilotCronJobs) {}
@Mutation(() => Boolean, {
description: 'Trigger generate missing titles cron job',
})
async triggerGenerateTitleCron() {
await this.cron.triggerGenerateMissingTitles();
return true;
}
@Mutation(() => Boolean, {
description: 'Trigger cleanup of trashed doc embeddings',
})
async triggerCleanupTrashedDocEmbeddings() {
await this.cron.triggerCleanupTrashedDocEmbeddings();
return true;
}
}
@@ -0,0 +1,192 @@
import type { Turn } from '../core';
import type { ChatSession } from '../session';
import type { ActionRuntimeBridgeEvent } from './action-runtime-bridge';
type ProjectedAssistantTurn = {
content: string;
attachments: string[];
metadata: Record<string, unknown>;
};
type ActionResultProjector = (
result: unknown,
artifacts: unknown[]
) => ProjectedAssistantTurn;
export function summarizeActionResult(result: unknown) {
if (typeof result === 'string') {
return result.slice(0, 500);
}
if (result === undefined || result === null) {
return '';
}
return JSON.stringify(result).slice(0, 500);
}
function stringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string')
: [];
}
function attachmentUrls(value: unknown): string[] {
if (Array.isArray(value)) {
return value.flatMap(attachmentUrls);
}
if (typeof value === 'string') {
return [value];
}
if (value && typeof value === 'object' && 'url' in value) {
const url = (value as { url?: unknown }).url;
return typeof url === 'string' ? [url] : [];
}
return [];
}
function textResult(result: unknown): string {
if (typeof result === 'string') {
return result;
}
if (result && typeof result === 'object') {
const value = result as {
content?: unknown;
text?: unknown;
result?: unknown;
params?: unknown;
};
if (typeof value.content === 'string') return value.content;
if (typeof value.text === 'string') return value.text;
if (typeof value.result === 'string') return value.result;
}
throw new Error('Action result does not match text output contract');
}
function metadataFromParams(result: unknown): Record<string, unknown> {
if (!result || typeof result !== 'object') return {};
const params = (result as { params?: unknown }).params;
return params && typeof params === 'object' && !Array.isArray(params)
? (params as Record<string, unknown>)
: {};
}
function projectTextResult(result: unknown): ProjectedAssistantTurn {
return {
content: textResult(result),
attachments: stringArray(
result && typeof result === 'object'
? (result as { attachments?: unknown }).attachments
: undefined
),
metadata: metadataFromParams(result),
};
}
function projectImageResult(
result: unknown,
artifacts: unknown[]
): ProjectedAssistantTurn {
const attachments = [
...attachmentUrls(artifacts),
...attachmentUrls(
result && typeof result === 'object'
? (result as { attachments?: unknown }).attachments
: undefined
),
...attachmentUrls(result),
];
if (!attachments.length) {
throw new Error('Action result does not include image attachments');
}
const content = summarizeActionResult(result);
return {
content: typeof content === 'string' ? content : '',
attachments,
metadata: {},
};
}
function isImageAction(actionId: string) {
return actionId.startsWith('image.filter.');
}
function resolveProjector(actionId: string): ActionResultProjector | null {
if (actionId.startsWith('transcript.audio.')) {
return null;
}
if (isImageAction(actionId)) {
return projectImageResult;
}
switch (actionId) {
case 'mindmap.generate':
case 'slides.outline':
return result => projectTextResult(result);
default:
throw new Error(`No action output projector registered for ${actionId}`);
}
}
export function projectActionResultToAssistantTurn(input: {
session: ChatSession;
actionId: string;
result: unknown;
artifacts?: unknown[];
wasAborted: boolean;
}): Turn | null {
const projector = resolveProjector(input.actionId);
if (!projector) {
return null;
}
const projected = input.wasAborted
? { content: '', attachments: [], metadata: {} }
: projector(input.result, input.artifacts ?? []);
return {
conversationId: input.session.config.sessionId,
role: 'assistant',
content: projected.content,
attachments: projected.attachments,
renderTrace: [],
toolEvents: [],
metadata: projected.metadata,
createdAt: new Date(),
};
}
export type ActionChatEvent = {
type: 'event' | 'attachment' | 'message' | 'error';
id?: string;
data: string | object;
};
export function projectActionEventToChatEvent(
messageId: string | undefined,
data: ActionRuntimeBridgeEvent
): ActionChatEvent {
switch (data.type) {
case 'action_done': {
if (
data.status !== 'succeeded' ||
isImageAction(data.actionId) ||
data.result === undefined
) {
return { type: 'event', id: messageId, data };
}
return {
type: 'message',
id: messageId,
data: textResult(data.result),
};
}
case 'attachment':
return {
type: 'attachment',
id: messageId,
data: data.attachment ?? data,
};
case 'error':
return { type: 'error', id: messageId, data };
default:
return { type: 'event', id: messageId, data };
}
}
@@ -0,0 +1,346 @@
import { Injectable, Optional } from '@nestjs/common';
import { Models } from '../../../models';
import type { AiActionRunStatus } from '../../../models/copilot-action-run';
import {
type NativeActionEvent,
type NativeActionRuntimeInput,
runNativeActionRecipePreparedStream,
} from '../../../native';
import type {
CopilotImageOptions,
CopilotProviderType,
CopilotStructuredOptions,
PromptMessage,
} from '../providers/types';
import type { ChatSession } from '../session';
import {
projectActionResultToAssistantTurn,
summarizeActionResult,
} from './action-output-projector';
import {
buildStructuredResponseFromSchemaJson,
type RequiredStructuredOutputContract,
} from './contracts';
import { ExecutionPlanBuilder } from './execution-plan';
import { TurnPersistence } from './hosts/turn-persistence';
type ActionRuntimeBridgeNativeInput = Omit<
NativeActionRuntimeInput,
'recipeId' | 'recipeVersion'
>;
export type ActionRuntimeBridgeInput = {
userId: string;
workspaceId: string;
docId?: string | null;
session?: ChatSession;
userMessageId?: string | null;
compatSubmissionId?: string | null;
actionId: string;
actionVersion: string;
attempt?: number;
retryOf?: string | null;
inputSnapshot?: unknown;
nativeInput?: ActionRuntimeBridgeNativeInput;
onRunCreated?: (
context: ActionRuntimeBridgeRunContext
) => Promise<void> | void;
prepareStructuredRoutes?: {
stepId?: string;
modelId?: string;
messages: PromptMessage[];
options?: CopilotStructuredOptions;
prefer?: CopilotProviderType;
responseSchemaJson?: Record<string, unknown>;
responseContract?: RequiredStructuredOutputContract;
};
prepareImageRoutes?: {
stepId?: string;
modelId?: string;
messages: PromptMessage[];
options?: CopilotImageOptions;
prefer?: CopilotProviderType;
};
persistAttachment?: (attachment: unknown) => Promise<unknown> | unknown;
signal?: AbortSignal;
};
export type ActionRuntimeBridgeEvent = NativeActionEvent & {
runId: string;
};
export type ActionRuntimeBridgeRunContext = {
runId: string;
attempt: number;
};
function extractResultArtifacts(result: unknown) {
if (!result || typeof result !== 'object') {
return [];
}
const value = result as { artifacts?: unknown; attachments?: unknown };
if (Array.isArray(value.artifacts)) {
return value.artifacts;
}
if (Array.isArray(value.attachments)) {
return value.attachments;
}
return [];
}
function resolveFinalStatus(
event: NativeActionEvent | undefined,
signal?: AbortSignal
): Extract<AiActionRunStatus, 'succeeded' | 'failed' | 'aborted'> {
if (signal?.aborted || event?.status === 'aborted') {
return 'aborted';
}
if (event?.type === 'action_done' && event.status === 'succeeded') {
return 'succeeded';
}
return 'failed';
}
@Injectable()
export class ActionRuntimeBridge {
constructor(
private readonly models: Models,
private readonly turnPersistence: TurnPersistence,
@Optional() private readonly plans?: ExecutionPlanBuilder
) {}
protected runNativeStream(
input: NativeActionRuntimeInput,
signal?: AbortSignal
) {
return runNativeActionRecipePreparedStream(input, signal);
}
private async prepareNativeInput(
input: ActionRuntimeBridgeInput
): Promise<ActionRuntimeBridgeNativeInput & { input: unknown }> {
const nativeInput = {
...input.nativeInput,
input: input.nativeInput?.input ?? {},
};
const structured = input.prepareStructuredRoutes;
const image = input.prepareImageRoutes;
if (!structured && !image) {
return nativeInput;
}
if (!this.plans) {
throw new Error('Action route preparation is not available');
}
const state =
nativeInput.input && typeof nativeInput.input === 'object'
? { ...(nativeInput.input as Record<string, unknown>) }
: {};
if (structured) {
const responseContract =
structured.responseContract ??
(buildStructuredResponseFromSchemaJson(
structured.responseSchemaJson ?? { type: 'object' }
) as RequiredStructuredOutputContract);
const plan = await this.plans.buildStructuredPlan(
{ modelId: structured.modelId },
structured.messages,
structured.options,
structured.prefer ? { prefer: structured.prefer } : undefined,
responseContract
);
const preparedRoutes = plan.nativeDispatch?.structured?.routes;
if (!preparedRoutes?.length) {
throw new Error('No native structured provider route prepared');
}
const existingPreparedRoutes =
state.preparedRoutes &&
typeof state.preparedRoutes === 'object' &&
!Array.isArray(state.preparedRoutes)
? (state.preparedRoutes as Record<string, unknown>)
: {};
state.preparedRoutes = {
...existingPreparedRoutes,
[structured.stepId ?? 'generate']: preparedRoutes,
};
}
if (image) {
const plan = await this.plans.buildImagePlan(
{ modelId: image.modelId },
image.messages,
image.options,
image.prefer ? { prefer: image.prefer } : undefined
);
const preparedRoutes = plan.nativeDispatch?.image?.routes;
if (!preparedRoutes?.length) {
throw new Error('No native image provider route prepared');
}
const existingPreparedRoutes =
state.preparedRoutes &&
typeof state.preparedRoutes === 'object' &&
!Array.isArray(state.preparedRoutes)
? (state.preparedRoutes as Record<string, unknown>)
: {};
state.preparedRoutes = {
...existingPreparedRoutes,
[image.stepId ?? 'generate-image']: preparedRoutes,
};
}
return {
...nativeInput,
input: state,
};
}
private async projectAssistantResult(
input: ActionRuntimeBridgeInput,
result: unknown,
artifacts: unknown[],
wasAborted: boolean
) {
if (!input.session) return null;
const turn = projectActionResultToAssistantTurn({
session: input.session,
actionId: input.actionId,
result,
artifacts,
wasAborted,
});
if (!turn) return null;
return await this.turnPersistence.persistProjectedResult(
input.session,
turn,
wasAborted
);
}
private async resolveAttempt(input: ActionRuntimeBridgeInput) {
if (!input.retryOf) {
return input.attempt ?? 1;
}
const previous = await this.models.copilotActionRun.get(input.retryOf);
if (!previous) {
throw new Error('Retry source action run not found');
}
if (
previous.userId !== input.userId ||
previous.workspaceId !== input.workspaceId ||
previous.actionId !== input.actionId ||
previous.actionVersion !== input.actionVersion ||
previous.sessionId !== (input.session?.config.sessionId ?? null)
) {
throw new Error('Retry source action run does not match current action');
}
if (input.attempt && input.attempt <= previous.attempt) {
throw new Error('Retry attempt must be greater than source action run');
}
if (input.attempt) {
return input.attempt;
}
return (previous?.attempt ?? 1) + 1;
}
async *runStream(
input: ActionRuntimeBridgeInput
): AsyncIterableIterator<ActionRuntimeBridgeEvent> {
const attempt = await this.resolveAttempt(input);
const run = await this.models.copilotActionRun.create({
userId: input.userId,
workspaceId: input.workspaceId,
docId: input.docId,
sessionId: input.session?.config.sessionId,
userMessageId: input.userMessageId,
compatSubmissionId: input.compatSubmissionId,
actionId: input.actionId,
actionVersion: input.actionVersion,
attempt,
retryOf: input.retryOf,
inputSnapshot: input.inputSnapshot,
});
await this.models.copilotActionRun.markRunning(run.id);
await input.onRunCreated?.({
runId: run.id,
attempt,
});
let finalEvent: NativeActionEvent | undefined;
const attachments: unknown[] = [];
try {
const nativeInput = await this.prepareNativeInput({
...input,
});
for await (const event of this.runNativeStream(
{
...nativeInput,
recipeId: input.actionId,
recipeVersion: input.actionVersion,
},
input.signal
)) {
finalEvent = event;
let projectedEvent = event;
if (event.type === 'attachment') {
const attachment = input.persistAttachment
? await input.persistAttachment(event.attachment)
: event.attachment;
attachments.push(attachment);
projectedEvent = { ...event, attachment };
}
yield { ...projectedEvent, runId: run.id };
}
} catch (error) {
finalEvent = {
type: 'error',
actionId: input.actionId,
actionVersion: input.actionVersion,
status: input.signal?.aborted ? 'aborted' : 'failed',
errorCode: input.signal?.aborted
? 'action_aborted'
: 'action_bridge_stream_error',
errorMessage:
error instanceof Error ? error.message : 'action stream failed',
};
yield { ...finalEvent, runId: run.id };
} finally {
let status = resolveFinalStatus(finalEvent, input.signal);
const result = finalEvent?.result;
const artifacts =
status === 'succeeded'
? [...attachments, ...extractResultArtifacts(result)]
: undefined;
let assistantMessageId: string | null = null;
let errorCode = status === 'succeeded' ? null : finalEvent?.errorCode;
if (status === 'succeeded' || status === 'aborted') {
try {
assistantMessageId =
(await this.projectAssistantResult(
input,
result,
artifacts ?? [],
status === 'aborted'
)) ?? null;
} catch {
status = 'failed';
errorCode = 'action_output_projection_failed';
}
}
await this.models.copilotActionRun.complete(run.id, {
status,
result: status === 'succeeded' ? result : undefined,
artifacts: status === 'succeeded' ? artifacts : undefined,
resultSummary:
status === 'succeeded' ? summarizeActionResult(result) : null,
errorCode,
trace: finalEvent?.trace ?? undefined,
assistantMessageId,
});
}
}
}
@@ -0,0 +1,209 @@
import { Injectable } from '@nestjs/common';
import { CopilotPromptInvalid } from '../../../base';
import { ValidatedStructuredValueSchema } from '../core';
import {
type CopilotChatOptions,
type CopilotEmbeddingOptions,
type CopilotImageOptions,
type CopilotProviderType,
type CopilotRerankRequest,
type CopilotStructuredOptions,
type ModelConditions,
type PromptMessage,
type StreamObject,
} from '../providers/types';
import {
type RequiredStructuredOutputContract,
requireStructuredOutputContract,
} from './contracts';
import {
ExecutionPlanBuilder,
type ExecutionPlanForKind,
} from './execution-plan';
import {
NativeExecutionEngine,
type NativeImageArtifact,
} from './native-execution-engine';
type ProviderFilter = {
prefer?: CopilotProviderType;
};
const providerModelId = (modelId?: string) => modelId ?? 'auto';
@Injectable()
export class CapabilityRuntime {
constructor(
private readonly plans: ExecutionPlanBuilder,
private readonly engine: NativeExecutionEngine
) {}
private async executePlan<TPlan, TResult>(
build: () => Promise<TPlan>,
execute: (plan: TPlan) => Promise<TResult>
) {
return await execute(await build());
}
private executeStreamPlan<TPlan, TChunk>(
build: () => Promise<TPlan>,
execute: (plan: TPlan) => AsyncIterableIterator<TChunk>
): AsyncIterableIterator<TChunk> {
return (async function* () {
yield* execute(await build());
})();
}
private hasNativeDispatch(
plan: ExecutionPlanForKind<'embedding'> | ExecutionPlanForKind<'rerank'>,
kind: 'embedding' | 'rerank'
) {
return !!plan.nativeDispatch?.[kind];
}
async text(
cond: ModelConditions,
messages: PromptMessage[],
options?: CopilotChatOptions,
filter?: ProviderFilter
) {
return await this.executePlan(
() => this.plans.buildTextPlan(cond, messages, options, filter),
plan => this.engine.execute(plan)
);
}
async *streamText(
cond: ModelConditions,
messages: PromptMessage[],
options?: CopilotChatOptions,
filter?: ProviderFilter
): AsyncIterableIterator<string> {
yield* this.executeStreamPlan(
() => this.plans.buildStreamTextPlan(cond, messages, options, filter),
plan => this.engine.executeStream(plan)
);
}
async *streamObject(
cond: ModelConditions,
messages: PromptMessage[],
options?: CopilotChatOptions,
filter?: ProviderFilter
): AsyncIterableIterator<StreamObject> {
yield* this.executeStreamPlan(
() => this.plans.buildStreamObjectPlan(cond, messages, options, filter),
plan => this.engine.executeStream(plan)
);
}
async generateStructured(
cond: ModelConditions,
messages: PromptMessage[],
options?: CopilotStructuredOptions,
filter?: ProviderFilter,
responseContract?: RequiredStructuredOutputContract
) {
return await this.executePlan(
() =>
this.plans.buildStructuredPlan(
cond,
messages,
options,
filter,
responseContract
),
plan => this.engine.execute(plan)
);
}
async generateStructuredValue(
cond: ModelConditions,
messages: PromptMessage[],
options: CopilotStructuredOptions,
responseContract?: RequiredStructuredOutputContract,
filter?: ProviderFilter
) {
const validatedResponseContract =
requireStructuredOutputContract(responseContract);
if (!options || !validatedResponseContract) {
throw new CopilotPromptInvalid('Structured schema contract is required');
}
const output = await this.generateStructured(
cond,
messages,
options,
filter,
validatedResponseContract
);
const value = JSON.parse(output);
return ValidatedStructuredValueSchema.parse({
value,
schemaHash: validatedResponseContract.schemaHash,
schemaValidationVersion: 'json-schema-v1',
provider: filter?.prefer ?? 'auto',
model: providerModelId(cond.modelId),
});
}
async embeddingConfigured(modelId: string) {
try {
return this.hasNativeDispatch(
await this.plans.buildEmbeddingPlan(modelId, 'ping'),
'embedding'
);
} catch {
return false;
}
}
async embed(
modelId: string,
input: string | string[],
options?: CopilotEmbeddingOptions
) {
return await this.executePlan(
() => this.plans.buildEmbeddingPlan(modelId, input, options),
plan => this.engine.execute(plan)
);
}
async rerankConfigured(modelId: string) {
try {
return this.hasNativeDispatch(
await this.plans.buildRerankPlan(modelId, {
query: 'ping',
candidates: [{ text: 'ping' }],
}),
'rerank'
);
} catch {
return false;
}
}
async rerank(
modelId: string,
request: CopilotRerankRequest,
options?: CopilotChatOptions
) {
return await this.executePlan(
() => this.plans.buildRerankPlan(modelId, request, options),
plan => this.engine.execute(plan)
);
}
async *streamImageArtifacts(
cond: ModelConditions,
messages: PromptMessage[],
options?: CopilotImageOptions,
filter?: ProviderFilter
): AsyncIterableIterator<NativeImageArtifact> {
yield* this.executeStreamPlan(
() => this.plans.buildImagePlan(cond, messages, options, filter),
plan => this.engine.executeImageArtifacts(plan)
);
}
}
@@ -0,0 +1,104 @@
import {
type LlmBackendConfig,
llmCompileExecutionPlan,
type LlmEmbeddingRequest,
type LlmImageRequest,
type LlmProtocol,
type LlmRequest,
type LlmRerankRequest,
type LlmStructuredRequest,
} from '../../../../native';
import type {
CopilotProviderType,
ModelConditions,
PromptMessage,
} from '../../providers/types';
// Owner: runtime core mirror facade.
// The semantic source of truth is the native/Rust execution-plan contract
// behind llmCompileExecutionPlan(); this file only keeps the TypeScript shape
// needed by Node live-plan assembly until generated/native TS types replace it.
export type ExecutionRequestKind =
| 'text'
| 'streamText'
| 'streamObject'
| 'structured'
| 'embedding'
| 'rerank'
| 'image';
export type ExecutionRoute = {
providerId: string;
protocol: LlmProtocol;
model: string;
backendConfig: LlmBackendConfig;
};
export type ExecutionTransportContract =
| { kind: 'chat'; request: LlmRequest }
| { kind: 'structured'; request: LlmStructuredRequest }
| { kind: 'embedding'; request: LlmEmbeddingRequest }
| { kind: 'rerank'; request: LlmRerankRequest }
| { kind: 'image'; request: LlmImageRequest };
export type SerializableExecutionPlanRequest =
| {
kind: 'text' | 'streamText' | 'streamObject';
cond: ModelConditions;
messages: PromptMessage[];
options?: Record<string, unknown>;
}
| {
kind: 'structured';
cond: ModelConditions;
messages: PromptMessage[];
options?: Record<string, unknown>;
}
| {
kind: 'image';
cond: ModelConditions;
messages: PromptMessage[];
options?: Record<string, unknown>;
}
| {
kind: 'embedding';
cond: ModelConditions;
modelId: string;
input: string | string[];
options?: Record<string, unknown>;
}
| {
kind: 'rerank';
cond: ModelConditions;
modelId: string;
request: {
query: string;
candidates: { id?: string; text: string }[];
topK?: number;
};
options?: Record<string, unknown>;
};
export type SerializableExecutionPlan = {
routes: ExecutionRoute[];
request: SerializableExecutionPlanRequest;
transport?: ExecutionTransportContract;
routePolicy: { fallbackOrder: string[] };
runtimePolicy: {
prefer?: CopilotProviderType;
maxSteps?: number;
};
attachmentPolicy: {
materializeRemoteAttachments: boolean;
};
responsePostprocess: {
mode: ExecutionRequestKind;
};
hostContext?: {
currentMessages?: PromptMessage[];
};
};
export function parseExecutionPlan(value: unknown) {
return llmCompileExecutionPlan<SerializableExecutionPlan>(value);
}
@@ -0,0 +1,7 @@
export * from './execution-plan-contract';
export * from './native-contract';
export * from './prompt-contract';
export * from './runtime-event-contract';
export * from './shared';
export * from './structured-output-contract';
export * from './tool-contract';
@@ -0,0 +1,97 @@
import serverNativeModule, {
type CapabilityMatchRequest,
type CapabilityMatchResponse,
type ModelRegistryMatchRequest,
type ModelRegistryMatchResponse,
type ModelRegistryResolveRequest,
type ModelRegistryResolveResponse,
type ModelRegistryVariantContract,
type ProviderDriverSpec,
type RequestedModelMatchRequest,
type RequestedModelMatchResponse,
} from '@affine/server-native';
// Owner: native/Rust contract facade.
// These types and validators intentionally proxy @affine/server-native and
// must not grow independent runtime semantics in Node.
export type {
CapabilityMatchRequest,
CapabilityMatchResponse,
ProviderDriverSpec,
RequestedModelMatchRequest,
RequestedModelMatchResponse,
};
export type CopilotModelBackendKind = ModelRegistryMatchRequest['backendKind'];
export type ModelRegistryVariant = ModelRegistryVariantContract;
export type ResolveModelRegistryVariantRequest = ModelRegistryResolveRequest;
export type ResolveModelRegistryVariantResponse = ModelRegistryResolveResponse;
export type MatchModelRegistryRequest = ModelRegistryMatchRequest;
export type MatchModelRegistryResponse = ModelRegistryMatchResponse;
function validateNativeContract<T>(name: string, value: unknown): T {
return serverNativeModule.llmValidateContract(name, value) as T;
}
export function parseCapabilityMatchRequest(value: unknown) {
return validateNativeContract<CapabilityMatchRequest>(
'capabilityMatchRequest',
value
);
}
export function parseCapabilityMatchResponse(value: unknown) {
return validateNativeContract<CapabilityMatchResponse>(
'capabilityMatchResponse',
value
);
}
export function parseResolveModelRegistryVariantRequest(value: unknown) {
return validateNativeContract<ResolveModelRegistryVariantRequest>(
'modelRegistryResolveRequest',
value
);
}
export function parseResolveModelRegistryVariantResponse(value: unknown) {
return validateNativeContract<ResolveModelRegistryVariantResponse>(
'modelRegistryResolveResponse',
value
);
}
export function parseMatchModelRegistryRequest(value: unknown) {
return validateNativeContract<MatchModelRegistryRequest>(
'modelRegistryMatchRequest',
value
);
}
export function parseMatchModelRegistryResponse(value: unknown) {
return validateNativeContract<MatchModelRegistryResponse>(
'modelRegistryMatchResponse',
value
);
}
export function parseProviderDriverSpec(value: unknown) {
return validateNativeContract<ProviderDriverSpec>(
'providerDriverSpec',
value
);
}
export function parseRequestedModelMatchRequest(value: unknown) {
return validateNativeContract<RequestedModelMatchRequest>(
'requestedModelMatchRequest',
value
);
}
export function parseRequestedModelMatchResponse(value: unknown) {
return validateNativeContract<RequestedModelMatchResponse>(
'requestedModelMatchResponse',
value
);
}
@@ -0,0 +1,98 @@
import {
llmValidateContract,
type NativePromptCountTokensRequest,
type NativePromptCountTokensResponse,
type NativePromptMetadataRequest,
type NativePromptMetadataResponse,
type NativePromptRenderRequest,
type NativePromptRenderResponse,
type NativePromptSessionRenderRequest,
type NativePromptSessionRenderResponse,
type PromptMessageContract as NativePromptMessageContract,
type PromptStructuredResponseContract as NativePromptStructuredResponseContract,
} from '../../../../native';
import { normalizePromptResponseFormat } from './structured-output-contract';
// Owner: native/Rust prompt contract facade plus Node responseFormat projection.
// Prompt/message/attachment semantics belong to adapter/native contracts; this
// file keeps only TypeScript aliases and host compatibility projection helpers.
export type PromptStructuredResponseContract =
NativePromptStructuredResponseContract;
export type PromptResponseFormat = {
type: 'json_schema';
responseSchemaJson?: Record<string, unknown>;
schemaHash?: string;
strict?: boolean;
};
export type PromptMessageContract = NativePromptMessageContract;
type PromptMessageInput = {
role: PromptMessageContract['role'];
content: string;
attachments?: unknown[] | null;
params?: Record<string, unknown> | null;
responseFormat?: PromptResponseFormat | null;
};
export type PromptRenderContract = NativePromptRenderRequest;
export type PromptRenderResult = NativePromptRenderResponse;
export type PromptTokenCountContract = NativePromptCountTokensRequest;
export type PromptTokenCountResult = NativePromptCountTokensResponse;
export type PromptMetadataContract = NativePromptMetadataRequest;
export type PromptMetadataResult = NativePromptMetadataResponse;
export type PromptSessionContract = NativePromptSessionRenderRequest;
export type PromptSessionResult = NativePromptSessionRenderResponse;
export type NativePromptResponseFormatProjection = {
nativeResponseFormat?: PromptStructuredResponseContract;
};
export type NativePromptMessageProjection = {
message: PromptMessageContract;
nativeResponseFormat?: PromptStructuredResponseContract;
};
export function projectPromptResponseFormatForNative(
responseFormat?: PromptResponseFormat | null
): NativePromptResponseFormatProjection {
const { nativeResponseFormat } =
normalizePromptResponseFormat(responseFormat);
return {
nativeResponseFormat,
};
}
export function projectPromptMessageForNative(
message: PromptMessageInput
): NativePromptMessageProjection {
const { nativeResponseFormat } = projectPromptResponseFormatForNative(
message.responseFormat
);
const nativeMessage: PromptMessageContract = {
role: message.role,
content: message.content,
...(message.attachments
? {
attachments:
message.attachments as PromptMessageContract['attachments'],
}
: {}),
...(message.params
? { params: message.params as PromptMessageContract['params'] }
: {}),
...(nativeResponseFormat ? { responseFormat: nativeResponseFormat } : {}),
};
return { message: nativeMessage, nativeResponseFormat };
}
export function parsePromptRenderContract(value: unknown) {
return llmValidateContract<PromptRenderContract>(
'promptRenderContract',
value
);
}
export function parsePromptSessionContract(value: unknown) {
return llmValidateContract<PromptSessionContract>(
'promptSessionContract',
value
);
}
@@ -0,0 +1,174 @@
import { z } from 'zod';
import { NonEmptyStringSchema, parseToolLoopStreamEvent } from './shared';
// Owner: app-facing stream projection.
// Native/runtime owns incoming tool-loop event validation; this file owns the
// GraphQL/SSE-facing stream object shape and projection helpers only.
export const TextDeltaStreamObjectSchema = z
.object({
type: z.literal('text-delta'),
textDelta: z.string(),
})
.strict();
export const ReasoningStreamObjectSchema = z
.object({
type: z.literal('reasoning'),
textDelta: z.string(),
})
.strict();
export const ToolCallStreamObjectSchema = z
.object({
type: z.literal('tool-call'),
toolCallId: NonEmptyStringSchema,
toolName: NonEmptyStringSchema,
args: z.record(z.unknown()),
rawArgumentsText: z.string().optional(),
argumentParseError: z.string().optional(),
thought: z.string().optional(),
})
.strict();
export const ToolResultStreamObjectSchema = z
.object({
type: z.literal('tool-result'),
toolCallId: NonEmptyStringSchema,
toolName: NonEmptyStringSchema,
args: z.record(z.unknown()),
result: z.unknown(),
rawArgumentsText: z.string().optional(),
argumentParseError: z.string().optional(),
})
.strict();
export const StreamObjectSchema = z.discriminatedUnion('type', [
TextDeltaStreamObjectSchema,
ReasoningStreamObjectSchema,
ToolCallStreamObjectSchema,
ToolResultStreamObjectSchema,
]);
export type StreamObject = z.infer<typeof StreamObjectSchema>;
export const ToolCallEventSchema = z
.object({
type: z.literal('tool_call'),
toolCallId: NonEmptyStringSchema,
toolName: NonEmptyStringSchema,
args: z.record(z.unknown()),
rawArgumentsText: z.string().optional(),
argumentParseError: z.string().optional(),
thought: z.string().optional(),
})
.strict();
export const ToolResultEventSchema = z
.object({
type: z.literal('tool_result'),
toolCallId: NonEmptyStringSchema,
toolName: NonEmptyStringSchema,
args: z.record(z.unknown()),
result: z.unknown(),
rawArgumentsText: z.string().optional(),
argumentParseError: z.string().optional(),
})
.strict();
export const ToolEventSchema = z.discriminatedUnion('type', [
ToolCallEventSchema,
ToolResultEventSchema,
]);
export type ToolEvent = z.infer<typeof ToolEventSchema>;
export function projectRuntimeEventToStreamObject(
value: unknown
): StreamObject | null {
const event = parseToolLoopStreamEvent(value);
switch (event.type) {
case 'text_delta': {
return { type: 'text-delta', textDelta: event.text };
}
case 'reasoning_delta': {
return { type: 'reasoning', textDelta: event.text };
}
case 'tool_call': {
return {
type: 'tool-call',
toolCallId: event.call_id,
toolName: event.name,
args: event.arguments,
rawArgumentsText: event.arguments_text,
argumentParseError: event.arguments_error,
thought: event.thought,
};
}
case 'tool_result': {
return {
type: 'tool-result',
toolCallId: event.call_id,
toolName: event.name,
args: event.arguments,
result: event.output,
rawArgumentsText: event.arguments_text,
argumentParseError: event.arguments_error,
};
}
default:
return null;
}
}
export function streamObjectToToolEvent(
streamObject: StreamObject
): ToolEvent | undefined {
switch (streamObject.type) {
case 'tool-call':
return {
type: 'tool_call',
toolCallId: streamObject.toolCallId,
toolName: streamObject.toolName,
args: streamObject.args,
rawArgumentsText: streamObject.rawArgumentsText,
argumentParseError: streamObject.argumentParseError,
thought: streamObject.thought,
};
case 'tool-result':
return {
type: 'tool_result',
toolCallId: streamObject.toolCallId,
toolName: streamObject.toolName,
args: streamObject.args,
result: streamObject.result,
rawArgumentsText: streamObject.rawArgumentsText,
argumentParseError: streamObject.argumentParseError,
};
default:
return;
}
}
export function toolEventToStreamObject(event: ToolEvent): StreamObject {
return event.type === 'tool_call'
? {
type: 'tool-call',
toolCallId: event.toolCallId,
toolName: event.toolName,
args: event.args,
rawArgumentsText: event.rawArgumentsText,
argumentParseError: event.argumentParseError,
thought: event.thought,
}
: {
type: 'tool-result',
toolCallId: event.toolCallId,
toolName: event.toolName,
args: event.args,
result: event.result,
rawArgumentsText: event.rawArgumentsText,
argumentParseError: event.argumentParseError,
};
}
@@ -0,0 +1,51 @@
import serverNativeModule from '@affine/server-native';
import { z } from 'zod';
import type { LlmToolLoopStreamEvent } from '../../../../native';
// Owner: Node compatibility helpers.
// JsonValue/NonEmptyString support host Zod schemas; ToolLoopStreamEvent is
// validated by the native/runtime contract via llmValidateContract().
const JsonPrimitiveSchema = z.union([
z.string(),
z.number(),
z.boolean(),
z.null(),
]);
export type JsonValue =
| string
| number
| boolean
| null
| JsonValue[]
| { [key: string]: JsonValue };
export const JsonValueSchema: z.ZodType<JsonValue> = z.lazy(() =>
z.union([
JsonPrimitiveSchema,
z.array(JsonValueSchema),
z.record(JsonValueSchema),
])
);
export const JsonObjectSchema = z.record(JsonValueSchema);
export const NonEmptyStringSchema = z.string().trim().min(1);
export const ToolDefinitionBaseSchema = z
.object({
name: NonEmptyStringSchema,
description: z.string().optional(),
parameters: JsonObjectSchema,
})
.strict();
export type ToolLoopStreamEvent = LlmToolLoopStreamEvent;
export function parseToolLoopStreamEvent(value: unknown) {
return serverNativeModule.llmValidateContract(
'toolLoopEvent',
value
) as LlmToolLoopStreamEvent;
}
@@ -0,0 +1,205 @@
import { llmCanonicalJsonSchemaHash } from '../../../../native';
import { toToolJsonSchema } from '../../tools/json-schema';
import type { JsonValue } from './shared';
// Owner: tool-authoring compatibility plus native schema hash facade.
// Zod-to-JSON-Schema conversion stays in Node for tool authoring, but canonical
// schema hashing is delegated to Rust so JSON key order is not semantic.
export type StructuredOutputValidator = {
parse(input: unknown): unknown;
safeParse(input: unknown): unknown;
};
export type StructuredOutputContract = {
responseSchemaJson?: Record<string, unknown>;
schemaHash?: string;
strict?: boolean;
};
export type RequiredStructuredOutputContract = StructuredOutputContract & {
responseSchemaJson: Record<string, unknown>;
schemaHash: string;
};
type StructuredResponseFormatLike = {
type?: string | null;
responseSchemaJson?: Record<string, unknown>;
schemaHash?: string;
strict?: boolean;
} | null;
type StructuredResponseFormatProjection = {
nativeResponseFormat?: {
type: 'json_schema';
responseSchemaJson: Record<string, JsonValue>;
schemaHash: string;
strict?: boolean;
};
hostResponseFormat?: {
type: 'json_schema';
responseSchemaJson?: Record<string, unknown>;
schemaHash?: string;
strict?: boolean;
};
};
export type StructuredOutputContractFields = Pick<
StructuredOutputContract,
'responseSchemaJson' | 'schemaHash' | 'strict'
>;
export function buildStructuredResponseFromSchemaJson(
responseSchemaJson?: Record<string, unknown>
): StructuredOutputContract {
if (!responseSchemaJson) return {};
const schemaHash = ensurePromptResponseSchemaHash(responseSchemaJson);
return { responseSchemaJson, schemaHash };
}
export function ensurePromptResponseSchemaHash(
schemaJson?: Record<string, unknown>,
schemaHash?: string
) {
if (schemaHash || !schemaJson) return schemaHash;
return llmCanonicalJsonSchemaHash(schemaJson);
}
export function isStructuredOutputValidator(
schema: unknown
): schema is StructuredOutputValidator {
return (
!!schema &&
typeof schema === 'object' &&
'parse' in schema &&
typeof schema.parse === 'function' &&
'safeParse' in schema &&
typeof schema.safeParse === 'function'
);
}
export function buildStructuredResponseContract(
schema?: unknown
): StructuredOutputContract {
if (isStructuredOutputValidator(schema)) {
return buildStructuredResponseFromSchemaJson(
toToolJsonSchema(schema) as Record<string, JsonValue>
);
}
if (schema && typeof schema === 'object' && !Array.isArray(schema)) {
return buildStructuredResponseFromSchemaJson(
schema as Record<string, JsonValue>
);
}
return {};
}
export function buildPromptStructuredResponseFromFields(
fields?: StructuredOutputContractFields | null
): StructuredOutputContract | undefined {
if (!fields) {
return;
}
const { responseSchemaJson, schemaHash, strict } = fields;
const normalizedSchemaHash = ensurePromptResponseSchemaHash(
responseSchemaJson,
schemaHash
);
if (!responseSchemaJson) {
return;
}
return {
...(responseSchemaJson ? { responseSchemaJson } : {}),
...(normalizedSchemaHash ? { schemaHash: normalizedSchemaHash } : {}),
...(strict !== undefined ? { strict } : {}),
};
}
export function buildPromptStructuredResponseContractFromResponseFormat(
responseFormat?: StructuredResponseFormatLike
): StructuredOutputContract | undefined {
if (responseFormat?.type !== 'json_schema') {
return;
}
const responseSchemaJson = responseFormat.responseSchemaJson;
const schemaHash = ensurePromptResponseSchemaHash(
responseSchemaJson,
responseFormat.schemaHash
);
if (!responseSchemaJson) {
return;
}
return {
...(responseSchemaJson ? { responseSchemaJson } : {}),
...(schemaHash ? { schemaHash } : {}),
...(responseFormat.strict !== undefined
? { strict: responseFormat.strict }
: {}),
};
}
export function normalizePromptResponseFormat(
responseFormat?: StructuredResponseFormatLike
): StructuredResponseFormatProjection {
if (responseFormat?.type !== 'json_schema') {
return {};
}
const contract =
buildPromptStructuredResponseContractFromResponseFormat(responseFormat);
if (!contract) {
return {};
}
const nextNativeResponseFormat =
contract.responseSchemaJson && contract.schemaHash
? {
type: 'json_schema' as const,
responseSchemaJson: contract.responseSchemaJson as Record<
string,
JsonValue
>,
schemaHash: contract.schemaHash,
...(responseFormat.strict !== undefined
? { strict: responseFormat.strict }
: {}),
}
: undefined;
const nextHostResponseFormat = {
type: 'json_schema' as const,
...(contract.responseSchemaJson
? { responseSchemaJson: contract.responseSchemaJson }
: {}),
...(contract.schemaHash ? { schemaHash: contract.schemaHash } : {}),
...(responseFormat.strict !== undefined
? { strict: responseFormat.strict }
: {}),
};
return {
nativeResponseFormat: nextNativeResponseFormat,
hostResponseFormat: contract.responseSchemaJson
? nextHostResponseFormat
: undefined,
};
}
export function requireStructuredOutputContract(
contract?: StructuredOutputContract
): RequiredStructuredOutputContract | undefined {
if (!contract?.responseSchemaJson || !contract.schemaHash) {
return;
}
return {
responseSchemaJson: contract.responseSchemaJson,
schemaHash: contract.schemaHash,
...(contract.strict !== undefined ? { strict: contract.strict } : {}),
};
}
@@ -0,0 +1,37 @@
import { z } from 'zod';
import type { CopilotToolSet } from '../../tools';
import { ensureToolJsonSchema } from '../../tools/tool';
import { ToolDefinitionBaseSchema } from './shared';
// Owner: tool authoring facade over runtime-owned callback contracts.
// Tool definitions are built from Node-hosted tools; callback request/response
// values are still validated against the runtime/native schema at the boundary.
export const ToolContractSchema = ToolDefinitionBaseSchema;
export type ToolContract = z.infer<typeof ToolContractSchema>;
export interface ToolCallRequest {
callId: string;
name: string;
args: Record<string, unknown>;
rawArgumentsText?: string;
argumentParseError?: string;
}
export interface ToolCallResult extends ToolCallRequest {
output: unknown;
isError?: boolean;
}
export function parseToolContract(value: unknown) {
return ToolContractSchema.parse(value);
}
export function buildToolContracts(toolSet: CopilotToolSet): ToolContract[] {
return Object.entries(toolSet).map(([name, tool]) =>
parseToolContract({
name,
description: tool.description,
parameters: ensureToolJsonSchema(tool, name),
})
);
}
@@ -0,0 +1,65 @@
import { Injectable } from '@nestjs/common';
import { metrics } from '../../../base';
import type { ResolvedCopilotProvider } from '../providers/factory';
import type { CopilotProviderType } from '../providers/types';
import type { ExecutionRequestKind } from './execution-plan';
type ExecutionDispatchPath = 'prepared_routes';
export function summarizePreparedRoutes(
routes: Array<Pick<ResolvedCopilotProvider, 'prepared'>>
) {
const preparedCount = routes.filter(route => !!route.prepared).length;
return {
routeCount: routes.length,
preparedCount,
preparedMode:
preparedCount === 0
? 'none'
: preparedCount === routes.length
? 'all'
: 'partial',
} as const;
}
function planAttrs(
kind: ExecutionRequestKind,
prefer?: CopilotProviderType,
routes?: ResolvedCopilotProvider[]
) {
const summary = summarizePreparedRoutes(routes ?? []);
return {
kind,
prefer: prefer ?? 'auto',
prepared: summary.preparedMode,
route_count: summary.routeCount,
};
}
@Injectable()
export class CopilotExecutionMetrics {
recordPlan(
kind: ExecutionRequestKind,
routes: ResolvedCopilotProvider[],
prefer?: CopilotProviderType
) {
const attrs = planAttrs(kind, prefer, routes);
metrics.ai.counter('execution_plan_total').add(1, attrs);
metrics.ai.histogram('execution_plan_routes').record(attrs.route_count, {
kind: attrs.kind,
prefer: attrs.prefer,
prepared: attrs.prepared,
});
}
recordDispatch(
kind: ExecutionRequestKind,
path: ExecutionDispatchPath,
routeCount: number
) {
const attrs = { kind, path };
metrics.ai.counter('execution_dispatch_total').add(1, attrs);
metrics.ai.histogram('execution_dispatch_routes').record(routeCount, attrs);
}
}
@@ -0,0 +1,826 @@
import { Injectable } from '@nestjs/common';
import type {
LlmPreparedDispatchRoute,
LlmPreparedEmbeddingDispatchRoute,
LlmPreparedImageDispatchRoute,
LlmPreparedRerankDispatchRoute,
LlmPreparedStructuredDispatchRoute,
} from '../../../native';
import { llmNormalizePreparedRoutes } from '../../../native';
import {
CopilotProviderFactory,
type ResolvedCopilotProvider,
} from '../providers/factory';
import type {
PreparedNativeEmbeddingExecution,
PreparedNativeExecution,
PreparedNativeImageExecution,
PreparedNativeRerankExecution,
PreparedNativeStructuredExecution,
} from '../providers/provider-runtime-contract';
import type {
CopilotChatOptions,
CopilotEmbeddingOptions,
CopilotImageOptions,
CopilotProviderType,
CopilotRerankRequest,
CopilotStructuredOptions,
ModelConditions,
PromptMessage,
} from '../providers/types';
import { ModelOutputType } from '../providers/types';
import type { RequiredStructuredOutputContract } from './contracts';
import {
type ExecutionRequestKind,
type ExecutionRoute,
type ExecutionTransportContract,
parseExecutionPlan,
type SerializableExecutionPlan,
type SerializableExecutionPlanRequest,
} from './contracts/execution-plan-contract';
import { CopilotExecutionMetrics } from './execution-metrics';
export type { ExecutionRequestKind };
type ProviderFilter = {
prefer?: CopilotProviderType;
};
type BaseExecutionRequest<TKind extends ExecutionRequestKind> = {
kind: TKind;
cond: ModelConditions;
};
type TextExecutionRequest = BaseExecutionRequest<'text'> & {
messages: PromptMessage[];
options?: CopilotChatOptions;
};
type StreamTextExecutionRequest = BaseExecutionRequest<'streamText'> & {
messages: PromptMessage[];
options?: CopilotChatOptions;
};
type StreamObjectExecutionRequest = BaseExecutionRequest<'streamObject'> & {
messages: PromptMessage[];
options?: CopilotChatOptions;
};
type StructuredExecutionRequest = BaseExecutionRequest<'structured'> & {
messages: PromptMessage[];
options?: CopilotStructuredOptions;
};
type ImageExecutionRequest = BaseExecutionRequest<'image'> & {
messages: PromptMessage[];
options?: CopilotImageOptions;
};
type EmbeddingExecutionRequest = BaseExecutionRequest<'embedding'> & {
modelId: string;
input: string | string[];
options?: CopilotEmbeddingOptions;
};
type RerankExecutionRequest = BaseExecutionRequest<'rerank'> & {
modelId: string;
request: CopilotRerankRequest;
options?: CopilotChatOptions;
};
export type ExecutionPlanRequest =
| TextExecutionRequest
| StreamTextExecutionRequest
| StreamObjectExecutionRequest
| StructuredExecutionRequest
| ImageExecutionRequest
| EmbeddingExecutionRequest
| RerankExecutionRequest;
export type ExecutionPlanForKind<TKind extends ExecutionRequestKind> =
ExecutionPlan & {
request: Extract<ExecutionPlanRequest, { kind: TKind }>;
};
type NativePreparedDispatchPlan<TRoute, TPrepared> = {
routes: TRoute[];
prepared: TPrepared;
};
export type NativeChatDispatchPlan = NativePreparedDispatchPlan<
LlmPreparedDispatchRoute,
PreparedNativeExecution
> & {
hasTools: boolean;
};
export type NativeStructuredDispatchPlan = NativePreparedDispatchPlan<
LlmPreparedStructuredDispatchRoute,
PreparedNativeStructuredExecution
>;
export type NativeEmbeddingDispatchPlan = NativePreparedDispatchPlan<
LlmPreparedEmbeddingDispatchRoute,
PreparedNativeEmbeddingExecution
>;
export type NativeRerankDispatchPlan = NativePreparedDispatchPlan<
LlmPreparedRerankDispatchRoute,
PreparedNativeRerankExecution
>;
export type NativeImageDispatchPlan = NativePreparedDispatchPlan<
LlmPreparedImageDispatchRoute,
PreparedNativeImageExecution
>;
export type ExecutionPlan = {
nativeDispatch?: {
chat?: NativeChatDispatchPlan;
structured?: NativeStructuredDispatchPlan;
embedding?: NativeEmbeddingDispatchPlan;
rerank?: NativeRerankDispatchPlan;
image?: NativeImageDispatchPlan;
};
serializable?: SerializableExecutionPlan;
transport?: ExecutionTransportContract;
request: ExecutionPlanRequest;
routePolicy: { fallbackOrder: string[] };
runtimePolicy: {
prefer?: CopilotProviderType;
};
attachmentPolicy: {
materializeRemoteAttachments: boolean;
};
responsePostprocess: { mode: ExecutionRequestKind };
hostPersistence: {
persistAssistantTurn: boolean;
outputKind: ExecutionRequestKind;
};
hostContext: {
signal?: AbortSignal;
currentMessages?: PromptMessage[];
};
};
type PreparedRouteLike<TRequest = unknown> = {
route: {
providerId: string;
protocol: PreparedNativeExecution['route']['protocol'];
model: string;
backendConfig: PreparedNativeExecution['route']['backendConfig'];
};
request: TRequest;
};
function buildPreparedTransport<
TKind extends ExecutionTransportContract['kind'],
TPrepared extends PreparedRouteLike,
>(
kind: TKind,
routes: ResolvedCopilotProvider[],
getPrepared: (route: ResolvedCopilotProvider) => TPrepared | undefined
): ExecutionTransportContract | undefined {
const prepared =
routes.length === 1 ? routes[0] && getPrepared(routes[0]) : undefined;
if (!prepared) {
return;
}
return {
kind,
request: prepared.request,
} as ExecutionTransportContract;
}
function collectPreparedRoutes<TPrepared extends PreparedRouteLike, TRoute>(
routes: ResolvedCopilotProvider[],
getPrepared: (route: ResolvedCopilotProvider) => TPrepared | undefined,
mapPreparedRoute: (prepared: TPrepared) => TRoute
): TRoute[] | undefined {
if (!routes.length) {
return;
}
const preparedRoutes: TRoute[] = [];
for (const route of routes) {
const prepared = getPrepared(route);
if (!prepared) {
return;
}
preparedRoutes.push(mapPreparedRoute(prepared));
}
return preparedRoutes;
}
function buildPreparedDispatchPlan<
TPrepared extends PreparedRouteLike,
TRoute,
TDispatch extends NativePreparedDispatchPlan<TRoute, TPrepared>,
>(
routes: ResolvedCopilotProvider[],
getPrepared: (route: ResolvedCopilotProvider) => TPrepared | undefined,
mapPreparedRoute: (prepared: TPrepared) => TRoute,
buildPreparedDispatchResult?: (
preparedRoutes: TRoute[],
prepared: TPrepared
) => TDispatch
): TDispatch | undefined {
const preparedRoutes = collectPreparedRoutes(
routes,
getPrepared,
mapPreparedRoute
);
const prepared = routes[0] && getPrepared(routes[0]);
if (!preparedRoutes || !prepared) {
return;
}
const normalizedRoutes = llmNormalizePreparedRoutes<TRoute[]>(preparedRoutes);
return buildPreparedDispatchResult
? buildPreparedDispatchResult(normalizedRoutes, prepared)
: ({ routes: normalizedRoutes, prepared } as TDispatch);
}
type DispatchPreparedRoute<TRequest> = {
provider_id: string;
protocol: PreparedNativeExecution['route']['protocol'];
model: string;
config: PreparedNativeExecution['route']['backendConfig'];
request: TRequest;
};
function mapPreparedDispatchRoute<TRequest>(
prepared: PreparedRouteLike<TRequest>
): DispatchPreparedRoute<TRequest> {
return {
provider_id: prepared.route.providerId,
protocol: prepared.route.protocol,
model: prepared.route.model,
config: prepared.route.backendConfig,
request: prepared.request,
};
}
type PreparedExecutionArtifactSpec<
TKind extends ExecutionTransportContract['kind'],
TPrepared extends PreparedRouteLike,
TRoute,
TDispatch extends NativePreparedDispatchPlan<TRoute, TPrepared>,
> = {
transportKind: TKind;
getPrepared: (route: ResolvedCopilotProvider) => TPrepared | undefined;
mapPreparedRoute: (prepared: TPrepared) => TRoute;
buildPreparedDispatch?: (
preparedRoutes: TRoute[],
prepared: TPrepared
) => TDispatch;
};
type PreparedExecutionArtifacts<TDispatch> = {
dispatch?: TDispatch;
transport?: ExecutionTransportContract;
};
function buildPreparedExecutionArtifacts<
TKind extends ExecutionTransportContract['kind'],
TPrepared extends PreparedRouteLike,
TRoute,
TDispatch extends NativePreparedDispatchPlan<TRoute, TPrepared>,
>(
routes: ResolvedCopilotProvider[],
spec: PreparedExecutionArtifactSpec<TKind, TPrepared, TRoute, TDispatch>
): PreparedExecutionArtifacts<TDispatch> {
return {
dispatch: buildPreparedDispatchPlan(
routes,
spec.getPrepared,
spec.mapPreparedRoute,
spec.buildPreparedDispatch
),
transport: buildPreparedTransport(
spec.transportKind,
routes,
spec.getPrepared
),
};
}
const chatArtifactSpec: PreparedExecutionArtifactSpec<
'chat',
PreparedNativeExecution,
LlmPreparedDispatchRoute,
NativeChatDispatchPlan
> = {
transportKind: 'chat',
getPrepared: route => route.prepared,
mapPreparedRoute: mapPreparedDispatchRoute,
buildPreparedDispatch: (preparedRoutes, prepared) => ({
routes: preparedRoutes,
prepared,
hasTools: Object.keys(prepared.tools).length > 0,
}),
};
const structuredArtifactSpec: PreparedExecutionArtifactSpec<
'structured',
PreparedNativeStructuredExecution,
LlmPreparedStructuredDispatchRoute,
NativeStructuredDispatchPlan
> = {
transportKind: 'structured',
getPrepared: route => route.preparedStructured,
mapPreparedRoute: mapPreparedDispatchRoute,
};
const embeddingArtifactSpec: PreparedExecutionArtifactSpec<
'embedding',
PreparedNativeEmbeddingExecution,
LlmPreparedEmbeddingDispatchRoute,
NativeEmbeddingDispatchPlan
> = {
transportKind: 'embedding',
getPrepared: route => route.preparedEmbedding,
mapPreparedRoute: mapPreparedDispatchRoute,
};
const rerankArtifactSpec: PreparedExecutionArtifactSpec<
'rerank',
PreparedNativeRerankExecution,
LlmPreparedRerankDispatchRoute,
NativeRerankDispatchPlan
> = {
transportKind: 'rerank',
getPrepared: route => route.preparedRerank,
mapPreparedRoute: mapPreparedDispatchRoute,
};
const imageArtifactSpec: PreparedExecutionArtifactSpec<
'image',
PreparedNativeImageExecution,
LlmPreparedImageDispatchRoute,
NativeImageDispatchPlan
> = {
transportKind: 'image',
getPrepared: route => route.preparedImage,
mapPreparedRoute: mapPreparedDispatchRoute,
};
function buildFallbackOrder(routes: ResolvedCopilotProvider[]) {
return routes.map(route => route.providerId);
}
function mapExecutionRoute(route: ResolvedCopilotProvider): ExecutionRoute {
const preparedRoute =
route.prepared?.route ??
route.preparedStructured?.route ??
route.preparedEmbedding?.route ??
route.preparedRerank?.route ??
route.preparedImage?.route;
if (preparedRoute) {
return {
providerId: preparedRoute.providerId,
protocol: preparedRoute.protocol,
model: preparedRoute.model,
backendConfig: preparedRoute.backendConfig,
};
}
const rawRoute = route as unknown as ExecutionRoute;
return {
providerId: rawRoute.providerId,
protocol: rawRoute.protocol,
model: rawRoute.model,
backendConfig: rawRoute.backendConfig,
};
}
function stripHostOnlyOptions<TOptions extends object | undefined>(
options: TOptions
): Record<string, unknown> | undefined {
if (!options) {
return;
}
const {
signal: _signal,
user: _user,
session: _session,
workspace: _workspace,
...serializable
} = options as Record<string, unknown>;
return Object.keys(serializable).length ? serializable : undefined;
}
function buildSerializableRequest(
request: ExecutionPlanRequest
): SerializableExecutionPlanRequest {
switch (request.kind) {
case 'text':
case 'streamText':
case 'streamObject':
case 'structured':
case 'image':
return {
...request,
options: stripHostOnlyOptions(request.options),
} as SerializableExecutionPlanRequest;
case 'embedding':
case 'rerank':
return {
...request,
options: stripHostOnlyOptions(request.options),
};
}
}
function buildSerializableExecutionPlan(
routes: ResolvedCopilotProvider[],
input: Omit<
ExecutionPlan,
'nativeDispatch' | 'serializable' | 'hostContext'
> &
Pick<ExecutionPlan, 'hostContext'>
): SerializableExecutionPlan {
return parseExecutionPlan({
routes: routes.map(mapExecutionRoute),
request: buildSerializableRequest(input.request),
transport: input.transport,
routePolicy: input.routePolicy,
runtimePolicy: input.runtimePolicy,
attachmentPolicy: input.attachmentPolicy,
responsePostprocess: input.responsePostprocess,
hostContext: input.hostContext.currentMessages
? { currentMessages: input.hostContext.currentMessages }
: undefined,
});
}
type MessagePlanArtifacts = Pick<ExecutionPlan, 'nativeDispatch' | 'transport'>;
function buildMessagePlanArtifacts(
kind: Extract<
ExecutionRequestKind,
'text' | 'streamText' | 'streamObject' | 'structured' | 'image'
>,
routes: ResolvedCopilotProvider[]
): MessagePlanArtifacts {
const chatArtifacts =
kind === 'text' || kind === 'streamText' || kind === 'streamObject'
? buildPreparedExecutionArtifacts(routes, chatArtifactSpec)
: undefined;
const structuredArtifacts =
kind === 'structured'
? buildPreparedExecutionArtifacts(routes, structuredArtifactSpec)
: undefined;
const imageArtifacts =
kind === 'image'
? buildPreparedExecutionArtifacts(routes, imageArtifactSpec)
: undefined;
const nativeDispatch = {
chat:
kind === 'text' || kind === 'streamText' || kind === 'streamObject'
? chatArtifacts?.dispatch
: undefined,
structured:
kind === 'structured' ? structuredArtifacts?.dispatch : undefined,
image: kind === 'image' ? imageArtifacts?.dispatch : undefined,
};
return {
nativeDispatch,
transport:
kind === 'text' || kind === 'streamText' || kind === 'streamObject'
? chatArtifacts?.transport
: kind === 'structured'
? structuredArtifacts?.transport
: kind === 'image'
? imageArtifacts?.transport
: undefined,
};
}
function buildEmbeddingPlanArtifacts(
routes: ResolvedCopilotProvider[]
): Pick<ExecutionPlan, 'nativeDispatch' | 'transport'> {
const embeddingArtifacts = buildPreparedExecutionArtifacts(
routes,
embeddingArtifactSpec
);
return {
nativeDispatch: {
embedding: embeddingArtifacts.dispatch,
},
transport: embeddingArtifacts.transport,
};
}
function buildRerankPlanArtifacts(
routes: ResolvedCopilotProvider[]
): Pick<ExecutionPlan, 'nativeDispatch' | 'transport'> {
const rerankArtifacts = buildPreparedExecutionArtifacts(
routes,
rerankArtifactSpec
);
return {
nativeDispatch: {
rerank: rerankArtifacts.dispatch,
},
transport: rerankArtifacts.transport,
};
}
@Injectable()
export class ExecutionPlanBuilder {
constructor(
private readonly providers: CopilotProviderFactory,
private readonly executionMetrics: CopilotExecutionMetrics
) {}
private async buildMessagePlan<
TKind extends Extract<
ExecutionRequestKind,
'text' | 'streamText' | 'streamObject' | 'structured' | 'image'
>,
>(
kind: TKind,
cond: ModelConditions,
messages: PromptMessage[],
options?:
| CopilotChatOptions
| CopilotStructuredOptions
| CopilotImageOptions,
filter: ProviderFilter = {}
): Promise<ExecutionPlanForKind<TKind>> {
const outputType =
kind === 'image'
? ModelOutputType.Image
: kind === 'streamObject'
? ModelOutputType.Object
: kind === 'structured'
? ModelOutputType.Structured
: ModelOutputType.Text;
const routes =
kind === 'text' || kind === 'streamText' || kind === 'streamObject'
? await this.providers.prepareRoutes(
kind,
{ ...cond, outputType },
messages,
(options as CopilotChatOptions | undefined) ?? {},
filter
)
: kind === 'structured'
? await this.providers.prepareStructuredRoutes(
{ ...cond, outputType },
messages,
(options as CopilotStructuredOptions | undefined) ?? {},
filter
)
: await this.providers.prepareImageRoutes(
{ ...cond, outputType },
messages,
(options as CopilotImageOptions | undefined) ?? {},
filter
);
this.executionMetrics.recordPlan(kind, routes, filter.prefer);
const { nativeDispatch, transport } = buildMessagePlanArtifacts(
kind,
routes
);
const plan = {
transport,
request: {
kind,
cond: { ...cond, modelId: cond.modelId },
messages,
options,
} as Extract<ExecutionPlanRequest, { kind: TKind }>,
routePolicy: {
fallbackOrder: buildFallbackOrder(routes),
},
runtimePolicy: { prefer: filter.prefer },
attachmentPolicy: { materializeRemoteAttachments: true },
responsePostprocess: { mode: kind },
hostPersistence: {
persistAssistantTurn: true,
outputKind: kind,
},
hostContext: {
signal: options?.signal,
currentMessages: messages,
},
} as Omit<ExecutionPlanForKind<TKind>, 'nativeDispatch' | 'serializable'>;
return {
nativeDispatch,
serializable: buildSerializableExecutionPlan(routes, plan),
...plan,
};
}
async buildTextPlan(
cond: ModelConditions,
messages: PromptMessage[],
options?: CopilotChatOptions,
filter?: ProviderFilter
): Promise<ExecutionPlanForKind<'text'>> {
return await this.buildMessagePlan('text', cond, messages, options, filter);
}
async buildStreamTextPlan(
cond: ModelConditions,
messages: PromptMessage[],
options?: CopilotChatOptions,
filter?: ProviderFilter
): Promise<ExecutionPlanForKind<'streamText'>> {
return await this.buildMessagePlan(
'streamText',
cond,
messages,
options,
filter
);
}
async buildStreamObjectPlan(
cond: ModelConditions,
messages: PromptMessage[],
options?: CopilotChatOptions,
filter?: ProviderFilter
): Promise<ExecutionPlanForKind<'streamObject'>> {
return await this.buildMessagePlan(
'streamObject',
cond,
messages,
options,
filter
);
}
async buildStructuredPlan(
cond: ModelConditions,
messages: PromptMessage[],
options?: CopilotStructuredOptions,
filter?: ProviderFilter,
responseContract?: RequiredStructuredOutputContract
): Promise<ExecutionPlanForKind<'structured'>> {
const outputType = ModelOutputType.Structured;
const routes = await this.providers.prepareStructuredRoutes(
{ ...cond, outputType },
messages,
options ?? {},
filter ?? {},
responseContract
);
this.executionMetrics.recordPlan('structured', routes, filter?.prefer);
const { nativeDispatch, transport } = buildMessagePlanArtifacts(
'structured',
routes
);
const plan = {
transport,
request: {
kind: 'structured',
cond: { ...cond, modelId: cond.modelId },
messages,
options,
},
routePolicy: {
fallbackOrder: buildFallbackOrder(routes),
},
runtimePolicy: { prefer: filter?.prefer },
attachmentPolicy: { materializeRemoteAttachments: true },
responsePostprocess: { mode: 'structured' },
hostPersistence: {
persistAssistantTurn: true,
outputKind: 'structured',
},
hostContext: {
signal: options?.signal,
currentMessages: messages,
},
} as Omit<
ExecutionPlanForKind<'structured'>,
'nativeDispatch' | 'serializable'
>;
return {
nativeDispatch,
serializable: buildSerializableExecutionPlan(routes, plan),
...plan,
};
}
async buildImagePlan(
cond: ModelConditions,
messages: PromptMessage[],
options?: CopilotImageOptions,
filter?: ProviderFilter
): Promise<ExecutionPlanForKind<'image'>> {
return await this.buildMessagePlan(
'image',
cond,
messages,
options,
filter
);
}
async buildEmbeddingPlan(
modelId: string,
input: string | string[],
options?: CopilotEmbeddingOptions
): Promise<ExecutionPlanForKind<'embedding'>> {
const routes = await this.providers.prepareEmbeddingRoutes(
modelId,
input,
options
);
this.executionMetrics.recordPlan('embedding', routes);
const { nativeDispatch, transport } = buildEmbeddingPlanArtifacts(routes);
const plan = {
transport,
request: {
kind: 'embedding',
cond: { modelId },
modelId,
input,
options,
},
routePolicy: {
fallbackOrder: buildFallbackOrder(routes),
},
runtimePolicy: {},
attachmentPolicy: { materializeRemoteAttachments: false },
responsePostprocess: { mode: 'embedding' },
hostPersistence: {
persistAssistantTurn: false,
outputKind: 'embedding',
},
hostContext: {
signal: options?.signal,
},
} as Omit<
ExecutionPlanForKind<'embedding'>,
'nativeDispatch' | 'serializable'
>;
return {
nativeDispatch,
serializable: buildSerializableExecutionPlan(routes, plan),
...plan,
};
}
async buildRerankPlan(
modelId: string,
request: CopilotRerankRequest,
options?: CopilotChatOptions
): Promise<ExecutionPlanForKind<'rerank'>> {
const routes = await this.providers.prepareRerankRoutes(
modelId,
request,
options
);
this.executionMetrics.recordPlan('rerank', routes);
const { nativeDispatch, transport } = buildRerankPlanArtifacts(routes);
const plan = {
transport,
request: {
kind: 'rerank',
cond: { modelId },
modelId,
request,
options,
},
routePolicy: {
fallbackOrder: buildFallbackOrder(routes),
},
runtimePolicy: {},
attachmentPolicy: { materializeRemoteAttachments: false },
responsePostprocess: { mode: 'rerank' },
hostPersistence: {
persistAssistantTurn: false,
outputKind: 'rerank',
},
hostContext: {
signal: options?.signal,
},
} as Omit<
ExecutionPlanForKind<'rerank'>,
'nativeDispatch' | 'serializable'
>;
return {
nativeDispatch,
serializable: buildSerializableExecutionPlan(routes, plan),
...plan,
};
}
}
@@ -0,0 +1,248 @@
import { Injectable } from '@nestjs/common';
import type { LlmImageResponse } from '../../../../native';
import { PromptService } from '../../prompt';
import type { PromptMessage } from '../../providers/types';
import type { ChatSession } from '../../session';
import { ChatQuerySchema } from '../../types';
import { projectActionEventToChatEvent } from '../action-output-projector';
import type { ActionRuntimeBridgeEvent } from '../action-runtime-bridge';
import { ActionRuntimeBridge } from '../action-runtime-bridge';
import { ConversationHost } from './conversation-host';
import { ImageResultHost } from './image-result-host';
export { projectActionEventToChatEvent };
function firstQueryValue(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : value;
}
const ACTION_PROMPTS: Record<string, string> = {
'mindmap.generate': 'mindmap.generate',
'slides.outline': 'slides.outline',
};
type ImageActionRoutePreparation = {
modelId?: string;
messages: PromptMessage[];
options: Record<string, unknown>;
};
function isImageAction(id: string) {
return id.startsWith('image.filter.');
}
function actionTextResultSchema() {
return {
type: 'object',
properties: {
result: { type: 'string' },
},
required: ['result'],
additionalProperties: false,
};
}
@Injectable()
export class ActionStreamHost {
constructor(
private readonly conversations: ConversationHost,
private readonly bridge: ActionRuntimeBridge,
private readonly prompts: PromptService,
private readonly imageResults: ImageResultHost
) {}
async stream(
userId: string,
sessionId: string,
query: Record<string, string | string[]>,
signal?: AbortSignal
): Promise<{
messageId?: string;
actionId: string;
actionVersion: string;
stream: AsyncIterableIterator<ActionRuntimeBridgeEvent>;
}> {
const parsedQuery = ChatQuerySchema.parse(query);
const prepared = await this.conversations.prepareTurn(
userId,
sessionId,
query
);
const requestedActionId =
firstQueryValue(query.actionId) ?? prepared.session.config.promptName;
const actionId = requestedActionId;
const actionVersion = firstQueryValue(query.actionVersion) ?? 'v1';
const retryOf = parsedQuery.retry
? firstQueryValue(query.runId)
: undefined;
const params = {
...prepared.params,
...this.conversations.buildLatestTurnPromptParams(prepared.latestTurn),
};
const finalMessage = await this.preparePromptMessages(
actionId,
prepared.session,
params
);
const imageRoutes = await this.prepareImageRoutes(
actionId,
prepared.session,
params,
userId,
signal
);
const runStream = this.bridge.runStream({
userId,
workspaceId: prepared.session.config.workspaceId,
docId: prepared.session.config.docId,
session: prepared.session,
userMessageId: prepared.latestTurn?.id,
compatSubmissionId: prepared.messageId,
actionId,
actionVersion,
retryOf,
inputSnapshot: {
params,
messageId: prepared.messageId,
},
persistAttachment: isImageAction(actionId)
? attachment =>
this.persistImageAttachment(
userId,
prepared.session.config.workspaceId,
attachment
)
: undefined,
prepareStructuredRoutes: isImageAction(actionId)
? undefined
: {
stepId: 'generate',
modelId:
typeof query.modelId === 'string' && query.modelId
? query.modelId
: undefined,
messages: finalMessage,
responseSchemaJson: actionTextResultSchema(),
options: {
...prepared.session.config.promptConfig,
signal,
user: userId,
workspace: prepared.session.config.workspaceId,
session: sessionId,
},
},
prepareImageRoutes: imageRoutes
? {
stepId: 'generate-image',
modelId: imageRoutes.modelId,
messages: imageRoutes.messages,
options: imageRoutes.options,
}
: undefined,
signal,
});
return {
messageId: prepared.messageId,
actionId,
actionVersion,
stream: runStream,
};
}
private async preparePromptMessages(
actionId: string,
session: ChatSession,
params: Record<string, unknown>
): Promise<PromptMessage[]> {
const promptName = ACTION_PROMPTS[actionId];
if (!promptName) {
return session.finish(params);
}
const prompt = await this.prompts.get(promptName);
if (!prompt) {
throw new Error(`Prompt ${promptName} not found`);
}
return this.prompts.finish(
prompt,
params as Record<string, string>,
session.config.sessionId
);
}
private async prepareImageRoutes(
actionId: string,
session: ChatSession,
params: Record<string, unknown>,
userId: string,
signal?: AbortSignal
): Promise<ImageActionRoutePreparation | undefined> {
if (!isImageAction(actionId)) {
return undefined;
}
const prompt = await this.prompts.get(actionId);
if (!prompt) {
throw new Error(`Prompt ${actionId} not found`);
}
const finalMessage = this.prompts.finish(
prompt,
params as Record<string, string>,
session.config.sessionId
);
return {
modelId: prompt.model,
messages: finalMessage,
options: {
...prompt.config,
signal,
user: userId,
workspace: session.config.workspaceId,
session: session.config.sessionId,
},
};
}
private async persistImageAttachment(
userId: string,
workspaceId: string,
attachment: unknown
) {
if (!attachment || typeof attachment !== 'object') {
return attachment;
}
const artifact = attachment as LlmImageResponse['images'][number] & {
url?: unknown;
data_base64?: unknown;
media_type?: unknown;
width?: unknown;
height?: unknown;
providerMetadata?: unknown;
};
const persisted = await this.imageResults.persistNativeArtifact(
userId,
workspaceId,
artifact
);
if (!persisted) {
return attachment;
}
return {
url: persisted,
...(typeof artifact.media_type === 'string'
? { mimeType: artifact.media_type }
: {}),
...(typeof artifact.width === 'number' ? { width: artifact.width } : {}),
...(typeof artifact.height === 'number'
? { height: artifact.height }
: {}),
...(artifact.providerMetadata !== undefined
? { providerMetadata: artifact.providerMetadata }
: {}),
};
}
}
@@ -0,0 +1,252 @@
import { createHash } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import { OneMB } from '../../../../base';
import type {
PromptAttachment,
PromptAttachmentSourceKind,
} from '../../providers/types';
import { promptAttachmentMimeType } from '../../providers/utils';
import { AttachmentMaterializer } from './attachment-materializer';
type AttachmentProviderHint = NonNullable<
Extract<PromptAttachment, { kind: 'data' }>['providerHint']
>;
export type AdmittedAttachmentSource = {
id: string;
kind: 'bytes';
mimeType: string;
size: number;
fileName?: string;
hash: string;
providerHint?: AttachmentProviderHint;
data: string;
encoding: 'base64';
};
export type AttachmentAdmissionContext = {
userId: string;
workspaceId: string;
sessionId?: string;
signal?: AbortSignal;
maxBytes?: number;
trustedHostSuffixes?: string[];
assertCanUseAttachment?: (source: {
kind: PromptAttachmentSourceKind | 'alias' | 'raw_url';
url?: string;
}) => Promise<void> | void;
};
type ParsedPromptAttachment = {
kind: PromptAttachmentSourceKind | 'alias' | 'raw_url';
url?: string;
data?: string;
encoding?: 'base64' | 'utf8';
mimeType?: string;
fileName?: string;
providerHint?: AttachmentProviderHint;
fileHandle?: string;
};
const DEFAULT_MAX_BYTES = 64 * OneMB;
function normalizeMimeType(mediaType?: string) {
return mediaType?.split(';', 1)[0]?.trim() || 'application/octet-stream';
}
function toBase64Buffer(data: string, encoding: 'base64' | 'utf8' = 'base64') {
return encoding === 'utf8'
? Buffer.from(data, 'utf8')
: Buffer.from(data, 'base64');
}
function parseDataUrl(url: string) {
const match = /^data:([^;,]+)?(;base64)?,(.*)$/s.exec(url);
if (!match) return;
const mimeType = normalizeMimeType(match[1]);
const isBase64 = !!match[2];
const rawData = match[3] ?? '';
const data = isBase64
? rawData
: Buffer.from(decodeURIComponent(rawData), 'utf8').toString('base64');
return { mimeType, data };
}
function hashBuffer(buffer: Buffer) {
return createHash('sha256').update(buffer).digest('hex');
}
function stableAttachmentId(hash: string) {
return `att_${hash.slice(0, 24)}`;
}
function parsePromptAttachment(
attachment: PromptAttachment
): ParsedPromptAttachment {
if (typeof attachment === 'string') {
return { kind: 'raw_url', url: attachment };
}
if ('attachment' in attachment) {
return {
kind: 'alias',
url: attachment.attachment,
mimeType: attachment.mimeType,
};
}
switch (attachment.kind) {
case 'url':
return {
kind: 'url',
url: attachment.url,
data: attachment.data,
encoding: attachment.encoding,
mimeType: attachment.mimeType,
fileName: attachment.fileName,
providerHint: attachment.providerHint,
};
case 'data':
case 'bytes':
return {
kind: attachment.kind,
data: attachment.data,
encoding: attachment.encoding,
mimeType: attachment.mimeType,
fileName: attachment.fileName,
providerHint: attachment.providerHint,
};
case 'file_handle':
return {
kind: 'file_handle',
fileHandle: attachment.fileHandle,
mimeType: attachment.mimeType,
fileName: attachment.fileName,
providerHint: attachment.providerHint,
};
}
}
function admittedBytesSource(input: {
data: string;
encoding?: 'base64' | 'utf8';
mimeType: string;
fileName?: string;
providerHint?: AttachmentProviderHint;
}): AdmittedAttachmentSource {
const buffer = toBase64Buffer(input.data, input.encoding);
const hash = hashBuffer(buffer);
return {
id: stableAttachmentId(hash),
kind: 'bytes',
mimeType: normalizeMimeType(input.mimeType),
size: buffer.byteLength,
fileName: input.fileName,
hash,
providerHint: input.providerHint,
data: buffer.toString('base64'),
encoding: 'base64',
};
}
@Injectable()
export class AttachmentAdmissionHost {
constructor(private readonly materializer: AttachmentMaterializer) {}
async admitPromptAttachment(
attachment: PromptAttachment,
context: AttachmentAdmissionContext
): Promise<AdmittedAttachmentSource> {
const parsed = parsePromptAttachment(attachment);
await context.assertCanUseAttachment?.({
kind: parsed.kind,
url: parsed.url,
});
if (parsed.kind === 'file_handle') {
throw new Error('File handle attachments must be passed directly');
}
if (parsed.kind === 'data' || parsed.kind === 'bytes') {
const data = parsed.data;
const mimeType = parsed.mimeType;
if (!data || !mimeType) {
throw new Error('Attachment data and MIME type are required');
}
return admittedBytesSource({
data,
encoding: parsed.encoding,
mimeType,
fileName: parsed.fileName,
providerHint: parsed.providerHint,
});
}
if (!parsed.url) {
throw new Error('Attachment URL is required for admission');
}
const dataUrl = parseDataUrl(parsed.url);
if (dataUrl) {
return admittedBytesSource({
data: dataUrl.data,
mimeType: parsed.mimeType
? normalizeMimeType(parsed.mimeType)
: dataUrl.mimeType,
fileName: parsed.fileName,
providerHint: parsed.providerHint,
});
}
const downloaded = await this.materializer.fetchRemoteAttachment(
parsed.url,
{
signal: context.signal,
maxBytes: context.maxBytes ?? DEFAULT_MAX_BYTES,
trustedHostSuffixes: context.trustedHostSuffixes,
}
);
const declaredMimeType = promptAttachmentMimeType(
attachment,
parsed.mimeType
);
return admittedBytesSource({
data: downloaded.data,
mimeType: declaredMimeType
? normalizeMimeType(declaredMimeType)
: downloaded.mimeType,
fileName: parsed.fileName,
providerHint: parsed.providerHint,
});
}
async admitPromptAttachments(
attachments: PromptAttachment[],
context: AttachmentAdmissionContext
) {
return Promise.all(
attachments.map(attachment =>
this.admitPromptAttachment(attachment, context)
)
);
}
}
export function admittedAttachmentToPromptAttachment(
source: AdmittedAttachmentSource
): PromptAttachment {
return {
kind: 'bytes',
data: source.data,
encoding: 'base64',
mimeType: source.mimeType,
fileName: source.fileName,
providerHint: source.providerHint,
};
}
@@ -0,0 +1,133 @@
import type { LlmBackendConfig, LlmProtocol } from '../../../../native';
import { llmPlanAttachmentReference } from '../../../../native';
import type { PromptAttachment } from '../../providers/types';
import {
type AdmittedAttachmentSource,
admittedAttachmentToPromptAttachment,
} from './attachment-admission';
export type AdmittedAttachmentMaterializationPlan = {
mode: 'inline';
reason: 'admitted_bytes';
attachment: PromptAttachment;
};
export type HostAttachmentMaterializationRequest = {
attachmentId: string;
target: 'bytes' | 'data';
providerConstraint?: string;
maxSize: number;
timeoutMs: number;
redirectPolicy: 'follow-safe';
expectedMime?: string;
url: string;
};
type RemoteReferenceReason =
| 'generic_remote_reference'
| 'gemini_api_file_uri'
| 'gemini_api_youtube_url';
type MaterializationRequestReason =
| 'generic_remote_reference'
| 'gemini_api_inline_http_url'
| 'unsupported_scheme'
| 'non_url_source';
function assertRemoteReferenceReason(
reason: string
): asserts reason is RemoteReferenceReason {
if (
reason !== 'generic_remote_reference' &&
reason !== 'gemini_api_file_uri' &&
reason !== 'gemini_api_youtube_url'
) {
throw new Error(`Unexpected remote attachment reference reason: ${reason}`);
}
}
function assertMaterializationRequestReason(
reason: string
): asserts reason is MaterializationRequestReason {
if (
reason !== 'gemini_api_inline_http_url' &&
reason !== 'generic_remote_reference' &&
reason !== 'unsupported_scheme' &&
reason !== 'non_url_source'
) {
throw new Error(`Unexpected attachment materialization reason: ${reason}`);
}
}
export async function planHostUrlAttachmentMaterialization(
protocol: LlmProtocol,
backendConfig: LlmBackendConfig,
input: {
attachmentId: string;
url: string;
expectedMime?: string;
maxSize: number;
timeoutMs?: number;
}
): Promise<
| {
mode: 'remote_reference';
reason:
| 'generic_remote_reference'
| 'gemini_api_file_uri'
| 'gemini_api_youtube_url';
url: string;
}
| {
mode: 'materialization_request';
reason:
| 'generic_remote_reference'
| 'gemini_api_inline_http_url'
| 'unsupported_scheme'
| 'non_url_source';
request: HostAttachmentMaterializationRequest;
}
> {
const plan = await llmPlanAttachmentReference(protocol, backendConfig, {
url: input.url,
});
const forceHostMaterialization =
protocol === 'gemini' &&
backendConfig.request_layer === 'gemini_vertex' &&
plan.reason === 'generic_remote_reference';
if (plan.mode === 'remote' && !forceHostMaterialization) {
assertRemoteReferenceReason(plan.reason);
return {
mode: 'remote_reference',
reason: plan.reason,
url: input.url,
};
}
assertMaterializationRequestReason(plan.reason);
return {
mode: 'materialization_request',
reason: plan.reason,
request: {
attachmentId: input.attachmentId,
target: 'bytes',
providerConstraint: protocol,
maxSize: input.maxSize,
timeoutMs: input.timeoutMs ?? 15_000,
redirectPolicy: 'follow-safe',
expectedMime: input.expectedMime,
url: input.url,
},
};
}
export function planAdmittedAttachmentMaterialization(
source: AdmittedAttachmentSource
): AdmittedAttachmentMaterializationPlan {
return {
mode: 'inline',
reason: 'admitted_bytes',
attachment: admittedAttachmentToPromptAttachment(source),
};
}
@@ -0,0 +1,120 @@
import { Injectable } from '@nestjs/common';
import {
Config,
readResponseBufferWithLimit,
safeFetch,
} from '../../../../base';
type FetchRemoteAttachmentOptions = {
signal?: AbortSignal;
maxBytes: number;
trustedHostSuffixes?: string[];
detectMimeType?: (buffer: Buffer, headerMimeType: string) => string;
};
function normalizeMimeType(mediaType?: string) {
return mediaType?.split(';', 1)[0]?.trim() || 'application/octet-stream';
}
export function resolveAttachmentFetchUrl(url: string) {
const parsed = new URL(url);
if (parsed.protocol !== 'gs:') {
return parsed;
}
if (!parsed.hostname) {
throw new Error('Invalid gs attachment URL: missing bucket');
}
return new URL(
`https://storage.googleapis.com/${parsed.hostname}${parsed.pathname}${parsed.search}`
);
}
@Injectable()
export class AttachmentMaterializer {
constructor(private readonly config: Config) {}
private buildFetchOptions(url: URL, trustedHostSuffixes: string[]) {
const baseOptions = { timeoutMs: 15_000, maxRedirects: 3 } as const;
if (!env.prod) {
return { ...baseOptions, allowPrivateOrigins: new Set([url.origin]) };
}
const trustedOrigins = new Set<string>();
const protocol = this.config.server.https ? 'https:' : 'http:';
const port = this.config.server.port;
const isDefaultPort =
(protocol === 'https:' && port === 443) ||
(protocol === 'http:' && port === 80);
const addHostOrigin = (host: string) => {
if (!host) return;
try {
const parsed = new URL(`${protocol}//${host}`);
if (!parsed.port && !isDefaultPort) {
parsed.port = String(port);
}
trustedOrigins.add(parsed.origin);
} catch {
return;
}
};
if (this.config.server.externalUrl) {
try {
trustedOrigins.add(new URL(this.config.server.externalUrl).origin);
} catch {
// ignore invalid external URL
}
}
addHostOrigin(this.config.server.host);
for (const host of this.config.server.hosts) {
addHostOrigin(host);
}
const hostname = url.hostname.toLowerCase();
const trustedByHost = trustedHostSuffixes.some(
suffix => hostname === suffix || hostname.endsWith(`.${suffix}`)
);
if (trustedOrigins.has(url.origin) || trustedByHost) {
return { ...baseOptions, allowPrivateOrigins: new Set([url.origin]) };
}
return baseOptions;
}
async fetchRemoteAttachment(
url: string,
options: FetchRemoteAttachmentOptions
) {
const parsed = resolveAttachmentFetchUrl(url);
const response = await safeFetch(
parsed,
{ method: 'GET', signal: options.signal },
this.buildFetchOptions(parsed, options.trustedHostSuffixes ?? [])
);
if (!response.ok) {
throw new Error(
`Failed to fetch attachment: ${response.status} ${response.statusText}`
);
}
const buffer = await readResponseBufferWithLimit(
response,
options.maxBytes
);
const headerMimeType = normalizeMimeType(
response.headers.get('content-type') || ''
);
return {
data: buffer.toString('base64'),
mimeType: options.detectMimeType
? options.detectMimeType(buffer, headerMimeType)
: headerMimeType,
};
}
}
@@ -0,0 +1,118 @@
import { Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { ServerFeature, ServerService } from '../../../../core';
import { SubscriptionService } from '../../../payment/service';
import { SubscriptionPlan, SubscriptionStatus } from '../../../payment/types';
import type { ChatSession } from '../../session';
import { type ToolsConfig } from '../../types';
import { getTools } from '../../utils';
import {
ModelSelectionPolicy,
type ResolveModelInput,
} from '../model-selection-policy';
export type ChatSelectionOptions = {
responseMode: 'text' | 'object' | 'image';
modelId?: string;
reasoning?: boolean;
webSearch?: boolean;
toolsConfig?: ToolsConfig;
};
type ResolvePolicyModelInput = ResolveModelInput & {
proModels?: string[] | null;
userId?: string;
paymentEnabled?: boolean;
};
@Injectable()
export class CapabilityPolicyHost {
constructor(
private readonly server: ServerService,
private readonly moduleRef: ModuleRef,
private readonly modelSelection: ModelSelectionPolicy
) {}
private async hasAiProAccess(
userId: string | undefined,
paymentEnabled: boolean | undefined
) {
if (!paymentEnabled || !userId) {
return false;
}
try {
const subscription = await this.moduleRef
.get(SubscriptionService, { strict: false })
.select(SubscriptionPlan.AI)
.getSubscription({
userId,
plan: SubscriptionPlan.AI,
} as never);
return subscription?.status === SubscriptionStatus.Active;
} catch {
return false;
}
}
private async resolveModel(input: ResolvePolicyModelInput) {
const resolved = this.modelSelection.resolveRequestedModel(input);
if (!resolved.matchedOptionalModel) {
return resolved.selectedModel;
}
if (
input.paymentEnabled &&
this.modelSelection.matchesModelList(
input.proModels ?? [],
input.requestedModelId
) &&
!(await this.hasAiProAccess(input.userId, input.paymentEnabled))
) {
return input.defaultModel;
}
return resolved.selectedModel;
}
async selectChat(session: ChatSession, options: ChatSelectionOptions) {
const model = await this.resolveChatModel({
userId: session.config.userId,
defaultModel: session.model,
optionalModels: session.optionalModels,
proModels: session.config.promptConfig?.proModels,
requestedModelId: options.modelId,
paymentEnabled: this.server.features.includes(ServerFeature.Payment),
});
const tools = getTools(
session.config.promptConfig?.tools,
options.toolsConfig
);
return {
model,
providerOptions: {
...session.config.promptConfig,
user: session.config.userId,
session: session.config.sessionId,
workspace: session.config.workspaceId,
reasoning: options.reasoning,
webSearch: options.webSearch,
tools,
},
};
}
async resolveChatModel(input: ResolvePolicyModelInput) {
return await this.resolveModel(input);
}
async resolvePromptModel(input: ResolveModelInput) {
return this.modelSelection.resolveRequestedModel(input).selectedModel;
}
async resolveFixedTaskModel(input: ResolveModelInput) {
return this.modelSelection.resolveRequestedModel(input).selectedModel;
}
}
@@ -0,0 +1,248 @@
import { Injectable } from '@nestjs/common';
import {
CopilotMessageNotFound,
CopilotSessionNotFound,
Mutex,
} from '../../../../base';
import { CompatSubmissionStore } from '../../compat/submission-store';
import {
canonicalizeTurnTrace,
type Turn,
turnFromChatMessage,
} from '../../core';
import type { PromptParams } from '../../providers/types';
import { ChatSession, ChatSessionService } from '../../session';
import { ChatQuerySchema } from '../../types';
export type PreparedConversationTurn = {
messageId?: string;
params: Record<string, string>;
session: ChatSession;
latestTurn?: Turn;
};
@Injectable()
export class ConversationHost {
constructor(
private readonly sessions: ChatSessionService,
private readonly submissions: CompatSubmissionStore,
private readonly mutex: Mutex
) {}
private async loadAcceptedTurn(
session: ChatSession,
sessionId: string,
messageId: string,
retry: boolean
): Promise<Turn | undefined> {
const accepted = await this.submissions.getAccepted(messageId);
if (!accepted) return;
if (accepted.sessionId !== sessionId) {
throw new CopilotMessageNotFound({ messageId });
}
if (retry) {
await this.sessions.revertLatestMessage(sessionId, false);
session.revertLatestMessage(false);
}
const existingTurn = session.findTurn(accepted.turnId);
if (existingTurn) return existingTurn;
const acceptedMessage = await this.sessions.getMessage(
sessionId,
accepted.turnId
);
if (acceptedMessage.role !== 'user') {
throw new CopilotMessageNotFound({ messageId: accepted.turnId });
}
const turn = turnFromChatMessage(acceptedMessage, sessionId);
session.pushPersistedTurn(turn);
return turn;
}
private async loadDurableTurn(
session: ChatSession,
sessionId: string,
messageId: string,
retry: boolean
): Promise<Turn | undefined> {
const turn = await this.sessions.findTurnByCompatSubmissionId(
sessionId,
messageId
);
if (!turn?.id) {
return;
}
if (retry) {
await this.sessions.revertLatestMessage(sessionId, false);
session.revertLatestMessage(false);
}
await this.submissions.markAccepted(messageId, {
sessionId,
turnId: turn.id,
});
const existingTurn = session.findTurn(turn.id);
if (existingTurn) {
return existingTurn;
}
session.pushPersistedTurn(turn);
return turn;
}
private async appendSessionMessage(
userId: string,
session: ChatSession,
sessionId: string,
messageId?: string,
retry = false
): Promise<Turn | undefined> {
if (!messageId) {
await this.sessions.revertLatestMessage(sessionId, false);
session.revertLatestMessage(false);
return session.latestUserTurn;
}
const acceptedTurn = await this.loadAcceptedTurn(
session,
sessionId,
messageId,
retry
);
if (acceptedTurn) {
return acceptedTurn;
}
await using lock = await this.mutex.acquire(
`copilot:submission:${messageId}`
);
if (!lock) {
throw new CopilotMessageNotFound({ messageId });
}
const acceptedAfterLock = await this.loadAcceptedTurn(
session,
sessionId,
messageId,
retry
);
if (acceptedAfterLock) return acceptedAfterLock;
const durableTurn = await this.loadDurableTurn(
session,
sessionId,
messageId,
retry
);
if (durableTurn) return durableTurn;
await this.sessions.checkQuota(userId);
const submission = await this.submissions.get(messageId);
if (!submission || submission.sessionId !== sessionId) {
throw new CopilotMessageNotFound({ messageId });
}
if (retry) {
await this.sessions.revertLatestMessage(sessionId, true);
session.revertLatestMessage(true);
}
const turn = await this.sessions.appendTurn({
sessionId,
userId: session.config.userId,
prompt: { model: session.model },
compatSubmissionId: messageId,
turn: {
conversationId: sessionId,
role: 'user',
content: submission.content ?? '',
attachments: submission.attachments ?? [],
metadata: submission.params ?? {},
renderTrace: [],
toolEvents: [],
createdAt: submission.createdAt,
},
});
await this.submissions.markAccepted(messageId, {
sessionId,
turnId: turn.id ?? '',
});
session.pushPersistedTurn(turn);
return turn;
}
async prepareTurn(
userId: string,
sessionId: string,
query: Record<string, string | string[]>
): Promise<PreparedConversationTurn> {
const { messageId, retry, params } = ChatQuerySchema.parse(query);
const session = await this.sessions.get(sessionId);
if (!session || session.config.userId !== userId) {
throw new CopilotSessionNotFound();
}
const latestMessage = await this.appendSessionMessage(
userId,
session,
sessionId,
messageId,
retry
);
const currentUserMessage =
session.stashTurns.findLast(turn => turn.role === 'user') ??
latestMessage;
return {
messageId,
params,
session,
latestTurn: currentUserMessage,
};
}
buildLatestTurnPromptParams(latestTurn?: Turn): PromptParams {
if (!latestTurn) {
return {};
}
return {
...latestTurn.metadata,
content: latestTurn.content,
attachments: latestTurn.attachments,
};
}
async persistAssistantTurn(
session: ChatSession,
turn: Turn,
wasAborted: boolean
) {
const trace = wasAborted
? { renderTrace: [], toolEvents: [] }
: canonicalizeTurnTrace(turn);
const assistantTurn = {
...turn,
content: wasAborted ? '> Request aborted' : turn.content,
attachments: wasAborted ? [] : turn.attachments,
renderTrace: trace.renderTrace,
toolEvents: trace.toolEvents,
metadata: wasAborted ? {} : turn.metadata,
};
const persisted = await this.sessions.appendTurn({
sessionId: session.config.sessionId,
userId: session.config.userId,
prompt: { model: session.model },
turn: assistantTurn,
});
session.pushPersistedTurn(persisted);
return persisted.id ?? null;
}
}
@@ -0,0 +1,45 @@
import { createHash } from 'node:crypto';
import { Injectable } from '@nestjs/common';
import type { LlmImageResponse } from '../../../../native';
import { CopilotStorage } from '../../storage';
@Injectable()
export class ImageResultHost {
constructor(private readonly storage: CopilotStorage) {}
async persistRemoteLink(userId: string, workspaceId: string, link: string) {
return await this.storage.handleRemoteLink(userId, workspaceId, link);
}
async persistNativeArtifact(
userId: string,
workspaceId: string,
artifact: LlmImageResponse['images'][number] & { mimeType?: string }
) {
if (artifact.data_base64) {
const buffer = Buffer.from(artifact.data_base64, 'base64');
const filename = cryptoHash(buffer);
const mediaType = artifact.media_type ?? artifact.mimeType;
if (!mediaType) {
return null;
}
return await this.storage.put(
userId,
workspaceId,
filename,
buffer,
mediaType
);
}
if (artifact.url) {
return await this.persistRemoteLink(userId, workspaceId, artifact.url);
}
return null;
}
}
function cryptoHash(buffer: Buffer) {
return createHash('sha256').update(buffer).digest('base64url');
}
@@ -0,0 +1,50 @@
import { Injectable } from '@nestjs/common';
import { type Turn, turnFromChatMessage } from '../../core';
import type { StreamObject } from '../../providers/types';
import { StreamObjectParser } from '../../providers/utils';
@Injectable()
export class ResponsePostprocessor {
buildTextAssistantTurn(sessionId: string, content: string): Turn {
return {
conversationId: sessionId,
role: 'assistant',
content,
attachments: [],
renderTrace: [],
toolEvents: [],
metadata: {},
createdAt: new Date(),
};
}
buildObjectAssistantTurn(sessionId: string, chunks: StreamObject[]): Turn {
const parser = new StreamObjectParser();
const streamObjects = parser.mergeTextDelta(chunks);
const content = parser.mergeContent(streamObjects);
return turnFromChatMessage(
{
role: 'assistant',
content,
streamObjects,
createdAt: new Date(),
},
sessionId
);
}
buildImageAssistantTurn(sessionId: string, attachments: string[]): Turn {
return {
conversationId: sessionId,
role: 'assistant',
content: '',
attachments,
renderTrace: [],
toolEvents: [],
metadata: {},
createdAt: new Date(),
};
}
}
@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import type { NodeTextMiddleware } from '../../config';
import type {
CopilotChatOptions,
CopilotChatTools,
} from '../../providers/types';
import type { CopilotTool, CopilotToolSet } from '../../tools';
import type { ToolLoopBackend } from '../tool/bridge';
import { ToolRuntime } from '../tool-runtime';
export type ProviderSpecificToolResolver = (
toolName: CopilotChatTools,
model: string
) => [string, CopilotTool?] | undefined;
@Injectable()
export class ToolExecutorHost {
constructor(private readonly runtime: ToolRuntime) {}
async getTools(
options: CopilotChatOptions,
model: string,
resolveProviderSpecificTool?: ProviderSpecificToolResolver
): Promise<CopilotToolSet> {
return await this.runtime.getTools(
options,
model,
resolveProviderSpecificTool
);
}
createNativeAdapter(
backend: ToolLoopBackend,
tools: CopilotToolSet,
options: {
maxSteps?: number;
nodeTextMiddleware?: NodeTextMiddleware[];
} = {}
) {
return this.runtime.createNativeAdapter(backend, tools, options);
}
}
@@ -0,0 +1,72 @@
import { Injectable } from '@nestjs/common';
import type { Turn } from '../../core';
import type { StreamObject } from '../../providers/types';
import { ChatSession } from '../../session';
import { ConversationHost } from './conversation-host';
import { ResponsePostprocessor } from './response-postprocessor';
@Injectable()
export class TurnPersistence {
constructor(
private readonly conversations: ConversationHost,
private readonly postprocessor: ResponsePostprocessor
) {}
async persistTextResult(
session: ChatSession,
content: string,
wasAborted: boolean
) {
return await this.conversations.persistAssistantTurn(
session,
this.postprocessor.buildTextAssistantTurn(
session.config.sessionId,
content
),
wasAborted
);
}
async persistObjectResult(
session: ChatSession,
chunks: StreamObject[],
wasAborted: boolean
) {
return await this.conversations.persistAssistantTurn(
session,
this.postprocessor.buildObjectAssistantTurn(
session.config.sessionId,
chunks
),
wasAborted
);
}
async persistImageResult(
session: ChatSession,
attachments: string[],
wasAborted: boolean
) {
return await this.conversations.persistAssistantTurn(
session,
this.postprocessor.buildImageAssistantTurn(
session.config.sessionId,
attachments
),
wasAborted
);
}
async persistProjectedResult(
session: ChatSession,
turn: Turn,
wasAborted: boolean
) {
return await this.conversations.persistAssistantTurn(
session,
turn,
wasAborted
);
}
}
@@ -0,0 +1,55 @@
import { Injectable } from '@nestjs/common';
import { CopilotSessionInvalidInput } from '../../../base';
import { llmResolveRequestedModelMatch } from '../../../native';
import { CopilotProviderRegistryService } from '../providers/registry-service';
export type ResolveModelInput = {
defaultModel: string;
optionalModels?: string[] | null;
requestedModelId?: string;
};
@Injectable()
export class ModelSelectionPolicy {
constructor(private readonly registries: CopilotProviderRegistryService) {}
private getRegistry() {
return this.registries.getRegistry();
}
private matchRequestedModel(
optionalModels: string[],
requestedModelId?: string,
defaultModel?: string
) {
return llmResolveRequestedModelMatch({
providerIds: [...this.getRegistry().profiles.keys()],
optionalModels,
requestedModelId,
defaultModel,
});
}
resolveRequestedModel(input: ResolveModelInput): {
selectedModel: string;
matchedOptionalModel: boolean;
} {
if (!input.defaultModel) {
throw new CopilotSessionInvalidInput('Model is required');
}
const matched = this.matchRequestedModel(
input.optionalModels ?? [],
input.requestedModelId,
input.defaultModel
);
return {
selectedModel: matched.selectedModel ?? input.defaultModel,
matchedOptionalModel: matched.matchedOptionalModel,
};
}
matchesModelList(models: string[], modelId?: string) {
return this.matchRequestedModel(models, modelId).matchedOptionalModel;
}
}
@@ -0,0 +1,28 @@
import { NetworkError } from '../../../base';
const LLM_TIMEOUT_ERROR_PREFIX = 'llm_timeout:';
function nativeErrorMessage(error: unknown) {
if (error instanceof Error) {
return error.message;
}
if (
error &&
typeof error === 'object' &&
typeof (error as { message?: unknown }).message === 'string'
) {
return (error as { message: string }).message;
}
return typeof error === 'string' ? error : undefined;
}
export function mapNativeSemanticError(error: unknown): unknown {
const message = nativeErrorMessage(error);
if (message?.startsWith(LLM_TIMEOUT_ERROR_PREFIX)) {
return new NetworkError(
message.slice(LLM_TIMEOUT_ERROR_PREFIX.length).trim() ||
'LLM request timed out'
);
}
return error;
}
@@ -0,0 +1,370 @@
import { Injectable } from '@nestjs/common';
import { NoCopilotProviderAvailable } from '../../../base';
import {
llmDispatchPlan,
llmDispatchPlanStream,
type LlmDispatchResponse,
llmEmbeddingDispatchPlan,
llmImageDispatchPlan,
type LlmImageResponse,
llmRerankDispatchPlan,
llmStructuredDispatchPlan,
llmValidateJsonSchema,
parseNativeStructuredOutput,
} from '../../../native';
import { type StreamObject } from '../providers/types';
import { CopilotExecutionMetrics } from './execution-metrics';
import {
type ExecutionPlan,
type ExecutionPlanForKind,
type NativeChatDispatchPlan,
type NativeImageDispatchPlan,
} from './execution-plan';
import { mapNativeSemanticError } from './native-errors';
import {
createNativeToolLoopAdapter,
NativeProviderAdapter,
} from './tool/native-adapter';
function modelIdForError(modelId?: string) {
return modelId ?? 'auto';
}
type ExecutionPlanKind = ExecutionPlan['request']['kind'];
type ValueExecutionKind = Exclude<
ExecutionPlanKind,
'streamText' | 'streamObject' | 'image'
>;
type StreamExecutionKind = Extract<
ExecutionPlanKind,
'streamText' | 'streamObject'
>;
export type NativeImageArtifact = LlmImageResponse['images'][number];
function resolveAbortSignal(
signalOrOptions?: AbortSignal | { signal?: AbortSignal }
) {
return signalOrOptions &&
typeof signalOrOptions === 'object' &&
'aborted' in signalOrOptions
? signalOrOptions
: signalOrOptions?.signal;
}
function extractTextResponse(response: LlmDispatchResponse) {
return response.message.content
.filter(part => part.type === 'text' || part.type === 'reasoning')
.map(part => part.text)
.join('')
.trim();
}
function recordPreparedDispatch(
executionMetrics: CopilotExecutionMetrics | undefined,
plan: ExecutionPlan,
routeCount: number
) {
executionMetrics?.recordDispatch(
plan.request.kind,
'prepared_routes',
routeCount
);
}
function createNativeChatAdapter(dispatch: NativeChatDispatchPlan) {
if (dispatch.hasTools) {
return createNativeToolLoopAdapter(
{ preparedRoutes: dispatch.routes },
dispatch.prepared.tools,
{
maxSteps: dispatch.prepared.maxSteps,
nodeTextMiddleware: dispatch.prepared.postprocess?.nodeTextMiddleware,
}
);
}
const nativeDispatch = (
_nativeRequest: typeof dispatch.prepared.request,
signalOrOptions?: AbortSignal | { signal?: AbortSignal }
) =>
llmDispatchPlanStream({
preparedRoutes: dispatch.routes,
signal: resolveAbortSignal(signalOrOptions),
});
return new NativeProviderAdapter(nativeDispatch, {
nodeTextMiddleware: dispatch.prepared.postprocess?.nodeTextMiddleware,
});
}
async function runPreparedValuePlan<TResult>(
plan: ExecutionPlan,
routeCount: number,
executionMetrics: CopilotExecutionMetrics | undefined,
run: () => Promise<TResult>
) {
recordPreparedDispatch(executionMetrics, plan, routeCount);
try {
return await run();
} catch (error) {
throw mapNativeSemanticError(error);
}
}
async function* mapPreparedStreamErrors<T>(
source: AsyncIterable<T>
): AsyncIterableIterator<T> {
try {
yield* source;
} catch (error) {
throw mapNativeSemanticError(error);
}
}
async function runChatValuePlan(
plan: ExecutionPlan,
dispatch: NativeChatDispatchPlan,
executionMetrics?: CopilotExecutionMetrics
) {
const adapter = createNativeChatAdapter(dispatch);
return await runPreparedValuePlan(
plan,
dispatch.routes.length,
executionMetrics,
async () => {
if (
!dispatch.hasTools &&
!dispatch.prepared.postprocess?.nodeTextMiddleware?.length
) {
const result = await llmDispatchPlan({
preparedRoutes: dispatch.routes,
});
return extractTextResponse(result.response);
}
if (plan.request.kind !== 'text') {
throw new Error('chat value dispatch requires text plan');
}
return await adapter.text(
dispatch.prepared.request,
plan.hostContext.signal,
plan.request.messages
);
}
);
}
async function* runChatStreamPlan(
plan: ExecutionPlan,
dispatch: NativeChatDispatchPlan,
executionMetrics?: CopilotExecutionMetrics
): AsyncIterableIterator<string | StreamObject> {
const adapter = createNativeChatAdapter(dispatch);
recordPreparedDispatch(executionMetrics, plan, dispatch.routes.length);
if (plan.request.kind === 'streamText') {
yield* mapPreparedStreamErrors(
adapter.streamText(
dispatch.prepared.request,
plan.hostContext.signal,
plan.request.messages
)
);
return;
}
if (plan.request.kind === 'streamObject') {
yield* mapPreparedStreamErrors(
adapter.streamObject(
dispatch.prepared.request,
plan.hostContext.signal,
plan.request.messages
)
);
return;
}
throw new Error('chat stream dispatch requires streamText/streamObject plan');
}
async function* runPreparedImageArtifactPlan(
dispatch: NativeImageDispatchPlan,
plan: ExecutionPlan,
executionMetrics?: CopilotExecutionMetrics
): AsyncIterableIterator<NativeImageArtifact> {
if (plan.request.kind !== 'image') {
throw new Error('image dispatch requires image plan');
}
recordPreparedDispatch(executionMetrics, plan, dispatch.routes.length);
let result;
try {
result = await llmImageDispatchPlan({
preparedRoutes: dispatch.routes,
});
} catch (error) {
throw mapNativeSemanticError(error);
}
for (const artifact of result.response.images) {
yield artifact;
}
}
async function executePreparedPlan(
plan: ExecutionPlan,
executionMetrics?: CopilotExecutionMetrics
): Promise<string | number[][] | number[] | null> {
switch (plan.request.kind) {
case 'text': {
const dispatch = plan.nativeDispatch?.chat;
return dispatch
? await runChatValuePlan(plan, dispatch, executionMetrics)
: null;
}
case 'structured': {
const dispatch = plan.nativeDispatch?.structured;
if (!dispatch) {
return null;
}
return await runPreparedValuePlan(
plan,
dispatch.routes.length,
executionMetrics,
async () => {
const result = await llmStructuredDispatchPlan({
preparedRoutes: dispatch.routes,
});
const parsed = parseNativeStructuredOutput(result.response);
const validated = llmValidateJsonSchema(
dispatch.prepared.request.schema,
parsed
);
return JSON.stringify(validated);
}
);
}
case 'embedding': {
const dispatch = plan.nativeDispatch?.embedding;
if (!dispatch) {
return null;
}
return await runPreparedValuePlan(
plan,
dispatch.routes.length,
executionMetrics,
async () => {
const result = await llmEmbeddingDispatchPlan({
preparedRoutes: dispatch.routes,
});
return result.response.embeddings;
}
);
}
case 'rerank': {
const dispatch = plan.nativeDispatch?.rerank;
if (!dispatch) {
return null;
}
return await runPreparedValuePlan(
plan,
dispatch.routes.length,
executionMetrics,
async () => {
const result = await llmRerankDispatchPlan({
preparedRoutes: dispatch.routes,
});
return result.response.scores;
}
);
}
default:
return null;
}
}
function executePreparedStreamPlan(
plan: ExecutionPlan,
executionMetrics?: CopilotExecutionMetrics
): AsyncIterableIterator<string | StreamObject> | null {
switch (plan.request.kind) {
case 'streamText':
case 'streamObject': {
const dispatch = plan.nativeDispatch?.chat;
return dispatch
? runChatStreamPlan(plan, dispatch, executionMetrics)
: null;
}
default:
return null;
}
}
function noRouteStream<T>(plan: ExecutionPlan) {
return (async function* (): AsyncIterableIterator<T> {
yield* [] as T[];
throw new NoCopilotProviderAvailable({
modelId: modelIdForError(plan.request.cond.modelId),
});
})();
}
@Injectable()
export class NativeExecutionEngine {
constructor(private readonly executionMetrics?: CopilotExecutionMetrics) {}
private noRoute(plan: ExecutionPlan): never {
throw new NoCopilotProviderAvailable({
modelId: modelIdForError(plan.request.cond.modelId),
});
}
async execute(
plan: ExecutionPlanForKind<'text' | 'structured'>
): Promise<string>;
async execute(plan: ExecutionPlanForKind<'embedding'>): Promise<number[][]>;
async execute(plan: ExecutionPlanForKind<'rerank'>): Promise<number[]>;
async execute(
plan: ExecutionPlanForKind<ValueExecutionKind>
): Promise<string | number[][] | number[]> {
const result = await executePreparedPlan(plan, this.executionMetrics);
if (result === null) {
return this.noRoute(plan);
}
return result;
}
executeStream(
plan: ExecutionPlanForKind<'streamText'>
): AsyncIterableIterator<string>;
executeStream(
plan: ExecutionPlanForKind<'streamObject'>
): AsyncIterableIterator<StreamObject>;
executeStream(
plan: ExecutionPlanForKind<StreamExecutionKind>
): AsyncIterableIterator<string | StreamObject> {
const result = executePreparedStreamPlan(plan, this.executionMetrics);
if (result) {
return result;
}
return noRouteStream(plan);
}
executeImageArtifacts(
plan: ExecutionPlanForKind<'image'>
): AsyncIterableIterator<NativeImageArtifact> {
const dispatch = plan.nativeDispatch?.image;
if (dispatch) {
return runPreparedImageArtifactPlan(
dispatch,
plan,
this.executionMetrics
);
}
return noRouteStream(plan);
}
}
@@ -0,0 +1,255 @@
import { CopilotPromptInvalid } from '../../../base';
import {
llmBuildCanonicalRequest,
llmBuildCanonicalStructuredRequest,
type LlmRequest,
type LlmStructuredRequest,
type NativePromptMessageInput,
} from '../../../native';
import type { ProviderMiddlewareConfig } from '../config';
import { applyPromptAttachmentMimeTypeHintForNative } from '../providers/attachments';
import type {
CopilotChatOptions,
CopilotStructuredOptions,
ModelAttachmentCapability,
PromptMessage,
} from '../providers/types';
import { type StructuredOutputContract, type ToolContract } from './contracts';
export type BuildCanonicalNativeRequestOptions = {
model: string;
messages: PromptMessage[];
options?: CopilotChatOptions | CopilotStructuredOptions;
toolContracts?: ToolContract[];
withAttachment?: boolean;
attachmentCapability?: ModelAttachmentCapability;
include?: string[];
reasoning?: Record<string, unknown>;
responseContract?: StructuredOutputContract;
middleware?: ProviderMiddlewareConfig;
};
export type BuildCanonicalNativeRequestResult = {
request: LlmRequest;
};
export type BuildCanonicalNativeStructuredRequestResult = {
request: LlmStructuredRequest;
};
type BuildCanonicalNativeStructuredRequestOptions = Omit<
BuildCanonicalNativeRequestOptions,
'toolContracts' | 'include' | 'options' | 'responseContract'
> & {
options?: CopilotStructuredOptions;
responseContract: StructuredOutputContract;
};
type BuildNativeStructuredRequestResult = {
request: LlmStructuredRequest;
};
function mapNativeRequestBuilderError(error: unknown): never {
if (error instanceof CopilotPromptInvalid) {
throw error;
}
if (
error instanceof Error &&
/Native path does not support .*attachments?|Schema is required/i.test(
error.message
)
) {
throw new CopilotPromptInvalid(error.message);
}
throw error;
}
export function preparePromptMessagesForNativeRequest(
messages: PromptMessage[],
withAttachment: boolean
): NativePromptMessageInput[] {
return messages.map(message => {
const attachments =
withAttachment && Array.isArray(message.attachments)
? message.attachments.map(attachment =>
applyPromptAttachmentMimeTypeHintForNative(attachment, message)
)
: undefined;
return {
role: message.role,
content: message.content,
...(attachments?.length ? { attachments } : {}),
};
});
}
export async function buildCanonicalNativeRequest({
model,
messages,
options = {},
toolContracts = [],
withAttachment = true,
attachmentCapability,
include,
reasoning,
responseContract,
middleware,
}: BuildCanonicalNativeRequestOptions): Promise<BuildCanonicalNativeRequestResult> {
const copiedMessages = messages.map(message => ({
...message,
attachments: message.attachments
? [...message.attachments]
: message.attachments,
}));
const explicitResponseContract = responseContract ?? {};
const normalizedMessages = preparePromptMessagesForNativeRequest(
copiedMessages,
withAttachment
);
let request: LlmRequest;
try {
request = llmBuildCanonicalRequest({
model,
messages: normalizedMessages,
maxTokens: options.maxTokens ?? undefined,
temperature: options.temperature ?? undefined,
tools: toolContracts,
include,
reasoning,
responseSchema: explicitResponseContract.responseSchemaJson,
attachmentCapability: attachmentCapability
? {
kinds: attachmentCapability.kinds,
sourceKinds: attachmentCapability.sourceKinds,
allowRemoteUrls: attachmentCapability.allowRemoteUrls,
}
: undefined,
middleware: middleware?.rust
? { request: middleware.rust.request, stream: middleware.rust.stream }
: undefined,
});
} catch (error) {
mapNativeRequestBuilderError(error);
}
return {
request,
};
}
export async function buildCanonicalNativeStructuredRequest({
model,
messages,
options = {},
withAttachment = true,
attachmentCapability,
reasoning,
responseContract,
middleware,
}: BuildCanonicalNativeStructuredRequestOptions): Promise<BuildCanonicalNativeStructuredRequestResult> {
const copiedMessages = messages.map(message => ({
...message,
attachments: message.attachments
? [...message.attachments]
: message.attachments,
}));
const explicitResponseContract = responseContract;
if (!explicitResponseContract?.responseSchemaJson) {
throw new CopilotPromptInvalid('Schema is required');
}
const normalizedMessages = preparePromptMessagesForNativeRequest(
copiedMessages,
withAttachment
);
let request: LlmStructuredRequest;
try {
request = llmBuildCanonicalStructuredRequest({
model,
messages: normalizedMessages,
schema: explicitResponseContract?.responseSchemaJson,
maxTokens: options.maxTokens ?? undefined,
temperature: options.temperature ?? undefined,
reasoning,
strict: options.strict,
responseMimeType: 'application/json',
attachmentCapability: attachmentCapability
? {
kinds: attachmentCapability.kinds,
sourceKinds: attachmentCapability.sourceKinds,
allowRemoteUrls: attachmentCapability.allowRemoteUrls,
}
: undefined,
middleware: middleware?.rust
? { request: middleware.rust.request }
: undefined,
});
} catch (error) {
mapNativeRequestBuilderError(error);
}
return { request };
}
export async function buildNativeRequest({
model,
messages,
options = {},
toolContracts = [],
withAttachment = true,
attachmentCapability,
include,
reasoning,
responseContract,
middleware,
}: BuildCanonicalNativeRequestOptions): Promise<BuildCanonicalNativeRequestResult> {
const { request } = await buildCanonicalNativeRequest({
model,
messages,
options,
toolContracts,
withAttachment,
attachmentCapability,
include,
reasoning,
responseContract,
middleware,
});
return {
request: {
...request,
stream: true,
},
};
}
export async function buildNativeStructuredRequest({
model,
messages,
options = {},
withAttachment = true,
attachmentCapability,
reasoning,
responseContract,
middleware,
}: Omit<
BuildCanonicalNativeRequestOptions,
'toolContracts' | 'include' | 'responseContract'
> & {
responseContract: StructuredOutputContract;
}): Promise<BuildNativeStructuredRequestResult> {
return await buildCanonicalNativeStructuredRequest({
model,
messages,
options,
withAttachment,
attachmentCapability,
reasoning,
responseContract,
middleware,
});
}
@@ -0,0 +1,111 @@
import { Injectable } from '@nestjs/common';
import { CopilotPromptNotFound } from '../../../base';
import { PromptService } from '../prompt/service';
import {
type CopilotChatOptions,
type CopilotProviderType,
type CopilotStructuredOptions,
type PromptMessage,
type PromptParams,
} from '../providers/types';
import { CapabilityRuntime } from './capability-runtime';
import type { RequiredStructuredOutputContract } from './contracts';
import { CapabilityPolicyHost } from './hosts/capability-policy-host';
type PromptRuntimeStructuredContract = RequiredStructuredOutputContract;
type PromptRuntimeStructuredProviderOptions = Omit<
NonNullable<CopilotStructuredOptions>,
'responseSchemaJson' | 'schemaHash'
>;
@Injectable()
export class PromptRuntime {
constructor(
private readonly prompts: PromptService,
private readonly capabilityPolicy: CapabilityPolicyHost,
private readonly runtime: CapabilityRuntime
) {}
private async preparePrompt(
promptName: string,
params: PromptParams,
options: {
modelId?: string;
prefer?: CopilotProviderType;
appendMessages?: PromptMessage[];
} = {}
) {
const prompt = await this.prompts.get(promptName);
if (!prompt) {
throw new CopilotPromptNotFound({ name: promptName });
}
return {
prompt,
modelId: await this.capabilityPolicy.resolvePromptModel({
defaultModel: prompt.model,
optionalModels: prompt.optionalModels,
requestedModelId: options.modelId,
}),
finalMessages: [
...this.prompts.finish(prompt, params),
...(options.appendMessages ?? []),
],
prefer: options.prefer,
};
}
async runText(
promptName: string,
params: PromptParams,
options: {
modelId?: string;
prefer?: CopilotProviderType;
appendMessages?: PromptMessage[];
providerOptions?: CopilotChatOptions;
} = {}
) {
const prepared = await this.preparePrompt(promptName, params, options);
return await this.runtime.text(
{ modelId: prepared.modelId },
prepared.finalMessages,
{
...prepared.prompt.config,
...options.providerOptions,
},
{ prefer: prepared.prefer }
);
}
async runStructured(
promptName: string,
params: PromptParams,
options: {
responseContract: PromptRuntimeStructuredContract;
modelId?: string;
prefer?: CopilotProviderType;
appendMessages?: PromptMessage[];
providerOptions?: PromptRuntimeStructuredProviderOptions;
strict?: boolean;
}
) {
const prepared = await this.preparePrompt(promptName, params, options);
return await this.runtime.generateStructuredValue(
{ modelId: prepared.modelId },
prepared.finalMessages,
{
...prepared.prompt.config,
...options.providerOptions,
responseSchemaJson: options.responseContract.responseSchemaJson,
schemaHash: options.responseContract.schemaHash,
strict: options.strict,
},
options.responseContract,
{ prefer: prepared.prefer }
);
}
}
@@ -0,0 +1,234 @@
import type {
CopilotProviderExecution,
PreparedNativeExecution,
PreparedNativeRequestOptions,
ProviderChatDriver,
ProviderChatDriverPrepareInput,
} from '../providers/provider-runtime-contract';
import type {
CopilotChatOptions,
CopilotProviderModel,
CopilotProviderType,
ModelConditions,
ModelFullConditions,
PromptMessage,
StreamObject,
} from '../providers/types';
import { ModelOutputType } from '../providers/types';
import {
resolveDriverOrThrow,
resolvePreparedModelId,
runPreparedExecution,
} from './provider-driver-runtime';
import type { NativeProviderAdapter } from './tool/native-adapter';
type MetricLabels = Record<string, string | number | boolean | undefined>;
export type ChatRuntimeContext = {
type: CopilotProviderType;
resolveChatDriver: () => ProviderChatDriver | undefined;
selectModel: (cond: ModelFullConditions) => CopilotProviderModel;
metricLabels: (
model: string,
labels?: MetricLabels,
execution?: CopilotProviderExecution
) => MetricLabels;
createPreparedExecutionAdapter: (
prepared: PreparedNativeExecution
) => NativeProviderAdapter;
};
type ChatExecutionMode = {
kind: ProviderChatDriverPrepareInput['kind'];
outputType: ModelOutputType;
unsupportedKind: 'text' | 'object';
callMetric: string;
errorMetric: string;
};
export async function prepareNativeChatExecution(
resolveChatDriver: () => ProviderChatDriver | undefined,
buildPreparedNativeExecution: (
options: PreparedNativeRequestOptions
) => Promise<PreparedNativeExecution>,
input: ProviderChatDriverPrepareInput
): Promise<PreparedNativeExecution | null> {
const driver = resolveChatDriver();
if (!driver) {
return null;
}
const prepared = await driver.prepare(input);
if (!prepared) {
return null;
}
return await buildPreparedNativeExecution({
...prepared,
execution: input.execution,
options: input.options,
});
}
async function runNativeChat(
context: ChatRuntimeContext,
prepareNativeExecution: (
kind: ProviderChatDriverPrepareInput['kind'],
cond: ModelConditions,
messages: PromptMessage[],
options?: CopilotChatOptions,
execution?: CopilotProviderExecution
) => Promise<PreparedNativeExecution | null>,
mode: ChatExecutionMode,
model: ModelConditions,
messages: PromptMessage[],
options: CopilotChatOptions | undefined,
execution: CopilotProviderExecution | undefined,
run: (
adapter: NativeProviderAdapter,
prepared: PreparedNativeExecution,
signal: AbortSignal | undefined,
promptMessages: PromptMessage[]
) =>
| Promise<string>
| AsyncIterableIterator<string>
| AsyncIterableIterator<StreamObject>
) {
const driver = resolveDriverOrThrow(
context.type,
mode.unsupportedKind,
context.resolveChatDriver
);
const chatOptions = options ?? {};
const prepared = await prepareNativeExecution(
mode.kind,
model,
messages,
chatOptions,
execution
);
const modelId = resolvePreparedModelId(
context,
model,
mode.outputType,
prepared
);
return await runPreparedExecution({
driver,
prepared,
modelId,
execution,
metricContext: context,
metricsName: {
call: mode.callMetric,
error: mode.errorMetric,
},
execute: async preparedExecution =>
await run(
context.createPreparedExecutionAdapter(preparedExecution),
preparedExecution,
chatOptions.signal,
messages
),
});
}
export async function runNativeText(
context: ChatRuntimeContext,
prepareNativeExecution: (
kind: ProviderChatDriverPrepareInput['kind'],
cond: ModelConditions,
messages: PromptMessage[],
options?: CopilotChatOptions,
execution?: CopilotProviderExecution
) => Promise<PreparedNativeExecution | null>,
model: ModelConditions,
messages: PromptMessage[],
options?: CopilotChatOptions,
execution?: CopilotProviderExecution
) {
return (await runNativeChat(
context,
prepareNativeExecution,
{
kind: 'text',
outputType: ModelOutputType.Text,
unsupportedKind: 'text',
callMetric: 'chat_text_calls',
errorMetric: 'chat_text_errors',
},
model,
messages,
options,
execution,
(adapter, prepared, signal, promptMessages) =>
adapter.text(prepared.request, signal, promptMessages)
)) as string;
}
export async function* runNativeStreamText(
context: ChatRuntimeContext,
prepareNativeExecution: (
kind: ProviderChatDriverPrepareInput['kind'],
cond: ModelConditions,
messages: PromptMessage[],
options?: CopilotChatOptions,
execution?: CopilotProviderExecution
) => Promise<PreparedNativeExecution | null>,
model: ModelConditions,
messages: PromptMessage[],
options?: CopilotChatOptions,
execution?: CopilotProviderExecution
): AsyncIterableIterator<string> {
yield* (await runNativeChat(
context,
prepareNativeExecution,
{
kind: 'streamText',
outputType: ModelOutputType.Text,
unsupportedKind: 'text',
callMetric: 'chat_text_stream_calls',
errorMetric: 'chat_text_stream_errors',
},
model,
messages,
options,
execution,
(adapter, prepared, signal, promptMessages) =>
adapter.streamText(prepared.request, signal, promptMessages)
)) as AsyncIterableIterator<string>;
}
export async function* runNativeStreamObject(
context: ChatRuntimeContext,
prepareNativeExecution: (
kind: ProviderChatDriverPrepareInput['kind'],
cond: ModelConditions,
messages: PromptMessage[],
options?: CopilotChatOptions,
execution?: CopilotProviderExecution
) => Promise<PreparedNativeExecution | null>,
model: ModelConditions,
messages: PromptMessage[],
options?: CopilotChatOptions,
execution?: CopilotProviderExecution
): AsyncIterableIterator<StreamObject> {
yield* (await runNativeChat(
context,
prepareNativeExecution,
{
kind: 'streamObject',
outputType: ModelOutputType.Object,
unsupportedKind: 'object',
callMetric: 'chat_object_stream_calls',
errorMetric: 'chat_object_stream_errors',
},
model,
messages,
options,
execution,
(adapter, prepared, signal, promptMessages) =>
adapter.streamObject(prepared.request, signal, promptMessages)
)) as AsyncIterableIterator<StreamObject>;
}
@@ -0,0 +1,682 @@
import {
CopilotPromptInvalid,
CopilotProviderNotSupported,
metrics,
} from '../../../base';
import {
buildLlmEmbeddingRequest,
buildLlmRerankRequest,
type LlmBackendConfig,
type LlmEmbeddingRequest,
type LlmProtocol,
type LlmRerankRequest,
type LlmStructuredRequest,
type LlmStructuredResponse,
llmValidateJsonSchema,
parseNativeStructuredOutput,
} from '../../../native';
import type { ProviderMiddlewareConfig } from '../config';
import { resolveProviderModelRoute } from '../providers/provider-model-runtime';
import type {
CopilotProviderExecution,
EmbeddingProviderDriver,
ImageProviderDriver,
PreparedNativeEmbeddingExecution,
PreparedNativeImageExecution,
PreparedNativeRerankExecution,
PreparedNativeStructuredExecution,
RerankProviderDriver,
StructuredProviderDriver,
} from '../providers/provider-runtime-contract';
import type {
CopilotChatOptions,
CopilotEmbeddingOptions,
CopilotImageOptions,
CopilotProviderModel,
CopilotProviderType,
CopilotRerankRequest,
CopilotStructuredOptions,
ModelAttachmentCapability,
ModelConditions,
ModelFullConditions,
PromptMessage,
} from '../providers/types';
import { ModelOutputType } from '../providers/types';
import { type RequiredStructuredOutputContract } from './contracts';
import { buildNativeStructuredRequest } from './native-request-runtime';
const DEFAULT_EMBEDDING_TASK_TYPE = 'RETRIEVAL_DOCUMENT';
type MetricLabels = Record<string, string | number | boolean | undefined>;
type DriverMetricNames = {
call: string;
error: string;
};
export type StructuredRuntimeContext = {
type: CopilotProviderType;
resolveStructuredDriver: () => StructuredProviderDriver | undefined;
checkParams: (input: {
cond: ModelFullConditions;
messages?: PromptMessage[];
embeddings?: string[];
options?:
| CopilotChatOptions
| CopilotStructuredOptions
| CopilotImageOptions;
withAttachment?: boolean;
execution?: CopilotProviderExecution;
}) => Promise<ModelFullConditions>;
selectModel: (
cond: ModelFullConditions,
execution?: CopilotProviderExecution
) => CopilotProviderModel;
getAttachCapability: (
model: CopilotProviderModel,
outputType: ModelOutputType
) => ModelAttachmentCapability | undefined;
getActiveProviderMiddleware: (
execution?: CopilotProviderExecution
) => ProviderMiddlewareConfig;
buildPreparedNativeStructuredExecution: (
protocol: LlmProtocol,
backendConfig: LlmBackendConfig,
model: string,
request: LlmStructuredRequest,
execution?: CopilotProviderExecution
) => PreparedNativeStructuredExecution;
createNativeStructuredDispatch: (
backendConfig: LlmBackendConfig,
protocol: LlmProtocol,
execution?: CopilotProviderExecution
) => (request: LlmStructuredRequest) => Promise<LlmStructuredResponse>;
metricLabels: (
model: string,
labels?: MetricLabels,
execution?: CopilotProviderExecution
) => MetricLabels;
};
export type EmbeddingRuntimeContext = {
type: CopilotProviderType;
resolveEmbeddingDriver: () => EmbeddingProviderDriver | undefined;
checkParams: (input: {
cond: ModelFullConditions;
messages?: PromptMessage[];
embeddings?: string[];
options?:
| CopilotChatOptions
| CopilotStructuredOptions
| CopilotImageOptions;
withAttachment?: boolean;
execution?: CopilotProviderExecution;
}) => Promise<ModelFullConditions>;
selectModel: (
cond: ModelFullConditions,
execution?: CopilotProviderExecution
) => CopilotProviderModel;
buildPreparedNativeEmbeddingExecution: (
protocol: LlmProtocol,
backendConfig: LlmBackendConfig,
model: string,
request: LlmEmbeddingRequest,
execution?: CopilotProviderExecution
) => PreparedNativeEmbeddingExecution;
createNativeEmbeddingDispatch: (
backendConfig: LlmBackendConfig,
protocol: LlmProtocol,
execution?: CopilotProviderExecution
) => (request: LlmEmbeddingRequest) => Promise<{ embeddings: number[][] }>;
metricLabels: (
model: string,
labels?: MetricLabels,
execution?: CopilotProviderExecution
) => MetricLabels;
};
export type RerankRuntimeContext = {
type: CopilotProviderType;
resolveRerankDriver: () => RerankProviderDriver | undefined;
checkParams: (input: {
cond: ModelFullConditions;
messages?: PromptMessage[];
embeddings?: string[];
options?:
| CopilotChatOptions
| CopilotStructuredOptions
| CopilotImageOptions;
withAttachment?: boolean;
execution?: CopilotProviderExecution;
}) => Promise<ModelFullConditions>;
selectModel: (
cond: ModelFullConditions,
execution?: CopilotProviderExecution
) => CopilotProviderModel;
buildPreparedNativeRerankExecution: (
protocol: LlmProtocol,
backendConfig: LlmBackendConfig,
model: string,
request: LlmRerankRequest,
execution?: CopilotProviderExecution
) => PreparedNativeRerankExecution;
createNativeRerankDispatch: (
backendConfig: LlmBackendConfig,
protocol: LlmProtocol,
execution?: CopilotProviderExecution
) => (request: LlmRerankRequest) => Promise<{ scores: number[] }>;
};
export type ImageRuntimeContext = {
type: CopilotProviderType;
resolveImageDriver: () => ImageProviderDriver | undefined;
checkParams: (input: {
cond: ModelFullConditions;
messages?: PromptMessage[];
options?: CopilotImageOptions;
withAttachment?: boolean;
execution?: CopilotProviderExecution;
}) => Promise<ModelFullConditions>;
selectModel: (
cond: ModelFullConditions,
execution?: CopilotProviderExecution
) => CopilotProviderModel;
buildPreparedNativeImageExecution: (
protocol: LlmProtocol,
backendConfig: LlmBackendConfig,
model: string,
messages: PromptMessage[],
options?: CopilotImageOptions,
execution?: CopilotProviderExecution
) => PreparedNativeImageExecution;
};
type NativeExecutionDriverBase = {
createBackendConfig: (
execution?: CopilotProviderExecution
) => Promise<LlmBackendConfig> | LlmBackendConfig;
mapError: (error: unknown) => unknown;
};
type ModelSelectionContext = {
selectModel: (
cond: ModelFullConditions,
execution?: CopilotProviderExecution
) => CopilotProviderModel;
};
type MetricContext = {
metricLabels: (
model: string,
labels?: MetricLabels,
execution?: CopilotProviderExecution
) => MetricLabels;
};
type RoutedPreparedExecution = {
route: {
model: string;
backendConfig: LlmBackendConfig;
protocol: LlmProtocol;
};
};
export function resolveDriverOrThrow<TDriver>(
type: CopilotProviderType,
kind: string,
resolveDriver: () => TDriver | undefined
) {
const driver = resolveDriver();
if (!driver) {
throw new CopilotProviderNotSupported({
provider: type,
kind,
});
}
return driver;
}
export function resolvePreparedModelId(
context: ModelSelectionContext,
cond: ModelConditions,
outputType: ModelOutputType,
prepared?: RoutedPreparedExecution | null
) {
return (
prepared?.route.model ??
context.selectModel({
...cond,
outputType,
}).id
);
}
async function prepareNativeExecutionBase<
TDriver extends NativeExecutionDriverBase,
TPrepared,
>({
resolveDriver,
cond,
outputType,
checkParams,
selectModel,
execution,
checkInput,
buildPrepared,
}: {
resolveDriver: () => TDriver | undefined;
cond: ModelConditions;
outputType: ModelOutputType;
checkParams: (input: {
cond: ModelFullConditions;
messages?: PromptMessage[];
embeddings?: string[];
options?:
| CopilotChatOptions
| CopilotStructuredOptions
| CopilotImageOptions;
withAttachment?: boolean;
execution?: CopilotProviderExecution;
}) => Promise<ModelFullConditions>;
selectModel: (
cond: ModelFullConditions,
execution?: CopilotProviderExecution
) => CopilotProviderModel;
execution?: CopilotProviderExecution;
checkInput: {
messages?: PromptMessage[];
embeddings?: string[];
options?:
| CopilotChatOptions
| CopilotStructuredOptions
| CopilotImageOptions;
withAttachment?: boolean;
};
buildPrepared: (args: {
driver: TDriver;
model: CopilotProviderModel;
backendConfig: LlmBackendConfig;
protocol: LlmProtocol;
}) => Promise<TPrepared> | TPrepared;
}): Promise<TPrepared | null> {
const driver = resolveDriver();
if (!driver) {
return null;
}
const normalizedCond = await checkParams({
...checkInput,
cond: { ...cond, outputType },
execution,
});
const model = selectModel(normalizedCond, execution);
const backendConfig = await driver.createBackendConfig(execution);
const route = resolveProviderModelRoute(model, outputType);
if (!route.protocol) {
throw new Error(`Missing native protocol for model ${model.id}`);
}
return await buildPrepared({
driver,
model,
backendConfig:
route.requestLayer === backendConfig.request_layer
? backendConfig
: { ...backendConfig, request_layer: route.requestLayer },
protocol: route.protocol,
});
}
export async function runPreparedExecution<
TPrepared extends RoutedPreparedExecution,
TResult,
>({
driver,
prepared,
modelId,
execution,
metricContext,
metricsName,
execute,
}: {
driver: Pick<NativeExecutionDriverBase, 'mapError'>;
prepared: TPrepared | null;
modelId: string;
execution?: CopilotProviderExecution;
metricContext?: MetricContext;
metricsName?: DriverMetricNames;
execute: (prepared: TPrepared) => Promise<TResult>;
}): Promise<TResult> {
try {
if (metricsName && metricContext) {
metrics.ai
.counter(metricsName.call)
.add(1, metricContext.metricLabels(modelId, {}, execution));
}
if (!prepared) {
throw new Error('native route is not available');
}
return await execute(prepared);
} catch (error) {
if (metricsName && metricContext) {
metrics.ai
.counter(metricsName.error)
.add(1, metricContext.metricLabels(modelId, {}, execution));
}
throw driver.mapError(error);
}
}
export async function prepareNativeStructuredExecution(
context: StructuredRuntimeContext,
cond: ModelConditions,
messages: PromptMessage[],
options: CopilotStructuredOptions = {},
responseContract?: RequiredStructuredOutputContract,
execution?: CopilotProviderExecution
): Promise<PreparedNativeStructuredExecution | null> {
const driver = context.resolveStructuredDriver();
if (!driver) {
return null;
}
const structuredOptions = options ?? {};
const normalizedCond = await context.checkParams({
messages,
cond: { ...cond, outputType: ModelOutputType.Structured },
options: structuredOptions,
execution,
});
const model = context.selectModel(normalizedCond, execution);
const backendConfig = await driver.createBackendConfig(execution);
const route = resolveProviderModelRoute(model, ModelOutputType.Structured);
if (!route.protocol) {
throw new Error(`Missing native protocol for model ${model.id}`);
}
const preparedMessages = driver.prepareMessages
? await driver.prepareMessages(messages, backendConfig, structuredOptions)
: messages;
if (!responseContract) {
throw new CopilotPromptInvalid('Schema is required');
}
const { request } = await buildNativeStructuredRequest({
model: model.id,
messages: preparedMessages,
options: structuredOptions,
responseContract,
attachmentCapability: context.getAttachCapability(
model,
ModelOutputType.Structured
),
middleware: context.getActiveProviderMiddleware(execution),
});
return context.buildPreparedNativeStructuredExecution(
route.protocol,
route.requestLayer === backendConfig.request_layer
? backendConfig
: { ...backendConfig, request_layer: route.requestLayer },
model.id,
request,
execution
);
}
export async function runNativeStructured(
context: StructuredRuntimeContext,
cond: ModelConditions,
messages: PromptMessage[],
options: CopilotStructuredOptions = {},
responseContract?: RequiredStructuredOutputContract,
execution?: CopilotProviderExecution
) {
const driver = resolveDriverOrThrow(
context.type,
'structure',
context.resolveStructuredDriver
);
const structuredOptions = options ?? {};
const prepared = await prepareNativeStructuredExecution(
context,
cond,
messages,
structuredOptions,
responseContract,
execution
);
const modelId = resolvePreparedModelId(
context,
cond,
ModelOutputType.Structured,
prepared
);
return await runPreparedExecution({
driver,
prepared,
modelId,
execution,
metricContext: context,
metricsName: {
call: 'chat_text_calls',
error: 'chat_text_errors',
},
execute: async preparedExecution => {
const dispatch = context.createNativeStructuredDispatch(
preparedExecution.route.backendConfig,
preparedExecution.route.protocol,
execution
);
for (let attempt = 0; ; attempt++) {
try {
const response = await dispatch(preparedExecution.request);
const parsed = parseNativeStructuredOutput(response);
const validated = llmValidateJsonSchema(
preparedExecution.request.schema,
parsed
);
return JSON.stringify(validated);
} catch (error) {
if (
!(await driver.shouldRetry?.({
error,
attempt,
options: structuredOptions,
}))
) {
throw error;
}
}
}
},
});
}
export async function prepareNativeEmbeddingExecution(
context: EmbeddingRuntimeContext,
cond: ModelConditions,
input: string | string[],
options: CopilotEmbeddingOptions = {},
execution?: CopilotProviderExecution
): Promise<PreparedNativeEmbeddingExecution | null> {
const values = Array.isArray(input) ? input : [input];
return await prepareNativeExecutionBase({
resolveDriver: context.resolveEmbeddingDriver,
cond,
outputType: ModelOutputType.Embedding,
checkParams: context.checkParams,
selectModel: context.selectModel,
execution,
checkInput: {
embeddings: values,
options,
},
buildPrepared: ({ driver, model, backendConfig, protocol }) =>
context.buildPreparedNativeEmbeddingExecution(
protocol,
backendConfig,
model.id,
buildLlmEmbeddingRequest({
model: model.id,
inputs: values,
dimensions: options?.dimensions ?? driver.defaultDimensions,
taskType: driver.taskType ?? DEFAULT_EMBEDDING_TASK_TYPE,
}),
execution
),
});
}
export async function runNativeEmbedding(
context: EmbeddingRuntimeContext,
cond: ModelConditions,
input: string | string[],
options?: CopilotEmbeddingOptions,
execution?: CopilotProviderExecution
) {
const driver = resolveDriverOrThrow(
context.type,
ModelOutputType.Embedding,
context.resolveEmbeddingDriver
);
const prepared = await prepareNativeEmbeddingExecution(
context,
cond,
input,
options,
execution
);
const modelId = resolvePreparedModelId(
context,
cond,
ModelOutputType.Embedding,
prepared
);
return await runPreparedExecution({
driver,
prepared,
modelId,
execution,
metricContext: context,
metricsName: {
call: 'generate_embedding_calls',
error: 'generate_embedding_errors',
},
execute: async preparedExecution => {
const response = await context.createNativeEmbeddingDispatch(
preparedExecution.route.backendConfig,
preparedExecution.route.protocol,
execution
)(preparedExecution.request);
return response.embeddings;
},
});
}
export async function prepareNativeRerankExecution(
context: RerankRuntimeContext,
cond: ModelConditions,
request: CopilotRerankRequest,
options: CopilotChatOptions = {},
execution?: CopilotProviderExecution
): Promise<PreparedNativeRerankExecution | null> {
return await prepareNativeExecutionBase({
resolveDriver: context.resolveRerankDriver,
cond,
outputType: ModelOutputType.Rerank,
checkParams: context.checkParams,
selectModel: context.selectModel,
execution,
checkInput: {
messages: [],
options,
},
buildPrepared: ({ model, backendConfig, protocol }) =>
context.buildPreparedNativeRerankExecution(
protocol,
backendConfig,
model.id,
buildLlmRerankRequest(model.id, request),
execution
),
});
}
export async function prepareNativeImageExecution(
context: ImageRuntimeContext,
cond: ModelConditions,
messages: PromptMessage[],
options: CopilotImageOptions = {},
execution?: CopilotProviderExecution
): Promise<PreparedNativeImageExecution | null> {
return await prepareNativeExecutionBase({
resolveDriver: context.resolveImageDriver,
cond,
outputType: ModelOutputType.Image,
checkParams: context.checkParams,
selectModel: context.selectModel,
execution,
checkInput: {
messages,
options,
},
buildPrepared: async ({ driver, model, backendConfig, protocol }) => {
const preparedMessages = driver.prepareMessages
? await driver.prepareMessages(messages, backendConfig, options)
: messages;
return context.buildPreparedNativeImageExecution(
protocol,
backendConfig,
model.id,
preparedMessages,
options,
execution
);
},
});
}
export async function runNativeRerank(
context: RerankRuntimeContext,
cond: ModelConditions,
request: CopilotRerankRequest,
options: CopilotChatOptions = {},
execution?: CopilotProviderExecution
) {
const driver = resolveDriverOrThrow(
context.type,
ModelOutputType.Rerank,
context.resolveRerankDriver
);
const prepared = await prepareNativeRerankExecution(
context,
cond,
request,
options,
execution
);
const modelId = resolvePreparedModelId(
context,
cond,
ModelOutputType.Rerank,
prepared
);
return await runPreparedExecution({
driver,
prepared,
modelId,
execution,
execute: async preparedExecution => {
const response = await context.createNativeRerankDispatch(
preparedExecution.route.backendConfig,
preparedExecution.route.protocol,
execution
)(preparedExecution.request);
return response.scores;
},
});
}
@@ -0,0 +1,490 @@
import type {
LlmBackendConfig,
LlmEmbeddingRequest,
LlmProtocol,
LlmRerankRequest,
LlmStructuredRequest,
LlmStructuredResponse,
} from '../../../native';
import type { ProviderMiddlewareConfig } from '../config';
import type { CopilotProvider } from '../providers/provider';
import type { ProviderModelRuntimeContext } from '../providers/provider-model-runtime';
import {
createNativeEmbeddingDispatch as inputCreateNativeEmbeddingDispatch,
createNativeRerankDispatch as inputCreateNativeRerankDispatch,
createNativeStructuredDispatch as inputCreateNativeStructuredDispatch,
createPreparedExecutionRuntime,
type CreatePreparedExecutionRuntimeInput,
type PreparedExecutionRuntime,
} from '../providers/provider-native-runtime';
import type {
CopilotProviderExecution,
EmbeddingProviderDriver,
ImageProviderDriver,
PreparedNativeEmbeddingExecution,
PreparedNativeExecution,
PreparedNativeImageExecution,
PreparedNativeRequestOptions,
PreparedNativeRerankExecution,
PreparedNativeStructuredExecution,
ProviderExecutionDrivers,
ProviderMetricLabels,
ProviderRuntimeHostSeed,
RerankProviderDriver,
StructuredProviderDriver,
} from '../providers/provider-runtime-contract';
import type {
CopilotChatOptions,
CopilotEmbeddingOptions,
CopilotImageOptions,
CopilotProviderModel,
CopilotRerankRequest,
CopilotStructuredOptions,
ModelAttachmentCapability,
ModelConditions,
ModelFullConditions,
ModelOutputType,
PromptMessage,
} from '../providers/types';
import type { CopilotToolSet } from '../tools';
import type { RequiredStructuredOutputContract } from './contracts';
import type { ChatRuntimeContext } from './provider-chat-runtime';
import {
prepareNativeChatExecution,
runNativeStreamObject,
runNativeStreamText,
runNativeText,
} from './provider-chat-runtime';
import type {
EmbeddingRuntimeContext,
ImageRuntimeContext,
RerankRuntimeContext,
StructuredRuntimeContext,
} from './provider-driver-runtime';
import {
prepareNativeEmbeddingExecution,
prepareNativeImageExecution,
prepareNativeRerankExecution,
prepareNativeStructuredExecution,
runNativeEmbedding,
runNativeRerank,
runNativeStructured,
} from './provider-driver-runtime';
import type { NativeProviderAdapter } from './tool/native-adapter';
type ProviderRuntimeContextInput = {
model: ProviderModelRuntimeContext;
resolveExecutionDrivers: () => ProviderExecutionDrivers | undefined;
selectModel: (
cond: ModelFullConditions,
execution?: CopilotProviderExecution
) => CopilotProviderModel;
metricLabels: (
model: string,
labels?: ProviderMetricLabels,
execution?: CopilotProviderExecution
) => ProviderMetricLabels;
checkParams: (input: {
cond: ModelFullConditions;
messages?: PromptMessage[];
embeddings?: string[];
options?:
| CopilotChatOptions
| CopilotStructuredOptions
| CopilotImageOptions;
withAttachment?: boolean;
execution?: CopilotProviderExecution;
}) => Promise<ModelFullConditions>;
getAttachCapability: (
model: CopilotProviderModel,
outputType: ModelOutputType
) => ModelAttachmentCapability | undefined;
getActiveProviderMiddleware: (
execution?: CopilotProviderExecution
) => ProviderMiddlewareConfig;
getTools: (
options: CopilotChatOptions,
model: string
) => Promise<CopilotToolSet>;
buildPreparedNativeExecution: (
options: PreparedNativeRequestOptions
) => Promise<PreparedNativeExecution>;
createPreparedExecutionAdapter: (
prepared: PreparedNativeExecution
) => NativeProviderAdapter;
buildPreparedNativeStructuredExecution: (
protocol: LlmProtocol,
backendConfig: LlmBackendConfig,
model: string,
request: LlmStructuredRequest,
execution?: CopilotProviderExecution
) => PreparedNativeStructuredExecution;
createNativeStructuredDispatch: (
backendConfig: LlmBackendConfig,
protocol: LlmProtocol,
execution?: CopilotProviderExecution
) => (request: LlmStructuredRequest) => Promise<LlmStructuredResponse>;
buildPreparedNativeEmbeddingExecution: (
protocol: LlmProtocol,
backendConfig: LlmBackendConfig,
model: string,
request: LlmEmbeddingRequest,
execution?: CopilotProviderExecution
) => PreparedNativeEmbeddingExecution;
createNativeEmbeddingDispatch: (
backendConfig: LlmBackendConfig,
protocol: LlmProtocol,
execution?: CopilotProviderExecution
) => (request: LlmEmbeddingRequest) => Promise<{ embeddings: number[][] }>;
buildPreparedNativeRerankExecution: (
protocol: LlmProtocol,
backendConfig: LlmBackendConfig,
model: string,
request: LlmRerankRequest,
execution?: CopilotProviderExecution
) => PreparedNativeRerankExecution;
createNativeRerankDispatch: (
backendConfig: LlmBackendConfig,
protocol: LlmProtocol,
execution?: CopilotProviderExecution
) => (request: LlmRerankRequest) => Promise<{ scores: number[] }>;
buildPreparedNativeImageExecution: (
protocol: LlmProtocol,
backendConfig: LlmBackendConfig,
model: string,
messages: PromptMessage[],
options?: CopilotImageOptions,
execution?: CopilotProviderExecution
) => PreparedNativeImageExecution;
};
export type ProviderRuntimeHostInput = Omit<
ProviderRuntimeContextInput,
| keyof ProviderRuntimeHostSeed
| 'buildPreparedNativeExecution'
| 'createPreparedExecutionAdapter'
| 'buildPreparedNativeStructuredExecution'
| 'buildPreparedNativeEmbeddingExecution'
| 'buildPreparedNativeRerankExecution'
| 'buildPreparedNativeImageExecution'
> &
ProviderRuntimeHostSeed & {
preparedExecutionRuntimeInput: CreatePreparedExecutionRuntimeInput;
createNativeStructuredDispatch: ProviderRuntimeContextInput['createNativeStructuredDispatch'];
createNativeEmbeddingDispatch: ProviderRuntimeContextInput['createNativeEmbeddingDispatch'];
createNativeRerankDispatch: ProviderRuntimeContextInput['createNativeRerankDispatch'];
};
type ProviderRuntimeHostOverride = {
overrideRuntimeHost?: (
runtimeHost: ProviderRuntimeContexts
) => ProviderRuntimeContexts;
};
const runtimeHosts = new WeakMap<CopilotProvider, ProviderRuntimeContexts>();
export type ProviderRuntimeContexts = {
model: ProviderModelRuntimeContext;
chat: ChatRuntimeContext;
structured: StructuredRuntimeContext;
embedding: EmbeddingRuntimeContext;
rerank: RerankRuntimeContext;
image: ImageRuntimeContext;
prepare: {
chat: (
kind: 'text' | 'streamText' | 'streamObject',
cond: ModelConditions,
messages: PromptMessage[],
options?: CopilotChatOptions,
execution?: CopilotProviderExecution
) => ReturnType<typeof prepareNativeChatExecution>;
structured: (
cond: ModelConditions,
messages: PromptMessage[],
options?: CopilotStructuredOptions,
responseContract?: RequiredStructuredOutputContract,
execution?: CopilotProviderExecution
) => ReturnType<typeof prepareNativeStructuredExecution>;
embedding: (
cond: ModelConditions,
input: string | string[],
options?: CopilotEmbeddingOptions,
execution?: CopilotProviderExecution
) => ReturnType<typeof prepareNativeEmbeddingExecution>;
rerank: (
cond: ModelConditions,
request: CopilotRerankRequest,
options?: CopilotChatOptions,
execution?: CopilotProviderExecution
) => ReturnType<typeof prepareNativeRerankExecution>;
image: (
cond: ModelConditions,
messages: PromptMessage[],
options?: CopilotImageOptions,
execution?: CopilotProviderExecution
) => ReturnType<typeof prepareNativeImageExecution>;
};
run: {
text: (
cond: ModelConditions,
messages: PromptMessage[],
options?: CopilotChatOptions,
execution?: CopilotProviderExecution
) => ReturnType<typeof runNativeText>;
streamText: (
cond: ModelConditions,
messages: PromptMessage[],
options?: CopilotChatOptions,
execution?: CopilotProviderExecution
) => ReturnType<typeof runNativeStreamText>;
streamObject: (
cond: ModelConditions,
messages: PromptMessage[],
options?: CopilotChatOptions,
execution?: CopilotProviderExecution
) => ReturnType<typeof runNativeStreamObject>;
structured: (
cond: ModelConditions,
messages: PromptMessage[],
options?: CopilotStructuredOptions,
responseContract?: RequiredStructuredOutputContract,
execution?: CopilotProviderExecution
) => ReturnType<typeof runNativeStructured>;
embedding: (
cond: ModelConditions,
input: string | string[],
options?: CopilotEmbeddingOptions,
execution?: CopilotProviderExecution
) => ReturnType<typeof runNativeEmbedding>;
rerank: (
cond: ModelConditions,
request: CopilotRerankRequest,
options?: CopilotChatOptions,
execution?: CopilotProviderExecution
) => ReturnType<typeof runNativeRerank>;
};
};
function createProviderRuntimeContexts(
input: ProviderRuntimeContextInput
): ProviderRuntimeContexts {
const resolveDriver = <K extends keyof ProviderExecutionDrivers>(
kind: K
): ProviderExecutionDrivers[K] | undefined =>
input.resolveExecutionDrivers()?.[kind];
const chatDriver = resolveDriver('chat');
const chatContext: ChatRuntimeContext = {
type: input.model.type,
resolveChatDriver: () => chatDriver,
selectModel: input.selectModel,
metricLabels: input.metricLabels,
createPreparedExecutionAdapter: input.createPreparedExecutionAdapter,
};
const structuredContext: StructuredRuntimeContext = {
type: input.model.type,
resolveStructuredDriver: () =>
resolveDriver('structured') as StructuredProviderDriver | undefined,
checkParams: input.checkParams,
selectModel: input.selectModel,
getAttachCapability: input.getAttachCapability,
getActiveProviderMiddleware: input.getActiveProviderMiddleware,
buildPreparedNativeStructuredExecution:
input.buildPreparedNativeStructuredExecution,
createNativeStructuredDispatch: input.createNativeStructuredDispatch,
metricLabels: input.metricLabels,
};
const embeddingContext: EmbeddingRuntimeContext = {
type: input.model.type,
resolveEmbeddingDriver: () =>
resolveDriver('embedding') as EmbeddingProviderDriver | undefined,
checkParams: input.checkParams,
selectModel: input.selectModel,
buildPreparedNativeEmbeddingExecution:
input.buildPreparedNativeEmbeddingExecution,
createNativeEmbeddingDispatch: input.createNativeEmbeddingDispatch,
metricLabels: input.metricLabels,
};
const rerankContext: RerankRuntimeContext = {
type: input.model.type,
resolveRerankDriver: () =>
resolveDriver('rerank') as RerankProviderDriver | undefined,
checkParams: input.checkParams,
selectModel: input.selectModel,
buildPreparedNativeRerankExecution:
input.buildPreparedNativeRerankExecution,
createNativeRerankDispatch: input.createNativeRerankDispatch,
};
const imageContext: ImageRuntimeContext = {
type: input.model.type,
resolveImageDriver: () =>
resolveDriver('image') as ImageProviderDriver | undefined,
checkParams: input.checkParams,
selectModel: input.selectModel,
buildPreparedNativeImageExecution: input.buildPreparedNativeImageExecution,
};
const prepare: ProviderRuntimeContexts['prepare'] = {
chat: (kind, cond, messages, options = {}, execution) =>
prepareNativeChatExecution(
chatContext.resolveChatDriver,
input.buildPreparedNativeExecution,
{
kind,
cond,
messages,
options,
execution,
}
),
structured: (cond, messages, options = {}, responseContract, execution) =>
prepareNativeStructuredExecution(
structuredContext,
cond,
messages,
options,
responseContract,
execution
),
embedding: (cond, values, options = {}, execution) =>
prepareNativeEmbeddingExecution(
embeddingContext,
cond,
values,
options,
execution
),
rerank: (cond, request, options = {}, execution) =>
prepareNativeRerankExecution(
rerankContext,
cond,
request,
options,
execution
),
image: (cond, messages, options = {}, execution) =>
prepareNativeImageExecution(
imageContext,
cond,
messages,
options,
execution
),
};
return {
model: input.model,
chat: chatContext,
structured: structuredContext,
embedding: embeddingContext,
rerank: rerankContext,
image: imageContext,
prepare,
run: {
text: (cond, messages, options, execution) =>
runNativeText(
chatContext,
prepare.chat,
cond,
messages,
options,
execution
),
streamText: (cond, messages, options, execution) =>
runNativeStreamText(
chatContext,
prepare.chat,
cond,
messages,
options,
execution
),
streamObject: (cond, messages, options, execution) =>
runNativeStreamObject(
chatContext,
prepare.chat,
cond,
messages,
options,
execution
),
structured: (cond, messages, options, responseContract, execution) =>
runNativeStructured(
structuredContext,
cond,
messages,
options,
responseContract,
execution
),
embedding: (cond, values, options, execution) =>
runNativeEmbedding(embeddingContext, cond, values, options, execution),
rerank: (cond, request, options, execution) =>
runNativeRerank(rerankContext, cond, request, options, execution),
},
};
}
export function createProviderRuntimeHost(
input: ProviderRuntimeHostInput
): ProviderRuntimeContexts {
const preparedExecutionRuntime: PreparedExecutionRuntime =
createPreparedExecutionRuntime(input.preparedExecutionRuntimeInput);
return createProviderRuntimeContexts({
...input,
buildPreparedNativeExecution:
preparedExecutionRuntime.buildPreparedNativeExecution,
createPreparedExecutionAdapter:
preparedExecutionRuntime.createPreparedExecutionAdapter,
buildPreparedNativeStructuredExecution:
preparedExecutionRuntime.buildPreparedNativeStructuredExecution,
buildPreparedNativeEmbeddingExecution:
preparedExecutionRuntime.buildPreparedNativeEmbeddingExecution,
buildPreparedNativeRerankExecution:
preparedExecutionRuntime.buildPreparedNativeRerankExecution,
buildPreparedNativeImageExecution:
preparedExecutionRuntime.buildPreparedNativeImageExecution,
createNativeStructuredDispatch: input.createNativeStructuredDispatch,
createNativeEmbeddingDispatch: input.createNativeEmbeddingDispatch,
createNativeRerankDispatch: input.createNativeRerankDispatch,
});
}
export function getProviderRuntimeHost(
provider: CopilotProvider
): ProviderRuntimeContexts {
const existingRuntimeHost = runtimeHosts.get(provider);
if (existingRuntimeHost) {
return existingRuntimeHost;
}
const runtimeHostSeed = provider.getRuntimeHostSeed();
const runtimeHost = createProviderRuntimeHost({
...runtimeHostSeed,
preparedExecutionRuntimeInput: {
resolveProviderId: execution =>
execution?.providerId ?? `${provider.type}-default`,
getTools: runtimeHostSeed.getTools,
getActiveProviderMiddleware: runtimeHostSeed.getActiveProviderMiddleware,
createNativeAdapter: provider.createNativeAdapter.bind(provider),
maxSteps: provider.maxSteps,
},
createNativeStructuredDispatch: (backendConfig, protocol, _execution) =>
inputCreateNativeStructuredDispatch(backendConfig, protocol),
createNativeEmbeddingDispatch: (backendConfig, protocol, _execution) =>
inputCreateNativeEmbeddingDispatch(backendConfig, protocol),
createNativeRerankDispatch: (backendConfig, protocol, _execution) =>
inputCreateNativeRerankDispatch(backendConfig, protocol),
});
const resolvedRuntimeHost =
(provider as ProviderRuntimeHostOverride).overrideRuntimeHost?.(
runtimeHost
) ?? runtimeHost;
runtimeHosts.set(provider, resolvedRuntimeHost);
return resolvedRuntimeHost;
}
@@ -0,0 +1,34 @@
import { Injectable } from '@nestjs/common';
import { Models } from '../../../models';
import { PromptService } from '../prompt/service';
export const DEFAULT_EMBEDDING_MODEL = 'gemini-embedding-001';
export const DEFAULT_RERANK_MODEL = 'gpt-4o-mini';
@Injectable()
export class TaskPolicy {
constructor(
private readonly models: Models,
private readonly prompts: PromptService
) {}
resolveEmbeddingModelId() {
return DEFAULT_EMBEDDING_MODEL;
}
resolveRerankModelId() {
return DEFAULT_RERANK_MODEL;
}
async resolveTranscriptionModel(userId: string) {
const prompt = await this.prompts.get('Transcript audio');
if (!prompt) return;
const hasAccess = await this.models.userFeature.has(
userId,
'unlimited_copilot'
);
return prompt.optionalModels[hasAccess ? 1 : 0] ?? prompt.model;
}
}
@@ -0,0 +1,207 @@
import { Injectable } from '@nestjs/common';
import { Config } from '../../../base';
import { DocReader, DocWriter } from '../../../core/doc';
import { AccessController } from '../../../core/permission';
import { Models } from '../../../models';
import { IndexerService } from '../../indexer';
import type { NodeTextMiddleware } from '../config';
import { CopilotContextService } from '../context/service';
import {
type CopilotChatOptions,
type CopilotChatTools,
} from '../providers/types';
import {
buildBlobContentGetter,
buildContentGetter,
buildDocContentGetter,
buildDocCreateHandler,
buildDocKeywordSearchGetter,
buildDocSearchGetter,
buildDocUpdateHandler,
buildDocUpdateMetaHandler,
type CopilotTool,
type CopilotToolSet,
createBlobReadTool,
createCodeArtifactTool,
createConversationSummaryTool,
createDocComposeTool,
createDocCreateTool,
createDocEditTool,
createDocKeywordSearchTool,
createDocReadTool,
createDocSemanticSearchTool,
createDocUpdateMetaTool,
createDocUpdateTool,
createExaCrawlTool,
createExaSearchTool,
createSectionEditTool,
} from '../tools';
import { PromptRuntime } from './prompt-runtime';
import type { ToolLoopBackend } from './tool/bridge';
import { createNativeToolLoopAdapter } from './tool/native-adapter';
export type ProviderSpecificToolResolver = (
toolName: CopilotChatTools,
model: string
) => [string, CopilotTool?] | undefined;
@Injectable()
export class ToolRuntime {
constructor(
private readonly config: Config,
private readonly ac: AccessController,
private readonly context: CopilotContextService,
private readonly docReader: DocReader,
private readonly docWriter: DocWriter,
private readonly models: Models,
private readonly promptRuntime: PromptRuntime,
private readonly indexerService: IndexerService
) {}
async getTools(
options: CopilotChatOptions,
model: string,
resolveProviderSpecificTool?: ProviderSpecificToolResolver
): Promise<CopilotToolSet> {
const tools: CopilotToolSet = {};
if (!options?.tools?.length) {
return tools;
}
for (const tool of options.tools) {
const toolDef = resolveProviderSpecificTool?.(tool, model);
if (toolDef) {
if (toolDef[1]) {
tools[toolDef[0]] = toolDef[1];
}
continue;
}
if (
!(env.dev || env.namespaces.canary) &&
['docCreate', 'docUpdate', 'docUpdateMeta'].includes(tool)
) {
continue;
}
switch (tool) {
case 'blobRead': {
const docContext = options.session
? await this.context.getBySessionId(options.session)
: null;
const getBlobContent = buildBlobContentGetter(this.ac, docContext);
tools.blob_read = createBlobReadTool(
getBlobContent.bind(null, options)
);
break;
}
case 'codeArtifact': {
tools.code_artifact = createCodeArtifactTool(
this.promptRuntime.runText.bind(this.promptRuntime)
);
break;
}
case 'conversationSummary': {
tools.conversation_summary = createConversationSummaryTool(
options.session,
this.promptRuntime.runText.bind(this.promptRuntime)
);
break;
}
case 'docEdit': {
const getDocContent = buildContentGetter(this.ac, this.docReader);
tools.doc_edit = createDocEditTool(
this.promptRuntime.runText.bind(this.promptRuntime),
getDocContent.bind(null, options)
);
break;
}
case 'docSemanticSearch': {
const searchDocs = buildDocSearchGetter(
this.ac,
this.context,
options.session,
this.models
);
tools.doc_semantic_search = createDocSemanticSearchTool(
searchDocs.bind(null, options)
);
break;
}
case 'docKeywordSearch': {
if (this.config.indexer.enabled) {
const searchDocs = buildDocKeywordSearchGetter(
this.ac,
this.indexerService,
this.models
);
tools.doc_keyword_search = createDocKeywordSearchTool(
searchDocs.bind(null, options)
);
}
break;
}
case 'docRead': {
const getDoc = buildDocContentGetter(
this.ac,
this.docReader,
this.models
);
tools.doc_read = createDocReadTool(getDoc.bind(null, options));
break;
}
case 'docCreate': {
const createDoc = buildDocCreateHandler(this.ac, this.docWriter);
tools.doc_create = createDocCreateTool(createDoc.bind(null, options));
break;
}
case 'docUpdate': {
const updateDoc = buildDocUpdateHandler(this.ac, this.docWriter);
tools.doc_update = createDocUpdateTool(updateDoc.bind(null, options));
break;
}
case 'docUpdateMeta': {
const updateDocMeta = buildDocUpdateMetaHandler(
this.ac,
this.docWriter
);
tools.doc_update_meta = createDocUpdateMetaTool(
updateDocMeta.bind(null, options)
);
break;
}
case 'webSearch': {
tools.web_search_exa = createExaSearchTool(this.config);
tools.web_crawl_exa = createExaCrawlTool(this.config);
break;
}
case 'docCompose': {
tools.doc_compose = createDocComposeTool(
this.promptRuntime.runText.bind(this.promptRuntime)
);
break;
}
case 'sectionEdit': {
tools.section_edit = createSectionEditTool(
this.promptRuntime.runText.bind(this.promptRuntime)
);
break;
}
}
}
return tools;
}
createNativeAdapter(
backend: ToolLoopBackend,
tools: CopilotToolSet,
options: {
maxSteps?: number;
nodeTextMiddleware?: NodeTextMiddleware[];
} = {}
) {
return createNativeToolLoopAdapter(backend, tools, options);
}
}
@@ -0,0 +1,175 @@
import {
type LlmBackendConfig,
llmDispatchToolLoopStream,
llmDispatchToolLoopStreamPrepared,
llmDispatchToolLoopStreamRouted,
type LlmPreparedDispatchRoute,
type LlmProtocol,
type LlmRequest,
type LlmRoutedBackend,
type LlmToolCallbackRequest,
type LlmToolCallbackResponse,
type LlmToolLoopStreamEvent,
} from '../../../../native';
import type {
CopilotTool,
CopilotToolExecuteOptions,
CopilotToolSet,
} from '../../tools';
export type ToolLoopDispatch = (
request: LlmRequest,
signalOrOptions?: AbortSignal | CopilotToolExecuteOptions,
maybeMessages?: CopilotToolExecuteOptions['messages']
) => AsyncIterableIterator<LlmToolLoopStreamEvent>;
export type ToolLoopBackend =
| { protocol: LlmProtocol; backendConfig: LlmBackendConfig }
| { routes: LlmRoutedBackend[] }
| { preparedRoutes: LlmPreparedDispatchRoute[] };
function normalizeToolExecuteOptions(
signalOrOptions?: AbortSignal | CopilotToolExecuteOptions,
maybeMessages?: CopilotToolExecuteOptions['messages']
): CopilotToolExecuteOptions {
if (
signalOrOptions &&
typeof signalOrOptions === 'object' &&
'aborted' in signalOrOptions
) {
return {
signal: signalOrOptions,
messages: maybeMessages,
};
}
if (!signalOrOptions) {
return maybeMessages ? { messages: maybeMessages } : {};
}
return {
...signalOrOptions,
signal: signalOrOptions.signal,
messages: signalOrOptions.messages ?? maybeMessages,
};
}
export function createToolExecutionCallback(
tools: CopilotToolSet,
options: CopilotToolExecuteOptions = {}
) {
return async (request: LlmToolCallbackRequest) => {
return await executeToolCall(tools, request, options);
};
}
async function executeToolCall(
tools: CopilotToolSet,
request: LlmToolCallbackRequest,
options: CopilotToolExecuteOptions
): Promise<LlmToolCallbackResponse> {
const tool = tools[request.name] as CopilotTool | undefined;
if (!tool?.execute) {
return {
callId: request.callId,
name: request.name,
args: request.args,
rawArgumentsText: request.rawArgumentsText,
argumentParseError: request.argumentParseError,
isError: true,
output: { message: `Tool not found: ${request.name}` },
};
}
if (request.argumentParseError) {
return {
callId: request.callId,
name: request.name,
args: request.args,
rawArgumentsText: request.rawArgumentsText,
argumentParseError: request.argumentParseError,
isError: true,
output: {
message: 'Invalid tool arguments JSON',
...(request.rawArgumentsText
? { rawArguments: request.rawArgumentsText }
: {}),
...(request.argumentParseError
? { error: request.argumentParseError }
: {}),
},
};
}
try {
const output = await tool.execute(request.args, options);
return {
callId: request.callId,
name: request.name,
args: request.args,
rawArgumentsText: request.rawArgumentsText,
argumentParseError: request.argumentParseError,
output: (output ?? null) as LlmToolCallbackResponse['output'],
};
} catch (error) {
return {
callId: request.callId,
name: request.name,
args: request.args,
rawArgumentsText: request.rawArgumentsText,
argumentParseError: request.argumentParseError,
output: {
message: error instanceof Error ? error.message : String(error),
},
isError: true,
};
}
}
export function createToolLoopBridge(
backend: ToolLoopBackend,
tools: CopilotToolSet,
maxSteps = 20
): ToolLoopDispatch {
return (
request: LlmRequest,
signalOrOptions?: AbortSignal | CopilotToolExecuteOptions,
maybeMessages?: CopilotToolExecuteOptions['messages']
) => {
const toolExecuteOptions = normalizeToolExecuteOptions(
signalOrOptions,
maybeMessages
);
const execute = createToolExecutionCallback(tools, toolExecuteOptions);
const toolLoopRequest = { ...request, stream: true };
if ('routes' in backend) {
return llmDispatchToolLoopStreamRouted(
backend.routes,
toolLoopRequest,
execute,
maxSteps,
toolExecuteOptions.signal
);
}
if ('preparedRoutes' in backend) {
return llmDispatchToolLoopStreamPrepared(
backend.preparedRoutes,
execute,
maxSteps,
toolExecuteOptions.signal
);
}
return llmDispatchToolLoopStream(
backend.protocol,
backend.backendConfig,
toolLoopRequest,
execute,
maxSteps,
toolExecuteOptions.signal
);
};
}
@@ -0,0 +1,343 @@
import type { LlmRequest, LlmToolLoopStreamEvent } from '../../../../native';
import type { NodeTextMiddleware } from '../../config';
import type { PromptMessage, StreamObject } from '../../providers/types';
import {
CitationFootnoteFormatter,
TextStreamParser,
} from '../../providers/utils';
import type { CopilotToolSet } from '../../tools';
import { projectRuntimeEventToStreamObject } from '../contracts/runtime-event-contract';
import { createToolLoopBridge, type ToolLoopBackend } from './bridge';
import {
type EnrichedToolCallEvent,
type EnrichedToolResultEvent,
NativeRuntimeAdapter,
} from './native-runtime-adapter';
type AttachmentFootnote = {
blobId: string;
fileName: string;
fileType: string;
};
type NativeProviderAdapterOptions = {
maxSteps?: number;
nodeTextMiddleware?: NodeTextMiddleware[];
};
type NativeStreamDispatch = ConstructorParameters<
typeof NativeRuntimeAdapter
>[0];
function pickAttachmentFootnote(value: unknown): AttachmentFootnote | null {
if (!value || typeof value !== 'object') {
return null;
}
const record = value as Record<string, unknown>;
const blobId =
typeof record.blobId === 'string'
? record.blobId
: typeof record.blob_id === 'string'
? record.blob_id
: undefined;
const fileName =
typeof record.fileName === 'string'
? record.fileName
: typeof record.name === 'string'
? record.name
: undefined;
const fileType =
typeof record.fileType === 'string'
? record.fileType
: typeof record.mimeType === 'string'
? record.mimeType
: 'application/octet-stream';
if (!blobId || !fileName) {
return null;
}
return { blobId, fileName, fileType };
}
function collectAttachmentFootnotes(
event: EnrichedToolResultEvent
): AttachmentFootnote[] {
if (event.name === 'blob_read') {
const item = pickAttachmentFootnote(event.output);
return item ? [item] : [];
}
if (event.name === 'doc_semantic_search' && Array.isArray(event.output)) {
return event.output
.map(item => pickAttachmentFootnote(item))
.filter((item): item is AttachmentFootnote => item !== null);
}
return [];
}
function formatAttachmentFootnotes(
attachments: AttachmentFootnote[],
options: { includeReferences?: boolean } = {}
) {
const references =
options.includeReferences === false
? ''
: attachments.map((_, index) => `[^${index + 1}]`).join('');
const definitions = attachments
.map((attachment, index) => {
return `[^${index + 1}]: ${JSON.stringify({
type: 'attachment',
blobId: attachment.blobId,
fileName: attachment.fileName,
fileType: attachment.fileType,
})}`;
})
.join('\n');
return references
? `\n\n${references}\n\n${definitions}`
: `\n\n${definitions}`;
}
export class NativeProviderAdapter {
readonly #runtime: NativeRuntimeAdapter;
readonly #enableCallout: boolean;
readonly #enableCitationFootnote: boolean;
constructor(
dispatchWithTools: NativeStreamDispatch,
options: NativeProviderAdapterOptions = {}
) {
this.#runtime = new NativeRuntimeAdapter(dispatchWithTools);
const enabledNodeTextMiddlewares = new Set(
options.nodeTextMiddleware ?? ['citation_footnote', 'callout']
);
this.#enableCallout =
enabledNodeTextMiddlewares.has('callout') ||
enabledNodeTextMiddlewares.has('thinking_format');
this.#enableCitationFootnote =
enabledNodeTextMiddlewares.has('citation_footnote');
}
async text(
request: LlmRequest,
signal?: AbortSignal,
messages?: PromptMessage[]
) {
let output = '';
for await (const chunk of this.streamText(request, signal, messages)) {
output += chunk;
}
return output.trim();
}
async *streamText(
request: LlmRequest,
signal?: AbortSignal,
messages?: PromptMessage[]
): AsyncIterableIterator<string> {
const textParser = this.#enableCallout ? new TextStreamParser() : null;
const citationFormatter = this.#enableCitationFootnote
? new CitationFootnoteFormatter()
: null;
let streamPartId = 0;
for await (const event of this.#runtime.streamEvents(
request,
signal,
messages
)) {
switch (event.type) {
case 'text_delta': {
const textEvent = event as unknown as { text: string };
if (textParser) {
yield textParser.parse({
type: 'text-delta',
id: String(streamPartId++),
text: textEvent.text,
});
} else {
yield textEvent.text;
}
break;
}
case 'reasoning_delta': {
const reasoningEvent = event as unknown as { text: string };
if (textParser) {
yield textParser.parse({
type: 'reasoning-delta',
id: String(streamPartId++),
text: reasoningEvent.text,
});
} else {
yield reasoningEvent.text;
}
break;
}
case 'tool_call': {
if (textParser) {
const toolCallEvent = event as EnrichedToolCallEvent;
yield textParser.parse({
type: 'tool-call',
toolCallId: toolCallEvent.call_id,
toolName: toolCallEvent.name,
input: toolCallEvent.arguments,
});
}
break;
}
case 'tool_result': {
if (!textParser) break;
const normalized = event as EnrichedToolResultEvent;
yield textParser.parse({
type: 'tool-result',
toolCallId: normalized.call_id,
toolName: normalized.name as never,
input: normalized.arguments,
output: normalized.output,
});
break;
}
case 'citation': {
if (citationFormatter) {
const citationEvent = event as unknown as {
index: number;
url: string;
};
citationFormatter.consume({
type: 'citation',
index: citationEvent.index,
url: citationEvent.url,
});
}
break;
}
case 'done': {
const footnotes = textParser?.end() ?? '';
const citations = citationFormatter?.end() ?? '';
const tails = [citations, footnotes].filter(Boolean).join('\n');
if (tails) {
yield `\n${tails}`;
}
break;
}
case 'error':
throw new Error(
typeof event.message === 'string'
? event.message
: 'native runtime stream error'
);
default:
break;
}
}
}
async *streamObject(
request: LlmRequest,
signal?: AbortSignal,
messages?: PromptMessage[]
): AsyncIterableIterator<StreamObject> {
const citationFormatter = this.#enableCitationFootnote
? new CitationFootnoteFormatter()
: null;
const fallbackAttachmentFootnotes = new Map<string, AttachmentFootnote>();
let hasFootnoteReference = false;
for await (const event of this.#runtime.streamEvents(
request,
signal,
messages
)) {
switch (event.type) {
case 'text_delta': {
const textEvent = event as unknown as { text: string };
if (textEvent.text.includes('[^')) {
hasFootnoteReference = true;
}
yield { type: 'text-delta', textDelta: textEvent.text };
break;
}
case 'reasoning_delta': {
const reasoningEvent = event as unknown as { text: string };
yield { type: 'reasoning', textDelta: reasoningEvent.text };
break;
}
case 'tool_call': {
const streamObject = projectRuntimeEventToStreamObject(
event as LlmToolLoopStreamEvent
);
if (!streamObject) break;
yield streamObject;
break;
}
case 'tool_result': {
const normalized = event as EnrichedToolResultEvent;
const attachments = collectAttachmentFootnotes(normalized);
attachments.forEach(attachment => {
fallbackAttachmentFootnotes.set(attachment.blobId, attachment);
});
const streamObject = projectRuntimeEventToStreamObject(
event as LlmToolLoopStreamEvent
);
if (!streamObject) break;
yield streamObject;
break;
}
case 'citation': {
if (citationFormatter) {
const citationEvent = event as unknown as {
index: number;
url: string;
};
citationFormatter.consume({
type: 'citation',
index: citationEvent.index,
url: citationEvent.url,
});
}
break;
}
case 'done': {
const citations = citationFormatter?.end() ?? '';
if (citations) {
hasFootnoteReference = true;
yield { type: 'text-delta', textDelta: `\n${citations}` };
}
if (!citations && fallbackAttachmentFootnotes.size > 0) {
yield {
type: 'text-delta',
textDelta: formatAttachmentFootnotes(
Array.from(fallbackAttachmentFootnotes.values()),
{ includeReferences: !hasFootnoteReference }
),
};
}
break;
}
case 'error':
throw new Error(
typeof event.message === 'string'
? event.message
: 'native runtime stream error'
);
default:
break;
}
}
}
}
export function createNativeToolLoopAdapter(
backend: ToolLoopBackend,
tools: CopilotToolSet,
options: NativeProviderAdapterOptions = {}
) {
return new NativeProviderAdapter(
createToolLoopBridge(backend, tools, options.maxSteps),
options
);
}
@@ -0,0 +1,63 @@
import type { LlmRequest, LlmToolLoopStreamEvent } from '../../../../native';
import type { PromptMessage, StreamObject } from '../../providers/types';
import { projectRuntimeEventToStreamObject } from '../contracts/runtime-event-contract';
type NativeRuntimeEvent = {
type: string;
[key: string]: unknown;
};
type NativeRuntimeDispatch = (
request: LlmRequest,
signalOrOptions?: AbortSignal | { signal?: AbortSignal },
maybeMessages?: PromptMessage[]
) => AsyncIterableIterator<NativeRuntimeEvent>;
export type EnrichedToolCallEvent = Extract<
LlmToolLoopStreamEvent,
{ type: 'tool_call' }
>;
export type EnrichedToolResultEvent = Omit<
Extract<LlmToolLoopStreamEvent, { type: 'tool_result' }>,
'name' | 'arguments'
> & {
name: string;
arguments: Record<string, unknown>;
};
export class NativeRuntimeAdapter {
readonly #dispatchWithTools: NativeRuntimeDispatch;
constructor(dispatchWithTools: NativeRuntimeDispatch) {
this.#dispatchWithTools = dispatchWithTools;
}
streamEvents(
request: LlmRequest,
signal?: AbortSignal,
messages?: PromptMessage[]
) {
return this.#dispatchWithTools(request, signal, messages);
}
async *streamObject(
request: LlmRequest,
signal?: AbortSignal,
messages?: PromptMessage[]
): AsyncIterableIterator<StreamObject> {
for await (const event of this.streamEvents(request, signal, messages)) {
if (event.type === 'error') {
throw new Error(
typeof event.message === 'string'
? event.message
: 'native runtime stream error'
);
}
const streamObject = projectRuntimeEventToStreamObject(event);
if (streamObject) {
yield streamObject;
}
}
}
}
@@ -0,0 +1,279 @@
import { Injectable } from '@nestjs/common';
import { CopilotContextService } from '../context/service';
import { type Turn } from '../core';
import {
ModelInputType,
type PromptParams,
type StreamObject,
} from '../providers/types';
import { ChatSession } from '../session';
import { ChatQuerySchema } from '../types';
import { CapabilityRuntime } from './capability-runtime';
import { CapabilityPolicyHost } from './hosts/capability-policy-host';
import { ConversationHost } from './hosts/conversation-host';
import { ImageResultHost } from './hosts/image-result-host';
import { TurnPersistence } from './hosts/turn-persistence';
@Injectable()
export class TurnOrchestrator {
constructor(
private readonly conversations: ConversationHost,
private readonly context: CopilotContextService,
private readonly capabilityPolicy: CapabilityPolicyHost,
private readonly runtime: CapabilityRuntime,
private readonly imageResults: ImageResultHost,
private readonly turnPersistence: TurnPersistence
) {}
private async buildPromptParams(
sessionId: string,
options: {
latestTurn?: Turn;
includeContextFiles?: boolean;
} = {}
): Promise<Record<string, unknown>> {
const current = await this.context.getBySessionId(sessionId);
const contextFiles =
options.includeContextFiles &&
current &&
(current.files.length > 0 || current.blobs.length > 0)
? [...current.files, ...(await current.getBlobMetadata())]
: [];
const latestTurn = options.latestTurn;
return {
...this.conversations.buildLatestTurnPromptParams(latestTurn),
...(contextFiles.length ? { contextFiles } : {}),
};
}
private async prepareChatSelection(
userId: string,
sessionId: string,
query: Record<string, string | string[]>,
selection: {
responseMode: 'text' | 'object' | 'image';
includeContextFiles?: boolean;
}
) {
const prepared = await this.conversations.prepareTurn(
userId,
sessionId,
query
);
const { modelId, reasoning, webSearch, toolsConfig } =
ChatQuerySchema.parse(query);
const promptParams = await this.buildPromptParams(sessionId, {
latestTurn: prepared.latestTurn,
includeContextFiles: selection.includeContextFiles,
});
const finalMessage = prepared.session.finish({
...prepared.params,
...promptParams,
});
return {
prepared,
finalMessage,
selection: await this.capabilityPolicy.selectChat(prepared.session, {
responseMode: selection.responseMode,
modelId,
reasoning,
webSearch,
toolsConfig,
}),
};
}
async streamText(
userId: string,
sessionId: string,
query: Record<string, string | string[]>,
signal?: AbortSignal,
wasAborted: () => boolean = () => false
) {
const { prepared, finalMessage, selection } =
await this.prepareChatSelection(userId, sessionId, query, {
responseMode: 'text',
includeContextFiles: true,
});
const stream = this.streamTextResult(
prepared.session,
selection.model,
finalMessage,
{
...selection.providerOptions,
signal,
},
wasAborted
);
return {
messageId: prepared.messageId,
model: selection.model,
finalMessage,
stream,
};
}
private async *streamTextResult(
session: ChatSession,
model: string,
finalMessage: ReturnType<ChatSession['finish']>,
options: Record<string, unknown>,
wasAborted: () => boolean
) {
let buffer = '';
for await (const chunk of this.runtime.streamText(
{ modelId: model },
finalMessage,
options
)) {
buffer += chunk;
yield chunk;
}
await this.turnPersistence.persistTextResult(session, buffer, wasAborted());
}
async streamObject(
userId: string,
sessionId: string,
query: Record<string, string | string[]>,
signal?: AbortSignal,
wasAborted: () => boolean = () => false
) {
const { prepared, finalMessage, selection } =
await this.prepareChatSelection(userId, sessionId, query, {
responseMode: 'object',
includeContextFiles: true,
});
return {
messageId: prepared.messageId,
model: selection.model,
finalMessage,
stream: this.streamObjectResult(
prepared.session,
selection.model,
finalMessage,
{
...selection.providerOptions,
signal,
},
wasAborted
),
};
}
private async *streamObjectResult(
session: ChatSession,
model: string,
finalMessage: ReturnType<ChatSession['finish']>,
options: Record<string, unknown>,
wasAborted: () => boolean
): AsyncIterableIterator<StreamObject> {
const chunks: StreamObject[] = [];
for await (const chunk of this.runtime.streamObject(
{ modelId: model },
finalMessage,
options
)) {
chunks.push(chunk);
yield chunk;
}
await this.turnPersistence.persistObjectResult(
session,
chunks,
wasAborted()
);
}
async streamImages(
userId: string,
sessionId: string,
query: Record<string, string | string[]>,
signal?: AbortSignal,
wasAborted: () => boolean = () => false
) {
const { prepared, finalMessage, selection } =
await this.prepareChatSelection(userId, sessionId, query, {
responseMode: 'image',
});
const [systemMessage] = finalMessage;
const finalParams: PromptParams = systemMessage?.params ?? {};
const hasAttachment =
!!prepared.session.latestUserTurn?.attachments?.length;
return {
messageId: prepared.messageId,
model: selection.model,
finalMessage,
stream: this.streamImageResult(
userId,
sessionId,
prepared.session,
undefined,
hasAttachment,
finalMessage,
{
...selection.providerOptions,
quality:
typeof finalParams.quality === 'string'
? finalParams.quality
: undefined,
seed: this.parseNumber(finalParams.seed),
signal,
},
wasAborted
),
};
}
private async *streamImageResult(
userId: string,
sessionId: string,
session: ChatSession,
model: string | undefined,
hasAttachment: boolean,
finalMessage: ReturnType<ChatSession['finish']>,
options: Record<string, unknown>,
wasAborted: () => boolean
): AsyncIterableIterator<string> {
const attachments: string[] = [];
for await (const artifact of this.runtime.streamImageArtifacts(
{
modelId: model,
inputTypes: hasAttachment
? [ModelInputType.Image]
: [ModelInputType.Text],
},
finalMessage,
options
)) {
const handled = await this.imageResults.persistNativeArtifact(
userId,
sessionId,
artifact
);
if (handled) {
attachments.push(handled);
yield handled;
}
}
await this.turnPersistence.persistImageResult(
session,
attachments,
wasAborted()
);
}
private parseNumber(value: unknown) {
if (!value) {
return undefined;
}
const num = Number.parseInt(String(value), 10);
return Number.isNaN(num) ? undefined : num;
}
}
@@ -1,24 +1,18 @@
import { randomUUID } from 'node:crypto';
import { Injectable, Logger } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { Transactional } from '@nestjs-cls/transactional';
import { AiPromptRole } from '@prisma/client';
import { pick } from 'lodash-es';
import {
Config,
CopilotActionTaken,
CopilotMessageNotFound,
CopilotPromptNotFound,
CopilotQuotaExceeded,
CopilotSessionInvalidInput,
CopilotSessionNotFound,
JobQueue,
NoCopilotProviderAvailable,
OnJob,
} from '../../base';
import { QuotaService } from '../../core/quota';
import {
CleanupSessionOptions,
ListSessionOptions,
@@ -26,28 +20,17 @@ import {
type UpdateChatSession,
UpdateChatSessionOptions,
} from '../../models';
import { SubscriptionService } from '../payment/service';
import { SubscriptionPlan, SubscriptionStatus } from '../payment/types';
import { ChatMessageCache } from './message';
import { ChatPrompt } from './prompt/chat-prompt';
import { ConversationPolicy } from './conversation/policy';
import { ConversationStore } from './conversation/store';
import { type Conversation, promptMessageFromTurn, type Turn } from './core';
import type { ResolvedPrompt } from './prompt';
import { PromptService } from './prompt/service';
import { promptAttachmentHasSource } from './providers/attachments';
import { CopilotProviderFactory } from './providers/factory';
import { buildProviderRegistry } from './providers/provider-registry';
import { type PromptMessage, type PromptParams } from './providers/types';
import { PromptRuntime } from './runtime/prompt-runtime';
import {
ModelOutputType,
type PromptMessage,
type PromptParams,
} from './providers/types';
import { promptAttachmentToUrl } from './providers/utils';
import {
type ChatHistory,
type ChatMessage,
ChatMessageSchema,
type ChatSessionForkOptions,
type ChatSessionOptions,
type ChatSessionState,
type SubmittedMessage,
} from './types';
declare global {
@@ -62,15 +45,31 @@ declare global {
}
}
const BACKGROUND_COPILOT_JOB_PRIORITY = 100;
export class ChatSession implements AsyncDisposable {
private stashMessageCount = 0;
private stashTurnCount = 0;
private readonly renderPromptSession: (
prompt: ResolvedPrompt,
turns: PromptMessage[],
params: PromptParams,
maxTokenSize: number,
sessionId?: string
) => PromptMessage[];
constructor(
private readonly moduleRef: ModuleRef,
private readonly messageCache: ChatMessageCache,
private readonly state: ChatSessionState,
renderPromptSession: (
prompt: ResolvedPrompt,
turns: PromptMessage[],
params: PromptParams,
maxTokenSize: number,
sessionId?: string
) => PromptMessage[],
private readonly dispose?: (state: ChatSessionState) => Promise<void>,
private readonly maxTokenSize = state.prompt.config?.maxTokens || 128 * 1024
) {}
) {
this.renderPromptSession = renderPromptSession;
}
get model() {
return this.state.prompt.model;
@@ -80,10 +79,6 @@ export class ChatSession implements AsyncDisposable {
return this.state.prompt.optionalModels;
}
get proModels() {
return this.state.prompt.config?.proModels || [];
}
get config() {
const {
sessionId,
@@ -96,219 +91,65 @@ export class ChatSession implements AsyncDisposable {
return { sessionId, userId, workspaceId, docId, promptName, promptConfig };
}
get stashMessages() {
if (!this.stashMessageCount) return [];
return this.state.messages.slice(-this.stashMessageCount);
get stashTurns() {
if (!this.stashTurnCount) return [];
return this.state.turns.slice(-this.stashTurnCount);
}
get latestUserMessage() {
return this.state.messages.findLast(m => m.role === 'user');
get latestUserTurn() {
return this.state.turns.findLast(({ role }) => role === 'user');
}
async resolveModel(
hasPayment: boolean,
requestedModelId?: string
): Promise<string> {
const config = this.moduleRef.get(Config, { strict: false });
const registry = config
? buildProviderRegistry(config.copilot.providers)
: null;
const defaultModel = this.model;
const normalizeModel = (modelId?: string) => {
if (!modelId) return modelId;
const separatorIndex = modelId.indexOf('/');
if (separatorIndex <= 0) return modelId;
const providerId = modelId.slice(0, separatorIndex);
if (!registry?.profiles.has(providerId)) return modelId;
return modelId.slice(separatorIndex + 1);
};
const inModelList = (models: string[], modelId?: string) => {
if (!modelId) return false;
return (
models.includes(modelId) ||
models.includes(normalizeModel(modelId) ?? '')
);
};
const normalize = (m?: string) => {
if (inModelList(this.optionalModels, m)) return m;
return defaultModel;
};
const isPro = (m?: string) => inModelList(this.proModels, m);
// try resolve payment subscription service lazily
let paymentEnabled = hasPayment;
let isUserAIPro = false;
try {
if (paymentEnabled) {
const sub = this.moduleRef.get(SubscriptionService, {
strict: false,
});
const subscription = await sub
.select(SubscriptionPlan.AI)
.getSubscription({
userId: this.config.userId,
plan: SubscriptionPlan.AI,
} as any);
isUserAIPro = subscription?.status === SubscriptionStatus.Active;
}
} catch {
// payment not available -> skip checks
paymentEnabled = false;
}
if (paymentEnabled && !isUserAIPro && isPro(requestedModelId)) {
if (!defaultModel) {
throw new CopilotSessionInvalidInput(
'Model is required for AI subscription fallback'
);
}
return defaultModel;
}
const resolvedModel = normalize(requestedModelId);
if (!resolvedModel) {
throw new CopilotSessionInvalidInput('Model is required');
}
return resolvedModel;
findTurn(turnId: string) {
return this.state.turns.find(({ id }) => id === turnId);
}
push(message: ChatMessage) {
private appendTurn(turn: Turn, persisted: boolean) {
if (
this.state.prompt.action &&
this.state.messages.length > 0 &&
message.role === 'user'
this.state.turns.length > 0 &&
turn.role === 'user'
) {
throw new CopilotActionTaken();
}
this.state.messages.push(message);
this.stashMessageCount += 1;
this.state.turns.push(turn);
if (!persisted) {
this.stashTurnCount += 1;
}
}
pushTurn(turn: Turn) {
this.appendTurn(turn, false);
}
pushPersistedTurn(turn: Turn) {
this.appendTurn(turn, true);
}
revertLatestMessage(removeLatestUserMessage: boolean) {
const messages = this.state.messages;
messages.splice(
messages.findLastIndex(({ role }) => role === AiPromptRole.user) +
const turns = this.state.turns;
turns.splice(
turns.findLastIndex(({ role }) => role === AiPromptRole.user) +
(removeLatestUserMessage ? 0 : 1)
);
}
async getMessageById(messageId: string) {
const message = await this.messageCache.get(messageId);
if (!message || message.sessionId !== this.state.sessionId) {
throw new CopilotMessageNotFound({ messageId });
}
return message;
}
async pushByMessageId(messageId: string) {
const message = await this.messageCache.get(messageId);
if (!message || message.sessionId !== this.state.sessionId) {
throw new CopilotMessageNotFound({ messageId });
}
this.push({
role: 'user',
content: message.content || '',
attachments: message.attachments,
params: message.params,
createdAt: new Date(),
});
}
pop() {
return this.state.messages.pop();
}
private takeMessages(): ChatMessage[] {
if (this.state.prompt.action) {
const messages = this.state.messages;
return messages.slice(messages.length - 1);
}
const ret = [];
const messages = this.state.messages.slice();
let size = this.state.prompt.tokens;
while (messages.length) {
const message = messages.pop();
if (!message) break;
size += this.state.prompt.encode(message.content);
if (size > this.maxTokenSize) {
break;
}
ret.push(message);
}
ret.reverse();
return ret;
}
private mergeUserContent(params: PromptParams) {
const messages = this.takeMessages();
const lastMessage = messages.pop();
if (
this.state.prompt.paramKeys.includes('content') &&
!messages.some(m => m.role === AiPromptRole.assistant) &&
lastMessage?.role === AiPromptRole.user
) {
const normalizedParams = {
...params,
...lastMessage.params,
content: lastMessage.content,
};
const finished = this.state.prompt.finish(
normalizedParams,
this.config.sessionId
);
// attachments should be combined with the first user message
const firstUserMessageIndex = finished.findIndex(
m => m.role === AiPromptRole.user
);
// if prompt not contains user message, skip merge content
if (firstUserMessageIndex < 0) return null;
const firstUserMessage = finished[firstUserMessageIndex];
firstUserMessage.attachments = [
finished[0].attachments || [],
lastMessage.attachments || [],
]
.flat()
.filter(v => promptAttachmentHasSource(v));
//insert all previous user message content before first user message
finished.splice(firstUserMessageIndex, 0, ...messages);
return finished;
}
return;
}
finish(params: PromptParams): PromptMessage[] {
// if the message in prompt config contains {{content}},
// we should combine it with the user message in the prompt
const mergedMessage = this.mergeUserContent(params);
if (mergedMessage) {
return mergedMessage;
}
const messages = this.takeMessages();
const lastMessage = messages.at(-1);
return [
...this.state.prompt.finish(
Object.keys(params).length ? params : lastMessage?.params || {},
this.config.sessionId
),
...messages.filter(m => m.content?.trim() || m.attachments?.length),
];
return this.renderPromptSession(
this.state.prompt,
this.state.turns.map(turn => promptMessageFromTurn(turn)),
params,
this.maxTokenSize,
this.state.sessionId
);
}
async save() {
await this.dispose?.({
...this.state,
// only provide new messages
messages: this.stashMessages,
turns: this.state.turns.slice(-this.stashTurnCount),
});
this.stashMessageCount = 0;
this.stashTurnCount = 0;
}
async [Symbol.asyncDispose]() {
@@ -316,41 +157,40 @@ export class ChatSession implements AsyncDisposable {
}
}
type Session = NonNullable<
Awaited<ReturnType<Models['copilotSession']['get']>>
export type ConversationState = {
conversation: Conversation;
turns: Turn[];
prompt: ResolvedPrompt;
tokenCost: number;
};
export type ConversationMetaState = {
conversation: Conversation;
prompt: ResolvedPrompt;
tokenCost: number;
};
type StoredConversation = NonNullable<
Awaited<ReturnType<ConversationStore['get']>>
>;
type SessionHistory = ChatHistory & {
prompt: ChatPrompt;
};
type StoredConversationMeta = NonNullable<
Awaited<ReturnType<ConversationStore['getMeta']>>
>;
@Injectable()
export class ChatSessionService {
private readonly logger = new Logger(ChatSessionService.name);
constructor(
private readonly moduleRef: ModuleRef,
private readonly models: Models,
private readonly jobs: JobQueue,
private readonly quota: QuotaService,
private readonly messageCache: ChatMessageCache,
private readonly prompt: PromptService
private readonly store: ConversationStore,
private readonly conversationPolicy: ConversationPolicy,
private readonly prompts: PromptService,
private readonly promptRuntime: PromptRuntime
) {}
private getMessage(session: Session): ChatMessage[] {
if (!Array.isArray(session.messages) || !session.messages.length) {
return [];
}
const messages = ChatMessageSchema.array().safeParse(session.messages);
if (!messages.success) {
this.logger.error(
`Unexpected message schema: ${JSON.stringify(messages.error)}`
);
return [];
}
return messages.data;
}
private stripNullBytes(value?: string | null): string {
if (!value) return '';
return value.replaceAll('\0', '');
@@ -365,162 +205,118 @@ export class ChatSessionService {
);
}
private async getHistory(session: Session): Promise<SessionHistory> {
const prompt = await this.prompt.get(session.promptName);
if (!prompt) throw new CopilotPromptNotFound({ name: session.promptName });
private async toConversationState(
session: StoredConversation
): Promise<ConversationState> {
const { conversation, prompt, tokenCost } =
await this.toConversationMetaState(session);
return {
...pick(session, [
'userId',
'workspaceId',
'docId',
'parentSessionId',
'pinned',
'title',
'createdAt',
'updatedAt',
]),
sessionId: session.id,
tokens: session.tokenCost,
messages: this.getMessage(session),
// prompt info
conversation,
turns: session.turns,
prompt,
action: prompt.action || null,
model: prompt.model,
optionalModels: prompt.optionalModels || null,
promptName: prompt.name,
tokenCost,
};
}
async getSessionInfo(sessionId: string): Promise<SessionHistory | undefined> {
const session = await this.models.copilotSession.get(sessionId);
if (!session) return;
private async toConversationMetaState(
session: StoredConversation | StoredConversationMeta
): Promise<ConversationMetaState> {
const prompt = await this.prompts.get(session.promptName);
if (!prompt) throw new CopilotPromptNotFound({ name: session.promptName });
return await this.getHistory(session);
return {
conversation: session.conversation,
prompt,
tokenCost: session.tokenCost,
};
}
// revert the latest messages not generate by user
// after revert, we can retry the action
async revertLatestMessage(
sessionId: string,
removeLatestUserMessage: boolean
) {
await this.models.copilotSession.revertLatestMessage(
sessionId,
removeLatestUserMessage
);
async getState(sessionId: string): Promise<ConversationState | undefined> {
const session = await this.store.get(sessionId);
if (!session) return;
return await this.toConversationState(session);
}
async getMetaState(
sessionId: string
): Promise<ConversationMetaState | undefined> {
const session = await this.store.getMeta(sessionId);
if (!session) return;
return await this.toConversationMetaState(session);
}
async count(options: ListSessionOptions): Promise<number> {
return await this.models.copilotSession.count(options);
return await this.store.count(options);
}
async list(
options: ListSessionOptions,
withMessages: boolean
): Promise<ChatHistory[]> {
const { userId: reqUserId } = options;
const sessions = await this.models.copilotSession.list({
async listStates(options: ListSessionOptions): Promise<ConversationState[]> {
const sessions = await this.store.list({
...options,
withMessages,
withMessages: true,
});
const histories = await Promise.all(
const states = await Promise.all(
sessions.map(async session => {
const { userId, id: sessionId, createdAt } = session;
try {
const { prompt, messages, ...baseHistory } =
await this.getHistory(session);
if (withMessages) {
if (
// filter out the user's session that not match the action option
(userId === reqUserId && !!options?.action !== !!prompt.action) ||
// filter out the non chat session from other user
(userId !== reqUserId && !!prompt.action)
) {
return undefined;
}
// render system prompt
const preload = (
options?.withPrompt
? prompt
.finish(messages[0]?.params || {}, sessionId)
.filter(({ role }) => role !== 'system')
: []
) as ChatMessage[];
// `createdAt` is required for history sorting in frontend
// let's fake the creating time of prompt messages
preload.forEach((msg, i) => {
msg.createdAt = new Date(
createdAt.getTime() - preload.length - i - 1
);
});
return {
...baseHistory,
messages: preload.concat(messages).map(m => ({
...m,
attachments: m.attachments
?.map(a => promptAttachmentToUrl(a))
.filter((a): a is string => !!a),
})),
};
} else {
return { ...baseHistory, messages: [] };
}
return await this.toConversationState(session);
} catch (e) {
this.logger.error('Unexpected error in list ChatHistories', e);
this.logger.error(
'Unexpected error in list copilot conversations',
e
);
}
return undefined;
})
);
return histories.filter((v): v is NonNullable<typeof v> => !!v);
return states.filter((v): v is NonNullable<typeof v> => !!v);
}
async listMetaStates(
options: ListSessionOptions
): Promise<ConversationMetaState[]> {
const sessions = await this.store.listMeta(options);
const states = await Promise.all(
sessions.map(async session => {
try {
return await this.toConversationMetaState(session);
} catch (e) {
this.logger.error(
'Unexpected error in list copilot conversation metadata',
e
);
}
return undefined;
})
);
return states.filter((v): v is NonNullable<typeof v> => !!v);
}
async getQuota(userId: string) {
const isCopilotUser = await this.models.userFeature.has(
userId,
'unlimited_copilot'
);
let limit: number | undefined;
if (!isCopilotUser) {
const quota = await this.quota.getUserQuota(userId);
limit = quota.copilotActionLimit;
}
const used = await this.models.copilotSession.countUserMessages(userId);
return { limit, used };
return await this.conversationPolicy.getQuota(userId);
}
async checkQuota(userId: string) {
const { limit, used } = await this.getQuota(userId);
if (limit && Number.isFinite(limit) && used >= limit) {
throw new CopilotQuotaExceeded();
}
await this.conversationPolicy.checkQuota(userId);
}
async create(options: ChatSessionOptions): Promise<string> {
const sessionId = randomUUID();
const prompt = await this.prompt.get(options.promptName);
const prompt = await this.prompts.get(options.promptName);
if (!prompt) {
this.logger.error(`Prompt not found: ${options.promptName}`);
throw new CopilotPromptNotFound({ name: options.promptName });
}
if (options.pinned) {
await this.unpin(options.workspaceId, options.userId);
}
// validate prompt compatibility with session type
this.models.copilotSession.checkSessionPrompt(options, prompt);
return await this.models.copilotSession.createWithPrompt(
return await this.store.create(
{
...options,
sessionId,
@@ -536,13 +332,13 @@ export class ChatSessionService {
@Transactional()
async unpin(workspaceId: string, userId: string) {
await this.models.copilotSession.unpin(workspaceId, userId);
await this.store.unpin(workspaceId, userId);
}
@Transactional()
async update(options: UpdateChatSession): Promise<string> {
const session = await this.getSessionInfo(options.sessionId);
if (!session) {
const state = await this.getState(options.sessionId);
if (!state) {
throw new CopilotSessionNotFound();
}
@@ -551,37 +347,49 @@ export class ChatSessionService {
sessionId: options.sessionId,
};
if (options.promptName) {
const prompt = await this.prompt.get(options.promptName);
const prompt = await this.prompts.get(options.promptName);
if (!prompt) {
this.logger.error(`Prompt not found: ${options.promptName}`);
throw new CopilotPromptNotFound({ name: options.promptName });
}
this.models.copilotSession.checkSessionPrompt(session, prompt);
this.models.copilotSession.checkSessionPrompt(
{
docId: state.conversation.docId,
pinned: state.conversation.pinned,
},
prompt
);
finalData.promptName = prompt.name;
finalData.promptAction = prompt.action ?? null;
finalData.promptModel = prompt.model;
}
finalData.pinned = options.pinned;
finalData.docId = options.docId;
if (Object.keys(finalData).length === 0) {
if (
options.promptName === undefined &&
options.pinned === undefined &&
options.docId === undefined
) {
throw new CopilotSessionInvalidInput(
'No valid fields to update in the session'
);
}
return await this.models.copilotSession.update(finalData);
return await this.store.update(finalData);
}
@Transactional()
async fork(options: ChatSessionForkOptions): Promise<string> {
const session = await this.getSessionInfo(options.sessionId);
if (!session) {
const state = await this.getState(options.sessionId);
if (!state) {
throw new CopilotSessionNotFound();
}
let messages = session.messages.map(m => ({ ...m, id: undefined }));
let turns = state.turns;
if (options.latestMessageId) {
const lastMessageIdx = session.messages.findLastIndex(
const lastMessageIdx = state.turns.findLastIndex(
({ id, role }) =>
role === AiPromptRole.assistant && id === options.latestMessageId
);
@@ -590,26 +398,68 @@ export class ChatSessionService {
messageId: options.latestMessageId,
});
}
messages = messages.slice(0, lastMessageIdx + 1);
turns = turns.slice(0, lastMessageIdx + 1);
}
return await this.models.copilotSession.fork({
...session,
return await this.store.fork({
userId: options.userId,
// docId can be changed in fork
workspaceId: state.conversation.workspaceId,
docId: options.docId,
sessionId: randomUUID(),
parentSessionId: options.sessionId,
messages,
pinned: state.conversation.pinned,
title: state.conversation.title,
prompt: {
name: state.prompt.name,
action: state.prompt.action,
model: state.prompt.model,
},
turns,
});
}
async cleanup(options: CleanupSessionOptions) {
return await this.models.copilotSession.cleanup(options);
return await this.store.cleanup(options);
}
async createMessage(message: SubmittedMessage): Promise<string> {
return await this.messageCache.set(message);
async getMessage(sessionId: string, messageId: string) {
const message = await this.models.copilotSession.getMessage(
sessionId,
messageId
);
if (!message) {
throw new CopilotMessageNotFound({ messageId });
}
return message;
}
async appendTurn(input: {
sessionId: string;
userId: string;
prompt: { model: string };
turn: Turn;
compatSubmissionId?: string;
}) {
return await this.store.appendTurn(input);
}
async findTurnByCompatSubmissionId(
sessionId: string,
compatSubmissionId: string
) {
return await this.store.findTurnByCompatSubmissionId(
sessionId,
compatSubmissionId
);
}
// revert the latest messages not generate by user
// after revert, we can retry the action
async revertLatestMessage(
sessionId: string,
removeLatestUserMessage: boolean
) {
await this.store.revertLatestTurn(sessionId, removeLatestUserMessage);
}
/**
@@ -618,7 +468,7 @@ export class ChatSessionService {
* {
* // allocate a session, can be reused chat in about 12 hours with same session
* await using session = await session.get(sessionId);
* session.push(message);
* session.pushTurn(turn);
* copilot.text({ modelId }, session.finish());
* }
* // session will be disposed after the block
@@ -626,16 +476,33 @@ export class ChatSessionService {
* @returns
*/
async get(sessionId: string): Promise<ChatSession | null> {
const state = await this.getSessionInfo(sessionId);
const state = await this.getState(sessionId);
if (state) {
return new ChatSession(
this.moduleRef,
this.messageCache,
state,
{
userId: state.conversation.userId,
sessionId: state.conversation.id,
workspaceId: state.conversation.workspaceId,
docId: state.conversation.docId,
turns: state.turns,
prompt: state.prompt,
},
(prompt, turns, params, maxTokenSize, sessionId) =>
this.prompts.renderSession(
prompt,
turns,
params,
maxTokenSize,
sessionId
),
async state => {
await this.models.copilotSession.updateMessages(state);
if (!state.prompt.action) {
await this.jobs.add('copilot.session.generateTitle', { sessionId });
await this.store.appendTurns(state);
if (this.conversationPolicy.shouldScheduleTitle(state.prompt)) {
await this.jobs.add(
'copilot.session.generateTitle',
{ sessionId: state.sessionId },
{ priority: BACKGROUND_COPILOT_JOB_PRIORITY }
);
}
}
);
@@ -643,34 +510,6 @@ export class ChatSessionService {
return null;
}
// public for test mock
async chatWithPrompt(
promptName: string,
message: Partial<PromptMessage>
): Promise<string> {
const prompt = await this.prompt.get(promptName);
if (!prompt) {
throw new CopilotPromptNotFound({ name: promptName });
}
const cond = { modelId: prompt.model };
const msg = { role: 'user' as const, content: '', ...message };
const config = Object.assign({}, prompt.config);
const provider = await this.moduleRef
.get(CopilotProviderFactory)
.getProvider({
outputType: ModelOutputType.Text,
modelId: prompt.model,
});
if (!provider) {
throw new NoCopilotProviderAvailable({ modelId: prompt.model });
}
return provider.text(cond, [...prompt.finish({}), msg], config);
}
@OnJob('copilot.session.deleteDoc')
async deleteDocSessions(doc: Jobs['copilot.session.deleteDoc']) {
const sessionIds = await this.models.copilotSession
@@ -693,34 +532,32 @@ export class ChatSessionService {
const { sessionId } = job;
try {
const session = await this.models.copilotSession.get(sessionId);
if (!session) {
const state = await this.getState(sessionId);
if (!state) {
this.logger.warn(
`Session ${sessionId} not found when generating title`
);
return;
}
const { userId, title } = session;
const messages =
session.messages?.map(m => ({
...m,
content: this.stripNullBytes(m.content),
})) ?? [];
const { conversation } = state;
const turns = state.turns.map(turn => ({
...turn,
content: this.stripNullBytes(turn.content),
}));
if (
title ||
!messages.length ||
messages.filter(m => m.role === 'user').length === 0 ||
messages.filter(m => m.role === 'assistant').length === 0
!this.conversationPolicy.shouldGenerateTitle({
title: conversation.title,
turns,
})
) {
return;
}
const promptContent = messages
.map(m => `[${m.role}]: ${m.content}`)
.join('\n');
const promptContent =
this.conversationPolicy.buildTitlePromptContent(turns);
const generatedTitle = this.stripNullBytes(
await this.chatWithPrompt('Summary as title', {
await this.promptRuntime.runText('Summary as title', {
content: promptContent,
})
).trim();
@@ -732,7 +569,7 @@ export class ChatSessionService {
return;
}
await this.models.copilotSession.update({
userId,
userId: conversation.userId,
sessionId,
title: generatedTitle,
});
@@ -48,13 +48,14 @@ export class CopilotStorage {
userId: string,
workspaceId: string,
key: string,
blob: BlobInputType
blob: BlobInputType,
mimeType = 'image/png'
) {
const name = `${userId}/${workspaceId}/${key}`;
await this.provider.put(name, blob);
if (!env.prod) {
// return image base64url for dev environment
return `data:image/png;base64,${blob.toString('base64')}`;
return `data:${mimeType};base64,${blob.toString('base64')}`;
}
return this.url.link(`/api/copilot/blob/${name}`);
}
@@ -92,8 +93,12 @@ export class CopilotStorage {
@CallMetric('ai', 'blob_proxy_remote_url')
async handleRemoteLink(userId: string, workspaceId: string, link: string) {
const { buffer } = await fetchBuffer(link, REMOTE_BLOB_MAX_BYTES, 'image/');
const { buffer, type } = await fetchBuffer(
link,
REMOTE_BLOB_MAX_BYTES,
'image/'
);
const filename = createHash('sha256').update(buffer).digest('base64url');
return this.put(userId, workspaceId, filename, buffer);
return this.put(userId, workspaceId, filename, buffer, type);
}
}
@@ -3,7 +3,11 @@ import { z } from 'zod';
import { toolError } from './error';
import { defineTool } from './tool';
import type { CopilotProviderFactory, PromptService } from './types';
type RunPromptText = (
promptName: string,
params: Record<string, unknown>
) => Promise<string>;
const logger = new Logger('CodeArtifactTool');
/**
@@ -12,10 +16,7 @@ const logger = new Logger('CodeArtifactTool');
* it can be saved as a single .html file and opened in any browser with no
* external dependencies.
*/
export const createCodeArtifactTool = (
promptService: PromptService,
factory: CopilotProviderFactory
) => {
export const createCodeArtifactTool = (prompt: RunPromptText) => {
return defineTool({
description:
'Generate a single-file HTML snippet (with inline <style> and <script>) that accomplishes the requested functionality. The final HTML should be runnable when saved as an .html file and opened in a browser. Do NOT reference external resources (CSS, JS, images) except through data URIs.',
@@ -35,20 +36,7 @@ export const createCodeArtifactTool = (
}),
execute: async ({ title, userPrompt }) => {
try {
const prompt = await promptService.get('Code Artifact');
if (!prompt) {
throw new Error('Prompt not found');
}
const provider = await factory.getProviderByModel(prompt.model);
if (!provider) {
throw new Error('Provider not found');
}
const content = await provider.text(
{
modelId: prompt.model,
},
prompt.finish({ content: userPrompt })
);
const content = await prompt('Code Artifact', { content: userPrompt });
// Remove surrounding ``` or ```html fences if present
let stripped = content.trim();
if (stripped.startsWith('```')) {
@@ -3,14 +3,17 @@ import { z } from 'zod';
import { toolError } from './error';
import { defineTool } from './tool';
import type { CopilotProviderFactory, PromptService } from './types';
type RunPromptText = (
promptName: string,
params: Record<string, unknown>
) => Promise<string>;
const logger = new Logger('ConversationSummaryTool');
export const createConversationSummaryTool = (
sessionId: string | undefined,
promptService: PromptService,
factory: CopilotProviderFactory
prompt: RunPromptText
) => {
return defineTool({
description:
@@ -38,27 +41,14 @@ export const createConversationSummaryTool = (
);
}
const prompt = await promptService.get('Conversation Summary');
const provider = await factory.getProviderByModel(prompt?.model || '');
if (!prompt || !provider) {
return toolError(
'Prompt Not Found',
'Failed to summarize conversation.'
);
}
const summary = await provider.text(
{ modelId: prompt.model },
prompt.finish({
messages: messages.map(m => ({
...m,
content: m.content.toString(),
})),
focus: focus || 'general',
length,
})
);
const summary = await prompt('Conversation Summary', {
messages: messages.map(m => ({
...m,
content: m.content.toString(),
})),
focus: focus || 'general',
length,
});
return {
focusArea: focus || 'general',
@@ -1,16 +1,21 @@
import { Logger } from '@nestjs/common';
import { z } from 'zod';
import type { PromptMessage } from '../providers/types';
import { toolError } from './error';
import { defineTool } from './tool';
import type { CopilotProviderFactory, PromptService } from './types';
type RunPromptText = (
promptName: string,
params: Record<string, unknown>,
options?: {
appendMessages?: PromptMessage[];
}
) => Promise<string>;
const logger = new Logger('DocComposeTool');
export const createDocComposeTool = (
promptService: PromptService,
factory: CopilotProviderFactory
) => {
export const createDocComposeTool = (prompt: RunPromptText) => {
return defineTool({
description:
'Write a new document with markdown content. This tool creates structured markdown content for documents including titles, sections, and formatting.',
@@ -24,22 +29,10 @@ export const createDocComposeTool = (
}),
execute: async ({ title, userPrompt }) => {
try {
const prompt = await promptService.get('Write an article about this');
if (!prompt) {
throw new Error('Prompt not found');
}
const provider = await factory.getProviderByModel(prompt.model);
if (!provider) {
throw new Error('Provider not found');
}
const content = await provider.text(
{
modelId: prompt.model,
},
[...prompt.finish({}), { role: 'user', content: userPrompt }]
const content = await prompt(
'Write an article about this',
{},
{ appendMessages: [{ role: 'user', content: userPrompt }] }
);
return {
@@ -3,11 +3,12 @@ import { z } from 'zod';
import { DocReader } from '../../../core/doc';
import { AccessController } from '../../../core/permission';
import { defineTool } from './tool';
import type {
CopilotChatOptions,
CopilotProviderFactory,
PromptService,
} from './types';
import type { CopilotChatOptions } from './types';
type RunPromptText = (
promptName: string,
params: Record<string, unknown>
) => Promise<string>;
const CodeEditSchema = z
.array(
@@ -46,8 +47,7 @@ export const buildContentGetter = (ac: AccessController, doc: DocReader) => {
};
export const createDocEditTool = (
factory: CopilotProviderFactory,
prompt: PromptService,
prompt: RunPromptText,
getContent: (targetId?: string) => Promise<string | undefined>
) => {
return defineTool({
@@ -182,16 +182,6 @@ You should specify the following arguments before the others: [doc_id], [origin_
}),
execute: async ({ doc_id, origin_content, code_edit }) => {
try {
const applyPrompt = await prompt.get('Apply Updates');
if (!applyPrompt) {
return 'Prompt not found';
}
const model = applyPrompt.model;
const provider = await factory.getProviderByModel(model);
if (!provider) {
return 'Editing docs is not supported';
}
const content = origin_content || (await getContent(doc_id));
if (!content) {
return 'Doc not found or doc is empty';
@@ -199,13 +189,11 @@ You should specify the following arguments before the others: [doc_id], [origin_
const changedContents = await Promise.all(
code_edit.map(async edit => {
return await provider.text({ modelId: model }, [
...applyPrompt.finish({
content,
op: edit.op,
updates: edit.updates,
}),
]);
return await prompt('Apply Updates', {
content,
op: edit.op,
updates: edit.updates,
});
})
);
@@ -7,19 +7,16 @@ import {
clearEmbeddingChunk,
type Models,
} from '../../../models';
import { CopilotContextService } from '../context/service';
import { workspaceSyncRequiredError } from './doc-sync';
import { toolError } from './error';
import { defineTool } from './tool';
import type {
ContextSession,
CopilotChatOptions,
CopilotContextService,
} from './types';
import type { CopilotChatOptions } from './types';
export const buildDocSearchGetter = (
ac: AccessController,
context: CopilotContextService,
docContext: ContextSession | null,
sessionId: string | undefined,
models: Models
) => {
const searchDocs = async (
@@ -48,7 +45,11 @@ export const buildDocSearchGetter = (
);
const [chunks, contextChunks] = await Promise.all([
context.matchWorkspaceAll(options.workspace, query, 10, signal),
docContext?.matchFiles(query, 10, signal) ?? [],
sessionId
? context
.getBySessionId(sessionId)
.then(current => current?.matchFiles(query, 10, signal) ?? [])
: [],
]);
const docChunks = await ac

Some files were not shown because too many files have changed in this diff Show More