mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-09 05:05:52 +08:00
feat(server): adapt gpt5 (#13478)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - New Features - Added GPT-5 family and made GPT-5/-mini the new defaults for Copilot scenarios and prompts. - Bug Fixes - Improved streaming chunk formats and reasoning/text semantics, consistent attachment mediaType handling, and more reliable reranking via log-prob handling. - Refactor - Unified maxOutputTokens usage; removed per-call step caps and migrated several tools to a unified inputSchema shape. - Chores - Upgraded AI SDK dependencies and bumped an internal dependency version. - Tests - Updated mocks and tests to reference GPT-5 variants and new stream formats. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -3,7 +3,7 @@ import {
|
||||
type AnthropicProviderOptions,
|
||||
} from '@ai-sdk/anthropic';
|
||||
import { type GoogleVertexAnthropicProvider } from '@ai-sdk/google-vertex/anthropic';
|
||||
import { AISDKError, generateText, streamText } from 'ai';
|
||||
import { AISDKError, generateText, stepCountIs, streamText } from 'ai';
|
||||
|
||||
import {
|
||||
CopilotProviderSideError,
|
||||
@@ -75,8 +75,7 @@ export abstract class AnthropicProvider<T> extends CopilotProvider<T> {
|
||||
anthropic: this.getAnthropicOptions(options, model.id),
|
||||
},
|
||||
tools: await this.getTools(options, model.id),
|
||||
maxSteps: this.MAX_STEPS,
|
||||
experimental_continueSteps: true,
|
||||
stopWhen: stepCountIs(this.MAX_STEPS),
|
||||
});
|
||||
|
||||
if (!text) throw new Error('Failed to generate text');
|
||||
@@ -169,8 +168,7 @@ export abstract class AnthropicProvider<T> extends CopilotProvider<T> {
|
||||
anthropic: this.getAnthropicOptions(options, model.id),
|
||||
},
|
||||
tools: await this.getTools(options, model.id),
|
||||
maxSteps: this.MAX_STEPS,
|
||||
experimental_continueSteps: true,
|
||||
stopWhen: stepCountIs(this.MAX_STEPS),
|
||||
});
|
||||
return fullStream;
|
||||
}
|
||||
|
||||
@@ -38,8 +38,6 @@ import {
|
||||
export const DEFAULT_DIMENSIONS = 256;
|
||||
|
||||
export abstract class GeminiProvider<T> extends CopilotProvider<T> {
|
||||
private readonly MAX_STEPS = 20;
|
||||
|
||||
protected abstract instance:
|
||||
| GoogleGenerativeAIProvider
|
||||
| GoogleVertexProvider;
|
||||
@@ -87,8 +85,6 @@ export abstract class GeminiProvider<T> extends CopilotProvider<T> {
|
||||
google: this.getGeminiOptions(options, model.id),
|
||||
},
|
||||
tools: await this.getTools(options, model.id),
|
||||
maxSteps: this.MAX_STEPS,
|
||||
experimental_continueSteps: true,
|
||||
});
|
||||
|
||||
if (!text) throw new Error('Failed to generate text');
|
||||
@@ -116,9 +112,7 @@ export abstract class GeminiProvider<T> extends CopilotProvider<T> {
|
||||
throw new CopilotPromptInvalid('Schema is required');
|
||||
}
|
||||
|
||||
const modelInstance = this.instance(model.id, {
|
||||
structuredOutputs: true,
|
||||
});
|
||||
const modelInstance = this.instance(model.id);
|
||||
const { object } = await generateObject({
|
||||
model: modelInstance,
|
||||
system,
|
||||
@@ -238,14 +232,21 @@ export abstract class GeminiProvider<T> extends CopilotProvider<T> {
|
||||
.counter('generate_embedding_calls')
|
||||
.add(1, { model: model.id });
|
||||
|
||||
const modelInstance = this.instance.textEmbeddingModel(model.id, {
|
||||
outputDimensionality: options.dimensions || DEFAULT_DIMENSIONS,
|
||||
taskType: 'RETRIEVAL_DOCUMENT',
|
||||
});
|
||||
const modelInstance = this.instance.textEmbeddingModel(model.id);
|
||||
|
||||
const embeddings = await Promise.allSettled(
|
||||
messages.map(m =>
|
||||
embedMany({ model: modelInstance, values: [m], maxRetries: 3 })
|
||||
embedMany({
|
||||
model: modelInstance,
|
||||
values: [m],
|
||||
maxRetries: 3,
|
||||
providerOptions: {
|
||||
google: {
|
||||
outputDimensionality: options.dimensions || DEFAULT_DIMENSIONS,
|
||||
taskType: 'RETRIEVAL_DOCUMENT',
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
@@ -275,8 +276,6 @@ export abstract class GeminiProvider<T> extends CopilotProvider<T> {
|
||||
google: this.getGeminiOptions(options, model.id),
|
||||
},
|
||||
tools: await this.getTools(options, model.id),
|
||||
maxSteps: this.MAX_STEPS,
|
||||
experimental_continueSteps: true,
|
||||
});
|
||||
return fullStream;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
experimental_generateImage as generateImage,
|
||||
generateObject,
|
||||
generateText,
|
||||
stepCountIs,
|
||||
streamText,
|
||||
Tool,
|
||||
} from 'ai';
|
||||
@@ -65,6 +66,18 @@ const ImageResponseSchema = z.union([
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
const LogProbsSchema = z.array(
|
||||
z.object({
|
||||
token: z.string(),
|
||||
logprob: z.number(),
|
||||
top_logprobs: z.array(
|
||||
z.object({
|
||||
token: z.string(),
|
||||
logprob: z.number(),
|
||||
})
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
readonly type = CopilotProviderType.OpenAI;
|
||||
@@ -162,6 +175,58 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gpt-5',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text, ModelInputType.Image],
|
||||
output: [
|
||||
ModelOutputType.Text,
|
||||
ModelOutputType.Object,
|
||||
ModelOutputType.Structured,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gpt-5-2025-08-07',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text, ModelInputType.Image],
|
||||
output: [
|
||||
ModelOutputType.Text,
|
||||
ModelOutputType.Object,
|
||||
ModelOutputType.Structured,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gpt-5-mini',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text, ModelInputType.Image],
|
||||
output: [
|
||||
ModelOutputType.Text,
|
||||
ModelOutputType.Object,
|
||||
ModelOutputType.Structured,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gpt-5-nano',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text, ModelInputType.Image],
|
||||
output: [
|
||||
ModelOutputType.Text,
|
||||
ModelOutputType.Object,
|
||||
ModelOutputType.Structured,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'o1',
|
||||
capabilities: [
|
||||
@@ -299,7 +364,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
model: string
|
||||
): [string, Tool?] | undefined {
|
||||
if (toolName === 'webSearch' && !this.isReasoningModel(model)) {
|
||||
return ['web_search_preview', openai.tools.webSearchPreview()];
|
||||
return ['web_search_preview', openai.tools.webSearchPreview({})];
|
||||
} else if (toolName === 'docEdit') {
|
||||
return ['doc_edit', undefined];
|
||||
}
|
||||
@@ -330,12 +395,12 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
system,
|
||||
messages: msgs,
|
||||
temperature: options.temperature ?? 0,
|
||||
maxTokens: options.maxTokens ?? 4096,
|
||||
maxOutputTokens: options.maxTokens ?? 4096,
|
||||
providerOptions: {
|
||||
openai: this.getOpenAIOptions(options, model.id),
|
||||
},
|
||||
tools: await this.getTools(options, model.id),
|
||||
maxSteps: this.MAX_STEPS,
|
||||
stopWhen: stepCountIs(this.MAX_STEPS),
|
||||
abortSignal: options.signal,
|
||||
});
|
||||
|
||||
@@ -451,7 +516,7 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
system,
|
||||
messages: msgs,
|
||||
temperature: options.temperature ?? 0,
|
||||
maxTokens: options.maxTokens ?? 4096,
|
||||
maxOutputTokens: options.maxTokens ?? 4096,
|
||||
maxRetries: options.maxRetries ?? 3,
|
||||
schema,
|
||||
providerOptions: {
|
||||
@@ -476,36 +541,37 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
await this.checkParams({ messages: [], cond: fullCond, options });
|
||||
const model = this.selectModel(fullCond);
|
||||
// get the log probability of "yes"/"no"
|
||||
const instance = this.#instance(model.id, { logprobs: 16 });
|
||||
const instance = this.#instance.chat(model.id);
|
||||
|
||||
const scores = await Promise.all(
|
||||
chunkMessages.map(async messages => {
|
||||
const [system, msgs] = await chatToGPTMessage(messages);
|
||||
|
||||
const { logprobs } = await generateText({
|
||||
const result = await generateText({
|
||||
model: instance,
|
||||
system,
|
||||
messages: msgs,
|
||||
temperature: 0,
|
||||
maxTokens: 16,
|
||||
maxOutputTokens: 16,
|
||||
providerOptions: {
|
||||
openai: {
|
||||
...this.getOpenAIOptions(options, model.id),
|
||||
logprobs: 16,
|
||||
},
|
||||
},
|
||||
abortSignal: options.signal,
|
||||
});
|
||||
|
||||
const topMap: Record<string, number> = (
|
||||
logprobs?.[0]?.topLogprobs ?? []
|
||||
).reduce<Record<string, number>>(
|
||||
const topMap: Record<string, number> = LogProbsSchema.parse(
|
||||
result.providerMetadata?.openai?.logprobs
|
||||
)[0].top_logprobs.reduce<Record<string, number>>(
|
||||
(acc, { token, logprob }) => ({ ...acc, [token]: logprob }),
|
||||
{}
|
||||
);
|
||||
|
||||
const findLogProb = (token: string): number => {
|
||||
// OpenAI often includes a leading space, so try matching '.yes', '_yes', ' yes' and 'yes'
|
||||
return [`.${token}`, `_${token}`, ` ${token}`, token]
|
||||
return [...'_:. "-\t,(=_“'.split('').map(c => c + token), token]
|
||||
.flatMap(v => [v, v.toLowerCase(), v.toUpperCase()])
|
||||
.reduce<number>(
|
||||
(best, key) =>
|
||||
@@ -544,12 +610,12 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
frequencyPenalty: options.frequencyPenalty ?? 0,
|
||||
presencePenalty: options.presencePenalty ?? 0,
|
||||
temperature: options.temperature ?? 0,
|
||||
maxTokens: options.maxTokens ?? 4096,
|
||||
maxOutputTokens: options.maxTokens ?? 4096,
|
||||
providerOptions: {
|
||||
openai: this.getOpenAIOptions(options, model.id),
|
||||
},
|
||||
tools: await this.getTools(options, model.id),
|
||||
maxSteps: this.MAX_STEPS,
|
||||
stopWhen: stepCountIs(this.MAX_STEPS),
|
||||
abortSignal: options.signal,
|
||||
});
|
||||
return fullStream;
|
||||
@@ -676,14 +742,16 @@ export class OpenAIProvider extends CopilotProvider<OpenAIConfig> {
|
||||
.counter('generate_embedding_calls')
|
||||
.add(1, { model: model.id });
|
||||
|
||||
const modelInstance = this.#instance.embedding(model.id, {
|
||||
dimensions: options.dimensions || DEFAULT_DIMENSIONS,
|
||||
user: options.user,
|
||||
});
|
||||
const modelInstance = this.#instance.embedding(model.id);
|
||||
|
||||
const { embeddings } = await embedMany({
|
||||
model: modelInstance,
|
||||
values: messages,
|
||||
providerOptions: {
|
||||
openai: {
|
||||
dimensions: options.dimensions || DEFAULT_DIMENSIONS,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return embeddings.filter(v => v && Array.isArray(v));
|
||||
|
||||
@@ -125,12 +125,12 @@ export class PerplexityProvider extends CopilotProvider<PerplexityConfig> {
|
||||
system,
|
||||
messages: msgs,
|
||||
temperature: options.temperature ?? 0,
|
||||
maxTokens: options.maxTokens ?? 4096,
|
||||
maxOutputTokens: options.maxTokens ?? 4096,
|
||||
abortSignal: options.signal,
|
||||
});
|
||||
|
||||
const parser = new CitationParser();
|
||||
for (const source of sources) {
|
||||
for (const source of sources.filter(s => s.sourceType === 'url')) {
|
||||
parser.push(source.url);
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ export class PerplexityProvider extends CopilotProvider<PerplexityConfig> {
|
||||
system,
|
||||
messages: msgs,
|
||||
temperature: options.temperature ?? 0,
|
||||
maxTokens: options.maxTokens ?? 4096,
|
||||
maxOutputTokens: options.maxTokens ?? 4096,
|
||||
abortSignal: options.signal,
|
||||
});
|
||||
|
||||
@@ -173,19 +173,18 @@ export class PerplexityProvider extends CopilotProvider<PerplexityConfig> {
|
||||
for await (const chunk of stream.fullStream) {
|
||||
switch (chunk.type) {
|
||||
case 'source': {
|
||||
parser.push(chunk.source.url);
|
||||
if (chunk.sourceType === 'url') {
|
||||
parser.push(chunk.url);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'text-delta': {
|
||||
const text = chunk.textDelta.replaceAll(
|
||||
/<\/?think>\n?/g,
|
||||
'\n---\n'
|
||||
);
|
||||
const text = chunk.text.replaceAll(/<\/?think>\n?/g, '\n---\n');
|
||||
const result = parser.parse(text);
|
||||
yield result;
|
||||
break;
|
||||
}
|
||||
case 'step-finish': {
|
||||
case 'finish-step': {
|
||||
const result = parser.end();
|
||||
yield result;
|
||||
break;
|
||||
|
||||
@@ -94,24 +94,24 @@ export async function chatToGPTMessage(
|
||||
|
||||
if (withAttachment) {
|
||||
for (let attachment of attachments) {
|
||||
let mimeType: string;
|
||||
let mediaType: string;
|
||||
if (typeof attachment === 'string') {
|
||||
mimeType =
|
||||
mediaType =
|
||||
typeof mimetype === 'string'
|
||||
? mimetype
|
||||
: await inferMimeType(attachment);
|
||||
} else {
|
||||
({ attachment, mimeType } = attachment);
|
||||
({ attachment, mimeType: mediaType } = attachment);
|
||||
}
|
||||
if (SIMPLE_IMAGE_URL_REGEX.test(attachment)) {
|
||||
const data =
|
||||
attachment.startsWith('data:') || useBase64Attachment
|
||||
? await fetch(attachment).then(r => r.arrayBuffer())
|
||||
: new URL(attachment);
|
||||
if (mimeType.startsWith('image/')) {
|
||||
contents.push({ type: 'image', image: data, mimeType });
|
||||
if (mediaType.startsWith('image/')) {
|
||||
contents.push({ type: 'image', image: data, mediaType });
|
||||
} else {
|
||||
contents.push({ type: 'file' as const, data, mimeType });
|
||||
contents.push({ type: 'file' as const, data, mediaType });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -417,12 +417,12 @@ export class TextStreamParser {
|
||||
if (!this.prefix) {
|
||||
this.resetPrefix();
|
||||
}
|
||||
result = chunk.textDelta;
|
||||
result = chunk.text;
|
||||
result = this.addNewline(chunk.type, result);
|
||||
break;
|
||||
}
|
||||
case 'reasoning': {
|
||||
result = chunk.textDelta;
|
||||
case 'reasoning-delta': {
|
||||
result = chunk.text;
|
||||
result = this.addPrefix(result);
|
||||
result = this.markAsCallout(result);
|
||||
break;
|
||||
@@ -438,28 +438,28 @@ export class TextStreamParser {
|
||||
break;
|
||||
}
|
||||
case 'web_search_exa': {
|
||||
result += `\nSearching the web "${chunk.args.query}"\n`;
|
||||
result += `\nSearching the web "${chunk.input.query}"\n`;
|
||||
break;
|
||||
}
|
||||
case 'web_crawl_exa': {
|
||||
result += `\nCrawling the web "${chunk.args.url}"\n`;
|
||||
result += `\nCrawling the web "${chunk.input.url}"\n`;
|
||||
break;
|
||||
}
|
||||
case 'doc_keyword_search': {
|
||||
result += `\nSearching the keyword "${chunk.args.query}"\n`;
|
||||
result += `\nSearching the keyword "${chunk.input.query}"\n`;
|
||||
break;
|
||||
}
|
||||
case 'doc_read': {
|
||||
result += `\nReading the doc "${chunk.args.doc_id}"\n`;
|
||||
result += `\nReading the doc "${chunk.input.doc_id}"\n`;
|
||||
break;
|
||||
}
|
||||
case 'doc_compose': {
|
||||
result += `\nWriting document "${chunk.args.title}"\n`;
|
||||
result += `\nWriting document "${chunk.input.title}"\n`;
|
||||
break;
|
||||
}
|
||||
case 'doc_edit': {
|
||||
this.docEditFootnotes.push({
|
||||
intent: chunk.args.instructions,
|
||||
intent: chunk.input.instructions,
|
||||
result: '',
|
||||
});
|
||||
break;
|
||||
@@ -475,12 +475,12 @@ export class TextStreamParser {
|
||||
result = this.addPrefix(result);
|
||||
switch (chunk.toolName) {
|
||||
case 'doc_edit': {
|
||||
if (
|
||||
chunk.result &&
|
||||
typeof chunk.result === 'object' &&
|
||||
Array.isArray(chunk.result.result)
|
||||
) {
|
||||
result += chunk.result.result
|
||||
const array =
|
||||
chunk.output && typeof chunk.output === 'object'
|
||||
? chunk.output.result
|
||||
: undefined;
|
||||
if (Array.isArray(array)) {
|
||||
result += array
|
||||
.map(item => {
|
||||
return `\n${item.changedContent}\n`;
|
||||
})
|
||||
@@ -493,37 +493,37 @@ export class TextStreamParser {
|
||||
break;
|
||||
}
|
||||
case 'doc_semantic_search': {
|
||||
if (Array.isArray(chunk.result)) {
|
||||
result += `\nFound ${chunk.result.length} document${chunk.result.length !== 1 ? 's' : ''} related to “${chunk.args.query}”.\n`;
|
||||
} else if (typeof chunk.result === 'string') {
|
||||
result += `\n${chunk.result}\n`;
|
||||
const output = chunk.output;
|
||||
if (Array.isArray(output)) {
|
||||
result += `\nFound ${output.length} document${output.length !== 1 ? 's' : ''} related to “${chunk.input.query}”.\n`;
|
||||
} else if (typeof output === 'string') {
|
||||
result += `\n${output}\n`;
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`Unexpected result type for doc_semantic_search: ${chunk.result?.message || 'Unknown error'}`
|
||||
`Unexpected result type for doc_semantic_search: ${output?.message || 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'doc_keyword_search': {
|
||||
if (Array.isArray(chunk.result)) {
|
||||
result += `\nFound ${chunk.result.length} document${chunk.result.length !== 1 ? 's' : ''} related to “${chunk.args.query}”.\n`;
|
||||
result += `\n${this.getKeywordSearchLinks(chunk.result)}\n`;
|
||||
const output = chunk.output;
|
||||
if (Array.isArray(output)) {
|
||||
result += `\nFound ${output.length} document${output.length !== 1 ? 's' : ''} related to “${chunk.input.query}”.\n`;
|
||||
result += `\n${this.getKeywordSearchLinks(output)}\n`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'doc_compose': {
|
||||
if (
|
||||
chunk.result &&
|
||||
typeof chunk.result === 'object' &&
|
||||
'title' in chunk.result
|
||||
) {
|
||||
result += `\nDocument "${chunk.result.title}" created successfully with ${chunk.result.wordCount} words.\n`;
|
||||
const output = chunk.output;
|
||||
if (output && typeof output === 'object' && 'title' in output) {
|
||||
result += `\nDocument "${output.title}" created successfully with ${output.wordCount} words.\n`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'web_search_exa': {
|
||||
if (Array.isArray(chunk.result)) {
|
||||
result += `\n${this.getWebSearchLinks(chunk.result)}\n`;
|
||||
const output = chunk.output;
|
||||
if (Array.isArray(output)) {
|
||||
result += `\n${this.getWebSearchLinks(output)}\n`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -598,11 +598,18 @@ export class TextStreamParser {
|
||||
export class StreamObjectParser {
|
||||
public parse(chunk: TextStreamPart<CustomAITools>) {
|
||||
switch (chunk.type) {
|
||||
case 'reasoning':
|
||||
case 'text-delta':
|
||||
case 'reasoning-delta': {
|
||||
return { type: 'reasoning' as const, textDelta: chunk.text };
|
||||
}
|
||||
case 'text-delta': {
|
||||
const { type, text: textDelta } = chunk;
|
||||
return { type, textDelta };
|
||||
}
|
||||
case 'tool-call':
|
||||
case 'tool-result': {
|
||||
return chunk;
|
||||
const { type, toolCallId, toolName, input: args } = chunk;
|
||||
const result = 'output' in chunk ? chunk.output : undefined;
|
||||
return { type, toolCallId, toolName, args, result } as StreamObject;
|
||||
}
|
||||
case 'error': {
|
||||
throw toError(chunk.error);
|
||||
|
||||
Reference in New Issue
Block a user