From 45b6c16305fe449ca2271a2f312bf828a13feb85 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 19 Oct 2026 10:36:39 +0000 Subject: [PATCH] 0.22.5: draft writer falls back to OpenAI, and says when an account is empty The /post drafter only ever called one provider, and reported OpenAI's "no credits remaining" 429 as "rate limited, try again shortly". Now Anthropic is tried first and OpenAI answers when it fails; an exhausted balance on either says "out of credits"; the provider's raw answer is logged server-side (never shown); WRITER_MODEL only goes to the provider it names. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 ++- package.json | 2 +- src/config.ts | 2 +- src/core/agentwriter.ts | 83 +++++++++++++++++++++++++++++----------- test/agentwriter.test.ts | 43 +++++++++++++++++++++ 5 files changed, 110 insertions(+), 26 deletions(-) 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; + }, + ); +});