feat(executors): Improved UI components for displaying provider limits and usage statistics in the dashboard.
This commit is contained in:
parent
249fc28c49
commit
32aefe5a76
21 changed files with 1113 additions and 311 deletions
|
|
@ -43,16 +43,16 @@ export const PROVIDER_MODELS = {
|
||||||
{ id: "glm-4.7", name: "GLM 4.7" },
|
{ id: "glm-4.7", name: "GLM 4.7" },
|
||||||
],
|
],
|
||||||
ag: [ // Antigravity - special case: models call different backends
|
ag: [ // Antigravity - special case: models call different backends
|
||||||
{ id: "gemini-3-pro-low", name: "Gemini 3 Pro Low" },
|
{ id: "claude-opus-4-5-thinking", name: "Claude Opus 4.5 Thinking" },
|
||||||
|
{ id: "claude-opus-4-5", name: "Claude Opus 4.5" },
|
||||||
|
{ id: "claude-sonnet-4-5-thinking", name: "Claude Sonnet 4.5 Thinking" },
|
||||||
|
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
|
||||||
{ id: "gemini-3-pro-high", name: "Gemini 3 Pro High" },
|
{ id: "gemini-3-pro-high", name: "Gemini 3 Pro High" },
|
||||||
|
{ id: "gemini-3-pro-low", name: "Gemini 3 Pro Low" },
|
||||||
{ id: "gemini-3-flash", name: "Gemini 3 Flash" },
|
{ id: "gemini-3-flash", name: "Gemini 3 Flash" },
|
||||||
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
|
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
|
||||||
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5 " },
|
|
||||||
{ id: "claude-sonnet-4-5-thinking", name: "Claude Sonnet 4.5 Thinking" },
|
|
||||||
{ id: "claude-opus-4-5-thinking", name: "Claude Opus 4.5" },
|
|
||||||
],
|
],
|
||||||
gh: [ // GitHub Copilot
|
gh: [ // GitHub Copilot - always uses OpenAI format (Copilot API is OpenAI-compatible)
|
||||||
{ id: "gpt-5", name: "GPT-5" },
|
|
||||||
{ id: "gpt-5-mini", name: "GPT-5 Mini" },
|
{ id: "gpt-5-mini", name: "GPT-5 Mini" },
|
||||||
// { id: "gpt-5.1", name: "GPT-5.1" },
|
// { id: "gpt-5.1", name: "GPT-5.1" },
|
||||||
// { id: "gpt-5.2", name: "GPT-5.2" },
|
// { id: "gpt-5.2", name: "GPT-5.2" },
|
||||||
|
|
@ -147,9 +147,12 @@ export function findModelName(aliasOrId, modelId) {
|
||||||
|
|
||||||
export function getModelTargetFormat(aliasOrId, modelId) {
|
export function getModelTargetFormat(aliasOrId, modelId) {
|
||||||
const models = PROVIDER_MODELS[aliasOrId];
|
const models = PROVIDER_MODELS[aliasOrId];
|
||||||
if (!models) return null;
|
if (models) {
|
||||||
const found = models.find(m => m.id === modelId);
|
const found = models.find(m => m.id === modelId);
|
||||||
return found?.targetFormat || null;
|
if (found?.targetFormat) return found.targetFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provider ID to alias mapping
|
// Provider ID to alias mapping
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ import {
|
||||||
parseConnectRPCFrame,
|
parseConnectRPCFrame,
|
||||||
extractTextFromResponse
|
extractTextFromResponse
|
||||||
} from "../utils/cursorProtobuf.js";
|
} from "../utils/cursorProtobuf.js";
|
||||||
|
import { estimateUsage } from "../utils/usageTracking.js";
|
||||||
|
import { FORMATS } from "../translator/formats.js";
|
||||||
|
import { buildCursorRequest } from "../translator/request/openai-to-cursor.js";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { v5 as uuidv5 } from "uuid";
|
import { v5 as uuidv5 } from "uuid";
|
||||||
import zlib from "zlib";
|
import zlib from "zlib";
|
||||||
|
|
@ -34,11 +37,26 @@ const COMPRESS_FLAG = {
|
||||||
};
|
};
|
||||||
|
|
||||||
function decompressPayload(payload, flags) {
|
function decompressPayload(payload, flags) {
|
||||||
|
// Check if payload is JSON error (starts with {"error")
|
||||||
|
if (payload.length > 10 && payload[0] === 0x7b && payload[1] === 0x22) {
|
||||||
|
try {
|
||||||
|
const text = payload.toString('utf-8');
|
||||||
|
if (text.startsWith('{"error"')) {
|
||||||
|
console.log(`[DECOMPRESS] Detected JSON error, skipping decompression`);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
if (flags === COMPRESS_FLAG.GZIP || flags === COMPRESS_FLAG.GZIP_ALT || flags === COMPRESS_FLAG.GZIP_BOTH) {
|
if (flags === COMPRESS_FLAG.GZIP || flags === COMPRESS_FLAG.GZIP_ALT || flags === COMPRESS_FLAG.GZIP_BOTH) {
|
||||||
try {
|
try {
|
||||||
return zlib.gunzipSync(payload);
|
return zlib.gunzipSync(payload);
|
||||||
} catch {
|
} catch (err) {
|
||||||
return null;
|
console.log(`[DECOMPRESS ERROR] flags=${flags}, payloadSize=${payload.length}, error=${err.message}`);
|
||||||
|
console.log(`[DECOMPRESS ERROR] First 50 bytes (hex):`, payload.slice(0, 50).toString('hex'));
|
||||||
|
console.log(`[DECOMPRESS ERROR] First 50 bytes (utf8):`, payload.slice(0, 50).toString('utf8').replace(/[^\x20-\x7E]/g, '.'));
|
||||||
|
// Try to use payload as-is if decompression fails
|
||||||
|
return payload;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return payload;
|
return payload;
|
||||||
|
|
@ -147,8 +165,10 @@ export class CursorExecutor extends BaseExecutor {
|
||||||
}
|
}
|
||||||
|
|
||||||
transformRequest(model, body, stream, credentials) {
|
transformRequest(model, body, stream, credentials) {
|
||||||
const messages = body.messages || [];
|
// Call translator to convert OpenAI format to Cursor format
|
||||||
const tools = body.tools || [];
|
const translatedBody = buildCursorRequest(model, body, stream, credentials);
|
||||||
|
const messages = translatedBody.messages || [];
|
||||||
|
const tools = translatedBody.tools || body.tools || [];
|
||||||
const reasoningEffort = body.reasoning_effort || null;
|
const reasoningEffort = body.reasoning_effort || null;
|
||||||
return generateCursorBody(messages, model, tools, reasoningEffort);
|
return generateCursorBody(messages, model, tools, reasoningEffort);
|
||||||
}
|
}
|
||||||
|
|
@ -243,8 +263,8 @@ export class CursorExecutor extends BaseExecutor {
|
||||||
}
|
}
|
||||||
|
|
||||||
const transformedResponse = stream !== false
|
const transformedResponse = stream !== false
|
||||||
? this.transformProtobufToSSE(response.body, model)
|
? this.transformProtobufToSSE(response.body, model, body)
|
||||||
: this.transformProtobufToJSON(response.body, model);
|
: this.transformProtobufToJSON(response.body, model, body);
|
||||||
|
|
||||||
return { response: transformedResponse, url, headers, transformedBody: body };
|
return { response: transformedResponse, url, headers, transformedBody: body };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -262,108 +282,43 @@ export class CursorExecutor extends BaseExecutor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
transformProtobufToJSON(buffer, model) {
|
transformProtobufToJSON(buffer, model, body) {
|
||||||
const responseId = `chatcmpl-cursor-${Date.now()}`;
|
const responseId = `chatcmpl-cursor-${Date.now()}`;
|
||||||
const created = Math.floor(Date.now() / 1000);
|
const created = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
let totalContent = "";
|
let totalContent = "";
|
||||||
const toolCalls = [];
|
const toolCalls = [];
|
||||||
|
const toolCallsMap = new Map(); // Track streaming tool calls by ID
|
||||||
|
let frameCount = 0;
|
||||||
|
|
||||||
|
console.log(`[CURSOR BUFFER] Total length: ${buffer.length} bytes`);
|
||||||
|
|
||||||
while (offset < buffer.length) {
|
while (offset < buffer.length) {
|
||||||
if (offset + 5 > buffer.length) break;
|
if (offset + 5 > buffer.length) {
|
||||||
|
console.log(`[CURSOR BUFFER] Reached end, offset=${offset}, remaining=${buffer.length - offset}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
const flags = buffer[offset];
|
const flags = buffer[offset];
|
||||||
const length = buffer.readUInt32BE(offset + 1);
|
const length = buffer.readUInt32BE(offset + 1);
|
||||||
|
|
||||||
if (offset + 5 + length > buffer.length) break;
|
console.log(`[CURSOR BUFFER] Frame ${frameCount + 1}: flags=0x${flags.toString(16).padStart(2, '0')}, length=${length}`);
|
||||||
|
|
||||||
|
if (offset + 5 + length > buffer.length) {
|
||||||
|
console.log(`[CURSOR BUFFER] Incomplete frame, offset=${offset}, length=${length}, buffer.length=${buffer.length}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
let payload = buffer.slice(offset + 5, offset + 5 + length);
|
let payload = buffer.slice(offset + 5, offset + 5 + length);
|
||||||
offset += 5 + length;
|
offset += 5 + length;
|
||||||
|
frameCount++;
|
||||||
|
|
||||||
payload = decompressPayload(payload, flags);
|
payload = decompressPayload(payload, flags);
|
||||||
if (!payload) continue;
|
if (!payload) {
|
||||||
|
console.log(`[CURSOR BUFFER] Frame ${frameCount}: decompression failed, skipping`);
|
||||||
try {
|
continue;
|
||||||
const text = payload.toString("utf-8");
|
}
|
||||||
if (text.startsWith("{") && text.includes('"error"')) {
|
|
||||||
return createErrorResponse(JSON.parse(text));
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
const result = extractTextFromResponse(new Uint8Array(payload));
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
error: {
|
|
||||||
message: result.error,
|
|
||||||
type: "rate_limit_error",
|
|
||||||
code: "rate_limited"
|
|
||||||
}
|
|
||||||
}), {
|
|
||||||
status: 429,
|
|
||||||
headers: { "Content-Type": "application/json" }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.toolCall) toolCalls.push(result.toolCall);
|
|
||||||
if (result.text) totalContent += result.text;
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = {
|
|
||||||
role: "assistant",
|
|
||||||
content: totalContent || null
|
|
||||||
};
|
|
||||||
|
|
||||||
if (toolCalls.length > 0) {
|
|
||||||
message.tool_calls = toolCalls;
|
|
||||||
}
|
|
||||||
|
|
||||||
const completion = {
|
|
||||||
id: responseId,
|
|
||||||
object: "chat.completion",
|
|
||||||
created,
|
|
||||||
model,
|
|
||||||
choices: [{
|
|
||||||
index: 0,
|
|
||||||
message,
|
|
||||||
finish_reason: toolCalls.length > 0 ? "tool_calls" : "stop"
|
|
||||||
}],
|
|
||||||
usage: {
|
|
||||||
prompt_tokens: 10,
|
|
||||||
completion_tokens: Math.max(1, Math.floor(totalContent.length / 4)),
|
|
||||||
total_tokens: 10 + Math.max(1, Math.floor(totalContent.length / 4))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return new Response(JSON.stringify(completion), {
|
|
||||||
status: 200,
|
|
||||||
headers: { "Content-Type": "application/json" }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
transformProtobufToSSE(buffer, model) {
|
|
||||||
const responseId = `chatcmpl-cursor-${Date.now()}`;
|
|
||||||
const created = Math.floor(Date.now() / 1000);
|
|
||||||
|
|
||||||
const chunks = [];
|
|
||||||
let offset = 0;
|
|
||||||
let totalContent = "";
|
|
||||||
const toolCalls = [];
|
|
||||||
|
|
||||||
while (offset < buffer.length) {
|
|
||||||
if (offset + 5 > buffer.length) break;
|
|
||||||
|
|
||||||
const flags = buffer[offset];
|
|
||||||
const length = buffer.readUInt32BE(offset + 1);
|
|
||||||
|
|
||||||
if (offset + 5 + length > buffer.length) break;
|
|
||||||
|
|
||||||
let payload = buffer.slice(offset + 5, offset + 5 + length);
|
|
||||||
offset += 5 + length;
|
|
||||||
|
|
||||||
payload = decompressPayload(payload, flags);
|
|
||||||
if (!payload) continue;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const text = payload.toString("utf-8");
|
const text = payload.toString("utf-8");
|
||||||
|
|
@ -373,6 +328,7 @@ export class CursorExecutor extends BaseExecutor {
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
const result = extractTextFromResponse(new Uint8Array(payload));
|
const result = extractTextFromResponse(new Uint8Array(payload));
|
||||||
|
console.log(`[CURSOR DECODED] Frame ${frameCount}:`, result);
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
|
|
@ -388,7 +344,149 @@ export class CursorExecutor extends BaseExecutor {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.toolCall) {
|
if (result.toolCall) {
|
||||||
toolCalls.push(result.toolCall);
|
const tc = result.toolCall;
|
||||||
|
|
||||||
|
if (toolCallsMap.has(tc.id)) {
|
||||||
|
// Accumulate arguments for existing tool call
|
||||||
|
const existing = toolCallsMap.get(tc.id);
|
||||||
|
existing.function.arguments += tc.function.arguments;
|
||||||
|
existing.isLast = tc.isLast;
|
||||||
|
} else {
|
||||||
|
// New tool call
|
||||||
|
toolCallsMap.set(tc.id, { ...tc });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push to final array when isLast is true
|
||||||
|
if (tc.isLast) {
|
||||||
|
const finalToolCall = toolCallsMap.get(tc.id);
|
||||||
|
toolCalls.push({
|
||||||
|
id: finalToolCall.id,
|
||||||
|
type: finalToolCall.type,
|
||||||
|
function: {
|
||||||
|
name: finalToolCall.function.name,
|
||||||
|
arguments: finalToolCall.function.arguments
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.text) totalContent += result.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[CURSOR BUFFER] Parsed ${frameCount} frames, toolCallsMap size: ${toolCallsMap.size}, finalized toolCalls: ${toolCalls.length}`);
|
||||||
|
|
||||||
|
// Finalize all remaining tool calls in map (in case stream ended without isLast=true)
|
||||||
|
for (const [id, tc] of toolCallsMap.entries()) {
|
||||||
|
// Check if already in final array
|
||||||
|
if (!toolCalls.find(t => t.id === id)) {
|
||||||
|
console.log(`[CURSOR BUFFER] Finalizing incomplete tool call: ${id}, isLast=${tc.isLast}`);
|
||||||
|
toolCalls.push({
|
||||||
|
id: tc.id,
|
||||||
|
type: tc.type,
|
||||||
|
function: {
|
||||||
|
name: tc.function.name,
|
||||||
|
arguments: tc.function.arguments
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[CURSOR BUFFER] Final toolCalls count: ${toolCalls.length}`);
|
||||||
|
|
||||||
|
const message = {
|
||||||
|
role: "assistant",
|
||||||
|
content: totalContent || null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (toolCalls.length > 0) {
|
||||||
|
message.tool_calls = toolCalls;
|
||||||
|
}
|
||||||
|
|
||||||
|
const usage = estimateUsage(body, totalContent.length, FORMATS.OPENAI);
|
||||||
|
|
||||||
|
const completion = {
|
||||||
|
id: responseId,
|
||||||
|
object: "chat.completion",
|
||||||
|
created,
|
||||||
|
model,
|
||||||
|
choices: [{
|
||||||
|
index: 0,
|
||||||
|
message,
|
||||||
|
finish_reason: toolCalls.length > 0 ? "tool_calls" : "stop"
|
||||||
|
}],
|
||||||
|
usage
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(completion), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
transformProtobufToSSE(buffer, model, body) {
|
||||||
|
const responseId = `chatcmpl-cursor-${Date.now()}`;
|
||||||
|
const created = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
const chunks = [];
|
||||||
|
let offset = 0;
|
||||||
|
let totalContent = "";
|
||||||
|
const toolCalls = [];
|
||||||
|
const toolCallsMap = new Map(); // Track streaming tool calls by ID
|
||||||
|
let frameCount = 0;
|
||||||
|
|
||||||
|
console.log(`[CURSOR BUFFER SSE] Total length: ${buffer.length} bytes`);
|
||||||
|
|
||||||
|
while (offset < buffer.length) {
|
||||||
|
if (offset + 5 > buffer.length) {
|
||||||
|
console.log(`[CURSOR BUFFER SSE] Reached end, offset=${offset}, remaining=${buffer.length - offset}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flags = buffer[offset];
|
||||||
|
const length = buffer.readUInt32BE(offset + 1);
|
||||||
|
|
||||||
|
console.log(`[CURSOR BUFFER SSE] Frame ${frameCount + 1}: flags=0x${flags.toString(16).padStart(2, '0')}, length=${length}`);
|
||||||
|
|
||||||
|
if (offset + 5 + length > buffer.length) {
|
||||||
|
console.log(`[CURSOR BUFFER SSE] Incomplete frame, offset=${offset}, length=${length}, buffer.length=${buffer.length}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload = buffer.slice(offset + 5, offset + 5 + length);
|
||||||
|
offset += 5 + length;
|
||||||
|
frameCount++;
|
||||||
|
|
||||||
|
payload = decompressPayload(payload, flags);
|
||||||
|
if (!payload) {
|
||||||
|
console.log(`[CURSOR BUFFER SSE] Frame ${frameCount}: decompression failed, skipping`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = payload.toString("utf-8");
|
||||||
|
if (text.startsWith("{") && text.includes('"error"')) {
|
||||||
|
return createErrorResponse(JSON.parse(text));
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const result = extractTextFromResponse(new Uint8Array(payload));
|
||||||
|
console.log(`[CURSOR DECODED SSE] Frame ${frameCount}:`, result);
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: {
|
||||||
|
message: result.error,
|
||||||
|
type: "rate_limit_error",
|
||||||
|
code: "rate_limited"
|
||||||
|
}
|
||||||
|
}), {
|
||||||
|
status: 429,
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.toolCall) {
|
||||||
|
const tc = result.toolCall;
|
||||||
|
|
||||||
if (chunks.length === 0) {
|
if (chunks.length === 0) {
|
||||||
chunks.push(`data: ${JSON.stringify({
|
chunks.push(`data: ${JSON.stringify({
|
||||||
|
|
@ -404,17 +502,66 @@ export class CursorExecutor extends BaseExecutor {
|
||||||
})}\n\n`);
|
})}\n\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
chunks.push(`data: ${JSON.stringify({
|
if (toolCallsMap.has(tc.id)) {
|
||||||
id: responseId,
|
// Accumulate arguments for existing tool call
|
||||||
object: "chat.completion.chunk",
|
const existing = toolCallsMap.get(tc.id);
|
||||||
created,
|
const oldArgsLen = existing.function.arguments.length;
|
||||||
model,
|
existing.function.arguments += tc.function.arguments;
|
||||||
choices: [{
|
existing.isLast = tc.isLast;
|
||||||
index: 0,
|
|
||||||
delta: { tool_calls: [{ index: toolCalls.length - 1, ...result.toolCall }] },
|
// Stream the delta arguments
|
||||||
finish_reason: null
|
if (tc.function.arguments) {
|
||||||
}]
|
chunks.push(`data: ${JSON.stringify({
|
||||||
})}\n\n`);
|
id: responseId,
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
created,
|
||||||
|
model,
|
||||||
|
choices: [{
|
||||||
|
index: 0,
|
||||||
|
delta: {
|
||||||
|
tool_calls: [{
|
||||||
|
index: existing.index,
|
||||||
|
id: tc.id,
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: tc.function.name,
|
||||||
|
arguments: tc.function.arguments
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
finish_reason: null
|
||||||
|
}]
|
||||||
|
})}\n\n`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// New tool call - assign index and add to map
|
||||||
|
const toolCallIndex = toolCalls.length;
|
||||||
|
toolCalls.push({ ...tc, index: toolCallIndex });
|
||||||
|
toolCallsMap.set(tc.id, { ...tc, index: toolCallIndex });
|
||||||
|
|
||||||
|
// Stream initial tool call with name
|
||||||
|
chunks.push(`data: ${JSON.stringify({
|
||||||
|
id: responseId,
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
created,
|
||||||
|
model,
|
||||||
|
choices: [{
|
||||||
|
index: 0,
|
||||||
|
delta: {
|
||||||
|
tool_calls: [{
|
||||||
|
index: toolCallIndex,
|
||||||
|
id: tc.id,
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: tc.function.name,
|
||||||
|
arguments: tc.function.arguments
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
finish_reason: null
|
||||||
|
}]
|
||||||
|
})}\n\n`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.text) {
|
if (result.text) {
|
||||||
|
|
@ -435,6 +582,8 @@ export class CursorExecutor extends BaseExecutor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`[CURSOR BUFFER SSE] Parsed ${frameCount} frames, toolCallsMap size: ${toolCallsMap.size}, toolCalls array: ${toolCalls.length}`);
|
||||||
|
|
||||||
if (chunks.length === 0 && toolCalls.length === 0) {
|
if (chunks.length === 0 && toolCalls.length === 0) {
|
||||||
chunks.push(`data: ${JSON.stringify({
|
chunks.push(`data: ${JSON.stringify({
|
||||||
id: responseId,
|
id: responseId,
|
||||||
|
|
@ -449,6 +598,8 @@ export class CursorExecutor extends BaseExecutor {
|
||||||
})}\n\n`);
|
})}\n\n`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const usage = estimateUsage(body, totalContent.length, FORMATS.OPENAI);
|
||||||
|
|
||||||
chunks.push(`data: ${JSON.stringify({
|
chunks.push(`data: ${JSON.stringify({
|
||||||
id: responseId,
|
id: responseId,
|
||||||
object: "chat.completion.chunk",
|
object: "chat.completion.chunk",
|
||||||
|
|
@ -459,11 +610,7 @@ export class CursorExecutor extends BaseExecutor {
|
||||||
delta: {},
|
delta: {},
|
||||||
finish_reason: toolCalls.length > 0 ? "tool_calls" : "stop"
|
finish_reason: toolCalls.length > 0 ? "tool_calls" : "stop"
|
||||||
}],
|
}],
|
||||||
usage: {
|
usage
|
||||||
prompt_tokens: 0,
|
|
||||||
completion_tokens: Math.max(1, Math.floor(totalContent.length / 4)),
|
|
||||||
total_tokens: Math.max(1, Math.floor(totalContent.length / 4))
|
|
||||||
}
|
|
||||||
})}\n\n`);
|
})}\n\n`);
|
||||||
chunks.push("data: [DONE]\n\n");
|
chunks.push("data: [DONE]\n\n");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,13 @@ export class GithubExecutor extends BaseExecutor {
|
||||||
async refreshCopilotToken(githubAccessToken, log) {
|
async refreshCopilotToken(githubAccessToken, log) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("https://api.github.com/copilot_internal/v2/token", {
|
const response = await fetch("https://api.github.com/copilot_internal/v2/token", {
|
||||||
headers: { "Authorization": `Bearer ${githubAccessToken}`, "User-Agent": "GitHub-Copilot/1.0", "Accept": "*/*" }
|
headers: {
|
||||||
|
"Authorization": `token ${githubAccessToken}`,
|
||||||
|
"User-Agent": "GithubCopilot/1.0",
|
||||||
|
"Editor-Version": "vscode/1.100.0",
|
||||||
|
"Editor-Plugin-Version": "copilot/1.300.0",
|
||||||
|
"Accept": "application/json"
|
||||||
|
}
|
||||||
});
|
});
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
@ -87,8 +93,18 @@ export class GithubExecutor extends BaseExecutor {
|
||||||
}
|
}
|
||||||
|
|
||||||
needsRefresh(credentials) {
|
needsRefresh(credentials) {
|
||||||
|
// Always refresh if no copilotToken
|
||||||
|
if (!credentials.copilotToken) return true;
|
||||||
|
|
||||||
if (credentials.copilotTokenExpiresAt) {
|
if (credentials.copilotTokenExpiresAt) {
|
||||||
if (new Date(credentials.copilotTokenExpiresAt).getTime() - Date.now() < 5 * 60 * 1000) return true;
|
// Handle both Unix timestamp (seconds) and ISO string
|
||||||
|
let expiresAtMs = credentials.copilotTokenExpiresAt;
|
||||||
|
if (typeof expiresAtMs === "number" && expiresAtMs < 1e12) {
|
||||||
|
expiresAtMs = expiresAtMs * 1000; // Convert seconds to ms
|
||||||
|
} else if (typeof expiresAtMs === "string") {
|
||||||
|
expiresAtMs = new Date(expiresAtMs).getTime();
|
||||||
|
}
|
||||||
|
if (expiresAtMs - Date.now() < 5 * 60 * 1000) return true;
|
||||||
}
|
}
|
||||||
return super.needsRefresh(credentials);
|
return super.needsRefresh(credentials);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,9 @@ export {
|
||||||
export { handleChatCore, isTokenExpiringSoon } from "./handlers/chatCore.js";
|
export { handleChatCore, isTokenExpiringSoon } from "./handlers/chatCore.js";
|
||||||
export { createStreamController, pipeWithDisconnect, createDisconnectAwareStream } from "./utils/streamHandler.js";
|
export { createStreamController, pipeWithDisconnect, createDisconnectAwareStream } from "./utils/streamHandler.js";
|
||||||
|
|
||||||
|
// Executors
|
||||||
|
export { getExecutor, hasSpecializedExecutor } from "./executors/index.js";
|
||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
export { errorResponse, formatProviderError } from "./utils/error.js";
|
export { errorResponse, formatProviderError } from "./utils/error.js";
|
||||||
export {
|
export {
|
||||||
|
|
|
||||||
|
|
@ -424,9 +424,11 @@ export async function refreshCopilotToken(githubAccessToken, log) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("https://api.github.com/copilot_internal/v2/token", {
|
const response = await fetch("https://api.github.com/copilot_internal/v2/token", {
|
||||||
headers: {
|
headers: {
|
||||||
"Authorization": `Bearer ${githubAccessToken}`,
|
"Authorization": `token ${githubAccessToken}`,
|
||||||
"User-Agent": "GitHub-Copilot/1.0",
|
"User-Agent": "GithubCopilot/1.0",
|
||||||
"Accept": "*/*"
|
"Editor-Version": "vscode/1.100.0",
|
||||||
|
"Editor-Plugin-Version": "copilot/1.300.0",
|
||||||
|
"Accept": "application/json"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,15 +61,23 @@ export async function getUsageForProvider(connection) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GitHub Copilot Usage
|
* GitHub Copilot Usage
|
||||||
|
* Uses GitHub accessToken (not copilotToken) to call copilot_internal/user API
|
||||||
*/
|
*/
|
||||||
async function getGitHubUsage(accessToken, providerSpecificData) {
|
async function getGitHubUsage(accessToken, providerSpecificData) {
|
||||||
try {
|
try {
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error("No GitHub access token available. Please re-authorize the connection.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// copilot_internal/user API requires GitHub OAuth token, not copilotToken
|
||||||
const response = await fetch("https://api.github.com/copilot_internal/user", {
|
const response = await fetch("https://api.github.com/copilot_internal/user", {
|
||||||
headers: {
|
headers: {
|
||||||
"Authorization": `Bearer ${accessToken}`,
|
"Authorization": `token ${accessToken}`,
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
"X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion,
|
"X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion,
|
||||||
"User-Agent": GITHUB_CONFIG.userAgent,
|
"User-Agent": GITHUB_CONFIG.userAgent,
|
||||||
|
"Editor-Version": "vscode/1.100.0",
|
||||||
|
"Editor-Plugin-Version": "copilot-chat/0.26.7",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -191,20 +199,48 @@ async function getAntigravityUsage(accessToken, providerSpecificData) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const quotas = {};
|
const quotas = {};
|
||||||
|
|
||||||
// Parse model quotas
|
// Parse model quotas (inspired by vscode-antigravity-cockpit)
|
||||||
if (data.models) {
|
if (data.models) {
|
||||||
for (const [name, info] of Object.entries(data.models)) {
|
// Filter only recommended/important models (must match PROVIDER_MODELS ag ids)
|
||||||
// Only include gemini and claude models
|
const importantModels = [
|
||||||
if (!name.includes("gemini") && !name.includes("claude")) continue;
|
'claude-opus-4-5-thinking',
|
||||||
|
'claude-opus-4-5',
|
||||||
if (info.quotaInfo) {
|
'claude-sonnet-4-5-thinking',
|
||||||
const percentage = (info.quotaInfo.remainingFraction || 0) * 100;
|
'claude-sonnet-4-5',
|
||||||
quotas[name] = {
|
'gemini-3-pro-high',
|
||||||
remaining: percentage,
|
'gemini-3-pro-low',
|
||||||
resetTime: info.quotaInfo.resetTime || "",
|
'gemini-3-flash',
|
||||||
unlimited: false,
|
'gemini-2.5-flash',
|
||||||
};
|
];
|
||||||
|
|
||||||
|
for (const [modelKey, info] of Object.entries(data.models)) {
|
||||||
|
// Skip models without quota info
|
||||||
|
if (!info.quotaInfo) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip internal models and non-important models
|
||||||
|
if (info.isInternal || !importantModels.includes(modelKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingFraction = info.quotaInfo.remainingFraction || 0;
|
||||||
|
const remainingPercentage = remainingFraction * 100;
|
||||||
|
|
||||||
|
// Convert percentage to used/total for UI compatibility
|
||||||
|
const total = 1000; // Normalized base
|
||||||
|
const remaining = Math.round(total * remainingFraction);
|
||||||
|
const used = total - remaining;
|
||||||
|
|
||||||
|
// Use modelKey as key (matches PROVIDER_MODELS id)
|
||||||
|
quotas[modelKey] = {
|
||||||
|
used,
|
||||||
|
total,
|
||||||
|
resetAt: info.quotaInfo.resetTime || null,
|
||||||
|
remainingPercentage,
|
||||||
|
unlimited: false,
|
||||||
|
displayName: info.displayName || modelKey,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,53 @@ export function filterToOpenAIFormat(body) {
|
||||||
delete body.tools;
|
delete body.tools;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize tools to OpenAI format (from Claude, Gemini, etc.)
|
||||||
|
if (body.tools && Array.isArray(body.tools) && body.tools.length > 0) {
|
||||||
|
body.tools = body.tools.map(tool => {
|
||||||
|
// Already OpenAI format
|
||||||
|
if (tool.type === "function" && tool.function) return tool;
|
||||||
|
|
||||||
|
// Claude format: {name, description, input_schema}
|
||||||
|
if (tool.name && (tool.input_schema || tool.description)) {
|
||||||
|
return {
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description || "",
|
||||||
|
parameters: tool.input_schema || { type: "object", properties: {} }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gemini format: {functionDeclarations: [{name, description, parameters}]}
|
||||||
|
if (tool.functionDeclarations && Array.isArray(tool.functionDeclarations)) {
|
||||||
|
return tool.functionDeclarations.map(fn => ({
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: fn.name,
|
||||||
|
description: fn.description || "",
|
||||||
|
parameters: fn.parameters || { type: "object", properties: {} }
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return tool;
|
||||||
|
}).flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize tool_choice to OpenAI format
|
||||||
|
if (body.tool_choice && typeof body.tool_choice === "object") {
|
||||||
|
const choice = body.tool_choice;
|
||||||
|
// Claude format: {type: "auto|any|tool", name?: "..."}
|
||||||
|
if (choice.type === "auto") {
|
||||||
|
body.tool_choice = "auto";
|
||||||
|
} else if (choice.type === "any") {
|
||||||
|
body.tool_choice = "required";
|
||||||
|
} else if (choice.type === "tool" && choice.name) {
|
||||||
|
body.tool_choice = { type: "function", function: { name: choice.name } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,11 +71,6 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1.5: Filter to clean OpenAI format (only when target is OpenAI)
|
|
||||||
if (targetFormat === FORMATS.OPENAI) {
|
|
||||||
result = filterToOpenAIFormat(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: openai -> target (if target is not openai)
|
// Step 2: openai -> target (if target is not openai)
|
||||||
if (targetFormat !== FORMATS.OPENAI) {
|
if (targetFormat !== FORMATS.OPENAI) {
|
||||||
const fromOpenAI = requestRegistry.get(`${FORMATS.OPENAI}:${targetFormat}`);
|
const fromOpenAI = requestRegistry.get(`${FORMATS.OPENAI}:${targetFormat}`);
|
||||||
|
|
@ -85,6 +80,12 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Always normalize to clean OpenAI format when target is OpenAI
|
||||||
|
// This handles hybrid requests (e.g., OpenAI messages + Claude tools)
|
||||||
|
if (targetFormat === FORMATS.OPENAI) {
|
||||||
|
result = filterToOpenAIFormat(result);
|
||||||
|
}
|
||||||
|
|
||||||
// Final step: prepare request for Claude format endpoints
|
// Final step: prepare request for Claude format endpoints
|
||||||
if (targetFormat === FORMATS.CLAUDE) {
|
if (targetFormat === FORMATS.CLAUDE) {
|
||||||
result = prepareClaudeRequest(result, provider);
|
result = prepareClaudeRequest(result, provider);
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,18 @@ import { register } from "../index.js";
|
||||||
import { FORMATS } from "../formats.js";
|
import { FORMATS } from "../formats.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert OpenAI messages to Cursor simple format
|
* Convert OpenAI messages to Cursor format with native tool_results support
|
||||||
* - system → user with [System Instructions] prefix
|
* - system → user with [System Instructions] prefix
|
||||||
* - tool → user with [Tool Result: name] prefix
|
* - tool → accumulate into tool_results array for next user/assistant message
|
||||||
* - assistant with tool_calls → append [Calling tool: name with args: {...}] to content
|
* - assistant with tool_calls → keep tool_calls structure (Cursor supports it natively)
|
||||||
*/
|
*/
|
||||||
function convertMessages(messages) {
|
function convertMessages(messages) {
|
||||||
const result = [];
|
const result = [];
|
||||||
|
let pendingToolResults = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < messages.length; i++) {
|
||||||
|
const msg = messages[i];
|
||||||
|
|
||||||
for (const msg of messages) {
|
|
||||||
if (msg.role === "system") {
|
if (msg.role === "system") {
|
||||||
result.push({
|
result.push({
|
||||||
role: "user",
|
role: "user",
|
||||||
|
|
@ -36,9 +39,14 @@ function convertMessages(messages) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolName = msg.name || "tool";
|
const toolName = msg.name || "tool";
|
||||||
result.push({
|
const toolCallId = msg.tool_call_id || "";
|
||||||
role: "user",
|
|
||||||
content: `[Tool Result: ${toolName}]\n${toolContent}`
|
// Accumulate tool result
|
||||||
|
pendingToolResults.push({
|
||||||
|
tool_call_id: toolCallId,
|
||||||
|
name: toolName,
|
||||||
|
index: pendingToolResults.length,
|
||||||
|
raw_args: toolContent
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -56,23 +64,34 @@ function convertMessages(messages) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep tool_calls structure for assistant messages
|
||||||
if (msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0) {
|
if (msg.role === "assistant" && msg.tool_calls && msg.tool_calls.length > 0) {
|
||||||
|
const assistantMsg = { role: "assistant" };
|
||||||
if (content) {
|
if (content) {
|
||||||
result.push({ role: "assistant", content });
|
assistantMsg.content = content;
|
||||||
|
}
|
||||||
|
assistantMsg.tool_calls = msg.tool_calls;
|
||||||
|
|
||||||
|
// Attach pending tool results to assistant message with tool_calls
|
||||||
|
if (pendingToolResults.length > 0) {
|
||||||
|
assistantMsg.tool_results = pendingToolResults;
|
||||||
|
pendingToolResults = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolCallsText = msg.tool_calls.map(tc => {
|
result.push(assistantMsg);
|
||||||
const funcName = tc.function?.name || "unknown";
|
} else if (content || pendingToolResults.length > 0) {
|
||||||
const funcArgs = tc.function?.arguments || "{}";
|
const msgObj = {
|
||||||
return `[Calling tool: ${funcName} with args: ${funcArgs}]`;
|
role: msg.role,
|
||||||
}).join("\n");
|
content: content || ""
|
||||||
|
};
|
||||||
|
|
||||||
result.push({
|
// Attach pending tool results to this message
|
||||||
role: "assistant",
|
if (pendingToolResults.length > 0) {
|
||||||
content: toolCallsText
|
msgObj.tool_results = pendingToolResults;
|
||||||
});
|
pendingToolResults = [];
|
||||||
} else if (content) {
|
}
|
||||||
result.push({ role: msg.role, content });
|
|
||||||
|
result.push(msgObj);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,10 +53,18 @@ const FIELD = {
|
||||||
MSG_CONTENT: 1,
|
MSG_CONTENT: 1,
|
||||||
MSG_ROLE: 2,
|
MSG_ROLE: 2,
|
||||||
MSG_ID: 13,
|
MSG_ID: 13,
|
||||||
|
MSG_TOOL_RESULTS: 18,
|
||||||
MSG_IS_AGENTIC: 29,
|
MSG_IS_AGENTIC: 29,
|
||||||
MSG_UNIFIED_MODE: 47,
|
MSG_UNIFIED_MODE: 47,
|
||||||
MSG_SUPPORTED_TOOLS: 51,
|
MSG_SUPPORTED_TOOLS: 51,
|
||||||
|
|
||||||
|
// ConversationMessage.ToolResult
|
||||||
|
TOOL_RESULT_CALL_ID: 1,
|
||||||
|
TOOL_RESULT_NAME: 2,
|
||||||
|
TOOL_RESULT_INDEX: 3,
|
||||||
|
TOOL_RESULT_RAW_ARGS: 5,
|
||||||
|
TOOL_RESULT_RESULT: 8,
|
||||||
|
|
||||||
// Model
|
// Model
|
||||||
MODEL_NAME: 1,
|
MODEL_NAME: 1,
|
||||||
MODEL_EMPTY: 4,
|
MODEL_EMPTY: 4,
|
||||||
|
|
@ -101,6 +109,7 @@ const FIELD = {
|
||||||
TOOL_ID: 3,
|
TOOL_ID: 3,
|
||||||
TOOL_NAME: 9,
|
TOOL_NAME: 9,
|
||||||
TOOL_RAW_ARGS: 10,
|
TOOL_RAW_ARGS: 10,
|
||||||
|
TOOL_IS_LAST: 11,
|
||||||
TOOL_MCP_PARAMS: 27,
|
TOOL_MCP_PARAMS: 27,
|
||||||
|
|
||||||
// MCPParams
|
// MCPParams
|
||||||
|
|
@ -166,11 +175,28 @@ function concatArrays(...arrays) {
|
||||||
|
|
||||||
// ==================== MESSAGE ENCODING ====================
|
// ==================== MESSAGE ENCODING ====================
|
||||||
|
|
||||||
export function encodeMessage(content, role, messageId, chatModeEnum = null, isLast = false, hasTools = false) {
|
export function encodeToolResult(toolResult) {
|
||||||
|
const toolCallId = toolResult.tool_call_id || "";
|
||||||
|
const toolName = toolResult.name || "";
|
||||||
|
const toolIndex = toolResult.index || 0;
|
||||||
|
const rawArgs = toolResult.raw_args || "{}";
|
||||||
|
|
||||||
|
return concatArrays(
|
||||||
|
encodeField(FIELD.TOOL_RESULT_CALL_ID, WIRE_TYPE.LEN, toolCallId),
|
||||||
|
encodeField(FIELD.TOOL_RESULT_NAME, WIRE_TYPE.LEN, toolName),
|
||||||
|
encodeField(FIELD.TOOL_RESULT_INDEX, WIRE_TYPE.VARINT, toolIndex),
|
||||||
|
encodeField(FIELD.TOOL_RESULT_RAW_ARGS, WIRE_TYPE.LEN, rawArgs)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeMessage(content, role, messageId, chatModeEnum = null, isLast = false, hasTools = false, toolResults = []) {
|
||||||
return concatArrays(
|
return concatArrays(
|
||||||
encodeField(FIELD.MSG_CONTENT, WIRE_TYPE.LEN, content),
|
encodeField(FIELD.MSG_CONTENT, WIRE_TYPE.LEN, content),
|
||||||
encodeField(FIELD.MSG_ROLE, WIRE_TYPE.VARINT, role),
|
encodeField(FIELD.MSG_ROLE, WIRE_TYPE.VARINT, role),
|
||||||
encodeField(FIELD.MSG_ID, WIRE_TYPE.LEN, messageId),
|
encodeField(FIELD.MSG_ID, WIRE_TYPE.LEN, messageId),
|
||||||
|
...(toolResults.length > 0 ? toolResults.map(tr =>
|
||||||
|
encodeField(FIELD.MSG_TOOL_RESULTS, WIRE_TYPE.LEN, encodeToolResult(tr))
|
||||||
|
) : []),
|
||||||
encodeField(FIELD.MSG_IS_AGENTIC, WIRE_TYPE.VARINT, hasTools ? 1 : 0),
|
encodeField(FIELD.MSG_IS_AGENTIC, WIRE_TYPE.VARINT, hasTools ? 1 : 0),
|
||||||
encodeField(FIELD.MSG_UNIFIED_MODE, WIRE_TYPE.VARINT, hasTools ? UNIFIED_MODE.AGENT : UNIFIED_MODE.CHAT),
|
encodeField(FIELD.MSG_UNIFIED_MODE, WIRE_TYPE.VARINT, hasTools ? UNIFIED_MODE.AGENT : UNIFIED_MODE.CHAT),
|
||||||
...(isLast && hasTools ? [encodeField(FIELD.MSG_SUPPORTED_TOOLS, WIRE_TYPE.LEN, encodeVarint(1))] : [])
|
...(isLast && hasTools ? [encodeField(FIELD.MSG_SUPPORTED_TOOLS, WIRE_TYPE.LEN, encodeVarint(1))] : [])
|
||||||
|
|
@ -254,7 +280,8 @@ export function encodeRequest(messages, modelName, tools = [], reasoningEffort =
|
||||||
role,
|
role,
|
||||||
messageId: msgId,
|
messageId: msgId,
|
||||||
isLast,
|
isLast,
|
||||||
hasTools
|
hasTools,
|
||||||
|
toolResults: msg.tool_results || []
|
||||||
});
|
});
|
||||||
|
|
||||||
messageIds.push({ messageId: msgId, role });
|
messageIds.push({ messageId: msgId, role });
|
||||||
|
|
@ -270,7 +297,7 @@ export function encodeRequest(messages, modelName, tools = [], reasoningEffort =
|
||||||
// Messages
|
// Messages
|
||||||
...formattedMessages.map(fm =>
|
...formattedMessages.map(fm =>
|
||||||
encodeField(FIELD.MESSAGES, WIRE_TYPE.LEN,
|
encodeField(FIELD.MESSAGES, WIRE_TYPE.LEN,
|
||||||
encodeMessage(fm.content, fm.role, fm.messageId, null, fm.isLast, fm.hasTools)
|
encodeMessage(fm.content, fm.role, fm.messageId, null, fm.isLast, fm.hasTools, fm.toolResults)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|
@ -439,6 +466,7 @@ function extractToolCall(toolCallData) {
|
||||||
let toolCallId = "";
|
let toolCallId = "";
|
||||||
let toolName = "";
|
let toolName = "";
|
||||||
let rawArgs = "";
|
let rawArgs = "";
|
||||||
|
let isLast = false;
|
||||||
|
|
||||||
// Extract tool call ID
|
// Extract tool call ID
|
||||||
if (toolCall.has(FIELD.TOOL_ID)) {
|
if (toolCall.has(FIELD.TOOL_ID)) {
|
||||||
|
|
@ -451,6 +479,11 @@ function extractToolCall(toolCallData) {
|
||||||
toolName = new TextDecoder().decode(toolCall.get(FIELD.TOOL_NAME)[0].value);
|
toolName = new TextDecoder().decode(toolCall.get(FIELD.TOOL_NAME)[0].value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract is_last flag
|
||||||
|
if (toolCall.has(FIELD.TOOL_IS_LAST)) {
|
||||||
|
isLast = toolCall.get(FIELD.TOOL_IS_LAST)[0].value !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Extract MCP params - nested real tool info
|
// Extract MCP params - nested real tool info
|
||||||
if (toolCall.has(FIELD.TOOL_MCP_PARAMS)) {
|
if (toolCall.has(FIELD.TOOL_MCP_PARAMS)) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -484,7 +517,8 @@ function extractToolCall(toolCallData) {
|
||||||
function: {
|
function: {
|
||||||
name: toolName,
|
name: toolName,
|
||||||
arguments: rawArgs || "{}"
|
arguments: rawArgs || "{}"
|
||||||
}
|
},
|
||||||
|
isLast
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,70 +2,13 @@ import { translateResponse, initState } from "../translator/index.js";
|
||||||
import { FORMATS } from "../translator/formats.js";
|
import { FORMATS } from "../translator/formats.js";
|
||||||
import { trackPendingRequest, appendRequestLog } from "@/lib/usageDb.js";
|
import { trackPendingRequest, appendRequestLog } from "@/lib/usageDb.js";
|
||||||
import { extractUsage, hasValidUsage, estimateUsage, logUsage, addBufferToUsage, filterUsageForFormat, COLORS } from "./usageTracking.js";
|
import { extractUsage, hasValidUsage, estimateUsage, logUsage, addBufferToUsage, filterUsageForFormat, COLORS } from "./usageTracking.js";
|
||||||
|
import { parseSSELine, hasValuableContent, fixInvalidId, formatSSE } from "./streamHelpers.js";
|
||||||
|
|
||||||
// Re-export COLORS for backward compatibility
|
export { COLORS, formatSSE };
|
||||||
export { COLORS };
|
|
||||||
|
|
||||||
// Singleton TextEncoder/Decoder for performance (reuse across all streams)
|
|
||||||
const sharedDecoder = new TextDecoder();
|
const sharedDecoder = new TextDecoder();
|
||||||
const sharedEncoder = new TextEncoder();
|
const sharedEncoder = new TextEncoder();
|
||||||
|
|
||||||
// Parse SSE data line (optimized - reduce string operations)
|
|
||||||
function parseSSELine(line) {
|
|
||||||
if (!line || line.charCodeAt(0) !== 100) return null; // 'd' = 100
|
|
||||||
|
|
||||||
const data = line.slice(5).trim();
|
|
||||||
if (data === "[DONE]") return { done: true };
|
|
||||||
|
|
||||||
try {
|
|
||||||
return JSON.parse(data);
|
|
||||||
} catch (error) {
|
|
||||||
// Log parse errors for debugging incomplete chunks
|
|
||||||
if (data.length > 0 && data.length < 1000) {
|
|
||||||
console.log(`[WARN] Failed to parse SSE line (${data.length} chars): ${data.substring(0, 100)}...`);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format output as SSE
|
|
||||||
* @param {object} data - Data to format
|
|
||||||
* @param {string} sourceFormat - Target format for client
|
|
||||||
* @returns {string} SSE formatted string
|
|
||||||
*/
|
|
||||||
export function formatSSE(data, sourceFormat) {
|
|
||||||
// Handle null/undefined
|
|
||||||
if (data === null || data === undefined) {
|
|
||||||
return "data: null\n\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data && data.done) return "data: [DONE]\n\n";
|
|
||||||
|
|
||||||
// OpenAI Responses API format: has event field
|
|
||||||
if (data && data.event && data.data) {
|
|
||||||
return `event: ${data.event}\ndata: ${JSON.stringify(data.data)}\n\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Claude format: include event prefix
|
|
||||||
if (sourceFormat === FORMATS.CLAUDE && data && data.type) {
|
|
||||||
// If perf_metrics is null, remove it to avoid serialization issues
|
|
||||||
if (data.usage && typeof data.usage === 'object' && data.usage.perf_metrics === null) {
|
|
||||||
const { perf_metrics, ...usageWithoutPerf } = data.usage;
|
|
||||||
data = { ...data, usage: usageWithoutPerf };
|
|
||||||
}
|
|
||||||
return `event: ${data.type}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If perf_metrics is null, remove it to avoid serialization issues
|
|
||||||
if (data?.usage && typeof data.usage === 'object' && data.usage.perf_metrics === null) {
|
|
||||||
const { perf_metrics, ...usageWithoutPerf } = data.usage;
|
|
||||||
data = { ...data, usage: usageWithoutPerf };
|
|
||||||
}
|
|
||||||
|
|
||||||
return `data: ${JSON.stringify(data)}\n\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream modes
|
* Stream modes
|
||||||
*/
|
*/
|
||||||
|
|
@ -129,37 +72,42 @@ export function createSSEStream(options = {}) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(trimmed.slice(5).trim());
|
const parsed = JSON.parse(trimmed.slice(5).trim());
|
||||||
|
|
||||||
// Track content length for estimation
|
const idFixed = fixInvalidId(parsed);
|
||||||
const content = parsed.choices?.[0]?.delta?.content || parsed.choices?.[0]?.delta?.reasoning_content;
|
|
||||||
|
if (!hasValuableContent(parsed, FORMATS.OPENAI)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delta = parsed.choices?.[0]?.delta;
|
||||||
|
const content = delta?.content || delta?.reasoning_content;
|
||||||
if (content && typeof content === "string") {
|
if (content && typeof content === "string") {
|
||||||
totalContentLength += content.length;
|
totalContentLength += content.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract usage from chunk
|
|
||||||
const extracted = extractUsage(parsed);
|
const extracted = extractUsage(parsed);
|
||||||
if (extracted) {
|
if (extracted) {
|
||||||
usage = extracted; // Keep original usage for logging
|
usage = extracted;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject estimated usage into final chunk (has finish_reason but no valid usage)
|
|
||||||
const isFinishChunk = parsed.choices?.[0]?.finish_reason;
|
const isFinishChunk = parsed.choices?.[0]?.finish_reason;
|
||||||
if (isFinishChunk && !hasValidUsage(parsed.usage)) {
|
if (isFinishChunk && !hasValidUsage(parsed.usage)) {
|
||||||
const estimated = estimateUsage(body, totalContentLength, FORMATS.OPENAI);
|
const estimated = estimateUsage(body, totalContentLength, FORMATS.OPENAI);
|
||||||
parsed.usage = filterUsageForFormat(estimated, FORMATS.OPENAI); // Filter + already has buffer
|
parsed.usage = filterUsageForFormat(estimated, FORMATS.OPENAI);
|
||||||
output = `data: ${JSON.stringify(parsed)}\n`;
|
output = `data: ${JSON.stringify(parsed)}\n`;
|
||||||
usage = estimated;
|
usage = estimated;
|
||||||
injectedUsage = true;
|
injectedUsage = true;
|
||||||
} else if (isFinishChunk && usage) {
|
} else if (isFinishChunk && usage) {
|
||||||
// Add buffer and filter usage for client (but keep original for logging)
|
|
||||||
const buffered = addBufferToUsage(usage);
|
const buffered = addBufferToUsage(usage);
|
||||||
parsed.usage = filterUsageForFormat(buffered, FORMATS.OPENAI);
|
parsed.usage = filterUsageForFormat(buffered, FORMATS.OPENAI);
|
||||||
output = `data: ${JSON.stringify(parsed)}\n`;
|
output = `data: ${JSON.stringify(parsed)}\n`;
|
||||||
injectedUsage = true;
|
injectedUsage = true;
|
||||||
|
} else if (idFixed) {
|
||||||
|
output = `data: ${JSON.stringify(parsed)}\n`;
|
||||||
|
injectedUsage = true;
|
||||||
}
|
}
|
||||||
} catch { }
|
} catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize if not already injected
|
|
||||||
if (!injectedUsage) {
|
if (!injectedUsage) {
|
||||||
if (line.startsWith("data:") && !line.startsWith("data: ")) {
|
if (line.startsWith("data:") && !line.startsWith("data: ")) {
|
||||||
output = "data: " + line.slice(5) + "\n";
|
output = "data: " + line.slice(5) + "\n";
|
||||||
|
|
@ -231,6 +179,11 @@ export function createSSEStream(options = {}) {
|
||||||
|
|
||||||
if (translated?.length > 0) {
|
if (translated?.length > 0) {
|
||||||
for (const item of translated) {
|
for (const item of translated) {
|
||||||
|
// Filter empty chunks
|
||||||
|
if (!hasValuableContent(item, sourceFormat)) {
|
||||||
|
continue; // Skip this empty chunk
|
||||||
|
}
|
||||||
|
|
||||||
// Inject estimated usage if finish chunk has no valid usage
|
// Inject estimated usage if finish chunk has no valid usage
|
||||||
const isFinishChunk = item.type === "message_delta" || item.choices?.[0]?.finish_reason;
|
const isFinishChunk = item.type === "message_delta" || item.choices?.[0]?.finish_reason;
|
||||||
if (state.finishReason && isFinishChunk && !hasValidUsage(item.usage) && totalContentLength > 0) {
|
if (state.finishReason && isFinishChunk && !hasValidUsage(item.usage) && totalContentLength > 0) {
|
||||||
|
|
|
||||||
85
open-sse/utils/streamHelpers.js
Normal file
85
open-sse/utils/streamHelpers.js
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { FORMATS } from "../translator/formats.js";
|
||||||
|
|
||||||
|
// Parse SSE data line
|
||||||
|
export function parseSSELine(line) {
|
||||||
|
if (!line || line.charCodeAt(0) !== 100) return null; // 'd' = 100
|
||||||
|
|
||||||
|
const data = line.slice(5).trim();
|
||||||
|
if (data === "[DONE]") return { done: true };
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(data);
|
||||||
|
} catch (error) {
|
||||||
|
if (data.length > 0 && data.length < 1000) {
|
||||||
|
console.log(`[WARN] Failed to parse SSE line (${data.length} chars): ${data.substring(0, 100)}...`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if chunk has valuable content (not empty)
|
||||||
|
export function hasValuableContent(chunk, format) {
|
||||||
|
// OpenAI format
|
||||||
|
if (format === FORMATS.OPENAI && chunk.choices?.[0]?.delta) {
|
||||||
|
const delta = chunk.choices[0].delta;
|
||||||
|
return delta.content && delta.content !== "" ||
|
||||||
|
delta.reasoning_content && delta.reasoning_content !== "" ||
|
||||||
|
delta.tool_calls && delta.tool_calls.length > 0 ||
|
||||||
|
chunk.choices[0].finish_reason ||
|
||||||
|
delta.role;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claude format
|
||||||
|
if (format === FORMATS.CLAUDE) {
|
||||||
|
const isContentBlockDelta = chunk.type === "content_block_delta";
|
||||||
|
const hasText = chunk.delta?.text && chunk.delta.text !== "";
|
||||||
|
const hasThinking = chunk.delta?.thinking && chunk.delta.thinking !== "";
|
||||||
|
|
||||||
|
if (isContentBlockDelta && !hasText && !hasThinking) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true; // Other formats: keep all chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix invalid id (generic or too short)
|
||||||
|
export function fixInvalidId(parsed) {
|
||||||
|
if (parsed.id && (parsed.id === "chat" || parsed.id === "completion" || parsed.id.length < 8)) {
|
||||||
|
const fallbackId = parsed.extend_fields?.requestId ||
|
||||||
|
parsed.extend_fields?.traceId ||
|
||||||
|
Date.now().toString(36);
|
||||||
|
parsed.id = `chatcmpl-${fallbackId}`;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format output as SSE
|
||||||
|
export function formatSSE(data, sourceFormat) {
|
||||||
|
if (data === null || data === undefined) return "data: null\n\n";
|
||||||
|
if (data && data.done) return "data: [DONE]\n\n";
|
||||||
|
|
||||||
|
// OpenAI Responses API format
|
||||||
|
if (data && data.event && data.data) {
|
||||||
|
return `event: ${data.event}\ndata: ${JSON.stringify(data.data)}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claude format
|
||||||
|
if (sourceFormat === FORMATS.CLAUDE && data && data.type) {
|
||||||
|
if (data.usage && typeof data.usage === 'object' && data.usage.perf_metrics === null) {
|
||||||
|
const { perf_metrics, ...usageWithoutPerf } = data.usage;
|
||||||
|
data = { ...data, usage: usageWithoutPerf };
|
||||||
|
}
|
||||||
|
return `event: ${data.type}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove null perf_metrics
|
||||||
|
if (data?.usage && typeof data.usage === 'object' && data.usage.perf_metrics === null) {
|
||||||
|
const { perf_metrics, ...usageWithoutPerf } = data.usage;
|
||||||
|
data = { ...data, usage: usageWithoutPerf };
|
||||||
|
}
|
||||||
|
|
||||||
|
return `data: ${JSON.stringify(data)}\n\n`;
|
||||||
|
}
|
||||||
|
|
@ -155,7 +155,10 @@ export default function ProviderLimitCard({
|
||||||
{!loading && !error && !message && quotas?.length > 0 && (
|
{!loading && !error && !message && quotas?.length > 0 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{quotas.map((quota, index) => {
|
{quotas.map((quota, index) => {
|
||||||
const percentage = calculatePercentage(quota.used, quota.total);
|
// For Antigravity, use remainingPercentage if available, otherwise calculate
|
||||||
|
const percentage = quota.remainingPercentage !== undefined
|
||||||
|
? Math.round((quota.total - quota.used) / quota.total * 100)
|
||||||
|
: calculatePercentage(quota.used, quota.total);
|
||||||
const unlimited = quota.total === 0 || quota.total === null;
|
const unlimited = quota.total === 0 || quota.total === null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,61 +1,76 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { cn } from "@/shared/utils/cn";
|
import { cn } from "@/shared/utils/cn";
|
||||||
|
import { formatResetTime } from "./utils";
|
||||||
// Helper function to calculate time until reset
|
|
||||||
const getResetTimeText = (resetTime) => {
|
|
||||||
if (!resetTime) return null;
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const reset = new Date(resetTime);
|
|
||||||
const diffMs = reset - now;
|
|
||||||
|
|
||||||
if (diffMs <= 0) return "Reset now";
|
|
||||||
|
|
||||||
const hours = Math.floor(diffMs / (1000 * 60 * 60));
|
|
||||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
|
||||||
|
|
||||||
if (hours > 0) {
|
|
||||||
return `Reset in ${hours}h`;
|
|
||||||
}
|
|
||||||
return `Reset in ${minutes}m`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate color based on remaining percentage
|
// Calculate color based on remaining percentage
|
||||||
const getColorClasses = (percentage) => {
|
const getColorClasses = (remainingPercentage) => {
|
||||||
if (percentage === 0) {
|
if (remainingPercentage === 0) {
|
||||||
return {
|
return {
|
||||||
text: "text-gray-400",
|
text: "text-gray-400",
|
||||||
bg: "bg-gray-400",
|
bg: "bg-gray-400",
|
||||||
bgLight: "bg-gray-400/10"
|
bgLight: "bg-gray-400/10",
|
||||||
|
emoji: "⚫"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const remaining = 100 - percentage;
|
if (remainingPercentage > 70) {
|
||||||
|
|
||||||
if (remaining > 70) {
|
|
||||||
return {
|
return {
|
||||||
text: "text-green-500",
|
text: "text-green-500",
|
||||||
bg: "bg-green-500",
|
bg: "bg-green-500",
|
||||||
bgLight: "bg-green-500/10"
|
bgLight: "bg-green-500/10",
|
||||||
|
emoji: "🟢"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (remaining >= 30) {
|
if (remainingPercentage >= 30) {
|
||||||
return {
|
return {
|
||||||
text: "text-yellow-500",
|
text: "text-yellow-500",
|
||||||
bg: "bg-yellow-500",
|
bg: "bg-yellow-500",
|
||||||
bgLight: "bg-yellow-500/10"
|
bgLight: "bg-yellow-500/10",
|
||||||
|
emoji: "🟡"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text: "text-red-500",
|
text: "text-red-500",
|
||||||
bg: "bg-red-500",
|
bg: "bg-red-500",
|
||||||
bgLight: "bg-red-500/10"
|
bgLight: "bg-red-500/10",
|
||||||
|
emoji: "🔴"
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Format reset time display
|
||||||
|
const formatResetTimeDisplay = (resetTime) => {
|
||||||
|
if (!resetTime) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resetDate = new Date(resetTime);
|
||||||
|
const now = new Date();
|
||||||
|
const isToday = resetDate.toDateString() === now.toDateString();
|
||||||
|
const isTomorrow = resetDate.toDateString() === new Date(now.getTime() + 86400000).toDateString();
|
||||||
|
|
||||||
|
const timeStr = resetDate.toLocaleTimeString(undefined, {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isToday) return `Today, ${timeStr}`;
|
||||||
|
if (isTomorrow) return `Tomorrow, ${timeStr}`;
|
||||||
|
|
||||||
|
return resetDate.toLocaleString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export default function QuotaProgressBar({
|
export default function QuotaProgressBar({
|
||||||
percentage = 0,
|
percentage = 0,
|
||||||
label = "",
|
label = "",
|
||||||
|
|
@ -65,29 +80,24 @@ export default function QuotaProgressBar({
|
||||||
resetTime = null
|
resetTime = null
|
||||||
}) {
|
}) {
|
||||||
const colors = getColorClasses(percentage);
|
const colors = getColorClasses(percentage);
|
||||||
const resetText = getResetTimeText(resetTime);
|
const countdown = formatResetTime(resetTime);
|
||||||
|
const resetDisplay = formatResetTimeDisplay(resetTime);
|
||||||
|
|
||||||
|
// percentage is already remaining percentage (from ProviderLimitCard)
|
||||||
|
const remaining = percentage;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* Label and usage info */}
|
{/* Label and percentage */}
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="font-medium text-text-primary dark:text-white">
|
<span className="font-semibold text-text-primary">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-2 text-text-muted">
|
<div className="flex items-center gap-1.5">
|
||||||
{unlimited ? (
|
<span className="text-xs">{colors.emoji}</span>
|
||||||
<span>Unlimited</span>
|
<span className={cn("font-medium", colors.text)}>
|
||||||
) : (
|
{remaining}%
|
||||||
<span>
|
</span>
|
||||||
{used.toLocaleString()}/{total.toLocaleString()} ({percentage}%)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{resetText && (
|
|
||||||
<>
|
|
||||||
<span>•</span>
|
|
||||||
<span className="text-xs">{resetText}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -96,10 +106,30 @@ export default function QuotaProgressBar({
|
||||||
<div className={cn("h-2 rounded-full overflow-hidden", colors.bgLight)}>
|
<div className={cn("h-2 rounded-full overflow-hidden", colors.bgLight)}>
|
||||||
<div
|
<div
|
||||||
className={cn("h-full transition-all duration-300", colors.bg)}
|
className={cn("h-full transition-all duration-300", colors.bg)}
|
||||||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
style={{ width: `${Math.min(remaining, 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Usage details and countdown */}
|
||||||
|
<div className="flex items-center justify-between text-xs text-text-muted">
|
||||||
|
<span>
|
||||||
|
{used.toLocaleString()} / {total.toLocaleString()} requests
|
||||||
|
</span>
|
||||||
|
{countdown !== "-" && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span>•</span>
|
||||||
|
<span className="font-medium">Reset in {countdown}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reset time display */}
|
||||||
|
{resetDisplay && (
|
||||||
|
<div className="text-xs text-text-muted/70">
|
||||||
|
Reset at {resetDisplay}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { formatResetTime, calculatePercentage } from "./utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format reset time display (Today, 12:00 PM)
|
||||||
|
*/
|
||||||
|
function formatResetTimeDisplay(resetTime) {
|
||||||
|
if (!resetTime) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(resetTime);
|
||||||
|
const now = new Date();
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
|
||||||
|
let dayStr = "";
|
||||||
|
if (date >= today && date < tomorrow) {
|
||||||
|
dayStr = "Today";
|
||||||
|
} else if (date >= tomorrow && date < new Date(tomorrow.getTime() + 24 * 60 * 60 * 1000)) {
|
||||||
|
dayStr = "Tomorrow";
|
||||||
|
} else {
|
||||||
|
dayStr = date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeStr = date.toLocaleTimeString("en-US", {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return `${dayStr}, ${timeStr}`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get color classes based on remaining percentage
|
||||||
|
*/
|
||||||
|
function getColorClasses(remainingPercentage) {
|
||||||
|
if (remainingPercentage === 0) {
|
||||||
|
return {
|
||||||
|
text: "text-text-muted",
|
||||||
|
bg: "bg-bg-muted",
|
||||||
|
bgLight: "bg-bg-muted/20",
|
||||||
|
emoji: "⚫"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remainingPercentage > 70) {
|
||||||
|
return {
|
||||||
|
text: "text-green-600 dark:text-green-400",
|
||||||
|
bg: "bg-green-500",
|
||||||
|
bgLight: "bg-green-500/10",
|
||||||
|
emoji: "🟢"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remainingPercentage >= 30) {
|
||||||
|
return {
|
||||||
|
text: "text-yellow-600 dark:text-yellow-400",
|
||||||
|
bg: "bg-yellow-500",
|
||||||
|
bgLight: "bg-yellow-500/10",
|
||||||
|
emoji: "🟡"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: "text-red-600 dark:text-red-400",
|
||||||
|
bg: "bg-red-500",
|
||||||
|
bgLight: "bg-red-500/10",
|
||||||
|
emoji: "🔴"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quota Table Component - Table-based display for quota data
|
||||||
|
*/
|
||||||
|
export default function QuotaTable({ quotas = [] }) {
|
||||||
|
if (!quotas || quotas.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<tbody>
|
||||||
|
{quotas.map((quota, index) => {
|
||||||
|
const remaining = quota.remainingPercentage !== undefined
|
||||||
|
? Math.round(quota.remainingPercentage)
|
||||||
|
: calculatePercentage(quota.used, quota.total);
|
||||||
|
|
||||||
|
const colors = getColorClasses(remaining);
|
||||||
|
const countdown = formatResetTime(quota.resetAt);
|
||||||
|
const resetDisplay = formatResetTimeDisplay(quota.resetAt);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={index}
|
||||||
|
className="border-b border-black/5 dark:border-white/5 hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors"
|
||||||
|
>
|
||||||
|
{/* Model Name with Status Emoji */}
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs">{colors.emoji}</span>
|
||||||
|
<span className="text-sm font-medium text-text-primary">{quota.name}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Limit (Progress + Numbers) */}
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{/* Progress bar - always show with border for visibility */}
|
||||||
|
<div className={`h-1.5 rounded-full overflow-hidden border ${colors.bgLight} ${
|
||||||
|
remaining === 0 ? 'border-black/10 dark:border-white/10' : 'border-transparent'
|
||||||
|
}`}>
|
||||||
|
<div
|
||||||
|
className={`h-full transition-all duration-300 ${colors.bg}`}
|
||||||
|
style={{ width: `${Math.min(remaining, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Numbers */}
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-text-muted">
|
||||||
|
{quota.used.toLocaleString()} / {quota.total > 0 ? quota.total.toLocaleString() : "∞"}
|
||||||
|
</span>
|
||||||
|
<span className={`font-medium ${colors.text}`}>
|
||||||
|
{remaining}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Reset Time */}
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{countdown !== "-" && (
|
||||||
|
<div className="text-sm text-text-primary font-medium">
|
||||||
|
in {countdown}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{resetDisplay && (
|
||||||
|
<div className="text-xs text-text-muted">
|
||||||
|
{resetDisplay}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
import ProviderLimitCard from "./ProviderLimitCard";
|
import ProviderLimitCard from "./ProviderLimitCard";
|
||||||
|
import QuotaTable from "./QuotaTable";
|
||||||
import { parseQuotaData, calculatePercentage } from "./utils";
|
import { parseQuotaData, calculatePercentage } from "./utils";
|
||||||
import Card from "@/shared/components/Card";
|
import Card from "@/shared/components/Card";
|
||||||
import Button from "@/shared/components/Button";
|
import Button from "@/shared/components/Button";
|
||||||
import { CardSkeleton } from "@/shared/components/Loading";
|
import { CardSkeleton } from "@/shared/components/Loading";
|
||||||
|
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
|
||||||
|
|
||||||
const REFRESH_INTERVAL_MS = 60000; // 60 seconds
|
const REFRESH_INTERVAL_MS = 60000; // 60 seconds
|
||||||
|
|
||||||
|
|
@ -48,8 +51,32 @@ export default function ProviderLimits() {
|
||||||
try {
|
try {
|
||||||
console.log(`[ProviderLimits] Fetching quota for ${provider} (${connectionId})`);
|
console.log(`[ProviderLimits] Fetching quota for ${provider} (${connectionId})`);
|
||||||
const response = await fetch(`/api/usage/${connectionId}`);
|
const response = await fetch(`/api/usage/${connectionId}`);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
const errorMsg = errorData.error || response.statusText;
|
||||||
|
|
||||||
|
// Handle different error types gracefully
|
||||||
|
if (response.status === 404) {
|
||||||
|
// Connection not found - skip silently
|
||||||
|
console.warn(`[ProviderLimits] Connection not found for ${provider}, skipping`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
// Auth error - show message instead of throwing
|
||||||
|
console.warn(`[ProviderLimits] Auth error for ${provider}:`, errorMsg);
|
||||||
|
setQuotaData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[connectionId]: {
|
||||||
|
quotas: [],
|
||||||
|
message: errorMsg,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`HTTP ${response.status}: ${errorMsg}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
@ -97,9 +124,14 @@ export default function ProviderLimits() {
|
||||||
try {
|
try {
|
||||||
const conns = await fetchConnections();
|
const conns = await fetchConnections();
|
||||||
|
|
||||||
// Fetch quota for all connections (filter by provider support in parseQuotaData)
|
// Filter only supported OAuth providers
|
||||||
|
const oauthConnections = conns.filter(
|
||||||
|
(conn) => USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) && conn.authType === "oauth"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch quota for supported OAuth connections only
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
conns.map((conn) => fetchQuota(conn.id, conn.provider))
|
oauthConnections.map((conn) => fetchQuota(conn.id, conn.provider))
|
||||||
);
|
);
|
||||||
|
|
||||||
setLastUpdated(new Date());
|
setLastUpdated(new Date());
|
||||||
|
|
@ -198,13 +230,31 @@ export default function ProviderLimits() {
|
||||||
}, [lastUpdated]);
|
}, [lastUpdated]);
|
||||||
|
|
||||||
// Filter only supported providers
|
// Filter only supported providers
|
||||||
const supportedProviders = ["antigravity", "kiro", "github", "claude"];
|
|
||||||
const filteredConnections = connections.filter((conn) =>
|
const filteredConnections = connections.filter((conn) =>
|
||||||
supportedProviders.includes(conn.provider)
|
USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) && conn.authType === "oauth"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Sort providers: antigravity first, then kiro, then others alphabetically
|
||||||
|
const sortedConnections = [...filteredConnections].sort((a, b) => {
|
||||||
|
const getProviderPriority = (provider) => {
|
||||||
|
if (provider === "antigravity") return 1;
|
||||||
|
if (provider === "kiro") return 2;
|
||||||
|
return 3;
|
||||||
|
};
|
||||||
|
|
||||||
|
const priorityA = getProviderPriority(a.provider);
|
||||||
|
const priorityB = getProviderPriority(b.provider);
|
||||||
|
|
||||||
|
if (priorityA !== priorityB) {
|
||||||
|
return priorityA - priorityB;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same priority: sort alphabetically
|
||||||
|
return a.provider.localeCompare(b.provider);
|
||||||
|
});
|
||||||
|
|
||||||
// Calculate summary stats
|
// Calculate summary stats
|
||||||
const totalProviders = filteredConnections.length;
|
const totalProviders = sortedConnections.length;
|
||||||
const activeWithLimits = Object.values(quotaData).filter(
|
const activeWithLimits = Object.values(quotaData).filter(
|
||||||
(data) => data?.quotas?.length > 0
|
(data) => data?.quotas?.length > 0
|
||||||
).length;
|
).length;
|
||||||
|
|
@ -236,7 +286,7 @@ export default function ProviderLimits() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Empty state
|
// Empty state
|
||||||
if (filteredConnections.length === 0) {
|
if (sortedConnections.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Card padding="lg">
|
<Card padding="lg">
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
|
|
@ -303,11 +353,78 @@ export default function ProviderLimits() {
|
||||||
|
|
||||||
{/* Provider Cards Grid */}
|
{/* Provider Cards Grid */}
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{filteredConnections.map((conn) => {
|
{sortedConnections.map((conn) => {
|
||||||
const quota = quotaData[conn.id];
|
const quota = quotaData[conn.id];
|
||||||
const isLoading = loading[conn.id];
|
const isLoading = loading[conn.id];
|
||||||
const error = errors[conn.id];
|
const error = errors[conn.id];
|
||||||
|
|
||||||
|
// Use table layout for Antigravity and Kiro, card layout for others
|
||||||
|
if (conn.provider === "antigravity" || conn.provider === "kiro") {
|
||||||
|
return (
|
||||||
|
<Card key={conn.id} padding="none">
|
||||||
|
<div className="p-6 border-b border-black/10 dark:border-white/10">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg flex items-center justify-center overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src={`/providers/${conn.provider}.png`}
|
||||||
|
alt={conn.provider}
|
||||||
|
width={40}
|
||||||
|
height={40}
|
||||||
|
className="object-contain"
|
||||||
|
sizes="40px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold text-text-primary capitalize">
|
||||||
|
{conn.provider}
|
||||||
|
</h3>
|
||||||
|
{conn.name && (
|
||||||
|
<p className="text-sm text-text-muted">{conn.name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => refreshProvider(conn.id, conn.provider)}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 transition-colors disabled:opacity-50"
|
||||||
|
title="Refresh quota"
|
||||||
|
>
|
||||||
|
<span className={`material-symbols-outlined text-[20px] text-text-muted ${isLoading ? "animate-spin" : ""}`}>
|
||||||
|
refresh
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-8 text-text-muted">
|
||||||
|
<span className="material-symbols-outlined text-[32px] animate-spin">
|
||||||
|
progress_activity
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<span className="material-symbols-outlined text-[32px] text-red-500">
|
||||||
|
error
|
||||||
|
</span>
|
||||||
|
<p className="mt-2 text-sm text-text-muted">{error}</p>
|
||||||
|
</div>
|
||||||
|
) : quota?.message ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-sm text-text-muted">{quota.message}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<QuotaTable quotas={quota?.quotas} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use card layout for other providers
|
||||||
return (
|
return (
|
||||||
<ProviderLimitCard
|
<ProviderLimitCard
|
||||||
key={conn.id}
|
key={conn.id}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
|
import { getModelsByProviderId } from "open-sse/config/providerModels.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format ISO date string to countdown format
|
* Format ISO date string to countdown format (inspired by vscode-antigravity-cockpit)
|
||||||
* @param {string|Date} date - ISO date string or Date object
|
* @param {string|Date} date - ISO date string or Date object
|
||||||
* @returns {string} Formatted countdown (e.g., "5d 12h", "2h 30m", "15m") or "-"
|
* @returns {string} Formatted countdown (e.g., "2d 5h 30m", "4h 40m", "15m") or "-"
|
||||||
*/
|
*/
|
||||||
export function formatResetTime(date) {
|
export function formatResetTime(date) {
|
||||||
if (!date) return "-";
|
if (!date) return "-";
|
||||||
|
|
@ -13,23 +15,25 @@ export function formatResetTime(date) {
|
||||||
|
|
||||||
if (diffMs <= 0) return "-";
|
if (diffMs <= 0) return "-";
|
||||||
|
|
||||||
const totalMinutes = Math.floor(diffMs / (1000 * 60));
|
const totalMinutes = Math.ceil(diffMs / (1000 * 60));
|
||||||
|
|
||||||
|
// < 60 minutes: show only minutes
|
||||||
|
if (totalMinutes < 60) {
|
||||||
|
return `${totalMinutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
const totalHours = Math.floor(totalMinutes / 60);
|
const totalHours = Math.floor(totalMinutes / 60);
|
||||||
const totalDays = Math.floor(totalHours / 24);
|
const remainingMinutes = totalMinutes % 60;
|
||||||
|
|
||||||
const days = totalDays;
|
// < 24 hours: show hours and minutes
|
||||||
const hours = totalHours % 24;
|
if (totalHours < 24) {
|
||||||
const minutes = totalMinutes % 60;
|
return `${totalHours}h ${remainingMinutes}m`;
|
||||||
|
|
||||||
if (days > 0) {
|
|
||||||
return `${days}d ${hours}h`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hours > 0) {
|
// >= 24 hours: show days, hours, and minutes
|
||||||
return `${hours}h ${minutes}m`;
|
const days = Math.floor(totalHours / 24);
|
||||||
}
|
const remainingHours = totalHours % 24;
|
||||||
|
return `${days}d ${remainingHours}h ${remainingMinutes}m`;
|
||||||
return `${minutes}m`;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return "-";
|
return "-";
|
||||||
}
|
}
|
||||||
|
|
@ -107,12 +111,14 @@ export function parseQuotaData(provider, data) {
|
||||||
|
|
||||||
case "antigravity":
|
case "antigravity":
|
||||||
if (data.quotas) {
|
if (data.quotas) {
|
||||||
Object.entries(data.quotas).forEach(([modelName, quota]) => {
|
Object.entries(data.quotas).forEach(([modelKey, quota]) => {
|
||||||
normalizedQuotas.push({
|
normalizedQuotas.push({
|
||||||
name: modelName,
|
name: quota.displayName || modelKey,
|
||||||
|
modelKey: modelKey, // Keep modelKey for sorting
|
||||||
used: quota.used || 0,
|
used: quota.used || 0,
|
||||||
total: quota.total || 0,
|
total: quota.total || 0,
|
||||||
resetAt: quota.resetAt || null,
|
resetAt: quota.resetAt || null,
|
||||||
|
remainingPercentage: quota.remainingPercentage,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -184,5 +190,20 @@ export function parseQuotaData(provider, data) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sort quotas according to PROVIDER_MODELS order
|
||||||
|
const modelOrder = getModelsByProviderId(provider);
|
||||||
|
if (modelOrder.length > 0) {
|
||||||
|
const orderMap = new Map(modelOrder.map((m, i) => [m.id, i]));
|
||||||
|
|
||||||
|
normalizedQuotas.sort((a, b) => {
|
||||||
|
// Use modelKey for antigravity, otherwise use name
|
||||||
|
const keyA = a.modelKey || a.name;
|
||||||
|
const keyB = b.modelKey || b.name;
|
||||||
|
const orderA = orderMap.get(keyA) ?? 999;
|
||||||
|
const orderB = orderMap.get(keyB) ?? 999;
|
||||||
|
return orderA - orderB;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return normalizedQuotas;
|
return normalizedQuotas;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ export default function UsagePage() {
|
||||||
options={[
|
options={[
|
||||||
{ value: "overview", label: "Overview" },
|
{ value: "overview", label: "Overview" },
|
||||||
{ value: "logs", label: "Logger" },
|
{ value: "logs", label: "Logger" },
|
||||||
// { value: "limits", label: "Limits" },
|
{ value: "limits", label: "Limits" },
|
||||||
]}
|
]}
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
onChange={setActiveTab}
|
onChange={setActiveTab}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,104 @@
|
||||||
import { getProviderConnectionById } from "@/lib/localDb";
|
import { getProviderConnectionById, updateProviderConnection } from "@/lib/localDb";
|
||||||
|
import { getMachineId } from "@/shared/utils/machine";
|
||||||
import { getUsageForProvider } from "open-sse/services/usage.js";
|
import { getUsageForProvider } from "open-sse/services/usage.js";
|
||||||
|
import { getExecutor } from "open-sse/executors/index.js";
|
||||||
|
import { syncToCloud } from "@/app/api/sync/cloud/route";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync to cloud if enabled
|
||||||
|
*/
|
||||||
|
async function syncToCloudIfEnabled() {
|
||||||
|
try {
|
||||||
|
const machineId = await getMachineId();
|
||||||
|
if (!machineId) return;
|
||||||
|
await syncToCloud(machineId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Usage API] Error syncing to cloud:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh credentials using executor and update database
|
||||||
|
* @returns {{ connection, refreshed: boolean }}
|
||||||
|
*/
|
||||||
|
async function refreshAndUpdateCredentials(connection) {
|
||||||
|
const executor = getExecutor(connection.provider);
|
||||||
|
|
||||||
|
// Build credentials object from connection
|
||||||
|
const credentials = {
|
||||||
|
accessToken: connection.accessToken,
|
||||||
|
refreshToken: connection.refreshToken,
|
||||||
|
expiresAt: connection.tokenExpiresAt,
|
||||||
|
providerSpecificData: connection.providerSpecificData,
|
||||||
|
// For GitHub
|
||||||
|
copilotToken: connection.providerSpecificData?.copilotToken,
|
||||||
|
copilotTokenExpiresAt: connection.providerSpecificData?.copilotTokenExpiresAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if refresh is needed
|
||||||
|
const needsRefresh = executor.needsRefresh(credentials);
|
||||||
|
|
||||||
|
if (!needsRefresh) {
|
||||||
|
return { connection, refreshed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use executor's refreshCredentials method
|
||||||
|
const refreshResult = await executor.refreshCredentials(credentials, console);
|
||||||
|
|
||||||
|
if (!refreshResult) {
|
||||||
|
// For GitHub, if refreshCredentials fails but we still have accessToken, try to use it directly
|
||||||
|
if (connection.provider === "github" && connection.accessToken) {
|
||||||
|
return { connection, refreshed: false };
|
||||||
|
}
|
||||||
|
throw new Error("Failed to refresh credentials. Please re-authorize the connection.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build update object
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const updateData = {
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update accessToken if present
|
||||||
|
if (refreshResult.accessToken) {
|
||||||
|
updateData.accessToken = refreshResult.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update refreshToken if present
|
||||||
|
if (refreshResult.refreshToken) {
|
||||||
|
updateData.refreshToken = refreshResult.refreshToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update token expiry
|
||||||
|
if (refreshResult.expiresIn) {
|
||||||
|
updateData.tokenExpiresAt = new Date(Date.now() + refreshResult.expiresIn * 1000).toISOString();
|
||||||
|
} else if (refreshResult.expiresAt) {
|
||||||
|
updateData.tokenExpiresAt = refreshResult.expiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle provider-specific data (copilotToken for GitHub, etc.)
|
||||||
|
if (refreshResult.copilotToken || refreshResult.copilotTokenExpiresAt) {
|
||||||
|
updateData.providerSpecificData = {
|
||||||
|
...connection.providerSpecificData,
|
||||||
|
copilotToken: refreshResult.copilotToken,
|
||||||
|
copilotTokenExpiresAt: refreshResult.copilotTokenExpiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update database
|
||||||
|
await updateProviderConnection(connection.id, updateData);
|
||||||
|
|
||||||
|
// Return updated connection
|
||||||
|
const updatedConnection = {
|
||||||
|
...connection,
|
||||||
|
...updateData,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
connection: updatedConnection,
|
||||||
|
refreshed: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/usage/[connectionId] - Get usage data for a specific connection
|
* GET /api/usage/[connectionId] - Get usage data for a specific connection
|
||||||
|
|
@ -9,7 +108,7 @@ export async function GET(request, { params }) {
|
||||||
const { connectionId } = await params;
|
const { connectionId } = await params;
|
||||||
|
|
||||||
// Get connection from database
|
// Get connection from database
|
||||||
const connection = await getProviderConnectionById(connectionId);
|
let connection = await getProviderConnectionById(connectionId);
|
||||||
if (!connection) {
|
if (!connection) {
|
||||||
return Response.json({ error: "Connection not found" }, { status: 404 });
|
return Response.json({ error: "Connection not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
@ -19,12 +118,30 @@ export async function GET(request, { params }) {
|
||||||
return Response.json({ message: "Usage not available for API key connections" });
|
return Response.json({ message: "Usage not available for API key connections" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refresh credentials if needed using executor
|
||||||
|
let refreshed = false;
|
||||||
|
try {
|
||||||
|
const result = await refreshAndUpdateCredentials(connection);
|
||||||
|
connection = result.connection;
|
||||||
|
refreshed = result.refreshed;
|
||||||
|
|
||||||
|
// Sync to cloud only if token was refreshed
|
||||||
|
if (refreshed) {
|
||||||
|
await syncToCloudIfEnabled();
|
||||||
|
}
|
||||||
|
} catch (refreshError) {
|
||||||
|
console.error("[Usage API] Credential refresh failed:", refreshError);
|
||||||
|
return Response.json({
|
||||||
|
error: `Credential refresh failed: ${refreshError.message}`
|
||||||
|
}, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch usage from provider API
|
// Fetch usage from provider API
|
||||||
const usage = await getUsageForProvider(connection);
|
const usage = await getUsageForProvider(connection);
|
||||||
return Response.json(usage);
|
return Response.json(usage);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("Error fetching usage:", error);
|
console.error("[Usage API] Error fetching usage:", error);
|
||||||
|
console.error("[Usage API] Error stack:", error.stack);
|
||||||
return Response.json({ error: error.message }, { status: 500 });
|
return Response.json({ error: error.message }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,15 @@ export async function getUsageForProvider(connection) {
|
||||||
*/
|
*/
|
||||||
async function getGitHubUsage(accessToken, providerSpecificData) {
|
async function getGitHubUsage(accessToken, providerSpecificData) {
|
||||||
try {
|
try {
|
||||||
|
// Use copilotToken for copilot_internal API, not GitHub OAuth accessToken
|
||||||
|
const copilotToken = providerSpecificData?.copilotToken;
|
||||||
|
if (!copilotToken) {
|
||||||
|
throw new Error("Copilot token not found. Please refresh token first.");
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch("https://api.github.com/copilot_internal/user", {
|
const response = await fetch("https://api.github.com/copilot_internal/user", {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${accessToken}`,
|
Authorization: `Bearer ${copilotToken}`,
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
"X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion,
|
"X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion,
|
||||||
"User-Agent": GITHUB_CONFIG.userAgent,
|
"User-Agent": GITHUB_CONFIG.userAgent,
|
||||||
|
|
|
||||||
|
|
@ -76,3 +76,6 @@ export const ID_TO_ALIAS = Object.values(AI_PROVIDERS).reduce((acc, p) => {
|
||||||
acc[p.id] = p.alias;
|
acc[p.id] = p.alias;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
|
// Providers that support usage/quota API
|
||||||
|
export const USAGE_SUPPORTED_PROVIDERS = ["antigravity", "kiro", "github", "claude"];
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue