mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-20 11:36:25 +08:00
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:
+16
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
+37
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
+104
@@ -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
|
||||
);
|
||||
}
|
||||
+174
@@ -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;
|
||||
}
|
||||
+205
@@ -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,
|
||||
};
|
||||
}
|
||||
+133
@@ -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
Reference in New Issue
Block a user