Skip to content

Commit ff09370

Browse files
committed
Merge remote-tracking branch 'origin/main' into dev
2 parents f37304e + bb4f834 commit ff09370

4 files changed

Lines changed: 135 additions & 7 deletions

File tree

src/oauth/index.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity"
1212
import { loginCursor, refreshCursorToken } from "./cursor";
1313
import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive";
1414
import { effectiveGoogleMode } from "../providers/registry";
15+
import { resolveProviderTransport } from "../providers/xai-transport";
1516

1617
const REFRESH_SKEW_MS = 60_000;
1718
const tokenRefreshes = new Map<string, Promise<string>>();
@@ -254,27 +255,28 @@ export async function resolveModelsAuthToken(name: string, prov: OcxProviderConf
254255
* response.
255256
*/
256257
export function buildModelsRequest(prov: OcxProviderConfig, apiKey: string | undefined, providerName = ""): { url: string; headers: Record<string, string> } {
257-
const headers: Record<string, string> = { ...(prov.headers ?? {}) };
258-
if (effectiveGoogleMode(providerName, prov) === "ai-studio") {
258+
const effectiveProvider = resolveProviderTransport(providerName, prov);
259+
const headers: Record<string, string> = { ...(effectiveProvider.headers ?? {}) };
260+
if (effectiveGoogleMode(providerName, effectiveProvider) === "ai-studio") {
259261
// Generative Language API: API key goes in x-goog-api-key (never Authorization: Bearer),
260262
// models live under /v1beta (v1 misses preview models), and pageSize maxes at 1000 —
261263
// enough to list everything without a pageToken loop. Vertex/antigravity keep the
262264
// generic branch (they fall back to their static model lists).
263265
if (apiKey) headers["x-goog-api-key"] = apiKey;
264-
return { url: `${prov.baseUrl}/v1beta/models?pageSize=1000`, headers };
266+
return { url: `${effectiveProvider.baseUrl}/v1beta/models?pageSize=1000`, headers };
265267
}
266-
if (prov.adapter === "anthropic") {
268+
if (effectiveProvider.adapter === "anthropic") {
267269
headers["anthropic-version"] = "2023-06-01";
268-
if (prov.authMode === "oauth") {
270+
if (effectiveProvider.authMode === "oauth") {
269271
headers["anthropic-beta"] = ANTHROPIC_OAUTH_BETA;
270272
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
271273
} else if (apiKey) {
272274
headers["x-api-key"] = apiKey;
273275
}
274-
return { url: `${prov.baseUrl}/v1/models?limit=1000`, headers };
276+
return { url: `${effectiveProvider.baseUrl}/v1/models?limit=1000`, headers };
275277
}
276278
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
277-
return { url: `${prov.baseUrl}/models`, headers };
279+
return { url: `${effectiveProvider.baseUrl}/models`, headers };
278280
}
279281

280282
/**

src/providers/xai-transport.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { OcxProviderConfig } from "../types";
2+
3+
/**
4+
* xAI account OAuth and xAI API keys share a bearer shape but not a billing
5+
* transport. OAuth represents the Grok CLI subscription entitlement, while a
6+
* key represents the API team. Keep the saved provider preset compatible with
7+
* the dashboard's "Use an API key instead" switch and resolve the transport at
8+
* request time.
9+
*/
10+
export const XAI_GROK_CLI_BASE_URL = "https://cli-chat-proxy.grok.com/v1";
11+
12+
/** Minimum-compatible official Grok CLI wire version verified with the proxy. */
13+
export const XAI_GROK_CLIENT_VERSION = "0.2.93";
14+
15+
const XAI_GROK_CLI_HEADERS: Readonly<Record<string, string>> = {
16+
"x-grok-client-identifier": "opencodex",
17+
"x-grok-client-version": XAI_GROK_CLIENT_VERSION,
18+
"x-xai-token-auth": "xai-grok-cli",
19+
};
20+
21+
/**
22+
* Resolve the effective xAI transport without mutating persisted config.
23+
* User-provided headers are preserved and may advance the compatibility
24+
* version without waiting for an opencodex release.
25+
*/
26+
export function resolveProviderTransport(
27+
providerName: string,
28+
provider: OcxProviderConfig,
29+
): OcxProviderConfig {
30+
if (providerName !== "xai" || provider.authMode !== "oauth") return provider;
31+
return {
32+
...provider,
33+
baseUrl: XAI_GROK_CLI_BASE_URL,
34+
headers: {
35+
...XAI_GROK_CLI_HEADERS,
36+
...(provider.headers ?? {}),
37+
},
38+
};
39+
}

src/server/responses.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { isUsageDebugEnabled } from "../usage/debug";
4141
import { readJsonRequestBody, DecompressedBodyTooLargeError, UnsupportedContentEncodingError } from "./request-decompress";
4242
import { resolveAdapter, resolveWireProtocolOverride } from "./adapter-resolve";
4343
import { hasKeyPoolFailover, rotateKeyOn429 } from "../providers/key-failover";
44+
import { resolveProviderTransport } from "../providers/xai-transport";
4445
import type { WsData } from "./ws-bridge";
4546
import { registerTurn, trackStreamLifetime, unregisterTurn } from "./lifecycle";
4647
import { redactSecretString } from "../lib/redact";
@@ -612,6 +613,7 @@ export async function handleResponses(
612613
return formatErrorResponse(401, "authentication_error", err instanceof Error ? err.message : String(err));
613614
}
614615
}
616+
route.provider = resolveProviderTransport(route.providerName, route.provider);
615617

616618
// Vision sidecar: the routed model can't see images (provider.noVisionModels). Describe each
617619
// attached image through the selected sidecar backend and replace it with text BEFORE the main

tests/xai-transport.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { createOpenAIChatAdapter } from "../src/adapters/openai-chat";
3+
import { buildModelsRequest } from "../src/oauth";
4+
import {
5+
resolveProviderTransport,
6+
XAI_GROK_CLI_BASE_URL,
7+
XAI_GROK_CLIENT_VERSION,
8+
} from "../src/providers/xai-transport";
9+
import type { OcxParsedRequest, OcxProviderConfig } from "../src/types";
10+
11+
function provider(authMode: "oauth" | "key"): OcxProviderConfig {
12+
return {
13+
adapter: "openai-chat",
14+
baseUrl: "https://api.x.ai/v1",
15+
authMode,
16+
apiKey: authMode === "oauth" ? "oauth-token" : "xai-api-key",
17+
defaultModel: "grok-4.5",
18+
};
19+
}
20+
21+
function parsed(): OcxParsedRequest {
22+
return {
23+
modelId: "grok-4.5",
24+
context: { messages: [{ role: "user", content: "hi", timestamp: 0 }] },
25+
stream: false,
26+
options: { reasoning: "low" },
27+
};
28+
}
29+
30+
describe("xAI auth-mode transport selection", () => {
31+
test("OAuth selects the Grok CLI subscription transport and required headers", () => {
32+
const effective = resolveProviderTransport("xai", provider("oauth"));
33+
const request = createOpenAIChatAdapter(effective).buildRequest(parsed());
34+
35+
expect(effective.baseUrl).toBe(XAI_GROK_CLI_BASE_URL);
36+
expect(request.url).toBe(`${XAI_GROK_CLI_BASE_URL}/chat/completions`);
37+
expect(request.headers).toMatchObject({
38+
Authorization: "Bearer oauth-token",
39+
"x-grok-client-identifier": "opencodex",
40+
"x-grok-client-version": XAI_GROK_CLIENT_VERSION,
41+
"x-xai-token-auth": "xai-grok-cli",
42+
});
43+
});
44+
45+
test("OAuth model discovery uses the subscription transport", () => {
46+
const request = buildModelsRequest(provider("oauth"), "oauth-token", "xai");
47+
48+
expect(request.url).toBe(`${XAI_GROK_CLI_BASE_URL}/models`);
49+
expect(request.headers).toMatchObject({
50+
Authorization: "Bearer oauth-token",
51+
"x-grok-client-identifier": "opencodex",
52+
"x-grok-client-version": XAI_GROK_CLIENT_VERSION,
53+
"x-xai-token-auth": "xai-grok-cli",
54+
});
55+
});
56+
57+
test("API key keeps the xAI API transport without subscription headers", () => {
58+
const configured = provider("key");
59+
const effective = resolveProviderTransport("xai", configured);
60+
const request = createOpenAIChatAdapter(effective).buildRequest(parsed());
61+
const modelsRequest = buildModelsRequest(configured, "xai-api-key", "xai");
62+
63+
expect(effective).toBe(configured);
64+
expect(request.url).toBe("https://api.x.ai/v1/chat/completions");
65+
expect(modelsRequest.url).toBe("https://api.x.ai/v1/models");
66+
expect(request.headers).toEqual({
67+
"Content-Type": "application/json",
68+
Authorization: "Bearer xai-api-key",
69+
});
70+
expect(modelsRequest.headers).toEqual({ Authorization: "Bearer xai-api-key" });
71+
});
72+
73+
test("custom providers and configured header overrides remain untouched", () => {
74+
const custom = provider("oauth");
75+
custom.headers = { "x-grok-client-version": "0.2.94", "x-custom": "kept" };
76+
77+
expect(resolveProviderTransport("custom-xai", custom)).toBe(custom);
78+
expect(resolveProviderTransport("xai", custom).headers).toMatchObject({
79+
"x-grok-client-version": "0.2.94",
80+
"x-custom": "kept",
81+
"x-grok-client-identifier": "opencodex",
82+
"x-xai-token-auth": "xai-grok-cli",
83+
});
84+
});
85+
});

0 commit comments

Comments
 (0)