refactor: improve type safety and add cloud agent providers

- Update types in several files to reduce usage of `any`
- Fix `fetch` body type error in `AntigravityExecutor` by returning `ReadableStream`
- Add `CLOUD_AGENT_PROVIDERS` constants

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Markus Hartung
2026-05-11 00:41:36 +02:00
parent 9f79a6bb94
commit bff9a0c4a6
9 changed files with 93 additions and 38 deletions

View File

@@ -42,11 +42,20 @@ const CREDITS_EXHAUSTED_TTL_MS = 5 * 60 * 60 * 1000; // 5 hours
const BARE_PRO_IDS = new Set(["gemini-3.1-pro"]);
function getChunkedOrFixedBody(bodyStr: string, stream: boolean) {
interface AntigravityContent {
role: string;
parts: unknown[];
[key: string]: unknown;
}
function getChunkedOrFixedBody(bodyStr: string, stream: boolean): BodyInit {
if (stream) {
return (async function* () {
yield new TextEncoder().encode(bodyStr);
})();
return new ReadableStream({
async start(controller) {
controller.enqueue(new TextEncoder().encode(bodyStr));
controller.close();
},
});
}
return bodyStr;
}
@@ -453,7 +462,7 @@ export class AntigravityExecutor extends BaseExecutor {
return { ...c, role, parts };
}) || [];
const contents: any[] = [];
const contents: AntigravityContent[] = [];
for (const c of normalizedContents) {
if (!Array.isArray(c.parts) || c.parts.length === 0) continue;
if (contents.length > 0 && contents[contents.length - 1].role === c.role) {
@@ -802,7 +811,7 @@ export class AntigravityExecutor extends BaseExecutor {
let response = await fetch(url, {
method: "POST",
headers: finalHeaders,
body: getChunkedOrFixedBody(serializedRequest.bodyString, stream) as any,
body: getChunkedOrFixedBody(serializedRequest.bodyString, stream),
...(stream ? { duplex: "half" } : {}),
signal,
});
@@ -814,7 +823,7 @@ export class AntigravityExecutor extends BaseExecutor {
response = await fetch(url, {
method: "POST",
headers: retryHeaders,
body: getChunkedOrFixedBody(serializedRequest.bodyString, stream) as any,
body: getChunkedOrFixedBody(serializedRequest.bodyString, stream),
...(stream ? { duplex: "half" } : {}),
signal,
});
@@ -884,7 +893,7 @@ export class AntigravityExecutor extends BaseExecutor {
const creditsResp = await fetch(url, {
method: "POST",
headers: finalCreditsHeaders,
body: getChunkedOrFixedBody(serializedCreditsRequest.bodyString, stream) as any,
body: getChunkedOrFixedBody(serializedCreditsRequest.bodyString, stream),
...(stream ? { duplex: "half" } : {}),
signal,
});

View File

@@ -495,12 +495,21 @@ export function getModelLockoutInfo(provider, connectionId, model) {
};
}
type ModelLockoutInfo = {
provider: string;
connectionId: string;
model: string;
reason: string;
remainingMs: number;
failureCount: number;
};
/**
* Get all active model lockouts (for dashboard)
*/
export function getAllModelLockouts() {
export function getAllModelLockouts(): ModelLockoutInfo[] {
const now = Date.now();
const active: any[] = [];
const active: ModelLockoutInfo[] = [];
for (const key of modelLockouts.keys()) {
cleanupModelLockKey(key, now);
}
@@ -908,7 +917,7 @@ export function checkFallbackError(
backoffLevel: number = 0,
_model: string | null = null,
provider: string | null = null,
headers: any = null,
headers: Headers | Record<string, string> | null = null,
profileOverride: ProviderProfile | null = null
): {
shouldFallback: boolean;

View File

@@ -644,10 +644,13 @@ async function getGlmUsage(apiKey: string, providerSpecificData?: Record<string,
resetAt,
displayName: getGlmQuotaDisplayName(quotaName),
details: Array.isArray(src.models)
? src.models.map((m: any) => ({
name: String(m.model || ""),
used: toNumber(m.percentage, 0),
}))
? (src.models as unknown[]).map((m) => {
const modelInfo = toRecord(m);
return {
name: String(modelInfo.model || ""),
used: toNumber(modelInfo.percentage, 0),
};
})
: [],
unlimited: false,
};

View File

@@ -89,52 +89,58 @@ export const DEFAULT_SAFETY_SETTINGS = [
];
// Convert OpenAI content to Gemini parts
export function convertOpenAIContentToParts(content: any) {
const parts: any[] = [];
export function convertOpenAIContentToParts(content: unknown) {
const parts: Record<string, unknown>[] = [];
if (typeof content === "string") {
parts.push({ text: content });
} else if (Array.isArray(content)) {
for (const item of content) {
if (item.type === "text") {
parts.push({ text: item.text });
const rec = toRecord(item);
if (rec.type === "text") {
parts.push({ text: rec.text });
} else {
// 1. Handle Gemini native inline_data injected into OpenAI arrays (e.g. Cherry Studio)
const geminiInline = item.inline_data || item.inlineData;
const geminiInline = toRecord(rec.inline_data || rec.inlineData);
if (geminiInline?.data) {
parts.push({
inlineData: {
mimeType: geminiInline.mime_type || geminiInline.mimeType || "application/pdf",
data: geminiInline.data.replace(/^data:[a-zA-Z0-9/+-]+;base64,/, ""),
mimeType: String(
geminiInline.mime_type || geminiInline.mimeType || "application/pdf"
),
data: String(geminiInline.data).replace(/^data:[a-zA-Z0-9/+-]+;base64,/, ""),
},
});
continue;
}
// 2. Handle Claude-style source blocks commonly used by AI clients
if (item.source?.type === "base64" && item.source?.data) {
const source = toRecord(rec.source);
if (source?.type === "base64" && source?.data) {
parts.push({
inlineData: {
mimeType: item.source.media_type || "application/pdf",
data: item.source.data.replace(/^data:[a-zA-Z0-9/+-]+;base64,/, ""),
mimeType: String(source.media_type || "application/pdf"),
data: String(source.data).replace(/^data:[a-zA-Z0-9/+-]+;base64,/, ""),
},
});
continue;
}
// 3. Handle raw data strings (e.g. {"type": "file", "data": "JVBER...", "mime_type": "..."})
const rawDataStr = item.data || item.file?.data || item.document?.data;
const file = toRecord(rec.file);
const doc = toRecord(rec.document);
const rawDataStr = rec.data || file?.data || doc?.data;
const mimeTypeFallback =
item.mime_type ||
item.media_type ||
item.file?.mime_type ||
item.document?.mime_type ||
rec.mime_type ||
rec.media_type ||
file?.mime_type ||
doc?.mime_type ||
"application/octet-stream";
if (typeof rawDataStr === "string" && !rawDataStr.startsWith("http")) {
const rawData = rawDataStr.replace(/^data:[a-zA-Z0-9/+-]+;base64,/, "");
parts.push({
inlineData: {
mimeType: mimeTypeFallback,
mimeType: String(mimeTypeFallback),
data: rawData,
},
});
@@ -142,8 +148,11 @@ export function convertOpenAIContentToParts(content: any) {
}
// 4. Standard OpenAI Data URIs
const fileData =
item.image_url?.url || item.file_url?.url || item.file?.url || item.document?.url;
const imageUrl = toRecord(rec.image_url);
const fileUrl = toRecord(rec.file_url);
const fileObj = toRecord(rec.file);
const docObj = toRecord(rec.document);
const fileData = imageUrl?.url || fileUrl?.url || fileObj?.url || docObj?.url;
if (typeof fileData === "string" && fileData.startsWith("data:")) {
const commaIndex = fileData.indexOf(",");
if (commaIndex !== -1) {

View File

@@ -8,6 +8,14 @@ import type { ModelCooldownErrorPayload } from "@/types";
* Strips stack traces and internal file paths from error messages before they
* reach the client.
*/
interface ErrorResponseBody {
error: {
message: string;
type?: string;
code?: string;
};
}
function sanitizeErrorMessage(message: unknown): string {
const str = typeof message === "string" ? message : String(message ?? "");
// If the message contains a stack trace (lines starting with " at "),
@@ -22,7 +30,7 @@ function sanitizeErrorMessage(message: unknown): string {
* @param {string} message - Error message
* @returns {object} Error response object
*/
export function buildErrorBody(statusCode, message) {
export function buildErrorBody(statusCode: number, message: string): ErrorResponseBody {
const errorInfo = getErrorInfo(statusCode);
return {
@@ -220,10 +228,10 @@ export function createErrorResult(
) {
const body = buildErrorBody(statusCode, message);
if (errorCode) {
(body.error as any).code = errorCode;
body.error.code = errorCode;
}
if (errorType) {
(body.error as any).type = errorType;
body.error.type = errorType;
}
const result: {

View File

@@ -185,5 +185,6 @@ export function createLogger(requestId: string | null = null): RequestScopedLogg
* Module-level default logger (no requestId — for startup/config messages).
*/
export const defaultLogger = createLogger();
export const log = defaultLogger;
export default logger;

View File

@@ -403,7 +403,7 @@ function parseRateLimits(value: unknown): RateLimitRule[] | null {
const parsed = JSON.parse(value);
if (!Array.isArray(parsed)) return null;
return parsed.filter(
(rule: any) =>
(rule: RateLimitRule) =>
typeof rule === "object" &&
rule !== null &&
typeof rule.limit === "number" &&

View File

@@ -272,7 +272,10 @@ export async function getPricingWithSources(): Promise<{
export async function getPricingForModel(provider: string, model: string) {
const pricing = await getPricing();
const findKeyInsensitive = (obj: Record<string, any> | undefined | null, key: string) => {
const findKeyInsensitive = <T>(
obj: Record<string, T> | undefined | null,
key: string
): T | undefined => {
if (!obj || !key) return undefined;
const lowerKey = key.toLowerCase();
for (const [k, v] of Object.entries(obj)) {

View File

@@ -1894,6 +1894,19 @@ export const SYSTEM_PROVIDERS = {
},
};
// Cloud Agent Providers
export const CLOUD_AGENT_PROVIDERS = {
jules: { id: "jules", alias: "jules", name: "Jules AI", icon: "smart_toy", color: "#6366F1" },
devin: { id: "devin", alias: "devin", name: "Devin AI", icon: "auto_fix_high", color: "#10B981" },
"codex-cloud": {
id: "codex-cloud",
alias: "codex-cloud",
name: "Codex Cloud",
icon: "cloud",
color: "#3B82F6",
},
};
// All providers (combined)
export const AI_PROVIDERS = {
...FREE_PROVIDERS,