Merge remote-tracking branch 'origin/main' into add-kimi-deferred-tools
This commit is contained in:
@@ -283,8 +283,9 @@ describe("Models runtime", () => {
|
||||
expect(offline.getModel("dynamic", "fetched")).toBeDefined();
|
||||
});
|
||||
|
||||
it("passes effective API-key credentials and skips unconfigured providers", async () => {
|
||||
it("passes effective API-key credentials and refresh options while skipping unconfigured providers", async () => {
|
||||
let effectiveCredential: unknown;
|
||||
let forceRefresh: boolean | undefined;
|
||||
let unconfiguredRefreshes = 0;
|
||||
const models = createModels();
|
||||
models.setProvider(
|
||||
@@ -293,6 +294,7 @@ describe("Models runtime", () => {
|
||||
auth: { apiKey: envKeyAuth("ambient-key") },
|
||||
refreshModels: async (context) => {
|
||||
effectiveCredential = context.credential;
|
||||
forceRefresh = context.force;
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -306,8 +308,9 @@ describe("Models runtime", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await models.refresh();
|
||||
await models.refresh({ force: true });
|
||||
expect(effectiveCredential).toEqual({ type: "api_key", key: "ambient-key", env: undefined });
|
||||
expect(forceRefresh).toBe(true);
|
||||
expect(unconfiguredRefreshes).toBe(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { InMemoryCredentialStore } from "../src/auth/credential-store.ts";
|
||||
import { anthropicOAuth } from "../src/auth/oauth/anthropic.ts";
|
||||
import { githubCopilotOAuth } from "../src/auth/oauth/github-copilot.ts";
|
||||
import { openaiCodexOAuth } from "../src/auth/oauth/openai-codex.ts";
|
||||
import { xaiOAuth } from "../src/auth/oauth/xai.ts";
|
||||
import { createModels } from "../src/models.ts";
|
||||
import * as extensionOAuthCompatibility from "../src/oauth.ts";
|
||||
import { anthropicProvider } from "../src/providers/anthropic.ts";
|
||||
@@ -32,6 +33,11 @@ describe.sequential("OAuthAuth adapters", () => {
|
||||
expect(auth).toEqual({ apiKey: "token" });
|
||||
});
|
||||
|
||||
it("xAI toAuth derives the api key from the access token", async () => {
|
||||
const auth = await xaiOAuth.toAuth({ type: "oauth", access: "token", refresh: "r", expires: 0 });
|
||||
expect(auth).toEqual({ apiKey: "token" });
|
||||
});
|
||||
|
||||
it("github-copilot toAuth derives baseUrl from the token proxy endpoint", async () => {
|
||||
const access = "tid=abc;exp=123;proxy-ep=proxy.enterprise.example;rest";
|
||||
const auth = await githubCopilotOAuth.toAuth({ type: "oauth", access, refresh: "r", expires: 0 });
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { xaiOAuth } from "../src/auth/oauth/xai.ts";
|
||||
import type { OAuthCredential } from "../src/auth/types.ts";
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function requestUrl(input: unknown): string {
|
||||
if (typeof input === "string") return input;
|
||||
if (input instanceof URL) return input.toString();
|
||||
if (input instanceof Request) return input.url;
|
||||
throw new Error(`Unsupported request input: ${String(input)}`);
|
||||
}
|
||||
|
||||
function requestForm(init: RequestInit | undefined): URLSearchParams {
|
||||
return new URLSearchParams(String(init?.body));
|
||||
}
|
||||
|
||||
function deviceCodeResponse(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
device_code: "device-code",
|
||||
user_code: "ABCD-1234",
|
||||
verification_uri: "https://accounts.x.ai/oauth2/device",
|
||||
expires_in: 900,
|
||||
interval: 5,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function tokenResponse(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
access_token: "access-token",
|
||||
refresh_token: "refresh-token",
|
||||
expires_in: 21_600,
|
||||
token_type: "Bearer",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
type DeviceCodeInfo = {
|
||||
userCode: string;
|
||||
verificationUri: string;
|
||||
intervalSeconds?: number;
|
||||
expiresInSeconds?: number;
|
||||
};
|
||||
|
||||
function loginXaiForTest(options: {
|
||||
onDeviceCode: (info: DeviceCodeInfo) => void;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<OAuthCredential> {
|
||||
return xaiOAuth.login({
|
||||
signal: options.signal,
|
||||
prompt: () => {
|
||||
throw new Error("Unexpected prompt");
|
||||
},
|
||||
notify: (event) => {
|
||||
if (event.type === "device_code") {
|
||||
const { type: _, ...info } = event;
|
||||
options.onDeviceCode(info);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function refreshXaiForTest(refreshToken: string): Promise<OAuthCredential> {
|
||||
return xaiOAuth.refresh({ type: "oauth", access: "old-access", refresh: refreshToken, expires: 0 });
|
||||
}
|
||||
|
||||
describe("xAI OAuth device flow", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("uses the device grant, delays polling, and handles pending and slow_down", async () => {
|
||||
vi.useFakeTimers();
|
||||
const startTime = new Date("2026-07-09T20:00:00Z");
|
||||
vi.setSystemTime(startTime);
|
||||
const pollTimes: number[] = [];
|
||||
const tokenReplies = [
|
||||
jsonResponse({ error: "authorization_pending" }, 400),
|
||||
jsonResponse({ error: "slow_down", interval: 10 }, 400),
|
||||
jsonResponse(tokenResponse()),
|
||||
];
|
||||
|
||||
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
||||
const url = requestUrl(input);
|
||||
|
||||
if (url === "https://auth.x.ai/oauth2/device/code") {
|
||||
const form = requestForm(init);
|
||||
expect(form.get("client_id")).toBe("b1a00492-073a-47ea-816f-4c329264a828");
|
||||
expect(form.get("scope")).toBe("openid profile email offline_access grok-cli:access api:access");
|
||||
expect(form.get("referrer")).toBe("pi");
|
||||
return jsonResponse(deviceCodeResponse());
|
||||
}
|
||||
|
||||
if (url === "https://auth.x.ai/oauth2/token") {
|
||||
pollTimes.push(Date.now());
|
||||
const form = requestForm(init);
|
||||
expect(form.get("grant_type")).toBe("urn:ietf:params:oauth:grant-type:device_code");
|
||||
expect(form.get("client_id")).toBe("b1a00492-073a-47ea-816f-4c329264a828");
|
||||
expect(form.get("device_code")).toBe("device-code");
|
||||
const reply = tokenReplies.shift();
|
||||
if (!reply) throw new Error("Unexpected token poll");
|
||||
return reply;
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected request: ${url}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const deviceCodes: DeviceCodeInfo[] = [];
|
||||
const loginPromise = loginXaiForTest({ onDeviceCode: (info) => deviceCodes.push(info) });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(deviceCodes).toEqual([
|
||||
{
|
||||
userCode: "ABCD-1234",
|
||||
verificationUri: "https://accounts.x.ai/oauth2/device",
|
||||
intervalSeconds: 5,
|
||||
expiresInSeconds: 900,
|
||||
},
|
||||
]);
|
||||
expect(pollTimes).toEqual([]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(pollTimes).toEqual([startTime.getTime() + 5000]);
|
||||
|
||||
// slow_down raised the interval to 10 seconds
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(pollTimes).toEqual([startTime.getTime() + 5000, startTime.getTime() + 10_000]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
const credentials = await loginPromise;
|
||||
expect(pollTimes).toEqual([
|
||||
startTime.getTime() + 5000,
|
||||
startTime.getTime() + 10_000,
|
||||
startTime.getTime() + 20_000,
|
||||
]);
|
||||
expect(credentials).toEqual({
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: startTime.getTime() + 20_000 + 21_600_000 - 300_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the default poll interval when the response reports interval 0", async () => {
|
||||
vi.useFakeTimers();
|
||||
const startTime = new Date("2026-07-09T20:00:00Z");
|
||||
vi.setSystemTime(startTime);
|
||||
const pollTimes: number[] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: unknown) => {
|
||||
if (requestUrl(input) === "https://auth.x.ai/oauth2/device/code") {
|
||||
return jsonResponse(deviceCodeResponse({ interval: 0 }));
|
||||
}
|
||||
pollTimes.push(Date.now());
|
||||
return jsonResponse(tokenResponse());
|
||||
}),
|
||||
);
|
||||
|
||||
const loginPromise = loginXaiForTest({ onDeviceCode: () => {} });
|
||||
// RFC 8628 default interval is 5 seconds when the server does not require a wait.
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
await loginPromise;
|
||||
expect(pollTimes).toEqual([startTime.getTime() + 5000]);
|
||||
});
|
||||
|
||||
it.each(["http://accounts.x.ai/oauth2/device", "file:///etc/passwd", "not a url"])(
|
||||
"rejects a non-https verification URI: %s",
|
||||
async (verificationUri) => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => jsonResponse(deviceCodeResponse({ verification_uri: verificationUri }))),
|
||||
);
|
||||
|
||||
await expect(loginXaiForTest({ onDeviceCode: () => {} })).rejects.toThrow("Untrusted verification URI");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["access_denied", "authorization_denied"])(
|
||||
"fails when device authorization is denied: %s",
|
||||
async (error) => {
|
||||
vi.useFakeTimers();
|
||||
let requestCount = 0;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
requestCount += 1;
|
||||
return requestCount === 1
|
||||
? jsonResponse(deviceCodeResponse({ interval: 1 }))
|
||||
: jsonResponse({ error }, 400);
|
||||
}),
|
||||
);
|
||||
|
||||
const loginPromise = loginXaiForTest({ onDeviceCode: () => {} });
|
||||
const assertion = expect(loginPromise).rejects.toThrow("xAI device authorization was denied");
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await assertion;
|
||||
},
|
||||
);
|
||||
|
||||
it("cancels while waiting for the first token poll", async () => {
|
||||
vi.useFakeTimers();
|
||||
const controller = new AbortController();
|
||||
const fetchMock = vi.fn(async () => jsonResponse(deviceCodeResponse()));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const loginPromise = loginXaiForTest({
|
||||
onDeviceCode: () => controller.abort(),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
await expect(loginPromise).rejects.toThrow("Login cancelled");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refreshes tokens and preserves an unrotated refresh token", async () => {
|
||||
let requestCount = 0;
|
||||
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
||||
expect(requestUrl(input)).toBe("https://auth.x.ai/oauth2/token");
|
||||
const form = requestForm(init);
|
||||
expect(form.get("grant_type")).toBe("refresh_token");
|
||||
expect(form.get("client_id")).toBe("b1a00492-073a-47ea-816f-4c329264a828");
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) {
|
||||
expect(form.get("refresh_token")).toBe("old-refresh");
|
||||
return jsonResponse(tokenResponse({ access_token: "new-access", refresh_token: "new-refresh" }));
|
||||
}
|
||||
expect(form.get("refresh_token")).toBe("keep-refresh");
|
||||
return jsonResponse(tokenResponse({ access_token: "newer-access", refresh_token: undefined }));
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const rotated = await refreshXaiForTest("old-refresh");
|
||||
const preserved = await refreshXaiForTest("keep-refresh");
|
||||
expect(rotated.type).toBe("oauth");
|
||||
expect(rotated.refresh).toBe("new-refresh");
|
||||
expect(rotated.access).toBe("new-access");
|
||||
expect(preserved.refresh).toBe("keep-refresh");
|
||||
expect(preserved.access).toBe("newer-access");
|
||||
expect(xaiOAuth.name).toBe("xAI (Grok/X subscription)");
|
||||
await expect(xaiOAuth.toAuth(preserved)).resolves.toEqual({ apiKey: "newer-access" });
|
||||
});
|
||||
|
||||
it("assumes a one-hour lifetime when expires_in is missing", async () => {
|
||||
vi.useFakeTimers();
|
||||
const startTime = new Date("2026-07-09T20:00:00Z");
|
||||
vi.setSystemTime(startTime);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => jsonResponse(tokenResponse({ expires_in: undefined }))),
|
||||
);
|
||||
|
||||
const credentials = await refreshXaiForTest("old-refresh");
|
||||
expect(credentials.expires).toBe(startTime.getTime() + 3_600_000 - 300_000);
|
||||
});
|
||||
|
||||
it("rejects token responses with missing fields", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => jsonResponse(tokenResponse({ access_token: undefined }))),
|
||||
);
|
||||
|
||||
await expect(refreshXaiForTest("old-refresh")).rejects.toThrow("Invalid xAI OAuth response field: access_token");
|
||||
});
|
||||
|
||||
it("surfaces the upstream error code and description on refresh failure", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => jsonResponse({ error: "invalid_grant", error_description: "refresh token revoked" }, 400)),
|
||||
);
|
||||
|
||||
await expect(refreshXaiForTest("old-refresh")).rejects.toThrow(
|
||||
"xAI OAuth token refresh failed (HTTP 400): invalid_grant: refresh token revoked",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenAIResponsesOptions } from "../src/api/openai-responses.ts";
|
||||
import { getSupportedThinkingLevels } from "../src/models.ts";
|
||||
import { XAI_MODELS } from "../src/providers/xai.models.ts";
|
||||
import { xaiProvider } from "../src/providers/xai.ts";
|
||||
import type { Context, Model } from "../src/types.ts";
|
||||
|
||||
type CapturedRequest = {
|
||||
url: string;
|
||||
headers: Headers;
|
||||
body: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function completedResponse(): Response {
|
||||
const event = {
|
||||
type: "response.completed",
|
||||
sequence_number: 0,
|
||||
response: {
|
||||
id: "resp_xai_test",
|
||||
status: "completed",
|
||||
output: [],
|
||||
usage: {
|
||||
input_tokens: 1,
|
||||
output_tokens: 1,
|
||||
total_tokens: 2,
|
||||
input_tokens_details: { cached_tokens: 0 },
|
||||
},
|
||||
},
|
||||
};
|
||||
return new Response(`data: ${JSON.stringify(event)}\n\ndata: [DONE]\n\n`, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
|
||||
async function captureRequest(
|
||||
model: Model<"openai-responses">,
|
||||
context: Context,
|
||||
options: OpenAIResponsesOptions,
|
||||
): Promise<CapturedRequest> {
|
||||
let captured: CapturedRequest | undefined;
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
|
||||
const request = new Request(input, init);
|
||||
captured = {
|
||||
url: request.url,
|
||||
headers: request.headers,
|
||||
body: JSON.parse(await request.clone().text()) as Record<string, unknown>,
|
||||
};
|
||||
return completedResponse();
|
||||
});
|
||||
|
||||
const result = await xaiProvider().stream(model, context, options).result();
|
||||
expect(result.stopReason, result.errorMessage).toBe("stop");
|
||||
expect(captured).toBeDefined();
|
||||
return captured!;
|
||||
}
|
||||
|
||||
describe("xAI Responses provider", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("uses Responses with low/medium/high efforts only for Grok 4.5", () => {
|
||||
expect(XAI_MODELS["grok-4.5"].api).toBe("openai-responses");
|
||||
expect(getSupportedThinkingLevels(XAI_MODELS["grok-4.5"])).toEqual(["low", "medium", "high"]);
|
||||
expect(XAI_MODELS["grok-4.3"].api).toBe("openai-completions");
|
||||
});
|
||||
|
||||
it("uses /responses with bearer auth and xAI-compatible request fields", async () => {
|
||||
const captured = await captureRequest(
|
||||
XAI_MODELS["grok-4.5"],
|
||||
{
|
||||
systemPrompt: "You are a careful coding assistant.",
|
||||
messages: [{ role: "user", content: "hello", timestamp: 1 }],
|
||||
},
|
||||
{
|
||||
apiKey: "xai-test-token",
|
||||
sessionId: "pi-session-123",
|
||||
cacheRetention: "long",
|
||||
reasoningEffort: "medium",
|
||||
},
|
||||
);
|
||||
|
||||
expect(captured.url).toBe("https://api.x.ai/v1/responses");
|
||||
expect(captured.headers.get("authorization")).toBe("Bearer xai-test-token");
|
||||
expect(captured.headers.get("session_id")).toBe("pi-session-123");
|
||||
expect(captured.body).toMatchObject({
|
||||
model: "grok-4.5",
|
||||
store: false,
|
||||
stream: true,
|
||||
prompt_cache_key: "pi-session-123",
|
||||
reasoning: { effort: "medium" },
|
||||
include: ["reasoning.encrypted_content"],
|
||||
});
|
||||
expect(captured.body).not.toHaveProperty("prompt_cache_retention");
|
||||
expect(captured.body.input).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
role: "developer",
|
||||
content: "You are a careful coding assistant.",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user