Skip to content
曾亮懿 ZengLiangYi Blog

AI SDK Provider Abstraction: Capabilities and Protocols

Test 7 ChatCrystal providers and AI SDK factories with offline mocks for Responses, Chat Completions, embeddings, and structured output rejection.

曾亮懿

曾亮懿

4 min read
Table of contents Show Hide

A shared model interface makes business logic easier to reuse. Compatibility still depends on protocols, authentication, model capabilities, and output validation. The pinned ChatCrystal implementation uses AI SDK 7.0.97. This article checks its factories and calls, then replaces fetch entirely to observe protocol selection, without assuming “OpenAI-compatible” covers every endpoint.

The current registry has seven providers

providers.ts registers factories in a Map, with supportsModelDiscovery included in the interface. This table describes that implementation, not every future capability offered by the vendors.

Name Language-model path Embedding factory Model discovery / Base URL requirement
ollama createOpenAI(…).chat(model) Yes Supported / required, with a local factory default
openai createOpenAI(…)(model) Yes Supported / optional
orcarouter createOpenAI(…)(model) at a fixed gateway No Supported / not required; supplied baseURL ignored
anthropic createAnthropic(…)(model) No Supported / not required
google createGoogle(…)(model) Yes Supported / not required
azure createAzure(…)(model) Yes Unsupported / required
custom createOpenAI(…)(model) Yes Supported / required

Registry metadata requires an API Key for every entry except ollama. The Ollama factory supplies a placeholder key, removes trailing slashes, and appends /v1. That does not establish correctness for every proxy, authentication setup, or URL already ending in /v1. Anthropic and OrcaRouter have no Embedding factory in this project; this does not predict every capability a vendor might offer.

One SDK factory can select different protocols

GetLanguageModel reads appConfig.llm. The private getEmbeddingModel in embedding.ts reads separate configuration and throws when no factory exists. Language and Embedding providers can be configured independently, while model capabilities and index compatibility still require checks.

In pinned @ai-sdk/openai 4.0.65, direct provider(model) uses Responses, while explicit .chat(model) uses Chat Completions. Current Ollama chooses the latter; OpenAI, OrcaRouter, and Custom choose the former. A compatible service implementing only /chat/completions is not thereby proven to accept Custom’s /responses requests.

Capability flags and factory types do not establish reachable endpoints, successful real authentication, structured-output support, or capabilities of a particular model. GetProvider throws for an unknown name; creating a model object does not establish a successful real request.

The current structured-summary call differs from the original example

Summarize.ts uses generateText, Output.object, and SummarizeSchema, passing instructions, prompt, maxOutputTokens=4096, and explicit maxRetries=3, then lowercasing tags. It does not use the original example’s generateObject call.

Schema describe supplies natural-language guidance without implementing max, min, or collection-size constraints. “Title within 20 characters” has no corresponding z.string().max(20). Valid structured output also does not establish factual correctness. The structured JSON prompt guide separates schema and semantic acceptance further.

The generateText reference documents a default maxRetries of 2. The project’s explicit 3 is not an SDK default of three retries, nor a guarantee that every schema error triggers regeneration. SDK request retries can also combine with outer task-queue retries; check retryable errors and counts separately.

Verify three protocols and one schema failure without networking

In an isolated directory, install npm install --ignore-scripts --save-exact ai@7.0.97 @ai-sdk/openai@4.0.65 zod@4.6.2, save lesson.mjs, and run with Node 24+. Fetch is completely replaced by a local Response constructor. Keys and model names are fixtures, without contacting any real model or network endpoint.

import assert from 'node:assert/strict';
import { createOpenAI } from '@ai-sdk/openai';
import { embed, generateText, Output } from 'ai';
import { z } from 'zod';

const paths = [];
let content = JSON.stringify({ title: 'fixture' });
const provider = createOpenAI({
  baseURL: 'https://fixture.invalid/v1',
  apiKey: 'fixture-only',
  fetch: async (input) => {
    const path = new URL(String(input)).pathname;
    paths.push(path);
    let body;
    if (path.endsWith('/embeddings')) {
      body = {
        object: 'list',
        data: [{ object: 'embedding', index: 0, embedding: [1, 0] }],
        model: 'fixture',
        usage: { prompt_tokens: 1, total_tokens: 1 },
      };
    } else if (path.endsWith('/chat/completions')) {
      body = {
        id: 'chat_fixture',
        created: 1,
        model: 'fixture',
        choices: [{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }],
        usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
      };
    } else {
      body = {
        id: 'resp_fixture',
        created_at: 1,
        model: 'fixture',
        output: [
          {
            type: 'message',
            id: 'msg_fixture',
            role: 'assistant',
            content: [{ type: 'output_text', text: content, annotations: [] }],
          },
        ],
        usage: { input_tokens: 1, output_tokens: 1 },
      };
    }
    return new Response(JSON.stringify(body), {
      status: 200,
      headers: { 'Content-Type': 'application/json' },
    });
  },
});
const output = Output.object({ schema: z.object({ title: z.string() }) });
const responses = await generateText({
  model: provider('fixture'),
  output,
  prompt: 'fixture',
  maxRetries: 0,
});
const chat = await generateText({
  model: provider.chat('fixture'),
  output,
  prompt: 'fixture',
  maxRetries: 0,
});
const vector = await embed({
  model: provider.embeddingModel('fixture'),
  value: 'fixture',
  maxRetries: 0,
});
assert.deepEqual(responses.output, { title: 'fixture' });
assert.deepEqual(chat.output, { title: 'fixture' });
assert.deepEqual(vector.embedding, [1, 0]);
assert.deepEqual(paths, ['/v1/responses', '/v1/chat/completions', '/v1/embeddings']);
content = JSON.stringify({ title: 42 });
const before = paths.length;
await assert.rejects(
  async () => {
    const invalid = await generateText({
      model: provider('fixture'),
      output,
      prompt: 'fixture',
      maxRetries: 3,
    });
    return invalid.output;
  },
  (error) => error.name === 'AI_NoObjectGeneratedError'
);
assert.equal(paths.length - before, 1);
console.log('protocol selection and schema rejection pass without network');

Three successful calls produce /responses, /chat/completions, and /embeddings, with the expected structured text and fixed vector. A later HTTP 200 contains a numeric title against a string schema. Even with maxRetries=3, it rejects as AI_NoObjectGeneratedError after one mock request. This verifies that specified failure without making every error nonretryable.

Replace .chat with a direct provider call and the path assertion fails. Change the title schema to accept any value and the rejection assertion fails. Restore each to pass. The mock establishes client protocol and validation behavior, without proving external services support them.

Provider changes require capability-specific acceptance

Inspect the final URL, body, and authentication headers. Check ordinary text, required structured schemas, embeddings, error statuses, invalid output, rate limiting, and cancellation separately. Only real endpoint tests establish service compatibility. This article compares no costs, rankings, quality, or latency.

Current maxInputChars defaults to 32000, a character budget rather than a model token limit. prepareTranscript selects turns against that budget and marks omissions: the path with at most three turns truncates its text; the longer path retains the first and last two turns, without a final hard truncation after joining. It therefore guarantees neither a strict character ceiling nor correct context-window sizing for every model. Switching Embedding models can change vector space even at the same dimension. Check index rebuilding and persistence state instead of treating model-object creation as a completed migration.

Share:

Related Posts

Five AI conversation parsers: retained fields and loss boundaries

Build an acceptance matrix from Claude Code, Codex, Cursor, Trae, and Copilot parsers, checking tool flags, thinking, order, and snapshot coverage.

曾亮懿 曾亮懿
4 min read
#ChatCrystal #TypeScript #Node.js

SourceAdapter internals: identity, content hashes, and reimport

Trace ChatCrystal’s registry, importer, and remote payload to verify identity, canonical hashes, metadata shortcuts, and parser-version boundaries.

曾亮懿 曾亮懿
4 min read
#ChatCrystal #TypeScript #Node.js

Code tooling: the actual roles of Biome, ESLint, and formatting

Read ChatCrystal’s scripts and Flat Config, verify ESLint diagnostics offline, and distinguish syntax rules, type checking, formatting, and CI gates.

曾亮懿 曾亮懿
4 min read
#ChatCrystal #TypeScript #Node.js

From writing to code

Explore ChatCrystal, developer tools and daily learning notes on my GitHub. Questions about the implementation are welcome.