Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
11 changes: 6 additions & 5 deletions src/ai-providers/agent-sdk/agent-sdk-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import type { SessionEntry } from '../../core/session.js'
import type { AgentSdkConfig, AgentSdkOverride } from './query.js'
import { toTextHistory } from '../../core/session.js'
import { buildChatHistoryPrompt, DEFAULT_MAX_HISTORY } from '../utils.js'
import { readAgentConfig, resolveProfile } from '../../core/config.js'
import { readAgentConfig, resolveProfile, type ResolvedProfile } from '../../core/config.js'
import { createChannel } from '../../core/async-channel.js'
import { askAgentSdk } from './query.js'
import { buildAgentSdkMcpServer } from './tool-bridge.js'
Expand Down Expand Up @@ -44,16 +44,17 @@ export class AgentSdkProvider implements AIProvider {
return buildAgentSdkMcpServer(tools, disabledTools)
}

async ask(prompt: string): Promise<ProviderResult> {
async ask(prompt: string, profile?: ResolvedProfile): Promise<ProviderResult> {
const config = await this.resolveConfig()
config.systemPrompt = await this.getSystemPrompt()
const profile = await resolveProfile()
const effectiveProfile = profile ?? await resolveProfile()
const override: AgentSdkOverride = {
model: profile.model, apiKey: profile.apiKey, baseUrl: profile.baseUrl,
loginMethod: profile.loginMethod as 'api-key' | 'claudeai' | undefined,
model: effectiveProfile.model, apiKey: effectiveProfile.apiKey, baseUrl: effectiveProfile.baseUrl,
loginMethod: effectiveProfile.loginMethod as 'api-key' | 'claudeai' | undefined,
}
const mcpServer = await this.buildMcpServer()
const result = await askAgentSdk(prompt, config, override, mcpServer)
if (!result.ok) throw new Error(result.text)
return { text: result.text, media: [] }
}

Expand Down
3 changes: 3 additions & 0 deletions src/ai-providers/agent-sdk/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,12 @@ export async function askAgentSdk(
if (isOAuthMode) {
// Force OAuth by removing any inherited API key
delete env.ANTHROPIC_API_KEY
delete env.CLAUDE_CODE_SIMPLE
} else {
const apiKey = override?.apiKey
if (apiKey) env.ANTHROPIC_API_KEY = apiKey
// Force API key mode — disable OAuth even if local login exists
env.CLAUDE_CODE_SIMPLE = '1'
}
const baseUrl = override?.baseUrl
if (baseUrl) env.ANTHROPIC_BASE_URL = baseUrl
Expand Down
175 changes: 175 additions & 0 deletions src/ai-providers/codex/__test__/codex.e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/**
* Codex provider E2E tests — verifies real API communication.
*
* Requires ~/.codex/auth.json (run `codex login` first).
* Skips gracefully if auth is not configured.
*
* Run: pnpm test:e2e
*/

import { describe, it, expect, beforeAll, beforeEach } from 'vitest'
import OpenAI from 'openai'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { homedir } from 'node:os'

// ==================== Setup ====================

const OAUTH_BASE_URL = 'https://chatgpt.com/backend-api/codex'
const MODEL = 'gpt-5.4-mini' // Use mini for faster/cheaper e2e

let client: OpenAI | null = null

async function tryLoadToken(): Promise<string | null> {
const codexHome = process.env.CODEX_HOME ?? join(homedir(), '.codex')
try {
const raw = JSON.parse(await readFile(join(codexHome, 'auth.json'), 'utf-8'))
return raw?.tokens?.access_token ?? null
} catch {
return null
}
}

beforeAll(async () => {
const token = await tryLoadToken()
if (!token) {
console.warn('codex e2e: ~/.codex/auth.json not found, skipping tests')
return
}
client = new OpenAI({ apiKey: token, baseURL: OAUTH_BASE_URL })
console.log('codex e2e: client initialized')
}, 15_000)

// ==================== Tests ====================

describe('Codex API — basic communication', () => {
beforeEach(({ skip }) => { if (!client) skip('no codex auth') })

it('receives a text response for a simple prompt', async () => {
const stream = client!.responses.stream({
model: MODEL,
instructions: 'You are a helpful assistant. Be very brief.',
input: [{ role: 'user', content: 'What is 2+2? Answer with just the number.' }],
store: false,
})

let text = ''
for await (const event of stream) {
if (event.type === 'response.output_text.delta') text += event.delta
}

expect(text).toBeTruthy()
expect(text).toContain('4')
}, 30_000)
})

describe('Codex API — tool call round-trip', () => {
beforeEach(({ skip }) => { if (!client) skip('no codex auth') })

const tools: OpenAI.Responses.Tool[] = [{
type: 'function',
name: 'get_price',
description: 'Get the current price of a stock by symbol',
parameters: {
type: 'object',
properties: { symbol: { type: 'string', description: 'Stock ticker symbol' } },
required: ['symbol'],
},
strict: null,
}]

it('receives a function call with call_id, name, and arguments', async () => {
const stream = client!.responses.stream({
model: MODEL,
instructions: 'You are a stock assistant. Always use the get_price tool when asked about prices.',
input: [{ role: 'user', content: 'What is the price of AAPL?' }],
tools,
store: false,
})

let funcCall: { call_id: string; name: string; arguments: string } | null = null
for await (const event of stream) {
if (event.type === 'response.output_item.done') {
const item = (event as any).item
if (item?.type === 'function_call') {
funcCall = { call_id: item.call_id, name: item.name, arguments: item.arguments }
}
}
}

expect(funcCall).not.toBeNull()
expect(funcCall!.call_id).toBeTruthy()
expect(funcCall!.name).toBe('get_price')
const args = JSON.parse(funcCall!.arguments)
expect(args.symbol).toMatch(/AAPL/i)
}, 30_000)

it('completes a full tool call round-trip', async () => {
// Round 1: get function call
const stream1 = client!.responses.stream({
model: MODEL,
instructions: 'You are a stock assistant. Always use the get_price tool.',
input: [{ role: 'user', content: 'Price of MSFT?' }],
tools,
store: false,
})

let funcCall: { call_id: string; name: string; arguments: string } | null = null
for await (const event of stream1) {
if (event.type === 'response.output_item.done') {
const item = (event as any).item
if (item?.type === 'function_call') {
funcCall = { call_id: item.call_id, name: item.name, arguments: item.arguments }
}
}
}

expect(funcCall).not.toBeNull()

// Round 2: send tool result back, get final text
const stream2 = client!.responses.stream({
model: MODEL,
instructions: 'You are a stock assistant.',
input: [
{ role: 'user', content: 'Price of MSFT?' },
{ type: 'function_call', call_id: funcCall!.call_id, name: funcCall!.name, arguments: funcCall!.arguments } as any,
{ type: 'function_call_output', call_id: funcCall!.call_id, output: '{"price": 420.50, "currency": "USD"}' } as any,
],
tools,
store: false,
})

let responseText = ''
for await (const event of stream2) {
if (event.type === 'response.output_text.delta') responseText += event.delta
}

expect(responseText).toBeTruthy()
expect(responseText).toMatch(/420/i)
}, 30_000)
})

describe('Codex API — structured multi-turn input', () => {
beforeEach(({ skip }) => { if (!client) skip('no codex auth') })

it('references earlier conversation context', async () => {
const stream = client!.responses.stream({
model: MODEL,
instructions: 'You are a helpful assistant. Be very brief.',
input: [
{ role: 'user', content: 'My name is Alice.' },
{ role: 'assistant', content: 'Nice to meet you, Alice!' },
{ role: 'user', content: 'What is my name?' },
],
store: false,
})

let text = ''
for await (const event of stream) {
if (event.type === 'response.output_text.delta') text += event.delta
}

expect(text).toBeTruthy()
expect(text.toLowerCase()).toContain('alice')
}, 30_000)
})
17 changes: 8 additions & 9 deletions src/ai-providers/codex/codex-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,24 +64,23 @@ export class CodexProvider implements AIProvider {
return { client: new OpenAI({ apiKey: token, baseURL }), model }
}

async ask(prompt: string): Promise<ProviderResult> {
const { client, model } = await this.createClient()
async ask(prompt: string, profile?: ResolvedProfile): Promise<ProviderResult> {
const { client, model } = await this.createClient(profile)
const instructions = await this.getSystemPrompt()

try {
const response = await client.responses.create({
// Use streaming — the ChatGPT subscription endpoint may not support non-streaming
const stream = client.responses.stream({
model,
instructions,
input: [{ role: 'user' as const, content: prompt }],
store: false,
})

const text = response.output
.filter((item): item is OpenAI.Responses.ResponseOutputMessage => item.type === 'message')
.flatMap(msg => msg.content)
.filter((c): c is OpenAI.Responses.ResponseOutputText => c.type === 'output_text')
.map(c => c.text)
.join('')
let text = ''
for await (const event of stream) {
if (event.type === 'response.output_text.delta') text += event.delta
}

return { text: text || '(no output)', media: [] }
} catch (err) {
Expand Down
2 changes: 1 addition & 1 deletion src/ai-providers/mock/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export class MockAIProvider implements AIProvider {
this._askResult = opts?.askResult ?? 'mock-ask-result'
}

async ask(prompt: string): Promise<ProviderResult> {
async ask(prompt: string, _profile?: unknown): Promise<ProviderResult> {
this.askCalls.push(prompt)
return { text: this._askResult, media: [] }
}
Expand Down
Loading
Loading