Schemaport
SchemaportDocs
GitHubGet started

TypeScript library

Provider adapter contract

Implement a new SchemaPort target by satisfying one small interface, and let core apply the compilation policy for you.

A provider package answers three questions about one target:

  1. What is wrong with this canonical tool? — check
  2. What does a working version look like, and what did that cost? — compile
  3. Does the real API accept it right now? — probe

Adapters know nothing about the CLI: no printing, no process.exit, no file system. The CLI knows nothing about provider compatibility rules. That split is what lets provider rules be versioned and released independently as the providers change.

The interface

TypeScript
interface SchemaPortProvider {
  readonly id: string               // 'openai'
  readonly displayName: string      // 'OpenAI'
  readonly rulesReviewedAt: string  // 'YYYY-MM-DD'
  readonly docs: readonly ProviderDocReference[]
  readonly apiKeyEnvVar?: string    // 'OPENAI_API_KEY'

  check(tool: CanonicalTool): Diagnostic[]
  compile(tool: CanonicalTool, options?: CompileOptions): CompileResult
  probe?(tool: CanonicalTool, options?: ProbeOptions): Promise<ProbeResult>
}

rulesReviewedAt and docs are not decoration. Provider behaviour changes; these fields let a reader see when the rules were last checked against official documentation and read those sources themselves.

check

Walk the canonical tool and return one diagnostic per problem. Use collectSchemas to visit every subschema with its path, and joinPath to build paths — never hand-build path strings.

TypeScript
import { collectSchemas, diagnostic, compilable, joinPath } from '@schemaport/core'

export function check(tool: CanonicalTool): Diagnostic[] {
  const diagnostics: Diagnostic[] = []

  for (const { schema, path } of collectSchemas(tool.inputSchema, 'inputSchema')) {
    if (schema.type === 'object' && schema.additionalProperties === undefined) {
      diagnostics.push(diagnostic({
        providerId: 'example',
        toolName: tool.name,
        severity: 'error',
        code: 'example/missing-additional-properties',
        message: 'Strict mode requires `additionalProperties: false` on every object.',
        path: joinPath(path, 'additionalProperties'),
        compile: compilable('Adds `additionalProperties: false`.'),
        docsUrl: 'https://example.com/docs/function-calling',
      }))
    }
  }

  return diagnostics
}

check must be pure: the same tool in, the same diagnostics out, in the same order.

Only implement rules you have evidence for. If a target's behaviour is uncertain, emit a warning that says it is uncertain. Never invent a guarantee, and never report a schema as compatible when a constraint would be accepted and then ignored.

compile

Produce the provider-native tool definition, record every change, and return through finalizeCompile. Never implement the lossy refusal yourself — mark transformations and let core apply the policy, so all four providers draw the line in exactly one place.

TypeScript
import { cloneSchema, finalizeCompile, transformation } from '@schemaport/core'

export function compile(tool: CanonicalTool, options?: CompileOptions): CompileResult {
  const transformations = []
  const parameters = cloneSchema(tool.inputSchema)

  // ... mutate `parameters`, pushing a transformation for each change ...
  transformations.push(transformation(
    'added-additional-properties-false',
    'inputSchema',
    'Closed the object, as strict mode requires.',
    false, // lossy
  ))

  return finalizeCompile({
    providerId: 'example',
    tool,
    output: { type: 'function', name: tool.name, parameters, strict: true },
    transformations,
    diagnostics: check(tool),
    options,
  })
}

finalizeCompile refuses when a transformation is lossy and the caller did not pass allowLossy, and when a diagnostic is an error whose compile.supported is false. It drops errors that compile worked around — the transformation record represents them — and always keeps warnings.

Classifying lossy

MeaningExample
lossy: falseThe compiled schema expresses the same contract in the shape the provider requires.Renaming inputSchema, normalizing type case, optional → required + nullable
lossy: trueThe compiled schema accepts inputs the canonical schema rejects, because a keyword was dropped or weakened.Dropping minimum, erasing a typed additionalProperties map, collapsing oneOf

The question is not how much the JSON changed. It is whether the set of argument values the provider will accept grew beyond what the canonical schema allows.

One issue can need two diagnostics

Because finalizeCompile drops errors that compile resolved, a single rule cannot be both "the provider rejects this as written" and "here is what changes at runtime". When both are true, emit two diagnostics at the same path — an error that disappears once compiled, and a warning that survives into the result and the manifest.

Do not demote the error to a warning just so it survives. That understates a true fact: a reader running schemaport check --fail-on error in CI would get a clean exit for a schema the provider will reject.

probe

Optional, and only meaningful for targets with a hosted API. The rules exist to keep a probe from lying:

Compile first

If compilation is refused, return probeCompileRefused — never send a schema SchemaPort would not generate.

Resolve credentials

Use resolveApiKey. A missing key returns probeMissingCredentials, which is an environment error, not a rejection.

Resolve the model

Use resolveProbeModel so the model can be overridden by option and environment variable. The default should come from current official docs.

Send the smallest request

The compiled tool, one short instruction from probePrompt(tool), and a small output cap. Never execute the developer's function, and never send real data.

Classify failures

Use classifyProviderError. Only 'rejected' means the schema was refused. An expired key, a stale model id, a rate limit and a network failure are all environment problems and must never be reported as a bad schema.

probeAccepted validates any returned tool-call arguments against the canonical schema, not the compiled one — so a provider that accepted a constraint and then ignored it shows up as accepted-but-wrong-shape rather than a clean pass.

options.client is a test seam. When it is supplied, use it and do not construct a client or read process.env.

Determinism

Compiling the same canonical tool twice must produce byte-identical output and the same transformations in the same order. No timestamps, no Date.now(), no Math.random(), no iteration over unordered sets, and no localeCompare — core exports compareStrings for ordering, which compares by code point so results do not vary by locale.

Packaging rules

  • Depend on @schemaport/core with a semver range. Never file: or link:.
  • Never depend on another provider package.
  • Never copy a core type into your package.
  • Export your provider as a named export plus a default export.

Testing

Provider packages own their evidence. Each should ship valid, invalid and warning fixtures; one test per implemented rule asserting the diagnostic code; a determinism test that compiles the same fixture twice; the lossy refusal path and the allowLossy path; and mocked probe tests covering accepted, rejected, missing credentials, and at least one non-schema failure.

Shared canonical fixtures are importable from core, so every provider is tested against the same inputs:

TypeScript
import { refundOrderTool, nestedTool, openMapTool } from '@schemaport/core'

No test may make a network request.

Next steps

Was this page helpful?

Edit this page on GitHub