feat(protocol): 支持 Claude 与 Gemini 协议间的双向转换

实现了 Claude API 请求到 Gemini API 请求的转换,以及 Gemini API 响应到 Claude API 响应的转换。
同时支持了两种协议间流式响应的转换,增强了项目的兼容性和灵活性。

更新了 README 文件,新增模型协议关系图和 Star History。
优化了 Gemini 核心服务中的模型选择逻辑,当请求的模型不存在时,会使用默认模型。
This commit is contained in:
hex2077 2025-07-31 22:54:21 +08:00
parent 4dbba6a5ab
commit b031ca4286
5 changed files with 610 additions and 17 deletions

View file

@ -78,6 +78,55 @@ The project adopts multiple modern design patterns to ensure code maintainabilit
6. **Response Conversion**: Converts service response back to client expected format 6. **Response Conversion**: Converts service response back to client expected format
7. **Streaming Processing**: Supports real-time streaming response transmission 7. **Streaming Processing**: Supports real-time streaming response transmission
### 🎨 Model Protocol and Provider Relationship Diagram
- OpenAI Protocol (P_OPENAI): Supports all MODEL_PROVIDER, including openai-custom, gemini-cli-oauth, claude-custom and
claude-kiro-oauth.
- Claude Protocol (P_CLAUDE): Supports claude-custom, claude-kiro-oauth and gemini-cli-oauth.
- Gemini Protocol (P_GEMINI): Supports gemini-cli-oauth.
```mermaid
graph TD
subgraph Core_Protocols
P_OPENAI(OpenAI Protocol)
P_GEMINI(Gemini Protocol)
P_CLAUDE(Claude Protocol)
end
subgraph Supported_Model_Providers
MP_OPENAI[openai-custom]
MP_GEMINI[gemini-cli-oauth]
MP_CLAUDE_C[claude-custom]
MP_CLAUDE_K[claude-kiro-oauth]
end
subgraph Internal_Conversion_Logic
direction LR
P_OPENAI <-->|Request/Response Conversion| P_GEMINI
P_OPENAI <-->|Request/Response Conversion| P_CLAUDE
P_GEMINI <-->|Request/Response Conversion| P_CLAUDE
end
P_OPENAI ---|Supports| MP_OPENAI
P_OPENAI ---|Supports| MP_GEMINI
P_OPENAI ---|Supports| MP_CLAUDE_C
P_OPENAI ---|Supports| MP_CLAUDE_K
P_GEMINI ---|Supports| MP_GEMINI
P_CLAUDE ---|Supports| MP_CLAUDE_C
P_CLAUDE ---|Supports| MP_CLAUDE_K
P_CLAUDE ---|Supports| MP_GEMINI
style P_OPENAI fill:#f9f,stroke:#333,stroke-width:2px
style P_GEMINI fill:#ccf,stroke:#333,stroke-width:2px
style P_CLAUDE fill:#cfc,stroke:#333,stroke-width:2px
```
---
### 🔧 Usage Instructions ### 🔧 Usage Instructions
* **MCP Support**: While the built-in command functions of the original Gemini CLI are not available, this project perfectly supports MCP (Model Context Protocol) and can work with MCP-compatible clients for more powerful functionality extensions. * **MCP Support**: While the built-in command functions of the original Gemini CLI are not available, this project perfectly supports MCP (Model Context Protocol) and can work with MCP-compatible clients for more powerful functionality extensions.
@ -310,3 +359,7 @@ This project is licensed under the [**GNU General Public License v3 (GPLv3)**](h
## 🙏 Acknowledgements ## 🙏 Acknowledgements
The development of this project was greatly inspired by the official Google Gemini CLI, and referenced some code implementations from Cline 3.18.0's `gemini-cli.ts`. I would like to express my sincere gratitude to the official Google team and the Cline development team for their excellent work! The development of this project was greatly inspired by the official Google Gemini CLI, and referenced some code implementations from Cline 3.18.0's `gemini-cli.ts`. I would like to express my sincere gratitude to the official Google team and the Cline development team for their excellent work!
## 🌟 Star History
[![Star History Chart](https://api.star-history.com/svg?repos=justlovemaki/AIClient-2-API&type=Timeline)](https://www.star-history.com/#justlovemaki/AIClient-2-API&Timeline)

View file

@ -78,6 +78,53 @@
6. **响应转换**: 将服务响应转换回客户端期望格式 6. **响应转换**: 将服务响应转换回客户端期望格式
7. **流式处理**: 支持实时流式响应传输 7. **流式处理**: 支持实时流式响应传输
### 🎨 模型协议与提供商关系图
- OpenAI 协议 (P_OPENAI): 支持所有 MODEL_PROVIDER包括 openai-custom、gemini-cli-oauth、claude-custom 和
claude-kiro-oauth。
- Claude 协议 (P_CLAUDE): 支持 claude-custom、claude-kiro-oauth 和 gemini-cli-oauth。
- Gemini 协议 (P_GEMINI): 支持 gemini-cli-oauth。
```mermaid
graph TD
subgraph Core_Protocols
P_OPENAI(OpenAI Protocol)
P_GEMINI(Gemini Protocol)
P_CLAUDE(Claude Protocol)
end
subgraph Supported_Model_Providers
MP_OPENAI[openai-custom]
MP_GEMINI[gemini-cli-oauth]
MP_CLAUDE_C[claude-custom]
MP_CLAUDE_K[claude-kiro-oauth]
end
subgraph Internal_Conversion_Logic
direction LR
P_OPENAI <-->|Request/Response Conversion| P_GEMINI
P_OPENAI <-->|Request/Response Conversion| P_CLAUDE
P_GEMINI <-->|Request/Response Conversion| P_CLAUDE
end
P_OPENAI ---|Supports| MP_OPENAI
P_OPENAI ---|Supports| MP_GEMINI
P_OPENAI ---|Supports| MP_CLAUDE_C
P_OPENAI ---|Supports| MP_CLAUDE_K
P_GEMINI ---|Supports| MP_GEMINI
P_CLAUDE ---|Supports| MP_CLAUDE_C
P_CLAUDE ---|Supports| MP_CLAUDE_K
P_CLAUDE ---|Supports| MP_GEMINI
style P_OPENAI fill:#f9f,stroke:#333,stroke-width:2px
style P_GEMINI fill:#ccf,stroke:#333,stroke-width:2px
style P_CLAUDE fill:#cfc,stroke:#333,stroke-width:2px
```
--- ---
### 🔧 使用说明 ### 🔧 使用说明
@ -318,3 +365,7 @@
## 🙏 致谢 ## 🙏 致谢
本项目的开发受到了官方 Google Gemini CLI 的极大启发并参考了Cline 3.18.0 版本 `gemini-cli.ts` 的部分代码实现。在此对 Google 官方团队和 Cline 开发团队的卓越工作表示衷心的感谢! 本项目的开发受到了官方 Google Gemini CLI 的极大启发并参考了Cline 3.18.0 版本 `gemini-cli.ts` 的部分代码实现。在此对 Google 官方团队和 Cline 开发团队的卓越工作表示衷心的感谢!
## 🌟 Star History
[![Star History Chart](https://api.star-history.com/svg?repos=justlovemaki/AIClient-2-API&type=Timeline)](https://www.star-history.com/#justlovemaki/AIClient-2-API&Timeline)

View file

@ -206,6 +206,8 @@ export async function handleStreamRequest(res, service, model, requestBody, from
// The service returns a stream in its native format (toProvider). // The service returns a stream in its native format (toProvider).
const nativeStream = await service.generateContentStream(model, requestBody); const nativeStream = await service.generateContentStream(model, requestBody);
const needsConversion = getProtocolPrefix(fromProvider) !== getProtocolPrefix(toProvider);
const isClaude = getProtocolPrefix(fromProvider) === MODEL_PROTOCOL_PREFIX.CLAUDE;
try { try {
for await (const nativeChunk of nativeStream) { for await (const nativeChunk of nativeStream) {
@ -215,20 +217,21 @@ export async function handleStreamRequest(res, service, model, requestBody, from
fullResponseText += chunkText; fullResponseText += chunkText;
} }
let clientChunk = chunkText; const chunkToSend = needsConversion
if (getProtocolPrefix(fromProvider) !== getProtocolPrefix(toProvider)) { ? convertData(chunkText, 'streamChunk', toProvider, fromProvider, model)
clientChunk = convertData(chunkText, 'streamChunk', toProvider, fromProvider, model); : nativeChunk;
if(!clientChunk){
continue; if (!chunkToSend) {
} continue;
res.write(`data: ${JSON.stringify(clientChunk)}\n\n`);
// console.log(`data: ${JSON.stringify(clientChunk)}\n`);
}else{
res.write(`data: ${JSON.stringify(nativeChunk)}\n\n`);
// console.log(`data-nv: ${JSON.stringify(nativeChunk)}\n`);
} }
if (isClaude) {
res.write(`event: ${chunkToSend.type}\n`);
// console.log(`event: ${chunkToSend.type}\n`);
}
res.write(`data: ${JSON.stringify(chunkToSend)}\n\n`);
// console.log(`data: ${JSON.stringify(chunkToSend)}\n`);
} }
} catch (error) { } catch (error) {

View file

@ -24,6 +24,7 @@ export function convertData(data, type, fromProvider, toProvider, model) {
}, },
[MODEL_PROTOCOL_PREFIX.GEMINI]: { // to Gemini protocol [MODEL_PROTOCOL_PREFIX.GEMINI]: { // to Gemini protocol
[MODEL_PROTOCOL_PREFIX.OPENAI]: toGeminiRequestFromOpenAI, // from OpenAI protocol [MODEL_PROTOCOL_PREFIX.OPENAI]: toGeminiRequestFromOpenAI, // from OpenAI protocol
[MODEL_PROTOCOL_PREFIX.CLAUDE]: toGeminiRequestFromClaude, // from Claude protocol
}, },
}, },
response: { response: {
@ -31,12 +32,18 @@ export function convertData(data, type, fromProvider, toProvider, model) {
[MODEL_PROTOCOL_PREFIX.GEMINI]: toOpenAIChatCompletionFromGemini, // from Gemini protocol [MODEL_PROTOCOL_PREFIX.GEMINI]: toOpenAIChatCompletionFromGemini, // from Gemini protocol
[MODEL_PROTOCOL_PREFIX.CLAUDE]: toOpenAIChatCompletionFromClaude, // from Claude protocol [MODEL_PROTOCOL_PREFIX.CLAUDE]: toOpenAIChatCompletionFromClaude, // from Claude protocol
}, },
[MODEL_PROTOCOL_PREFIX.CLAUDE]: { // to Claude protocol
[MODEL_PROTOCOL_PREFIX.GEMINI]: toClaudeChatCompletionFromGemini, // from Gemini protocol
},
}, },
streamChunk: { streamChunk: {
[MODEL_PROTOCOL_PREFIX.OPENAI]: { // to OpenAI protocol [MODEL_PROTOCOL_PREFIX.OPENAI]: { // to OpenAI protocol
[MODEL_PROTOCOL_PREFIX.GEMINI]: toOpenAIStreamChunkFromGemini, // from Gemini protocol [MODEL_PROTOCOL_PREFIX.GEMINI]: toOpenAIStreamChunkFromGemini, // from Gemini protocol
[MODEL_PROTOCOL_PREFIX.CLAUDE]: toOpenAIStreamChunkFromClaude, // from Claude protocol [MODEL_PROTOCOL_PREFIX.CLAUDE]: toOpenAIStreamChunkFromClaude, // from Claude protocol
}, },
[MODEL_PROTOCOL_PREFIX.CLAUDE]: { // to Claude protocol
[MODEL_PROTOCOL_PREFIX.GEMINI]: toClaudeStreamChunkFromGemini, // from Gemini protocol
},
}, },
modelList: { modelList: {
[MODEL_PROTOCOL_PREFIX.OPENAI]: { // to OpenAI protocol [MODEL_PROTOCOL_PREFIX.OPENAI]: { // to OpenAI protocol
@ -958,3 +965,471 @@ function isValidAudioType(mimeType) {
]; ];
return validTypes.includes(mimeType.toLowerCase()); return validTypes.includes(mimeType.toLowerCase());
} }
/**
* Converts a Claude API request body to a Gemini API request body.
* Handles system instructions and multimodal content.
* @param {Object} claudeRequest - The request body from the Claude API.
* @returns {Object} The formatted request body for the Gemini API.
*/
/**
* Converts a Claude API request body to a Gemini API request body.
* Handles system instructions and multimodal content.
* @param {Object} claudeRequest - The request body from the Claude API.
* @returns {Object} The formatted request body for the Gemini API.
*/
export function toGeminiRequestFromClaude(claudeRequest) {
// Ensure claudeRequest is a valid object
if (!claudeRequest || typeof claudeRequest !== 'object') {
console.warn("Invalid claudeRequest provided to toGeminiRequestFromClaude.");
return { contents: [] };
}
const geminiRequest = {
contents: []
};
// Handle system instruction
if (claudeRequest.system) {
let incomingSystemText = null;
if (typeof claudeRequest.system === 'string') {
incomingSystemText = claudeRequest.system;
} else if (typeof claudeRequest.system === 'object') {
incomingSystemText = JSON.stringify(claudeRequest.system);
} else if (claudeRequest.messages?.length > 0) {
// Fallback to first user message if no system property
const userMessage = claudeRequest.messages.find(m => m.role === 'user');
if (userMessage) {
if (Array.isArray(userMessage.content)) {
incomingSystemText = userMessage.content.map(block => block.text).join('');
} else {
incomingSystemText = userMessage.content;
}
}
}
geminiRequest.systemInstruction = {
parts: [{ text: incomingSystemText}] // Ensure system is string
};
}
// Process messages
if (Array.isArray(claudeRequest.messages)) {
claudeRequest.messages.forEach(message => {
// Ensure message is a valid object and has a role and content
if (!message || typeof message !== 'object' || !message.role || !message.content) {
console.warn("Skipping invalid message in claudeRequest.messages.");
return;
}
const geminiRole = message.role === 'assistant' ? 'model' : 'user';
const processedParts = processClaudeContentToGeminiParts(message.content);
// If the processed parts contain a function response, it should be a 'function' role message
// Claude's tool_result block does not contain the function name, only tool_use_id.
// We need to infer the function name from the previous tool_use message.
// For simplicity in this conversion, we'll assume the tool_use_id is the function name
// or that the tool_result is always preceded by a tool_use with the correct name.
// A more robust solution would involve tracking tool_use_ids to function names.
const functionResponsePart = processedParts.find(part => part.functionResponse);
if (functionResponsePart) {
geminiRequest.contents.push({
role: 'function',
parts: [functionResponsePart]
});
} else if (processedParts.length > 0) { // Only push if there are actual parts
geminiRequest.contents.push({
role: geminiRole,
parts: processedParts
});
}
});
}
// Add generation config
const generationConfig = {};
if (claudeRequest.max_tokens !== undefined) {
generationConfig.maxOutputTokens = Number(claudeRequest.max_tokens); // Ensure number type
}
if (claudeRequest.temperature !== undefined) {
generationConfig.temperature = Number(claudeRequest.temperature); // Ensure number type
}
if (claudeRequest.top_p !== undefined) {
generationConfig.topP = Number(claudeRequest.top_p); // Ensure number type
}
if (Object.keys(generationConfig).length > 0) {
geminiRequest.generationConfig = generationConfig;
}
// Handle tools
if (Array.isArray(claudeRequest.tools)) {
geminiRequest.tools = [{
functionDeclarations: claudeRequest.tools.map(tool => {
// Ensure tool is a valid object and has a name
if (!tool || typeof tool !== 'object' || !tool.name) {
console.warn("Skipping invalid tool declaration in claudeRequest.tools.");
return null; // Return null for invalid tools, filter out later
}
delete tool.input_schema.$schema;
return {
name: String(tool.name), // Ensure name is string
description: String(tool.description || ''), // Ensure description is string
parameters: tool.input_schema && typeof tool.input_schema === 'object' ? tool.input_schema : { type: 'object', properties: {} }
};
}).filter(Boolean) // Filter out any nulls from invalid tool declarations
}];
// If no valid functionDeclarations, remove the tools array
if (geminiRequest.tools[0].functionDeclarations.length === 0) {
delete geminiRequest.tools;
}
}
// Handle tool_choice
if (claudeRequest.tool_choice) {
geminiRequest.toolConfig = buildGeminiToolConfigFromClaude(claudeRequest.tool_choice);
}
return geminiRequest;
}
/**
* Builds Gemini toolConfig from Claude tool_choice.
* @param {Object} claudeToolChoice - The tool_choice object from Claude API.
* @returns {Object|undefined} The formatted toolConfig for Gemini API, or undefined if invalid.
*/
function buildGeminiToolConfigFromClaude(claudeToolChoice) {
if (!claudeToolChoice || typeof claudeToolChoice !== 'object' || !claudeToolChoice.type) {
console.warn("Invalid claudeToolChoice provided to buildGeminiToolConfigFromClaude.");
return undefined;
}
switch (claudeToolChoice.type) {
case 'auto':
return { functionCallingConfig: { mode: 'AUTO' } };
case 'none':
return { functionCallingConfig: { mode: 'NONE' } };
case 'tool':
if (claudeToolChoice.name && typeof claudeToolChoice.name === 'string') {
return { functionCallingConfig: { mode: 'ANY', allowedFunctionNames: [claudeToolChoice.name] } };
}
console.warn("Invalid tool name in claudeToolChoice of type 'tool'.");
return undefined;
default:
console.warn(`Unsupported claudeToolChoice type: ${claudeToolChoice.type}`);
return undefined;
}
}
/**
* Processes Claude content to Gemini parts format with multimodal support.
* @param {string|Array} content - Claude message content.
* @returns {Array} Array of Gemini parts.
*/
function processClaudeContentToGeminiParts(content) {
if (!content) return [];
// Handle string content
if (typeof content === 'string') {
return [{ text: content }];
}
// Handle array content (multimodal)
if (Array.isArray(content)) {
const parts = [];
content.forEach(block => {
// Ensure block is a valid object and has a type
if (!block || typeof block !== 'object' || !block.type) {
console.warn("Skipping invalid content block in processClaudeContentToGeminiParts.");
return;
}
switch (block.type) {
case 'text':
if (typeof block.text === 'string') {
parts.push({ text: block.text });
} else {
console.warn("Invalid text content in Claude text block.");
}
break;
case 'image':
if (block.source && typeof block.source === 'object' && block.source.type === 'base64' &&
typeof block.source.media_type === 'string' && typeof block.source.data === 'string') {
parts.push({
inlineData: {
mimeType: block.source.media_type,
data: block.source.data
}
});
} else {
console.warn("Invalid image source in Claude image block.");
}
break;
case 'tool_use':
if (typeof block.name === 'string' && block.input && typeof block.input === 'object') {
parts.push({
functionCall: {
name: block.name,
args: block.input
}
});
} else {
console.warn("Invalid tool_use block in Claude content.");
}
break;
case 'tool_result':
// Claude's tool_result block does not contain the function name, only tool_use_id.
// Gemini's functionResponse requires a function name.
// For now, we'll use the tool_use_id as the name, but this is a potential point of failure
// if the tool_use_id is not the actual function name in Gemini's context.
// A more robust solution would involve tracking the function name from the tool_use block.
if (typeof block.tool_use_id === 'string') {
parts.push({
functionResponse: {
name: block.tool_use_id, // This might need to be the actual function name
response: { content: block.content } // content can be any JSON-serializable value
}
});
} else {
console.warn("Invalid tool_result block in Claude content: missing tool_use_id.");
}
break;
default:
// Handle any other content types as text if they have a text property
if (typeof block.text === 'string') {
parts.push({ text: block.text });
} else {
console.warn(`Unsupported Claude content block type: ${block.type}. Skipping.`);
}
}
});
return parts;
}
return [];
}
/**
* Converts a Gemini API response to a Claude API messages response.
* @param {Object} geminiResponse - The Gemini API response object.
* @param {string} model - The model name to include in the response.
* @returns {Object} The formatted Claude API messages response.
*/
export function toClaudeChatCompletionFromGemini(geminiResponse, model) {
// Handle cases where geminiResponse or candidates are missing or empty
if (!geminiResponse || !geminiResponse.candidates || geminiResponse.candidates.length === 0) {
return {
id: `msg_${uuidv4()}`,
type: "message",
role: "assistant",
content: [], // Empty content for no candidates
model: model,
stop_reason: "end_turn", // Default stop reason
stop_sequence: null,
usage: {
input_tokens: geminiResponse?.usageMetadata?.promptTokenCount || 0,
output_tokens: geminiResponse?.usageMetadata?.candidatesTokenCount || 0
}
};
}
const candidate = geminiResponse.candidates[0];
const content = processGeminiResponseToClaudeContent(geminiResponse);
const finishReason = candidate.finishReason;
let stopReason = "end_turn"; // Default stop reason
if (finishReason) {
switch (finishReason) {
case 'STOP':
stopReason = 'end_turn';
break;
case 'MAX_TOKENS':
stopReason = 'max_tokens';
break;
case 'SAFETY':
stopReason = 'safety';
break;
case 'RECITATION':
stopReason = 'recitation';
break;
case 'OTHER':
stopReason = 'other';
break;
default:
stopReason = 'end_turn';
}
}
return {
id: `msg_${uuidv4()}`,
type: "message",
role: "assistant",
content: content,
model: model,
stop_reason: stopReason,
stop_sequence: null,
usage: {
input_tokens: geminiResponse.usageMetadata?.promptTokenCount || 0,
output_tokens: geminiResponse.usageMetadata?.candidatesTokenCount || 0
}
};
}
/**
* Processes Gemini response content to Claude format.
* @param {Object} geminiResponse - The Gemini API response.
* @returns {Array} Array of Claude content blocks.
*/
function processGeminiResponseToClaudeContent(geminiResponse) {
if (!geminiResponse || !geminiResponse.candidates || geminiResponse.candidates.length === 0) return [];
const content = [];
geminiResponse.candidates.forEach(candidate => {
if (candidate.content && candidate.content.parts) {
candidate.content.parts.forEach(part => {
if (part.text) {
content.push({
type: 'text',
text: part.text
});
} else if (part.inlineData) {
content.push({
type: 'image',
source: {
type: 'base64',
media_type: part.inlineData.mimeType,
data: part.inlineData.data
}
});
} else if (part.functionCall) {
// Convert Gemini functionCall to Claude tool_use
content.push({
type: 'tool_use',
id: uuidv4(), // Generate a new ID for the tool use
name: part.functionCall.name,
input: part.functionCall.args || {}
});
}
});
}
});
return content;
}
/**
* Converts a Gemini API stream chunk to a Claude API messages stream chunk.
* @param {Object} geminiChunk - The Gemini API stream chunk object.
* @param {string} [model] - Optional model name to include in the response.
* @returns {Object} The formatted Claude API messages stream chunk.
*/
export function toClaudeStreamChunkFromGemini(geminiChunk, model) {
if (!geminiChunk) {
return null;
}
// Handle different types of Gemini stream events
if (geminiChunk.candidates && geminiChunk.candidates.length > 0) {
const candidate = geminiChunk.candidates[0];
if (candidate.content && candidate.content.parts) {
const textParts = candidate.content.parts
.filter(part => part.text)
.map(part => part.text);
const functionCallPart = candidate.content.parts.find(part => part.functionCall);
if (functionCallPart) {
// Handle tool_use
return {
type: "content_block_start",
index: 0,
content_block: {
type: "tool_use",
id: `toolu_${uuidv4()}`, // Claude tool use ID format
name: functionCallPart.functionCall.name,
input: functionCallPart.functionCall.args || {}
}
};
} else if (textParts.length > 0) {
return {
type: "content_block_delta",
index: 0,
delta: {
type: "text_delta",
text: textParts.join('')
}
};
}
}
// Handle finish reason
if (candidate.finishReason) {
let stopReason = "end_turn";
switch (candidate.finishReason) {
case 'STOP':
stopReason = 'end_turn';
break;
case 'MAX_TOKENS':
stopReason = 'max_tokens';
break;
case 'SAFETY':
stopReason = 'safety';
break;
case 'RECITATION':
stopReason = 'recitation';
break;
case 'OTHER':
stopReason = 'other';
break;
default:
stopReason = 'end_turn';
}
return {
type: "message_delta",
delta: {
stop_reason: stopReason,
stop_sequence: null
},
usage: geminiChunk.usageMetadata ? {
output_tokens: geminiChunk.usageMetadata.candidatesTokenCount || 0
} : undefined
};
}
}
// Handle usage metadata updates (only if no other content/finish reason)
if (geminiChunk.usageMetadata && (!geminiChunk.candidates || geminiChunk.candidates.length === 0)) {
return {
type: "message_delta",
delta: {},
usage: {
input_tokens: geminiChunk.usageMetadata.promptTokenCount || 0,
output_tokens: geminiChunk.usageMetadata.candidatesTokenCount || 0
}
};
}
// Default text delta for simple text chunks (should ideally be handled by candidate.content.parts)
// This case might occur if the geminiChunk is just a string, which is not typical for Gemini API.
// Added for robustness, but main logic should rely on geminiChunk.candidates.
if (typeof geminiChunk === 'string') {
return {
type: "content_block_delta",
index: 0,
delta: {
type: "text_delta",
text: geminiChunk
}
};
}
return null;
}

View file

@ -14,6 +14,7 @@ const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com';
const CODE_ASSIST_API_VERSION = 'v1internal'; const CODE_ASSIST_API_VERSION = 'v1internal';
const OAUTH_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com'; const OAUTH_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com';
const OAUTH_CLIENT_SECRET = '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'; const OAUTH_CLIENT_SECRET = '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';
const GEMINI_MODELS = ['gemini-2.5-flash', 'gemini-2.5-pro'];
function toGeminiApiResponse(codeAssistResponse) { function toGeminiApiResponse(codeAssistResponse) {
if (!codeAssistResponse) return null; if (!codeAssistResponse) return null;
@ -45,7 +46,7 @@ export class GeminiApiService {
this.projectId = await this.discoverProjectAndModels(); this.projectId = await this.discoverProjectAndModels();
} else { } else {
console.log(`[Gemini] Using provided Project ID: ${this.projectId}`); console.log(`[Gemini] Using provided Project ID: ${this.projectId}`);
this.availableModels = ['gemini-2.5-pro', 'gemini-2.5-flash']; this.availableModels = GEMINI_MODELS;
console.log(`[Gemini] Using fixed models: [${this.availableModels.join(', ')}]`); console.log(`[Gemini] Using fixed models: [${this.availableModels.join(', ')}]`);
} }
if (this.projectId === 'default') { if (this.projectId === 'default') {
@ -155,7 +156,7 @@ export class GeminiApiService {
} }
console.log('[Gemini] Discovering Project ID...'); console.log('[Gemini] Discovering Project ID...');
this.availableModels = ['gemini-2.5-pro', 'gemini-2.5-flash']; this.availableModels = GEMINI_MODELS;
console.log(`[Gemini] Using fixed models: [${this.availableModels.join(', ')}]`); console.log(`[Gemini] Using fixed models: [${this.availableModels.join(', ')}]`);
try { try {
const loadResponse = await this.callApi('loadCodeAssist', { metadata: { pluginType: 'GEMINI' } }); const loadResponse = await this.callApi('loadCodeAssist', { metadata: { pluginType: 'GEMINI' } });
@ -298,16 +299,26 @@ export class GeminiApiService {
async generateContent(model, requestBody) { async generateContent(model, requestBody) {
console.log(`[Auth Token] Time until expiry: ${formatExpiryTime(this.authClient.credentials.expiry_date)}`); console.log(`[Auth Token] Time until expiry: ${formatExpiryTime(this.authClient.credentials.expiry_date)}`);
let selectedModel = model;
if (!GEMINI_MODELS.includes(model)) {
console.warn(`[Gemini] Model '${model}' not found. Using default model: '${GEMINI_MODELS[0]}'`);
selectedModel = GEMINI_MODELS[0];
}
const processedRequestBody = ensureRolesInContents(requestBody); const processedRequestBody = ensureRolesInContents(requestBody);
const apiRequest = { model, project: this.projectId, request: processedRequestBody }; const apiRequest = { model: selectedModel, project: this.projectId, request: processedRequestBody };
const response = await this.callApi(API_ACTIONS.GENERATE_CONTENT, apiRequest); const response = await this.callApi(API_ACTIONS.GENERATE_CONTENT, apiRequest);
return toGeminiApiResponse(response.response); return toGeminiApiResponse(response.response);
} }
async * generateContentStream(model, requestBody) { async * generateContentStream(model, requestBody) {
console.log(`[Auth Token] Time until expiry: ${formatExpiryTime(this.authClient.credentials.expiry_date)}`); console.log(`[Auth Token] Time until expiry: ${formatExpiryTime(this.authClient.credentials.expiry_date)}`);
let selectedModel = model;
if (!GEMINI_MODELS.includes(model)) {
console.warn(`[Gemini] Model '${model}' not found. Using default model: '${GEMINI_MODELS[0]}'`);
selectedModel = GEMINI_MODELS[0];
}
const processedRequestBody = ensureRolesInContents(requestBody); const processedRequestBody = ensureRolesInContents(requestBody);
const apiRequest = { model, project: this.projectId, request: processedRequestBody }; const apiRequest = { model: selectedModel, project: this.projectId, request: processedRequestBody };
const stream = this.streamApi(API_ACTIONS.STREAM_GENERATE_CONTENT, apiRequest); const stream = this.streamApi(API_ACTIONS.STREAM_GENERATE_CONTENT, apiRequest);
for await (const chunk of stream) { for await (const chunk of stream) {
yield toGeminiApiResponse(chunk.response); yield toGeminiApiResponse(chunk.response);