Compare commits
2 commits
master
...
preserve/o
| Author | SHA1 | Date | |
|---|---|---|---|
| e99e91aa09 | |||
| 0f6a449dbc |
7 changed files with 69 additions and 43 deletions
|
|
@ -185,9 +185,13 @@ export function resetAccountState(account) {
|
||||||
if (!account) return account;
|
if (!account) return account;
|
||||||
return {
|
return {
|
||||||
...account,
|
...account,
|
||||||
|
...buildClearModelLocksUpdate(account),
|
||||||
rateLimitedUntil: null,
|
rateLimitedUntil: null,
|
||||||
backoffLevel: 0,
|
backoffLevel: 0,
|
||||||
|
testStatus: "active",
|
||||||
lastError: null,
|
lastError: null,
|
||||||
|
errorCode: null,
|
||||||
|
lastErrorAt: null,
|
||||||
status: "active"
|
status: "active"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,40 @@ export function openaiToClaudeRequest(model, body, stream) {
|
||||||
|
|
||||||
flushCurrentMessage();
|
flushCurrentMessage();
|
||||||
|
|
||||||
|
// GUARD: some Claude auth channels (OAuth/Claude Code) reject requests
|
||||||
|
// that end on an assistant turn ("assistant message prefill" 400).
|
||||||
|
// Agentic loops (e.g. Hermes) sometimes resend their own last output as
|
||||||
|
// the new final message to request a continuation, with no new user
|
||||||
|
// turn in between. Normalize by appending a synthetic turn so the
|
||||||
|
// request always ends on `user`, regardless of auth channel or model.
|
||||||
|
// If the trailing assistant message has unresolved tool_use blocks,
|
||||||
|
// Anthropic separately requires a matching tool_result for each one
|
||||||
|
// (not just any user turn), so synthesize those instead of plain text.
|
||||||
|
{
|
||||||
|
const lastMsg = result.messages[result.messages.length - 1];
|
||||||
|
if (lastMsg && lastMsg.role === ROLE.ASSISTANT) {
|
||||||
|
const unresolvedToolUseIds = Array.isArray(lastMsg.content)
|
||||||
|
? lastMsg.content.filter(b => b.type === CLAUDE_BLOCK.TOOL_USE).map(b => b.id)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (unresolvedToolUseIds.length > 0) {
|
||||||
|
result.messages.push({
|
||||||
|
role: ROLE.USER,
|
||||||
|
content: unresolvedToolUseIds.map(id => ({
|
||||||
|
type: CLAUDE_BLOCK.TOOL_RESULT,
|
||||||
|
tool_use_id: id,
|
||||||
|
content: "Continuing."
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
result.messages.push({
|
||||||
|
role: ROLE.USER,
|
||||||
|
content: [{ type: CLAUDE_BLOCK.TEXT, text: "Continue." }]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add cache_control to last assistant message
|
// Add cache_control to last assistant message
|
||||||
for (let i = result.messages.length - 1; i >= 0; i--) {
|
for (let i = result.messages.length - 1; i >= 0; i--) {
|
||||||
const message = result.messages[i];
|
const message = result.messages[i];
|
||||||
|
|
|
||||||
|
|
@ -189,13 +189,14 @@ export async function createProviderConnection(data) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Critical: OAuth refresh token race — atomic merge inside transaction
|
// Critical: OAuth refresh token race — atomic merge inside transaction
|
||||||
export async function updateProviderConnection(id, data) {
|
export async function updateProviderConnection(id, update) {
|
||||||
const db = await getAdapter();
|
const db = await getAdapter();
|
||||||
let result;
|
let result;
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
const row = db.get(`SELECT * FROM providerConnections WHERE id = ?`, [id]);
|
const row = db.get(`SELECT * FROM providerConnections WHERE id = ?`, [id]);
|
||||||
if (!row) { result = null; return; }
|
if (!row) { result = null; return; }
|
||||||
const existing = rowToConn(row);
|
const existing = rowToConn(row);
|
||||||
|
const data = typeof update === "function" ? update(existing) : update;
|
||||||
const merged = { ...existing, ...data, updatedAt: new Date().toISOString() };
|
const merged = { ...existing, ...data, updatedAt: new Date().toISOString() };
|
||||||
upsert(db, merged);
|
upsert(db, merged);
|
||||||
if (data.priority !== undefined) reorderInTx(db, existing.provider);
|
if (data.priority !== undefined) reorderInTx(db, existing.provider);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import {
|
import {
|
||||||
extractApiKey, isValidApiKey,
|
extractApiKey, isValidApiKey,
|
||||||
getProviderCredentials, markAccountUnavailable,
|
getProviderCredentials, markAccountUnavailable, clearAccountError,
|
||||||
} from "../services/auth.js";
|
} from "../services/auth.js";
|
||||||
import { getSettings } from "@/lib/localDb";
|
import { getSettings } from "@/lib/localDb";
|
||||||
import { getModelInfo } from "../services/model.js";
|
import { getModelInfo } from "../services/model.js";
|
||||||
|
|
@ -74,7 +74,10 @@ export async function handleStt(request) {
|
||||||
|
|
||||||
const result = await handleSttCore({ provider, model, formData, credentials, sttConfig: AI_PROVIDERS[provider]?.sttConfig });
|
const result = await handleSttCore({ provider, model, formData, credentials, sttConfig: AI_PROVIDERS[provider]?.sttConfig });
|
||||||
|
|
||||||
if (result.success) return result.response;
|
if (result.success) {
|
||||||
|
await clearAccountError(credentials.connectionId, credentials, model);
|
||||||
|
return result.response;
|
||||||
|
}
|
||||||
|
|
||||||
const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model);
|
const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model);
|
||||||
if (shouldFallback) {
|
if (shouldFallback) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import {
|
import {
|
||||||
extractApiKey, isValidApiKey,
|
extractApiKey, isValidApiKey,
|
||||||
getProviderCredentials, markAccountUnavailable,
|
getProviderCredentials, markAccountUnavailable, clearAccountError,
|
||||||
} from "../services/auth.js";
|
} from "../services/auth.js";
|
||||||
import { getSettings } from "@/lib/localDb";
|
import { getSettings } from "@/lib/localDb";
|
||||||
import { getModelInfo, getComboModels } from "../services/model.js";
|
import { getModelInfo, getComboModels } from "../services/model.js";
|
||||||
|
|
@ -101,7 +101,10 @@ async function handleSingleModelTts(body, modelStr, responseFormat, language, st
|
||||||
|
|
||||||
const result = await handleTtsCore({ provider, model, input: body.input, credentials, responseFormat, language, style });
|
const result = await handleTtsCore({ provider, model, input: body.input, credentials, responseFormat, language, style });
|
||||||
|
|
||||||
if (result.success) return result.response;
|
if (result.success) {
|
||||||
|
await clearAccountError(credentials.connectionId, credentials, model);
|
||||||
|
return result.response;
|
||||||
|
}
|
||||||
|
|
||||||
const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model);
|
const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model);
|
||||||
if (shouldFallback) {
|
if (shouldFallback) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { getProviderConnections, validateApiKey, updateProviderConnection, getSettings, getProxyPools } from "@/lib/localDb";
|
import { getProviderConnections, validateApiKey, updateProviderConnection, getSettings, getProxyPools } from "@/lib/localDb";
|
||||||
import { resolveConnectionProxyConfig, pickProxyPoolId } from "@/lib/network/connectionProxy";
|
import { resolveConnectionProxyConfig, pickProxyPoolId } from "@/lib/network/connectionProxy";
|
||||||
import { formatRetryAfter, checkFallbackError, isModelLockActive, buildModelLockUpdate, getEarliestModelLockUntil } from "open-sse/services/accountFallback.js";
|
import { formatRetryAfter, checkFallbackError, isModelLockActive, buildModelLockUpdate, getEarliestModelLockUntil, resetAccountState } from "open-sse/services/accountFallback.js";
|
||||||
import { MAX_RATE_LIMIT_COOLDOWN_MS } from "open-sse/config/errorConfig.js";
|
import { MAX_RATE_LIMIT_COOLDOWN_MS } from "open-sse/config/errorConfig.js";
|
||||||
import { resolveProviderId, FREE_PROVIDERS } from "@/shared/constants/providers.js";
|
import { resolveProviderId, FREE_PROVIDERS } from "@/shared/constants/providers.js";
|
||||||
import * as log from "../utils/logger.js";
|
import * as log from "../utils/logger.js";
|
||||||
|
|
@ -274,43 +274,8 @@ export async function markAccountUnavailable(connectionId, status, errorText, pr
|
||||||
*/
|
*/
|
||||||
export async function clearAccountError(connectionId, currentConnection, model = null) {
|
export async function clearAccountError(connectionId, currentConnection, model = null) {
|
||||||
if (!connectionId || connectionId === "noauth") return;
|
if (!connectionId || connectionId === "noauth") return;
|
||||||
const conn = currentConnection._connection || currentConnection;
|
// Reset inside transaction so concurrent 429 writes cannot leave stale locks.
|
||||||
const now = Date.now();
|
await updateProviderConnection(connectionId, resetAccountState);
|
||||||
const allLockKeys = Object.keys(conn).filter(k => k.startsWith("modelLock_"));
|
|
||||||
|
|
||||||
if (!conn.testStatus && !conn.lastError && allLockKeys.length === 0) return;
|
|
||||||
|
|
||||||
// Keys to clear: current model's lock + all expired locks
|
|
||||||
const keysToClear = allLockKeys.filter(k => {
|
|
||||||
if (model && k === `modelLock_${model}`) return true; // succeeded model
|
|
||||||
if (model && k === "modelLock___all") return true; // account-level lock
|
|
||||||
const expiry = conn[k];
|
|
||||||
return expiry && new Date(expiry).getTime() <= now; // expired
|
|
||||||
});
|
|
||||||
|
|
||||||
if (keysToClear.length === 0 && conn.testStatus !== "unavailable" && !conn.lastError) return;
|
|
||||||
|
|
||||||
// Check if any active locks remain after clearing
|
|
||||||
const remainingActiveLocks = allLockKeys.filter(k => {
|
|
||||||
if (keysToClear.includes(k)) return false;
|
|
||||||
const expiry = conn[k];
|
|
||||||
return expiry && new Date(expiry).getTime() > now;
|
|
||||||
});
|
|
||||||
|
|
||||||
const clearObj = Object.fromEntries(keysToClear.map(k => [k, null]));
|
|
||||||
|
|
||||||
// Only reset error state if no active locks remain
|
|
||||||
if (remainingActiveLocks.length === 0) {
|
|
||||||
Object.assign(clearObj, {
|
|
||||||
testStatus: "active",
|
|
||||||
lastError: null,
|
|
||||||
errorCode: null,
|
|
||||||
lastErrorAt: null,
|
|
||||||
backoffLevel: 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await updateProviderConnection(connectionId, clearObj);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
16
tests/unit/account-fallback-reset.test.js
Normal file
16
tests/unit/account-fallback-reset.test.js
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { applyErrorState, resetAccountState } from "../../open-sse/services/accountFallback.js";
|
||||||
|
|
||||||
|
describe("resetAccountState", () => {
|
||||||
|
it("clears every model lock and backoff after success", () => {
|
||||||
|
const reset = resetAccountState({ backoffLevel: 9, modelLock_alpha: "2099-01-01T00:00:00.000Z", modelLock___all: "2099-01-01T00:00:00.000Z", lastError: "429", errorCode: 429 });
|
||||||
|
expect(reset.backoffLevel).toBe(0);
|
||||||
|
expect(reset.modelLock_alpha).toBeNull();
|
||||||
|
expect(reset.modelLock___all).toBeNull();
|
||||||
|
expect(reset.lastError).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps 429 on error path ratcheting upward", () => {
|
||||||
|
expect(applyErrorState({ backoffLevel: 3 }, 429, "rate limited").backoffLevel).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue