Skip to content

Commit 016266b

Browse files
cuipengfeiOmX
andcommitted
Keep auto-session tokens tied to auth freshness
Refresh near-expiry auto-session tokens before first covered-model use, invalidate cached sessions when the Copilot auth token changes, and retry Invalid auto-mode selector once after refreshing the session token at the Copilot service boundary. Constraint: Copilot-Session-Token is bound to the auth token context and must not outlive auth token identity changes. Rejected: Handling selector failures in route handlers | session-token injection and upstream workaround ownership belongs in src/services/copilot. Confidence: high Scope-risk: narrow Directive: Keep auto-session retry single-attempt only; do not add generic retries around Copilot upstream errors. Tested: bun run lint:all --fix; bun run build; bun test; bun run typecheck Not-tested: Live Copilot backend probe with real credentials Co-authored-by: OmX <omx@oh-my-codex.dev>
1 parent b3cdd77 commit 016266b

9 files changed

Lines changed: 389 additions & 55 deletions

src/lib/auto-session.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,22 @@ import consola from "consola"
22

33
import { getModelsSession } from "~/services/copilot/get-models-session"
44

5+
import { state } from "./state"
6+
57
interface AutoSessionCache {
68
sessionToken?: string
79
availableModels: Set<string>
810
expiresAt: number
911
hasBeenUsed: boolean
12+
authToken?: string
1013
}
1114

1215
const cache: AutoSessionCache = {
1316
sessionToken: undefined,
1417
availableModels: new Set(),
1518
expiresAt: 0,
1619
hasBeenUsed: false,
20+
authToken: undefined,
1721
}
1822

1923
const FIVE_MINUTES_MS = 5 * 60 * 1000
@@ -24,18 +28,31 @@ const refreshAutoSession = async () => {
2428
cache.availableModels = new Set(session.available_models)
2529
cache.expiresAt = session.expires_at
2630
cache.hasBeenUsed = false
31+
cache.authToken = state.copilotToken
2732
consola.info(
2833
`[auto-session] refreshed token, models=${cache.availableModels.size}`,
2934
)
3035
}
3136

37+
export const invalidateAutoSession = (): void => {
38+
cache.sessionToken = undefined
39+
cache.availableModels = new Set()
40+
cache.expiresAt = 0
41+
cache.hasBeenUsed = false
42+
cache.authToken = undefined
43+
}
44+
3245
const shouldRefresh = (): boolean => {
3346
if (!cache.sessionToken) {
3447
return true
3548
}
3649

50+
if (cache.authToken !== state.copilotToken) {
51+
return true
52+
}
53+
3754
const expiresSoon = cache.expiresAt * 1000 - Date.now() < FIVE_MINUTES_MS
38-
return expiresSoon && cache.hasBeenUsed
55+
return expiresSoon
3956
}
4057

4158
export const prewarmAutoSession = async (): Promise<void> => {

src/lib/token.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import fs from "node:fs/promises"
33
import { setTimeout as delay } from "node:timers/promises"
44

55
import { isOpencodeOauthApp } from "~/lib/api-config"
6+
import { invalidateAutoSession } from "~/lib/auto-session"
67
import { PATHS } from "~/lib/paths"
78
import { getCopilotToken } from "~/services/github/get-copilot-token"
89
import { getCopilotUsage } from "~/services/github/get-copilot-usage"
@@ -43,7 +44,11 @@ function applyCopilotTokenMetadata(
4344
telemetry,
4445
} = metadata
4546

47+
const previousToken = state.copilotToken
4648
state.copilotToken = token
49+
if (previousToken !== token) {
50+
invalidateAutoSession()
51+
}
4752
state.copilotApiUrl = endpoints?.api
4853
state.copilotTrackingId = tracking_id
4954
state.copilotTelemetryEnabled = telemetry === "enabled"
@@ -75,7 +80,11 @@ export const setupCopilotToken = async () => {
7580
if (isOpencodeOauthApp()) {
7681
if (!state.githubToken) throw new Error(`opencode token not found`)
7782

83+
const previousToken = state.copilotToken
7884
state.copilotToken = state.githubToken
85+
if (previousToken !== state.copilotToken) {
86+
invalidateAutoSession()
87+
}
7988

8089
consola.debug("GitHub Copilot token set from opencode auth token")
8190
if (state.showToken) {
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import consola from "consola"
2+
3+
import {
4+
getAutoSessionTokenForModel,
5+
invalidateAutoSession,
6+
} from "~/lib/auto-session"
7+
8+
const INVALID_AUTO_MODE_SELECTOR = "Invalid auto-mode selector"
9+
10+
export const isInvalidAutoModeSelectorResponse = async (
11+
response: Response,
12+
): Promise<boolean> => {
13+
if (response.status !== 401) {
14+
return false
15+
}
16+
17+
const body = await response
18+
.clone()
19+
.text()
20+
.catch(() => "")
21+
22+
return body.includes(INVALID_AUTO_MODE_SELECTOR)
23+
}
24+
25+
export const retryAfterInvalidAutoModeSelector = async (
26+
response: Response,
27+
headers: Record<string, string>,
28+
model: string,
29+
retry: () => Promise<Response>,
30+
): Promise<Response> => {
31+
if (!(await isInvalidAutoModeSelectorResponse(response))) {
32+
return response
33+
}
34+
35+
consola.warn(
36+
"[auto-session] invalid selector, refreshing session and retrying",
37+
)
38+
invalidateAutoSession()
39+
delete headers["Copilot-Session-Token"]
40+
41+
const autoToken = await getAutoSessionTokenForModel(model)
42+
if (autoToken) {
43+
headers["Copilot-Session-Token"] = autoToken
44+
}
45+
46+
return retry()
47+
}

src/services/copilot/create-chat-completions.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import {
2828
trackGhostTextShown,
2929
} from "~/services/telemetry/telemetry"
3030

31+
import { retryAfterInvalidAutoModeSelector } from "./auto-session-retry"
32+
3133
/**
3234
* Check if error response indicates a thinking block issue that can be
3335
* resolved by stripping thinking/reasoning fields and retrying.
@@ -235,11 +237,20 @@ export const createChatCompletions = async (
235237

236238
// First attempt: passthrough unchanged
237239
consola.debug(`<-- model: ${payload.model}`)
238-
const response = await fetch(`${copilotBaseUrl(state)}/chat/completions`, {
239-
method: "POST",
240+
const url = `${copilotBaseUrl(state)}/chat/completions`
241+
const sendRequest = () =>
242+
fetch(url, {
243+
method: "POST",
244+
headers,
245+
body: JSON.stringify(payload),
246+
})
247+
248+
const response = await retryAfterInvalidAutoModeSelector(
249+
await sendRequest(),
240250
headers,
241-
body: JSON.stringify(payload),
242-
})
251+
payload.model,
252+
sendRequest,
253+
)
243254

244255
logCopilotRateLimits(response.headers)
245256

src/services/copilot/create-messages.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ import {
3434
trackGhostTextShown,
3535
} from "~/services/telemetry/telemetry"
3636

37+
import { retryAfterInvalidAutoModeSelector } from "./auto-session-retry"
38+
3739
export interface CreateMessagesOptions {
3840
initiator?: "user" | "agent"
3941
anthropicBeta?: string
@@ -297,11 +299,19 @@ const sendWithSignatureRetry = async (
297299
headers: Record<string, string>,
298300
enhancedPayload: ReturnType<typeof buildEnhancedPayload>,
299301
): Promise<Response> => {
300-
const response = await fetch(url, {
301-
method: "POST",
302+
const sendRequest = () =>
303+
fetch(url, {
304+
method: "POST",
305+
headers,
306+
body: JSON.stringify(enhancedPayload),
307+
})
308+
309+
const response = await retryAfterInvalidAutoModeSelector(
310+
await sendRequest(),
302311
headers,
303-
body: JSON.stringify(enhancedPayload),
304-
})
312+
enhancedPayload.model,
313+
sendRequest,
314+
)
305315

306316
if (response.ok) return response
307317

src/services/copilot/create-responses.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import {
2828
trackGhostTextShown,
2929
} from "~/services/telemetry/telemetry"
3030

31+
import { retryAfterInvalidAutoModeSelector } from "./auto-session-retry"
32+
3133
export interface ResponsesPayload {
3234
model: string
3335
instructions?: string | null
@@ -442,11 +444,20 @@ export const createResponses = async (
442444

443445
consola.debug(`<-- model: ${payload.model}`)
444446

445-
const response = await fetch(`${copilotBaseUrl(state)}/responses`, {
446-
method: "POST",
447+
const url = `${copilotBaseUrl(state)}/responses`
448+
const sendRequest = () =>
449+
fetch(url, {
450+
method: "POST",
451+
headers,
452+
body: JSON.stringify(payload),
453+
})
454+
455+
const response = await retryAfterInvalidAutoModeSelector(
456+
await sendRequest(),
447457
headers,
448-
body: JSON.stringify(payload),
449-
})
458+
payload.model,
459+
sendRequest,
460+
)
450461

451462
logCopilotRateLimits(response.headers)
452463

0 commit comments

Comments
 (0)