All files / src/ai/providers openai-compatible.ts

97.61% Statements 82/84
89.47% Branches 17/19
100% Functions 3/3
97.61% Lines 82/84

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 852x 2x 2x 2x 2x 2x 2x 2x 6x 6x 6x 6x 2x 2x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x     6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 6x 2x 2x 1x 1x 1x 1x 4x 4x 4x 4x 4x 4x 4x 4x 4x 6x 6x 3x 3x 2x 2x 1x 1x 3x 3x 3x 1x 1x 1x 1x 1x 1x 1x 6x 6x 6x 2x  
import { AIProvider, ChatOptions, EmptyContentError } from './base';
import { t } from '../../i18n/messages';
 
export class OpenAICompatibleProvider extends AIProvider {
  id: string;
  name: string;
 
  constructor(apiKey: string, baseUrl: string, id: string, name: string) {
    super(apiKey, baseUrl);
    this.id = id;
    this.name = name;
  }
 
  async chat(systemPrompt: string, userPrompt: string, options: ChatOptions): Promise<string> {
    const url = `${this.baseUrl}/chat/completions`;
 
    const bodyObj: Record<string, unknown> = {
      model: options.model,
      max_tokens: options.maxTokens,
      temperature: options.temperature,
      messages: [
        { role: 'system', content: systemPrompt },
        { role: 'user', content: userPrompt },
      ],
    };
 
    if (options.seed !== undefined) {
      bodyObj.seed = options.seed;
    }
 
    const body = JSON.stringify(bodyObj);
 
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
 
    try {
      const response = await fetch(url, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${this.apiKey}`,
        },
        body,
        signal: controller.signal,
      });
 
      if (!response.ok) {
        const errorText = await response.text();
        if (response.status === 401) {
          throw new Error(t('adapter.invalidApiKey'));
        }
        throw new Error(`API 请求失败 (${response.status}): ${errorText}`);
      }
 
      const data = await response.json() as {
        choices?: Array<{
          message?: { content?: string | null };
          finish_reason?: string | null;
        }>;
        error?: { message?: string };
      };
 
      const content = data.choices?.[0]?.message?.content;
      if (content === undefined || content === null || content.trim() === '') {
        const finish = data.choices?.[0]?.finish_reason ?? 'unknown';
        if (finish === 'length') {
          throw new EmptyContentError(t('adapter.maxTokensTruncated', { 0: String(options.maxTokens) }));
        }
        const parts = [
          `finish_reason=${finish}`,
          `choices=${data.choices?.length ?? 0}`,
        ];
        if (data.error?.message) {
          parts.push(data.error.message);
        }
        throw new EmptyContentError(t('adapter.emptyContent', { 0: parts.join(', ') }));
      }
 
      return content;
    } finally {
      clearTimeout(timeout);
    }
  }
}