Skip to content
Open
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
8 changes: 1 addition & 7 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,13 +147,7 @@ export const OpenAIAuthPlugin: Plugin = async ({ client }: PluginInput) => {
// Step 1: Check and refresh token if needed
const currentAuth = await getAuth();
if (shouldRefreshToken(currentAuth)) {
const refreshResult = await refreshAndUpdateToken(
currentAuth,
client,
);
if (!refreshResult.success) {
return refreshResult.response;
}
await refreshAndUpdateToken(currentAuth, client);
}

// Step 2: Extract and rewrite URL for Codex backend
Expand Down
74 changes: 21 additions & 53 deletions lib/request/fetch-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import { convertSseToJson, ensureContentType } from "./response-handler.js";
import type { UserConfig, RequestBody } from "../types.js";
import {
PLUGIN_NAME,
HTTP_STATUS,
OPENAI_HEADERS,
OPENAI_HEADER_VALUES,
URL_PATHS,
Expand All @@ -34,26 +33,18 @@ export function shouldRefreshToken(auth: Auth): boolean {
* Refreshes the OAuth token and updates stored credentials
* @param currentAuth - Current auth state
* @param client - Opencode client for updating stored credentials
* @returns Updated auth or error response
* @returns Updated auth state
* @throws Error if token refresh fails
*/
export async function refreshAndUpdateToken(
currentAuth: Auth,
client: OpencodeClient,
): Promise<
{ success: true; auth: Auth } | { success: false; response: Response }
> {
): Promise<Auth> {
const refreshToken = currentAuth.type === "oauth" ? currentAuth.refresh : "";
const refreshResult = await refreshAccessToken(refreshToken);

if (refreshResult.type === "failed") {
console.error(`[${PLUGIN_NAME}] ${ERROR_MESSAGES.TOKEN_REFRESH_FAILED}`);
return {
success: false,
response: new Response(
JSON.stringify({ error: "Token refresh failed" }),
{ status: HTTP_STATUS.UNAUTHORIZED },
),
};
throw new Error(`[${PLUGIN_NAME}] ${ERROR_MESSAGES.TOKEN_REFRESH_FAILED}`);
}

// Update stored credentials
Expand All @@ -74,7 +65,7 @@ export async function refreshAndUpdateToken(
currentAuth.expires = refreshResult.expires;
}

return { success: true, auth: currentAuth };
return currentAuth;
}

/**
Expand Down Expand Up @@ -206,75 +197,57 @@ export function createCodexHeaders(

/**
* Handles error responses from the Codex API
* Logs error details and returns the response for OpenCode/AI SDK to process
* @param response - Error response from API
* @returns Response with error details
* @returns Original response (AI SDK will convert to APICallError)
*/
export async function handleErrorResponse(
response: Response,
): Promise<Response> {
const raw = await response.text();
// Clone response so we can read body for logging while preserving original
const cloned = response.clone();
const raw = await cloned.text();

let enriched = raw;
let friendly_message: string | undefined;
try {
const parsed = JSON.parse(raw) as any;
const err = parsed?.error ?? {};

// Parse Codex rate-limit headers if present
const h = response.headers;
const primary = {
used_percent: toNumber(h.get("x-codex-primary-used-percent")),
window_minutes: toInt(h.get("x-codex-primary-window-minutes")),
resets_at: toInt(h.get("x-codex-primary-reset-at")),
};
const secondary = {
used_percent: toNumber(h.get("x-codex-secondary-used-percent")),
window_minutes: toInt(h.get("x-codex-secondary-window-minutes")),
resets_at: toInt(h.get("x-codex-secondary-reset-at")),
};
const rate_limits =
primary.used_percent !== undefined || secondary.used_percent !== undefined
? { primary, secondary }
: undefined;

// Friendly message for subscription/rate usage limits
const code = (err.code ?? err.type ?? "").toString();
const resetsAt = err.resets_at ?? primary.resets_at ?? secondary.resets_at;
const mins = resetsAt ? Math.max(0, Math.round((resetsAt * 1000 - Date.now()) / 60000)) : undefined;
let friendly_message: string | undefined;
if (/usage_limit_reached|usage_not_included|rate_limit_exceeded/i.test(code) || response.status === 429) {
const plan = err.plan_type ? ` (${String(err.plan_type).toLowerCase()} plan)` : "";
const when = mins !== undefined ? ` Try again in ~${mins} min.` : "";
friendly_message = `You have hit your ChatGPT usage limit${plan}.${when}`.trim();
}

const enhanced = {
error: {
...err,
message: err.message ?? friendly_message ?? "Usage limit reached.",
friendly_message,
rate_limits,
status: response.status,
},
};
enriched = JSON.stringify(enhanced);
} catch {
// Raw body not JSON; leave unchanged
enriched = raw;
// Raw body not JSON; ignore
}

console.error(`[${PLUGIN_NAME}] ${response.status} error:`, enriched);
// Log friendly message if available, otherwise status code
if (friendly_message) {
console.error(`[${PLUGIN_NAME}] ${friendly_message}`);
} else {
console.error(`[${PLUGIN_NAME}] API error: ${response.status} ${response.statusText}`);
}
logRequest(LOG_STAGES.ERROR_RESPONSE, {
status: response.status,
error: enriched,
friendly_message,
});

const headers = new Headers(response.headers);
headers.set("content-type", "application/json; charset=utf-8");
return new Response(enriched, {
status: response.status,
statusText: response.statusText,
headers,
});
// Return original response - OpenCode/AI SDK will handle HTTP errors
return response;
}

/**
Expand Down Expand Up @@ -304,11 +277,6 @@ export async function handleSuccessResponse(
});
}

function toNumber(v: string | null): number | undefined {
if (v == null) return undefined;
const n = Number(v);
return Number.isFinite(n) ? n : undefined;
}
function toInt(v: string | null): number | undefined {
if (v == null) return undefined;
const n = parseInt(v, 10);
Expand Down
79 changes: 69 additions & 10 deletions test/fetch-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { describe, it, expect, vi } from 'vitest';
import {
shouldRefreshToken,
refreshAndUpdateToken,
extractRequestUrl,
rewriteUrlForCodex,
createCodexHeaders,
handleErrorResponse,
} from '../lib/request/fetch-helpers.js';
import * as authModule from '../lib/auth/auth.js';
import type { Auth } from '../lib/types.js';
import { URL_PATHS, OPENAI_HEADERS, OPENAI_HEADER_VALUES } from '../lib/constants.js';
import { URL_PATHS, OPENAI_HEADERS, OPENAI_HEADER_VALUES, PLUGIN_NAME, ERROR_MESSAGES } from '../lib/constants.js';

describe('Fetch Helpers Module', () => {
describe('shouldRefreshToken', () => {
Expand Down Expand Up @@ -42,6 +44,64 @@ describe('Fetch Helpers Module', () => {
});
});

describe('refreshAndUpdateToken', () => {
it('should throw error when token refresh fails', async () => {
vi.spyOn(authModule, 'refreshAccessToken').mockResolvedValue({ type: 'failed' });

const auth: Auth = {
type: 'oauth',
access: 'old-access',
refresh: 'old-refresh',
expires: Date.now() - 1000,
};
const mockClient = { auth: { set: vi.fn() } } as any;

await expect(refreshAndUpdateToken(auth, mockClient)).rejects.toThrow(
`[${PLUGIN_NAME}] ${ERROR_MESSAGES.TOKEN_REFRESH_FAILED}`
);
expect(mockClient.auth.set).not.toHaveBeenCalled();

vi.restoreAllMocks();
});

it('should update auth and return updated state on success', async () => {
const newTokens = {
type: 'success' as const,
access: 'new-access',
refresh: 'new-refresh',
expires: Date.now() + 3600000,
};
vi.spyOn(authModule, 'refreshAccessToken').mockResolvedValue(newTokens);

const auth: Auth = {
type: 'oauth',
access: 'old-access',
refresh: 'old-refresh',
expires: Date.now() - 1000,
};
const mockClient = { auth: { set: vi.fn().mockResolvedValue(undefined) } } as any;

const result = await refreshAndUpdateToken(auth, mockClient);

expect(mockClient.auth.set).toHaveBeenCalledWith({
path: { id: 'openai' },
body: {
type: 'oauth',
access: 'new-access',
refresh: 'new-refresh',
expires: newTokens.expires,
},
});
expect(result.type).toBe('oauth');
if (result.type === 'oauth') {
expect(result.access).toBe('new-access');
expect(result.refresh).toBe('new-refresh');
}

vi.restoreAllMocks();
});
});

describe('extractRequestUrl', () => {
it('should extract URL from string', () => {
const url = 'https://example.com/test';
Expand Down Expand Up @@ -93,7 +153,7 @@ describe('Fetch Helpers Module', () => {
expect(headers.get('accept')).toBe('text/event-stream');
});

it('enriches usage limit errors with friendly message and rate limits', async () => {
it('returns original response for usage limit errors (OpenCode handles display)', async () => {
const body = {
error: {
code: 'usage_limit_reached',
Expand All @@ -107,14 +167,13 @@ describe('Fetch Helpers Module', () => {
'x-codex-primary-reset-at': String(Math.floor(Date.now() / 1000) + 1800),
});
const resp = new Response(JSON.stringify(body), { status: 429, headers });
const enriched = await handleErrorResponse(resp);
expect(enriched.status).toBe(429);
const json = await enriched.json() as any;
expect(json.error).toBeTruthy();
expect(json.error.friendly_message).toMatch(/usage limit/i);
expect(json.error.rate_limits.primary.used_percent).toBe(75);
expect(json.error.rate_limits.primary.window_minutes).toBe(300);
expect(typeof json.error.rate_limits.primary.resets_at).toBe('number');
const result = await handleErrorResponse(resp);
// Should return original response with same status
expect(result.status).toBe(429);
// Body should be preserved (original response, not enriched)
const json = await result.json() as any;
expect(json.error.code).toBe('usage_limit_reached');
expect(json.error.message).toBe('limit reached');
});

it('should remove x-api-key header', () => {
Expand Down