mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-09 05:05:52 +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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user