Ai.OpenaiModel
Examples below assume this module is imported with an
imports:entry under aliasAiOpenai(andaiasAi). Kind references (AiOpenai.OpenaiModel,Ai.Text,Ai.TextStream, …) follow those aliases — if you import either module under a different name, substitute accordingly.
OpenAI-compatible provider for the Ai.Model abstract. A Telo.Provider — a configured model client referenced by Ai.Text / Ai.TextStream / Ai.Agent, never invoked directly as a target or step. Implements the full AiModelInstance runtime contract (invoke + stream) by calling the OpenAI POST /chat/completions HTTP API directly — no vendor SDK, no zod, nothing beyond @telorun/ai. Because the wire protocol is the de-facto standard, the same controller serves OpenAI and every OpenAI-compatible endpoint (Azure OpenAI, Ollama, vLLM, Groq, Together, OpenRouter, …) via baseUrl.
kind: Telo.Application
metadata: { name: summarizer, version: 1.0.0 }
imports:
AiOpenai: std/ai-openai@0.8.0
---
kind: AiOpenai.OpenaiModel
metadata: { name: Gpt4o }
model: gpt-4o
apiKey: "${{ secrets.OPENAI_API_KEY }}"
options:
temperature: 0.2
maxTokens: 800
The resource is then referenced from any Ai.Model consumer:
kind: Ai.Text
metadata: { name: Summarizer }
model: !ref Gpt4o
Schema
| Field | Type | Required | Description |
|---|---|---|---|
model | string | yes | Model identifier (e.g. gpt-4o, gpt-4o-mini, o1-preview). |
apiKey | string | yes | API key, sent as Authorization: Bearer …. Use a secret reference: "${{ secrets.OPENAI_API_KEY }}". Compile-evaluated, never appears at runtime CEL. |
baseUrl | string | no | Override the API base URL (default https://api.openai.com/v1). /chat/completions is appended. Useful for Azure OpenAI and OpenAI-compatible gateways. |
options | object | no | camelCase OpenAI request params, normalized to snake_case and merged into the request body. Merged beneath the caller's options. |
apiKey and baseUrl are x-telo-eval: compile, so they resolve at load time from secrets.* / env.*. Hardcoded keys in manifests work but are strongly discouraged.
Invoke / stream
Both methods call POST {baseUrl}/chat/completions directly:
invoke({messages, options, tools?})→ buffered request →{text, usage, finishReason, toolCalls?}.stream({messages, options})→stream: truerequest, parsed from the SSEdata:frames →AsyncIterable<StreamPart>.stream_options.include_usageis set so the terminalfinishpart carries token usage.
OpenAI finish_reason values map into the Ai contract:
OpenAI finish_reason | Ai.Model finishReason |
|---|---|
stop | stop |
length | length |
tool_calls | tool-calls |
function_call | tool-calls |
content_filter | content-filter |
| anything else / absent | other |
tool-calls is preserved (not flattened to other): Ai.Agent drives the tool-use loop on it — when the model requests tools, the returned toolCalls are executed and replayed. Ai.Text / Ai.TextStream never pass tools, so they never see this reason.
Tool calls are advertised as OpenAI tools: [{ type: "function", function: { name, description, parameters } }] (no execute — the agent runs tools itself). The model's tool_calls come back with arguments as a JSON string; the provider parses each into the ToolCall.arguments object. Malformed argument JSON surfaces as an error rather than a silent empty object.
Multimodal content
Message content may be a string or content parts (text + image). The provider translates them into OpenAI's wire shapes:
- A user message with parts becomes an OpenAI content array — text parts →
{ type: "text", text }, image parts →{ type: "image_url", image_url: { url } }, whereurlis adata:<mediaType>;base64,…URL built from the part's bytes (or its base64 string). System messages can't carry images, so any parts are flattened to their text. - A tool message can't carry images in OpenAI chat completions. When a tool answered with an image, the provider emits the
toolmessage with a short text placeholder (its text parts, if any) and then a synthetic follow-upusermessage holding the image parts — the documented OpenAI pattern. The Ai contract stays provider-neutral; only this translation differs (an image-native provider would map the same parts into its own tool-result blocks).
Options
options use camelCase (the Telo manifest convention). Each top-level key is normalized to the OpenAI snake_case wire parameter before the request is sent (maxTokens → max_tokens, topP → top_p):
temperature: numbermaxTokens: number(ormaxCompletionTokensfor reasoning models likeo1/o3)topP: numberfrequencyPenalty: numberpresencePenalty: numberseed: numberstop: string | string[]
Any other field OpenAI (or your compatible gateway) accepts flows through — responseFormat, logitBias, provider-specific extensions, etc. Only top-level keys are converted; nested object values (a responseFormat JSON schema, a logitBias token map) keep their own casing. Keys already written in snake_case are passed through unchanged.
Snapshot redaction
apiKey is omitted from the CEL-visible snapshot. Other fields (model id, baseUrl, options) remain visible — useful for telemetry and debugging:
inputs:
modelName: "${{ resources.Gpt4o.model }}" # works
apiKey: "${{ resources.Gpt4o.apiKey }}" # always null
Errors
A non-2xx response from invoke throws an actionable error built from the provider's { error: { message } } body (falling back to the raw response text), prefixed with the HTTP status. No retry, no swallowing. Wrap in try / catch inside Run.Sequence if you want to handle them.
For streaming calls, a non-OK response or a mid-stream failure is translated into a StreamPart of shape { type: "error", error: { message, code?, data? } } and yielded as the terminator — generic encoders (Ndjson.Encoder, Sse.Encoder) frame this as an in-band error record without a bespoke serialization step. The native Error instance never reaches the wire (it isn't JSON-serializable). Already-emitted text-delta parts are preserved; consumers see partial output plus a structured error record.
Azure OpenAI / OpenAI-compatible gateways
Set baseUrl to override the endpoint (the controller appends /chat/completions):
kind: AiOpenai.OpenaiModel
metadata: { name: LocalLlama }
model: llama3.1
apiKey: "${{ secrets.OPENAI_API_KEY }}" # ignored by servers that don't require auth
baseUrl: "http://localhost:11434/v1" # Ollama, vLLM, LM Studio, …