feat: pre-request token validation, mid-stream error handling, and usage stats improvements
- Qoder: handle mid-stream errors by returning proper error Response instead of embedding in stream - Qoder: add refreshCredentials() to validate token via quota endpoint before requests - chatCore: validate and refresh provider tokens before sending chat requests - chatCore: bail early with 401 on unrecoverable token refresh errors - Usage stats: track apiKey, comboName, fallbackHistory in request details - Dashboard: improve Combos, Endpoint, Provider, Usage, and RequestDetails pages - API keys route: upsert logic with provider_type support - DB repos: usageRepo query improvements, requestDetailsRepo pagination, apiKeysRepo updates
This commit is contained in:
@@ -368,16 +368,10 @@ async function wrapQoderSSE(response, model, midStreamError = {}) {
|
||||
const inner = typeof envelope.body === "string" ? envelope.body : "";
|
||||
if (statusVal !== 200) {
|
||||
const parsed = parseQoderStreamError(statusVal, inner);
|
||||
// Store error in shared state so onStreamComplete can trigger cooldown
|
||||
// Store error in shared state so the caller can return a proper error Response
|
||||
midStreamError.error = { status: parsed.statusCode, message: parsed.message, errorCode: parsed.errorCode };
|
||||
const errChunk = JSON.stringify({
|
||||
id: `qoder-error-${Date.now()}`,
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model,
|
||||
choices: [{ index: 0, delta: { content: `\n\n${parsed.message}` }, finish_reason: "stop" }],
|
||||
});
|
||||
controller.enqueue(encoder.encode(`data: ${errChunk}\n\n`));
|
||||
// End the stream with [DONE]; the caller (QoderExecutor.execute) will detect
|
||||
// midStreamError and return a non-2xx Response so the API client gets a real error.
|
||||
controller.enqueue(encoder.encode(SSE_DONE));
|
||||
doneEmitted = true;
|
||||
return;
|
||||
@@ -555,19 +549,82 @@ export class QoderExecutor extends BaseExecutor {
|
||||
}
|
||||
|
||||
const midStreamError = {};
|
||||
const wrapped = await wrapQoderSSE(response, `qoder/${qoderKey}`, midStreamError);
|
||||
let wrapped = await wrapQoderSSE(response, `qoder/${qoderKey}`, midStreamError);
|
||||
|
||||
// If a mid-stream error was detected, return a proper error Response instead of
|
||||
// a successful one with an error message inside.
|
||||
if (midStreamError?.error) {
|
||||
const err = midStreamError.error;
|
||||
log?.error?.("QODER", `Upstream error ${err.status}: ${err.message}`);
|
||||
wrapped = new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: err.message,
|
||||
type: "upstream_error",
|
||||
code: String(err.status),
|
||||
}
|
||||
}),
|
||||
{
|
||||
status: err.status >= 400 && err.status < 600 ? err.status : 502,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return { response: wrapped, url, headers, transformedBody: payload, midStreamError };
|
||||
}
|
||||
|
||||
// Qoder device tokens don't refresh through OAuth — the upstream returns
|
||||
// 403 for our flow. Surfacing failure via 401-on-chat is enough; the
|
||||
// dashboard tells users to re-login when their token expires (~30 days).
|
||||
async refreshCredentials() {
|
||||
return null;
|
||||
// Validate Qoder token by calling the quota endpoint. If it returns 403,
|
||||
// return a structured unrecoverable error so chatCore bails before sending
|
||||
// the actual chat request (instead of forwarding a "quota exceeded" success).
|
||||
async refreshCredentials(credentials, log) {
|
||||
// Qoder's quota endpoint validates the access token. If it returns 403,
|
||||
// the token is invalid and the user needs to reconnect.
|
||||
const oauth = PROVIDERS.qoder?.oauth;
|
||||
const url = oauth?.quotaUsageUrl || "https://openapi.qoder.sh/api/v2/quota/usage";
|
||||
const authToken = credentials?.accessToken;
|
||||
if (!authToken) return null;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (res.status === 403) {
|
||||
const errText = await res.text().catch(() => "");
|
||||
log?.error?.("TOKEN_REFRESH", `Qoder token invalid (403): ${errText}`);
|
||||
return {
|
||||
error: "unrecoverable_refresh_error",
|
||||
code: "invalid_token",
|
||||
message: "qoder token invalid; reconnect the account",
|
||||
};
|
||||
}
|
||||
|
||||
// Token is valid — stamp lastRefreshAt so needsRefresh stays quiet
|
||||
const psd = { ...credentials.providerSpecificData, lastRefreshAt: new Date().toISOString() };
|
||||
return {
|
||||
accessToken: authToken,
|
||||
expiresIn: 86400,
|
||||
providerSpecificData: psd,
|
||||
lastRefreshAt: new Date().toISOString(),
|
||||
};
|
||||
} catch (err) {
|
||||
log?.warn?.("TOKEN_REFRESH", `Qoder refresh check failed: ${err.message}`);
|
||||
return null; // Network blip — let the request proceed, failover handles 403
|
||||
}
|
||||
}
|
||||
|
||||
needsRefresh() {
|
||||
return false;
|
||||
// 24h cooldown before re-checking token validity
|
||||
needsRefresh(credentials) {
|
||||
if (!credentials?.accessToken) return false;
|
||||
const psd = credentials?.providerSpecificData || {};
|
||||
if (!psd.lastRefreshAt) return true;
|
||||
const elapsed = Date.now() - new Date(psd.lastRefreshAt).getTime();
|
||||
return elapsed > 24 * 60 * 60 * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user