feat(core): add o4-mini model (#12175)

Close [AI-85](https://linear.app/affine-design/issue/AI-85)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
	- Enhanced AI chat experience with improved web search integration, including a new "AUTO" mode that allows the AI to search the web only when needed.
	- Updated AI model for chat to "o4-mini" for improved performance.
- **Improvements**
	- Refined instructions to reduce hallucinations and ensure the AI admits uncertainty when unsure.
	- Enhanced streaming responses to display web search queries and results more clearly.
	- Updated web search tool modes for more accurate and flexible search behavior.
	- Centralized AI provider options and improved tool selection logic for better response quality.
- **Bug Fixes**
	- Improved handling of tool calls and reasoning steps in AI chat responses.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
akumatus
2025-05-07 13:36:41 +00:00
parent b4f56c0a78
commit 32db367298
7 changed files with 105 additions and 32 deletions
@@ -177,6 +177,12 @@ export class AnthropicProvider
yield message.textDelta;
break;
}
case 'tool-call': {
if (message.toolName === 'web_search') {
yield '\n' + `Searching the web "${message.args.query}"` + '\n';
}
break;
}
case 'tool-result': {
if (message.toolName === 'web_search') {
this.toolResults.push(this.getWebSearchLinks(message.result));
@@ -219,18 +225,15 @@ export class AnthropicProvider
return undefined;
}
private getAnthropicOptions(
options: CopilotChatOptions
): AnthropicProviderOptions {
private getAnthropicOptions(options: CopilotChatOptions) {
const result: AnthropicProviderOptions = {};
if (options?.reasoning) {
return {
thinking: {
type: 'enabled',
budgetTokens: 12000,
},
result.thinking = {
type: 'enabled',
budgetTokens: 12000,
};
}
return {};
return result;
}
private getWebSearchLinks(
@@ -2,6 +2,7 @@ import {
createOpenAI,
openai,
type OpenAIProvider as VercelOpenAIProvider,
OpenAIResponsesProviderOptions,
} from '@ai-sdk/openai';
import {
AISDKError,
@@ -10,7 +11,6 @@ import {
generateObject,
generateText,
streamText,
ToolSet,
} from 'ai';
import {
@@ -19,6 +19,7 @@ import {
metrics,
UserFriendlyError,
} from '../../../base';
import { createExaTool } from '../tools';
import { CopilotProvider } from './provider';
import {
ChatMessageRole,
@@ -42,6 +43,11 @@ export type OpenAIConfig = {
baseUrl?: string;
};
type OpenAITools = {
web_search_preview: ReturnType<typeof openai.tools.webSearchPreview>;
web_search: ReturnType<typeof createExaTool>;
};
export class OpenAIProvider
extends CopilotProvider<OpenAIConfig>
implements
@@ -68,7 +74,7 @@ export class OpenAIProvider
'gpt-4.1-2025-04-14',
'gpt-4.1-mini',
'o1',
'o3-mini',
'o4-mini',
// embeddings
'text-embedding-3-large',
'text-embedding-3-small',
@@ -81,6 +87,8 @@ export class OpenAIProvider
'gpt-image-1',
];
private readonly MAX_STEPS = 20;
#instance!: VercelOpenAIProvider;
override configured(): boolean {
@@ -179,21 +187,28 @@ export class OpenAIProvider
}
}
private getTools(options: CopilotChatOptions): ToolSet | undefined {
private getTools(
options: CopilotChatOptions,
model: string
): Partial<OpenAITools> {
const tools: Partial<OpenAITools> = {};
if (options?.tools?.length) {
const tools: ToolSet = {};
for (const tool of options.tools) {
switch (tool) {
case 'webSearch': {
tools.web_search_preview = openai.tools.webSearchPreview();
// o series reasoning models
if (model.startsWith('o')) {
tools.web_search = createExaTool(this.AFFiNEConfig);
} else {
tools.web_search_preview = openai.tools.webSearchPreview();
}
break;
}
}
}
return tools;
}
return undefined;
return tools;
}
// ====== text to text ======
@@ -231,10 +246,10 @@ export class OpenAIProvider
: await generateText({
...commonParams,
providerOptions: {
openai: options.user ? { user: options.user } : {},
openai: this.getOpenAIOptions(options),
},
toolChoice: options.webSearch ? 'required' : 'auto',
tools: this.getTools(options),
tools: this.getTools(options, model),
maxSteps: this.MAX_STEPS,
});
return text.trim();
@@ -258,14 +273,16 @@ export class OpenAIProvider
const modelInstance = this.#instance.responses(model);
const tools = this.getTools(options, model);
const { fullStream } = streamText({
model: modelInstance,
system,
messages: msgs,
tools: this.getTools(options),
providerOptions: {
openai: options.user ? { user: options.user } : {},
openai: this.getOpenAIOptions(options),
},
tools: tools as OpenAITools,
maxSteps: this.MAX_STEPS,
frequencyPenalty: options.frequencyPenalty || 0,
presencePenalty: options.presencePenalty || 0,
temperature: options.temperature || 0,
@@ -274,25 +291,53 @@ export class OpenAIProvider
});
const parser = new CitationParser();
let lastType;
for await (const chunk of fullStream) {
if (chunk) {
switch (chunk.type) {
case 'text-delta': {
if (lastType !== chunk.type) {
yield `\n`;
}
const result = parser.parse(chunk.textDelta);
yield result;
break;
}
case 'reasoning': {
if (lastType !== chunk.type) {
yield `\n`;
}
yield chunk.textDelta;
break;
}
case 'tool-call': {
if (chunk.toolName === 'web_search') {
yield '\n' + `Searching the web "${chunk.args.query}"` + '\n';
}
break;
}
case 'tool-result': {
if (chunk.toolName === 'web_search') {
yield '\n' + this.getWebSearchLinks(chunk.result) + '\n';
}
break;
}
case 'step-finish': {
const result = parser.end();
yield result;
break;
}
case 'error': {
const error = chunk.error as { type: string; message: string };
throw new Error(error.message);
}
}
if (options.signal?.aborted) {
await fullStream.cancel();
break;
}
lastType = chunk.type;
}
}
} catch (e: any) {
@@ -380,4 +425,28 @@ export class OpenAIProvider
throw e;
}
}
private getOpenAIOptions(options: CopilotChatOptions) {
const result: OpenAIResponsesProviderOptions = {};
if (options?.reasoning) {
result.reasoningEffort = 'medium';
result.reasoningSummary = 'auto';
}
if (options?.user) {
result.user = options.user;
}
return result;
}
private getWebSearchLinks(
list: {
title: string | null;
url: string;
}[]
): string {
const links = list.reduce((acc, result) => {
return acc + `\n[${result.title ?? result.url}](${result.url})\n`;
}, '\n');
return links + '\n';
}
}