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
68 changes: 45 additions & 23 deletions packages/opencode/src/altimate/plugin/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,29 +76,51 @@ export async function AnthropicAuthPlugin(input: PluginInput): Promise<Hooks> {
const currentAuth = await getAuth()
if (currentAuth.type !== "oauth") return fetch(requestInput, init)

// Refresh token if expired
if (!currentAuth.access || currentAuth.expires < Date.now()) {
const response = await fetch("https://console.anthropic.com/v1/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "refresh_token",
refresh_token: currentAuth.refresh,
client_id: CLIENT_ID,
}),
})
if (!response.ok) throw new Error(`Token refresh failed: ${response.status}`)
const json: TokenResponse = await response.json()
await input.client.auth.set({
path: { id: "anthropic" },
body: {
type: "oauth",
refresh: json.refresh_token,
access: json.access_token,
expires: Date.now() + json.expires_in * 1000,
},
})
currentAuth.access = json.access_token
// Refresh token if expired or about to expire (30s buffer)
if (!currentAuth.access || currentAuth.expires < Date.now() + 30_000) {
let lastError: Error | undefined
for (let attempt = 0; attempt < 3; attempt++) {
try {
const response = await fetch("https://console.anthropic.com/v1/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "refresh_token",
refresh_token: currentAuth.refresh,
client_id: CLIENT_ID,
}),
})
if (!response.ok) {
const body = await response.text().catch(() => "")
throw new Error(
`Anthropic OAuth token refresh failed (HTTP ${response.status}). ` +
`Try re-authenticating: altimate-code auth login anthropic` +
(body ? ` — ${body.slice(0, 200)}` : ""),
)
}
const json: TokenResponse = await response.json()
await input.client.auth.set({
path: { id: "anthropic" },
body: {
type: "oauth",
refresh: json.refresh_token,
access: json.access_token,
expires: Date.now() + json.expires_in * 1000,
},
})
currentAuth.access = json.access_token
currentAuth.expires = Date.now() + json.expires_in * 1000
lastError = undefined
break
} catch (e) {
lastError = e instanceof Error ? e : new Error(String(e))
// Don't retry on 4xx (permanent auth failures) — only retry on network errors / 5xx
const is4xx = lastError.message.includes("HTTP 4")
if (is4xx || attempt >= 2) break
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)))
}
}
if (lastError) throw lastError
}

// Build headers from incoming request
Expand Down
45 changes: 31 additions & 14 deletions packages/opencode/src/plugin/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,19 +128,36 @@ async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: Pk
}

async function refreshAccessToken(refreshToken: string): Promise<TokenResponse> {
const response = await fetch(`${ISSUER}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: CLIENT_ID,
}).toString(),
})
if (!response.ok) {
throw new Error(`Token refresh failed: ${response.status}`)
let lastError: Error | undefined
for (let attempt = 0; attempt < 3; attempt++) {
try {
const response = await fetch(`${ISSUER}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: CLIENT_ID,
}).toString(),
})
if (!response.ok) {
const body = await response.text().catch(() => "")
throw new Error(
`Codex OAuth token refresh failed (HTTP ${response.status}). ` +
`Try re-authenticating: altimate-code auth login openai` +
(body ? ` — ${body.slice(0, 200)}` : ""),
)
}
return response.json()
} catch (e) {
lastError = e instanceof Error ? e : new Error(String(e))
// Don't retry on 4xx (permanent auth failures) — only retry on network errors / 5xx
const is4xx = lastError.message.includes("HTTP 4")
if (is4xx || attempt >= 2) break
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)))
}
}
return response.json()
throw lastError ?? new Error("Token refresh failed after retries")
}

const HTML_SUCCESS = `<!doctype html>
Expand Down Expand Up @@ -436,8 +453,8 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
// Cast to include accountId field
const authWithAccount = currentAuth as typeof currentAuth & { accountId?: string }

// Check if token needs refresh
if (!currentAuth.access || currentAuth.expires < Date.now()) {
// Check if token needs refresh (30s buffer to avoid edge-case expiry during request)
if (!currentAuth.access || currentAuth.expires < Date.now() + 30_000) {
log.info("refreshing codex access token")
const tokens = await refreshAccessToken(currentAuth.refresh)
const newAccountId = extractAccountId(tokens) || authWithAccount.accountId
Expand Down
18 changes: 16 additions & 2 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -882,8 +882,22 @@ export namespace MessageV2 {
},
{ cause: e },
).toObject()
case e instanceof Error:
return new NamedError.Unknown({ message: e.toString() }, { cause: e }).toObject()
case e instanceof Error: {
const msg = e.message || e.name || "Unknown error"
// Token refresh failures should surface as auth errors with recovery instructions
if (/OAuth token refresh failed/i.test(msg)) {
return new MessageV2.AuthError(
{
providerID: ctx.providerID,
message: msg,
},
{ cause: e },
).toObject()
}
// Include error class name for better diagnostics when message is generic
const displayMsg = msg === "Error" || msg === e.name ? `${e.name}: ${e.stack?.split("\n")[0] || "unknown error"}` : msg
return new NamedError.Unknown({ message: displayMsg }, { cause: e }).toObject()
}
default:
try {
const parsed = ProviderError.parseStreamError(e)
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/session/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ export namespace SessionRetry {
export function retryable(error: ReturnType<NamedError["toObject"]>) {
// context overflow errors should not be retried
if (MessageV2.ContextOverflowError.isInstance(error)) return undefined
// auth errors (token refresh failures) should be retried — the token may refresh on next attempt
if (MessageV2.AuthError.isInstance(error)) {
return `Authentication failed — retrying. If this persists, run: altimate-code auth login ${error.data.providerID}`
}
if (MessageV2.APIError.isInstance(error)) {
if (!error.data.isRetryable) return undefined
if (error.data.responseBody?.includes("FreeUsageLimitError"))
Expand Down
58 changes: 58 additions & 0 deletions packages/opencode/test/session/retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,29 @@ describe("session.retry.retryable", () => {

expect(SessionRetry.retryable(error)).toBeUndefined()
})

test("retries auth errors with recovery message", () => {
const error = new MessageV2.AuthError({
providerID: "anthropic",
message: "Anthropic OAuth token refresh failed (HTTP 401)",
}).toObject() as ReturnType<NamedError["toObject"]>

const result = SessionRetry.retryable(error)
expect(result).toBeDefined()
expect(result).toContain("Authentication failed")
expect(result).toContain("altimate-code auth login anthropic")
})

test("retries auth errors for other providers", () => {
const error = new MessageV2.AuthError({
providerID: "openai",
message: "Codex OAuth token refresh failed (HTTP 403)",
}).toObject() as ReturnType<NamedError["toObject"]>

const result = SessionRetry.retryable(error)
expect(result).toBeDefined()
expect(result).toContain("altimate-code auth login openai")
})
})

describe("session.message-v2.fromError", () => {
Expand Down Expand Up @@ -173,6 +196,41 @@ describe("session.message-v2.fromError", () => {
expect(retryable).toBe("Connection reset by server")
})

test("converts token refresh failure to ProviderAuthError", () => {
const error = new Error("Anthropic OAuth token refresh failed (HTTP 401). Try re-authenticating: altimate-code auth login anthropic")
const result = MessageV2.fromError(error, { providerID: "anthropic" })

expect(result.name).toBe("ProviderAuthError")
expect((result as any).data.providerID).toBe("anthropic")
expect((result as any).data.message).toContain("token refresh failed")
})

test("converts codex token refresh failure to ProviderAuthError", () => {
const error = new Error("Codex OAuth token refresh failed (HTTP 403). Try re-authenticating: altimate-code auth login openai")
const result = MessageV2.fromError(error, { providerID: "openai" })

expect(result.name).toBe("ProviderAuthError")
expect((result as any).data.providerID).toBe("openai")
})

test("provides descriptive message for generic Error with no message", () => {
const error = new Error()
const result = MessageV2.fromError(error, { providerID: "test" })

expect(result.name).toBe("UnknownError")
// Should not be just "Error" — should include stack or context
expect((result as any).data.message).not.toBe("Error")
expect((result as any).data.message.length).toBeGreaterThan(5)
})

test("provides descriptive message for TypeError with no message", () => {
const error = new TypeError()
const result = MessageV2.fromError(error, { providerID: "test" })

expect(result.name).toBe("UnknownError")
expect((result as any).data.message).toContain("TypeError")
})

test("marks OpenAI 404 status codes as retryable", () => {
const error = new APICallError({
message: "boom",
Expand Down
Loading