|
@@ -145,6 +145,25 @@ class DeepSeekService
|
|
|
if ((!empty($videos) || !empty($videoUrls)) && !$isGeminiModel) {
|
|
if ((!empty($videos) || !empty($videoUrls)) && !$isGeminiModel) {
|
|
|
Utils::throwError('20003:仅Gemini模型支持视频输入');
|
|
Utils::throwError('20003:仅Gemini模型支持视频输入');
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ // Gemini 多模态(图片/视频 -> 文本)按模型预检积分;纯文本不扣费
|
|
|
|
|
+ $uid = Site::getUid();
|
|
|
|
|
+ $chargeModality = '';
|
|
|
|
|
+ $chargePoints = 0;
|
|
|
|
|
+ $hasImageMedia = !empty($images) || !empty($imageUrls)
|
|
|
|
|
+ || $this->messagesContainMedia($messages, ['image_url', 'image']);
|
|
|
|
|
+ $hasVideoMedia = !empty($videos) || !empty($videoUrls)
|
|
|
|
|
+ || $this->messagesContainMedia($messages, ['video_url', 'video']);
|
|
|
|
|
+ if ($isGeminiModel && ($hasImageMedia || $hasVideoMedia)) {
|
|
|
|
|
+ $chargeModality = $hasVideoMedia ? 'video' : 'image';
|
|
|
|
|
+ $pricingModel = $this->geminiMultimodalPricingModel($model);
|
|
|
|
|
+ if ($pricingModel !== '') {
|
|
|
|
|
+ $chargePoints = $this->pointsService->getVideoChargePoints(['model' => $pricingModel]);
|
|
|
|
|
+ if ($chargePoints > 0) {
|
|
|
|
|
+ $this->pointsService->checkUserPointsEnough($chargePoints, $uid);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
// 如果没有提供messages,则根据system_prompt、content和images构建
|
|
// 如果没有提供messages,则根据system_prompt、content和images构建
|
|
|
if (empty($messages)) {
|
|
if (empty($messages)) {
|
|
@@ -296,19 +315,119 @@ class DeepSeekService
|
|
|
// 根据模型类型选择调用方法
|
|
// 根据模型类型选择调用方法
|
|
|
if (in_array($model, ['deepseek-reasoner', 'deepseek-chat', 'deepseek-v4-flash', 'deepseek-v4-pro'])) {
|
|
if (in_array($model, ['deepseek-reasoner', 'deepseek-chat', 'deepseek-v4-flash', 'deepseek-v4-pro'])) {
|
|
|
// DeepSeek 官方模型使用 DeepSeek API
|
|
// DeepSeek 官方模型使用 DeepSeek API
|
|
|
- return $this->deepSeekStreamResponse($post_data);
|
|
|
|
|
|
|
+ $streamGenerator = $this->deepSeekStreamResponse($post_data);
|
|
|
} else if (in_array($model, $this->gpt_text_models)) {
|
|
} else if (in_array($model, $this->gpt_text_models)) {
|
|
|
// GPT 模型使用 TokenRouter API
|
|
// GPT 模型使用 TokenRouter API
|
|
|
- return $this->gpt54StreamResponse($post_data);
|
|
|
|
|
|
|
+ $streamGenerator = $this->gpt54StreamResponse($post_data);
|
|
|
} else if (in_array($model, $this->gemini_text_models)) {
|
|
} else if (in_array($model, $this->gemini_text_models)) {
|
|
|
// Gemini 模型使用 Gemini API
|
|
// Gemini 模型使用 Gemini API
|
|
|
- return $this->geminiStreamResponse($post_data, $model);
|
|
|
|
|
|
|
+ $streamGenerator = $this->geminiStreamResponse($post_data, $model);
|
|
|
} else if (in_array($model, $this->valid_text_models)) {
|
|
} else if (in_array($model, $this->valid_text_models)) {
|
|
|
// 火山引擎支持的模型使用火山引擎 API
|
|
// 火山引擎支持的模型使用火山引擎 API
|
|
|
- return $this->volcEngineChatCompletion($post_data);
|
|
|
|
|
|
|
+ $streamGenerator = $this->volcEngineChatCompletion($post_data);
|
|
|
} else {
|
|
} else {
|
|
|
Utils::throwError('20003:不支持的模型: ' . $model);
|
|
Utils::throwError('20003:不支持的模型: ' . $model);
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ if ($chargePoints > 0) {
|
|
|
|
|
+ return $this->wrapGenerateTextCharge($streamGenerator, $uid, $model, $chargePoints, $chargeModality);
|
|
|
|
|
+ }
|
|
|
|
|
+ return $streamGenerator;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 流式生成成功后再按 Gemini 多模态规则扣费
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param \Generator $generator
|
|
|
|
|
+ * @param int $uid
|
|
|
|
|
+ * @param string $model
|
|
|
|
|
+ * @param int $points
|
|
|
|
|
+ * @param string $modality
|
|
|
|
|
+ * @return \Generator
|
|
|
|
|
+ */
|
|
|
|
|
+ private function wrapGenerateTextCharge(\Generator $generator, int $uid, string $model, int $points, string $modality): \Generator
|
|
|
|
|
+ {
|
|
|
|
|
+ $charged = false;
|
|
|
|
|
+ foreach ($generator as $chunk) {
|
|
|
|
|
+ if (!$charged && isset($chunk['type']) && $chunk['type'] === 'done') {
|
|
|
|
|
+ $fullContent = (string)getProp($chunk, 'full_content', getProp($chunk, 'answer', ''));
|
|
|
|
|
+ if ($fullContent !== '') {
|
|
|
|
|
+ $this->chargeGeminiMultimodal($uid, $model, $points, $modality, getProp($chunk, 'usage', []));
|
|
|
|
|
+ $charged = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ yield $chunk;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Gemini 多模态(图片/视频 -> 文本)成功后扣费并记录 token 明细
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param int $uid
|
|
|
|
|
+ * @param string $model
|
|
|
|
|
+ * @param int $points
|
|
|
|
|
+ * @param string $modality
|
|
|
|
|
+ * @param mixed $usage
|
|
|
|
|
+ * @return void
|
|
|
|
|
+ */
|
|
|
|
|
+ private function chargeGeminiMultimodal(int $uid, string $model, int $points, string $modality, $usage): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $result = $this->pointsService->recordChatCharge(
|
|
|
|
|
+ $uid,
|
|
|
|
|
+ $points,
|
|
|
|
|
+ $this->pointsService->getTokensFromUsage($usage),
|
|
|
|
|
+ [
|
|
|
|
|
+ 'model' => $model,
|
|
|
|
|
+ 'modality' => $modality,
|
|
|
|
|
+ 'source' => 'generateText',
|
|
|
|
|
+ ],
|
|
|
|
|
+ $modality === 'video' ? 'Gemini视频理解成功计费' : 'Gemini图片理解成功计费',
|
|
|
|
|
+ $model
|
|
|
|
|
+ );
|
|
|
|
|
+ if (empty($result['charged'])) {
|
|
|
|
|
+ throw new \Exception('Gemini多模态计费失败: ' . (string)($result['reason'] ?? 'unknown'));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Gemini 文本模型 ID 转 mp_video_models 中的按次计费模型 ID
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param string $model
|
|
|
|
|
+ * @return string
|
|
|
|
|
+ */
|
|
|
|
|
+ private function geminiMultimodalPricingModel(string $model): string
|
|
|
|
|
+ {
|
|
|
|
|
+ $map = [
|
|
|
|
|
+ 'gemini-3.1-flash-image-preview-t' => 'gemini-3.1-flash',
|
|
|
|
|
+ 'gemini-3.1-pro-preview' => 'gemini-3.1-pro',
|
|
|
|
|
+ 'gemini-3-flash-preview' => 'gemini-3.0-flash',
|
|
|
|
|
+ 'gemini-3-pro-preview' => 'gemini-3.0-pro',
|
|
|
|
|
+ 'gemini-3.6-flash' => 'gemini-3.6-flash',
|
|
|
|
|
+ ];
|
|
|
|
|
+ return $map[$model] ?? '';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 检查 OpenAI 风格消息中是否包含指定媒体类型
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param array $messages
|
|
|
|
|
+ * @param array $mediaTypes
|
|
|
|
|
+ * @return bool
|
|
|
|
|
+ */
|
|
|
|
|
+ private function messagesContainMedia(array $messages, array $mediaTypes): bool
|
|
|
|
|
+ {
|
|
|
|
|
+ foreach ($messages as $message) {
|
|
|
|
|
+ $content = getProp($message, 'content', '');
|
|
|
|
|
+ if (!is_array($content)) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ foreach ($content as $part) {
|
|
|
|
|
+ if (is_array($part) && in_array(getProp($part, 'type', ''), $mediaTypes, true)) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return false;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
public function createGenerateText($data) {
|
|
public function createGenerateText($data) {
|