diff --git a/README.md b/README.md index ff4f621..aca3eab 100644 --- a/README.md +++ b/README.md @@ -82,24 +82,24 @@ process.stdout.write("\n"); ## Reference -| Export | Description | -| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -| `createXaiResponsesAdapter` | `AdapterFactory` for the CLI chat proxy. Per-source `quirks` are ignored. | -| `xaiResponsesQuirks` | The `ResponsesQuirks` the adapter is built from. | -| `xaiOAuthConfig` | `OAuthClientConfig` for `@corbits/oauth-core`: client id, endpoints, loopback redirect, scopes. | -| `exchangeXaiCode(code, verifier, now, fetch?)` | Redeems an authorization code for `XaiTokens`. | -| `refreshXaiTokens(refresh, now, fetch?)` | Refreshes `XaiTokens`, keeping the old refresh token when the response omits one. | -| `xaiTokensFromResponse(response, now, previousRefresh?)` | Maps a token endpoint response to `XaiTokens`, carrying `id_token` as `idToken`. | -| `xaiUserIdFromAccessToken(access)` | Reads the JWT `sub` claim. Returns `undefined` on malformed input; does not verify signatures. | -| `XaiTokens` | Type. | -| `XAI_PROVIDER` | The `"xai"` provider id. | -| `XAI_OAUTH_PROXY_BASE_URL` | `https://cli-chat-proxy.grok.com/v1`, the base URL for OAuth credentials. | -| `XAI_API_KEY_BASE_URL` | `https://api.x.ai/v1`, the base URL for API key credentials. | -| `XAI_DEFAULT_MODELS` | The model ids the CLI chat proxy serves. | -| `XAI_USER_ID_OPTION` | `providerOptions` key sent as the `x-grok-user-id` header. | -| `XAI_SESSION_ID_OPTION` | `providerOptions` key sent as `prompt_cache_key`. | -| `XAI_REASONING_EFFORT_OPTION` | `providerOptions` key sent as `reasoning.effort`. | -| `XAI_REFRESH_SKEW_MS` | Refresh this long before expiry. | +| Export | Description | +| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `createXaiResponsesAdapter` | `AdapterFactory` for the CLI chat proxy. Per-source `quirks` accept only `{ userId? }`; any other key throws. | +| `xaiResponsesQuirks` | The `ResponsesQuirks` the adapter is built from. | +| `xaiOAuthConfig` | `OAuthClientConfig` for `@corbits/oauth-core`: client id, endpoints, loopback redirect, scopes. | +| `exchangeXaiCode(code, verifier, now, fetch?)` | Redeems an authorization code for `XaiTokens`. | +| `refreshXaiTokens(refresh, now, fetch?)` | Refreshes `XaiTokens`, keeping the old refresh token when the response omits one. | +| `xaiTokensFromResponse(response, now, previousRefresh?)` | Maps a token endpoint response to `XaiTokens`, carrying `id_token` as `idToken`. | +| `xaiUserIdFromAccessToken(access)` | Reads the JWT `sub` claim. Returns `undefined` on malformed input; does not verify signatures. | +| `XaiTokens` | Type. | +| `XAI_PROVIDER` | The `"xai"` provider id. | +| `XAI_OAUTH_PROXY_BASE_URL` | `https://cli-chat-proxy.grok.com/v1`, the base URL for OAuth credentials. | +| `XAI_API_KEY_BASE_URL` | `https://api.x.ai/v1`, the base URL for API key credentials. | +| `XAI_DEFAULT_MODELS` | The model ids the CLI chat proxy serves. | +| `XAI_USER_ID_OPTION` | `providerOptions` key sent as the `x-grok-user-id` header. | +| `XAI_SESSION_ID_OPTION` | `providerOptions` key sent as `prompt_cache_key`. | +| `XAI_REASONING_EFFORT_OPTION` | `providerOptions` key sent as `reasoning.effort`. | +| `XAI_REFRESH_SKEW_MS` | Refresh this long before expiry. | The OAuth and client-identity constants behind `xaiOAuthConfig` and the adapter (`XAI_CLIENT_ID`, `XAI_AUTHORIZE_URL`, `XAI_TOKEN_URL`, `XAI_REDIRECT_URI`, `XAI_SCOPES`, `XAI_TOKEN_TIMEOUT_MS`, `XAI_RESPONSES_PATH`, `XAI_CLIENT_IDENTIFIER`, `XAI_CLIENT_VERSION`, `XAI_USER_AGENT`, `XAI_CLIENT_MODE`, `XAI_TOKEN_AUTH`, `XAI_AUTHENTICATE_RESPONSE`) are exported too. @@ -141,7 +141,7 @@ const manifest: AdapterManifest = [ export const deps = createDependencies(await loadAdapterRegistry(manifest)); ``` -Each xAI source points at `XAI_OAUTH_PROXY_BASE_URL`, uses the OAuth access token as its credential, and picks a model from `XAI_DEFAULT_MODELS`. Pass `xaiUserIdFromAccessToken(token)` as `providerOptions[XAI_USER_ID_OPTION]`. +Each xAI source points at `XAI_OAUTH_PROXY_BASE_URL`, uses the OAuth access token as its credential, and picks a model from `XAI_DEFAULT_MODELS`. Pass `xaiUserIdFromAccessToken(token)` as `providerOptions[XAI_USER_ID_OPTION]`, or, for an Interchange catalog source, which carries no `providerOptions`, as the source's `quirks: { userId }`. The option overrides the quirk. The quirk must match the credential's account, so re-set it when the credential is signed in to another account. ## License diff --git a/src/responses-adapter.test.ts b/src/responses-adapter.test.ts index 259f938..2f34088 100644 --- a/src/responses-adapter.test.ts +++ b/src/responses-adapter.test.ts @@ -90,3 +90,23 @@ describe("createXaiResponsesAdapter", () => { expect(parsed).not.toHaveProperty("temperature"); }); }); + +describe("createXaiResponsesAdapter — source quirks", () => { + test("sends the userId quirk as x-grok-user-id, and the option overrides it", () => { + const adapter = createXaiResponsesAdapter(source, { userId: "user-quirk" }); + expect( + adapter.buildRequest(turns, "grok-4.5", {}).headers["x-grok-user-id"], + ).toBe("user-quirk"); + expect( + adapter.buildRequest(turns, "grok-4.5", { + providerOptions: { xaiUserId: "user-option" }, + }).headers["x-grok-user-id"], + ).toBe("user-option"); + }); + + test("rejects a userId that would split the header", () => { + expect(() => createXaiResponsesAdapter(source, { userId: "a\nb" })).toThrow( + /invalid quirks/, + ); + }); +}); diff --git a/src/responses-adapter.ts b/src/responses-adapter.ts index c35339f..56a34af 100644 --- a/src/responses-adapter.ts +++ b/src/responses-adapter.ts @@ -3,6 +3,7 @@ import { type ResponsesQuirks, } from "@corbits/openai-responses"; import type { AdapterFactory } from "@intx/inference"; +import { type } from "arktype"; import { XAI_AUTHENTICATE_RESPONSE, XAI_CLIENT_IDENTIFIER, @@ -50,10 +51,35 @@ export const xaiResponsesQuirks: ResponsesQuirks = { temperature: false, }; +// A catalog source carries no providerOptions, so a deployment states the +// user id here. Visible ASCII only, so a value that is not a valid header +// fails here rather than on every request. +const XaiSourceQuirks = type({ + "userId?": /^[\x21-\x7e]+$/, + "+": "reject", +}); + +const createBaseAdapter = responsesAdapterFactory(xaiResponsesQuirks); + /** * `AdapterFactory` for xAI's Responses-speaking CLI chat proxy, with xAI's * headers, system-prompt placement, and reasoning shape baked in via - * `@corbits/openai-responses`. + * `@corbits/openai-responses`. The source's `quirks` may name the + * `userId` sent as `x-grok-user-id`; a per-call option overrides it. */ -export const createXaiResponsesAdapter: AdapterFactory = - responsesAdapterFactory(xaiResponsesQuirks); +export const createXaiResponsesAdapter: AdapterFactory = (source, quirks) => { + const parsed = XaiSourceQuirks(quirks ?? {}); + if (parsed instanceof type.errors) + throw new Error(`@corbits/xai-provider: invalid quirks: ${parsed.summary}`); + if (parsed.userId === undefined) return createBaseAdapter(source); + return responsesAdapterFactory({ + ...xaiResponsesQuirks, + headers: { + ...xaiResponsesQuirks.headers, + static: { + ...xaiResponsesQuirks.headers?.static, + "x-grok-user-id": parsed.userId, + }, + }, + })(source); +};