Skip to content
Open
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
103 changes: 70 additions & 33 deletions src/lib/ai.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,41 @@
import type { Vendor } from './config.js'

// ─── Vendor Response Types ──────────────────────────────────────────────────

interface VendorErrorBody {
error?: { message?: string }
}

interface OpenAIResponsesContent {
type: string
text?: string
}

interface OpenAIResponsesOutputItem {
type: string
content?: OpenAIResponsesContent[]
}

interface OpenAIResponsesResult {
output?: OpenAIResponsesOutputItem[]
}

interface OpenAIChatCompletionResult {
choices?: { message: { role: string; content: string | null } }[]
}

interface AnthropicContentBlock {
type: string
text?: string
input?: unknown
}

interface AnthropicMessagesResult {
content?: AnthropicContentBlock[]
}

// ─────────────────────────────────────────────────────────────────────────────

/**
* Extracts the outermost JSON object from a string that may contain markdown
* fences or surrounding prose. Exported for testing and use as a fallback.
Expand Down Expand Up @@ -155,15 +191,15 @@ async function callOpenAI(prompt: string, apiKey: string): Promise<GeneratedInte
'OpenAI',
async (res) => {
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(`OpenAI error: ${(err as any)?.error?.message ?? res.status}`)
const err = (await res.json().catch(() => ({}))) as VendorErrorBody
throw new Error(`OpenAI error: ${err.error?.message ?? res.status}`)
}
const data = await res.json()
const data = (await res.json()) as OpenAIResponsesResult
const text = data.output
?.filter((b: any) => b.type === 'message')
.flatMap((b: any) => b.content)
.filter((c: any) => c.type === 'output_text')
.map((c: any) => c.text)
?.filter((b) => b.type === 'message')
.flatMap((b) => b.content ?? [])
.filter((c) => c.type === 'output_text')
.map((c) => c.text ?? '')
.join('') ?? ''
return parseJSON<GeneratedInterest>(text, 'OpenAI interest response')
},
Expand Down Expand Up @@ -199,12 +235,12 @@ async function callAnthropic(prompt: string, apiKey: string): Promise<GeneratedI
'Anthropic',
async (res) => {
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(`Anthropic error: ${(err as any)?.error?.message ?? res.status}`)
const err = (await res.json().catch(() => ({}))) as VendorErrorBody
throw new Error(`Anthropic error: ${err.error?.message ?? res.status}`)
}
const data = await res.json()
const toolBlock = data.content?.find((b: any) => b.type === 'tool_use')
if (!toolBlock) {
const data = (await res.json()) as AnthropicMessagesResult
const toolBlock = data.content?.find((b) => b.type === 'tool_use')
if (!toolBlock?.input) {
throw new Error('Anthropic response did not include a tool_use block with structured output')
}
return toolBlock.input as GeneratedInterest
Expand Down Expand Up @@ -253,10 +289,10 @@ async function callOpenAIReply(tweetText: string, userPrompt: string, apiKey: st
'OpenAI',
async (res) => {
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(`OpenAI error: ${(err as any)?.error?.message ?? res.status}`)
const err = (await res.json().catch(() => ({}))) as VendorErrorBody
throw new Error(`OpenAI error: ${err.error?.message ?? res.status}`)
}
const data = await res.json()
const data = (await res.json()) as OpenAIChatCompletionResult
const text = data.choices?.[0]?.message?.content ?? ''
return parseJSON<GeneratedReply>(text, 'OpenAI reply response')
},
Expand Down Expand Up @@ -296,12 +332,12 @@ async function callAnthropicReply(tweetText: string, userPrompt: string, apiKey:
'Anthropic',
async (res) => {
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(`Anthropic error: ${(err as any)?.error?.message ?? res.status}`)
const err = (await res.json().catch(() => ({}))) as VendorErrorBody
throw new Error(`Anthropic error: ${err.error?.message ?? res.status}`)
}
const data = await res.json()
const toolBlock = data.content?.find((b: any) => b.type === 'tool_use')
if (!toolBlock) {
const data = (await res.json()) as AnthropicMessagesResult
const toolBlock = data.content?.find((b) => b.type === 'tool_use')
if (!toolBlock?.input) {
throw new Error('Anthropic response did not include a tool_use block with structured output')
}
return toolBlock.input as GeneratedReply
Expand Down Expand Up @@ -403,15 +439,15 @@ export async function generateTopicSuggestions(
'OpenAI',
async (res) => {
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(`OpenAI error: ${(err as any)?.error?.message ?? res.status}`)
const err = (await res.json().catch(() => ({}))) as VendorErrorBody
throw new Error(`OpenAI error: ${err.error?.message ?? res.status}`)
}
const data = await res.json()
const data = (await res.json()) as OpenAIResponsesResult
const text = data.output
?.filter((b: any) => b.type === 'message')
.flatMap((b: any) => b.content)
.filter((c: any) => c.type === 'output_text')
.map((c: any) => c.text)
?.filter((b) => b.type === 'message')
.flatMap((b) => b.content ?? [])
.filter((c) => c.type === 'output_text')
.map((c) => c.text ?? '')
.join('') ?? ''
return parseJSON<GeneratedInterest[]>(text, 'OpenAI suggestions response')
},
Expand Down Expand Up @@ -456,15 +492,16 @@ export async function generateTopicSuggestions(
'Anthropic',
async (res) => {
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(`Anthropic error: ${(err as any)?.error?.message ?? res.status}`)
const err = (await res.json().catch(() => ({}))) as VendorErrorBody
throw new Error(`Anthropic error: ${err.error?.message ?? res.status}`)
}
const data = await res.json()
const toolBlock = data.content?.find((b: any) => b.type === 'tool_use')
if (!toolBlock) {
const data = (await res.json()) as AnthropicMessagesResult
const toolBlock = data.content?.find((b) => b.type === 'tool_use')
if (!toolBlock?.input) {
throw new Error('Anthropic response did not include a tool_use block with structured output')
}
return toolBlock.input.suggestions as GeneratedInterest[]
const wrapper = toolBlock.input as { suggestions: GeneratedInterest[] }
return wrapper.suggestions
},
)
}
Expand Down
Loading