feat(ai): add packages/ai provider layer (Chunk #8)
Self-contained SSE-based provider layer for OpenAI-compatible endpoints (Ollama/llama.cpp/LM Studio/vLLM), Anthropic, Gemini, and OpenRouter. - budget.ts: re-export core token-budget engine (countTokens, allocateBudget, DEFAULT_BUDGET) - client.ts: streamCompletion/collectStream/complete with budget enforcement via enforceBudget + measureBudget - discover.ts: auto-detect local providers by probing common /v1/models - adapters.ts: per-provider param mapping + streaming adapters + factory - thinking.ts: thinking/reasoning-param normalization per provider - http.ts: SSE parsing + 429 retry - errors.ts: BudgetExceededError 35 unit/integration tests pass; typecheck and lint clean.
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
BudgetExceededError,
|
||||
collectStream,
|
||||
complete,
|
||||
streamCompletion,
|
||||
UnknownProviderError,
|
||||
} from "./index.js";
|
||||
import type { ResolvedProvider } from "./index.js";
|
||||
|
||||
/** Build a `text/event-stream` Response from a list of SSE event payloads. */
|
||||
function sseResponse(events: string[], status = 200): Response {
|
||||
const encoder = new TextEncoder();
|
||||
const body = new ReadableStream<Uint8Array<ArrayBuffer>>({
|
||||
start(controller) {
|
||||
for (const event of events) {
|
||||
controller.enqueue(encoder.encode(`data: ${event}\n\n`));
|
||||
}
|
||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(body, {
|
||||
status,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Build a JSON Response (for non-streaming completions). */
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const config: ResolvedProvider = {
|
||||
id: "test-openai",
|
||||
kind: "openai-compatible",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
apiKey: "sk-test",
|
||||
model: "gpt-4o",
|
||||
contextWindow: 128_000,
|
||||
};
|
||||
|
||||
let savedFetch: typeof fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
savedFetch = globalThis.fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = savedFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("streamCompletion", () => {
|
||||
it("yields text deltas in order", async () => {
|
||||
globalThis.fetch = (() =>
|
||||
sseResponse([
|
||||
JSON.stringify({ choices: [{ delta: { content: "Hello" } }] }),
|
||||
JSON.stringify({ choices: [{ delta: { content: " world" } }] }),
|
||||
])) as unknown as typeof fetch;
|
||||
|
||||
const chunks: Array<{ type: string; text?: string }> = [];
|
||||
for await (const chunk of streamCompletion(
|
||||
config,
|
||||
[{ role: "user", content: "hi" }],
|
||||
)) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
const textChunks = chunks.filter((c) => c.type === "text");
|
||||
expect(textChunks.map((c) => c.text)).toEqual(["Hello", " world"]);
|
||||
});
|
||||
|
||||
it("parses reasoning deltas separately from text", async () => {
|
||||
globalThis.fetch = (() =>
|
||||
sseResponse([
|
||||
JSON.stringify({ choices: [{ delta: { reasoning_content: "let me think" } }] }),
|
||||
JSON.stringify({ choices: [{ delta: { content: "the answer" } }] }),
|
||||
])) as unknown as typeof fetch;
|
||||
|
||||
const reasoning: string[] = [];
|
||||
const text: string[] = [];
|
||||
for await (const chunk of streamCompletion(
|
||||
config,
|
||||
[{ role: "user", content: "hi" }],
|
||||
)) {
|
||||
if (chunk.type === "reasoning" && chunk.reasoning) {
|
||||
reasoning.push(chunk.reasoning);
|
||||
} else if (chunk.type === "text" && chunk.text) {
|
||||
text.push(chunk.text);
|
||||
}
|
||||
}
|
||||
|
||||
expect(reasoning).toEqual(["let me think"]);
|
||||
expect(text).toEqual(["the answer"]);
|
||||
});
|
||||
|
||||
it("tracks usage on the final done chunk", async () => {
|
||||
globalThis.fetch = (() =>
|
||||
sseResponse([
|
||||
JSON.stringify({ choices: [{ delta: { content: "ok" } }] }),
|
||||
JSON.stringify({
|
||||
choices: [{ delta: {} }],
|
||||
usage: { prompt_tokens: 12, completion_tokens: 3, total_tokens: 15 },
|
||||
}),
|
||||
])) as unknown as typeof fetch;
|
||||
|
||||
const result = await collectStream(
|
||||
config,
|
||||
[{ role: "user", content: "hi" }],
|
||||
);
|
||||
expect(result.text).toBe("ok");
|
||||
expect(result.usage).toEqual({ promptTokens: 12, completionTokens: 3, total: 15 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectStream", () => {
|
||||
it("assembles full text, reasoning, and usage", async () => {
|
||||
globalThis.fetch = (() =>
|
||||
sseResponse([
|
||||
JSON.stringify({ choices: [{ delta: { reasoning_content: "hmm" } }] }),
|
||||
JSON.stringify({ choices: [{ delta: { content: "Final" } }] }),
|
||||
JSON.stringify({ choices: [{ delta: { content: " answer" } }] }),
|
||||
JSON.stringify({
|
||||
choices: [{ delta: {} }],
|
||||
usage: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 },
|
||||
}),
|
||||
])) as unknown as typeof fetch;
|
||||
|
||||
const result = await collectStream(
|
||||
config,
|
||||
[{ role: "user", content: "q" }],
|
||||
);
|
||||
expect(result.text).toBe("Final answer");
|
||||
expect(result.reasoning).toBe("hmm");
|
||||
expect(result.usage).toEqual({ promptTokens: 5, completionTokens: 2, total: 7 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("complete (non-streaming)", () => {
|
||||
it("returns the full text and usage", async () => {
|
||||
globalThis.fetch = (() =>
|
||||
jsonResponse({
|
||||
choices: [{ message: { content: "full text" } }],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 },
|
||||
})) as unknown as typeof fetch;
|
||||
|
||||
const result = await complete(
|
||||
config,
|
||||
[{ role: "user", content: "hi" }],
|
||||
);
|
||||
expect(result.text).toBe("full text");
|
||||
expect(result.usage).toEqual({ promptTokens: 1, completionTokens: 2, total: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("retry on 429", () => {
|
||||
it("retries a rate-limited request and succeeds", async () => {
|
||||
let calls = 0;
|
||||
globalThis.fetch = ((() => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
return jsonResponse({ error: { message: "rate limited" } }, 429);
|
||||
}
|
||||
return sseResponse([JSON.stringify({ choices: [{ delta: { content: "recovered" } }] })]);
|
||||
})) as unknown as typeof fetch;
|
||||
|
||||
const result = await collectStream(
|
||||
config,
|
||||
[{ role: "user", content: "hi" }],
|
||||
);
|
||||
expect(result.text).toBe("recovered");
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("budget enforcement", () => {
|
||||
it("throws BudgetExceededError before any network call", async () => {
|
||||
const tinyConfig: ResolvedProvider = { ...config, contextWindow: 8 };
|
||||
const fetchSpy = vi.fn();
|
||||
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
||||
|
||||
await expect(
|
||||
collectStream(tinyConfig, [{ role: "system", content: "x".repeat(200) }]),
|
||||
).rejects.toBeInstanceOf(BudgetExceededError);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider resolution", () => {
|
||||
it("throws UnknownProviderError for an unrecognized kind", async () => {
|
||||
const badConfig = { ...config, kind: "nope" } as unknown as ResolvedProvider;
|
||||
await expect(
|
||||
collectStream(badConfig, [{ role: "user", content: "hi" }]),
|
||||
).rejects.toBeInstanceOf(UnknownProviderError);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user