Skip to content

Commit f44c878

Browse files
authored
Merge pull request #112 from TraderAlice/dev
feat: AI provider preset system + profile UX + Codex fixes
2 parents 3da936d + 9e4d144 commit f44c878

40 files changed

+1465
-466
lines changed

src/ai-providers/agent-sdk/agent-sdk-provider.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import type { SessionEntry } from '../../core/session.js'
1515
import type { AgentSdkConfig, AgentSdkOverride } from './query.js'
1616
import { toTextHistory } from '../../core/session.js'
1717
import { buildChatHistoryPrompt, DEFAULT_MAX_HISTORY } from '../utils.js'
18-
import { readAgentConfig, resolveProfile } from '../../core/config.js'
18+
import { readAgentConfig, resolveProfile, type ResolvedProfile } from '../../core/config.js'
1919
import { createChannel } from '../../core/async-channel.js'
2020
import { askAgentSdk } from './query.js'
2121
import { buildAgentSdkMcpServer } from './tool-bridge.js'
@@ -44,16 +44,17 @@ export class AgentSdkProvider implements AIProvider {
4444
return buildAgentSdkMcpServer(tools, disabledTools)
4545
}
4646

47-
async ask(prompt: string): Promise<ProviderResult> {
47+
async ask(prompt: string, profile?: ResolvedProfile): Promise<ProviderResult> {
4848
const config = await this.resolveConfig()
4949
config.systemPrompt = await this.getSystemPrompt()
50-
const profile = await resolveProfile()
50+
const effectiveProfile = profile ?? await resolveProfile()
5151
const override: AgentSdkOverride = {
52-
model: profile.model, apiKey: profile.apiKey, baseUrl: profile.baseUrl,
53-
loginMethod: profile.loginMethod as 'api-key' | 'claudeai' | undefined,
52+
model: effectiveProfile.model, apiKey: effectiveProfile.apiKey, baseUrl: effectiveProfile.baseUrl,
53+
loginMethod: effectiveProfile.loginMethod as 'api-key' | 'claudeai' | undefined,
5454
}
5555
const mcpServer = await this.buildMcpServer()
5656
const result = await askAgentSdk(prompt, config, override, mcpServer)
57+
if (!result.ok) throw new Error(result.text)
5758
return { text: result.text, media: [] }
5859
}
5960

src/ai-providers/agent-sdk/query.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,12 @@ export async function askAgentSdk(
125125
if (isOAuthMode) {
126126
// Force OAuth by removing any inherited API key
127127
delete env.ANTHROPIC_API_KEY
128+
delete env.CLAUDE_CODE_SIMPLE
128129
} else {
129130
const apiKey = override?.apiKey
130131
if (apiKey) env.ANTHROPIC_API_KEY = apiKey
132+
// Force API key mode — disable OAuth even if local login exists
133+
env.CLAUDE_CODE_SIMPLE = '1'
131134
}
132135
const baseUrl = override?.baseUrl
133136
if (baseUrl) env.ANTHROPIC_BASE_URL = baseUrl
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/**
2+
* Codex provider E2E tests — verifies real API communication.
3+
*
4+
* Requires ~/.codex/auth.json (run `codex login` first).
5+
* Skips gracefully if auth is not configured.
6+
*
7+
* Run: pnpm test:e2e
8+
*/
9+
10+
import { describe, it, expect, beforeAll, beforeEach } from 'vitest'
11+
import OpenAI from 'openai'
12+
import { readFile } from 'node:fs/promises'
13+
import { join } from 'node:path'
14+
import { homedir } from 'node:os'
15+
16+
// ==================== Setup ====================
17+
18+
const OAUTH_BASE_URL = 'https://chatgpt.com/backend-api/codex'
19+
const MODEL = 'gpt-5.4-mini' // Use mini for faster/cheaper e2e
20+
21+
let client: OpenAI | null = null
22+
23+
async function tryLoadToken(): Promise<string | null> {
24+
const codexHome = process.env.CODEX_HOME ?? join(homedir(), '.codex')
25+
try {
26+
const raw = JSON.parse(await readFile(join(codexHome, 'auth.json'), 'utf-8'))
27+
return raw?.tokens?.access_token ?? null
28+
} catch {
29+
return null
30+
}
31+
}
32+
33+
beforeAll(async () => {
34+
const token = await tryLoadToken()
35+
if (!token) {
36+
console.warn('codex e2e: ~/.codex/auth.json not found, skipping tests')
37+
return
38+
}
39+
client = new OpenAI({ apiKey: token, baseURL: OAUTH_BASE_URL })
40+
console.log('codex e2e: client initialized')
41+
}, 15_000)
42+
43+
// ==================== Tests ====================
44+
45+
describe('Codex API — basic communication', () => {
46+
beforeEach(({ skip }) => { if (!client) skip('no codex auth') })
47+
48+
it('receives a text response for a simple prompt', async () => {
49+
const stream = client!.responses.stream({
50+
model: MODEL,
51+
instructions: 'You are a helpful assistant. Be very brief.',
52+
input: [{ role: 'user', content: 'What is 2+2? Answer with just the number.' }],
53+
store: false,
54+
})
55+
56+
let text = ''
57+
for await (const event of stream) {
58+
if (event.type === 'response.output_text.delta') text += event.delta
59+
}
60+
61+
expect(text).toBeTruthy()
62+
expect(text).toContain('4')
63+
}, 30_000)
64+
})
65+
66+
describe('Codex API — tool call round-trip', () => {
67+
beforeEach(({ skip }) => { if (!client) skip('no codex auth') })
68+
69+
const tools: OpenAI.Responses.Tool[] = [{
70+
type: 'function',
71+
name: 'get_price',
72+
description: 'Get the current price of a stock by symbol',
73+
parameters: {
74+
type: 'object',
75+
properties: { symbol: { type: 'string', description: 'Stock ticker symbol' } },
76+
required: ['symbol'],
77+
},
78+
strict: null,
79+
}]
80+
81+
it('receives a function call with call_id, name, and arguments', async () => {
82+
const stream = client!.responses.stream({
83+
model: MODEL,
84+
instructions: 'You are a stock assistant. Always use the get_price tool when asked about prices.',
85+
input: [{ role: 'user', content: 'What is the price of AAPL?' }],
86+
tools,
87+
store: false,
88+
})
89+
90+
let funcCall: { call_id: string; name: string; arguments: string } | null = null
91+
for await (const event of stream) {
92+
if (event.type === 'response.output_item.done') {
93+
const item = (event as any).item
94+
if (item?.type === 'function_call') {
95+
funcCall = { call_id: item.call_id, name: item.name, arguments: item.arguments }
96+
}
97+
}
98+
}
99+
100+
expect(funcCall).not.toBeNull()
101+
expect(funcCall!.call_id).toBeTruthy()
102+
expect(funcCall!.name).toBe('get_price')
103+
const args = JSON.parse(funcCall!.arguments)
104+
expect(args.symbol).toMatch(/AAPL/i)
105+
}, 30_000)
106+
107+
it('completes a full tool call round-trip', async () => {
108+
// Round 1: get function call
109+
const stream1 = client!.responses.stream({
110+
model: MODEL,
111+
instructions: 'You are a stock assistant. Always use the get_price tool.',
112+
input: [{ role: 'user', content: 'Price of MSFT?' }],
113+
tools,
114+
store: false,
115+
})
116+
117+
let funcCall: { call_id: string; name: string; arguments: string } | null = null
118+
for await (const event of stream1) {
119+
if (event.type === 'response.output_item.done') {
120+
const item = (event as any).item
121+
if (item?.type === 'function_call') {
122+
funcCall = { call_id: item.call_id, name: item.name, arguments: item.arguments }
123+
}
124+
}
125+
}
126+
127+
expect(funcCall).not.toBeNull()
128+
129+
// Round 2: send tool result back, get final text
130+
const stream2 = client!.responses.stream({
131+
model: MODEL,
132+
instructions: 'You are a stock assistant.',
133+
input: [
134+
{ role: 'user', content: 'Price of MSFT?' },
135+
{ type: 'function_call', call_id: funcCall!.call_id, name: funcCall!.name, arguments: funcCall!.arguments } as any,
136+
{ type: 'function_call_output', call_id: funcCall!.call_id, output: '{"price": 420.50, "currency": "USD"}' } as any,
137+
],
138+
tools,
139+
store: false,
140+
})
141+
142+
let responseText = ''
143+
for await (const event of stream2) {
144+
if (event.type === 'response.output_text.delta') responseText += event.delta
145+
}
146+
147+
expect(responseText).toBeTruthy()
148+
expect(responseText).toMatch(/420/i)
149+
}, 30_000)
150+
})
151+
152+
describe('Codex API — structured multi-turn input', () => {
153+
beforeEach(({ skip }) => { if (!client) skip('no codex auth') })
154+
155+
it('references earlier conversation context', async () => {
156+
const stream = client!.responses.stream({
157+
model: MODEL,
158+
instructions: 'You are a helpful assistant. Be very brief.',
159+
input: [
160+
{ role: 'user', content: 'My name is Alice.' },
161+
{ role: 'assistant', content: 'Nice to meet you, Alice!' },
162+
{ role: 'user', content: 'What is my name?' },
163+
],
164+
store: false,
165+
})
166+
167+
let text = ''
168+
for await (const event of stream) {
169+
if (event.type === 'response.output_text.delta') text += event.delta
170+
}
171+
172+
expect(text).toBeTruthy()
173+
expect(text.toLowerCase()).toContain('alice')
174+
}, 30_000)
175+
})

src/ai-providers/codex/codex-provider.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -64,24 +64,23 @@ export class CodexProvider implements AIProvider {
6464
return { client: new OpenAI({ apiKey: token, baseURL }), model }
6565
}
6666

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

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

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

8685
return { text: text || '(no output)', media: [] }
8786
} catch (err) {

src/ai-providers/mock/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export class MockAIProvider implements AIProvider {
5454
this._askResult = opts?.askResult ?? 'mock-ask-result'
5555
}
5656

57-
async ask(prompt: string): Promise<ProviderResult> {
57+
async ask(prompt: string, _profile?: unknown): Promise<ProviderResult> {
5858
this.askCalls.push(prompt)
5959
return { text: this._askResult, media: [] }
6060
}

0 commit comments

Comments
 (0)