CI / test (push) Successful in 9m14s
From review. - /profile asked before signing in keeps its query for ten minutes, and the sign-in (password or OIDC) returns to it: VS Code's sign-in state came back lost, and the confirmation came every time. Only /profile is ever returned to. - The extension keeps an https gateway at https when a link names it by plain http (a proxy ending TLS unannounced), so the key is never sent in the clear. The install command says --force, since dev builds share a version and VS Code kept the old one. - Every frame of every install recording is checked for a key, a password or a private key, not only the page. - The admin API document again matches the API: f94d902's MCPServerRequest.clear was not in it. - CHANGELOG for this round's fixes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
282 lines
10 KiB
JavaScript
282 lines
10 KiB
JavaScript
'use strict';
|
|
|
|
// What the extension does that does not need VS Code: reading the gateway's
|
|
// model list, turning VS Code's messages into chat-completion messages, and
|
|
// reading the streamed answer back. Kept apart so it can be tested with plain
|
|
// Node, against a gateway, without an editor running.
|
|
|
|
const USER = 1;
|
|
const ASSISTANT = 2;
|
|
// Not in every VS Code that runs this: a system message, where it exists.
|
|
const SYSTEM = 3;
|
|
|
|
// gatewayBase is the address the person typed, as the API is under it: no
|
|
// trailing slash, and no /v1, which people copy from client guides.
|
|
function gatewayBase(raw) {
|
|
let s = String(raw || '').trim();
|
|
if (!s) return '';
|
|
// A bare host is https; any other scheme than http(s) is not a gateway.
|
|
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) {
|
|
if (!/^https?:\/\//i.test(s)) return '';
|
|
} else {
|
|
s = 'https://' + s;
|
|
}
|
|
let u;
|
|
try {
|
|
u = new URL(s);
|
|
} catch {
|
|
return '';
|
|
}
|
|
if (u.protocol !== 'https:' && u.protocol !== 'http:') return '';
|
|
let path = u.pathname.replace(/\/+$/, '');
|
|
if (path.endsWith('/v1')) path = path.slice(0, -3);
|
|
return u.origin + path;
|
|
}
|
|
|
|
// modelInfo is what VS Code is told of one entry of /v1/models, or null for
|
|
// one that does not chat: an embedding model, or one that makes pictures,
|
|
// meshes or anything else through a workflow.
|
|
//
|
|
// The sizes are the catalog's when it gives them. VS Code trims a
|
|
// conversation to maxInputTokens, and a request over what a replica holds is
|
|
// refused by the gateway, so guessing high is worse than guessing low.
|
|
function modelInfo(entry, showWithoutTools) {
|
|
if (!entry || typeof entry.id !== 'string' || !entry.id) return null;
|
|
const caps = entry.capabilities || {};
|
|
if (caps.embeddings || caps.images || caps.meshes || caps.workflows) return null;
|
|
const tools = !!caps.tools;
|
|
if (!tools && !showWithoutTools) return null;
|
|
const context = Number(entry.context_length) > 0 ? Number(entry.context_length) : 8192;
|
|
let output = Number(entry.max_output_tokens) > 0 ? Number(entry.max_output_tokens) : 0;
|
|
output = Math.min(output || 4096, Math.max(256, Math.floor(context / 4)));
|
|
const detail = entry.alias_of ? `llmgw · ${entry.alias_of}` : 'llmgw';
|
|
return {
|
|
id: entry.id,
|
|
name: entry.id,
|
|
family: entry.alias_of || entry.id,
|
|
version: 'llmgw',
|
|
detail,
|
|
tooltip: entry.description || undefined,
|
|
maxInputTokens: Math.max(256, context - output),
|
|
maxOutputTokens: output,
|
|
capabilities: { toolCalling: tools, imageInput: !!caps.vision },
|
|
};
|
|
}
|
|
|
|
function isToolCall(p) {
|
|
return p && typeof p.callId === 'string' && typeof p.name === 'string' && 'input' in p;
|
|
}
|
|
function isToolResult(p) {
|
|
return p && typeof p.callId === 'string' && Array.isArray(p.content);
|
|
}
|
|
function isText(p) {
|
|
return p && typeof p.value === 'string' && !isToolCall(p) && !isToolResult(p);
|
|
}
|
|
function isImage(p) {
|
|
return p && typeof p.mimeType === 'string' && p.mimeType.startsWith('image/') && p.data instanceof Uint8Array;
|
|
}
|
|
|
|
// resultText flattens a tool result to the text a chat-completion tool
|
|
// message carries. Parts that are not text (prompt-tsx trees) go as JSON.
|
|
function resultText(content) {
|
|
return content
|
|
.map((c) => (isText(c) ? c.value : typeof c === 'string' ? c : JSON.stringify(c)))
|
|
.join('');
|
|
}
|
|
|
|
// chatMessages turns VS Code's request messages into chat-completion ones.
|
|
//
|
|
// One VS Code message can hold several kinds of part; the API they are going
|
|
// to wants each in its own message: an assistant's tool calls in one, each
|
|
// tool result in a tool message straight after it, and only then the text a
|
|
// person wrote beside the results.
|
|
function chatMessages(messages, vision) {
|
|
const out = [];
|
|
for (const m of messages) {
|
|
const parts = Array.isArray(m.content) ? m.content : [];
|
|
if (m.role === ASSISTANT) {
|
|
const text = parts.filter(isText).map((p) => p.value).join('');
|
|
const calls = parts.filter(isToolCall).map((p) => ({
|
|
id: p.callId,
|
|
type: 'function',
|
|
function: { name: p.name, arguments: JSON.stringify(p.input ?? {}) },
|
|
}));
|
|
const msg = { role: 'assistant', content: text || (calls.length ? null : '') };
|
|
if (calls.length) msg.tool_calls = calls;
|
|
out.push(msg);
|
|
continue;
|
|
}
|
|
for (const p of parts.filter(isToolResult)) {
|
|
out.push({ role: 'tool', tool_call_id: p.callId, content: resultText(p.content) });
|
|
}
|
|
const texts = parts.filter(isText).map((p) => p.value);
|
|
const images = vision ? parts.filter(isImage) : [];
|
|
if (!texts.length && !images.length) continue;
|
|
const role = m.role === SYSTEM ? 'system' : 'user';
|
|
if (!images.length) {
|
|
out.push({ role, content: texts.join('') });
|
|
continue;
|
|
}
|
|
const content = [];
|
|
if (texts.length) content.push({ type: 'text', text: texts.join('') });
|
|
for (const img of images) {
|
|
const b64 = Buffer.from(img.data).toString('base64');
|
|
content.push({ type: 'image_url', image_url: { url: `data:${img.mimeType};base64,${b64}` } });
|
|
}
|
|
out.push({ role, content });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// chatTools turns VS Code's tool list into chat-completion functions. A tool
|
|
// without a schema still needs one: an object that takes nothing.
|
|
function chatTools(tools) {
|
|
return (tools || []).map((t) => ({
|
|
type: 'function',
|
|
function: {
|
|
name: t.name,
|
|
description: t.description || '',
|
|
parameters: t.inputSchema && Object.keys(t.inputSchema).length ? t.inputSchema : { type: 'object', properties: {} },
|
|
},
|
|
}));
|
|
}
|
|
|
|
// requestBody is the whole chat-completion request. toolMode 2 is VS Code's
|
|
// "Required": the model must call one of the tools.
|
|
function requestBody(model, messages, options) {
|
|
const body = {
|
|
model: model.id,
|
|
messages: chatMessages(messages, !!(model.capabilities && model.capabilities.imageInput)),
|
|
stream: true,
|
|
max_tokens: model.maxOutputTokens,
|
|
};
|
|
const tools = chatTools(options && options.tools);
|
|
if (tools.length) {
|
|
body.tools = tools;
|
|
if (options.toolMode === 2) body.tool_choice = 'required';
|
|
}
|
|
const mo = (options && options.modelOptions) || {};
|
|
for (const k of ['temperature', 'top_p', 'stop', 'seed']) {
|
|
if (mo[k] !== undefined) body[k] = mo[k];
|
|
}
|
|
return body;
|
|
}
|
|
|
|
// sseData yields the data of each server-sent event in a byte stream, as the
|
|
// chat-completion stream sends them: "data: {...}" lines, ending "[DONE]".
|
|
async function* sseData(stream) {
|
|
const decoder = new TextDecoder();
|
|
let buf = '';
|
|
for await (const chunk of stream) {
|
|
buf += decoder.decode(chunk, { stream: true });
|
|
let i;
|
|
while ((i = buf.indexOf('\n')) >= 0) {
|
|
const line = buf.slice(0, i).replace(/\r$/, '');
|
|
buf = buf.slice(i + 1);
|
|
if (line.startsWith('data:')) yield line.slice(5).trimStart();
|
|
}
|
|
}
|
|
buf += decoder.decode();
|
|
if (buf.startsWith('data:')) yield buf.slice(5).trimStart();
|
|
}
|
|
|
|
// Answer collects one streamed answer: its text as it comes, and its tool
|
|
// calls, whose arguments arrive in pieces and are only whole at the end.
|
|
class Answer {
|
|
constructor() {
|
|
this.calls = new Map();
|
|
this.usage = null;
|
|
}
|
|
|
|
// add takes one event's data and returns the text it carries, if any.
|
|
add(data) {
|
|
if (data === '[DONE]') return '';
|
|
let ev;
|
|
try {
|
|
ev = JSON.parse(data);
|
|
} catch {
|
|
return '';
|
|
}
|
|
if (ev.error) {
|
|
const msg = typeof ev.error === 'string' ? ev.error : ev.error.message || JSON.stringify(ev.error);
|
|
throw new Error(msg);
|
|
}
|
|
if (ev.usage) this.usage = ev.usage;
|
|
let text = '';
|
|
for (const choice of ev.choices || []) {
|
|
const d = choice.delta || {};
|
|
if (typeof d.content === 'string') text += d.content;
|
|
for (const tc of d.tool_calls || []) {
|
|
const key = tc.index ?? tc.id ?? this.calls.size;
|
|
const c = this.calls.get(key) || { id: '', name: '', args: '' };
|
|
if (tc.id) c.id = tc.id;
|
|
if (tc.function && tc.function.name) c.name += tc.function.name;
|
|
if (tc.function && typeof tc.function.arguments === 'string') c.args += tc.function.arguments;
|
|
this.calls.set(key, c);
|
|
}
|
|
}
|
|
return text;
|
|
}
|
|
|
|
// toolCalls is every call the answer made, its arguments parsed. A call
|
|
// whose arguments are not JSON goes with them as text, under "input", so
|
|
// the tool sees what the model wrote rather than nothing.
|
|
toolCalls() {
|
|
const out = [];
|
|
let n = 0;
|
|
for (const c of this.calls.values()) {
|
|
n++;
|
|
if (!c.name) continue;
|
|
let input = {};
|
|
if (c.args.trim()) {
|
|
try {
|
|
input = JSON.parse(c.args);
|
|
} catch {
|
|
input = { input: c.args };
|
|
}
|
|
}
|
|
out.push({ id: c.id || `call_${n}`, name: c.name, input });
|
|
}
|
|
return out;
|
|
}
|
|
}
|
|
|
|
// errorMessage reads what a gateway said when it refused a request.
|
|
function errorMessage(status, bodyText) {
|
|
let msg = '';
|
|
try {
|
|
const j = JSON.parse(bodyText);
|
|
const e = j.error;
|
|
msg = (typeof e === 'string' ? e : e && e.message) || j.message || '';
|
|
} catch {
|
|
msg = bodyText.trim().slice(0, 300);
|
|
}
|
|
if (status === 401) return 'llmgw: the gateway did not accept the key. Run "llmgw: Sign In" to make a new one.';
|
|
if (status === 403) return `llmgw: this key may not use that model${msg ? ` (${msg})` : ''}.`;
|
|
if (status === 429) return `llmgw: a limit was reached${msg ? `: ${msg}` : ''}.`;
|
|
return `llmgw: the gateway answered ${status}${msg ? `: ${msg}` : ''}.`;
|
|
}
|
|
|
|
// tokenGuess is how many tokens a text is, near enough for VS Code to decide
|
|
// what fits: about four characters to a token in English and in code.
|
|
function tokenGuess(text) {
|
|
return Math.ceil(String(text || '').length / 4);
|
|
}
|
|
|
|
// downgrades reports whether next is the gateway at current by plain http
|
|
// where current is https: the same host and port, a weaker scheme.
|
|
function downgrades(current, next) {
|
|
try {
|
|
const a = new URL(current);
|
|
const b = new URL(next);
|
|
return a.protocol === 'https:' && b.protocol === 'http:' && a.hostname === b.hostname;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
USER, ASSISTANT, SYSTEM,
|
|
gatewayBase, modelInfo, chatMessages, chatTools, requestBody, sseData, Answer, errorMessage, tokenGuess, downgrades,
|
|
};
|