Files
PubScript/packages/ai/src/thinking.ts
T
Schwitzz 4c4b83bb68 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.
2026-10-28 18:59:54 -05:00

91 lines
2.8 KiB
TypeScript

/**
* Thinking/reasoning parameter mapping.
*
* Different providers express the reasoning budget differently:
*
* - OpenAI-style (`openai-compatible`, `openrouter`): `reasoning_effort`
* (`"low" | "medium" | "high"`), optionally `max_tokens` for the reasoning
* budget.
* - Anthropic: a `thinking` object `{ enabled: boolean, budget_tokens: number }`
* plus `thinking_budget` (must be <= 32768 and > 0).
* - Google (Gemini): `reasoning_effort` + `thinking_budget`.
*
* This module normalizes the provider-agnostic `ThinkingConfig` into the
* provider-specific wire format. See CHUNK#8.md §3.
*/
import type { ProviderKind, ThinkingConfig } from "./providers/types.js";
/** The maximum reasoning budget Anthropic accepts. */
const ANTHROPIC_MAX_THINKING_TOKENS = 32768;
/** Normalize a thinking config into the wire-format body fragment for a kind. */
export function normalizeThinking(
kind: ProviderKind,
thinking?: ThinkingConfig,
): Record<string, unknown> {
if (!thinking?.enabled) {
return {};
}
switch (kind) {
case "anthropic":
return normalizeAnthropicThinking(thinking);
case "google":
return normalizeGoogleThinking(thinking);
case "openai-compatible":
case "openrouter":
default:
return normalizeOpenAIThinking(thinking);
}
}
/** OpenAI-style: `reasoning_effort` + optional `max_tokens`. */
function normalizeOpenAIThinking(thinking: ThinkingConfig): Record<string, unknown> {
const effort = thinking.effort ?? "medium";
const body: Record<string, unknown> = {
reasoning_effort: effort,
};
if (typeof thinking.budget === "number") {
body.max_tokens = thinking.budget;
}
return body;
}
/** Anthropic: `thinking` object + `thinking_budget`. */
function normalizeAnthropicThinking(thinking: ThinkingConfig): Record<string, unknown> {
const budget =
typeof thinking.budget === "number"
? Math.min(thinking.budget, ANTHROPIC_MAX_THINKING_TOKENS)
: 2048;
return {
thinking: {
enabled: true,
budget_tokens: budget,
},
thinking_budget: budget,
};
}
/** Google: `reasoning_effort` + `thinking_budget`. */
function normalizeGoogleThinking(thinking: ThinkingConfig): Record<string, unknown> {
const effort = thinking.effort ?? "medium";
const budget = typeof thinking.budget === "number" ? thinking.budget : 2048;
return {
reasoning_effort: effort,
thinking_budget: budget,
};
}
/**
* Compute the reasoning budget (in tokens) to advertise to the budget engine,
* so the output budget accounts for the reasoning spend. Returns 0 when
* thinking is disabled.
*/
export function reasoningBudgetTokens(thinking?: ThinkingConfig): number {
if (!thinking?.enabled) {
return 0;
}
return typeof thinking.budget === "number" ? thinking.budget : 2048;
}