diff --git a/README.md b/README.md index 83c7926..8ca276a 100644 --- a/README.md +++ b/README.md @@ -175,8 +175,10 @@ An employer with a brief and no time can have a model expand it into the form. S one key and the box appears on `/post`; set neither and it does not: ```bash -OPENAI_API_KEY=sk-... # or ANTHROPIC_API_KEY=sk-ant-... -WRITER_MODEL=gpt-5.2-codex # optional; defaults to gpt-5.2 or claude-opus-5 +ANTHROPIC_API_KEY=sk-ant-... # tried first +OPENAI_API_KEY=sk-... # either alone works; with both, OpenAI is the fallback +WRITER_MODEL=gpt-5.2-codex # optional; defaults to claude-opus-5 / gpt-5.2, and only + # applies to the provider it names (claude-* = Anthropic) ``` It fills the form in and stops. Nothing is written and nothing is published: the diff --git a/package.json b/package.json index 65bdeb2..b5c52e2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/agenticjobs", - "version": "0.22.4", + "version": "0.22.5", "description": "An agent-friendly job board you self-host. It posts its own jobs, never scrapes anyone else's, and answers on every surface: web, API, MCP, CLI, TUI, desktop and PWA. Instances find each other through an open directory.", "license": "MIT", "type": "module", diff --git a/src/config.ts b/src/config.ts index 8d29976..bcb5e69 100644 --- a/src/config.ts +++ b/src/config.ts @@ -187,7 +187,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { } /** Kept in step with package.json by the release script. */ -export const VERSION = '0.22.4'; +export const VERSION = '0.22.5'; export const SOFTWARE_NAME = 'agenticjobs'; /** diff --git a/src/core/agentwriter.ts b/src/core/agentwriter.ts index c9ec337..c15cccd 100644 --- a/src/core/agentwriter.ts +++ b/src/core/agentwriter.ts @@ -10,8 +10,10 @@ * * Two providers, because this is MIT software other people self-host and a * board that only works if you bank with one vendor is not self-hostable. The - * key that is set decides; when neither is set the feature is simply absent - * from the page rather than present and broken. + * keys that are set decide; when neither is set the feature is simply absent + * from the page rather than present and broken. With both set, the second is + * a fallback: a provider that is out of credits or down hands the brief to the + * other one instead of the employer. * * Written against the two REST APIs directly rather than either vendor SDK. * One dependency per provider to fill in a form is a poor trade for a package @@ -36,16 +38,33 @@ export class AgentWriterProblem extends Error {} export type WriterProvider = 'anthropic' | 'openai'; /** - * Which provider this instance can use, or null for none. + * The providers this instance can use, in the order they are tried. * - * Anthropic first when both are set, for no better reason than that a board - * with both keys has to pick one and a coin flip at request time would make - * the same brief produce different listings. + * Anthropic first when both are set, so the same brief goes to the same model + * whenever it can; OpenAI only answers when Anthropic could not. */ +export function writerProviders(config: Config): WriterProvider[] { + const providers: WriterProvider[] = []; + if (config.anthropicApiKey !== null) providers.push('anthropic'); + if (config.openaiApiKey !== null) providers.push('openai'); + return providers; +} + +/** The provider tried first, or null for none. */ export function writerProvider(config: Config): WriterProvider | null { - if (config.anthropicApiKey !== null) return 'anthropic'; - if (config.openaiApiKey !== null) return 'openai'; - return null; + return writerProviders(config)[0] ?? null; +} + +/** + * WRITER_MODEL, for the provider it names. + * + * One setting serves two vendors, so a Claude model name must never be sent to + * OpenAI on the fallback path, nor a GPT name to Anthropic. + */ +function modelFor(provider: WriterProvider, config: Config): string | null { + const model = config.writerModel; + if (model === null) return null; + return model.startsWith('claude') === (provider === 'anthropic') ? model : null; } /** @@ -118,8 +137,8 @@ export async function draftListing( throw new AgentWriterProblem('Say a little more about the role, and I will draft it.'); } - const provider = writerProvider(config); - if (provider === null) { + const providers = writerProviders(config); + if (providers.length === 0) { throw new AgentWriterProblem('This board has no model configured.'); } @@ -133,12 +152,26 @@ export async function draftListing( .filter((line) => line !== null) .join('\n'); - const raw = - provider === 'anthropic' - ? await askAnthropic(prompt, config, fetchImpl) - : await askOpenAi(prompt, config, fetchImpl); - - return fieldsFromModelJson(raw); + // Each provider in turn. A failure on the first is logged for whoever runs + // the board and never shown; the employer only hears about it when every + // provider failed, and then from the last one tried. + let problem: AgentWriterProblem | null = null; + for (const provider of providers) { + try { + const raw = + provider === 'anthropic' + ? await askAnthropic(prompt, config, fetchImpl) + : await askOpenAi(prompt, config, fetchImpl); + return fieldsFromModelJson(raw); + } catch (error) { + if (!(error instanceof AgentWriterProblem)) throw error; + if (problem === null && providers.length > 1) { + console.error(`agent writer: ${provider} failed, trying the next provider: ${error.message}`); + } + problem = error; + } + } + throw problem ?? new AgentWriterProblem('This board has no model configured.'); } /** @@ -161,7 +194,7 @@ async function askAnthropic( 'anthropic-version': '2023-06-01', }, body: JSON.stringify({ - model: config.writerModel ?? 'claude-opus-5', + model: modelFor('anthropic', config) ?? 'claude-opus-5', max_tokens: 4000, output_config: { effort: 'low' }, system: SYSTEM, @@ -207,7 +240,7 @@ async function askOpenAi( authorization: `Bearer ${config.openaiApiKey ?? ''}`, }, body: JSON.stringify({ - model: config.writerModel ?? 'gpt-5.2', + model: modelFor('openai', config) ?? 'gpt-5.2', max_completion_tokens: 4000, response_format: { type: 'json_object' }, messages: [ @@ -239,12 +272,18 @@ async function askOpenAi( * carry account details that do not belong on a public page. */ async function problemFor(response: Response, provider: string): Promise { - // Read and discard, so the connection is not left hanging on a body nobody - // consumed. - await response.text().catch(() => ''); + // Read in full, so the connection is not left hanging, and so an empty + // account can be told apart from a busy one. Both providers say "out of + // credits" with a status that also means something else: OpenAI with a 429, + // Anthropic with a 400. + const body = await response.text().catch(() => ''); + console.error(`agent writer: ${provider} answered ${response.status}: ${body.slice(0, 300)}`); if (response.status === 401 || response.status === 403) { return new AgentWriterProblem(`This board's ${provider} key was rejected.`); } + if (/insufficient_quota|credit_balance|credit balance/i.test(body)) { + return new AgentWriterProblem(`This board's ${provider} account is out of credits.`); + } if (response.status === 429) { return new AgentWriterProblem('The model is rate limited right now. Try again shortly.'); } diff --git a/test/agentwriter.test.ts b/test/agentwriter.test.ts index 630d558..6524816 100644 --- a/test/agentwriter.test.ts +++ b/test/agentwriter.test.ts @@ -206,3 +206,46 @@ test('a brief too short to work with never reaches the model', async () => { ); assert.equal(called, false, 'and costs the board nothing'); }); + +test('with both keys, an Anthropic failure falls back to OpenAI', async () => { + const sent = []; + const fake = async (url, init) => { + sent.push({ url, model: JSON.parse(init.body).model }); + if (url.includes('anthropic')) { + return new Response(JSON.stringify({ error: { message: 'Your credit balance is too low' } }), { + status: 400, + }); + } + return new Response(JSON.stringify({ choices: [{ message: { content: FULL } }] }), { status: 200 }); + }; + + const values = await draftListing('A real brief about a real job.', { boardName: 'B', orgName: null }, + { ...CONFIG, anthropicApiKey: 'sk-ant', openaiApiKey: 'sk-oai', writerModel: 'claude-haiku-5-5' }, fake); + + assert.equal(values['title'], 'Senior Go Engineer'); + assert.deepEqual(sent.map((s) => s.url), [ + 'https://api.anthropic.com/v1/messages', + 'https://api.openai.com/v1/chat/completions', + ]); + assert.equal(sent[0].model, 'claude-haiku-5-5'); + assert.equal(sent[1].model, 'gpt-5.2', 'a Claude model name is never sent to OpenAI'); +}); + +test('an empty account says so, rather than claiming a rate limit', async () => { + // OpenAI answers an exhausted balance with a 429, the same status as a real + // rate limit, and "try again shortly" would never come true. + const broke = async () => + new Response(JSON.stringify({ error: { code: 'credit_balance_exhausted', type: 'insufficient_quota' } }), { + status: 429, + }); + + await assert.rejects( + draftListing('A real brief about a real job.', { boardName: 'B', orgName: null }, + { ...CONFIG, openaiApiKey: 'sk-test' }, broke), + (error) => { + assert.ok(error instanceof AgentWriterProblem); + assert.match(error.message, /out of credits/); + return true; + }, + ); +});