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 docs/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,7 @@ Request shaping:
- `supportsStore` — emit `store: false` on requests. Default: auto (off for non-standard endpoints).
- `supportsDeveloperRole` — use the `developer` system role for reasoning models instead of `system`. Default: auto.
- `sendSessionHeaders` — forward the agent session id as `session_id` and `x-session-id` request headers so OpenAI-compatible relays/proxies can do session-affinity routing and reuse a server-side prompt cache. Default: `false`. Caller-set `headers`/`requestTransform` values are never overwritten.
- `supportsResponsesSessionAffinity` — for `openai-responses`, opt in to forwarding `session_id` and `x-client-request-id` affinity headers to a custom OpenAI-compatible relay. Canonical OpenAI routing remains automatic; known non-OpenAI provider IDs are rejected. Default: `false`.
- `supportsUsageInStreaming` — send `stream_options: { include_usage: true }` to receive token usage on streaming responses. Default: `true`.
- `maxTokensField` — `"max_completion_tokens"` or `"max_tokens"`. Default: auto.
- `supportsToolChoice` — emit the `tool_choice` parameter when the caller forces a specific tool. Default: `true`. Set `false` for endpoints that 400 on `tool_choice` (e.g. DeepSeek when reasoning is on).
Expand Down
3 changes: 3 additions & 0 deletions packages/ai/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# Changelog

## [Unreleased]
### Added

- Added opt-in `compat.supportsResponsesSessionAffinity` for OpenAI Responses custom relays. When enabled, supported `openai-responses` models may send `session_id` and `x-client-request-id` affinity headers to a custom endpoint; canonical OpenAI routing remains automatic and known non-OpenAI provider IDs remain excluded.

### Fixed

Expand Down
1 change: 1 addition & 0 deletions packages/ai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -782,6 +782,7 @@ interface OpenAICompat {
supportsStore?: boolean; // Whether provider supports the `store` field (default: true)
supportsDeveloperRole?: boolean; // Whether provider supports `developer` role vs `system` (default: true)
sendSessionHeaders?: boolean; // Forward the session id as `session_id`/`x-session-id` headers for relay session-affinity & prompt-cache reuse (default: false)
supportsResponsesSessionAffinity?: boolean; // Opt in to session-affinity headers for custom openai-responses relays; canonical OpenAI routing is automatic (default: false)
supportsReasoningEffort?: boolean; // Whether provider supports `reasoning_effort` (default: true)
maxTokensField?: "max_completion_tokens" | "max_tokens"; // Which field name to use (default: max_completion_tokens)
extraBody?: Record<string, unknown>; // Extra request-body fields for custom proxy routing or provider-specific options
Expand Down
13 changes: 12 additions & 1 deletion packages/ai/src/providers/openai-completions-compat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,19 @@ type ResolvedToolStrictMode = NonNullable<OpenAICompat["toolStrictMode"]> | "mix
export type ResolvedOpenAICompat = Required<
Omit<
OpenAICompat,
"openRouterRouting" | "vercelGatewayRouting" | "extraBody" | "toolStrictMode" | "toolChoiceSupport"
| "openRouterRouting"
| "vercelGatewayRouting"
| "extraBody"
| "toolStrictMode"
| "toolChoiceSupport"
| "supportsResponsesSessionAffinity"
>
> & {
openRouterRouting?: OpenAICompat["openRouterRouting"];
vercelGatewayRouting?: OpenAICompat["vercelGatewayRouting"];
extraBody?: OpenAICompat["extraBody"];
toolStrictMode: ResolvedToolStrictMode;
supportsResponsesSessionAffinity?: OpenAICompat["supportsResponsesSessionAffinity"];
/** Optional explicit capability override; resolved via deriveToolChoiceSupport. */
toolChoiceSupport?: OpenAICompat["toolChoiceSupport"];
};
Expand Down Expand Up @@ -204,6 +210,7 @@ export function detectOpenAICompat(model: Model<"openai-completions">, resolvedB
supportsStore: !isNonStandard,
supportsDeveloperRole: !isNonStandard,
sendSessionHeaders: false,
supportsResponsesSessionAffinity: false,
supportsMultipleSystemMessages: supportsMultipleSystemMessagesDefault,
supportsReasoningEffort: !isGrok && !isZai,
reasoningEffortMap,
Expand Down Expand Up @@ -270,6 +277,10 @@ export function resolveOpenAICompat(
supportsStore: model.compat.supportsStore ?? detected.supportsStore,
supportsDeveloperRole: model.compat.supportsDeveloperRole ?? detected.supportsDeveloperRole,
sendSessionHeaders: model.compat.sendSessionHeaders ?? detected.sendSessionHeaders,
supportsResponsesSessionAffinity:
("supportsResponsesSessionAffinity" in model.compat
? model.compat.supportsResponsesSessionAffinity
: undefined) ?? detected.supportsResponsesSessionAffinity,
supportsMultipleSystemMessages:
model.compat.supportsMultipleSystemMessages ?? detected.supportsMultipleSystemMessages,
supportsReasoningEffort: model.compat.supportsReasoningEffort ?? detected.supportsReasoningEffort,
Expand Down
87 changes: 65 additions & 22 deletions packages/ai/src/providers/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,21 @@ import type {
} from "openai/resources/responses/responses";
import packageJson from "../../package.json" with { type: "json" };
import { getEnvApiKey } from "../stream";
import type {
AssistantMessage,
CacheRetention,
Context,
FetchImpl,
MessageAttribution,
Model,
OpenAICompat,
ProviderSessionState,
ServiceTier,
StreamFunction,
StreamOptions,
Tool,
ToolChoice,
import {
type AssistantMessage,
type CacheRetention,
type Context,
type FetchImpl,
isKnownProvider,
type MessageAttribution,
type Model,
type OpenAICompat,
type ProviderSessionState,
type ServiceTier,
type StreamFunction,
type StreamOptions,
type Tool,
type ToolChoice,
} from "../types";
import {
createOpenAIResponsesHistoryPayload,
Expand Down Expand Up @@ -142,6 +143,48 @@ function isDefaultOpenAIBaseUrl(baseUrl: string): boolean {
}
}

function isCanonicalOpenAIAffinityOrigin(baseUrl: string | undefined): boolean {
if (!baseUrl) return false;
try {
const url = new URL(baseUrl);
return (
url.origin === "https://api.openai.com" &&
url.username === "" &&
url.password === "" &&
(url.pathname === "" || url.pathname === "/" || url.pathname === "/v1") &&
url.search === "" &&
url.hash === ""
);
} catch {
return false;
}
}
/**
* Official OpenAI keeps its existing session-routing behavior even when prompt
* caching is disabled. Relay affinity is opt-in, cache-enabled, and limited to
* explicitly supported openai or unknown provider ids so known non-target
* transports cannot inherit the headers.
*/

function shouldSendOpenAIResponsesSessionHeaders(
model: Model<"openai-responses">,
baseUrl: string | undefined,
cacheRetention: CacheRetention,
): boolean {
if (model.provider === "openai") {
if (isCanonicalOpenAIAffinityOrigin(baseUrl)) return true;
return cacheRetention !== "none" && model.compat?.supportsResponsesSessionAffinity === true;
}
if (cacheRetention === "none" || isKnownProvider(model.provider)) {
return false;
}
return (
Boolean(baseUrl?.trim()) &&
model.compat?.supportsResponsesSessionAffinity === true &&
!isCanonicalOpenAIAffinityOrigin(baseUrl)
);
}

function isOpenAIHostBaseUrl(baseUrl: string): boolean {
try {
const url = new URL(baseUrl);
Expand Down Expand Up @@ -305,6 +348,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
try {
// Keep request headers and prompt-cache routing on the same session-derived value.
const cacheSessionId = getOpenAIResponsesCacheSessionId(options);
const cacheRetention = resolveCacheRetention(options?.cacheRetention ?? model.cacheRetention);
const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
const { client, copilotPremiumRequests, baseUrl, requestBaseUrl, requestQuery } = createClient(
model,
Expand All @@ -313,6 +357,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
options?.headers,
options?.initiatorOverride,
cacheSessionId,
cacheRetention,
options?.onSseEvent,
options?.fetch,
options?.authCredentialType,
Expand All @@ -323,7 +368,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
);
const premiumRequestsTotal = copilotPremiumRequests;
const providerSessionState = getOpenAIResponsesProviderSessionState(model, options?.providerSessionState);
const { params } = buildParams(model, context, options, providerSessionState, baseUrl);
const { params } = buildParams(model, context, options, providerSessionState, cacheRetention, baseUrl);
const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs();
options?.onPayload?.(params, undefined, options?.attemptScope);
rawRequestDump = {
Expand Down Expand Up @@ -469,6 +514,7 @@ function createClient(
extraHeaders?: Record<string, string>,
initiatorOverride?: MessageAttribution,
sessionId?: string,
cacheRetention?: CacheRetention,
onSseEvent?: OpenAIResponsesOptions["onSseEvent"],
fetchOverride?: FetchImpl,
authCredentialType?: OpenAIResponsesOptions["authCredentialType"],
Expand Down Expand Up @@ -502,18 +548,14 @@ function createClient(
// `{...default, ...customHeaders}`). #3557.
mergeDashScopeTokenPlanHeaders({ ...(model.headers ?? {}), ...(extraHeaders ?? {}) })
: { ...(model.headers ?? {}), ...(extraHeaders ?? {}) };
const headers = applyOpenAIRequestTransformHeaders(
baseHeaders,
model.requestTransform,
`Gajae-Code/${packageJson.version}`,
);
let copilotPremiumRequests: number | undefined;

let baseUrl =
model.provider === "openai" ? resolveOpenAIProviderBaseUrl(model.baseUrl, authCredentialType) : model.baseUrl;
if (model.provider === "openai" && !baseUrl) {
baseUrl = OPENAI_DEFAULT_BASE_URL;
}
let headers = baseHeaders;
if (model.provider === "github-copilot") {
apiKey = parseGitHubCopilotApiKey(rawApiKey).accessToken;
const hasImages = hasCopilotVisionInput(context.messages);
Expand All @@ -528,10 +570,11 @@ function createClient(
copilotPremiumRequests = copilot.premiumRequests;
baseUrl = resolveGitHubCopilotBaseUrl(model.baseUrl, rawApiKey) ?? model.baseUrl;
}
if (sessionId && model.provider === "openai" && (!model.baseUrl || (baseUrl && isDefaultOpenAIBaseUrl(baseUrl)))) {
if (sessionId && shouldSendOpenAIResponsesSessionHeaders(model, baseUrl, cacheRetention ?? "short")) {
headers.session_id ??= sessionId;
headers["x-client-request-id"] ??= sessionId;
}
headers = applyOpenAIRequestTransformHeaders(headers, model.requestTransform, `Gajae-Code/${packageJson.version}`);
const { baseUrl: clientBaseUrl, query: endpointQuery } = splitBaseUrlQuery(baseUrl);
const baseFetch = fetchOverride ?? fetch;
const queryFetch = Object.assign(
Expand Down Expand Up @@ -579,6 +622,7 @@ function buildParams(
context: Context,
options: OpenAIResponsesOptions | undefined,
providerSessionState: OpenAIResponsesProviderSessionState | undefined,
cacheRetention: CacheRetention,
resolvedBaseUrl?: string,
): { conversationMessages: ResponseInput; params: OpenAIResponsesSamplingParams } {
const strictResponsesPairing =
Expand Down Expand Up @@ -621,7 +665,6 @@ function buildParams(
}
}

const cacheRetention = resolveCacheRetention(options?.cacheRetention ?? model.cacheRetention);
const promptCacheKey = getOpenAIResponsesCacheSessionId(options);
const params: OpenAIResponsesSamplingParams = {
model: model.wireModelId ?? model.id,
Expand Down
132 changes: 74 additions & 58 deletions packages/ai/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,64 +113,73 @@ export interface ThinkingConfig {
mode: ThinkingControlMode;
}

export type KnownProvider =
| "alibaba-token-plan"
| "amazon-bedrock"
| "azure-openai"
| "anthropic"
| "google"
| "google-gemini-cli"
| "google-antigravity"
| "google-vertex"
| "openai"
| "openai-codex"
| "opencodex"
| "kimi-code"
| "minimax-code"
| "minimax-code-cn"
| "github-copilot"
| "fireworks"
| "firepass"
| "fugu"
| "gitlab-duo"
| "cursor"
| "deepseek"
| "deepinfra"
| "xai"
| "groq"
| "cerebras"
| "openrouter"
| "kilo"
| "vercel-ai-gateway"
| "zai"
| "glm-zcode"
| "mistral"
| "minimax"
| "opencode-go"
| "opencode-zen"
| "opengateway"
| "bizrouter"
| "mara"
| "synthetic"
| "cloudflare-ai-gateway"
| "huggingface"
| "litellm"
| "moonshot"
| "nvidia"
| "nanogpt"
| "ollama"
| "ollama-cloud"
| "qianfan"
| "qwen-portal"
| "together"
| "venice"
| "vllm"
| "xiaomi"
| "xiaomi-token-plan-sgp"
| "xiaomi-token-plan-ams"
| "xiaomi-token-plan-cn"
| "zenmux"
| "lm-studio";
export const KNOWN_PROVIDERS = [
"alibaba-token-plan",
"amazon-bedrock",
"azure-openai",
"anthropic",
"google",
"google-gemini-cli",
"google-antigravity",
"google-vertex",
"openai",
"openai-codex",
"opencodex",
"kimi-code",
"minimax-code",
"minimax-code-cn",
"github-copilot",
"fireworks",
"firepass",
"fugu",
"gitlab-duo",
"cursor",
"deepseek",
"deepinfra",
"xai",
"groq",
"cerebras",
"openrouter",
"kilo",
"vercel-ai-gateway",
"zai",
"glm-zcode",
"mistral",
"minimax",
"opencode-go",
"opencode-zen",
"opengateway",
"bizrouter",
"mara",
"synthetic",
"cloudflare-ai-gateway",
"huggingface",
"litellm",
"moonshot",
"nvidia",
"nanogpt",
"ollama",
"ollama-cloud",
"qianfan",
"qwen-portal",
"together",
"venice",
"vllm",
"xiaomi",
"xiaomi-token-plan-sgp",
"xiaomi-token-plan-ams",
"xiaomi-token-plan-cn",
"zenmux",
"lm-studio",
] as const;

export type KnownProvider = (typeof KNOWN_PROVIDERS)[number];

const KNOWN_PROVIDER_SET = new Set<string>(KNOWN_PROVIDERS);

export function isKnownProvider(provider: string): provider is KnownProvider {
return KNOWN_PROVIDER_SET.has(provider);
}
export type Provider = KnownProvider | string;

import type { Effort } from "./model-thinking";
Expand Down Expand Up @@ -852,6 +861,13 @@ export interface OpenAICompat extends ToolChoiceCompat {
* caller already set via `headers`/`requestTransform`.
*/
sendSessionHeaders?: boolean;
/**
* Whether an OpenAI Responses transport may forward the agent session id
* as `session_id` and `x-client-request-id` affinity headers for an
* explicitly configured custom relay. First-party OpenAI uses its canonical
* HTTPS origin automatically; known non-OpenAI providers remain excluded.
*/
supportsResponsesSessionAffinity?: boolean;
/**
* Whether the provider's chat-completions endpoint accepts multiple
* leading `system`/`developer` messages. When false, ordered system
Expand Down
1 change: 1 addition & 0 deletions packages/ai/test/composer-discipline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const compat: Required<OpenAICompat> = {
supportsStore: true,
supportsDeveloperRole: false,
sendSessionHeaders: false,
supportsResponsesSessionAffinity: false,
supportsMultipleSystemMessages: true,
supportsReasoningEffort: false,
reasoningEffortMap: {},
Expand Down
1 change: 1 addition & 0 deletions packages/ai/test/issue-967-vision-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const compat: Required<OpenAICompat> = {
supportsStore: true,
supportsDeveloperRole: true,
sendSessionHeaders: false,
supportsResponsesSessionAffinity: false,
supportsMultipleSystemMessages: true,
supportsReasoningEffort: true,
reasoningEffortMap: {},
Expand Down
Loading
Loading