Skip to content

Commit 7626068

Browse files
committed
feat(core): support reasoning_effort for OpenAI-compatible models
1 parent 0df2e54 commit 7626068

1 file changed

Lines changed: 305 additions & 0 deletions

File tree

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import type {
8+
GenerateContentResponse,
9+
GenerateContentParameters,
10+
FunctionCall,
11+
Content,
12+
Part,
13+
Tool,
14+
} from '@google/genai';
15+
import type { LlmProvider } from './types.js';
16+
import type { ProviderConfig } from '../services/providerRegistry.js';
17+
import type { LlmRole } from '../telemetry/llmRole.js';
18+
import { debugLogger } from '../utils/debugLogger.js';
19+
import { ProviderFactory } from './providerFactory.js';
20+
21+
/**
22+
* Adapter for OpenAI-compatible APIs (DeepSeek, Groq, Ollama, etc.).
23+
*/
24+
export class OpenAiCompatibleProvider implements LlmProvider {
25+
constructor(private readonly config: ProviderConfig) {}
26+
27+
async generateContent(
28+
request: GenerateContentParameters,
29+
userPromptId: string,
30+
role: LlmRole,
31+
): Promise<GenerateContentResponse> {
32+
const baseUrl = this.config.baseUrl || 'https://api.openai.com/v1';
33+
const apiKey = this.config.apiKey;
34+
const rawModel = request.model || this.config.defaultModel;
35+
36+
if (!rawModel) {
37+
throw new Error('Model not specified for OpenAI-compatible provider');
38+
}
39+
40+
const model = ProviderFactory.stripPrefix(rawModel);
41+
42+
const contents = request.contents as Content[];
43+
44+
// Map Gemini request to OpenAI request
45+
const messages = contents.map((content) => ({
46+
role: content.role === 'user' ? 'user' : 'assistant',
47+
content: content.parts?.map((p: Part) => p.text).join('') ?? '',
48+
}));
49+
50+
// Map tools
51+
const tools = (request.config?.tools as Tool[] | undefined)?.flatMap((t) =>
52+
t.functionDeclarations?.map((f) => ({
53+
type: 'function',
54+
function: {
55+
name: f.name,
56+
description: f.description,
57+
parameters: f.parameters,
58+
},
59+
}))
60+
);
61+
62+
const body: any = {
63+
model,
64+
messages,
65+
stream: false,
66+
...request.config,
67+
};
68+
69+
// Remove tools if empty or not supported in a way that OpenAI expects
70+
if (tools && tools.length > 0) {
71+
body.tools = tools;
72+
} else {
73+
delete body.tools;
74+
}
75+
76+
// Clean up Gemini-specific config fields that shouldn't go to OpenAI
77+
delete body.systemInstruction;
78+
delete body.abortSignal;
79+
80+
debugLogger.log(`Calling OpenAI-compatible API at ${baseUrl}/chat/completions`);
81+
82+
const response = await fetch(`${baseUrl}/chat/completions`, {
83+
method: 'POST',
84+
headers: {
85+
'Content-Type': 'application/json',
86+
...(apiKey && { Authorization: `Bearer ${apiKey}` }),
87+
...this.config.customHeaders,
88+
},
89+
body: JSON.stringify(body),
90+
});
91+
92+
if (!response.ok) {
93+
const errorText = await response.text();
94+
throw new Error(`OpenAI API error (${response.status}): ${errorText}`);
95+
}
96+
97+
const result = await response.json();
98+
const choice = result.choices[0];
99+
const message = choice.message;
100+
101+
const parts: Part[] = [];
102+
if (message.content) {
103+
parts.push({ text: message.content });
104+
}
105+
106+
if (message.tool_calls) {
107+
for (const call of message.tool_calls) {
108+
if (call.type === 'function') {
109+
parts.push({
110+
functionCall: {
111+
name: call.function.name,
112+
args: JSON.parse(call.function.arguments),
113+
} as FunctionCall,
114+
});
115+
}
116+
}
117+
}
118+
119+
// Map OpenAI response back to Gemini response
120+
return {
121+
candidates: [
122+
{
123+
content: {
124+
role: 'model',
125+
parts,
126+
},
127+
finishReason: choice.finish_reason === 'tool_calls' ? 'STOP' : 'STOP',
128+
},
129+
],
130+
usageMetadata: {
131+
promptTokenCount: result.usage?.prompt_tokens,
132+
candidatesTokenCount: result.usage?.completion_tokens,
133+
totalTokenCount: result.usage?.total_tokens,
134+
},
135+
} as GenerateContentResponse;
136+
}
137+
138+
async generateContentStream(
139+
request: GenerateContentParameters,
140+
_userPromptId: string,
141+
_role: LlmRole,
142+
): Promise<AsyncGenerator<GenerateContentResponse>> {
143+
const baseUrl = this.config.baseUrl || 'https://api.openai.com/v1';
144+
const apiKey = this.config.apiKey;
145+
const rawModel = request.model || this.config.defaultModel;
146+
147+
if (!rawModel) {
148+
throw new Error('Model not specified for OpenAI-compatible provider');
149+
}
150+
151+
const model = ProviderFactory.stripPrefix(rawModel);
152+
const contents = request.contents as Content[];
153+
154+
// Map Gemini request to OpenAI request
155+
const messages = contents.map((content) => ({
156+
role: content.role === 'user' ? 'user' : 'assistant',
157+
content: content.parts?.map((p: Part) => p.text).join('') ?? '',
158+
}));
159+
160+
// Map tools
161+
const tools = (request.config?.tools as Tool[] | undefined)?.flatMap((t) =>
162+
t.functionDeclarations?.map((f) => ({
163+
type: 'function',
164+
function: {
165+
name: f.name,
166+
description: f.description,
167+
parameters: f.parameters,
168+
},
169+
})),
170+
);
171+
172+
const body: any = {
173+
model,
174+
messages,
175+
stream: true,
176+
...request.config,
177+
};
178+
179+
// Remove tools if empty or not supported in a way that OpenAI expects
180+
if (tools && tools.length > 0) {
181+
body.tools = tools;
182+
} else {
183+
delete body.tools;
184+
}
185+
186+
// Clean up Gemini-specific config fields that shouldn't go to OpenAI
187+
delete body.systemInstruction;
188+
delete body.abortSignal;
189+
190+
debugLogger.log(
191+
`Calling OpenAI-compatible API (stream) at ${baseUrl}/chat/completions`,
192+
);
193+
194+
const response = await fetch(`${baseUrl}/chat/completions`, {
195+
method: 'POST',
196+
headers: {
197+
'Content-Type': 'application/json',
198+
...(apiKey && { Authorization: `Bearer ${apiKey}` }),
199+
...this.config.customHeaders,
200+
},
201+
body: JSON.stringify(body),
202+
});
203+
204+
if (!response.ok) {
205+
const errorText = await response.text();
206+
throw new Error(`OpenAI API error (${response.status}): ${errorText}`);
207+
}
208+
209+
if (!response.body) {
210+
throw new Error('No response body for OpenAI-compatible stream');
211+
}
212+
213+
const reader = response.body.getReader();
214+
const decoder = new TextDecoder();
215+
216+
async function* streamGenerator(): AsyncGenerator<GenerateContentResponse> {
217+
let buffer = '';
218+
let isDone = false;
219+
try {
220+
while (!isDone) {
221+
const { done, value } = await reader.read();
222+
if (done) break;
223+
224+
buffer += decoder.decode(value, { stream: true });
225+
const lines = buffer.split('\n');
226+
buffer = lines.pop() || '';
227+
228+
for (const line of lines) {
229+
const trimmedLine = line.trim();
230+
if (!trimmedLine || !trimmedLine.startsWith('data: ')) continue;
231+
232+
const data = trimmedLine.substring(6);
233+
if (data === '[DONE]') {
234+
isDone = true;
235+
break;
236+
}
237+
238+
try {
239+
const result = JSON.parse(data);
240+
const choice = result.choices[0];
241+
if (!choice) continue;
242+
243+
const delta = choice.delta;
244+
const finishReason = choice.finish_reason;
245+
246+
const parts: Part[] = [];
247+
if (delta?.content) {
248+
parts.push({ text: delta.content });
249+
}
250+
251+
if (delta?.tool_calls) {
252+
for (const call of delta.tool_calls) {
253+
if (call.type === 'function') {
254+
parts.push({
255+
functionCall: {
256+
name: call.function.name,
257+
args: call.function.arguments
258+
? JSON.parse(call.function.arguments)
259+
: {},
260+
} as FunctionCall,
261+
});
262+
}
263+
}
264+
}
265+
266+
let mappedFinishReason: any;
267+
if (finishReason === 'stop') mappedFinishReason = 'STOP';
268+
else if (finishReason === 'length') mappedFinishReason = 'MAX_TOKENS';
269+
else if (finishReason === 'content_filter')
270+
mappedFinishReason = 'SAFETY';
271+
else if (finishReason === 'tool_calls') mappedFinishReason = 'STOP';
272+
273+
if (parts.length > 0 || mappedFinishReason || result.usage) {
274+
yield {
275+
candidates: [
276+
{
277+
content: {
278+
role: 'model',
279+
parts: parts.length > 0 ? parts : undefined,
280+
},
281+
finishReason: mappedFinishReason,
282+
},
283+
],
284+
usageMetadata: result.usage
285+
? {
286+
promptTokenCount: result.usage.prompt_tokens,
287+
candidatesTokenCount: result.usage.completion_tokens,
288+
totalTokenCount: result.usage.total_tokens,
289+
}
290+
: undefined,
291+
} as GenerateContentResponse;
292+
}
293+
} catch (e) {
294+
debugLogger.error(`Error parsing OpenAI stream chunk: ${e}`);
295+
}
296+
}
297+
}
298+
} finally {
299+
reader.releaseLock();
300+
}
301+
}
302+
303+
return streamGenerator();
304+
}
305+
}

0 commit comments

Comments
 (0)