mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 07:03:25 +00:00
feat(ai,agent,coding-agent): add sessionId for provider session-based caching
- Add sessionId to StreamOptions for providers that support session-based caching - OpenAI Codex provider uses sessionId for prompt_cache_key and routing headers - Agent class now accepts and forwards sessionId to stream functions - coding-agent passes session ID from SessionManager and updates on session changes - Update ai package README with table of contents, OpenAI Codex OAuth docs, and env vars table - Increase Codex instructions cache TTL from 15 minutes to 24 hours - Add tests for sessionId forwarding in ai and agent packages
This commit is contained in:
parent
858c6bae8a
commit
edb0da9611
14 changed files with 335 additions and 56 deletions
|
|
@ -2,6 +2,10 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `sessionId` option on `Agent` to forward session identifiers to LLM providers for session-based caching.
|
||||||
|
|
||||||
## [0.37.2] - 2026-01-05
|
## [0.37.2] - 2026-01-05
|
||||||
|
|
||||||
## [0.37.1] - 2026-01-05
|
## [0.37.1] - 2026-01-05
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,12 @@ export interface AgentOptions {
|
||||||
*/
|
*/
|
||||||
streamFn?: StreamFn;
|
streamFn?: StreamFn;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional session identifier forwarded to LLM providers.
|
||||||
|
* Used by providers that support session-based caching (e.g., OpenAI Codex).
|
||||||
|
*/
|
||||||
|
sessionId?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves an API key dynamically for each LLM call.
|
* Resolves an API key dynamically for each LLM call.
|
||||||
* Useful for expiring tokens (e.g., GitHub Copilot OAuth).
|
* Useful for expiring tokens (e.g., GitHub Copilot OAuth).
|
||||||
|
|
@ -89,6 +95,7 @@ export class Agent {
|
||||||
private steeringMode: "all" | "one-at-a-time";
|
private steeringMode: "all" | "one-at-a-time";
|
||||||
private followUpMode: "all" | "one-at-a-time";
|
private followUpMode: "all" | "one-at-a-time";
|
||||||
public streamFn: StreamFn;
|
public streamFn: StreamFn;
|
||||||
|
private _sessionId?: string;
|
||||||
public getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
|
public getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
|
||||||
private runningPrompt?: Promise<void>;
|
private runningPrompt?: Promise<void>;
|
||||||
private resolveRunningPrompt?: () => void;
|
private resolveRunningPrompt?: () => void;
|
||||||
|
|
@ -100,9 +107,25 @@ export class Agent {
|
||||||
this.steeringMode = opts.steeringMode || "one-at-a-time";
|
this.steeringMode = opts.steeringMode || "one-at-a-time";
|
||||||
this.followUpMode = opts.followUpMode || "one-at-a-time";
|
this.followUpMode = opts.followUpMode || "one-at-a-time";
|
||||||
this.streamFn = opts.streamFn || streamSimple;
|
this.streamFn = opts.streamFn || streamSimple;
|
||||||
|
this._sessionId = opts.sessionId;
|
||||||
this.getApiKey = opts.getApiKey;
|
this.getApiKey = opts.getApiKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current session ID used for provider caching.
|
||||||
|
*/
|
||||||
|
get sessionId(): string | undefined {
|
||||||
|
return this._sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the session ID for provider caching.
|
||||||
|
* Call this when switching sessions (new session, branch, resume).
|
||||||
|
*/
|
||||||
|
set sessionId(value: string | undefined) {
|
||||||
|
this._sessionId = value;
|
||||||
|
}
|
||||||
|
|
||||||
get state(): AgentState {
|
get state(): AgentState {
|
||||||
return this._state;
|
return this._state;
|
||||||
}
|
}
|
||||||
|
|
@ -286,6 +309,7 @@ export class Agent {
|
||||||
const config: AgentLoopConfig = {
|
const config: AgentLoopConfig = {
|
||||||
model,
|
model,
|
||||||
reasoning,
|
reasoning,
|
||||||
|
sessionId: this._sessionId,
|
||||||
convertToLlm: this.convertToLlm,
|
convertToLlm: this.convertToLlm,
|
||||||
transformContext: this.transformContext,
|
transformContext: this.transformContext,
|
||||||
getApiKey: this.getApiKey,
|
getApiKey: this.getApiKey,
|
||||||
|
|
|
||||||
|
|
@ -229,4 +229,30 @@ describe("Agent", () => {
|
||||||
agent.abort();
|
agent.abort();
|
||||||
await firstPrompt.catch(() => {});
|
await firstPrompt.catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("forwards sessionId to streamFn options", async () => {
|
||||||
|
let receivedSessionId: string | undefined;
|
||||||
|
const agent = new Agent({
|
||||||
|
sessionId: "session-abc",
|
||||||
|
streamFn: (_model, _context, options) => {
|
||||||
|
receivedSessionId = options?.sessionId;
|
||||||
|
const stream = new MockAssistantStream();
|
||||||
|
queueMicrotask(() => {
|
||||||
|
const message = createAssistantMessage("ok");
|
||||||
|
stream.push({ type: "done", reason: "stop", message });
|
||||||
|
});
|
||||||
|
return stream;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await agent.prompt("hello");
|
||||||
|
expect(receivedSessionId).toBe("session-abc");
|
||||||
|
|
||||||
|
// Test setter
|
||||||
|
agent.sessionId = "session-def";
|
||||||
|
expect(agent.sessionId).toBe("session-def");
|
||||||
|
|
||||||
|
await agent.prompt("hello again");
|
||||||
|
expect(receivedSessionId).toBe("session-def");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,10 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `sessionId` option in `StreamOptions` for providers that support session-based caching. OpenAI Codex provider uses this to set `prompt_cache_key` and routing headers.
|
||||||
|
|
||||||
## [0.37.2] - 2026-01-05
|
## [0.37.2] - 2026-01-05
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,50 @@ Unified LLM API with automatic model discovery, provider configuration, token an
|
||||||
|
|
||||||
**Note**: This library only includes models that support tool calling (function calling), as this is essential for agentic workflows.
|
**Note**: This library only includes models that support tool calling (function calling), as this is essential for agentic workflows.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Supported Providers](#supported-providers)
|
||||||
|
- [Installation](#installation)
|
||||||
|
- [Quick Start](#quick-start)
|
||||||
|
- [Tools](#tools)
|
||||||
|
- [Defining Tools](#defining-tools)
|
||||||
|
- [Handling Tool Calls](#handling-tool-calls)
|
||||||
|
- [Streaming Tool Calls with Partial JSON](#streaming-tool-calls-with-partial-json)
|
||||||
|
- [Validating Tool Arguments](#validating-tool-arguments)
|
||||||
|
- [Complete Event Reference](#complete-event-reference)
|
||||||
|
- [Image Input](#image-input)
|
||||||
|
- [Thinking/Reasoning](#thinkingreasoning)
|
||||||
|
- [Unified Interface](#unified-interface-streamsimplecompletesimple)
|
||||||
|
- [Provider-Specific Options](#provider-specific-options-streamcomplete)
|
||||||
|
- [Streaming Thinking Content](#streaming-thinking-content)
|
||||||
|
- [Stop Reasons](#stop-reasons)
|
||||||
|
- [Error Handling](#error-handling)
|
||||||
|
- [Aborting Requests](#aborting-requests)
|
||||||
|
- [Continuing After Abort](#continuing-after-abort)
|
||||||
|
- [APIs, Models, and Providers](#apis-models-and-providers)
|
||||||
|
- [Providers and Models](#providers-and-models)
|
||||||
|
- [Querying Providers and Models](#querying-providers-and-models)
|
||||||
|
- [Custom Models](#custom-models)
|
||||||
|
- [OpenAI Compatibility Settings](#openai-compatibility-settings)
|
||||||
|
- [Type Safety](#type-safety)
|
||||||
|
- [Cross-Provider Handoffs](#cross-provider-handoffs)
|
||||||
|
- [Context Serialization](#context-serialization)
|
||||||
|
- [Browser Usage](#browser-usage)
|
||||||
|
- [Environment Variables](#environment-variables-nodejs-only)
|
||||||
|
- [Checking Environment Variables](#checking-environment-variables)
|
||||||
|
- [OAuth Providers](#oauth-providers)
|
||||||
|
- [Vertex AI (ADC)](#vertex-ai-adc)
|
||||||
|
- [CLI Login](#cli-login)
|
||||||
|
- [Programmatic OAuth](#programmatic-oauth)
|
||||||
|
- [Login Flow Example](#login-flow-example)
|
||||||
|
- [Using OAuth Tokens](#using-oauth-tokens)
|
||||||
|
- [Provider Notes](#provider-notes)
|
||||||
|
- [License](#license)
|
||||||
|
|
||||||
## Supported Providers
|
## Supported Providers
|
||||||
|
|
||||||
- **OpenAI**
|
- **OpenAI**
|
||||||
|
- **OpenAI Codex** (ChatGPT Plus/Pro subscription, requires OAuth, see below)
|
||||||
- **Anthropic**
|
- **Anthropic**
|
||||||
- **Google**
|
- **Google**
|
||||||
- **Vertex AI** (Gemini via Vertex AI)
|
- **Vertex AI** (Gemini via Vertex AI)
|
||||||
|
|
@ -16,6 +57,8 @@ Unified LLM API with automatic model discovery, provider configuration, token an
|
||||||
- **xAI**
|
- **xAI**
|
||||||
- **OpenRouter**
|
- **OpenRouter**
|
||||||
- **GitHub Copilot** (requires OAuth, see below)
|
- **GitHub Copilot** (requires OAuth, see below)
|
||||||
|
- **Google Gemini CLI** (requires OAuth, see below)
|
||||||
|
- **Antigravity** (requires OAuth, see below)
|
||||||
- **Any OpenAI-compatible API**: Ollama, vLLM, LM Studio, etc.
|
- **Any OpenAI-compatible API**: Ollama, vLLM, LM Studio, etc.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
@ -806,17 +849,19 @@ const response = await complete(model, {
|
||||||
|
|
||||||
In Node.js environments, you can set environment variables to avoid passing API keys:
|
In Node.js environments, you can set environment variables to avoid passing API keys:
|
||||||
|
|
||||||
```bash
|
| Provider | Environment Variable(s) |
|
||||||
OPENAI_API_KEY=sk-...
|
|----------|------------------------|
|
||||||
ANTHROPIC_API_KEY=sk-ant-...
|
| OpenAI | `OPENAI_API_KEY` |
|
||||||
GEMINI_API_KEY=...
|
| Anthropic | `ANTHROPIC_API_KEY` or `ANTHROPIC_OAUTH_TOKEN` |
|
||||||
MISTRAL_API_KEY=...
|
| Google | `GEMINI_API_KEY` |
|
||||||
GROQ_API_KEY=gsk_...
|
| Vertex AI | `GOOGLE_CLOUD_PROJECT` (or `GCLOUD_PROJECT`) + `GOOGLE_CLOUD_LOCATION` + ADC |
|
||||||
CEREBRAS_API_KEY=csk-...
|
| Mistral | `MISTRAL_API_KEY` |
|
||||||
XAI_API_KEY=xai-...
|
| Groq | `GROQ_API_KEY` |
|
||||||
ZAI_API_KEY=...
|
| Cerebras | `CEREBRAS_API_KEY` |
|
||||||
OPENROUTER_API_KEY=sk-or-...
|
| xAI | `XAI_API_KEY` |
|
||||||
```
|
| OpenRouter | `OPENROUTER_API_KEY` |
|
||||||
|
| zAI | `ZAI_API_KEY` |
|
||||||
|
| GitHub Copilot | `COPILOT_GITHUB_TOKEN` or `GH_TOKEN` or `GITHUB_TOKEN` |
|
||||||
|
|
||||||
When set, the library automatically uses these keys:
|
When set, the library automatically uses these keys:
|
||||||
|
|
||||||
|
|
@ -845,6 +890,7 @@ const key = getEnvApiKey('openai'); // checks OPENAI_API_KEY
|
||||||
Several providers require OAuth authentication instead of static API keys:
|
Several providers require OAuth authentication instead of static API keys:
|
||||||
|
|
||||||
- **Anthropic** (Claude Pro/Max subscription)
|
- **Anthropic** (Claude Pro/Max subscription)
|
||||||
|
- **OpenAI Codex** (ChatGPT Plus/Pro subscription, access to GPT-5.x Codex models)
|
||||||
- **GitHub Copilot** (Copilot subscription)
|
- **GitHub Copilot** (Copilot subscription)
|
||||||
- **Google Gemini CLI** (Free Gemini 2.0/2.5 via Google Cloud Code Assist)
|
- **Google Gemini CLI** (Free Gemini 2.0/2.5 via Google Cloud Code Assist)
|
||||||
- **Antigravity** (Free Gemini 3, Claude, GPT-OSS via Google Cloud)
|
- **Antigravity** (Free Gemini 3, Claude, GPT-OSS via Google Cloud)
|
||||||
|
|
@ -873,6 +919,7 @@ The library provides login and token refresh functions. Credential storage is th
|
||||||
import {
|
import {
|
||||||
// Login functions (return credentials, do not store)
|
// Login functions (return credentials, do not store)
|
||||||
loginAnthropic,
|
loginAnthropic,
|
||||||
|
loginOpenAICodex,
|
||||||
loginGitHubCopilot,
|
loginGitHubCopilot,
|
||||||
loginGeminiCli,
|
loginGeminiCli,
|
||||||
loginAntigravity,
|
loginAntigravity,
|
||||||
|
|
@ -882,7 +929,7 @@ import {
|
||||||
getOAuthApiKey, // (provider, credentialsMap) => { newCredentials, apiKey } | null
|
getOAuthApiKey, // (provider, credentialsMap) => { newCredentials, apiKey } | null
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
type OAuthProvider, // 'anthropic' | 'github-copilot' | 'google-gemini-cli' | 'google-antigravity'
|
type OAuthProvider, // 'anthropic' | 'openai-codex' | 'github-copilot' | 'google-gemini-cli' | 'google-antigravity'
|
||||||
type OAuthCredentials,
|
type OAuthCredentials,
|
||||||
} from '@mariozechner/pi-ai';
|
} from '@mariozechner/pi-ai';
|
||||||
```
|
```
|
||||||
|
|
@ -937,6 +984,8 @@ const response = await complete(model, {
|
||||||
|
|
||||||
### Provider Notes
|
### Provider Notes
|
||||||
|
|
||||||
|
**OpenAI Codex**: Requires a ChatGPT Plus or Pro subscription. Provides access to GPT-5.x Codex models with extended context windows and reasoning capabilities. The library automatically handles session-based prompt caching when `sessionId` is provided in stream options.
|
||||||
|
|
||||||
**GitHub Copilot**: If you get "The requested model is not supported" error, enable the model manually in VS Code: open Copilot Chat, click the model selector, select the model (warning icon), and click "Enable".
|
**GitHub Copilot**: If you get "The requested model is not supported" error, enable the model manually in VS Code: open Copilot Chat, click the model selector, select the model (warning icon), and click "Enable".
|
||||||
|
|
||||||
**Google Gemini CLI / Antigravity**: These use Google Cloud OAuth. The `apiKey` returned by `getOAuthApiKey()` is a JSON string containing both the token and project ID, which the library handles automatically.
|
**Google Gemini CLI / Antigravity**: These use Google Cloud OAuth. The `apiKey` returned by `getOAuthApiKey()` is a JSON string containing both the token and project ID, which the library handles automatically.
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,7 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
|
||||||
model: model.id,
|
model: model.id,
|
||||||
input: messages,
|
input: messages,
|
||||||
stream: true,
|
stream: true,
|
||||||
|
prompt_cache_key: options?.sessionId,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (options?.maxTokens) {
|
if (options?.maxTokens) {
|
||||||
|
|
@ -132,7 +133,7 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
|
||||||
const transformedBody = await transformRequestBody(params, codexOptions, systemPrompt);
|
const transformedBody = await transformRequestBody(params, codexOptions, systemPrompt);
|
||||||
|
|
||||||
const reasoningEffort = transformedBody.reasoning?.effort ?? null;
|
const reasoningEffort = transformedBody.reasoning?.effort ?? null;
|
||||||
const headers = createCodexHeaders(model.headers, accountId, apiKey, transformedBody.prompt_cache_key);
|
const headers = createCodexHeaders(model.headers, accountId, apiKey, options?.sessionId);
|
||||||
logCodexDebug("codex request", {
|
logCodexDebug("codex request", {
|
||||||
url,
|
url,
|
||||||
model: params.model,
|
model: params.model,
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@ export async function getCodexInstructions(normalizedModel = "gpt-5.1-codex"): P
|
||||||
cachedTimestamp = metadata.lastChecked;
|
cachedTimestamp = metadata.lastChecked;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CACHE_TTL_MS = 15 * 60 * 1000;
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
if (cachedTimestamp && Date.now() - cachedTimestamp < CACHE_TTL_MS && existsSync(cacheFile)) {
|
if (cachedTimestamp && Date.now() - cachedTimestamp < CACHE_TTL_MS && existsSync(cacheFile)) {
|
||||||
return readFileSync(cacheFile, "utf8");
|
return readFileSync(cacheFile, "utf8");
|
||||||
}
|
}
|
||||||
|
|
@ -183,45 +183,3 @@ export async function getCodexInstructions(normalizedModel = "gpt-5.1-codex"): P
|
||||||
throw new Error(`No cached Codex instructions available for ${modelFamily}`);
|
throw new Error(`No cached Codex instructions available for ${modelFamily}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TOOL_REMAP_MESSAGE = `<user_instructions priority="0">
|
|
||||||
<environment_override priority="0">
|
|
||||||
YOU ARE IN A DIFFERENT ENVIRONMENT. These instructions override ALL previous tool references.
|
|
||||||
</environment_override>
|
|
||||||
|
|
||||||
<tool_replacements priority="0">
|
|
||||||
<critical_rule priority="0">
|
|
||||||
❌ APPLY_PATCH DOES NOT EXIST → ✅ USE "edit" INSTEAD
|
|
||||||
- NEVER use: apply_patch, applyPatch
|
|
||||||
- ALWAYS use: edit tool for ALL file modifications
|
|
||||||
</critical_rule>
|
|
||||||
|
|
||||||
<critical_rule priority="0">
|
|
||||||
❌ UPDATE_PLAN DOES NOT EXIST
|
|
||||||
- NEVER use: update_plan, updatePlan, read_plan, readPlan, todowrite, todoread
|
|
||||||
- There is no plan tool in this environment
|
|
||||||
</critical_rule>
|
|
||||||
</tool_replacements>
|
|
||||||
|
|
||||||
<available_tools priority="0">
|
|
||||||
File Operations:
|
|
||||||
• read - Read file contents
|
|
||||||
• edit - Modify files with exact find/replace
|
|
||||||
• write - Create or overwrite files
|
|
||||||
|
|
||||||
Search/Discovery:
|
|
||||||
• grep - Search file contents for patterns (read-only)
|
|
||||||
• find - Find files by glob pattern (read-only)
|
|
||||||
• ls - List directory contents (read-only)
|
|
||||||
|
|
||||||
Execution:
|
|
||||||
• bash - Run shell commands
|
|
||||||
</available_tools>
|
|
||||||
|
|
||||||
<verification_checklist priority="0">
|
|
||||||
Before file modifications:
|
|
||||||
1. Am I using "edit" NOT "apply_patch"?
|
|
||||||
2. Am I avoiding plan tools entirely?
|
|
||||||
3. Am I using only the tools listed above?
|
|
||||||
</verification_checklist>
|
|
||||||
</user_instructions>`;
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ export interface RequestBody {
|
||||||
};
|
};
|
||||||
include?: string[];
|
include?: string[];
|
||||||
prompt_cache_key?: string;
|
prompt_cache_key?: string;
|
||||||
|
prompt_cache_retention?: "in_memory" | "24h";
|
||||||
max_output_tokens?: number;
|
max_output_tokens?: number;
|
||||||
max_completion_tokens?: number;
|
max_completion_tokens?: number;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,7 @@ function mapOptionsForApi<TApi extends Api>(
|
||||||
maxTokens: options?.maxTokens || Math.min(model.maxTokens, 32000),
|
maxTokens: options?.maxTokens || Math.min(model.maxTokens, 32000),
|
||||||
signal: options?.signal,
|
signal: options?.signal,
|
||||||
apiKey: apiKey || options?.apiKey,
|
apiKey: apiKey || options?.apiKey,
|
||||||
|
sessionId: options?.sessionId,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper to clamp xhigh to high for providers that don't support it
|
// Helper to clamp xhigh to high for providers that don't support it
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,12 @@ export interface StreamOptions {
|
||||||
maxTokens?: number;
|
maxTokens?: number;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
|
/**
|
||||||
|
* Optional session identifier for providers that support session-based caching.
|
||||||
|
* Providers can use this to enable prompt caching, request routing, or other
|
||||||
|
* session-aware features. Ignored by providers that don't support it.
|
||||||
|
*/
|
||||||
|
sessionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unified options with reasoning passed to streamSimple() and completeSimple()
|
// Unified options with reasoning passed to streamSimple() and completeSimple()
|
||||||
|
|
|
||||||
|
|
@ -129,4 +129,201 @@ describe("openai-codex streaming", () => {
|
||||||
expect(sawTextDelta).toBe(true);
|
expect(sawTextDelta).toBe(true);
|
||||||
expect(sawDone).toBe(true);
|
expect(sawDone).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sets conversation_id/session_id headers and prompt_cache_key when sessionId is provided", async () => {
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-"));
|
||||||
|
process.env.PI_CODING_AGENT_DIR = tempDir;
|
||||||
|
|
||||||
|
const payload = Buffer.from(
|
||||||
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
|
||||||
|
"utf8",
|
||||||
|
).toString("base64");
|
||||||
|
const token = `aaa.${payload}.bbb`;
|
||||||
|
|
||||||
|
const sse = `${[
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
type: "response.output_item.added",
|
||||||
|
item: { type: "message", id: "msg_1", role: "assistant", status: "in_progress", content: [] },
|
||||||
|
})}`,
|
||||||
|
`data: ${JSON.stringify({ type: "response.content_part.added", part: { type: "output_text", text: "" } })}`,
|
||||||
|
`data: ${JSON.stringify({ type: "response.output_text.delta", delta: "Hello" })}`,
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
type: "response.output_item.done",
|
||||||
|
item: {
|
||||||
|
type: "message",
|
||||||
|
id: "msg_1",
|
||||||
|
role: "assistant",
|
||||||
|
status: "completed",
|
||||||
|
content: [{ type: "output_text", text: "Hello" }],
|
||||||
|
},
|
||||||
|
})}`,
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
type: "response.completed",
|
||||||
|
response: {
|
||||||
|
status: "completed",
|
||||||
|
usage: {
|
||||||
|
input_tokens: 5,
|
||||||
|
output_tokens: 3,
|
||||||
|
total_tokens: 8,
|
||||||
|
input_tokens_details: { cached_tokens: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})}`,
|
||||||
|
].join("\n\n")}\n\n`;
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(encoder.encode(sse));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessionId = "test-session-123";
|
||||||
|
const fetchMock = vi.fn(async (input: string | URL, init?: RequestInit) => {
|
||||||
|
const url = typeof input === "string" ? input : input.toString();
|
||||||
|
if (url === "https://api.github.com/repos/openai/codex/releases/latest") {
|
||||||
|
return new Response(JSON.stringify({ tag_name: "rust-v0.0.0" }), { status: 200 });
|
||||||
|
}
|
||||||
|
if (url.startsWith("https://raw.githubusercontent.com/openai/codex/")) {
|
||||||
|
return new Response("PROMPT", { status: 200, headers: { etag: '"etag"' } });
|
||||||
|
}
|
||||||
|
if (url === "https://chatgpt.com/backend-api/codex/responses") {
|
||||||
|
const headers = init?.headers instanceof Headers ? init.headers : undefined;
|
||||||
|
// Verify sessionId is set in headers
|
||||||
|
expect(headers?.get("conversation_id")).toBe(sessionId);
|
||||||
|
expect(headers?.get("session_id")).toBe(sessionId);
|
||||||
|
|
||||||
|
// Verify sessionId is set in request body as prompt_cache_key
|
||||||
|
const body = typeof init?.body === "string" ? (JSON.parse(init.body) as Record<string, unknown>) : null;
|
||||||
|
expect(body?.prompt_cache_key).toBe(sessionId);
|
||||||
|
expect(body?.prompt_cache_retention).toBe("in-memory");
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
});
|
||||||
|
|
||||||
|
global.fetch = fetchMock as typeof fetch;
|
||||||
|
|
||||||
|
const model: Model<"openai-codex-responses"> = {
|
||||||
|
id: "gpt-5.1-codex",
|
||||||
|
name: "GPT-5.1 Codex",
|
||||||
|
api: "openai-codex-responses",
|
||||||
|
provider: "openai-codex",
|
||||||
|
baseUrl: "https://chatgpt.com/backend-api",
|
||||||
|
reasoning: true,
|
||||||
|
input: ["text"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 400000,
|
||||||
|
maxTokens: 128000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const context: Context = {
|
||||||
|
systemPrompt: "You are a helpful assistant.",
|
||||||
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const streamResult = streamOpenAICodexResponses(model, context, { apiKey: token, sessionId });
|
||||||
|
await streamResult.result();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not set conversation_id/session_id headers when sessionId is not provided", async () => {
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), "pi-codex-stream-"));
|
||||||
|
process.env.PI_CODING_AGENT_DIR = tempDir;
|
||||||
|
|
||||||
|
const payload = Buffer.from(
|
||||||
|
JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "acc_test" } }),
|
||||||
|
"utf8",
|
||||||
|
).toString("base64");
|
||||||
|
const token = `aaa.${payload}.bbb`;
|
||||||
|
|
||||||
|
const sse = `${[
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
type: "response.output_item.added",
|
||||||
|
item: { type: "message", id: "msg_1", role: "assistant", status: "in_progress", content: [] },
|
||||||
|
})}`,
|
||||||
|
`data: ${JSON.stringify({ type: "response.content_part.added", part: { type: "output_text", text: "" } })}`,
|
||||||
|
`data: ${JSON.stringify({ type: "response.output_text.delta", delta: "Hello" })}`,
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
type: "response.output_item.done",
|
||||||
|
item: {
|
||||||
|
type: "message",
|
||||||
|
id: "msg_1",
|
||||||
|
role: "assistant",
|
||||||
|
status: "completed",
|
||||||
|
content: [{ type: "output_text", text: "Hello" }],
|
||||||
|
},
|
||||||
|
})}`,
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
type: "response.completed",
|
||||||
|
response: {
|
||||||
|
status: "completed",
|
||||||
|
usage: {
|
||||||
|
input_tokens: 5,
|
||||||
|
output_tokens: 3,
|
||||||
|
total_tokens: 8,
|
||||||
|
input_tokens_details: { cached_tokens: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})}`,
|
||||||
|
].join("\n\n")}\n\n`;
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const stream = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(encoder.encode(sse));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchMock = vi.fn(async (input: string | URL, init?: RequestInit) => {
|
||||||
|
const url = typeof input === "string" ? input : input.toString();
|
||||||
|
if (url === "https://api.github.com/repos/openai/codex/releases/latest") {
|
||||||
|
return new Response(JSON.stringify({ tag_name: "rust-v0.0.0" }), { status: 200 });
|
||||||
|
}
|
||||||
|
if (url.startsWith("https://raw.githubusercontent.com/openai/codex/")) {
|
||||||
|
return new Response("PROMPT", { status: 200, headers: { etag: '"etag"' } });
|
||||||
|
}
|
||||||
|
if (url === "https://chatgpt.com/backend-api/codex/responses") {
|
||||||
|
const headers = init?.headers instanceof Headers ? init.headers : undefined;
|
||||||
|
// Verify headers are not set when sessionId is not provided
|
||||||
|
expect(headers?.has("conversation_id")).toBe(false);
|
||||||
|
expect(headers?.has("session_id")).toBe(false);
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
});
|
||||||
|
|
||||||
|
global.fetch = fetchMock as typeof fetch;
|
||||||
|
|
||||||
|
const model: Model<"openai-codex-responses"> = {
|
||||||
|
id: "gpt-5.1-codex",
|
||||||
|
name: "GPT-5.1 Codex",
|
||||||
|
api: "openai-codex-responses",
|
||||||
|
provider: "openai-codex",
|
||||||
|
baseUrl: "https://chatgpt.com/backend-api",
|
||||||
|
reasoning: true,
|
||||||
|
input: ["text"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 400000,
|
||||||
|
maxTokens: 128000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const context: Context = {
|
||||||
|
systemPrompt: "You are a helpful assistant.",
|
||||||
|
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
|
||||||
|
};
|
||||||
|
|
||||||
|
// No sessionId provided
|
||||||
|
const streamResult = streamOpenAICodexResponses(model, context, { apiKey: token });
|
||||||
|
await streamResult.result();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,10 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Session ID is now forwarded to LLM providers for session-based caching (used by OpenAI Codex for prompt caching).
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Add `minimatch` as a direct dependency for explicit imports.
|
- Add `minimatch` as a direct dependency for explicit imports.
|
||||||
|
|
|
||||||
|
|
@ -856,6 +856,7 @@ export class AgentSession {
|
||||||
await this.abort();
|
await this.abort();
|
||||||
this.agent.reset();
|
this.agent.reset();
|
||||||
this.sessionManager.newSession(options);
|
this.sessionManager.newSession(options);
|
||||||
|
this.agent.sessionId = this.sessionManager.getSessionId();
|
||||||
this._steeringMessages = [];
|
this._steeringMessages = [];
|
||||||
this._followUpMessages = [];
|
this._followUpMessages = [];
|
||||||
this._pendingNextTurnMessages = [];
|
this._pendingNextTurnMessages = [];
|
||||||
|
|
@ -1666,6 +1667,7 @@ export class AgentSession {
|
||||||
|
|
||||||
// Set new session
|
// Set new session
|
||||||
this.sessionManager.setSessionFile(sessionPath);
|
this.sessionManager.setSessionFile(sessionPath);
|
||||||
|
this.agent.sessionId = this.sessionManager.getSessionId();
|
||||||
|
|
||||||
// Reload messages
|
// Reload messages
|
||||||
const sessionContext = this.sessionManager.buildSessionContext();
|
const sessionContext = this.sessionManager.buildSessionContext();
|
||||||
|
|
@ -1745,6 +1747,7 @@ export class AgentSession {
|
||||||
} else {
|
} else {
|
||||||
this.sessionManager.createBranchedSession(selectedEntry.parentId);
|
this.sessionManager.createBranchedSession(selectedEntry.parentId);
|
||||||
}
|
}
|
||||||
|
this.agent.sessionId = this.sessionManager.getSessionId();
|
||||||
|
|
||||||
// Reload messages from entries (works for both file and in-memory mode)
|
// Reload messages from entries (works for both file and in-memory mode)
|
||||||
const sessionContext = this.sessionManager.buildSessionContext();
|
const sessionContext = this.sessionManager.buildSessionContext();
|
||||||
|
|
|
||||||
|
|
@ -613,6 +613,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||||
tools: activeToolsArray,
|
tools: activeToolsArray,
|
||||||
},
|
},
|
||||||
convertToLlm,
|
convertToLlm,
|
||||||
|
sessionId: sessionManager.getSessionId(),
|
||||||
transformContext: extensionRunner
|
transformContext: extensionRunner
|
||||||
? async (messages) => {
|
? async (messages) => {
|
||||||
return extensionRunner.emitContext(messages);
|
return extensionRunner.emitContext(messages);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue