Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ tmp/
temp/
ui/dist/
*.css
test/
debug_*.png
debug_*.html
proxylist.txt
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ services:

- `GET /v1/models`: 列出模型。
- `POST /v1/chat/completions`: 聊天补全和图片生成,支持非流式、真流式和假流式。
- `POST /v1/audio/speech`: 使用 Gemini TTS 生成二进制语音,支持 WAV(默认)和原始 PCM 输出。
- `POST /v1/embeddings`: 生成文本嵌入向量。
- `POST /v1/responses`: OpenAI Responses API 兼容接口,用于对话生成,不支持图像生成,支持非流式、真流式和假流式。
- `POST /v1/responses/input_tokens`: 计算 OpenAI Responses API 请求的输入 token 数量。
Expand Down
1 change: 1 addition & 0 deletions README_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ This endpoint is processed and then forwarded to the Gemini API format endpoint.

- `GET /v1/models`: List models.
- `POST /v1/chat/completions`: Chat completion and image generation, supports non-streaming, real streaming, and fake streaming.
- `POST /v1/audio/speech`: Generate binary speech audio with Gemini TTS. Supports WAV (default) and raw PCM output.
- `POST /v1/embeddings`: Generate text embedding vectors.
- `POST /v1/responses`: OpenAI Responses API compatible endpoint for conversation generation, does not support image generation, and supports non-streaming, real streaming, and fake streaming.
- `POST /v1/responses/input_tokens`: Count input tokens for an OpenAI Responses API request.
Expand Down
19 changes: 19 additions & 0 deletions docs/en/api-examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,25 @@ curl -X POST http://localhost:7860/v1/responses \
}'
```

### 🎤 Speech Generation

The OpenAI-compatible speech endpoint returns binary audio directly. Gemini-native PCM is wrapped in a WAV container when `response_format` is `wav` (the default):

```bash
curl -X POST http://localhost:7860/v1/audio/speech \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-api-key-1" \
-d '{
"model": "gemini-3.1-flash-tts-preview",
"input": "Hello, this is a text to speech test.",
"voice": "Kore",
"response_format": "wav"
}' \
--output speech.wav
```

Supported response formats are `wav` and `pcm`. The `pcm` option returns Gemini's raw PCM bytes with the sample format declared in the response `Content-Type`. MP3, AAC, FLAC, and Opus are not returned because this project does not include an audio encoder; requesting them returns an OpenAI-style `400` error. Unsupported speech parameters, including `speed`, `instructions`, `stream`, and `stream_format`, also return `400` instead of being silently ignored.

## ♊ Gemini Native API Format

```bash
Expand Down
19 changes: 19 additions & 0 deletions docs/zh/api-examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,25 @@ curl -X POST http://localhost:7860/v1/responses \
}'
```

### 🎤 语音生成

OpenAI 兼容的语音端点会直接返回二进制音频。当 `response_format` 为 `wav`(默认值)时,服务会将 Gemini 原生 PCM 封装为 WAV:

```bash
curl -X POST http://localhost:7860/v1/audio/speech \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-api-key-1" \
-d '{
"model": "gemini-3.1-flash-tts-preview",
"input": "你好,这是一个语音合成测试。",
"voice": "Kore",
"response_format": "wav"
}' \
--output speech.wav
```

支持的响应格式为 `wav` 和 `pcm`。选择 `pcm` 时会返回 Gemini 的原始 PCM 字节,并在响应 `Content-Type` 中声明采样格式。本项目未包含音频编码器,因此不会返回 MP3、AAC、FLAC 或 Opus;请求这些格式时会返回 OpenAI 风格的 `400` 错误。不支持的语音参数(包括 `speed`、`instructions`、`stream` 和 `stream_format`)同样会返回 `400`,不会被静默忽略。

## ♊ Gemini 原生 API 格式

```bash
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"build:ui": "vite build",
"preview:ui": "vite preview",
"start": "cross-env NODE_ENV=production node main.js",
"test": "node --test test/*.test.js",
"quick-start": "cross-env NODE_ENV=production node main.js",
"prestart": "npm run build:ui",
"save-auth": "node scripts/auth/saveAuth.js",
Expand Down
79 changes: 79 additions & 0 deletions src/core/FormatConverter.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

const axios = require("axios");
const mime = require("mime-types");
const { convertGeminiAudioResponse } = require("../utils/AudioUtils");

/**
* Format Converter Module
Expand Down Expand Up @@ -1036,6 +1037,84 @@ class FormatConverter {
return { cleanModelName, googleRequest, path };
}

/**
* Convert an OpenAI speech request into Gemini native TTS format.
* Only WAV and raw PCM responses are supported because Gemini returns PCM and this
* project does not include a lossy audio encoder.
*
* @param {object} openaiBody - OpenAI speech request body
* @returns {{ cleanModelName: string, googleRequest: object, responseFormat: "wav"|"pcm" }}
*/
translateOpenAISpeechToGoogle(openaiBody) {
if (!openaiBody || typeof openaiBody !== "object" || Array.isArray(openaiBody)) {
throw new Error("Request body must be a JSON object.");
}

const requiredStringFields = ["model", "input", "voice"];
for (const field of requiredStringFields) {
if (typeof openaiBody[field] !== "string" || openaiBody[field].trim().length === 0) {
throw new Error(`Missing required parameter: '${field}'.`);
}
}

const supportedFields = new Set(["input", "model", "response_format", "voice"]);
const unsupportedFields = Object.keys(openaiBody).filter(field => !supportedFields.has(field));
if (unsupportedFields.length > 0) {
const fieldList = unsupportedFields.map(field => `'${field}'`).join(", ");
throw new Error(`Unsupported parameter${unsupportedFields.length === 1 ? "" : "s"}: ${fieldList}.`);
}

const responseFormat = openaiBody.response_format === undefined ? "wav" : openaiBody.response_format;
if (typeof responseFormat !== "string" || !["pcm", "wav"].includes(responseFormat.toLowerCase())) {
const requestedFormat = typeof responseFormat === "string" ? responseFormat : typeof responseFormat;
throw new Error(
`Unsupported response_format '${requestedFormat}'. Supported response formats are 'wav' and 'pcm'.`
);
}

const cleanModelName = openaiBody.model.trim().replace(/^models\//, "");
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(cleanModelName)) {
throw new Error("Invalid 'model': expected a Gemini model name without path or query parameters.");
}

const googleRequest = {
contents: [
{
parts: [{ text: openaiBody.input }],
role: "user",
},
],
generationConfig: {
responseModalities: ["AUDIO"],
speechConfig: {
voiceConfig: {
prebuiltVoiceConfig: {
voiceName: openaiBody.voice.trim(),
},
},
},
},
};
Comment on lines +1096 to +1097

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor configured safety settings for speech requests

When operators set SAFETY_SETTINGS_THRESHOLD (the documented default is OFF), the chat and native generateContent paths add safetySettings before forwarding, but this new speech request is sent without ever going through _finalizeGoogleRequest or adding the default safety settings. In deployments relying on relaxed safety settings, TTS prompts that Google’s defaults would block can fail here even though the same generation configuration works through the other proxy paths; add the configured safety settings before returning/sending this Gemini request.

Useful? React with 👍 / 👎.


this.logger.info(`[Adapter] OpenAI speech request translated for model "${cleanModelName}".`);
return {
cleanModelName,
googleRequest,
responseFormat: responseFormat.toLowerCase(),
};
}

/**
* Decode Gemini inline audio and convert it to the requested OpenAI speech format.
*
* @param {object} googleResponse - Gemini generateContent response
* @param {"wav"|"pcm"} responseFormat - Validated output format
* @returns {{ audioBuffer: Buffer, contentType: string }}
*/
convertGoogleToOpenAISpeech(googleResponse, responseFormat) {
return convertGeminiAudioResponse(googleResponse, responseFormat);
}

/**
* Common final processing for Gemini requests:
* 1. Inject force features (Search, URL Context)
Expand Down
4 changes: 4 additions & 0 deletions src/core/ProxyServerSystem.js
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,10 @@ class ProxyServerSystem extends EventEmitter {
this.requestHandler.processOpenAIRequest(req, res);
});

app.post("/v1/audio/speech", (req, res) => {
this.requestHandler.processOpenAISpeechRequest(req, res);
});

app.post(["/v1/embeddings", "/v1/openai/embeddings"], (req, res) => {
this.requestHandler.processOpenAIEmbeddingsRequest(req, res);
});
Expand Down
149 changes: 144 additions & 5 deletions src/core/RequestHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -1042,6 +1042,99 @@ class RequestHandler {
}
}

// Process OpenAI speech synthesis requests
async processOpenAISpeechRequest(req, res) {
const requestId = this._generateRequestId();
this._startTrackedRequest(requestId, req, {
apiFormat: "openai",
isStreaming: false,
requestCategory: "generation",
streamMode: null,
});
this._setResponseApiFormat(res, "openai");
res.__proxyResponseStreamMode = null;

try {
let cleanModelName, googleRequest, responseFormat;
try {
const translatedRequest = this.formatConverter.translateOpenAISpeechToGoogle(req.body);
cleanModelName = translatedRequest.cleanModelName;
googleRequest = translatedRequest.googleRequest;
responseFormat = translatedRequest.responseFormat;
} catch (error) {
this.logger.warn(
`[Adapter] OpenAI speech request validation failed: ${error.message}, request ID: ${requestId}`
);
return this._sendErrorResponse(res, 400, error.message, "invalid_request_error");
}

if (!(await this._ensureBrowserBackedRequestReady(res, { waitErrorType: "service_unavailable" }))) {
return;
}

const usageCount = this.authSwitcher.incrementUsageCount();
if (usageCount > 0) {
const rotationCountText =
this.config.switchOnUses > 0 ? `${usageCount}/${this.config.switchOnUses}` : `${usageCount}`;
this.logger.info(
`[Request] OpenAI speech generation request - account rotation count: ${rotationCountText} (Current account: ${this.currentAuthIndex}), request ID: ${requestId}`
);
if (this.authSwitcher.shouldSwitchByUsage()) {
this.needsSwitchingAfterRequest = true;
}
}

const proxyRequest = {
body: JSON.stringify(googleRequest),
headers: { "Content-Type": "application/json" },
is_generative: true,
method: "POST",
path: `/v1beta/models/${cleanModelName}:generateContent`,
query_params: {},
request_id: requestId,
response_format: responseFormat,
response_transform: "geminiTtsToOpenAIAudio",
streaming_mode: "fake",
tracking_model: cleanModelName,
};
this._initializeProxyRequestAttempt(proxyRequest);
this._updateTrackedRequest(requestId, {
isStreaming: false,
model: cleanModelName,
path: proxyRequest.path,
requestCategory: "generation",
streamMode: null,
});

try {
const messageQueue = this.connectionRegistry.createMessageQueue(
requestId,
this.currentAuthIndex,
proxyRequest.request_attempt_id
);
this._setupClientDisconnectHandler(res, requestId);
await this._handleNonStreamResponse(proxyRequest, messageQueue, req, res);
} catch (error) {
this._handleQueueTimeout(error, requestId);
this._handleRequestError(error, res, requestId);
} finally {
this.connectionRegistry.removeMessageQueue(requestId, "request_complete");
if (this.needsSwitchingAfterRequest) {
this.logger.info(
`[Auth] Rotation count reached switching threshold (${this.authSwitcher.usageCount}/${this.config.switchOnUses}), will automatically switch account in background...`
);
this.authSwitcher.switchToNextAuth().catch(error => {
this.logger.error(`[Auth] Background account switching task failed: ${error.message}`);
});
this.needsSwitchingAfterRequest = false;
}
if (!res.writableEnded) res.end();
}
} finally {
this._finalizeTrackedRequest(requestId, res);
}
}

// Process File Upload requests
async processUploadRequest(req, res) {
const requestId = this._generateRequestId();
Expand Down Expand Up @@ -3160,11 +3253,17 @@ class RequestHandler {
const fullBodyBuffer = Buffer.concat(chunks);
let responseBodyBuffer = fullBodyBuffer;

try {
const fullResponse = JSON.parse(responseBodyBuffer.toString());
this._logGeminiNativeResponseDebug(fullResponse, "non-stream");
} catch (e) {
// Ignore JSON parsing errors for finish reason
if (proxyRequest.response_transform === "geminiTtsToOpenAIAudio") {
this.logger.debug(
`[Request] Received Gemini TTS response body (${responseBodyBuffer.length} bytes), request ID: ${proxyRequest.request_id}`
);
} else {
try {
const fullResponse = JSON.parse(responseBodyBuffer.toString());
this._logGeminiNativeResponseDebug(fullResponse, "non-stream");
} catch (e) {
// Ignore JSON parsing errors for finish reason
}
}

if (proxyRequest.response_transform === "batchEmbedToEmbedContent") {
Expand All @@ -3177,6 +3276,46 @@ class RequestHandler {
}
}

if (proxyRequest.response_transform === "geminiTtsToOpenAIAudio") {
try {
const upstreamStatus = Number(headerMessage.status || 200);
if (upstreamStatus < 200 || upstreamStatus >= 300) {
throw new Error(`Gemini returned unexpected status ${upstreamStatus}.`);
}

let googleResponse;
try {
googleResponse = JSON.parse(responseBodyBuffer.toString());
} catch {
throw new Error("Gemini response was not valid JSON.");
}
const { audioBuffer, contentType } = this.formatConverter.convertGoogleToOpenAISpeech(
googleResponse,
proxyRequest.response_format
);
res.status(200).set({
"Cache-Control": "no-store",
"Content-Length": String(audioBuffer.length),
"Content-Type": contentType,
});
res.send(audioBuffer);
this.logger.info(
`✅ [Request] Response completed (OpenAI speech, ${proxyRequest.response_format}), request ID: ${proxyRequest.request_id}`
);
} catch (error) {
this.logger.error(
`❌ [Adapter] Failed to decode Gemini speech response: ${error.message}, request ID: ${proxyRequest.request_id}`
);
this._sendErrorResponse(
res,
502,
`Failed to decode audio from Gemini response: ${error.message}`,
"api_error"
);
}
return;
}

this._setResponseHeaders(res, headerMessage, req);

// Ensure Content-Type is set (Express defaults Buffer to application/octet-stream)
Expand Down
Loading