Skip to content

Commit 04c3423

Browse files
cpfifferletta-code
andauthored
fix(api): normalize Letta URLs and avoid self-hosted conversations redirect (#31)
Use a shared URL builder across scripts and update createConversation to call /v1/conversations/ with query params to avoid HTTPS->HTTP redirect failures behind reverse proxies. This also deduplicates LETTA_API_BASE handling and adds regression tests for trailing-slash behavior. 👾 Generated with [Letta Code](https://letta.com) Co-authored-by: Letta Code <noreply@letta.com>
1 parent 6346094 commit 04c3423

File tree

8 files changed

+158
-19
lines changed

8 files changed

+158
-19
lines changed

scripts/agent_config.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,11 @@
1515
import * as fs from 'fs';
1616
import * as path from 'path';
1717
import { fileURLToPath } from 'url';
18+
import { buildLettaApiUrl } from './letta_api_url.js';
1819

1920
const __filename = fileURLToPath(import.meta.url);
2021
const __dirname = path.dirname(__filename);
2122

22-
const LETTA_BASE_URL = process.env.LETTA_BASE_URL || 'https://api.letta.com';
23-
const LETTA_API_BASE = `${LETTA_BASE_URL}/v1`;
2423
const CONFIG_DIR = path.join(process.env.HOME || '~', '.letta', 'claude-subconscious');
2524
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
2625
const DEFAULT_AGENT_FILE = path.join(__dirname, '..', 'Subconscious.af');
@@ -154,7 +153,7 @@ function getAgentNameFromFile(): string {
154153
* Rename an agent
155154
*/
156155
async function renameAgent(apiKey: string, agentId: string, name: string): Promise<void> {
157-
const url = `${LETTA_API_BASE}/agents/${agentId}`;
156+
const url = buildLettaApiUrl(`/agents/${agentId}`);
158157

159158
const response = await fetch(url, {
160159
method: 'PATCH',
@@ -175,7 +174,7 @@ async function renameAgent(apiKey: string, agentId: string, name: string): Promi
175174
* List available models from Letta server
176175
*/
177176
async function listAvailableModels(apiKey: string): Promise<LettaModel[]> {
178-
const url = `${LETTA_API_BASE}/models/`;
177+
const url = buildLettaApiUrl('/models/');
179178

180179
const response = await fetch(url, {
181180
method: 'GET',
@@ -195,7 +194,7 @@ async function listAvailableModels(apiKey: string): Promise<LettaModel[]> {
195194
* Get agent details including current model configuration
196195
*/
197196
async function getAgentDetails(apiKey: string, agentId: string): Promise<AgentDetails> {
198-
const url = `${LETTA_API_BASE}/agents/${agentId}`;
197+
const url = buildLettaApiUrl(`/agents/${agentId}`);
199198

200199
const response = await fetch(url, {
201200
method: 'GET',
@@ -402,7 +401,7 @@ async function updateAgentModel(
402401
currentConfig: LlmConfig | undefined,
403402
log: (msg: string) => void = console.log
404403
): Promise<void> {
405-
const url = `${LETTA_API_BASE}/agents/${agentId}`;
404+
const url = buildLettaApiUrl(`/agents/${agentId}`);
406405

407406
log(`Updating agent model to: ${modelHandle}`);
408407

@@ -433,7 +432,7 @@ async function updateAgentModel(
433432
* Import agent from .af file
434433
*/
435434
async function importDefaultAgent(apiKey: string): Promise<string> {
436-
const url = `${LETTA_API_BASE}/agents/import`;
435+
const url = buildLettaApiUrl('/agents/import');
437436

438437
// Read the agent file
439438
const agentFileContent = fs.readFileSync(DEFAULT_AGENT_FILE);

scripts/conversation_utils.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
2+
3+
const fetchMock = vi.fn();
4+
5+
vi.stubGlobal('fetch', fetchMock);
6+
7+
describe('createConversation', () => {
8+
afterEach(() => {
9+
fetchMock.mockReset();
10+
vi.unstubAllEnvs();
11+
vi.resetModules();
12+
});
13+
14+
it('uses trailing-slash conversations endpoint with agent_id query', async () => {
15+
vi.stubEnv('LETTA_BASE_URL', 'https://letta.example.com');
16+
17+
fetchMock.mockResolvedValue({
18+
ok: true,
19+
json: async () => ({ id: 'conversation-123' }),
20+
});
21+
22+
const { createConversation } = await import('./conversation_utils.js');
23+
const conversationId = await createConversation('test-key', 'agent-123');
24+
25+
expect(conversationId).toBe('conversation-123');
26+
expect(fetchMock).toHaveBeenCalledTimes(1);
27+
expect(fetchMock).toHaveBeenCalledWith(
28+
'https://letta.example.com/v1/conversations/?agent_id=agent-123',
29+
expect.objectContaining({
30+
method: 'POST',
31+
headers: expect.objectContaining({
32+
Authorization: 'Bearer test-key',
33+
}),
34+
}),
35+
);
36+
});
37+
});

scripts/conversation_utils.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,17 @@ import * as os from 'os';
88
import * as path from 'path';
99
import { spawn, ChildProcess } from 'child_process';
1010
import { fileURLToPath } from 'url';
11+
import {
12+
buildLettaApiUrl,
13+
LETTA_API_BASE,
14+
} from './letta_api_url.js';
1115

1216
// ESM-compatible __dirname
1317
const __filename = fileURLToPath(import.meta.url);
1418
const __dirname = path.dirname(__filename);
1519

1620
// Configuration
17-
const LETTA_BASE_URL = process.env.LETTA_BASE_URL || 'https://api.letta.com';
18-
export const LETTA_API_BASE = `${LETTA_BASE_URL}/v1`;
21+
export { LETTA_API_BASE };
1922
// Only show app URL for hosted service; self-hosted users get IDs directly
2023
const IS_HOSTED = !process.env.LETTA_BASE_URL;
2124
const LETTA_APP_BASE = 'https://app.letta.com';
@@ -202,7 +205,7 @@ export function saveConversationsMap(cwd: string, map: ConversationsMap): void {
202205
* Create a new conversation for an agent
203206
*/
204207
export async function createConversation(apiKey: string, agentId: string, log: LogFn = noopLog): Promise<string> {
205-
const url = `${LETTA_API_BASE}/conversations?agent_id=${agentId}`;
208+
const url = buildLettaApiUrl('/conversations/', { agent_id: agentId });
206209

207210
log(`Creating new conversation for agent ${agentId}`);
208211

@@ -339,7 +342,9 @@ export interface Agent {
339342
* Fetch agent data from Letta API
340343
*/
341344
export async function fetchAgent(apiKey: string, agentId: string): Promise<Agent> {
342-
const url = `${LETTA_API_BASE}/agents/${agentId}?include=agent.blocks`;
345+
const url = buildLettaApiUrl(`/agents/${agentId}`, {
346+
include: 'agent.blocks',
347+
});
343348

344349
const response = await fetch(url, {
345350
method: 'GET',

scripts/letta_api_url.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
2+
3+
import {
4+
buildLettaApiUrl,
5+
getLettaApiBase,
6+
normalizeLettaBaseUrl,
7+
} from './letta_api_url.js';
8+
9+
describe('letta_api_url', () => {
10+
afterEach(() => {
11+
vi.unstubAllEnvs();
12+
});
13+
14+
it('normalizes base URL by trimming trailing slash', () => {
15+
expect(normalizeLettaBaseUrl('https://example.com/')).toBe(
16+
'https://example.com',
17+
);
18+
expect(normalizeLettaBaseUrl('https://example.com///')).toBe(
19+
'https://example.com',
20+
);
21+
});
22+
23+
it('builds /v1 base URL unless already present', () => {
24+
expect(getLettaApiBase('https://example.com')).toBe(
25+
'https://example.com/v1',
26+
);
27+
expect(getLettaApiBase('https://example.com/v1')).toBe(
28+
'https://example.com/v1',
29+
);
30+
});
31+
32+
it('builds URLs with optional query params', () => {
33+
const url = buildLettaApiUrl('/agents/agent-123', {
34+
include: 'agent.blocks',
35+
limit: 20,
36+
});
37+
38+
expect(url).toBe(
39+
'https://api.letta.com/v1/agents/agent-123?include=agent.blocks&limit=20',
40+
);
41+
});
42+
43+
it('preserves trailing slash path for conversations create endpoint', () => {
44+
const url = buildLettaApiUrl('/conversations/', { agent_id: 'agent-123' });
45+
46+
expect(url).toBe(
47+
'https://api.letta.com/v1/conversations/?agent_id=agent-123',
48+
);
49+
});
50+
});

scripts/letta_api_url.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* Shared Letta API URL helpers.
3+
*
4+
* Centralizes base URL normalization and endpoint URL construction.
5+
*/
6+
7+
export type LettaApiQueryValue = string | number | boolean | null | undefined;
8+
9+
export type LettaApiQuery = Record<string, LettaApiQueryValue>;
10+
11+
export function normalizeLettaBaseUrl(baseUrl = process.env.LETTA_BASE_URL): string {
12+
const rawBase = baseUrl?.trim() || 'https://api.letta.com';
13+
return rawBase.replace(/\/+$/, '');
14+
}
15+
16+
export function getLettaApiBase(baseUrl = process.env.LETTA_BASE_URL): string {
17+
const normalizedBase = normalizeLettaBaseUrl(baseUrl);
18+
return normalizedBase.endsWith('/v1')
19+
? normalizedBase
20+
: `${normalizedBase}/v1`;
21+
}
22+
23+
export const LETTA_BASE_URL = normalizeLettaBaseUrl();
24+
export const LETTA_API_BASE = getLettaApiBase();
25+
26+
export function buildLettaApiUrl(
27+
path: string,
28+
query: LettaApiQuery = {},
29+
apiBase: string = LETTA_API_BASE,
30+
): string {
31+
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
32+
const normalizedApiBase = apiBase.replace(/\/+$/, '');
33+
const url = new URL(`${normalizedApiBase}${normalizedPath}`);
34+
35+
for (const [key, value] of Object.entries(query)) {
36+
if (value === undefined || value === null) {
37+
continue;
38+
}
39+
url.searchParams.set(key, String(value));
40+
}
41+
42+
return url.toString();
43+
}

scripts/pretool_sync.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,13 @@
1717
import * as fs from 'fs';
1818
import * as readline from 'readline';
1919
import { getAgentId } from './agent_config.js';
20+
import { buildLettaApiUrl } from './letta_api_url.js';
2021
import {
2122
loadSyncState,
2223
saveSyncState,
2324
lookupConversation,
2425
SyncState,
2526
getMode,
26-
LETTA_API_BASE,
2727
} from './conversation_utils.js';
2828

2929
const DEBUG = process.env.LETTA_DEBUG === '1';
@@ -100,7 +100,9 @@ async function readHookInput(): Promise<HookInput | null> {
100100
* Fetch agent data from Letta API
101101
*/
102102
async function fetchAgent(apiKey: string, agentId: string): Promise<Agent> {
103-
const url = `${LETTA_API_BASE}/agents/${agentId}?include=agent.blocks`;
103+
const url = buildLettaApiUrl(`/agents/${agentId}`, {
104+
include: 'agent.blocks',
105+
});
104106

105107
const response = await fetch(url, {
106108
method: 'GET',
@@ -129,7 +131,9 @@ async function fetchNewMessages(
129131
return { messages: [], lastMessageId: null };
130132
}
131133

132-
const url = `${LETTA_API_BASE}/conversations/${conversationId}/messages?limit=20`;
134+
const url = buildLettaApiUrl(`/conversations/${conversationId}/messages`, {
135+
limit: 20,
136+
});
133137

134138
const response = await fetch(url, {
135139
method: 'GET',

scripts/session_start.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,9 @@ import {
3333
getTempStateDir,
3434
getSdkToolsMode,
3535
} from './conversation_utils.js';
36+
import { buildLettaApiUrl } from './letta_api_url.js';
3637

3738
// Configuration
38-
const LETTA_BASE_URL = process.env.LETTA_BASE_URL || 'https://api.letta.com';
39-
const LETTA_API_BASE = `${LETTA_BASE_URL}/v1`;
4039
const TEMP_STATE_DIR = getTempStateDir();
4140
const LOG_FILE = path.join(TEMP_STATE_DIR, 'session_start.log');
4241

@@ -173,7 +172,7 @@ async function sendSessionStartMessage(
173172
sessionId: string,
174173
cwd: string
175174
): Promise<void> {
176-
const url = `${LETTA_API_BASE}/conversations/${conversationId}/messages`;
175+
const url = buildLettaApiUrl(`/conversations/${conversationId}/messages`);
177176

178177
const projectName = path.basename(cwd);
179178
const timestamp = new Date().toISOString();

scripts/sync_letta_memory.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import * as fs from 'fs';
2121
import * as path from 'path';
2222
import * as readline from 'readline';
2323
import { getAgentId } from './agent_config.js';
24+
import { buildLettaApiUrl } from './letta_api_url.js';
2425
import {
2526
loadSyncState,
2627
saveSyncState,
@@ -35,7 +36,6 @@ import {
3536
cleanLettaFromClaudeMd,
3637
getMode,
3738
getTempStateDir,
38-
LETTA_API_BASE,
3939
} from './conversation_utils.js';
4040

4141
// Configuration
@@ -198,7 +198,9 @@ async function fetchAssistantMessages(
198198

199199
// Use a high limit because Letta returns multiple entries per logical message
200200
// (hidden_reasoning + assistant_message pairs), so limit=50 may not reach newest messages
201-
const url = `${LETTA_API_BASE}/conversations/${conversationId}/messages?limit=300`;
201+
const url = buildLettaApiUrl(`/conversations/${conversationId}/messages`, {
202+
limit: 300,
203+
});
202204

203205
const response = await fetch(url, {
204206
method: 'GET',

0 commit comments

Comments
 (0)