Two Free Adapters, One Contract to Test
A free tier is a contract, not a promise. Write the contract down first. Then test it before you build on it.
Most teams do this backwards. They wire the free endpoint into the app, ship, and learn the rules when something breaks. That is not architecture review. That is archaeology after the outage.
This is an architecture review you can run this week. Two ports, two adapters, one contract suite. The free model and the free server stay replaceable.
Name the constraints before you name the vendor
Start with a constraint interview. Write the answers down. Where you have no answer, write unknown.
Ask three things of every free dependency. What is the capacity, and who counts it? What happens at the boundary — error, silence, or throttle? Which parts of your system stop when it stops?
Capacity is the easy one. The operator states that MonkeyCode offers free model access and a free server option, with 10 million free tokens as a starting budget. Confirm the current number in your dashboard before you plan anything. Numbers move. Treat any figure as a snapshot.
Latency, model identity, and retention are harder. If you cannot verify them, they belong in your design as unknown. An unknown field is honest. A guessed field becomes a false assumption in a test.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Draw the data flow as two separate lanes
One lane spends tokens. One lane spends compute. They fail for different reasons, so keep them apart.
Your API calls the model lane on request. Your worker calls the server lane on a schedule. Both lanes sit behind interfaces you own.
// ports.ts — the only surface the rest of your app may touch
export type Usage = { inputTokens: number; outputTokens: number };
export interface InferencePort {
complete(req: { prompt: string; maxTokens: number; signal?: AbortSignal }): Promise<
| { ok: true; text: string; usage: Usage }
| { ok: false; reason: "capped" | "timeout" | "refused" | "bad_response" }
>;
}
export interface RuntimePort {
run<T>(job: { name: string; payload: unknown; timeoutMs: number }): Promise<
| { ok: true; value: T }
| { ok: false; reason: "unavailable" | "timeout" | "crashed" }
>;
}
Notice what is missing. There is no throw for expected failures. Running out of tokens is normal on a free tier. It is a return value, not an exception.
The adapter below is a skeleton, not a tested client. Swap in the real endpoint and header names. Keep the shape.
// adapters/freeModel.ts
export function freeModelAdapter(cfg: {
baseUrl: string;
apiKey: string;
maxOutput: number;
}): InferencePort {
return {
async complete({ prompt, maxTokens, signal }) {
const budget = Math.min(maxTokens, cfg.maxOutput);
try {
const res = await fetch(cfg.baseUrl, {
method: "POST",
signal,
headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiKey}` },
body: JSON.stringify({ prompt, max_tokens: budget }),
});
if (res.status === 429) return { ok: false, reason: "capped" };
if (!res.ok) return { ok: false, reason: "bad_response" };
const json = await res.json();
if (typeof json.text !== "string") return { ok: false, reason: "bad_response" };
return { ok: true, text: json.text, usage: json.usage };
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
return { ok: false, reason: "timeout" };
}
return { ok: false, reason: "bad_response" };
}
},
};
}
Everything ugly lives here. Your routes never see a 429. Your routes see capped.
Test the contract, not the vendor
The suite below runs against any adapter. A fake first, the real one second. Same assertions, two targets.
// contract.spec.ts
import { describe, it, expect } from "vitest";
import type { InferencePort } from "./ports";
export function inferenceContract(label: string, make: () => InferencePort) {
describe(`inference contract: ${label}`, () => {
it("never throws, always returns a reason or a result", async () => {
const out = await make().complete({ prompt: "ping", maxTokens: 16 });
expect(typeof out.ok).toBe("boolean");
});
it("stops when the caller aborts", async () => {
const ac = new AbortController();
ac.abort();
const out = await make().complete({ prompt: "ping", maxTokens: 16, signal: ac.signal });
expect(out.ok).toBe(false);
});
it("reports usage it can defend", async () => {
const out = await make().complete({ prompt: "ping", maxTokens: 16 });
if (out.ok) expect(out.usage.outputTokens).toBeLessThanOrEqual(16);
});
});
}
Add a fake that misbehaves on demand. Slow, capped, garbage, and abort are the four shapes you will actually meet.
// fakes/slowFake.ts
import type { InferencePort } from "../ports";
export const slowFake = (delayMs: number): InferencePort => ({
complete: ({ signal }) =>
new Promise((resolve) => {
const t = setTimeout(() => resolve({ ok: true, text: "ok", usage: { inputTokens: 1, outputTokens: 1 } }), delayMs);
signal?.addEventListener("abort", () => {
clearTimeout(t);
resolve({ ok: false, reason: "timeout" });
});
}),
});
Run it twice. Fake first, then the free lane.
npx vitest run contract.spec.ts
MONKEYCODE_BASE_URL=... MONKEYCODE_KEY=... npx vitest run contract.spec.ts
The first run proves your app absorbs failure. The second proves the adapter maps real responses onto the same shapes. If run two throws, the adapter is wrong. It is almost never the vendor.
Where each lane actually breaks
Token exhaustion looks like a business problem. It arrives slowly, as a rising capped rate. Your queue depth grows while nothing errors. Alert on the ratio, not the count.
The free server breaks differently. It goes away, and it takes your worker with it. That is why the worker writes its state to storage you control before it starts. A job you can restart is a job you can survive.
Keep the ports apart for this reason. A quota outage in the model lane should not stop scheduled jobs in the runtime lane. Shared failure domains are the expensive mistake here, and they are invisible until both fail on the same afternoon.
What I would change next
Read the contract output as a to-do list. The table below is the one I use.
| Signal | Next change |
|---|---|
| Fake passes, free adapter throws | Normalize errors at the edge; the port is leaking |
capped arrives before your own budget |
Cut maxTokens, queue the rest, never blind-retry |
| Timeouts at your job timeout | Split the job into checkpointed steps |
| Garbage body on HTTP 200 | Validate shape, return bad_response, log the raw sample |
| Both lanes down together | You shared a failure domain; separate them |
One row deserves emphasis. Blind retries on a free tier turn a throttle into an outage. Back off, then degrade.
Who should not use this
Skip this approach if you need a contractual SLA. Free capacity is best effort, and no interface changes that. Skip it for regulated data you cannot send to a third party. Skip it if your latency budget is under a second per call.
Also skip it if you have no test runner. The whole method is a suite. Without one you are back to archaeology.
If your situation allows it, this is a cheap way to try a free model and a free server without betting the app on either. Run the contract suite against the free lane first. Then let it earn its place.
The point was never the free tier. The point is that you can swap it out without a rewrite.
Top comments (0)