File size: 7,731 Bytes
5923435
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
/**
 * 浏览器直连 OpenRouter Chat Completions(实验:LLM Raw Chat 管理员旁路)。
 * 返回形状对齐本地 /v1/completions 的 OpenAICompletionsResponse(无 info_radar 染色)。
 */
import type { CompletionsChatMessage, OpenAICompletionsResponse } from './completionsClient';

const OPENROUTER_CHAT_URL = 'https://openrouter.ai/api/v1/chat/completions';

export type OpenRouterChatRequest = {
    apiKey: string;
    model: string;
    messages: CompletionsChatMessage[];
    maxTokens: number;
    signal?: AbortSignal;
    onDelta?: (text: string, streamEnd: boolean) => void;
};

export type OpenRouterChatResult = {
    response: OpenAICompletionsResponse;
    /** 对端返回字段汇总(usage / id / model / finish_reason 等),供右侧 JSON 展示 */
    apiMeta: Record<string, unknown>;
};

function finishReasonFromOpenRouter(
    reason: string | null | undefined
): string | null {
    if (reason == null) return null;
    if (reason === 'stop') return 'stop';
    if (reason === 'length') return 'length';
    return reason;
}

/**
 * 流式 POST OpenRouter;拼出完整 assistant 文本,映射为本地 completions 响应形。
 */
export async function postOpenRouterChat(
    req: OpenRouterChatRequest
): Promise<OpenRouterChatResult> {
    const { apiKey, model, messages, maxTokens, signal, onDelta } = req;
    if (!apiKey.trim()) {
        throw new Error('OpenRouter API key is required');
    }
    if (!model.trim()) {
        throw new Error('OpenRouter model id is required');
    }

    const resp = await fetch(OPENROUTER_CHAT_URL, {
        method: 'POST',
        headers: {
            Authorization: `Bearer ${apiKey.trim()}`,
            'Content-Type': 'application/json',
            'HTTP-Referer': typeof location !== 'undefined' ? location.origin : 'https://info-radar.local',
            'X-Title': 'info-radar-chat-openrouter',
        },
        body: JSON.stringify({
            model: model.trim(),
            messages: messages.map((m) => ({
                role: m.role,
                content: m.content,
                ...(m.name !== undefined ? { name: m.name } : {}),
            })),
            max_tokens: maxTokens,
            temperature: 0,
            stream: true,
            reasoning: { effort: 'none' },
        }),
        signal,
    });

    if (!resp.ok) {
        const errText = await resp.text().catch(() => '');
        let detail = errText.slice(0, 500);
        try {
            const j = JSON.parse(errText) as { error?: { message?: string } | string };
            const e = j.error;
            if (typeof e === 'string') detail = e;
            else if (e && typeof e.message === 'string') detail = e.message;
        } catch {
            /* keep raw */
        }
        throw new Error(`OpenRouter HTTP ${resp.status}: ${detail}`);
    }

    if (!resp.body) {
        throw new Error('OpenRouter response missing body');
    }

    const reader = resp.body.getReader();
    const decoder = new TextDecoder();
    let buffer = '';
    let fullText = '';
    let finishReason: string | null = null;
    let usage: OpenAICompletionsResponse['usage'];
    let rawModel = model;
    let responseId: string | undefined;
    /** 合并流式 chunk 里除 delta 外的字段,供调试展示 */
    const apiMeta: Record<string, unknown> = {};

    const mergeMetaFromChunk = (chunk: Record<string, unknown>): void => {
        for (const [k, v] of Object.entries(chunk)) {
            if (k === 'choices') continue;
            if (v !== undefined) {
                apiMeta[k] = v;
            }
        }
        const choices = chunk.choices;
        if (!Array.isArray(choices) || choices.length === 0) return;
        const c0 = choices[0] as Record<string, unknown>;
        const choiceMeta: Record<string, unknown> = {};
        for (const [k, v] of Object.entries(c0)) {
            if (k === 'delta' || k === 'text') continue;
            if (v !== undefined && v !== null) {
                choiceMeta[k] = v;
            }
        }
        if (Object.keys(choiceMeta).length > 0) {
            apiMeta.choice = { ...(apiMeta.choice as object | undefined), ...choiceMeta };
        }
    };

    const handleDataPayload = (payload: string): void => {
        if (payload === '[DONE]') return;
        let chunk: Record<string, unknown>;
        try {
            chunk = JSON.parse(payload) as Record<string, unknown>;
        } catch {
            throw new Error(`OpenRouter unparseable SSE chunk: ${payload.slice(0, 200)}`);
        }
        if (chunk.error) {
            const err = chunk.error;
            const msg =
                typeof err === 'string'
                    ? err
                    : err && typeof err === 'object' && 'message' in err
                      ? String((err as { message?: unknown }).message)
                      : JSON.stringify(err);
            throw new Error(`OpenRouter error: ${msg}`);
        }
        mergeMetaFromChunk(chunk);
        if (typeof chunk.id === 'string' && chunk.id) {
            responseId = chunk.id;
        }
        if (typeof chunk.model === 'string' && chunk.model) {
            rawModel = chunk.model;
        }
        if (chunk.usage && typeof chunk.usage === 'object') {
            usage = chunk.usage as OpenAICompletionsResponse['usage'];
        }
        const choices = chunk.choices;
        if (!Array.isArray(choices) || !choices[0] || typeof choices[0] !== 'object') {
            return;
        }
        const choice = choices[0] as {
            delta?: { content?: string | null };
            finish_reason?: string | null;
        };
        const piece = choice.delta?.content;
        if (typeof piece === 'string' && piece.length > 0) {
            fullText += piece;
            onDelta?.(piece, false);
        }
        if (choice.finish_reason) {
            finishReason = choice.finish_reason;
        }
    };

    while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        buffer += decoder.decode(value, { stream: true });
        for (;;) {
            const nl = buffer.indexOf('\n');
            if (nl < 0) break;
            const line = buffer.slice(0, nl).replace(/\r$/, '');
            buffer = buffer.slice(nl + 1);
            if (!line.startsWith('data:')) continue;
            handleDataPayload(line.slice(5).trimStart());
        }
    }
    if (buffer.trim()) {
        for (const line of buffer.split('\n')) {
            const t = line.replace(/\r$/, '');
            if (!t.startsWith('data:')) continue;
            handleDataPayload(t.slice(5).trimStart());
        }
    }

    onDelta?.('', true);

    const mappedFinish = finishReasonFromOpenRouter(finishReason);
    apiMeta.assistant_text_length = fullText.length;
    if (mappedFinish != null) {
        apiMeta.finish_reason = mappedFinish;
    }

    return {
        response: {
            id: responseId ?? `openrouter-${Date.now()}`,
            object: 'text_completion',
            created: intTime(),
            model: rawModel,
            choices: [
                {
                    text: fullText,
                    index: 0,
                    finish_reason: mappedFinish,
                },
            ],
            usage,
            info_radar: { bpe_strings: [] },
        },
        apiMeta,
    };
}

function intTime(): number {
    return Math.floor(Date.now() / 1000);
}

/** 右侧 input 段展示用:把 messages 打成可读多段文本 */
export function formatOpenRouterMessagesAsPrompt(
    messages: CompletionsChatMessage[]
): string {
    return messages
        .map((m) => `[${m.role}]\n${m.content}`)
        .join('\n\n');
}