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
1 change: 1 addition & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Most users do not need these overrides. Only point base URL overrides at endpoin
| `PI_GROK_CLI_TOKEN_TIMEOUT_MS` | `30000` | Timeout for OAuth token requests. |
| `PI_GROK_CLI_IMAGINE_BASE_URL` | `https://api.x.ai/v1` | Override the Imagine API base URL. |
| `PI_GROK_CLI_IMAGINE_MODEL` | `grok-imagine-image-quality` | Override the Imagine image model. |
| `PI_GROK_CLI_VERSION_URL` | `https://x.ai/cli/stable` | Override the URL that returns the latest stable Grok CLI version sent with requests. |

OAuth logins store the effective main API base URL in each account's credentials. Changing or removing `PI_GROK_CLI_BASE_URL` or `GROK_CLI_BASE_URL` does not immediately redirect an already authenticated account; its stored URL remains active until token refresh or re-login. Log in again to apply the change immediately.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Use your X Premium or SuperGrok subscription in [pi](https://pi.dev/) with a cle
- **Image generation:** Generate images directly or let Grok use the `image_gen` tool.
- **Native image input:** Send images directly to supported Grok models, including Composer 2.5.

> Requires pi 0.80.9 or newer and an xAI/Grok account with access to the selected model. Availability varies by account, plan, region, and xAI rollout. The Grok Build executable is not required.
> Requires pi 0.86.0 or newer and an xAI/Grok account with access to the selected model. Availability varies by account, plan, region, and xAI rollout. The Grok Build executable is not required.
>
> This is an unofficial community integration. It does not bypass xAI access controls, quotas, or billing.

Expand Down
232 changes: 117 additions & 115 deletions bun.lock

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,16 @@
]
},
"peerDependencies": {
"@earendil-works/pi-ai": ">=0.80.9",
"@earendil-works/pi-coding-agent": ">=0.80.9",
"@earendil-works/pi-tui": ">=0.80.9"
"@earendil-works/pi-ai": ">=0.86.0",
"@earendil-works/pi-coding-agent": ">=0.86.0",
"@earendil-works/pi-tui": ">=0.86.0"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
"devDependencies": {
"@biomejs/biome": "2.4.16",
"@earendil-works/pi-agent-core": "^0.80.9",
"@earendil-works/pi-ai": "^0.80.9",
"@earendil-works/pi-coding-agent": "^0.80.9",
"@earendil-works/pi-tui": "^0.80.9",
"@earendil-works/pi-agent-core": "^0.86.0",
"@earendil-works/pi-ai": "^0.86.0",
"@earendil-works/pi-coding-agent": "^0.86.0",
"@earendil-works/pi-tui": "^0.86.0",
"@vitest/coverage-v8": "^4.1.8",
"happy-dom": "^20.10.6",
"husky": "^9.1.7",
Expand Down
25 changes: 19 additions & 6 deletions src/provider/proxyRetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@ import type {
export async function* streamWithProxyRetry(options: {
start: () => AssistantMessageEventStream;
rotate?: () => void;
refreshVersion?: () => Promise<void>;
signal?: AbortSignal;
onMessage: (message: AssistantMessage) => void;
}): AsyncGenerator<AssistantMessageEvent> {
for (let attempt = 0; ; attempt += 1) {
let rotations = 0;
let versionRefreshed = false;
for (;;) {
const stream = options.start();
let started = false;
for await (const event of stream) {
Expand All @@ -25,21 +28,31 @@ export async function* streamWithProxyRetry(options: {
}

const message = await stream.result();
const status =
!started && !options.signal?.aborted && message.stopReason === 'error'
? /^grok-cli API error \((\d+)\)/.exec(message.errorMessage ?? '')?.[1]
: undefined;
if (status === '426' && !versionRefreshed && options.refreshVersion) {
versionRefreshed = true;
await options.refreshVersion();
continue;
}
if (
!started &&
!options.signal?.aborted &&
message.stopReason === 'error' &&
/^OpenAI API error \((401|502|520)\)/.test(message.errorMessage ?? '') &&
attempt < 2 &&
(status === '401' || status === '502' || status === '520') &&
rotations < 2 &&
options.rotate
) {
try {
options.rotate();
rotations += 1;
continue;
} catch {
// A failed session write must not replace the original proxy error.
}
}
if (message.stopReason === 'pending') {
throw new Error('Grok CLI response ended without a stop reason');
}
options.onMessage(message);
if (message.stopReason === 'error' || message.stopReason === 'aborted') {
yield { type: 'error', reason: message.stopReason, error: message };
Expand Down
17 changes: 15 additions & 2 deletions src/provider/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,12 @@ import { rememberRequestAccount } from './requestOwnership.js';
import { registerExhaustionRotation } from './rotation.js';
import { createSessionAccountSelection } from './sessionAccountSelection.js';
import { registerSessionConvId } from './sessionConvId.js';
import { grokCliModelHeaders } from './stream.js';
import {
grokCliModelHeaders,
grokCliVersionHeaders,
refreshGrokCliVersion,
resolveGrokCliVersion,
} from './stream.js';
import { registerUsageCommand } from './usage.js';

const marker = (): OAuthCredentials => ({
Expand Down Expand Up @@ -165,7 +170,11 @@ export default function registerGrokCli(pi: ExtensionAPI) {
return lazyStream(model, async () => {
await migration;
if (migrationError) throw new Error(migrationError);
const route = await resolveAccountRoute(accountId);
const [route, resolvedVersion] = await Promise.all([
resolveAccountRoute(accountId),
resolveGrokCliVersion(),
]);
let version = resolvedVersion;
return streamWithProxyRetry({
start: () =>
streamSimpleOpenAIResponses(
Expand All @@ -181,6 +190,7 @@ export default function registerGrokCli(pi: ExtensionAPI) {
// Keep the retry budget here; SDK retries would reuse the failed conversation ID.
maxRetries: 0,
headers: {
...grokCliVersionHeaders(version),
...options?.headers,
...(sessionId ? { 'x-grok-conv-id': convIds.convId(sessionId) } : {}),
},
Expand All @@ -191,6 +201,9 @@ export default function registerGrokCli(pi: ExtensionAPI) {
convIds.rotate(sessionId);
}
: undefined,
refreshVersion: async () => {
version = await refreshGrokCliVersion();
},
signal: options?.signal,
onMessage: (message) => rememberRequestAccount(message, route.accountId),
});
Expand Down
2 changes: 1 addition & 1 deletion src/provider/rotation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
} from './sessionAccountSelection.js';

export const EXHAUSTED_BALANCE_ERROR =
'OpenAI API error (402): 402 "Grok Build usage balance exhausted"';
'grok-cli API error (402): 402 "Grok Build usage balance exhausted"';
export const ROTATION_CONTINUATION =
'Continue the previous request using the newly selected Grok account. Do not repeat completed work.';
const RECENT_EXHAUSTION_COOLDOWN_MS = 5 * 60_000;
Expand Down
72 changes: 52 additions & 20 deletions src/provider/stream.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,62 @@
// Grok CLI client version. Keep it in sync with the version the official Grok
// CLI client emits (observed in captured cli-chat-proxy.grok.com traffic).
export const GROK_CLI_VERSION = '0.2.91';
// Grok CLI release sent when the latest stable release cannot be looked up.
// Keep it at a current official release so it stays above the endpoint minimum.
export const GROK_CLI_VERSION = '1.0.46';

// The version gate reads the client version out of User-Agent. The accepted
// format is the product/version pair the official clients emit — both the pager
// and shell tokens are included for parity with the native client.
export const GROK_CLI_USER_AGENT = `grok-pager/${GROK_CLI_VERSION} grok-shell/${GROK_CLI_VERSION} (macos; aarch64)`;
const STABLE_VERSION_TIMEOUT_MS = 5_000;

export const GROK_CLI_CLIENT_IDENTIFIER = 'grok-pager';
export const GROK_CLI_TOKEN_AUTH = 'xai-grok-cli';
let stableVersion: Promise<string> | undefined;

/**
* Static identification headers the inference endpoint requires on every
* request (the version gate rejects requests whose User-Agent carries no Grok
* version with HTTP 426 "version (none)").
*
* These are attached to each model definition so pi-coding-agent injects them
* via `model.headers` on every request. The dynamic conversation id is added
* through the provider-scoped `before_provider_headers` hook.
* The inference endpoint rejects requests with HTTP 426 when
* `x-grok-client-version` is missing or older than its minimum supported
* release; it ignores User-Agent. Reading the latest stable release from the
* pointer the official installer uses keeps requests above a raised minimum
* without an extension release. The lookup runs once per process until a
* request is rejected by the gate.
*/
export function resolveGrokCliVersion() {
stableVersion ??= fetch(process.env.PI_GROK_CLI_VERSION_URL || 'https://x.ai/cli/stable', {
signal: AbortSignal.timeout(STABLE_VERSION_TIMEOUT_MS),
})
.then(async (response) => {
const version = response.ok ? (await response.text()).trim() : '';
return /^\d+\.\d+\.\d+(-[\w.]+)?$/.test(version) ? version : GROK_CLI_VERSION;
})
.catch(() => GROK_CLI_VERSION);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
return stableVersion;
}

let refreshing: Promise<string> | undefined;

// Discards the cached release after the gate rejects it and looks it up again.
// Concurrent rejections share one lookup so a failed one cannot replace a newer release.
export function refreshGrokCliVersion() {
if (!refreshing) {
stableVersion = undefined;
refreshing = resolveGrokCliVersion().finally(() => {
refreshing = undefined;
});
}
return refreshing;
}

/**
* Static identification headers attached to each model definition so Pi sends
* them on every request. The version headers are added per request by the
* provider stream once the version is resolved.
*/
export function grokCliModelHeaders(modelId: string): Record<string, string> {
return {
'User-Agent': GROK_CLI_USER_AGENT,
'x-grok-client-identifier': GROK_CLI_CLIENT_IDENTIFIER,
'x-grok-client-version': GROK_CLI_VERSION,
'x-xai-token-auth': GROK_CLI_TOKEN_AUTH,
'x-grok-client-identifier': 'grok-shell',
'x-xai-token-auth': 'xai-grok-cli',
'x-grok-model-override': modelId,
};
}

// Same format as the official Grok CLI client's own version headers.
export function grokCliVersionHeaders(version: string): Record<string, string> {
return {
'User-Agent': `grok-shell/${version} (macos; aarch64)`,
'x-grok-client-version': version,
};
}
8 changes: 4 additions & 4 deletions tests/provider/package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ describe('npm package manifest', () => {
expect(existsSync(new URL('../../vitest.config.ts', import.meta.url))).toBe(true);
});

it('declares the Pi runtime version required by dashboard auth', () => {
expect(packageJson.peerDependencies?.['@earendil-works/pi-ai']).toBe('>=0.80.9');
expect(packageJson.peerDependencies?.['@earendil-works/pi-coding-agent']).toBe('>=0.80.9');
expect(packageJson.peerDependencies?.['@earendil-works/pi-tui']).toBe('>=0.80.9');
it('declares the Pi runtime version whose provider error prefix the extension matches', () => {
expect(packageJson.peerDependencies?.['@earendil-works/pi-ai']).toBe('>=0.86.0');
expect(packageJson.peerDependencies?.['@earendil-works/pi-coding-agent']).toBe('>=0.86.0');
expect(packageJson.peerDependencies?.['@earendil-works/pi-tui']).toBe('>=0.86.0');
expect(packageJson.dependencies).toBeUndefined();
expect(packageJson.devDependencies?.['@types/proper-lockfile']).toBeUndefined();
});
Expand Down
Loading
Loading