Skip to content

Commit dd09e62

Browse files
SteffenDEclauderekram1-node
authored andcommitted
feat(opencode): add copilot specific provider to properly handle copilot reasoning tokens (anomalyco#8900)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
1 parent a6103a2 commit dd09e62

33 files changed

Lines changed: 2381 additions & 17 deletions

packages/opencode/src/provider/provider.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { createVertexAnthropic } from "@ai-sdk/google-vertex/anthropic"
2424
import { createOpenAI } from "@ai-sdk/openai"
2525
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
2626
import { createOpenRouter, type LanguageModelV2 } from "@openrouter/ai-sdk-provider"
27-
import { createOpenaiCompatible as createGitHubCopilotOpenAICompatible } from "./sdk/openai-compatible/src"
27+
import { createOpenaiCompatible as createGitHubCopilotOpenAICompatible } from "./sdk/copilot"
2828
import { createXai } from "@ai-sdk/xai"
2929
import { createMistral } from "@ai-sdk/mistral"
3030
import { createGroq } from "@ai-sdk/groq"

packages/opencode/src/provider/sdk/openai-compatible/src/README.md renamed to packages/opencode/src/provider/sdk/copilot/README.md

File renamed without changes.
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import {
2+
type LanguageModelV2Prompt,
3+
type SharedV2ProviderMetadata,
4+
UnsupportedFunctionalityError,
5+
} from '@ai-sdk/provider';
6+
import type { OpenAICompatibleChatPrompt } from './openai-compatible-api-types';
7+
import { convertToBase64 } from '@ai-sdk/provider-utils';
8+
9+
function getOpenAIMetadata(message: {
10+
providerOptions?: SharedV2ProviderMetadata;
11+
}) {
12+
return message?.providerOptions?.copilot ?? {};
13+
}
14+
15+
export function convertToOpenAICompatibleChatMessages(
16+
prompt: LanguageModelV2Prompt,
17+
): OpenAICompatibleChatPrompt {
18+
const messages: OpenAICompatibleChatPrompt = [];
19+
for (const { role, content, ...message } of prompt) {
20+
const metadata = getOpenAIMetadata({ ...message });
21+
switch (role) {
22+
case 'system': {
23+
messages.push({
24+
role: 'system',
25+
content: [
26+
{
27+
type: 'text',
28+
text: content,
29+
},
30+
],
31+
...metadata,
32+
});
33+
break;
34+
}
35+
36+
case 'user': {
37+
if (content.length === 1 && content[0].type === 'text') {
38+
messages.push({
39+
role: 'user',
40+
content: content[0].text,
41+
...getOpenAIMetadata(content[0]),
42+
});
43+
break;
44+
}
45+
46+
messages.push({
47+
role: 'user',
48+
content: content.map(part => {
49+
const partMetadata = getOpenAIMetadata(part);
50+
switch (part.type) {
51+
case 'text': {
52+
return { type: 'text', text: part.text, ...partMetadata };
53+
}
54+
case 'file': {
55+
if (part.mediaType.startsWith('image/')) {
56+
const mediaType =
57+
part.mediaType === 'image/*'
58+
? 'image/jpeg'
59+
: part.mediaType;
60+
61+
return {
62+
type: 'image_url',
63+
image_url: {
64+
url:
65+
part.data instanceof URL
66+
? part.data.toString()
67+
: `data:${mediaType};base64,${convertToBase64(part.data)}`,
68+
},
69+
...partMetadata,
70+
};
71+
} else {
72+
throw new UnsupportedFunctionalityError({
73+
functionality: `file part media type ${part.mediaType}`,
74+
});
75+
}
76+
}
77+
}
78+
}),
79+
...metadata,
80+
});
81+
82+
break;
83+
}
84+
85+
case 'assistant': {
86+
let text = '';
87+
let reasoningText: string | undefined;
88+
let reasoningOpaque: string | undefined;
89+
const toolCalls: Array<{
90+
id: string;
91+
type: 'function';
92+
function: { name: string; arguments: string };
93+
}> = [];
94+
95+
for (const part of content) {
96+
const partMetadata = getOpenAIMetadata(part);
97+
// Check for reasoningOpaque on any part (may be attached to text/tool-call)
98+
const partOpaque = (
99+
part.providerOptions as { copilot?: { reasoningOpaque?: string } }
100+
)?.copilot?.reasoningOpaque;
101+
if (partOpaque && !reasoningOpaque) {
102+
reasoningOpaque = partOpaque;
103+
}
104+
105+
switch (part.type) {
106+
case 'text': {
107+
text += part.text;
108+
break;
109+
}
110+
case 'reasoning': {
111+
reasoningText = part.text;
112+
break;
113+
}
114+
case 'tool-call': {
115+
toolCalls.push({
116+
id: part.toolCallId,
117+
type: 'function',
118+
function: {
119+
name: part.toolName,
120+
arguments: JSON.stringify(part.input),
121+
},
122+
...partMetadata,
123+
});
124+
break;
125+
}
126+
}
127+
}
128+
129+
messages.push({
130+
role: 'assistant',
131+
content: text || null,
132+
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
133+
reasoning_text: reasoningText,
134+
reasoning_opaque: reasoningOpaque,
135+
...metadata,
136+
});
137+
138+
break;
139+
}
140+
141+
case 'tool': {
142+
for (const toolResponse of content) {
143+
const output = toolResponse.output;
144+
145+
let contentValue: string;
146+
switch (output.type) {
147+
case 'text':
148+
case 'error-text':
149+
contentValue = output.value;
150+
break;
151+
case 'content':
152+
case 'json':
153+
case 'error-json':
154+
contentValue = JSON.stringify(output.value);
155+
break;
156+
}
157+
158+
const toolResponseMetadata = getOpenAIMetadata(toolResponse);
159+
messages.push({
160+
role: 'tool',
161+
tool_call_id: toolResponse.toolCallId,
162+
content: contentValue,
163+
...toolResponseMetadata,
164+
});
165+
}
166+
break;
167+
}
168+
169+
default: {
170+
const _exhaustiveCheck: never = role;
171+
throw new Error(`Unsupported role: ${_exhaustiveCheck}`);
172+
}
173+
}
174+
}
175+
176+
return messages;
177+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export function getResponseMetadata({
2+
id,
3+
model,
4+
created,
5+
}: {
6+
id?: string | undefined | null;
7+
created?: number | undefined | null;
8+
model?: string | undefined | null;
9+
}) {
10+
return {
11+
id: id ?? undefined,
12+
modelId: model ?? undefined,
13+
timestamp: created != null ? new Date(created * 1000) : undefined,
14+
};
15+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import type { LanguageModelV2FinishReason } from '@ai-sdk/provider';
2+
3+
export function mapOpenAICompatibleFinishReason(
4+
finishReason: string | null | undefined,
5+
): LanguageModelV2FinishReason {
6+
switch (finishReason) {
7+
case 'stop':
8+
return 'stop';
9+
case 'length':
10+
return 'length';
11+
case 'content_filter':
12+
return 'content-filter';
13+
case 'function_call':
14+
case 'tool_calls':
15+
return 'tool-calls';
16+
default:
17+
return 'unknown';
18+
}
19+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import type { JSONValue } from '@ai-sdk/provider';
2+
3+
export type OpenAICompatibleChatPrompt = Array<OpenAICompatibleMessage>;
4+
5+
export type OpenAICompatibleMessage =
6+
| OpenAICompatibleSystemMessage
7+
| OpenAICompatibleUserMessage
8+
| OpenAICompatibleAssistantMessage
9+
| OpenAICompatibleToolMessage;
10+
11+
// Allow for arbitrary additional properties for general purpose
12+
// provider-metadata-specific extensibility.
13+
type JsonRecord<T = never> = Record<
14+
string,
15+
JSONValue | JSONValue[] | T | T[] | undefined
16+
>;
17+
18+
export interface OpenAICompatibleSystemMessage
19+
extends JsonRecord<OpenAICompatibleSystemContentPart> {
20+
role: 'system';
21+
content: string | Array<OpenAICompatibleSystemContentPart>;
22+
}
23+
24+
export interface OpenAICompatibleSystemContentPart
25+
extends JsonRecord {
26+
type: 'text';
27+
text: string;
28+
}
29+
30+
export interface OpenAICompatibleUserMessage
31+
extends JsonRecord<OpenAICompatibleContentPart> {
32+
role: 'user';
33+
content: string | Array<OpenAICompatibleContentPart>;
34+
}
35+
36+
export type OpenAICompatibleContentPart =
37+
| OpenAICompatibleContentPartText
38+
| OpenAICompatibleContentPartImage;
39+
40+
export interface OpenAICompatibleContentPartImage extends JsonRecord {
41+
type: 'image_url';
42+
image_url: { url: string };
43+
}
44+
45+
export interface OpenAICompatibleContentPartText extends JsonRecord {
46+
type: 'text';
47+
text: string;
48+
}
49+
50+
export interface OpenAICompatibleAssistantMessage
51+
extends JsonRecord<OpenAICompatibleMessageToolCall> {
52+
role: 'assistant';
53+
content?: string | null;
54+
tool_calls?: Array<OpenAICompatibleMessageToolCall>;
55+
// Copilot-specific reasoning fields
56+
reasoning_text?: string;
57+
reasoning_opaque?: string;
58+
}
59+
60+
export interface OpenAICompatibleMessageToolCall extends JsonRecord {
61+
type: 'function';
62+
id: string;
63+
function: {
64+
arguments: string;
65+
name: string;
66+
};
67+
}
68+
69+
export interface OpenAICompatibleToolMessage
70+
extends JsonRecord {
71+
role: 'tool';
72+
content: string;
73+
tool_call_id: string;
74+
}

0 commit comments

Comments
 (0)