Skip to content
Open
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
117 changes: 62 additions & 55 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,65 +31,72 @@ We'll appreciate you including tests to your code if it is needed and possible.

## Implementing an API

> Using Code generation in this project is a bit nuanced. For small additions and edits it is OK to write everything manually. For larger changes consider asking a maintaner to implement the wanted piece. Read on if you'd like to implement larger parts and you're ok to dig into the nits-and-bits of the approach.
There are two ways to add or change an API in this project: write the required
methods and types by hand, or use the OpenAPI specification to generate the
supporting types. Code generation is especially useful for a large API with many
nested schemas, but it is not a requirement for every change.

Currently there are 2 ways we can implement or edit an API:
1. By implementing all the needed methods and types ourselves manually.
2. By generating Types and using them in the code we write.

Before attempting the Code Generation, see if the spec contains what you need:
1. When a need to implement a new API arises - check if it's implemented in [openai-openapi](https://github.com/openai/openai-openapi)*. For an easier exploration use [Swagger Editor](https://editor.swagger.io) to browse `OpenAPI Specification` file and see if the needed Types are described there (yes, it may not be there, it may take time for OpenAI to update the spec).
2. If the Paths and Types required for implementation are not in the spec file - [implement everything by hand](#implementing-by-hand).
3. If the spec contains what's needed - proceed to [generate the code](#implementing-using-code-generation).

> * [openai-openapi](https://github.com/openai/openai-openapi) repo may not contain the latest spec. Try checking `.stats.yml` of an official OpenAI SDK like [openai-python](https://github.com/openai/openai-python) or [openai-node](https://github.com/openai/openai-node), it may contain a more up-to-date spec.

#### Pros and cons of using Code Generation

##### + Implementation speed

It's super fast to implement a large API like Responses API using Code Generation. Even though top-level types of the API are hand-crafted, they use so many smaller generated types, it would take very long to implement myself.

##### + The generated types are structured

OpenAPI spec is provided by OpenAPI - it has all the insights on the DB scheme used by OpenAI. It may contain insights about the schemes future which we, as an outside developers, may not be able to see.

For example, a generated `EasyInputMessage` type is used in both `Responses` and `Evals` APIs.

##### - OpenAI lags behind

OpenAI doesn't update the OpenAPI spec too frequently, which may lead to a nede to extract a generated type.

##### - Generated code is not "beautiful"

There are drawbacks in the looks of the generated code, like enums have upper case etc., which may lead to a desire to extract a type to make the code look more user friendly.
Start by checking the repository's [`openapi.yaml`](openapi.yaml). A tool such as
[Swagger Editor](https://editor.swagger.io) can make the document easier to
explore. If it describes the paths and schemas you need, use the generation
workflow below. If it does not, implement the missing API by hand rather than
editing the generated file. See [openai-openapi](https://github.com/openai/openai-openapi) for the latest spec.

### Implementing by hand

Implementing by hand is straightforward. See `ChatQuery`, `ChatResult` and their dependant types for an example.
See `ChatQuery`, `ChatResult`, and their dependent types for examples. Handwritten
top-level types are often preferable because they let us present a focused Swift
API with carefully written documentation. Generated types can still supply the
larger collection of nested schemas behind that API.

### Implementing using Code Generation

#### What we generate and what we don't
1. Currently the only thing we generate is Types. I.e. we don't generate API methods. So for example, during implementing `Responses API` I didn't generate `ResponsesEndpoint` file and its `createResponse` and `createResponseStreaming` methods.
2. This is optional, but I haven't used generated types at the top level. In the `Reponses API` example, the 3 main (top level) types are `CreateModelResponseQuery`, `ResponseObject` and `ResponseStreamEvent`. They are all written by hand to make the API and documentation comments look good and full. What's generated are the types they depend on. `CreateModelResponseQuery` depends on `Schemas.Includable`, `ResponseObject` depends on `Schemas.ResponseError` and so on.

#### How to generate Types
1. Use [Swift OpenAPI Generator](https://github.com/apple/swift-openapi-generator) to generate the code using OpenAI's OpenAPI spec file. See comments at the top of `Components.swift` on the configuration that was used for the last generation, append your configs to that so that a new generation doesn't miss something out. I used CLI to generate the files outside of the project folder and then just copied the generated code into the project.
2. When you've got `Types.swift` generated - copy only the `Components` enum into `/Sources/OpenAI/Public/Schemas/Generated/Components.swift`. Replace the existing enum. See comments at the top of `Components.swift` file (section "Manual operations after Types.swift file was generated") for any operations that have to be done manually after the generated code is in place. If you need to edit anything manually in `Components` enum - note it in the mentioned section.
3. Use the types from `Components.Schemas` as you need.

#### What to do if a Generated Type is incomplete
Let's say you've [come across an issue](https://github.com/MacPaw/OpenAI/issues/347) when a generated Types has missing fields, and the latest OpenAI-OpenAPI spec still doesn't have it implemented. Consider the constraints:

1. We can't wait for them to update the spec, we don't even know if they are going to.
2. We don't want to edit `Components.swift`, as we want to keep it easily replacable with new generations.

So, this is a case where we would [extract a type](#extracting-a-generated-type).

#### Extracting a generated Type
If for any reason a generated Type doesn't meet your needs - you can always just extract it and edit, or just write a type manually.

If a type is a direct edit or extension of a type - put it into `Sources/OpenAI/Public/Schemas/Edited/`. It is preferred to keep the name and the structure of a file so that when OpenAPI spec is updated we could removed the edited file and use a generated code.

If the new type is not part of OpenAI provided spec - just name it the way you think right and put it into another folder. See `Sources/OpenAI/Public/Schemas/Facade/` for examples, where new types are introduced facading the generated code to make the Responses API in Swift more friendly.
Generation produces types only. API methods, endpoints, and the public top-level
models that wrap those types remain handwritten. For example, the Responses API
uses handwritten `CreateModelResponseQuery`, `ResponseObject`, and
`ResponseStreamEvent` types, while their supporting schemas come from
`Components.Schemas`.

The workflow is automated by [`make generate`](Makefile). The Makefile is the
source of truth for prerequisites and the exact commands; in particular, it
documents the required sibling checkout of the project's Swift OpenAPI Generator
fork and the generator changes that fork must contain.

Before running generation, update
[`openapi-generator-config.yaml`](openapi-generator-config.yaml) with every path
and standalone schema the change needs. Then run:

```sh
make generate
```

The command:

1. prepares a generator-compatible copy of `openapi.yaml` under `.build/`;
2. applies the narrowly scoped workarounds documented in [`Scripts/`](Scripts/);
3. runs Swift OpenAPI Generator with the repository's configuration; and
4. extracts the generated `Components` enum into
`Sources/OpenAI/Public/Schemas/Generated/Components.swift` while preserving
that file's imports and header.

The source specification is not modified during this process. The final
preparation diff is written to `.build/openapi-generator/openapi.patch`; review
it along with the generated Swift diff. Build the package and run the relevant
tests before submitting the change.

Do not edit `Components.swift` by hand. It is deliberately replaceable output,
so a later generation would discard such edits.

#### When a generated type is not suitable

The specification can lag behind the live API, and generated types are not
always the best public Swift interface. If a generated type is incomplete or
awkward, extract or recreate it as a handwritten type instead of patching the
generated file.

Put a direct replacement or adaptation in
`Sources/OpenAI/Public/Schemas/Edited/`. Keep its name and structure close to the
generated schema where practical so it can be replaced again when the upstream
specification catches up. Types that are not part of the specification belong in
an appropriate handwritten area; `Sources/OpenAI/Public/Schemas/Facade/`
contains examples that provide a friendlier API over generated schemas.
8 changes: 8 additions & 0 deletions Demo/DemoChat/Sources/ResponsesStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,15 @@ public final class ResponsesStore: ObservableObject {
)
)
case .functionToolCall(let functionToolCall):
guard functionToolCall.name == weatherFunctionTool.name else {
throw StoreError.unknownFunctionCalled(name: functionToolCall.name)
}

lastFinishedFunctionToolCall = functionToolCall
case .webSearchToolCall:
// The non-streaming response includes the completed web-search call
// alongside the assistant message. There is no incremental state to update.
webSearchInProgress = false
default:
throw StoreError.unhandledOutputItem(output)
}
Expand Down
61 changes: 59 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,16 +1,73 @@
# Requires a local fork of swift-openapi-generator to be checked out as a
# sibling directory named `swift-openapi-generator` (i.e. ../swift-openapi-generator).
# See https://github.com/apple/swift-openapi-generator for the upstream repo.
#
# The fork must include these changes, which are not available in the official
# generator at the time of writing:
#
# - Handle OpenAPI 3.1 nullable schemas expressed as
# `anyOf: [<schema>, { type: null }]`. The generator must ignore the null
# branch while assigning the Swift type, then make the resulting type
# optional. Without this change, nullable properties are unsupported or are
# generated as an anyOf wrapper instead of the expected optional Swift type.
#
# - When a oneOf discriminator has no explicit mapping, also match the string
# enum values declared by the referenced schemas' discriminator property.
# The OpenAI spec uses runtime values such as `input_text`, which do not match
# schema names such as `InputTextContent`; without this change, decoding a
# valid response throws unknownOneOfDiscriminator. See
# https://github.com/openai/openai-openapi/issues/542 for the spec issue.
#
# - When inferred discriminator values collide across multiple oneOf schemas,
# fall back to structural decoding for the colliding value instead of
# generating duplicate switch patterns. The OpenAI spec uses `message` for
# both InputMessage and OutputMessage, so the discriminator alone cannot
# select the correct schema.
#
# Expected diagnostic:
# The generator warns that `InputMessageResource/value2` requires `type` even
# though that property is declared by the sibling `InputMessage` schema in the
# same `allOf`. JSON Schema applies both members to the same object, while the
# generator validates each generated allOf payload independently. The property
# remains available through the generated `InputMessage` payload, so this
# warning is intentionally ignored.
GENERATOR_DIR := $(shell dirname $(abspath $(lastword $(MAKEFILE_LIST))))/../swift-openapi-generator
PROJECT_DIR := $(shell dirname $(abspath $(lastword $(MAKEFILE_LIST))))
TYPES_SWIFT := $(GENERATOR_DIR)/Types.swift
COMPONENTS_SWIFT := $(PROJECT_DIR)/Sources/OpenAI/Public/Schemas/Generated/Components.swift
PREPARED_OPENAPI := $(PROJECT_DIR)/.build/openapi-generator/openapi.yaml
OPENAPI_DIFF := $(PROJECT_DIR)/.build/openapi-generator/openapi.patch

.PHONY: generate
generate:
# Prepare a working copy with conditional, documented upstream-spec fixes.
# See the scripts called by prepare_openapi.py for each error and its fix.
python3 -B "$(PROJECT_DIR)/Scripts/prepare_openapi.py" \
"$(PROJECT_DIR)/openapi.yaml" \
"$(PREPARED_OPENAPI)"
# The LocalShellToolCallOutput, MCP approval response, and response audio
# event removals are required-list entries without matching schema properties.
# They otherwise produce swift-openapi-generator warnings that the names are
# likely typos and will be skipped.
#
# WebSearchActionSearch/query is different: the property is declared, but the
# live API can omit the deprecated singular query and return queries instead.
# It must be optional so valid web-search response items decode successfully.
python3 -B "$(PROJECT_DIR)/Scripts/remove_required_properties.py" \
"$(PREPARED_OPENAPI)" \
"$(PREPARED_OPENAPI)" \
--remove-required "LocalShellToolCallOutput" "call_id" \
--remove-required "MCPApprovalResponse" "request_id" \
--remove-required "MCPApprovalResponseResource" "request_id" \
--remove-required "ResponseAudioDoneEvent" "response_id" \
--remove-required "ResponseAudioTranscriptDeltaEvent" "response_id" \
--remove-required "ResponseAudioTranscriptDoneEvent" "response_id" \
--remove-required "WebSearchActionSearch" "query" \
--diff-source "$(PROJECT_DIR)/openapi.yaml" \
--diff-output "$(OPENAPI_DIFF)"
cd "$(GENERATOR_DIR)" && swift run swift-openapi-generator generate \
--config "$(PROJECT_DIR)/openapi-generator-config.yaml" \
"$(PROJECT_DIR)/openapi.with-code-samples.yml"
python3 "$(PROJECT_DIR)/Scripts/extract_components.py" \
"$(PREPARED_OPENAPI)"
python3 -B "$(PROJECT_DIR)/Scripts/extract_components.py" \
"$(TYPES_SWIFT)" \
"$(COMPONENTS_SWIFT)"
121 changes: 121 additions & 0 deletions Scripts/fix_boolean_exclusive_minimum.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Convert OpenAPI 3.0 boolean exclusiveMinimum syntax to OpenAPI 3.1.

Symptom:
"Expected `exclusiveMinimum` value ... to be parsable as Double but it was
not."

Cause:
The document declares OpenAPI 3.1 but contains the OpenAPI 3.0 form:
`minimum: X` together with `exclusiveMinimum: true`. In OpenAPI 3.1,
`exclusiveMinimum` itself must contain the numeric boundary.

Fix:
Convert `minimum: X` plus `exclusiveMinimum: true` to
`exclusiveMinimum: X`. A false boolean is removed while its inclusive
`minimum` remains unchanged.

Removal condition:
When no boolean exclusiveMinimum values remain upstream, this script is a
no-op. It can be removed from prepare_openapi.py after generation is
verified with the new spec.
"""

from __future__ import annotations

import argparse
import re
from pathlib import Path


BOOLEAN_EXCLUSIVE_MINIMUM_RE = re.compile(
r"^(?P<indent>\s*)exclusiveMinimum:\s*(?P<value>true|false)"
r"[ \t]*(?:#.*)?(?P<newline>\r?\n)?$"
)
NUMERIC_MINIMUM_RE = re.compile(
r"^(?P<indent>\s*)minimum:\s*"
r"(?P<value>[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?)"
r"[ \t]*(?:#.*)?(?:\r?\n)?$"
)


def fix_boolean_exclusive_minimum(document: str) -> tuple[str, int]:
"""Return an OpenAPI 3.1-compatible document and conversion count."""

lines = document.splitlines(keepends=True)
replacement_count = 0

for index, line in enumerate(lines):
exclusive_match = BOOLEAN_EXCLUSIVE_MINIMUM_RE.match(line)
if exclusive_match is None:
continue

replacement_count += 1
if exclusive_match.group("value") == "false":
lines[index] = ""
continue

indent = exclusive_match.group("indent")
minimum_index = None
minimum_value = None

# Find a preceding `minimum` sibling in this schema. Other siblings,
# such as `maximum`, may appear between the two constraints.
for candidate_index in range(index - 1, -1, -1):
candidate = lines[candidate_index]
if not candidate.strip() or candidate.lstrip().startswith("#"):
continue

candidate_indent = candidate[: len(candidate) - len(candidate.lstrip())]
if len(candidate_indent) < len(indent):
break

minimum_match = NUMERIC_MINIMUM_RE.match(candidate)
if minimum_match and minimum_match.group("indent") == indent:
minimum_index = candidate_index
minimum_value = minimum_match.group("value")
break

if minimum_index is None or minimum_value is None:
line_number = index + 1
raise ValueError(
"Cannot convert `exclusiveMinimum: true` on line "
f"{line_number}: no numeric sibling `minimum` was found."
)

newline = exclusive_match.group("newline") or ""
lines[minimum_index] = ""
lines[index] = f"{indent}exclusiveMinimum: {minimum_value}{newline}"

return "".join(lines), replacement_count


def report_result(replacement_count: int) -> None:
if replacement_count:
print(
"Boolean exclusiveMinimum workaround applied: "
f"{replacement_count} replacement(s)."
)
else:
print(
"Boolean exclusiveMinimum workaround not needed. The upstream spec "
"may be fixed; consider removing this workaround after generation "
"succeeds."
)


def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("input", type=Path, help="Input OpenAPI document")
parser.add_argument("output", type=Path, help="Fixed OpenAPI document")
args = parser.parse_args()

document = args.input.read_text(encoding="utf-8")
fixed_document, replacement_count = fix_boolean_exclusive_minimum(document)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(fixed_document, encoding="utf-8")
report_result(replacement_count)


if __name__ == "__main__":
main()
Loading