diff --git a/.env.example b/.env.example index 20f7686..9b2077a 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,19 @@ ANTHROPIC_API_KEY= # Overrides the default model (claude-opus-5). ANTHROPIC_MODEL= +# The fallback chain, tried in this order: anthropic -> openai -> gemini. +# Each key is independently optional; whichever are set become the chain, and a +# provider is only skipped when it reports that it is out of budget. A capped +# key that regains access rejoins at the front with no redeploy. +OPENAI_API_KEY= +# Overrides the default model (gpt-5.5). No moving "latest" alias exists for +# the reasoning line, so bumping a generation is a config change, not a deploy. +OPENAI_MODEL= + +GEMINI_API_KEY= +# Overrides the default model (gemini-flash-latest). +GEMINI_MODEL= + # -------------------------------------------------------------------- email # Account email only — verification links. Outreach never goes through this. # diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 15dd76a..ab859e8 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -903,7 +903,7 @@ export function createApp(options: AppOptions): Hono { throw new ApiError( 503, 'composer_unavailable', - 'no language model is configured; set ANTHROPIC_API_KEY to enable drafting', + 'no language model is configured; set ANTHROPIC_API_KEY, OPENAI_API_KEY or GEMINI_API_KEY to enable drafting', ); } diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index f4f8e45..830bfdf 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -20,7 +20,13 @@ */ import { closeDatabase, getDatabase, migrate, queryAll } from '@outreachgraph/db'; -import { ClaudeModel, FallbackModel, GeminiModel, type FallbackEntry } from '@outreachgraph/ai'; +import { + ClaudeModel, + FallbackModel, + GeminiModel, + OpenAIModel, + type FallbackEntry, +} from '@outreachgraph/ai'; import { ResendMailer } from '@outreachgraph/email'; import { createApp } from '../../api/src/app'; import { pruneSessions } from '../../api/src/auth'; @@ -93,6 +99,19 @@ if (process.env.ANTHROPIC_API_KEY) { // Second, not instead of. A capped Anthropic key regains access on its own, and // when it does the chain returns to Claude with no redeploy — which is what a // fallback should do rather than becoming a permanent quiet substitution. +// +// OpenAI comes before Gemini because it is the closer substitute: the prompts +// and the deterministic gates were built around Claude, and a same-shaped model +// is likelier to produce a draft that still passes them. +if (process.env.OPENAI_API_KEY) { + chain.push({ + name: 'openai', + model: new OpenAIModel({ + ...(process.env.OPENAI_MODEL ? { model: process.env.OPENAI_MODEL } : {}), + }), + }); +} + if (process.env.GEMINI_API_KEY) { chain.push({ name: 'gemini', diff --git a/docs/prd-implementation-map.md b/docs/prd-implementation-map.md index 894f743..77005db 100644 --- a/docs/prd-implementation-map.md +++ b/docs/prd-implementation-map.md @@ -8,43 +8,44 @@ Where each part of the V1 PRD lives. Code comments cite section numbers ## Implemented -| PRD section | Implementation | Tests | -| ---------------------------------- | ------------------------------------------------------------------------ | --------------------------------- | -| §8 Prospect pipeline | `packages/domain/src/pipeline.ts` | — | -| §9.1–9.3 Identity graph, evidence | `packages/domain/src/person.ts` | — | -| §9.4 Confidence bands, thresholds | `packages/domain/src/confidence.ts` | `identity/src/resolver.test.ts` | -| §9.5 Resolution model | `packages/identity/src/resolver.ts`, `weights.ts` | 25 tests | -| §10.1 Provider interface | `packages/providers/src/provider.ts` | — | -| §10.2 Waterfall | `packages/providers/src/waterfall.ts` | 17 tests | -| §10.3 Provenance | `packages/domain/src/provenance.ts`, `providers/src/waterfall.ts` | included above | -| §11.1–11.2 Signal schema and types | `packages/domain/src/signal.ts` | — | -| §11.3 Signal decay | `packages/signals/src/decay.ts` | 35 tests | -| §12.1–12.6 Scoring | `packages/scoring/src/scores.ts`, `weights.ts` | 31 tests | -| §13.1 Action kinds | `packages/domain/src/networks.ts` | — | -| §13.2 Recommendation schema | `packages/domain/src/outreach.ts` | — | -| §14.1 Outreach composer | `packages/ai/src/composer.ts`, `model.ts`, `draft.ts` | 29 tests | -| §14.2 Quality checks | `packages/ai/src/checks.ts`, `packages/domain/src/outreach.ts` | 40 tests | -| §14.3 Personalised video | `packages/media/src/script.ts`, `renderer.ts`, `render.ts` | 36 tests | -| §15 Approval queue | `apps/api` `GET /recommendations` | `apps/api/src/app.test.ts` | -| §16 Platform policy | `packages/policy/src/capability-matrix.ts` | 46 tests | -| §16.2 Policy modes | `packages/policy/src/capability-matrix.ts` | included above | -| §16.3–16.6 Per-network rules | `packages/policy/src/capability-matrix.ts` | included above | -| §17.3 Suppression | `packages/domain/src/compliance.ts`, migration `0004` | `apps/api/src/app.test.ts` | -| §17.4 Sensitive categories | `packages/domain/src/compliance.ts` | — | -| §17.5 Minors | `evaluateEligibility`, policy `person_ineligible` gate | policy tests | -| §17.6 Source deletion | `packages/pipeline/src/jobs.ts` `markSourceUnavailable` | pipeline tests | -| §18 Rate limits, cooldowns | `packages/policy/src/engine.ts` | policy tests | -| §20.8 Policy engine | `packages/policy/src/engine.ts` | 46 tests | -| §21 Database model | `migrations/0000`–`0007` | `packages/db/src/migrate.test.ts` | -| §23 API endpoints | `apps/api/src/app.ts` | 61 tests | -| §34 Workspace isolation | `apps/api/src/repository.ts` | `apps/api/src/app.test.ts` | -| §37 Feature flags | `feature_flags` table, policy `feature_flag` gate | policy + API tests | -| §13 Next-best-action | `packages/recommend/src/engine.ts` | 25 tests | -| §20.6 Strategy agent | `packages/recommend` — deterministic, chooses only from `allowedActions` | included above | -| §16.6 GitHub as a signal source | `packages/providers/src/github/` | 27 tests | -| §8 Pipeline, end to end | `packages/pipeline/src/pipeline.ts`, `POST /prospects` | 11 + 8 tests, live GitHub run | -| §25.2 Prospect list and detail | `apps/web/app/prospects/`, `GET /people` | 2 API tests + build | -| §34 Email verification | `packages/email/`, `apps/api/src/auth.ts`, migration `0006` | 7 + 11 tests | +| PRD section | Implementation | Tests | +| ---------------------------------- | ------------------------------------------------------------------------ | ---------------------------------- | +| §8 Prospect pipeline | `packages/domain/src/pipeline.ts` | — | +| §9.1–9.3 Identity graph, evidence | `packages/domain/src/person.ts` | — | +| §9.4 Confidence bands, thresholds | `packages/domain/src/confidence.ts` | `identity/src/resolver.test.ts` | +| §9.5 Resolution model | `packages/identity/src/resolver.ts`, `weights.ts` | 25 tests | +| §10.1 Provider interface | `packages/providers/src/provider.ts` | — | +| §10.2 Waterfall | `packages/providers/src/waterfall.ts` | 17 tests | +| §10.3 Provenance | `packages/domain/src/provenance.ts`, `providers/src/waterfall.ts` | included above | +| §11.1–11.2 Signal schema and types | `packages/domain/src/signal.ts` | — | +| §11.3 Signal decay | `packages/signals/src/decay.ts` | 35 tests | +| §12.1–12.6 Scoring | `packages/scoring/src/scores.ts`, `weights.ts` | 31 tests | +| §13.1 Action kinds | `packages/domain/src/networks.ts` | — | +| §13.2 Recommendation schema | `packages/domain/src/outreach.ts` | — | +| §14.1 Outreach composer | `packages/ai/src/composer.ts`, `model.ts`, `draft.ts` | 29 tests | +| §14.1 Model fallback chain | `packages/ai/src/fallback.ts`, `openai.ts`, `gemini.ts` | `packages/ai/src/fallback.test.ts` | +| §14.2 Quality checks | `packages/ai/src/checks.ts`, `packages/domain/src/outreach.ts` | 40 tests | +| §14.3 Personalised video | `packages/media/src/script.ts`, `renderer.ts`, `render.ts` | 36 tests | +| §15 Approval queue | `apps/api` `GET /recommendations` | `apps/api/src/app.test.ts` | +| §16 Platform policy | `packages/policy/src/capability-matrix.ts` | 46 tests | +| §16.2 Policy modes | `packages/policy/src/capability-matrix.ts` | included above | +| §16.3–16.6 Per-network rules | `packages/policy/src/capability-matrix.ts` | included above | +| §17.3 Suppression | `packages/domain/src/compliance.ts`, migration `0004` | `apps/api/src/app.test.ts` | +| §17.4 Sensitive categories | `packages/domain/src/compliance.ts` | — | +| §17.5 Minors | `evaluateEligibility`, policy `person_ineligible` gate | policy tests | +| §17.6 Source deletion | `packages/pipeline/src/jobs.ts` `markSourceUnavailable` | pipeline tests | +| §18 Rate limits, cooldowns | `packages/policy/src/engine.ts` | policy tests | +| §20.8 Policy engine | `packages/policy/src/engine.ts` | 46 tests | +| §21 Database model | `migrations/0000`–`0007` | `packages/db/src/migrate.test.ts` | +| §23 API endpoints | `apps/api/src/app.ts` | 61 tests | +| §34 Workspace isolation | `apps/api/src/repository.ts` | `apps/api/src/app.test.ts` | +| §37 Feature flags | `feature_flags` table, policy `feature_flag` gate | policy + API tests | +| §13 Next-best-action | `packages/recommend/src/engine.ts` | 25 tests | +| §20.6 Strategy agent | `packages/recommend` — deterministic, chooses only from `allowedActions` | included above | +| §16.6 GitHub as a signal source | `packages/providers/src/github/` | 27 tests | +| §8 Pipeline, end to end | `packages/pipeline/src/pipeline.ts`, `POST /prospects` | 11 + 8 tests, live GitHub run | +| §25.2 Prospect list and detail | `apps/web/app/prospects/`, `GET /people` | 2 API tests + build | +| §34 Email verification | `packages/email/`, `apps/api/src/auth.ts`, migration `0006` | 7 + 11 tests | ## Partially implemented diff --git a/packages/ai/src/fallback.test.ts b/packages/ai/src/fallback.test.ts index 37c5e28..2a1f409 100644 --- a/packages/ai/src/fallback.test.ts +++ b/packages/ai/src/fallback.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test'; import { FallbackModel, isBudgetExhausted } from './fallback'; import { GeminiModel, GeminiApiError, DEFAULT_GEMINI_MODEL } from './gemini'; +import { OpenAIModel, OpenAIApiError, DEFAULT_OPENAI_MODEL } from './openai'; import { StubModel, type GenerateInput, type GenerateResult, type TextModel } from './model'; /** A model that always throws, for driving the chain's failure paths. */ @@ -157,13 +158,32 @@ describe('FallbackModel', () => { await expect(chain.generate(INPUT)).rejects.toThrow('quota exceeded'); }); + test('it walks past every exhausted provider, not just the first', async () => { + const skipped: string[] = []; + + const chain = new FallbackModel( + [ + { name: 'anthropic', model: failing(withStatus(400, 'reached your API usage limits')) }, + { name: 'openai', model: failing(withStatus(429, 'insufficient_quota')) }, + { name: 'gemini', model: new StubModel('from gemini') }, + ], + { onFallback: (attempt) => skipped.push(attempt.provider) }, + ); + + // The whole point of a third link: two dead keys is the case that produced + // this chain, and stopping at the second would have been no fallback at all. + expect((await chain.generate(INPUT)).text).toBe('from gemini'); + expect(skipped).toEqual(['anthropic', 'openai']); + }); + test('it reports its order for the boot log', () => { const chain = new FallbackModel([ { name: 'anthropic', model: new StubModel('a') }, - { name: 'gemini', model: new StubModel('b') }, + { name: 'openai', model: new StubModel('b') }, + { name: 'gemini', model: new StubModel('c') }, ]); - expect(chain.providers).toEqual(['anthropic', 'gemini']); + expect(chain.providers).toEqual(['anthropic', 'openai', 'gemini']); }); test('an empty chain is a configuration error, not a silent no-op', () => { @@ -171,6 +191,151 @@ describe('FallbackModel', () => { }); }); +describe('OpenAIModel', () => { + function stubFetch(body: unknown, status = 200) { + return async (): Promise => + new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); + } + + /** Captures the request body so the wire format can be asserted on. */ + function recordingFetch(body: unknown) { + const sent: { body?: Record; headers?: Record } = {}; + + const fetchImpl = async (_input: string | URL | Request, init?: RequestInit) => { + sent.body = JSON.parse(String(init?.body)) as Record; + sent.headers = init?.headers as Record; + return new Response(JSON.stringify(body), { + headers: { 'content-type': 'application/json' }, + }); + }; + + return { sent, fetchImpl }; + } + + test('it reads the reply and reports the cache discount', async () => { + const model = new OpenAIModel({ + apiKey: 'test', + fetchImpl: stubFetch({ + model: 'gpt-5-2025-08-07', + choices: [{ message: { content: '{"ok":true}' }, finish_reason: 'stop' }], + usage: { + prompt_tokens: 300, + completion_tokens: 152, + prompt_tokens_details: { cached_tokens: 256 }, + }, + }), + }); + + const result = await model.generate({ system: 's', user: 'u' }); + + expect(result.text).toBe('{"ok":true}'); + // The snapshot the alias resolved to, not the alias we asked for — the + // draft record should say which model actually wrote it. + expect(result.model).toBe('gpt-5-2025-08-07'); + expect(result.inputTokens).toBe(300); + expect(result.outputTokens).toBe(152); + expect(result.cachedTokens).toBe(256); + }); + + test('the token budget leaves room for reasoning, under the right field name', async () => { + const { sent, fetchImpl } = recordingFetch({ choices: [{ message: { content: 'x' } }] }); + + await new OpenAIModel({ apiKey: 'test', fetchImpl }).generate({ + system: 's', + user: 'u', + maxTokens: 100, + }); + + // `max_tokens` is rejected outright by the reasoning models, and sizing the + // cap to maxTokens alone spends it all on reasoning and returns nothing. + expect(sent.body?.max_tokens).toBeUndefined(); + expect(sent.body?.max_completion_tokens as number).toBeGreaterThan(100); + }); + + test('a refusal is a refusal, not an empty draft', async () => { + const model = new OpenAIModel({ + apiKey: 'test', + fetchImpl: stubFetch({ + choices: [{ message: { content: null, refusal: 'I cannot help with that' } }], + }), + }); + + const result = await model.generate({ system: 's', user: 'u' }); + + // HTTP 200 with null content: read without checking `refusal`, this is a + // successful draft that happens to be blank. + expect(result.refused).toBe(true); + expect(result.text).toBe(''); + }); + + test('an http error carries its status so the chain can classify it', async () => { + const model = new OpenAIModel({ + apiKey: 'test', + fetchImpl: stubFetch({ error: { code: 'insufficient_quota' } }, 429), + }); + + const error = await model.generate({ system: 's', user: 'u' }).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(OpenAIApiError); + expect((error as OpenAIApiError).status).toBe(429); + // And the chain must read that as budget, or the second link never runs. + expect(isBudgetExhausted(error)).toBe(true); + }); + + test('the cached prefix is prepended rather than dropped', async () => { + const { sent, fetchImpl } = recordingFetch({ choices: [{ message: { content: 'x' } }] }); + + await new OpenAIModel({ apiKey: 'test', fetchImpl }).generate({ + system: 'rules', + user: 'u', + cachedPrefix: 'the offering', + }); + + const messages = sent.body?.messages as { role: string; content: string }[]; + // Caching is automatic on the prefix here, so it leads; dropping it would + // change the instructions the model is working from. + expect(messages[0]!.role).toBe('system'); + expect(messages[0]!.content).toBe('the offering\n\nrules'); + }); + + test('the real credit-exhausted body reads as budget, not as a bad request', async () => { + // Verbatim from the live API on 2026-08-13, which is how the shape below + // is known rather than assumed. + const model = new OpenAIModel({ + apiKey: 'test', + fetchImpl: stubFetch( + { + error: { + message: 'You have no credits remaining. Add credits to continue using the API.', + type: 'insufficient_quota', + code: 'credit_balance_exhausted', + }, + }, + 429, + ), + }); + + const error = await model.generate({ system: 's', user: 'u' }).catch((e: unknown) => e); + expect(isBudgetExhausted(error)).toBe(true); + }); + + test('the default model is not a dated snapshot', () => { + // A pinned snapshot retires on a schedule; that is an outage with a date. + expect(DEFAULT_OPENAI_MODEL).not.toMatch(/\d{4}-\d{2}-\d{2}/); + }); + + test('the key travels as a bearer header, never in the URL', async () => { + const { sent, fetchImpl } = recordingFetch({ choices: [{ message: { content: 'x' } }] }); + + await new OpenAIModel({ apiKey: 'secret-key', fetchImpl }).generate({ system: 's', user: 'u' }); + + expect(sent.headers?.authorization).toBe('Bearer secret-key'); + }); +}); + describe('GeminiModel', () => { function stubFetch(body: unknown, status = 200) { return async (): Promise => diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 00afb5f..181f63f 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -42,6 +42,14 @@ export { draftForRecommendation, type DraftResult } from './draft'; export { draftProfile, type ProfileDraft, type ProfileDraftResult } from './profile'; +export { + OpenAIModel, + OpenAIApiError, + MissingOpenAIKeyError, + DEFAULT_OPENAI_MODEL, + type OpenAIModelOptions, +} from './openai'; + export { GeminiModel, GeminiApiError, diff --git a/packages/ai/src/openai.ts b/packages/ai/src/openai.ts new file mode 100644 index 0000000..94e1489 --- /dev/null +++ b/packages/ai/src/openai.ts @@ -0,0 +1,150 @@ +/** + * OpenAI behind the same `TextModel` interface. + * + * This is the first fallback, not the second: it sits between Anthropic and + * Gemini because it is the closer substitute — same instruction-following + * shape, same prompt structure, so a draft written here still passes the + * deterministic gates that were tuned against Claude's output. + * + * Plain HTTP rather than the OpenAI SDK, for the reason `GeminiModel` gives: + * one POST with a documented body is less to keep working than a dependency, + * and it keeps this package's install footprint to the one SDK it already has. + */ + +import type { GenerateInput, GenerateResult, TextModel } from './model'; + +/** + * Undated rather than a dated snapshot, matching the Gemini adapter's + * reasoning: snapshots retire on a schedule and a pinned one is an outage with + * a date on it. `gpt-5.5` resolves to whichever snapshot backs that generation. + * + * There is no moving `-latest` alias to use here the way Gemini has one — the + * `*-chat-latest` ids move, but they are the chat-tuned line and not the + * reasoning line, so they are not the same model with a friendlier name. That + * makes this a generation to be bumped by hand, and `OPENAI_MODEL` exists so a + * newer one can be adopted without a deploy of this file. + */ +export const DEFAULT_OPENAI_MODEL = 'gpt-5.5'; + +const ENDPOINT = 'https://api.openai.com/v1/chat/completions'; + +export interface OpenAIModelOptions { + readonly apiKey?: string; + readonly model?: string; + /** For a proxy or a compatible gateway; defaults to the OpenAI endpoint. */ + readonly endpoint?: string; + readonly fetchImpl?: (input: string | URL | Request, init?: RequestInit) => Promise; +} + +export class MissingOpenAIKeyError extends Error { + constructor() { + super('OPENAI_API_KEY is not configured'); + this.name = 'MissingOpenAIKeyError'; + } +} + +/** Carries the HTTP status so the fallback chain can tell budget from bad input. */ +export class OpenAIApiError extends Error { + readonly status: number; + + constructor(status: number, detail: string) { + super(`openai ${status}: ${detail}`); + this.name = 'OpenAIApiError'; + this.status = status; + } +} + +interface OpenAIResponse { + model?: string; + choices?: { + message?: { content?: string | null; refusal?: string | null }; + finish_reason?: string; + }[]; + usage?: { + prompt_tokens?: number; + completion_tokens?: number; + prompt_tokens_details?: { cached_tokens?: number }; + }; +} + +export class OpenAIModel implements TextModel { + readonly #apiKey: string; + readonly #model: string; + readonly #endpoint: string; + readonly #fetch: NonNullable; + + constructor(options: OpenAIModelOptions = {}) { + const apiKey = options.apiKey ?? process.env.OPENAI_API_KEY; + if (!apiKey) throw new MissingOpenAIKeyError(); + + this.#apiKey = apiKey; + this.#model = options.model ?? DEFAULT_OPENAI_MODEL; + this.#endpoint = options.endpoint ?? ENDPOINT; + this.#fetch = options.fetchImpl ?? fetch; + } + + async generate(input: GenerateInput): Promise { + const model = input.model ?? this.#model; + + // Prompt caching here is automatic on the prefix of the request — there is + // no breakpoint to declare — so the cached half is simply put first and + // the discount either applies or it does not. Dropping it instead would + // change the instructions the model works from, which is the one outcome + // worth avoiding. + const system = input.cachedPrefix ? `${input.cachedPrefix}\n\n${input.system}` : input.system; + + // Reasoning tokens are drawn from this same budget and are not small, the + // lesson `GeminiModel` already paid for: sizing the cap to the caller's + // `maxTokens` alone lets the model reason up to the limit and return an + // empty string, which reads downstream as a model that had nothing to say. + const maxCompletionTokens = (input.maxTokens ?? 2048) * 3 + 2048; + + let response: Response; + try { + response = await this.#fetch(this.#endpoint, { + method: 'POST', + headers: { + authorization: `Bearer ${this.#apiKey}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + model, + // Not `max_tokens`: the reasoning models reject it outright, and a + // fallback that 400s on every call is worse than no fallback. + max_completion_tokens: maxCompletionTokens, + messages: [ + { role: 'system', content: system }, + { role: 'user', content: input.user }, + ], + }), + }); + } catch (error) { + throw new OpenAIApiError(0, error instanceof Error ? error.message : String(error)); + } + + if (!response.ok) { + const detail = await response.text().catch(() => ''); + throw new OpenAIApiError(response.status, detail.slice(0, 500)); + } + + const body = (await response.json()) as OpenAIResponse; + + const choice = body.choices?.[0]; + + // A refusal comes back as HTTP 200 with `refusal` set and `content` null. + // Reading content without checking it is how a refusal becomes an empty + // draft that looks like success. + const refused = Boolean(choice?.message?.refusal) || choice?.finish_reason === 'content_filter'; + + return { + text: (choice?.message?.content ?? '').trim(), + model: body.model ?? model, + inputTokens: body.usage?.prompt_tokens ?? 0, + // `completion_tokens` already includes reasoning tokens, so this is the + // billed figure rather than a flattering subset of it. + outputTokens: body.usage?.completion_tokens ?? 0, + cachedTokens: body.usage?.prompt_tokens_details?.cached_tokens ?? 0, + refused, + }; + } +}