Compare commits

..

2 Commits

Author SHA1 Message Date
682df6a573 fix(backend): disable reasoning and fallback to reasoning field
Set reasoning effort to none in the LLM client to reduce latency
and token usage. Fall back to the reasoning field when content is
absent in the response.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-22 22:46:03 +00:00
a52addebd8 feat(backend): add GET /api/sources/:sourceId (#89)
Return { enabled, config } for a user's source. Defaults to
{ enabled: false, config: {} } when no row exists.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-22 21:45:17 +00:00
6 changed files with 135 additions and 119 deletions

View File

@@ -50,11 +50,13 @@ export function createLlmClient(config: LlmClientConfig): LlmClient {
schema: enhancementResultJsonSchema, schema: enhancementResultJsonSchema,
}, },
}, },
reasoning: { effort: "none" },
stream: false, stream: false,
}, },
}) })
const content = response.choices?.[0]?.message?.content const message = response.choices?.[0]?.message
const content = message?.content ?? message?.reasoning
if (typeof content !== "string") { if (typeof content !== "string") {
console.warn("[enhancement] LLM returned no content in response") console.warn("[enhancement] LLM returned no content in response")
return null return null

View File

@@ -5,10 +5,10 @@ import merge from "lodash.merge"
import type { Database } from "../db/index.ts" import type { Database } from "../db/index.ts"
import type { FeedEnhancer } from "../enhancement/enhance-feed.ts" import type { FeedEnhancer } from "../enhancement/enhance-feed.ts"
import type { FeedSourceProvider } from "./feed-source-provider.ts"
import { InvalidSourceConfigError, SourceNotFoundError } from "../sources/errors.ts" import { InvalidSourceConfigError, SourceNotFoundError } from "../sources/errors.ts"
import { sources } from "../sources/user-sources.ts" import { sources } from "../sources/user-sources.ts"
import type { FeedSourceProvider } from "./feed-source-provider.ts"
import { UserSession } from "./user-session.ts" import { UserSession } from "./user-session.ts"
export interface UserSessionManagerConfig { export interface UserSessionManagerConfig {
@@ -38,6 +38,27 @@ export class UserSessionManager {
return this.providers.get(sourceId) return this.providers.get(sourceId)
} }
/**
* Returns the user's config for a source, or defaults if no row exists.
*
* @throws {SourceNotFoundError} if the sourceId has no registered provider
*/
async fetchSourceConfig(
userId: string,
sourceId: string,
): Promise<{ enabled: boolean; config: unknown }> {
const provider = this.providers.get(sourceId)
if (!provider) {
throw new SourceNotFoundError(sourceId, userId)
}
const row = await sources(this.db, userId).find(sourceId)
return {
enabled: row?.enabled ?? false,
config: row?.config ?? {},
}
}
async getOrCreate(userId: string): Promise<UserSession> { async getOrCreate(userId: string): Promise<UserSession> {
const existing = this.sessions.get(userId) const existing = this.sessions.get(userId)
if (existing) return existing if (existing) return existing
@@ -104,16 +125,18 @@ export class UserSessionManager {
// read stale config. Use SELECT FOR UPDATE or atomic jsonb merge if // read stale config. Use SELECT FOR UPDATE or atomic jsonb merge if
// this becomes a problem. // this becomes a problem.
let mergedConfig: Record<string, unknown> | undefined let mergedConfig: Record<string, unknown> | undefined
if (update.config !== undefined && provider.configSchema) { if (update.config !== undefined) {
const existing = await sources(this.db, userId).find(sourceId) const existing = await sources(this.db, userId).find(sourceId)
const existingConfig = (existing?.config ?? {}) as Record<string, unknown> const existingConfig = (existing?.config ?? {}) as Record<string, unknown>
mergedConfig = merge({}, existingConfig, update.config) mergedConfig = merge({}, existingConfig, update.config)
if (provider.configSchema) {
const validated = provider.configSchema(mergedConfig) const validated = provider.configSchema(mergedConfig)
if (validated instanceof type.errors) { if (validated instanceof type.errors) {
throw new InvalidSourceConfigError(sourceId, validated.summary) throw new InvalidSourceConfigError(sourceId, validated.summary)
} }
} }
}
// Throws SourceNotFoundError if the row doesn't exist // Throws SourceNotFoundError if the row doesn't exist
await sources(this.db, userId).updateConfig(sourceId, { await sources(this.db, userId).updateConfig(sourceId, {
@@ -146,24 +169,23 @@ export class UserSessionManager {
async upsertSourceConfig( async upsertSourceConfig(
userId: string, userId: string,
sourceId: string, sourceId: string,
data: { enabled: boolean; config?: unknown }, data: { enabled: boolean; config: unknown },
): Promise<void> { ): Promise<void> {
const provider = this.providers.get(sourceId) const provider = this.providers.get(sourceId)
if (!provider) { if (!provider) {
throw new SourceNotFoundError(sourceId, userId) throw new SourceNotFoundError(sourceId, userId)
} }
if (provider.configSchema && data.config !== undefined) { if (provider.configSchema) {
const validated = provider.configSchema(data.config) const validated = provider.configSchema(data.config)
if (validated instanceof type.errors) { if (validated instanceof type.errors) {
throw new InvalidSourceConfigError(sourceId, validated.summary) throw new InvalidSourceConfigError(sourceId, validated.summary)
} }
} }
const config = data.config ?? {}
await sources(this.db, userId).upsertConfig(sourceId, { await sources(this.db, userId).upsertConfig(sourceId, {
enabled: data.enabled, enabled: data.enabled,
config, config: data.config,
}) })
const session = this.sessions.get(userId) const session = this.sessions.get(userId)
@@ -171,7 +193,7 @@ export class UserSessionManager {
if (!data.enabled) { if (!data.enabled) {
session.removeSource(sourceId) session.removeSource(sourceId)
} else { } else {
const source = await provider.feedSourceForUser(userId, config) const source = await provider.feedSourceForUser(userId, data.config)
if (session.hasSource(sourceId)) { if (session.hasSource(sourceId)) {
session.replaceSource(sourceId, source) session.replaceSource(sourceId, source)
} else { } else {

View File

@@ -138,6 +138,10 @@ function patch(app: Hono, sourceId: string, body: unknown) {
}) })
} }
function get(app: Hono, sourceId: string) {
return app.request(`/api/sources/${sourceId}`, { method: "GET" })
}
function put(app: Hono, sourceId: string, body: unknown) { function put(app: Hono, sourceId: string, body: unknown) {
return app.request(`/api/sources/${sourceId}`, { return app.request(`/api/sources/${sourceId}`, {
method: "PUT", method: "PUT",
@@ -150,6 +154,72 @@ function put(app: Hono, sourceId: string, body: unknown) {
// Tests // Tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe("GET /api/sources/:sourceId", () => {
test("returns 401 without auth", async () => {
activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)])
const res = await get(app, "aelis.weather")
expect(res.status).toBe(401)
})
test("returns 404 for unknown source", async () => {
activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await get(app, "unknown.source")
expect(res.status).toBe(404)
const body = (await res.json()) as { error: string }
expect(body.error).toContain("not found")
})
test("returns enabled and config for existing source", async () => {
activeStore = createInMemoryStore()
activeStore.seed(MOCK_USER_ID, "aelis.weather", {
enabled: true,
config: { units: "metric" },
})
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await get(app, "aelis.weather")
expect(res.status).toBe(200)
const body = (await res.json()) as { enabled: boolean; config: unknown }
expect(body.enabled).toBe(true)
expect(body.config).toEqual({ units: "metric" })
})
test("returns defaults when user has no row for source", async () => {
activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await get(app, "aelis.weather")
expect(res.status).toBe(200)
const body = (await res.json()) as { enabled: boolean; config: unknown }
expect(body.enabled).toBe(false)
expect(body.config).toEqual({})
})
test("returns disabled source", async () => {
activeStore = createInMemoryStore()
activeStore.seed(MOCK_USER_ID, "aelis.weather", {
enabled: false,
config: { units: "imperial" },
})
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await get(app, "aelis.weather")
expect(res.status).toBe(200)
const body = (await res.json()) as { enabled: boolean; config: unknown }
expect(body.enabled).toBe(false)
expect(body.config).toEqual({ units: "imperial" })
})
})
describe("PATCH /api/sources/:sourceId", () => { describe("PATCH /api/sources/:sourceId", () => {
test("returns 401 without auth", async () => { test("returns 401 without auth", async () => {
activeStore = createInMemoryStore() activeStore = createInMemoryStore()
@@ -217,31 +287,6 @@ describe("PATCH /api/sources/:sourceId", () => {
expect(body.error).toContain("Invalid JSON") expect(body.error).toContain("Invalid JSON")
}) })
test("returns 400 when request body contains unknown fields", async () => {
activeStore = createInMemoryStore()
activeStore.seed(MOCK_USER_ID, "aelis.weather")
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await patch(app, "aelis.weather", {
enabled: true,
unknownField: "hello",
})
expect(res.status).toBe(400)
})
test("returns 400 when weather config contains unknown fields", async () => {
activeStore = createInMemoryStore()
activeStore.seed(MOCK_USER_ID, "aelis.weather")
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await patch(app, "aelis.weather", {
config: { units: "metric", unknownField: "hello" },
})
expect(res.status).toBe(400)
})
test("returns 400 when weather config fails validation", async () => { test("returns 400 when weather config fails validation", async () => {
activeStore = createInMemoryStore() activeStore = createInMemoryStore()
activeStore.seed(MOCK_USER_ID, "aelis.weather") activeStore.seed(MOCK_USER_ID, "aelis.weather")
@@ -365,7 +410,7 @@ describe("PATCH /api/sources/:sourceId", () => {
removeSpy.mockRestore() removeSpy.mockRestore()
}) })
test("returns 400 when config is provided for source without schema", async () => { test("accepts location source with arbitrary config (no schema)", async () => {
activeStore = createInMemoryStore() activeStore = createInMemoryStore()
activeStore.seed(MOCK_USER_ID, "aelis.location") activeStore.seed(MOCK_USER_ID, "aelis.location")
const { app } = createApp([createStubProvider("aelis.location")], MOCK_USER_ID) const { app } = createApp([createStubProvider("aelis.location")], MOCK_USER_ID)
@@ -374,19 +419,7 @@ describe("PATCH /api/sources/:sourceId", () => {
config: { something: "value" }, config: { something: "value" },
}) })
expect(res.status).toBe(400) expect(res.status).toBe(204)
})
test("returns 400 when empty config is provided for source without schema", async () => {
activeStore = createInMemoryStore()
activeStore.seed(MOCK_USER_ID, "aelis.location")
const { app } = createApp([createStubProvider("aelis.location")], MOCK_USER_ID)
const res = await patch(app, "aelis.location", {
config: {},
})
expect(res.status).toBe(400)
}) })
test("updates enabled on location source", async () => { test("updates enabled on location source", async () => {
@@ -460,31 +493,6 @@ describe("PUT /api/sources/:sourceId", () => {
expect(res.status).toBe(400) expect(res.status).toBe(400)
}) })
test("returns 400 when request body contains unknown fields", async () => {
activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await put(app, "aelis.weather", {
enabled: true,
config: { units: "metric" },
unknownField: "hello",
})
expect(res.status).toBe(400)
})
test("returns 400 when weather config contains unknown fields", async () => {
activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
const res = await put(app, "aelis.weather", {
enabled: true,
config: { units: "metric", unknownField: "hello" },
})
expect(res.status).toBe(400)
})
test("returns 400 when config fails schema validation", async () => { test("returns 400 when config fails schema validation", async () => {
activeStore = createInMemoryStore() activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID) const { app } = createApp([createStubProvider("aelis.weather", weatherConfig)], MOCK_USER_ID)
@@ -603,7 +611,7 @@ describe("PUT /api/sources/:sourceId", () => {
expect(session.hasSource("aelis.weather")).toBe(true) expect(session.hasSource("aelis.weather")).toBe(true)
}) })
test("returns 400 when config is provided for source without schema", async () => { test("accepts location source with arbitrary config (no schema)", async () => {
activeStore = createInMemoryStore() activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.location")], MOCK_USER_ID) const { app } = createApp([createStubProvider("aelis.location")], MOCK_USER_ID)
@@ -612,29 +620,9 @@ describe("PUT /api/sources/:sourceId", () => {
config: { something: "value" }, config: { something: "value" },
}) })
expect(res.status).toBe(400)
})
test("returns 400 when empty config is provided for source without schema", async () => {
activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.location")], MOCK_USER_ID)
const res = await put(app, "aelis.location", {
enabled: true,
config: {},
})
expect(res.status).toBe(400)
})
test("returns 204 without config field for source without schema", async () => {
activeStore = createInMemoryStore()
const { app } = createApp([createStubProvider("aelis.location")], MOCK_USER_ID)
const res = await put(app, "aelis.location", {
enabled: true,
})
expect(res.status).toBe(204) expect(res.status).toBe(204)
const row = activeStore.rows.get(`${MOCK_USER_ID}:aelis.location`)
expect(row).toBeDefined()
expect(row!.config).toEqual({ something: "value" })
}) })
}) })

View File

@@ -20,22 +20,15 @@ interface SourcesHttpHandlersDeps {
} }
const UpdateSourceConfigRequestBody = type({ const UpdateSourceConfigRequestBody = type({
"+": "reject",
"enabled?": "boolean", "enabled?": "boolean",
"config?": "unknown", "config?": "unknown",
}) })
const ReplaceSourceConfigRequestBody = type({ const ReplaceSourceConfigRequestBody = type({
"+": "reject",
enabled: "boolean", enabled: "boolean",
config: "unknown", config: "unknown",
}) })
const ReplaceSourceConfigNoConfigRequestBody = type({
"+": "reject",
enabled: "boolean",
})
export function registerSourcesHttpHandlers( export function registerSourcesHttpHandlers(
app: Hono, app: Hono,
{ sessionManager, authSessionMiddleware }: SourcesHttpHandlersDeps, { sessionManager, authSessionMiddleware }: SourcesHttpHandlersDeps,
@@ -45,10 +38,31 @@ export function registerSourcesHttpHandlers(
await next() await next()
}) })
app.get("/api/sources/:sourceId", inject, authSessionMiddleware, handleGetSource)
app.patch("/api/sources/:sourceId", inject, authSessionMiddleware, handleUpdateSource) app.patch("/api/sources/:sourceId", inject, authSessionMiddleware, handleUpdateSource)
app.put("/api/sources/:sourceId", inject, authSessionMiddleware, handleReplaceSource) app.put("/api/sources/:sourceId", inject, authSessionMiddleware, handleReplaceSource)
} }
async function handleGetSource(c: Context<Env>) {
const sourceId = c.req.param("sourceId")
if (!sourceId) {
return c.body(null, 404)
}
const sessionManager = c.get("sessionManager")
const user = c.get("user")!
try {
const result = await sessionManager.fetchSourceConfig(user.id, sourceId)
return c.json(result)
} catch (err) {
if (err instanceof SourceNotFoundError) {
return c.json({ error: err.message }, 404)
}
throw err
}
}
async function handleUpdateSource(c: Context<Env>) { async function handleUpdateSource(c: Context<Env>) {
const sourceId = c.req.param("sourceId") const sourceId = c.req.param("sourceId")
if (!sourceId) { if (!sourceId) {
@@ -76,10 +90,6 @@ async function handleUpdateSource(c: Context<Env>) {
return c.json({ error: parsed.summary }, 400) return c.json({ error: parsed.summary }, 400)
} }
if (!provider.configSchema && "config" in parsed) {
return c.json({ error: `Source "${sourceId}" does not accept config` }, 400)
}
const { enabled, config: newConfig } = parsed const { enabled, config: newConfig } = parsed
const user = c.get("user")! const user = c.get("user")!
@@ -121,16 +131,12 @@ async function handleReplaceSource(c: Context<Env>) {
return c.json({ error: "Invalid JSON" }, 400) return c.json({ error: "Invalid JSON" }, 400)
} }
const schema = provider.configSchema const parsed = ReplaceSourceConfigRequestBody(body)
? ReplaceSourceConfigRequestBody
: ReplaceSourceConfigNoConfigRequestBody
const parsed = schema(body)
if (parsed instanceof type.errors) { if (parsed instanceof type.errors) {
return c.json({ error: parsed.summary }, 400) return c.json({ error: parsed.summary }, 400)
} }
const { enabled } = parsed const { enabled, config } = parsed
const config = "config" in parsed ? parsed.config : undefined
const user = c.get("user")! const user = c.get("user")!
try { try {

View File

@@ -8,7 +8,6 @@ export type TflSourceProviderOptions =
| { apiKey?: never; client: ITflApi } | { apiKey?: never; client: ITflApi }
export const tflConfig = type({ export const tflConfig = type({
"+": "reject",
"lines?": "string[]", "lines?": "string[]",
}) })

View File

@@ -9,7 +9,6 @@ export interface WeatherSourceProviderOptions {
} }
export const weatherConfig = type({ export const weatherConfig = type({
"+": "reject",
"units?": "'metric' | 'imperial'", "units?": "'metric' | 'imperial'",
"hourlyLimit?": "number", "hourlyLimit?": "number",
"dailyLimit?": "number", "dailyLimit?": "number",