SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
12.3 KB · 276 lines typescript
Raw Blame History
1import Anthropic from '@anthropic-ai/sdk';2import { z } from 'zod';3import { estimateUsd, addUsage, ZERO_USAGE } from '../pricing.js';4import { recordCost } from '../costs.js';5import { AiNotConfiguredError, AiRefusalError, type ChatMessage, type Completion, type CompletionRequest, type ContentPart, type EmbedRequest, type EmbedResult, type ExtractRequest, type ExtractResult, type ModelProvider, type Role, type StreamDelta, type ToolAgentRequest, type Usage } from '../types.js';67export interface AnthropicProviderOptions {8  apiKey: string;9  /** model per role; defaults below */10  models?: Partial<Record<Role, string>>;11  /** stable system-prompt prefix cached across calls */12  defaultTimeoutMs?: number;13}1415/**16 * Default role → model mapping. Fast/cheap for bulk pipeline work, strong for research and vision.17 * All ids are the exact strings from the current model table (no date suffixes).18 */19export const ANTHROPIC_DEFAULT_MODELS: Record<Role, string> = {20  normalize: 'claude-haiku-4-5',21  classify: 'claude-haiku-4-5',22  summarize: 'claude-haiku-4-5',23  resolve: 'claude-sonnet-5',24  research: 'claude-opus-5',25  vision: 'claude-opus-5',26  embed: '',27};2829function usageOf(u: Anthropic.Usage | Anthropic.MessageDeltaUsage | null | undefined): Usage {30  return {31    inputTokens: u?.input_tokens ?? 0,32    outputTokens: u?.output_tokens ?? 0,33    cacheReadTokens: u?.cache_read_input_tokens ?? 0,34    cacheWriteTokens: u?.cache_creation_input_tokens ?? 0,35  };36}3738function toBlocks(content: string | ContentPart[]): string | Anthropic.ContentBlockParam[] {39  if (typeof content === 'string') return content;40  return content.map((p): Anthropic.ContentBlockParam => {41    if (p.type === 'text') return { type: 'text', text: p.text };42    const img = p.image;43    if (img.url) return { type: 'image', source: { type: 'url', url: img.url } };44    return { type: 'image', source: { type: 'base64', media_type: img.mediaType, data: img.data ?? '' } };45  });46}4748function toMessages(messages: ChatMessage[]): Anthropic.MessageParam[] {49  return messages.map((m) => ({ role: m.role, content: toBlocks(m.content) }));50}5152/** Haiku 4.5 still uses budgeted thinking; current-generation models use adaptive thinking. */53function thinkingFor(model: string, effort: string | undefined): Anthropic.ThinkingConfigParam | undefined {54  if (model.startsWith('claude-haiku')) return undefined;55  if (effort === 'low') return { type: 'adaptive' };56  return { type: 'adaptive' };57}5859function outputConfig(model: string, effort: string | undefined, format?: Anthropic.JSONOutputFormat): Anthropic.OutputConfig | undefined {60  const cfg: Anthropic.OutputConfig = {};61  if (effort && !model.startsWith('claude-haiku')) cfg.effort = effort as Anthropic.OutputConfig['effort'];62  if (format) cfg.format = format;63  return Object.keys(cfg).length ? cfg : undefined;64}6566export class AnthropicProvider implements ModelProvider {67  readonly id = 'anthropic';68  private readonly client: Anthropic;69  private readonly models: Record<Role, string>;7071  constructor(opts: AnthropicProviderOptions) {72    if (!opts.apiKey) throw new AiNotConfiguredError('ANTHROPIC_API_KEY missing');73    this.client = new Anthropic({ apiKey: opts.apiKey, timeout: opts.defaultTimeoutMs ?? 10 * 60_000, maxRetries: 2 });74    this.models = { ...ANTHROPIC_DEFAULT_MODELS, ...(opts.models ?? {}) };75  }7677  supports(role: Role): boolean {78    return role !== 'embed';79  }8081  modelFor(role: Role): string {82    return this.models[role] || ANTHROPIC_DEFAULT_MODELS[role];83  }8485  private async account(role: Role, model: string, usage: Usage, cost?: CompletionRequest['cost']): Promise<number> {86    const usdEst = estimateUsd(model, usage);87    await recordCost({ provider: this.id, model, role, usage, usdEst, context: cost });88    return usdEst;89  }9091  async complete(role: Role, req: CompletionRequest): Promise<Completion> {92    const model = this.modelFor(role);93    const system = req.json ? `${req.system ?? ''}\nRespond with a single JSON object and nothing else.`.trim() : req.system;94    const res = await this.client.messages.create({95      model,96      max_tokens: req.maxTokens ?? 4096,97      ...(system ? { system: [{ type: 'text', text: system, cache_control: { type: 'ephemeral' } }] } : {}),98      messages: toMessages(req.messages),99      ...(thinkingFor(model, req.effort) ? { thinking: thinkingFor(model, req.effort) } : {}),100      ...(outputConfig(model, req.effort) ? { output_config: outputConfig(model, req.effort) } : {}),101      ...(req.stopSequences ? { stop_sequences: req.stopSequences } : {}),102    });103    const usage = usageOf(res.usage);104    const usdEst = await this.account(role, model, usage, req.cost);105    if (res.stop_reason === 'refusal') {106      const d = res.stop_details;107      return { text: '', model, provider: this.id, usage, usdEst, stopReason: 'refusal', refusal: { category: d?.category ?? null, explanation: d?.explanation ?? null } };108    }109    const text = res.content110      .filter((b): b is Anthropic.TextBlock => b.type === 'text')111      .map((b) => b.text)112      .join('');113    return { text, model, provider: this.id, usage, usdEst, stopReason: res.stop_reason, refusal: null };114  }115116  async *stream(role: Role, req: CompletionRequest): AsyncIterable<StreamDelta> {117    yield* this.runTools(role, { ...req, tools: [], execute: async () => null });118  }119120  async *runTools(role: Role, req: ToolAgentRequest): AsyncIterable<StreamDelta> {121    const model = this.modelFor(role);122    const messages = toMessages(req.messages);123    const tools: Anthropic.Tool[] = req.tools.map((t) => ({124      name: t.name,125      description: t.description,126      input_schema: t.inputSchema as Anthropic.Tool.InputSchema,127    }));128    const maxIter = req.maxIterations ?? 8;129    let total: Usage = ZERO_USAGE;130    let iterations = 0;131    let stopReason: string | null = null;132    try {133      while (iterations < maxIter) {134        iterations++;135        const stream = this.client.messages.stream(136          {137            model,138            max_tokens: req.maxTokens ?? 8192,139            ...(req.system ? { system: [{ type: 'text', text: req.system, cache_control: { type: 'ephemeral' } }] } : {}),140            messages,141            ...(tools.length ? { tools } : {}),142            ...(thinkingFor(model, req.effort) ? { thinking: { ...thinkingFor(model, req.effort)!, display: 'summarized' } as Anthropic.ThinkingConfigParam } : {}),143            ...(outputConfig(model, req.effort) ? { output_config: outputConfig(model, req.effort) } : {}),144          },145          { signal: req.signal },146        );147        const queue: StreamDelta[] = [];148        let resolveWake: (() => void) | null = null;149        const wake = () => {150          resolveWake?.();151          resolveWake = null;152        };153        stream.on('text', (delta) => {154          queue.push({ type: 'text', text: delta });155          wake();156        });157        stream.on('thinking', (delta) => {158          queue.push({ type: 'thinking', text: delta });159          wake();160        });161        let finished = false;162        let failure: unknown = null;163        const finalP = stream164          .finalMessage()165          .catch((err) => {166            failure = err;167            return null;168          })169          .finally(() => {170            finished = true;171            wake();172          });173        while (!finished || queue.length) {174          if (queue.length) {175            yield queue.shift()!;176            continue;177          }178          await new Promise<void>((r) => {179            resolveWake = r;180          });181        }182        const message = await finalP;183        if (failure || !message) throw failure ?? new Error('stream ended without a message');184        const u = usageOf(message.usage);185        total = addUsage(total, u);186        yield { type: 'usage', usage: u, usdEst: estimateUsd(model, u), model };187        stopReason = message.stop_reason;188        if (message.stop_reason === 'refusal') {189          const d = message.stop_details;190          throw new AiRefusalError(d?.category ?? null, d?.explanation ?? null);191        }192        if (message.stop_reason === 'pause_turn') {193          messages.push({ role: 'assistant', content: message.content });194          continue;195        }196        const toolUses = message.content.filter((b): b is Anthropic.ToolUseBlock => b.type === 'tool_use');197        if (message.stop_reason !== 'tool_use' || toolUses.length === 0) break;198        messages.push({ role: 'assistant', content: message.content });199        const results: Anthropic.ToolResultBlockParam[] = [];200        for (const tu of toolUses) {201          yield { type: 'tool_call', id: tu.id, name: tu.name, input: tu.input };202          const started = Date.now();203          let output: unknown;204          let isError = false;205          try {206            output = await req.execute(tu.name, tu.input);207          } catch (err) {208            isError = true;209            output = { error: err instanceof Error ? err.message : String(err) };210          }211          yield { type: 'tool_result', id: tu.id, name: tu.name, output, isError, durationMs: Date.now() - started };212          results.push({ type: 'tool_result', tool_use_id: tu.id, content: typeof output === 'string' ? output : JSON.stringify(output ?? null), is_error: isError });213        }214        messages.push({ role: 'user', content: results });215      }216    } finally {217      await this.account(role, model, total, req.cost);218    }219    yield { type: 'done', stopReason };220  }221222  async extract<T>(role: Role, req: ExtractRequest<T>): Promise<ExtractResult<T>> {223    const model = this.modelFor(role);224    const schema = z.toJSONSchema(req.schema as z.ZodType, { target: 'draft-2020-12', io: 'output' }) as Record<string, unknown>;225    const format: Anthropic.JSONOutputFormat = { type: 'json_schema', schema: stripUnsupported(schema) };226    const content = typeof req.input === 'string' ? `${req.prompt}\n\n${req.input}` : [{ type: 'text', text: req.prompt } as ContentPart, ...req.input];227    const res = await this.client.messages.create({228      model,229      max_tokens: req.maxTokens ?? 4096,230      ...(req.system ? { system: [{ type: 'text', text: req.system, cache_control: { type: 'ephemeral' } }] } : {}),231      messages: toMessages([{ role: 'user', content }]),232      ...(thinkingFor(model, req.effort) ? { thinking: thinkingFor(model, req.effort) } : {}),233      output_config: outputConfig(model, req.effort, format)!,234    });235    const usage = usageOf(res.usage);236    const usdEst = await this.account(role, model, usage, req.cost);237    if (res.stop_reason === 'refusal') throw new AiRefusalError(res.stop_details?.category ?? null, res.stop_details?.explanation ?? null);238    const text = res.content239      .filter((b): b is Anthropic.TextBlock => b.type === 'text')240      .map((b) => b.text)241      .join('');242    const parsed = req.schema.safeParse(JSON.parse(text));243    if (!parsed.success) throw new Error(`extract: model output did not match schema: ${parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ')}`);244    const data = parsed.data as T & { confidence?: number };245    const confidence = typeof data.confidence === 'number' ? Math.max(0, Math.min(1, data.confidence)) : 1;246    return { data: parsed.data, confidence, model, provider: this.id, usage, usdEst };247  }248249  vision<T>(req: ExtractRequest<T>): Promise<ExtractResult<T>> {250    return this.extract('vision', req);251  }252253  async embed(_req: EmbedRequest): Promise<EmbedResult> {254    throw new AiNotConfiguredError('Anthropic has no embeddings endpoint; configure OPENAI_API_KEY or a local embedding model');255  }256}257258/** Structured-output schemas reject a few JSON-schema keywords; strip them recursively. */259function stripUnsupported(schema: Record<string, unknown>): Record<string, unknown> {260  const drop = new Set(['$schema', 'default', 'minLength', 'maxLength', 'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'pattern', 'format', 'minItems', 'maxItems', 'multipleOf']);261  const walk = (node: unknown): unknown => {262    if (Array.isArray(node)) return node.map(walk);263    if (node && typeof node === 'object') {264      const out: Record<string, unknown> = {};265      for (const [k, v] of Object.entries(node as Record<string, unknown>)) {266        if (drop.has(k)) continue;267        out[k] = walk(v);268      }269      if (out.type === 'object' && out.properties && out.additionalProperties === undefined) out.additionalProperties = false;270      return out;271    }272    return node;273  };274  return walk(schema) as Record<string, unknown>;275}276