Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions open-sse/config/providerRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
} from "./providerHeaderProfiles.ts";
import type { ProviderRequestDefaults } from "../services/providerRequestDefaults.ts";
import { resolvePublicCred } from "../utils/publicCreds.ts";
import { buildGitLabOAuthEndpoints, GITLAB_DUO_DEFAULT_BASE_URL } from "@/lib/oauth/gitlab";

// ── Types ─────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -1032,6 +1033,33 @@ export const REGISTRY: Record<string, RegistryEntry> = {
],
},

"gitlab-duo": {
id: "gitlab-duo",
alias: "gld",
format: "openai",
executor: "gitlab",
// baseUrl is dynamic: resolved at request time from providerSpecificData.baseUrl
// by GitlabExecutor.buildUrl() via buildGitLabOAuthEndpoints().
// The default here keeps the PROVIDERS map non-null so refreshAccessToken()
// can look up this provider.
baseUrl: buildGitLabOAuthEndpoints(GITLAB_DUO_DEFAULT_BASE_URL).publicCompletionsUrl,
authType: "oauth",
authHeader: "bearer",
defaultContextLength: 128000,
oauth: {
clientIdEnv: "GITLAB_DUO_OAUTH_CLIENT_ID",
clientIdDefault: process.env.GITLAB_OAUTH_CLIENT_ID || "",
clientSecretEnv: "GITLAB_DUO_OAUTH_CLIENT_SECRET",
clientSecretDefault: process.env.GITLAB_OAUTH_CLIENT_SECRET || "",
tokenUrl: buildGitLabOAuthEndpoints(GITLAB_DUO_DEFAULT_BASE_URL).tokenUrl,
authUrl: buildGitLabOAuthEndpoints(GITLAB_DUO_DEFAULT_BASE_URL).authorizeUrl,
},
models: [
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (GitLab Duo)" },
{ id: "claude-haiku-4-5", name: "Claude Haiku 4.5 (GitLab Duo)" },
],
},

cursor: {
id: "cursor",
alias: "cu",
Expand Down
36 changes: 31 additions & 5 deletions open-sse/executors/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from "../services/apiKeyRotator.ts";
import type { KeyHealth } from "../services/apiKeyRotator.ts";
import { getOpenAICompatibleType, isClaudeCodeCompatible } from "../services/provider.ts";
import { runWithOnPersist, getRefreshLeadMs } from "../services/tokenRefresh.ts";
import type { ProviderRequestDefaults } from "../services/providerRequestDefaults.ts";
import { signRequestBody } from "../services/claudeCodeCCH.ts";
import {
Expand Down Expand Up @@ -453,7 +454,12 @@ export class BaseExecutor {
needsRefresh(credentials?: ProviderCredentials | null) {
if (!credentials?.expiresAt) return false;
const expiresAtMs = new Date(credentials.expiresAt).getTime();
return expiresAtMs - Date.now() < 5 * 60 * 1000;
// Use the provider-specific lead time (REFRESH_LEAD_MS) so rotating-token
// providers like Codex refresh proactively far ahead of expiry. Keeping the
// refresh_token "warm" prevents Auth0 from marking it as stale and revoking
// the token family on first use after long idle.
const lead = getRefreshLeadMs(this.provider);
return expiresAtMs - Date.now() < lead;
}

parseError(response: Response, bodyText: string) {
Expand Down Expand Up @@ -551,14 +557,34 @@ export class BaseExecutor {

if (this.needsRefresh(credentials)) {
try {
const refreshed = await this.refreshCredentials(credentials, log || null);
if (refreshed) {
// Fix A: wire onCredentialsRefreshed through runWithOnPersist so it runs
// INSIDE the per-connection mutex inside getAccessToken. Not every
// executor routes through getAccessToken (e.g. github.ts), so use a flag
// to detect whether the persist callback actually fired and fall back to
// post-refresh mutation when it didn't.
let proactivePersistRan = false;
const proactiveOnPersist = arguments[0].onCredentialsRefreshed
? async (refreshResult: Record<string, unknown>) => {
proactivePersistRan = true;
activeCredentials = {
...credentials,
...(refreshResult as Partial<ProviderCredentials>),
};
await arguments[0].onCredentialsRefreshed(
refreshResult as Partial<ProviderCredentials>
);
}
: null;

const refreshed = await runWithOnPersist(proactiveOnPersist, () =>
this.refreshCredentials(credentials, log || null)
);

if (refreshed && !proactivePersistRan) {
activeCredentials = {
...credentials,
...refreshed,
};
// Persist the proactively refreshed credentials to prevent consuming rotating tokens
// without updating the central database connection.
if (arguments[0].onCredentialsRefreshed) {
await arguments[0].onCredentialsRefreshed(refreshed);
Comment on lines +566 to 589

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using arguments[0] to access onCredentialsRefreshed is fragile and bypasses TypeScript's type safety. Since onCredentialsRefreshed is already defined as an optional property of the ExecuteInput type, it is highly recommended to destructure it directly in the execute method signature (e.g., async execute({ ..., onCredentialsRefreshed }: ExecuteInput)). This avoids the need for arguments[0] entirely and ensures full type safety without changing the method's public signature or affecting any subclasses.

}
Expand Down
45 changes: 37 additions & 8 deletions open-sse/handlers/chatCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ import { resolveStreamReadinessTimeout } from "../utils/streamReadinessPolicy.ts
import { createStreamController, pipeWithDisconnect } from "../utils/streamHandler.ts";
import { createSseHeartbeatTransform, shapeForClientFormat } from "../utils/sseHeartbeat.ts";
import { addBufferToUsage, filterUsageForFormat, estimateUsage } from "../utils/usageTracking.ts";
import { refreshWithRetry, isUnrecoverableRefreshError } from "../services/tokenRefresh.ts";
import {
refreshWithRetry,
isUnrecoverableRefreshError,
runWithOnPersist,
} from "../services/tokenRefresh.ts";
import { createRequestLogger } from "../utils/requestLogger.ts";
import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.ts";
import { DEFAULT_THINKING_CLAUDE_SIGNATURE } from "../config/defaultThinkingSignature.ts";
Expand Down Expand Up @@ -3813,8 +3817,30 @@ export async function handleChatCore({
isQwenExpiredError) &&
!hadStreamOptions // Skip refresh if failure may be from stream_options removal, not auth
) {
// Fix A: wrap refreshCredentials in runWithOnPersist so the persist callback
// executes INSIDE the per-connection mutex held by getAccessToken. This makes
// [network refresh + DB write + outer-state mutation] one atomic step and
// prevents concurrent requests from reading a stale refreshToken before the
// DB has been updated (refresh_token_reused on Codex/OpenAI).
//
// Not every executor routes refresh through getAccessToken (e.g. github.ts
// calls refreshCopilotToken directly). When the persistFn doesn't fire from
// inside getAccessToken, we still need to do the credentials mutation + user
// callback after refreshCredentials returns. The `persistFnRan` flag tracks
// which path executed so we don't double-fire (race-prone) or skip (regression).
let persistFnRan = false;
const persistFn = onCredentialsRefreshed
? async (refreshResult: any) => {
persistFnRan = true;
// Mutate the shared credentials object so subsequent executor calls
// in this request see the new tokens. Runs INSIDE the mutex.
Object.assign(credentials, refreshResult);
await onCredentialsRefreshed(refreshResult);
}
: undefined;

const newCredentials = (await refreshWithRetry(
() => executor.refreshCredentials(credentials, log),
() => runWithOnPersist(persistFn, () => executor.refreshCredentials(credentials, log)),
3,
Comment on lines 3842 to 3844

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid retrying token refresh when persist callback fails

Wrapping runWithOnPersist(...) inside refreshWithRetry makes persistence errors trigger a second upstream refresh attempt. If onCredentialsRefreshed throws after the first refresh already consumed a rotating refresh token (for example due to a transient DB write failure), the retry reuses the old token and can hit refresh_token_reused, invalidating the account family for providers like Codex/OpenAI. The retry loop should not rerun upstream refresh after post-refresh persistence failures.

Useful? React with 👍 / 👎.

log,
provider // Explicitly pass the provider to avoid universally tripping the "unknown" circuit breaker
Expand All @@ -3826,12 +3852,15 @@ export async function handleChatCore({
if (newCredentials?.accessToken || newCredentials?.copilotToken) {
log?.info?.("TOKEN", `${provider.toUpperCase()} | refreshed`);

// Update credentials
Object.assign(credentials, newCredentials);

// Notify caller about refreshed credentials
if (onCredentialsRefreshed && newCredentials) {
await onCredentialsRefreshed(newCredentials);
// Fall back to post-mutex mutation only for executors that don't route
// through getAccessToken (and therefore never fire onPersist). For
// executors that DO route through it (Codex, Claude, Gemini, etc.) the
// mutation already happened atomically inside the mutex.
if (!persistFnRan) {
Object.assign(credentials, newCredentials);
if (onCredentialsRefreshed) {
await onCredentialsRefreshed(newCredentials);
}
}

// Retry with new credentials — model + extra headers follow translatedBody.model so they
Expand Down
Loading