Skip to content

Commit d0f5b5b

Browse files
authored
Merge branch 'main' into fix/fallback-trigger-on-upstream-error
2 parents 2cf755c + 47d3d1b commit d0f5b5b

4 files changed

Lines changed: 110 additions & 4 deletions

File tree

extensions/xai/xai-oauth.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,67 @@ describe("xAI OAuth", () => {
211211
expect(refreshed.expires).toBe(121_000);
212212
});
213213

214+
it("rediscovers the current token endpoint for stale xAI OAuth credentials", async () => {
215+
const fetchImpl = vi.fn<typeof fetch>(async (url, init) => {
216+
if (requestUrl(url) === XAI_OAUTH_DISCOVERY_URL) {
217+
expect(init?.method).toBeUndefined();
218+
return jsonResponse({
219+
authorization_endpoint: "https://auth.x.ai/oauth2/authorize",
220+
token_endpoint: "https://auth.x.ai/oauth2/token",
221+
});
222+
}
223+
expect(requestUrl(url)).toBe("https://auth.x.ai/oauth2/token");
224+
expect(init?.method).toBe("POST");
225+
expect(requireStringBody(init)).toContain("refresh_token=refresh-1");
226+
return jsonResponse({
227+
access_token: "access-2",
228+
refresh_token: "refresh-2",
229+
expires_in: 120,
230+
});
231+
});
232+
const credential = {
233+
type: "oauth",
234+
provider: "xai",
235+
access: "access-1",
236+
refresh: "refresh-1",
237+
expires: 100,
238+
tokenEndpoint: "https://auth.x.ai/oauth/token",
239+
} satisfies OAuthCredential & { tokenEndpoint: string };
240+
241+
const refreshed = await refreshXaiOAuthCredential(credential, { fetchImpl, now: () => 1_000 });
242+
243+
expect(fetchImpl).toHaveBeenCalledTimes(2);
244+
expect(fetchImpl.mock.calls.map(([url]) => requestUrl(url))).toEqual([
245+
XAI_OAUTH_DISCOVERY_URL,
246+
"https://auth.x.ai/oauth2/token",
247+
]);
248+
expect(refreshed).toMatchObject({
249+
access: "access-2",
250+
refresh: "refresh-2",
251+
tokenEndpoint: "https://auth.x.ai/oauth2/token",
252+
});
253+
});
254+
255+
it("does not reuse the stale xAI OAuth token endpoint when discovery fails", async () => {
256+
const fetchImpl = vi.fn<typeof fetch>(async (url) => {
257+
expect(requestUrl(url)).toBe(XAI_OAUTH_DISCOVERY_URL);
258+
throw new Error("discovery unavailable");
259+
});
260+
const credential = {
261+
type: "oauth",
262+
provider: "xai",
263+
access: "access-1",
264+
refresh: "refresh-1",
265+
expires: 100,
266+
tokenEndpoint: "https://auth.x.ai/oauth/token",
267+
} satisfies OAuthCredential & { tokenEndpoint: string };
268+
269+
await expect(refreshXaiOAuthCredential(credential, { fetchImpl })).rejects.toThrow(
270+
"discovery unavailable",
271+
);
272+
expect(fetchImpl).toHaveBeenCalledTimes(1);
273+
});
274+
214275
it("does not coerce partial xAI expires_in values", async () => {
215276
const fetchImpl = vi.fn<typeof fetch>(async () =>
216277
jsonResponse({

extensions/xai/xai-oauth.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export const XAI_OAUTH_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
2727
export const XAI_OAUTH_SCOPE = "openid profile email offline_access grok-cli:access api:access";
2828
export const XAI_OAUTH_ISSUER = "https://auth.x.ai";
2929
export const XAI_OAUTH_DISCOVERY_URL = `${XAI_OAUTH_ISSUER}/.well-known/openid-configuration`;
30+
const XAI_LEGACY_OAUTH_TOKEN_ENDPOINT = `${XAI_OAUTH_ISSUER}/oauth/token`;
3031
export const XAI_OAUTH_CALLBACK_HOST = "127.0.0.1";
3132
export const XAI_OAUTH_CALLBACK_PORT = 56121;
3233
export const XAI_OAUTH_CALLBACK_PATH = "/callback";
@@ -492,6 +493,28 @@ function readCredentialString<TKey extends string>(
492493
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
493494
}
494495

496+
async function resolveXaiOAuthRefreshTokenEndpoint(
497+
credential: OAuthCredential,
498+
options: XaiOAuthFetchOptions,
499+
): Promise<string> {
500+
const cachedEndpoint = readCredentialString(credential, "tokenEndpoint");
501+
if (!cachedEndpoint) {
502+
return (await fetchXaiOAuthDiscovery(options)).tokenEndpoint;
503+
}
504+
let endpoint: URL;
505+
try {
506+
endpoint = new URL(cachedEndpoint);
507+
} catch {
508+
return cachedEndpoint;
509+
}
510+
if (`${endpoint.origin}${endpoint.pathname}` !== XAI_LEGACY_OAUTH_TOKEN_ENDPOINT) {
511+
return cachedEndpoint;
512+
}
513+
// Older persisted xAI OAuth credentials can point at the retired endpoint;
514+
// rediscover once so refresh writes back the current OAuth token endpoint.
515+
return (await fetchXaiOAuthDiscovery(options)).tokenEndpoint;
516+
}
517+
495518
async function noteXaiOAuthUrl(ctx: ProviderAuthContext, authorizeUrl: string): Promise<void> {
496519
const lines = ["Open this xAI OAuth URL in your browser:"];
497520
if (ctx.isRemote) {
@@ -665,9 +688,7 @@ export async function refreshXaiOAuthCredential(
665688
if (!refreshToken) {
666689
throw new Error("xAI OAuth credential is missing refresh token");
667690
}
668-
const tokenEndpoint =
669-
readCredentialString(credential, "tokenEndpoint") ??
670-
(await fetchXaiOAuthDiscovery(options)).tokenEndpoint;
691+
const tokenEndpoint = await resolveXaiOAuthRefreshTokenEndpoint(credential, options);
671692
const tokens = await exchangeXaiOAuthToken({
672693
...options,
673694
tokenEndpoint,

packages/media-core/src/mime.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
FILE_TYPE_SNIFF_MAX_BYTES,
99
imageMimeFromFormat,
1010
isAudioFileName,
11+
isGifMedia,
1112
kindFromMime,
1213
mimeTypeFromFilePath,
1314
normalizeMimeType,
@@ -271,6 +272,29 @@ describe("isAudioFileName", () => {
271272
});
272273
});
273274

275+
describe("isGifMedia", () => {
276+
it.each([
277+
{
278+
opts: { contentType: "image/gif; charset=binary" },
279+
expected: true,
280+
},
281+
{
282+
opts: { contentType: " IMAGE/GIF " },
283+
expected: true,
284+
},
285+
{
286+
opts: { contentType: "image/png" },
287+
expected: false,
288+
},
289+
{
290+
opts: { fileName: "animation.GIF" },
291+
expected: true,
292+
},
293+
] as const)("detects GIF media from normalized metadata %#", ({ opts, expected }) => {
294+
expect(isGifMedia(opts)).toBe(expected);
295+
});
296+
});
297+
274298
describe("normalizeMimeType", () => {
275299
function expectNormalizedMimeCase(
276300
input: Parameters<typeof normalizeMimeType>[0],

packages/media-core/src/mime.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ export function isGifMedia(opts: {
252252
contentType?: string | null;
253253
fileName?: string | null;
254254
}): boolean {
255-
if (opts.contentType?.toLowerCase() === "image/gif") {
255+
if (normalizeMimeType(opts.contentType) === "image/gif") {
256256
return true;
257257
}
258258
const ext = getFileExtension(opts.fileName);

0 commit comments

Comments
 (0)