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:
DarkSky
2025-08-13 10:32:15 +08:00
committed by GitHub
parent fda7e9008d
commit 072557eba1
29 changed files with 410 additions and 374 deletions
@@ -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);