Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,28 @@ and `@modelschemas/client` types its responses from it.
- A change that is not additive is also recorded in
[docs/api-changes.md](docs/api-changes.md).

### Generation OpenAPI (`GET /v1/openapi/{provider}`)

One operation per endpoint. A response that can be JSON or a stream lists
`application/json` (or `text/plain`) and `text/event-stream` under one
`200`, as OpenAI's own spec does. Each schema's `$defs` are hoisted into
`components.schemas`, so every `$ref` resolves within the document;
identical definitions share one name, and a name two endpoints define
differently gets a content-hash suffix. An upstream `#stream` path key
(Mistral transcriptions) is merged into its base operation: the request
body is `anyOf` both variants (`stream: false` / `stream: true`). A
document that would still hold an unresolved `$ref` is refused with
`500 unresolved_refs`, naming the ref and where it sits; twins that list
one media type with different schemas are refused with
`500 schema_conflict`.

**Breaking:** the `{path}#stream` SSE twins are gone. hey-api 0.99.0 types
a JSON-or-SSE response as JSON only and returns the stream as one string
([hey-api/hey-api#4482](https://github.com/hey-api/hey-api/issues/4482)),
so a hey-api client loses typed streaming until that is fixed. Kubb v5
handles it: the response is a union keyed by content type, and
`toEventStream()` yields typed events.

## Examples

Three TanStack Start apps in [`examples/`](./examples) exercise the
Expand Down
6 changes: 6 additions & 0 deletions docs/api-changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ Non-additive changes to the catalog and estimate API. Package release
steps stay in the README under "Releasing on npm". npm package changelogs
are written by changesets.

**Breaking:** `GET /v1/openapi/{provider}` has no `{path}#stream`
operations. JSON and `text/event-stream` share one operation's `200`, and
schemas live in `components.schemas` (every `$ref` resolves). hey-api
clients lose typed streaming until hey-api/hey-api#4482 is fixed (README,
"Generation OpenAPI").

**Breaking (0.1.x):** `capabilities` is a map of booleans, not a list of
flag names: `["tools", "reasoning"]` is now `{ "tools": true, "reasoning":
true }`, and a flag the provider states the model does not support is
Expand Down
2 changes: 1 addition & 1 deletion src/server/ingest/asyncapi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ describe('extractAsyncApiSchemas', () => {
})

it('does not bump the OpenAPI extractor version', () => {
expect(EXTRACTOR_VERSION).toBe('1')
expect(EXTRACTOR_VERSION).toBe('2')
})
})

Expand Down
22 changes: 22 additions & 0 deletions src/server/ingest/bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,28 @@ describe('bundleSchema', () => {
expect(findDanglingRefs(schema)).toEqual([])
})

it("keeps an inline schema's own $defs beside the component closure (Mistral speech stream)", () => {
const { schema, warnings } = bundleSchema(
{
type: 'object',
properties: {
event: { $ref: '#/$defs/EventTypes' },
data: { $ref: '#/components/schemas/Delta' },
},
$defs: { EventTypes: { type: 'string', enum: ['delta'] } },
},
{ Delta: { type: 'object' } },
null,
'test',
)
expect(warnings).toEqual([])
expect(Object.keys(schema.$defs as object).sort()).toEqual([
'Delta',
'EventTypes',
])
expect(findDanglingRefs(schema)).toEqual([])
})

it('records warnings for dangling refs instead of throwing', () => {
const { schema, warnings } = bundleSchema(
{
Expand Down
26 changes: 20 additions & 6 deletions src/server/ingest/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import type { OpenApiDocument } from '#/server/providers/types.ts'
* provider's classify/fetchSpec could alter derived schemas — it tells
* verifiers which extractor produced a stored hash.
*/
export const EXTRACTOR_VERSION = '1'
export const EXTRACTOR_VERSION = '2'

export type JsonValue =
| string
Expand Down Expand Up @@ -53,9 +53,8 @@ export interface ExtractedEndpointSchemas {
streamOutput?: Record<string, JsonValue>
/**
* Success-response media types to publish. JSON and `text/event-stream`
* are both kept. The generation document puts them on two operations,
* because `@hey-api/openapi-ts` keeps only `application/json` when both
* are listed on one operation. Audio and other non-JSON types stay.
* are both kept, on one operation in the generation document. Audio and
* other non-JSON types stay.
*/
outputContentTypes?: Array<string>
warnings: Array<string>
Expand Down Expand Up @@ -165,11 +164,26 @@ export function bundleSchema(
}
if (closure.size === 0) return { schema, warnings }

const defs: Record<string, JsonValue> = {}
// An inline schema can carry its own `$defs` (Mistral's speech stream):
// keep them beside the component closure.
const defs: Record<string, JsonValue> = isObject(schema.$defs)
? { ...schema.$defs }
: {}
for (const refName of [...closure].sort()) {
const target = resolveSchema(refName)
if (target === undefined) continue
defs[refName] = rewriteRefs(target, rootName)
const rewritten = rewriteRefs(target, rootName)
const local = defs[refName]
if (
local !== undefined &&
JSON.stringify(local) !== JSON.stringify(rewritten)
) {
warnings.push(
`${context}: local $defs '${refName}' differs from the component of the same name; kept the local one`,
)
continue
}
defs[refName] = rewritten
}
return { schema: { ...schema, $defs: defs }, warnings }
}
Expand Down
103 changes: 90 additions & 13 deletions src/server/provider-openapi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import {
operationsFor,
pathParamsFromPath,
requestMedia,
hoistSchemaDefs,
responseMedia,
streamOperationPath,
unresolvedRefs,
} from './provider-openapi.ts'
import { falProvider } from './providers/fal.ts'
import { anthropicProvider } from './providers/anthropic.ts'
Expand Down Expand Up @@ -128,7 +129,7 @@ describe('generation media types', () => {
})
})

it('splits a JSON and SSE success body into two posts on the real URL', () => {
it('keeps JSON and SSE on one post, as the official OpenAI spec does', () => {
const chunk = { type: 'object', properties: { delta: { type: 'string' } } }
const ops = operationsFor(
'openai',
Expand All @@ -155,23 +156,14 @@ describe('generation media types', () => {
)
expect(ops.map((op) => [op.method, op.path])).toEqual([
['post', '/chat/completions'],
['post', streamOperationPath('/chat/completions')],
])
const json = ops[0]?.operation.responses as {
const responses = ops[0]?.operation.responses as {
'200': { content: Record<string, { schema: unknown }> }
}
const stream = ops[1]?.operation.responses as {
'200': { content: Record<string, { schema: unknown }> }
}
expect(json['200'].content).toEqual({
expect(responses['200'].content).toEqual({
'application/json': { schema },
})
expect(stream['200'].content).toEqual({
'text/event-stream': { schema: chunk },
})
expect(ops[1]?.operation.operationId).not.toBe(
ops[0]?.operation.operationId,
)
})

it('describes audio responses as binary and keeps a stream schema beside them', () => {
Expand Down Expand Up @@ -248,3 +240,88 @@ describe('connect profiles', () => {
expect(resolveConnect(getProvider('voyage'))).toBeNull()
})
})

describe('hoistSchemaDefs', () => {
const body = (schema: unknown) => ({
post: {
requestBody: { content: { 'application/json': { schema } } },
responses: { '200': { content: { 'application/json': { schema } } } },
},
})

it('moves $defs into components, dedupes, and renames a clash by content', () => {
const a = {
type: 'object',
properties: {
x: { $ref: '#/$defs/Shared' },
y: { $ref: '#/$defs/Item' },
},
$defs: {
Shared: { type: 'string' },
Item: { type: 'object', properties: { s: { $ref: '#/$defs/Shared' } } },
},
}
const b = {
type: 'object',
properties: {
x: { $ref: '#/$defs/Shared' },
y: { $ref: '#/$defs/Item' },
},
$defs: { Shared: { type: 'string' }, Item: { type: 'integer' } },
discriminator: {
propertyName: 'kind',
mapping: { item: '#/components/schemas/Item' },
},
}
const paths: Record<string, Record<string, unknown>> = {
'/a': body(a),
'/b': body(b),
}
const schemas = hoistSchemaDefs(Object.values(paths), {})
const document = { paths, components: { schemas } }
expect(unresolvedRefs(document)).toEqual([])
expect(JSON.stringify(document)).not.toContain('$defs')
const names = Object.keys(schemas).sort()
expect(names).toHaveLength(3)
expect(names).toContain('Shared')
expect(names).toContain('Item')
expect(schemas.Item).toEqual({
type: 'object',
properties: { s: { $ref: '#/components/schemas/Shared' } },
})
const renamed = names.find((name) => name.startsWith('Item_'))
expect(renamed && schemas[renamed]).toEqual({ type: 'integer' })
const bBody = (
paths['/b']!.post as {
requestBody: {
content: Record<
string,
{ schema: { discriminator: { mapping: Record<string, string> } } }
>
}
}
).requestBody.content['application/json']?.schema
expect(bBody?.discriminator.mapping.item).toBe(
`#/components/schemas/${renamed ?? ''}`,
)
})

it('turns a root self-reference into a component', () => {
const tree = {
type: 'object',
properties: { children: { type: 'array', items: { $ref: '#' } } },
}
const paths: Record<string, Record<string, unknown>> = { '/t': body(tree) }
const schemas = hoistSchemaDefs(Object.values(paths), { named: tree })
expect(unresolvedRefs({ paths, components: { schemas } })).toEqual([])
expect(schemas.named).toEqual({
type: 'object',
properties: {
children: {
type: 'array',
items: { $ref: '#/components/schemas/named' },
},
},
})
})
})
Loading
Loading